Merge branch 'back-from-fork'

This commit is contained in:
Carl Chenet 2018-10-15 21:20:14 +02:00
commit 5849ba4a09
71 changed files with 1659 additions and 547 deletions

3
.gitignore vendored
View file

@ -18,11 +18,14 @@
vendor/bundle vendor/bundle
public/assets public/assets
upstream-patches
# templates to be created per-site # templates to be created per-site
app/views/home/privacy.* app/views/home/privacy.*
app/views/home/about.* app/views/home/about.*
app/views/home/chat.* app/views/home/chat.*
app/views/home/404.*
app/views/layouts/_footer.*
app/assets/stylesheets/local/* app/assets/stylesheets/local/*
public/favicon.ico public/favicon.ico
public/apple-touch-icon* public/apple-touch-icon*

10
Gemfile
View file

@ -1,6 +1,6 @@
source "https://rubygems.org" source "https://rubygems.org"
gem "rails", "4.1.12" gem "rails", "4.2.8"
gem "unicorn" gem "unicorn"
@ -21,10 +21,14 @@ gem "dynamic_form"
gem "exception_notification" gem "exception_notification"
gem "bcrypt", "~> 3.1.2" gem "bcrypt", "~> 3.1.2"
gem "rotp"
gem "rqrcode"
gem "nokogiri", "= 1.6.1" gem "nokogiri", "= 1.6.1"
gem "htmlentities" gem "htmlentities"
gem "rdiscount" gem "commonmarker", "~> 0.14"
gem "activerecord-typedstore"
# for twitter-posting bot # for twitter-posting bot
gem "oauth" gem "oauth"
@ -33,7 +37,7 @@ gem "oauth"
gem "mail" gem "mail"
group :test, :development do group :test, :development do
gem "rspec-rails", "~> 2.6" gem "rspec-rails", "~> 3.5", ">= 3.5.2"
gem "machinist" gem "machinist"
gem "sqlite3" gem "sqlite3"
gem "faker" gem "faker"

View file

@ -116,7 +116,12 @@ var _Lobsters = Class.extend({
var li = $(voterEl).closest(".story, .comment"); var li = $(voterEl).closest(".story, .comment");
var scoreDiv = li.find("div.score").get(0); var scoreDiv = li.find("div.score").get(0);
var score = parseInt(scoreDiv.innerHTML); var score = 0;
var showScore = true;
if (scoreDiv.innerHTML == "-")
showScore = false;
else
score = parseInt(scoreDiv.innerHTML);
var action = ""; var action = "";
if (li.hasClass("upvoted") && point > 0) { if (li.hasClass("upvoted") && point > 0) {
@ -150,7 +155,8 @@ var _Lobsters = Class.extend({
action = "downvote"; action = "downvote";
} }
scoreDiv.innerHTML = score; if (showScore)
scoreDiv.innerHTML = score;
if (action == "upvote" || action == "unvote") { if (action == "upvote" || action == "unvote") {
li.find(".reason").html(""); li.find(".reason").html("");
@ -174,12 +180,7 @@ var _Lobsters = Class.extend({
if ($(form).find('#parent_comment_short_id').length) { if ($(form).find('#parent_comment_short_id').length) {
$(form).closest('.comment').replaceWith($.parseHTML(data)); $(form).closest('.comment').replaceWith($.parseHTML(data));
} else { } else {
if ($(form).attr("id").match(/^edit_comment_.+$/)) { $(form).parent(".comment").replaceWith($.parseHTML(data));
$(form).parent(".comment").replaceWith($.parseHTML(data));
} else {
$(form).closest('.comment').after($.parseHTML(data));
$(form).find('textarea').val('');
}
} }
}); });
}, },
@ -338,6 +339,16 @@ $(document).ready(function() {
return Lobsters.moderateStory(this); return Lobsters.moderateStory(this);
}), }),
$(".toggle_dragons").click(function(a) {
var c = $(".dragon_threads").first();
if (c) {
if (c.hasClass("hidden"))
c.removeClass("hidden");
else
c.addClass("hidden");
}
}),
$(document).on("click", "a.comment_replier", function() { $(document).on("click", "a.comment_replier", function() {
if (!Lobsters.curUser) { if (!Lobsters.curUser) {
Lobsters.bounceToLogin(); Lobsters.bounceToLogin();
@ -408,6 +419,34 @@ $(document).ready(function() {
}); });
} }
}); });
$(document).on("click", "a.comment_undeletor", function() {
if (confirm("Êtes-vous sûr de vouloir restaurer ce commentaire ?")) {
var li = $(this).closest(".comment");
$.post("/comments/" + $(li).attr("data-shortid") + "/undelete",
function(d) {
$(li).replaceWith(d);
});
}
});
$(document).on("click", "a.comment_dragon", function() {
if (confirm("Êtes-vous sûr de vouloir marquer ce fil de discussion ?")) {
var li = $(this).closest(".comment");
$.post("/comments/" + $(li).attr("data-shortid") + "/dragon",
function(d) {
window.location.reload();
});
}
});
$(document).on("click", "a.comment_undragon", function() {
if (confirm("Êtes-vous sûr de vouloir supprimer la marque de ce fil de discussion ?")) {
var li = $(this).closest(".comment");
$.post("/comments/" + $(li).attr("data-shortid") + "/undragon",
function(d) {
window.location.reload();
});
}
});
$(document).on("click", "a.comment_moderator", function() { $(document).on("click", "a.comment_moderator", function() {
var reason = prompt("Raison de la modération :"); var reason = prompt("Raison de la modération :");
@ -421,16 +460,6 @@ $(document).ready(function() {
}); });
}); });
$(document).on("click", "a.comment_undeletor", function() {
if (confirm("Êtes-vous sûr de vouloir dé-supprimer ce commentaire ?")) {
var li = $(this).closest(".comment");
$.post("/comments/" + $(li).attr("data-shortid") + "/undelete",
function(d) {
$(li).replaceWith(d);
});
}
});
Lobsters.runSelect2(); Lobsters.runSelect2();
$(document).on("click", "div.markdown_help_toggler .markdown_help_label", $(document).on("click", "div.markdown_help_toggler .markdown_help_label",
@ -484,4 +513,11 @@ $(document).ready(function() {
$("#story_guidelines").toggle(); $("#story_guidelines").toggle();
return false; return false;
}); });
$('textarea#comment').keydown(function (e) {
if ((e.metaKey || e.ctrlKey) && e.keyCode == 13) {
$("button.comment-post").click();
}
});
}); });

View file

@ -21,6 +21,7 @@ body, textarea, input, button {
body { body {
background-color: #fefefe; background-color: #fefefe;
line-height: 1.45em;
} }
a { a {
@ -47,11 +48,11 @@ div.clear {
a.tag { a.tag {
background-color: #fffcd7; background-color: #fffcd7;
border: 1px solid #d5d458; border: 1px solid #d5d458;
border-radius: 10px; border-radius: 5px;
color: #555; color: #555;
font-size: 8pt; font-size: 8pt;
margin-left: 0.25em; margin-left: 0.25em;
padding: 0px 0.5em 1px 0.5em; padding: 0px 0.4em 1px 0.4em;
text-decoration: none; text-decoration: none;
vertical-align: text-top; vertical-align: text-top;
} }
@ -117,17 +118,16 @@ select,
textarea { textarea {
color: #555; color: #555;
background-color: white; background-color: white;
line-height: 1.2em;
padding: 3px 5px; padding: 3px 5px;
} }
textarea { textarea {
line-height: 1.35em;
resize: vertical; resize: vertical;
} }
input[type="text"], input[type="text"],
input[type="search"], input[type="search"],
input[type="password"], input[type="password"],
input[type="email"], input[type="email"],
input[type="number"],
textarea { textarea {
border: 1px solid #ccc; border: 1px solid #ccc;
} }
@ -232,6 +232,12 @@ button:disabled {
color: gray; color: gray;
} }
.totp_code::-webkit-inner-spin-button,
.totp_code::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* outliners */ /* outliners */
@ -366,7 +372,6 @@ ol.comments {
margin-left: 20px; margin-left: 20px;
margin-bottom: 0em; margin-bottom: 0em;
padding-left: 6px; padding-left: 6px;
line-height: 1.35em;
} }
ol.comments1 { ol.comments1 {
margin-left: 0; margin-left: 0;
@ -449,8 +454,8 @@ li.story {
clear: both; clear: both;
} }
ol.stories li.story div.story_liner { ol.stories li.story div.story_liner {
padding-top: 0.3em; padding-top: 0.25em;
padding-bottom: 0.3em; padding-bottom: 0.25em;
} }
.comment { .comment {
clear: both; clear: both;
@ -476,13 +481,17 @@ li div.details {
opacity: 0.7; opacity: 0.7;
} }
.negative_3 { .negative_3 {
opacity: 0.5; opacity: 0.6;
} }
.negative_5 { .negative_5 {
opacity: 0.2; opacity: 0.5;
} }
.comment.highlighted { .comment.bad {
opacity: 0.7;
}
.comment:target {
background-color: #fffcd7; background-color: #fffcd7;
border-radius: 20px; border-radius: 20px;
} }
@ -493,7 +502,7 @@ li .link {
} }
li .link a { li .link a {
font-size: 11pt; font-size: 11.5pt;
text-decoration: none; text-decoration: none;
} }
@ -555,9 +564,15 @@ li .comment_folder_button:checked ~ ol.comments li {
display: none; display: none;
} }
/* try to force a highlighted comment to stay visible */
li .comment_folder_button:checked ~ div.comment div.comment_text,
li .comment_folder_button:checked ~ div.comment div.voters {
display: block;
}
li .byline { li .byline {
color: #888; color: #888;
font-size: 9pt; font-size: 9.5pt;
} }
li .byline > img.avatar { li .byline > img.avatar {
margin-bottom: -5px; margin-bottom: -5px;
@ -601,6 +616,9 @@ span.user_is_author, a.user_is_author,
li .byline a.user_is_author { li .byline a.user_is_author {
color: #6081bd; color: #6081bd;
} }
li .byline a.story_has_suggestions {
color: #6081bd;
}
li.story.hidden { li.story.hidden {
opacity: 0.25; opacity: 0.25;
@ -628,7 +646,6 @@ div.story_content {
ol.stories.list div.story_content { ol.stories.list div.story_content {
color: #777; color: #777;
max-height: 2.6em; max-height: 2.6em;
line-height: 1.3em;
margin: 0.25em 40px 0.25em 0; margin: 0.25em 40px 0.25em 0;
overflow: hidden; overflow: hidden;
text-overflow: clip; text-overflow: clip;
@ -674,11 +691,10 @@ div.page_link_buttons span {
div.story_text { div.story_text {
margin-bottom: 1.5em; margin-bottom: 1.5em;
max-width: 700px; max-width: 700px;
line-height: 1.4em; word-wrap: break-word;
} }
div.story_text p { div.story_text p {
margin: 0.75em 0; margin: 0.75em 0;
line-height: 1.4em;
} }
div.story_text img { div.story_text img {
max-width: 100%; max-width: 100%;
@ -692,7 +708,9 @@ a#story_text_expander {
} }
div.comment_text { div.comment_text {
font-size: 10.5pt;
max-width: 700px; max-width: 700px;
word-wrap: break-word;
} }
div.comment_text blockquote, div.comment_text blockquote,
@ -730,11 +748,21 @@ div.comment_text code {
line-height: 1.2em; line-height: 1.2em;
} }
div.comment_actions a { div.dragons {
color: #888; }
font-weight: bold; .dragon_text {
font-size: 8.5pt; background-color: #f8f8f8;
text-decoration: none; color: gray;
font-style: italic;
margin: 1em;
padding: 2em 0 2.5em 0;
text-align: center;
}
.dragon_text a {
color: gray;
}
.dragon_threads.hidden {
display: none;
} }
a.pagelink { a.pagelink {
@ -837,6 +865,11 @@ div.comment_form_container form {
max-width: 700px; max-width: 700px;
} }
div.comment_form_container textarea {
box-sizing: border-box;
width: 100%;
}
/* trees */ /* trees */
@ -844,7 +877,6 @@ div.comment_form_container form {
.tree ul { .tree ul {
margin: 0 0 0 0.5em; margin: 0 0 0 0.5em;
padding: 0; padding: 0;
line-height: 1.5em;
list-style: none; list-style: none;
position: relative; position: relative;
} }
@ -890,6 +922,12 @@ div.comment_form_container form {
height: auto; height: auto;
} }
li.noparent:before,
ul.noparent:before {
border-top: 0 !important;
border-left: 0 !important;
}
ul.user_tree { ul.user_tree {
color: #888; color: #888;
} }
@ -1201,7 +1239,6 @@ div#flasher {
div#flasher div.flash-error, div#flasher div.flash-error,
div#flasher div.flash-notice, div#flasher div.flash-notice,
div#flasher div.flash-success { div#flasher div.flash-success {
line-height: 1.5em;
display: inline-block; display: inline-block;
padding-top: 25px; padding-top: 25px;
margin-left: auto; margin-left: auto;

View file

@ -242,6 +242,5 @@
div#footer { div#footer {
text-align: center; text-align: center;
float: none; float: none;
padding-left: 10px;
} }
} }

View file

@ -8,6 +8,9 @@ class ApplicationController < ActionController::Base
TAG_FILTER_COOKIE = :tag_filters TAG_FILTER_COOKIE = :tag_filters
def authenticate_user def authenticate_user
# eagerly evaluate, in case this triggers an IpSpoofAttackError
request.remote_ip
if session[:u] && if session[:u] &&
(user = User.where(:session_token => session[:u].to_s).first) && (user = User.where(:session_token => session[:u].to_s).first) &&
user.is_active? user.is_active?
@ -50,13 +53,12 @@ class ApplicationController < ActionController::Base
Rails.logger.info " Traffic level: #{@traffic.to_i}" Rails.logger.info " Traffic level: #{@traffic.to_i}"
end end
if rand(2000000) == 1 intensity = (@traffic * 7).floor + 50.0
@traffic_color = sprintf("%02x%02x%02x", if (blue = (rand(2000000) == 1)) && @user
0, 0, [ 255, (@traffic * 7).floor + 50.0 ].min) Rails.logger.info " User #{@user.id} (#{@user.username}) saw blue logo"
else
@traffic_color = sprintf("%02x%02x%02x",
[ 255, (@traffic * 7).floor + 50.0 ].min, 0, 0)
end end
color = (blue ? "0000%02x" : "%02x0000")
@traffic_color = sprintf(color, intensity > 255 ? 255 : intensity)
true true
end end

View file

@ -128,6 +128,26 @@ class CommentsController < ApplicationController
:content_type => "text/html", :locals => { :comment => comment } :content_type => "text/html", :locals => { :comment => comment }
end end
def dragon
if !((comment = find_comment) && @user.is_moderator?)
return render :text => "can't find comment", :status => 400
end
comment.become_dragon_for_user(@user)
render :text => "ok"
end
def undragon
if !((comment = find_comment) && @user.is_moderator?)
return render :text => "can't find comment", :status => 400
end
comment.remove_dragon_for_user(@user)
render :text => "ok"
end
def update def update
if !((comment = find_comment) && comment.is_editable_by_user?(@user)) if !((comment = find_comment) && comment.is_editable_by_user?(@user))
return render :text => "can't find comment", :status => 400 return render :text => "can't find comment", :status => 400
@ -211,7 +231,7 @@ class CommentsController < ApplicationController
@comments = Comment.where( @comments = Comment.where(
:is_deleted => false, :is_moderated => false :is_deleted => false, :is_moderated => false
).order( ).order(
"created_at DESC" "id DESC"
).offset( ).offset(
(@page - 1) * COMMENTS_PER_PAGE (@page - 1) * COMMENTS_PER_PAGE
).limit( ).limit(
@ -266,7 +286,7 @@ class CommentsController < ApplicationController
comments = Comment.where( comments = Comment.where(
:thread_id => thread_ids :thread_id => thread_ids
).includes( ).includes(
:user, :story :user, :story, :hat, :votes => :user
).arrange_for_user( ).arrange_for_user(
@showing_user @showing_user
) )
@ -274,7 +294,7 @@ class CommentsController < ApplicationController
comments_by_thread_id = comments.group_by(&:thread_id) comments_by_thread_id = comments.group_by(&:thread_id)
@threads = comments_by_thread_id.values_at(*thread_ids).compact @threads = comments_by_thread_id.values_at(*thread_ids).compact
if @user && (@showing_user.id == @user.id) if @user
@votes = Vote.comment_votes_by_user_for_story_hash(@user.id, @votes = Vote.comment_votes_by_user_for_story_hash(@user.id,
comments.map(&:story_id).uniq) comments.map(&:story_id).uniq)

View file

@ -28,7 +28,7 @@ class HatsController < ApplicationController
@hat_request.comment = params[:hat_request][:comment] @hat_request.comment = params[:hat_request][:comment]
if @hat_request.save if @hat_request.save
flash[:success] = "Successfully submitted hat request." flash[:success] = t('.submittedhatrequest')
return redirect_to "/hats" return redirect_to "/hats"
end end
@ -47,7 +47,7 @@ class HatsController < ApplicationController
permit(:hat, :link)) permit(:hat, :link))
@hat_request.approve_by_user!(@user) @hat_request.approve_by_user!(@user)
flash[:success] = "Successfully approved hat request." flash[:success] = t('.approvedhatrequest')
return redirect_to "/hats/requests" return redirect_to "/hats/requests"
end end
@ -57,7 +57,7 @@ class HatsController < ApplicationController
@hat_request.reject_by_user_for_reason!(@user, @hat_request.reject_by_user_for_reason!(@user,
params[:hat_request][:rejection_comment]) params[:hat_request][:rejection_comment])
flash[:success] = "Successfully rejected hat request." flash[:success] = t('.rejectedhatrequest')
return redirect_to "/hats/requests" return redirect_to "/hats/requests"
end end

View file

@ -4,11 +4,24 @@ class HomeController < ApplicationController
before_filter { @page = page } before_filter { @page = page }
before_filter :require_logged_in_user, :only => [ :upvoted ] before_filter :require_logged_in_user, :only => [ :upvoted ]
def four_oh_four
begin
@title = "Resource Not Found"
render :action => "404", :status => 404
rescue ActionView::MissingTemplate
render :text => "<div class=\"box wide\">" <<
"<div class=\"legend\">404</div>" <<
"Resource not found" <<
"</div>", :layout => "application"
end
end
def about def about
begin begin
@title = I18n.t 'controllers.home_controller.abouttitle' @title = I18n.t 'controllers.home_controller.abouttitle'
render :action => "about" render :action => "about"
rescue rescue ActionView::MissingTemplate
render :text => I18n.t('controllers.home_controller.abouttext'), :layout => "application" render :text => I18n.t('controllers.home_controller.abouttext'), :layout => "application"
end end
end end
@ -17,8 +30,9 @@ class HomeController < ApplicationController
begin begin
@title = I18n.t 'controllers.home_controller.chattitle' @title = I18n.t 'controllers.home_controller.chattitle'
render :action => "chat" render :action => "chat"
rescue rescue ActionView::MissingTemplate
render :text => "<div class=\"box wide\">" << render :text => "<div class=\"box wide\">" <<
"<div class=\"legend\">Chat</div>" <<
"Keep it on-site" << "Keep it on-site" <<
"</div>", :layout => "application" "</div>", :layout => "application"
end end
@ -28,7 +42,7 @@ class HomeController < ApplicationController
begin begin
@title = I18n.t 'controllers.home_controller.privacytitle' @title = I18n.t 'controllers.home_controller.privacytitle'
render :action => "privacy" render :action => "privacy"
rescue rescue ActionView::MissingTemplate
render :text => I18n.t('controllers.home_controller.licensetext'), :layout => "application" render :text => I18n.t('controllers.home_controller.licensetext'), :layout => "application"
end end
end end
@ -239,7 +253,12 @@ private
else else
key = opts.merge(page: page).sort.map{|k,v| "#{k}=#{v.to_param}" key = opts.merge(page: page).sort.map{|k,v| "#{k}=#{v.to_param}"
}.join(" ") }.join(" ")
Rails.cache.fetch("stories #{key}", :expires_in => 45, &block) begin
Rails.cache.fetch("stories #{key}", :expires_in => 45, &block)
rescue Errno::ENOENT => e
Rails.logger.error "error fetching stories #{key}: #{e}"
yield
end
end end
end end
end end

View file

@ -1,3 +1,8 @@
class LoginBannedError < StandardError; end
class LoginDeletedError < StandardError; end
class LoginTOTPFailedError < StandardError; end
class LoginFailedError < StandardError; end
class LoginController < ApplicationController class LoginController < ApplicationController
before_filter :authenticate_user before_filter :authenticate_user
@ -22,53 +27,104 @@ class LoginController < ApplicationController
user = User.where(:username => params[:email]).first user = User.where(:username => params[:email]).first
end end
fail_reason = nil
begin begin
if !user if !user
raise "no user" raise LoginFailedError
end end
if !user.try(:authenticate, params[:password].to_s) if !user.authenticate(params[:password].to_s)
raise "authentication failed" # if the user has 2fa enabled and the password looks like it has a totp
# code attached, separate them
if user.has_2fa? &&
(m = params[:password].to_s.match(/\A(.+):(\d+)\z/)) &&
user.authenticate(m[1])
params[:password] = m[1]
params[:totp] = m[2]
else
raise LoginFailedError
end
end end
if user.is_banned? if user.is_banned?
raise "user is banned" raise LoginBannedError
end end
if !user.is_active? if !user.is_active?
user.undelete! raise LoginDeletedError
flash[:success] = "Your account has been reactivated and your " <<
"unmoderated comments have been undeleted."
end end
session[:u] = user.session_token
if !user.password_digest.to_s.match(/^\$2a\$#{BCrypt::Engine::DEFAULT_COST}\$/) if !user.password_digest.to_s.match(/^\$2a\$#{BCrypt::Engine::DEFAULT_COST}\$/)
user.password = user.password_confirmation = params[:password].to_s user.password = user.password_confirmation = params[:password].to_s
user.save! user.save!
end end
if (rd = session[:redirect_to]).present? if user.has_2fa?
session.delete(:redirect_to) if params[:totp].present?
return redirect_to rd if user.authenticate_totp(params[:totp])
elsif params[:referer].present? # ok, fall through
begin else
ru = URI.parse(params[:referer]) raise LoginTOTPFailedError
if ru.host == Rails.application.domain end
return redirect_to ru.to_s else
return respond_to do |format|
format.html {
session[:twofa_u] = user.session_token
redirect_to "/login/2fa"
}
format.json {
render :json => { :status => 0,
:error => "must supply totp parameter" }
}
end end
rescue => e
Rails.logger.error "error parsing referer: #{e}"
end end
end end
return redirect_to "/" return respond_to do |format|
rescue format.html {
session[:u] = user.session_token
if (rd = session[:redirect_to]).present?
session.delete(:redirect_to)
return redirect_to rd
elsif params[:referer].present?
begin
ru = URI.parse(params[:referer])
if ru.host == Rails.application.domain
return redirect_to ru.to_s
end
rescue => e
Rails.logger.error "error parsing referer: #{e}"
end
end
redirect_to "/"
}
format.json {
render :json => { :status => 1, :username => user.username }
}
end
rescue LoginBannedError
fail_reason = I18n.t 'controllers.login_controller.bannedaccount'
rescue LoginDeletedError
fail_reason = I18n.t 'controllers.login_controller.deletedaccount'
rescue LoginTOTPFailedError
fail_reason = I18n.t 'controllers.login_controller.totpinvalid'
rescue LoginFailedError
fail_reason = I18n.t 'controllers.login_controller.flashlogininvalid'
end end
flash.now[:error] = I18n.t 'controllers.login_controller.flashlogininvalid' respond_to do |format|
@referer = params[:referer] format.html {
index flash.now[:error] = fail_reason
@referer = params[:referer]
index
}
format.json {
render :json => { :status => 0, :error => fail_reason }
}
end
end end
def forgot_password def forgot_password
@ -114,16 +170,48 @@ class LoginController < ApplicationController
end end
if @reset_user.save && @reset_user.is_active? if @reset_user.save && @reset_user.is_active?
session[:u] = @reset_user.session_token if @reset_user.has_2fa?
return redirect_to "/" flash[:success] = t('.passwordreset')
return redirect_to "/login"
else
session[:u] = @reset_user.session_token
return redirect_to "/"
end
else else
flash[:error] = "Could not reset password." flash[:error] = t('.couldnotresetpassword')
end end
end end
else else
flash[:error] = "Invalid reset token. It may have already been " << flash[:error] = t('.invalidresettoken')
"used or you may have copied it incorrectly."
return redirect_to forgot_password_path return redirect_to forgot_password_path
end end
end end
def twofa
if tmpu = find_twofa_user
Rails.logger.info " Authenticated as user #{tmpu.id} " <<
"(#{tmpu.username}), verifying TOTP"
else
reset_session
return redirect_to "/login"
end
end
def twofa_verify
if (tmpu = find_twofa_user) && tmpu.authenticate_totp(params[:totp_code])
session[:u] = tmpu.session_token
session.delete(:twofa_u)
return redirect_to "/"
else
flash[:error] = t('.totpcodenotmatch')
return redirect_to "/login/2fa"
end
end
private
def find_twofa_user
if session[:twofa_u].present?
return User.where(:session_token => session[:twofa_u]).first
end
end
end end

View file

@ -19,7 +19,11 @@ class SearchController < ApplicationController
end end
if @search.valid? if @search.valid?
@search.search_for_user!(@user) begin
@search.search_for_user!(@user)
rescue ThinkingSph::ConnectionError
flash[:error] = I18n.t 'controllers.search_controller.flasherrorsearchcontroller'
end
end end
end end

View file

@ -1,6 +1,8 @@
class SettingsController < ApplicationController class SettingsController < ApplicationController
before_filter :require_logged_in_user before_filter :require_logged_in_user
TOTP_SESSION_TIMEOUT = (60 * 15)
def index def index
@title = t('.accountsettings') @title = t('.accountsettings')
@ -19,9 +21,107 @@ class SettingsController < ApplicationController
return redirect_to settings_path return redirect_to settings_path
end end
def pushover def update
@edit_user = @user.clone
if params[:user][:password].empty? ||
@user.authenticate(params[:current_password].to_s)
if @edit_user.update_attributes(user_params)
flash.now[:success] = t('.updatesettingsflash')
@user = @edit_user
end
else
flash[:error] = t('.passwordnotcorrect')
end
render :action => "index"
end
def twofa
@title = t('.title')
end
def twofa_auth
if @user.authenticate(params[:user][:password].to_s)
session[:last_authed] = Time.now.to_i
session.delete(:totp_secret)
if @user.has_2fa?
@user.disable_2fa!
flash[:success] = t('.2fahasbeendisabled')
return redirect_to "/settings"
else
return redirect_to twofa_enroll_url
end
else
flash[:error] = t('.2fapassnotcorrect')
return redirect_to twofa_url
end
end
def twofa_enroll
@title = t('.title')
if (Time.now.to_i - session[:last_authed].to_i) > TOTP_SESSION_TIMEOUT
flash[:error] = t('.enrollmenttimeout')
return redirect_to twofa_url
end
if !session[:totp_secret]
session[:totp_secret] = ROTP::Base32.random_base32
end
totp = ROTP::TOTP.new(session[:totp_secret],
:issuer => Rails.application.name)
totp_url = totp.provisioning_uri(@user.email)
# no option for inline svg, so just strip off leading <?xml> tag
qrcode = RQRCode::QRCode.new(totp_url)
qr = qrcode.as_svg(:offset => 0, color: "000", :module_size => 5,
:shape_rendering => "crispEdges").gsub(/^<\?xml.*>/, "")
@qr_svg = "<a href=\"#{totp_url}\">#{qr}</a>"
end
def twofa_verify
@title = t('.title')
if ((Time.now.to_i - session[:last_authed].to_i) > TOTP_SESSION_TIMEOUT) ||
!session[:totp_secret]
flash[:error] = t('.enrollmenttimeout')
return redirect_to twofa_url
end
end
def twofa_update
if ((Time.now.to_i - session[:last_authed].to_i) > TOTP_SESSION_TIMEOUT) ||
!session[:totp_secret]
flash[:error] = t('.enrollmenttimeout')
return redirect_to twofa_url
end
@user.totp_secret = session[:totp_secret]
if @user.authenticate_totp(params[:totp_code])
# re-roll, just in case
@user.session_token = nil
@user.save!
session[:u] = @user.session_token
flash[:success] = t('.2fahasbeenenabled')
session.delete(:totp_secret)
return redirect_to "/settings"
else
flash[:error] = t('.totpinvalid')
return redirect_to twofa_verify_url
end
end
# external services
def pushover_auth
if !Pushover.SUBSCRIPTION_CODE if !Pushover.SUBSCRIPTION_CODE
flash[:error] = t('.pushovernotconfigured') flash[:error] = "This site is not configured for Pushover"
return redirect_to "/settings" return redirect_to "/settings"
end end
@ -36,12 +136,12 @@ class SettingsController < ApplicationController
def pushover_callback def pushover_callback
if !session[:pushover_rand].to_s.present? if !session[:pushover_rand].to_s.present?
flash[:error] = t('.pushovernorandomtokensession') flash[:error] = "No random token present in session"
return redirect_to "/settings" return redirect_to "/settings"
end end
if !params[:rand].to_s.present? if !params[:rand].to_s.present?
flash[:error] = t('.pushovernorandomtokenurl') flash[:error] = "No random token present in URL"
return redirect_to "/settings" return redirect_to "/settings"
end end
@ -54,23 +154,88 @@ class SettingsController < ApplicationController
@user.save! @user.save!
if @user.pushover_user_key.present? if @user.pushover_user_key.present?
flash[:success] = t('.accountsetuppushover') flash[:success] = "Your account is now setup for Pushover notifications."
else else
flash[:success] = t('.accountnolongersetuppushover') flash[:success] = "Your account is no longer setup for Pushover " <<
"notifications."
end end
return redirect_to "/settings" return redirect_to "/settings"
end end
def update def github_auth
@edit_user = @user.clone session[:github_state] = SecureRandom.hex
return redirect_to Github.oauth_auth_url(session[:github_state])
end
if @edit_user.update_attributes(user_params) def github_callback
flash.now[:success] = t('.updatesettingsflash') if !session[:github_state].present? || !params[:code].present? ||
@user = @edit_user (params[:state].to_s != session[:github_state].to_s)
flash[:error] = "Invalid OAuth state"
return redirect_to "/settings"
end end
render :action => "index" session.delete(:github_state)
tok, username = Github.token_and_user_from_code(params[:code])
if tok.present? && username.present?
@user.github_oauth_token = tok
@user.github_username = username
@user.save!
flash[:success] = "Your account has been linked to GitHub user " <<
"#{username}."
else
return github_disconnect
end
return redirect_to "/settings"
end
def github_disconnect
@user.github_oauth_token = nil
@user.github_username = nil
@user.save!
flash[:success] = "Your GitHub association has been removed."
return redirect_to "/settings"
end
def twitter_auth
session[:twitter_state] = SecureRandom.hex
return redirect_to Twitter.oauth_auth_url(session[:twitter_state])
end
def twitter_callback
if !session[:twitter_state].present? ||
(params[:state].to_s != session[:twitter_state].to_s)
flash[:error] = "Invalid OAuth state"
return redirect_to "/settings"
end
session.delete(:twitter_state)
tok, sec, username = Twitter.token_secret_and_user_from_token_and_verifier(
params[:oauth_token], params[:oauth_verifier])
if tok.present? && username.present?
@user.twitter_oauth_token = tok
@user.twitter_oauth_token_secret = sec
@user.twitter_username = username
@user.save!
flash[:success] = "Your account has been linked to Twitter user @" <<
"#{username}."
else
return twitter_disconnect
end
return redirect_to "/settings"
end
def twitter_disconnect
@user.twitter_oauth_token = nil
@user.twitter_username = nil
@user.twitter_oauth_token_secret = nil
@user.save!
flash[:success] = "Your Twitter association has been removed."
return redirect_to "/settings"
end end
private private
@ -81,7 +246,7 @@ private
:email_replies, :email_messages, :email_mentions, :email_replies, :email_messages, :email_mentions,
:pushover_replies, :pushover_messages, :pushover_mentions, :pushover_replies, :pushover_messages, :pushover_mentions,
:mailing_list_mode, :show_avatars, :show_story_previews, :mailing_list_mode, :show_avatars, :show_story_previews,
:show_submitted_story_threads :show_submitted_story_threads, :hide_dragons
) )
end end
end end

View file

@ -126,25 +126,15 @@ class StoriesController < ApplicationController
return redirect_to @story.merged_into_story.comments_path return redirect_to @story.merged_into_story.comments_path
end end
if @story.can_be_seen_by_user?(@user) if !@story.can_be_seen_by_user?(@user)
@title = @story.title raise ActionController::RoutingError.new("story gone")
else
@title = "[Story removed]"
end end
@title = @story.title
@short_url = @story.short_id_url @short_url = @story.short_id_url
@comments = @story.merged_comments.includes(:user, :story, @comments = @story.merged_comments.includes(:user, :story, :hat,
:hat).arrange_for_user(@user) :votes => :user).arrange_for_user(@user)
if params[:comment_short_id]
@comments.each do |c,x|
if c.short_id == params[:comment_short_id]
c.highlighted = true
break
end
end
end
respond_to do |format| respond_to do |format|
format.html { format.html {
@ -160,6 +150,10 @@ class StoriesController < ApplicationController
"apple-touch-icon-144.png", "apple-touch-icon-144.png",
} }
if @story.user.twitter_username.present?
@meta_tags["twitter:creator"] = "@" + @story.user.twitter_username
end
load_user_votes load_user_votes
render :action => "show" render :action => "show"

View file

@ -1,7 +1,6 @@
class UsersController < ApplicationController class UsersController < ApplicationController
before_filter :require_logged_in_moderator, :only => [ :enable_invitation, before_filter :require_logged_in_moderator,
:disable_invitation, :only => [ :enable_invitation, :disable_invitation, :ban, :unban ]
:ban, :unban ]
def show def show
@showing_user = User.where(:username => params[:username]).first! @showing_user = User.where(:username => params[:username]).first!
@ -16,11 +15,17 @@ class UsersController < ApplicationController
def tree def tree
@title = I18n.t 'controllers.users_controller.usertitle' @title = I18n.t 'controllers.users_controller.usertitle'
newest_user = User.last.id
if params[:by].to_s == "karma" if params[:by].to_s == "karma"
@users = User.order("karma DESC, id ASC").to_a content = Rails.cache.fetch("users_by_karma_#{newest_user}",
@user_count = @users.length :expires_in => (60 * 60 * 24)) {
@title << " By Karma" @users = User.order("karma DESC, id ASC").to_a
render :action => "list" @user_count = @users.length
@title << " By Karma"
render_to_string :action => "list", :layout => nil
}
render :text => content, :layout => "application"
elsif params[:moderators] elsif params[:moderators]
@users = User.where("is_admin = ? OR is_moderator = ?", true, true). @users = User.where("is_admin = ? OR is_moderator = ?", true, true).
order("id ASC").to_a order("id ASC").to_a
@ -28,10 +33,15 @@ class UsersController < ApplicationController
@title = "Moderators and Administrators" @title = "Moderators and Administrators"
render :action => "list" render :action => "list"
else else
users = User.order("id DESC").to_a content = Rails.cache.fetch("users_tree_#{newest_user}",
@user_count = users.length :expires_in => (60 * 60 * 24)) {
@users_by_parent = users.group_by(&:invited_by_user_id) users = User.order("id DESC").to_a
@newest = User.order("id DESC").limit(10) @user_count = users.length
@users_by_parent = users.group_by(&:invited_by_user_id)
@newest = User.order("id DESC").limit(10)
render_to_string :action => "tree", :layout => nil
}
render :text => content, :layout => "application"
end end
end end

View file

@ -1,6 +1,15 @@
module ApplicationHelper module ApplicationHelper
MAX_PAGES = 15 MAX_PAGES = 15
def avatar_img(user, size)
image_tag(user.avatar_url(size), {
:srcset => "#{user.avatar_url(size)} 1x, " <<
"#{user.avatar_url(size * 2)} 2x",
:class => "avatar",
:size => "#{size}x#{size}",
:alt => "#{user.username} avatar" })
end
def break_long_words(str, len = 30) def break_long_words(str, len = 30)
safe_join(str.split(" ").map{|w| safe_join(str.split(" ").map{|w|
if w.length > len if w.length > len

View file

View file

View file

@ -10,7 +10,7 @@ class Comment < ActiveRecord::Base
:class_name => "Moderation" :class_name => "Moderation"
belongs_to :hat belongs_to :hat
attr_accessor :current_vote, :previewing, :indent_level, :highlighted attr_accessor :current_vote, :previewing, :indent_level
before_validation :on => :create do before_validation :on => :create do
self.assign_short_id_and_upvote self.assign_short_id_and_upvote
@ -26,9 +26,14 @@ class Comment < ActiveRecord::Base
DOWNVOTABLE_DAYS = 7 DOWNVOTABLE_DAYS = 7
# the lowest a score can go, which makes it collapsed by default
DOWNVOTABLE_MIN_SCORE = -5
# after this many minutes old, a comment cannot be edited # after this many minutes old, a comment cannot be edited
MAX_EDIT_MINS = (60 * 6) MAX_EDIT_MINS = (60 * 6)
SCORE_RANGE_TO_HIDE = (-2 .. 4)
validate do validate do
self.comment.to_s.strip == "" && self.comment.to_s.strip == "" &&
errors.add(" ", I18n.t( 'models.comment.commentcannotbeblank')) errors.add(" ", I18n.t( 'models.comment.commentcannotbeblank'))
@ -44,10 +49,14 @@ class Comment < ActiveRecord::Base
self.comment.to_s.strip.match(/\Atl;?dr.?$\z/i) && self.comment.to_s.strip.match(/\Atl;?dr.?$\z/i) &&
errors.add(:base, "Wow! A blue car!") errors.add(:base, "Wow! A blue car!")
self.comment.to_s.strip.match(/\Ame too.?\z/i) &&
errors.add(:base, I18n.t( 'models.comment.metooerror'))
end end
def self.arrange_for_user(user) def self.arrange_for_user(user)
parents = self.order("confidence DESC").group_by(&:parent_comment_id) parents = self.order("is_dragon ASC, (upvotes - downvotes) < 0 ASC, " <<
"confidence DESC").group_by(&:parent_comment_id)
# top-down list of comments, regardless of indent level # top-down list of comments, regardless of indent level
ordered = [] ordered = []
@ -164,6 +173,36 @@ class Comment < ActiveRecord::Base
end end
end end
def become_dragon_for_user(user)
Comment.record_timestamps = false
self.is_dragon = true
m = Moderation.new
m.comment_id = self.id
m.moderator_user_id = user.id
m.action = I18n.t('models.comment.turnedintodragon')
m.save
self.save(:validate => false)
Comment.record_timestamps = true
end
def remove_dragon_for_user(user)
Comment.record_timestamps = false
self.is_dragon = false
m = Moderation.new
m.comment_id = self.id
m.moderator_user_id = user.id
m.action = I18n.t('models.comment.slayeddragon')
m.save
self.save(:validate => false)
Comment.record_timestamps = true
end
# http://evanmiller.org/how-not-to-sort-by-average-rating.html # http://evanmiller.org/how-not-to-sort-by-average-rating.html
# https://github.com/reddit/reddit/blob/master/r2/r2/lib/db/_sorts.pyx # https://github.com/reddit/reddit/blob/master/r2/r2/lib/db/_sorts.pyx
def calculated_confidence def calculated_confidence
@ -223,7 +262,7 @@ class Comment < ActiveRecord::Base
if u.email_mentions? if u.email_mentions?
begin begin
EmailReply.mention(self, u).deliver EmailReply.mention(self, u).deliver_now
rescue => e rescue => e
Rails.logger.error "error e-mailing #{u.email}: #{e}" Rails.logger.error "error e-mailing #{u.email}: #{e}"
end end
@ -247,7 +286,7 @@ class Comment < ActiveRecord::Base
u.id != self.user.id u.id != self.user.id
if u.email_replies? if u.email_replies?
begin begin
EmailReply.reply(self, u).deliver EmailReply.reply(self, u).deliver_now
rescue => e rescue => e
Rails.logger.error "error e-mailing #{u.email}: #{e}" Rails.logger.error "error e-mailing #{u.email}: #{e}"
end end
@ -321,7 +360,7 @@ class Comment < ActiveRecord::Base
end end
def is_downvotable? def is_downvotable?
if self.created_at if self.created_at && self.score > DOWNVOTABLE_MIN_SCORE
Time.now - self.created_at <= DOWNVOTABLE_DAYS.days Time.now - self.created_at <= DOWNVOTABLE_DAYS.days
else else
false false
@ -392,14 +431,24 @@ class Comment < ActiveRecord::Base
self.upvotes - self.downvotes self.upvotes - self.downvotes
end end
def short_id_path def score_for_user(u)
self.story.short_id_path + "/c/#{self.short_id}" if self.showing_downvotes_for_user?(u)
score
else
"-"
end
end end
def short_id_url def short_id_url
Rails.application.root_url + "c/#{self.short_id}" Rails.application.root_url + "c/#{self.short_id}"
end end
def showing_downvotes_for_user?(u)
return (u && u.is_moderator?) ||
(self.created_at && self.created_at < 36.hours.ago) ||
!SCORE_RANGE_TO_HIDE.include?(self.score)
end
def to_param def to_param
self.short_id self.short_id
end end
@ -409,13 +458,14 @@ class Comment < ActiveRecord::Base
end end
def url def url
self.story.comments_url + "/comments/#{self.short_id}#c_#{self.short_id}" self.story.comments_url + "#c_#{self.short_id}"
end end
def vote_summary_for_user(u) def vote_summary_for_user(u)
r_counts = {} r_counts = {}
r_users = {} r_users = {}
self.votes.includes(:user).each do |v| # don't includes(:user) here and assume the caller did this already
self.votes.each do |v|
r_counts[v.reason.to_s] ||= 0 r_counts[v.reason.to_s] ||= 0
r_counts[v.reason.to_s] += v.vote r_counts[v.reason.to_s] += v.vote

View file

@ -24,6 +24,6 @@ class Invitation < ActiveRecord::Base
end end
def send_email def send_email
InvitationMailer.invitation(self).deliver InvitationMailer.invitation(self).deliver_now
end end
end end

View file

@ -26,8 +26,8 @@ class InvitationRequest < ActiveRecord::Base
def markeddown_memo def markeddown_memo
Markdowner.to_html(self.memo) Markdowner.to_html(self.memo)
end end
def send_email def send_email
InvitationRequestMailer.invitation_request(self).deliver InvitationRequestMailer.invitation_request(self).deliver_now
end end
end end

View file

@ -49,7 +49,7 @@ class Message < ActiveRecord::Base
if self.recipient.email_messages? if self.recipient.email_messages?
begin begin
EmailMessage.notify(self, self.recipient).deliver EmailMessage.notify(self, self.recipient).deliver_now
rescue => e rescue => e
Rails.logger.error "error e-mailing #{self.recipient.email}: #{e}" Rails.logger.error "error e-mailing #{self.recipient.email}: #{e}"
end end

View file

@ -68,8 +68,14 @@ class Search
if domain.present? if domain.present?
self.what = "stories" self.what = "stories"
story_ids = Story.select(:id).where("`url` REGEXP '//([^/]*\.)?" + begin
ActiveRecord::Base.connection.quote_string(domain) + "/'"). reg = Regexp.new("//([^/]*\.)?#{domain}/")
rescue RegexpError
return false
end
story_ids = Story.select(:id).where("`url` REGEXP '" +
ActiveRecord::Base.connection.quote_string(reg.source) + "'").
collect(&:id) collect(&:id)
if story_ids.any? if story_ids.any?
@ -96,14 +102,9 @@ class Search
query = Riddle.escape(words) query = Riddle.escape(words)
# go go gadget search # go go gadget search
self.results = [] self.total_results = -1
self.total_results = 0 self.results = ThinkingSphinx.search query, opts
begin self.total_results = self.results.total_entries
self.results = ThinkingSphinx.search query, opts
self.total_results = self.results.total_entries
rescue => e
Rails.logger.info "Error from Sphinx: #{e.inspect}"
end
if self.page > self.page_count if self.page > self.page_count
self.page = self.page_count self.page = self.page_count
@ -132,5 +133,10 @@ class Search
end end
end end
end end
rescue ThinkingSphinx::ConnectionError => e
self.results = []
self.total_results = -1
raise e
end end
end end

View file

@ -13,7 +13,8 @@ class StoriesPaginator
def get def get
with_pagination_info @scope.limit(per_page + 1) with_pagination_info @scope.limit(per_page + 1)
.offset((@page - 1) * per_page) .offset((@page - 1) * per_page)
.includes(:user, :taggings => :tag) .includes(:user, :suggested_titles, :suggested_taggings,
:taggings => :tag)
end end
private private

View file

@ -33,6 +33,9 @@ class Story < ActiveRecord::Base
DOWNVOTABLE_DAYS = 14 DOWNVOTABLE_DAYS = 14
# the lowest a score can go
DOWNVOTABLE_MIN_SCORE = -5
# after this many minutes old, a story cannot be edited # after this many minutes old, a story cannot be edited
MAX_EDIT_MINS = (60 * 6) MAX_EDIT_MINS = (60 * 6)
@ -123,7 +126,11 @@ class Story < ActiveRecord::Base
end end
def self.recalculate_all_hotnesses! def self.recalculate_all_hotnesses!
Story.all.order("id DESC").each do |s| # do the front page first, since find_each can't take an order
Story.order("id DESC").limit(100).each do |s|
s.recalculate_hotness!
end
Story.find_each do |s|
s.recalculate_hotness! s.recalculate_hotness!
end end
true true
@ -138,6 +145,10 @@ class Story < ActiveRecord::Base
Story.connection.adapter_name.match(/mysql/i) ? "signed" : "integer" Story.connection.adapter_name.match(/mysql/i) ? "signed" : "integer"
end end
def archive_url
"https://archive.is/#{CGI.escape(self.url)}"
end
def as_json(options = {}) def as_json(options = {})
h = [ h = [
:short_id, :short_id,
@ -185,21 +196,35 @@ class Story < ActiveRecord::Base
end end
def calculated_hotness def calculated_hotness
base = self.tags.map{|t| t.hotness_mod }.sum # take each tag's hotness modifier into effect, and give a slight bump to
# stories submitted by the author
base = self.tags.map{|t| t.hotness_mod }.sum +
(self.user_is_author ? 0.25 : 0.0)
# give a story's comment votes some weight (unless the hotness mod is # give a story's comment votes some weight, ignoring submitter's comments
# negative), but ignore the story submitter's own comments cpoints = self.comments.
if base < 0 where("user_id <> ?", self.user_id).
cpoints = 0 select(:upvotes, :downvotes).
else map{|c|
cpoints = self.comments.where("user_id <> ?", self.user_id). if base < 0
select(:upvotes, :downvotes).map{|c| c.upvotes + 1 - c.downvotes }. # in stories already starting out with a bad hotness mod, only look
inject(&:+).to_f * 0.5 # at the downvotes to find out if this tire fire needs to be put out
end c.downvotes * -0.5
else
c.upvotes + 1 - c.downvotes
end
}.
inject(&:+).to_f * 0.5
# mix in any stories this one cannibalized # mix in any stories this one cannibalized
cpoints += self.merged_stories.map{|s| s.score }.inject(&:+).to_f cpoints += self.merged_stories.map{|s| s.score }.inject(&:+).to_f
# if a story has many comments but few votes, it's probably a bad story, so
# cap the comment points at the number of upvotes
if cpoints > self.upvotes
cpoints = self.upvotes
end
# don't immediately kill stories at 0 by bumping up score by one # don't immediately kill stories at 0 by bumping up score by one
order = Math.log([ (score + 1).abs + cpoints, 1 ].max, 10) order = Math.log([ (score + 1).abs + cpoints, 1 ].max, 10)
if score > 0 if score > 0
@ -227,7 +252,7 @@ class Story < ActiveRecord::Base
return false return false
end end
if self.tags.select{|t| t.privileged? }.any? if self.taggings.select{|t| t.tag && t.tag.privileged? }.any?
return false return false
end end
@ -305,10 +330,24 @@ class Story < ActiveRecord::Base
def fetch_story_cache! def fetch_story_cache!
if self.url.present? if self.url.present?
self.story_cache = StoryCacher.get_story_text(self.url) self.story_cache = StoryCacher.get_story_text(self)
end end
end end
def fix_bogus_chars
# this is needlessly complicated to work around character encoding issues
# that arise when doing just self.title.to_s.gsub(160.chr, "")
self.title = self.title.to_s.split("").map{|chr|
if chr.ord == 160
" "
else
chr
end
}.join("")
true
end
def generated_markeddown_description def generated_markeddown_description
Markdowner.to_html(self.description, { :allow_images => true }) Markdowner.to_html(self.description, { :allow_images => true })
end end
@ -323,6 +362,10 @@ class Story < ActiveRecord::Base
"hotness = '#{self.calculated_hotness}' WHERE id = #{self.id.to_i}") "hotness = '#{self.calculated_hotness}' WHERE id = #{self.id.to_i}")
end end
def has_suggestions?
self.suggested_taggings.any? || self.suggested_titles.any?
end
def hider_count def hider_count
@hider_count ||= HiddenStory.where(:story_id => self.id).count @hider_count ||= HiddenStory.where(:story_id => self.id).count
end end
@ -341,8 +384,7 @@ class Story < ActiveRecord::Base
end end
def is_downvotable? def is_downvotable?
return true if self.created_at && self.score >= DOWNVOTABLE_MIN_SCORE
if self.created_at
Time.now - self.created_at <= DOWNVOTABLE_DAYS.days Time.now - self.created_at <= DOWNVOTABLE_DAYS.days
else else
false false
@ -467,20 +509,6 @@ class Story < ActiveRecord::Base
self.user_id, nil, false) self.user_id, nil, false)
end end
def fix_bogus_chars
# this is needlessly complicated to work around character encoding issues
# that arise when doing just self.title.to_s.gsub(160.chr, "")
self.title = self.title.to_s.split("").map{|chr|
if chr.ord == 160
" "
else
chr
end
}.join("")
true
end
def score def score
upvotes - downvotes upvotes - downvotes
end end
@ -788,6 +816,24 @@ class Story < ActiveRecord::Base
title = parsed.at_css("title").try(:text).to_s title = parsed.at_css("title").try(:text).to_s
end end
# see if the site name is available, so we can strip it out in case it was
# present in the fetched title
begin
site_name = parsed.at_css("meta[property='og:site_name']").
attributes["content"].text
if site_name.present? && site_name.length < title.length &&
title[-(site_name.length), site_name.length] == site_name
title = title[0, title.length - site_name.length]
# remove title/site name separator
if title.match(/ [ \-\|\u2013] $/)
title = title[0, title.length - 3]
end
end
rescue
end
@fetched_attributes[:title] = title @fetched_attributes[:title] = title
# now get canonical version of url (though some cms software puts incorrect # now get canonical version of url (though some cms software puts incorrect

View file

@ -33,13 +33,34 @@ class User < ActiveRecord::Base
has_secure_password has_secure_password
typed_store :settings do |s|
s.boolean :email_notifications, :default => false
s.boolean :email_replies, :default => false
s.boolean :pushover_replies, :default => false
s.string :pushover_user_key
s.boolean :email_messages, :default => false
s.boolean :pushover_messages, :default => false
s.boolean :email_mentions, :default => false
s.boolean :show_avatars, :default => true
s.boolean :show_story_previews, :default => false
s.boolean :show_submitted_story_threads, :default => false
s.boolean :hide_dragons, :default => false
s.string :totp_secret
s.string :github_oauth_token
s.string :github_username
s.string :twitter_oauth_token
s.string :twitter_oauth_token_secret
s.string :twitter_username
end
validates :email, :format => { :with => /\A[^@ ]+@[^@ ]+\.[^@ ]+\Z/ }, validates :email, :format => { :with => /\A[^@ ]+@[^@ ]+\.[^@ ]+\Z/ },
:uniqueness => { :case_sensitive => false } :uniqueness => { :case_sensitive => false }
validates :password, :presence => true, :on => :create validates :password, :presence => true, :on => :create
VALID_USERNAME = /[A-Za-z0-9][A-Za-z0-9_-]{0,24}/
validates :username, validates :username,
:format => { :with => /\A[A-Za-z0-9][A-Za-z0-9_-]{0,24}\Z/ }, :format => { :with => /\A#{VALID_USERNAME}\z/ },
:uniqueness => { :case_sensitive => false } :uniqueness => { :case_sensitive => false }
validates_each :username do |record,attr,value| validates_each :username do |record,attr,value|
@ -48,15 +69,17 @@ class User < ActiveRecord::Base
end end
end end
scope :active, -> { where(:banned_at => nil, :deleted_at => nil) }
before_save :check_session_token before_save :check_session_token
before_validation :on => :create do before_validation :on => :create do
self.create_rss_token self.create_rss_token
self.create_mailing_list_token self.create_mailing_list_token
end end
BANNED_USERNAMES = [ "admin", "administrator", "hostmaster", "mailer-daemon", BANNED_USERNAMES = [ "admin", "administrator", "contact", "fraud", "guest",
"postmaster", "root", "security", "support", "webmaster", "moderator", "help", "hostmaster", "mailer-daemon", "moderator", "moderators", "nobody",
"moderators", "help", "contact", "fraud", "guest", "nobody", ] "postmaster", "root", "security", "support", "sysop", "webmaster" ]
# days old accounts are considered new for # days old accounts are considered new for
NEW_USER_DAYS = 7 NEW_USER_DAYS = 7
@ -65,7 +88,7 @@ class User < ActiveRecord::Base
MIN_KARMA_TO_SUGGEST = 10 MIN_KARMA_TO_SUGGEST = 10
# minimum karma required to be able to downvote comments # minimum karma required to be able to downvote comments
MIN_KARMA_TO_DOWNVOTE = 100 MIN_KARMA_TO_DOWNVOTE = 50
# minimum karma required to be able to submit new stories # minimum karma required to be able to submit new stories
MIN_KARMA_TO_SUBMIT_STORIES = -4 MIN_KARMA_TO_SUBMIT_STORIES = -4
@ -77,10 +100,8 @@ class User < ActiveRecord::Base
end end
end end
def self.username_regex def self.username_regex_s
User.validators_on(:username).select{|v| "/^" + VALID_USERNAME.to_s.gsub(/(\?-mix:|\(|\))/, "") + "$/"
v.class == ActiveModel::Validations::FormatValidator }.first.
options[:with].inspect
end end
def as_json(options = {}) def as_json(options = {})
@ -98,10 +119,25 @@ class User < ActiveRecord::Base
attrs.push :about attrs.push :about
h = super(:only => attrs) h = super(:only => attrs)
h[:avatar_url] = avatar_url
h[:avatar_url] = self.avatar_url
if self.github_username.present?
h[:github_username] = self.github_username
end
if self.twitter_username.present?
h[:twitter_username] = self.twitter_username
end
h h
end end
def authenticate_totp(code)
totp = ROTP::TOTP.new(self.totp_secret)
totp.verify(code)
end
def avatar_url(size = 100) def avatar_url(size = 100)
"https://secure.gravatar.com/avatar/" + "https://secure.gravatar.com/avatar/" +
Digest::MD5.hexdigest(self.email.strip.downcase) + Digest::MD5.hexdigest(self.email.strip.downcase) +
@ -176,7 +212,7 @@ class User < ActiveRecord::Base
# user can unvote # user can unvote
return true return true
end end
elsif obj.is_a?(Comment) elsif obj.is_a?(Comment) && obj.is_downvotable?
return !self.is_new? && (self.karma >= MIN_KARMA_TO_DOWNVOTE) return !self.is_new? && (self.karma >= MIN_KARMA_TO_DOWNVOTE)
end end
@ -262,6 +298,11 @@ class User < ActiveRecord::Base
end end
end end
def disable_2fa!
self.totp_secret = nil
self.save!
end
def grant_moderatorship_by_user!(user) def grant_moderatorship_by_user!(user)
User.transaction do User.transaction do
self.is_moderator = true self.is_moderator = true
@ -287,7 +328,11 @@ class User < ActiveRecord::Base
self.password_reset_token = "#{Time.now.to_i}-#{Utils.random_str(30)}" self.password_reset_token = "#{Time.now.to_i}-#{Utils.random_str(30)}"
self.save! self.save!
PasswordReset.password_reset_link(self, ip).deliver PasswordReset.password_reset_link(self, ip).deliver_now
end
def has_2fa?
self.totp_secret.present?
end end
def is_active? def is_active?
@ -303,9 +348,7 @@ class User < ActiveRecord::Base
end end
def linkified_about def linkified_about
# most users are probably mentioning "@username" to mean a twitter url, not Markdowner.to_html(self.about)
# a link to a profile on this site
Markdowner.to_html(self.about, { :disable_profile_links => true })
end end
def most_common_story_tag def most_common_story_tag

View file

@ -1,13 +1,11 @@
<input id="comment_folder_<%= comment.short_id %>" <input id="comment_folder_<%= comment.short_id %>"
class="comment_folder_button" type="checkbox" class="comment_folder_button" type="checkbox"
<%= comment.score <= -1 ? "checked" : "" %>> <%= comment.score <= Comment::DOWNVOTABLE_MIN_SCORE ? "checked" : "" %>>
<div id="comment_<%= comment.short_id %>" <div id="c_<%= comment.short_id %>"
data-shortid="<%= comment.short_id if comment.persisted? %>" data-shortid="<%= comment.short_id if comment.persisted? %>"
class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ? class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
"upvoted" : "downvoted") : "" %> "upvoted" : "downvoted") : "" %>
<%= comment.highlighted ? "highlighted" : "" %> <%= comment.score < Comment::SCORE_RANGE_TO_HIDE.first ? "bad" : "" %>">
<%= comment.score <= 0 ? "negative" : "" %>
<%= comment.score <= -1 ? "negative_1" : "" %>">
<% if !comment.is_gone? %> <% if !comment.is_gone? %>
<div class="voters"> <div class="voters">
<% if @user %> <% if @user %>
@ -15,7 +13,7 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
<% else %> <% else %>
<%= link_to "", login_path, :class => "upvoter" %> <%= link_to "", login_path, :class => "upvoter" %>
<% end %> <% end %>
<div class="score"><%= comment.score %></div> <div class="score"><%= comment.score_for_user(@user) %></div>
<% if @user && @user.can_downvote?(comment) %> <% if @user && @user.can_downvote?(comment) %>
<a class="downvoter"></a> <a class="downvoter"></a>
<% else %> <% else %>
@ -35,8 +33,8 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
<% end %> <% end %>
<% if (@user && @user.show_avatars?) || !@user %> <% if (@user && @user.show_avatars?) || !@user %>
<a href="/u/<%= comment.user.username %>"><img <a href="/u/<%= comment.user.username %>"><%=
src="<%= comment.user.avatar_url(16) %>" class="avatar"></a> avatar_img(comment.user, 16) %></a>
<% end %> <% end %>
<a href="/u/<%= comment.user.username %>" <a href="/u/<%= comment.user.username %>"
@ -61,7 +59,7 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
<% if !comment.previewing %> <% if !comment.previewing %>
| |
<a href="<%= comment.short_id_url %>"><%= t('.link') %></a> <a href="<%= comment.url %>"><%= t('.link') %></a>
<% if comment.is_editable_by_user?(@user) %> <% if comment.is_editable_by_user?(@user) %>
| |
@ -80,6 +78,15 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
<% end %> <% end %>
<% end %> <% end %>
<% if !comment.parent_comment_id && @user && @user.is_moderator? %>
|
<% if comment.is_dragon? %>
<a class="comment_undragon"><%= t('.undragon') %></a>
<% else %>
<a class="comment_dragon"><%= t('.dragon') %></a>
<% end %>
<% end %>
<% if @user && !comment.story.is_gone? && !comment.is_gone? %> <% if @user && !comment.story.is_gone? && !comment.is_gone? %>
| |
<a class="comment_replier" unselectable="on"><%= t('.reply') %></a> <a class="comment_replier" unselectable="on"><%= t('.reply') %></a>
@ -93,8 +100,8 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
<span class="reason"> <span class="reason">
<% if comment.downvotes > 0 && <% if comment.downvotes > 0 &&
((comment.score <= 0 && comment.user_id == @user.try(:id)) || comment.showing_downvotes_for_user?(@user) &&
@user.try("is_moderator?")) %> (comment.user_id == @user.try(:id) || @user.try("is_moderator?")) %>
| <%= comment.vote_summary_for_user(@user).downcase %> | <%= comment.vote_summary_for_user(@user).downcase %>
<% elsif comment.current_vote && comment.current_vote[:vote] == -1 %> <% elsif comment.current_vote && comment.current_vote[:vote] == -1 %>
| -1 | -1

View file

@ -15,7 +15,7 @@ data-shortid="<%= comment.short_id if comment.persisted? %>">
<div style="width: 100%;"> <div style="width: 100%;">
<%= text_area_tag "comment", comment.comment, :rows => 5, <%= text_area_tag "comment", comment.comment, :rows => 5,
:style => "width: 100%;", :autocomplete => "off", :disabled => !@user, :disabled => !@user,
:placeholder => (@user ? "" : t('.mustbelogged')) :placeholder => (@user ? "" : t('.mustbelogged'))
%> %>

View file

@ -8,12 +8,10 @@
<td><strong><%= t('.strongtext') %></strong></td> <td><strong><%= t('.strongtext') %></strong></td>
<td><%= raw t('.strongtextdesc') %></td> <td><%= raw t('.strongtextdesc') %></td>
</tr> </tr>
<!--
<tr> <tr>
<td><strike>struck-through</strike></td> <td><strike>struck-through</strike></td>
<td>surround text with <tt>~~two tilde characters~~</tt></td> <td>surround text with <tt>~~two tilde characters~~</tt></td>
</tr> </tr>
-->
<tr> <tr>
<td><tt><%= t('.fixedwidth') %></tt></td> <td><tt><%= t('.fixedwidth') %></tt></td>
<td><%= raw t('.fixedwidthdesc') %></td> <td><%= raw t('.fixedwidthdesc') %></td>

View file

@ -1,60 +1,29 @@
<div class="box wide"> <div class="box wide">
<div class="legend"> <div class="legend">
Request a Hat <%= t('.title') %>
</div> </div>
<p> <%= raw(t('.description')) %>
A hat is a formal, verified, way of posting a comment while speaking for a
project, organization, or company. Each user may have multiple hats, one of
which may be worn at any time when posting a comment or sending a private
message.
</p>
<p>
Hats are intended for core project developers and company employees that are
authorized to make comments on behalf of those organizations, and are usually
reserved for notable projects and organizations. Hats are not intended to
list every contribution a person has made to any software project.
</p>
<p>
Hats will not be authorized for occasional contributors to projects,
developers of projects which are not widely known, past employees of
companies, or other situations where it cannot be verified that one is
authorized to represent that organization. In general, unless one has an
e-mail address at a compay domain or have commit access to a project, a hat
will not be authorized.
</p>
<p>
To request a hat for your account, provide a short description of the hat
(e.g., "OpenBSD Developer"), a public link that will be shown when hovering
over the hat that users can see, such as your e-mail address at that project
or company, or a link to a company website showing your employment, and
private comments that will be seen only by moderators during approval.
</p>
<p>
Once your hat is requested, a moderator will verify your request by e-mailing
the address you submitted as the link, or doing some other manual
verification of project association.
</p>
<%= form_for @hat_request, :url => create_hat_request_path do |f| %> <%= form_for @hat_request, :url => create_hat_request_path do |f| %>
<p> <p>
<%= f.label :hat, "Hat:" %> <%= f.label :hat, t('.hat') %>
<%= f.text_field :hat, :size => 20, <%= f.text_field :hat, :size => 20,
:placeholder => "XYZ Project Member" %> :placeholder => t('.hatplaceholder') %>
<br /> <br />
<%= f.label :link, "Link:" %> <%= f.label :link, t('.link') %>
<%= f.text_field :link, :size => 50, <%= f.text_field :link, :size => 50,
:placeholder => "user@project.org, or a URL to an employment page" %> :placeholder => t('.linkplaceholder') %>
<br /> <br />
<%= f.label :comment, "Comment:" %> <%= f.label :comment, t('.comment') %>
<%= f.text_area :comment, :rows => 4, <%= f.text_area :comment, :rows => 4,
:placeholder => "Will only be shown to moderators during approval" %> :placeholder => t('.commentplaceholder') %>
</p> </p>
<p> <p>
<%= submit_tag "Request Hat" %> <%= submit_tag t('.requesthatbutton') %>
</p> </p>
<% end %> <% end %>
</div> </div>

View file

@ -1,25 +1,22 @@
<div class="box wide"> <div class="box wide">
<% if @user %> <% if @user %>
<div class="legend right"> <div class="legend right">
<a href="<%= request_hat_url %>">Request Hat</a> <a href="<%= request_hat_url %>"><%= t('.request') %></a>
</div> </div>
<% end %> <% end %>
<div class="legend"> <div class="legend">
Hats <%= t('.title') %>
</div> </div>
<p> <p>
A hat is a formal, verified, way of posting a comment while speaking for a <%= t('.description') %>
project, organization, or company. Each user may have multiple hats, one of
which may be selected to be worn when posting a comment or sending a private
message.
</p> </p>
<table class="data" width="100%" cellspacing=0> <table class="data" width="100%" cellspacing=0>
<tr> <tr>
<th style="width: 130px;">User</th> <th style="width: 130px;"><%= t('.user') %></th>
<th>Hat</th> <th><%= t('.hat') %></th>
<th>Link</th> <th><%= t('.link') %></th>
</tr> </tr>
<% bit = 0 %> <% bit = 0 %>
<% @hat_groups.keys.sort_by{|a| a.downcase }.each do |hg| %> <% @hat_groups.keys.sort_by{|a| a.downcase }.each do |hg| %>

View file

@ -1,10 +1,10 @@
<div class="box wide"> <div class="box wide">
<div class="legend"> <div class="legend">
Requested Hats <%= t('.title') %>
</div> </div>
<% if @hat_requests.count == 0 %> <% if @hat_requests.count == 0 %>
No hat requests. <%= t('.nohatrequests') %>
<% else %> <% else %>
<% @hat_requests.each_with_index do |hr,x| %> <% @hat_requests.each_with_index do |hr,x| %>
<% if x > 0 %> <% if x > 0 %>
@ -15,42 +15,42 @@
:method => :post do |f| %> :method => :post do |f| %>
<p> <p>
<div class="boxline"> <div class="boxline">
<%= f.label :user_id, "User:", :class => "required" %> <%= f.label :user_id, t('.user'), :class => "required" %>
<a href="/u/<%= hr.user.username %>"><%= hr.user.username %></a> <a href="/u/<%= hr.user.username %>"><%= hr.user.username %></a>
</div> </div>
<div class="boxline"> <div class="boxline">
<%= f.label :hat, "Hat:", :class => "required" %> <%= f.label :hat, t('.hat'), :class => "required" %>
<%= f.text_field "hat", :size => 25 %> <%= f.text_field "hat", :size => 75 %>
</div> </div>
<div class="boxline"> <div class="boxline">
<%= f.label :link, "Link:", :class => "required" %> <%= f.label :link, t('.link'), :class => "required" %>
<%= f.text_field "link", :size => 25 %> <%= f.text_field "link", :size => 75 %>
</div> </div>
<div class="boxline"> <div class="boxline">
<%= f.label :link, "Comment:", :class => "required" %> <%= f.label :link, t('.comment'), :class => "required" %>
<div class="d"> <div class="d">
<%= raw(h(hr.comment.to_s).gsub(/\n/, "<br>")) %> <%= raw(h(hr.comment.to_s).gsub(/\n/, "<br>")) %>
</div> </div>
</div> </div>
<p style="clear: both;"> <p style="clear: both;">
<%= submit_tag "Approve Hat Request" %> <%= submit_tag t('.approve') %>
</p> </p>
<% end %> <% end %>
<p> <p>
or <%= t('.hator') %>
</p> </p>
<%= form_for hr, :url => reject_hat_request_url(:id => hr), <%= form_for hr, :url => reject_hat_request_url(:id => hr),
:method => :post do |f| %> :method => :post do |f| %>
<div class="boxline"> <div class="boxline">
<%= f.label :link, "Reason:", :class => "required" %> <%= f.label :link, t('.reason'), :class => "required" %>
<%= f.text_area :rejection_comment, :rows => 4 %> <%= f.text_area :rejection_comment, :rows => 4 %>
</div> </div>
<p> <p>
<%= submit_tag "Reject Hat Request" %> <%= submit_tag t('.reject') %>
</p> </p>
<% end %> <% end %>
<% end %> <% end %>

View file

@ -13,7 +13,7 @@
<br /> <br />
<%= f.label :email, t('.buildinvemail') %> <%= f.label :email, t('.buildinvemail') %>
<%= f.text_field :email, :size => 30 %> <%= f.email_field :email, :size => 30 %>
<br /> <br />
<%= f.label :memo, t('.buildinvurl') %> <%= f.label :memo, t('.buildinvurl') %>

View file

@ -9,7 +9,6 @@
<link rel="apple-touch-icon" sizes="72x72" href="/apple-touch-icon-144.png" /> <link rel="apple-touch-icon" sizes="72x72" href="/apple-touch-icon-144.png" />
<link rel="apple-touch-icon" sizes="144x144" href="/apple-touch-icon-144.png" /> <link rel="apple-touch-icon" sizes="144x144" href="/apple-touch-icon-144.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noarchive,noodp,noydir" />
<meta name="referrer" content="always" /> <meta name="referrer" content="always" />
<meta name="theme-color" content="#AC130D" /> <meta name="theme-color" content="#AC130D" />
<% if @meta_tags %> <% if @meta_tags %>
@ -97,9 +96,6 @@
raw("class=\"cur_url\"") : "" %>><%= @user.username %> raw("class=\"cur_url\"") : "" %>><%= @user.username %>
(<%= @user.karma %>)</a> (<%= @user.karma %>)</a>
<%= link_to t('.logoutlink'), { :controller => "login", :action => "logout" },
:data => { :confirm => t('.confirmlogoutlink') },
:method => "post" %>
<% else %> <% else %>
<a href="/login"><%= t('.loginlink') %></a> <a href="/login"><%= t('.loginlink') %></a>
<% end %> <% end %>
@ -119,21 +115,27 @@
<%= yield %> <%= yield %>
<div id="footer"> <div id="footer">
<a href="/moderations"><%= t('.moderationloglink') %></a> <% if lookup_context.template_exists?("footer", "layouts", true) %>
<% if @user && !@user.is_new? && <%= render :partial => "layouts/footer" %>
(iqc = InvitationRequest.verified_count) > 0 %> <% else %>
<a href="/invitations"><%= t('.invitationqueuelink') %>(<%= iqc %>)</a> <a href="/moderations">Moderation Log</a>
<% if @user && !@user.is_new? &&
(iqc = InvitationRequest.verified_count) > 0 %>
<a href="/invitations">Invitation Queue(<%= iqc %>)</a>
<% end %>
<% if @user && @user.is_moderator? &&
(hrc = HatRequest.count) > 0 %>
<a href="/hats/requests">Hat Requests (<%= hrc %>)</a>
<% else %>
<a href="/hats"><%= t('.hatslink') %></a>
<% end %>
<a href="/privacy"><%= t('.privacylink') %></a>
<a href="/about"><%= t('.aboutlink') %></a>
<a href="https://blog.journalduhacker.net"><%= t('.blog') %></a>
<a href="https://twitter.com/journalduhacker"><%= t('.twitter') %></a>
<a href="https://framasphere.org/people/2aaaaba0110c0133c7ea2a0000053625"><%= t('.diaspora') %></a>
<a href="https://framapiaf.org/@journalduhacker"><%= t('.mastodon') %></a>
<% end %> <% end %>
<% if @user && @user.is_moderator? &&
(hrc = HatRequest.count) > 0 %>
<a href="/hats/requests"><%= t('.hatrequestlink') %> (<%= hrc %>)</a>
<% end %>
<a href="/privacy"><%= t('.privacylink') %></a>
<a href="/about"><%= t('.aboutlink') %></a>
<a href="https://blog.journalduhacker.net"><%= t('.blog') %></a>
<a href="https://twitter.com/journalduhacker"><%= t('.twitter') %></a>
<a href="https://framasphere.org/people/2aaaaba0110c0133c7ea2a0000053625"><%= t('.diaspora') %></a>
<a href="https://framapiaf.org/@journalduhacker"><%= t('.mastodon') %></a>
</div> </div>
<div class="clear"></div> <div class="clear"></div>
</div> </div>

View file

@ -0,0 +1,23 @@
<div class="box wide">
<div class="legend">
<%= t('.login2fa') %>
</div>
<%= form_tag twofa_login_url do %>
<p>
<%= t('.logintotpcode') %>
</p>
<p>
<%= label_tag :totp_code, t('.totpcode') %>
<%= number_field_tag :totp_code, "", :size => 10, :autocomplete => "off",
:autofocus => true, :class => "totp_code" %>
<br />
</p>
<p>
<%= submit_tag t('.loginbutton') %>
</p>
<% end %>
</div>

View file

@ -73,20 +73,17 @@
<div class="boxline"> <div class="boxline">
<%= f.label :recipient_username, t('.tomsglabel'), :class => "required" %> <%= f.label :recipient_username, t('.tomsglabel'), :class => "required" %>
<%= f.text_field :recipient_username, :size => 20, <%= f.text_field :recipient_username, :size => 20 %>
:autocomplete => "off" %>
</div> </div>
<div class="boxline"> <div class="boxline">
<%= f.label :subject, t('.subject'), :class => "required" %> <%= f.label :subject, t('.subject'), :class => "required" %>
<%= f.text_field :subject, :style => "width: 500px;", <%= f.text_field :subject, :style => "width: 500px;" %>
:autocomplete => "off" %>
</div> </div>
<div class="boxline"> <div class="boxline">
<%= f.label :body, t('.message'), :class => "required" %> <%= f.label :body, t('.message'), :class => "required" %>
<%= f.text_area :body, :style => "width: 500px;", :rows => 5, <%= f.text_area :body, :style => "width: 500px;", :rows => 5 %>
:autocomplete => "off" %>
</div> </div>
<div class="boxline"> <div class="boxline">

View file

@ -69,13 +69,11 @@
<%= error_messages_for @new_message %> <%= error_messages_for @new_message %>
<div class="boxline"> <div class="boxline">
<%= f.text_field :subject, :style => "width: 500px;", <%= f.text_field :subject, :style => "width: 500px;" %>
:autocomplete => "off" %>
</div> </div>
<div class="boxline"> <div class="boxline">
<%= f.text_area :body, :style => "width: 500px;", :rows => 5, <%= f.text_area :body, :style => "width: 500px;", :rows => 5 %>
:autocomplete => "off" %>
</div> </div>
<div class="boxline"> <div class="boxline">

View file

@ -5,14 +5,14 @@
<table class="data" width="100%" cellspacing=0> <table class="data" width="100%" cellspacing=0>
<tr> <tr>
<th style="min-width: 130px;"><%= t('.datecolumn') %></th> <th style="min-width: 150px;"><%= t('.datecolumn') %></th>
<th><%= t('.moderatorcolumn') %></th> <th><%= t('.moderatorcolumn') %></th>
<th><%= t('.reasoncolumn') %></th> <th><%= t('.reasoncolumn') %></th>
</tr> </tr>
<% bit = 0 %> <% bit = 0 %>
<% @moderations.each do |mod| %> <% @moderations.each do |mod| %>
<tr class="row<%= bit %> nobottom"> <tr class="row<%= bit %> nobottom">
<td><%= l mod.created_at %></td> <td><%= mod.created_at.strftime("%Y-%m-%d %H:%M %z") %></td>
<td><% if mod.moderator %> <td><% if mod.moderator %>
<a href="/messages?to=<%= mod.moderator.try(:username) %>"><%= <a href="/messages?to=<%= mod.moderator.try(:username) %>"><%=
mod.moderator.try(:username) %></a> mod.moderator.try(:username) %></a>

View file

@ -1,6 +1,10 @@
<div class="box wide"> <div class="box wide">
<div class="legend right"> <div class="legend right">
<a href="/u/<%= @user.username %>"><%= t('.viewprofile') %></a> <a href="/u/<%= @user.username %>"><%= t('.viewprofile') %></a>
|
<%= link_to t('.logoutlink'), { :controller => "login", :action => "logout" },
:data => { :confirm => t('.confirmlogoutlink') },
:method => "post" %>
</div> </div>
<div class="legend"> <div class="legend">
<%= t('.accountsettings') %> <%= t('.accountsettings') %>
@ -14,10 +18,16 @@
<%= f.label :username, t('.username'), :class => "required" %> <%= f.label :username, t('.username'), :class => "required" %>
<%= f.text_field :username, :size => 15 %> <%= f.text_field :username, :size => 15 %>
<span class="hint"> <span class="hint">
<tt><%= User.username_regex %></tt> <tt><%= User.username_regex_s %></tt>
</span> </span>
</div> </div>
<div class="boxline">
<%= label_tag :current_password, t('.currentpassword'),
:class => "required" %>
<%= password_field_tag :current_password, nil, :size => 40 %>
</div>
<div class="boxline"> <div class="boxline">
<%= f.label :password, t('.password'), :class => "required" %> <%= f.label :password, t('.password'), :class => "required" %>
<%= f.password_field :password, :size => 40, :autocomplete => "off" %> <%= f.password_field :password, :size => 40, :autocomplete => "off" %>
@ -32,7 +42,7 @@
<div class="boxline"> <div class="boxline">
<%= f.label :email, t('.emailaddress'), :class => "required" %> <%= f.label :email, t('.emailaddress'), :class => "required" %>
<%= f.text_field :email, :size => 40 %> <%= f.email_field :email, :size => 40 %>
<span class="hint"> <span class="hint">
<%= raw(t('.gravatarized')) %> <%= raw(t('.gravatarized')) %>
</span> </span>
@ -63,18 +73,19 @@
<br> <br>
<div class="legend"> <div class="legend">
<%= t('.notificationsettings') %> <%= t('.securitysettings') %>
</div> </div>
<div class="boxline"> <div class="boxline">
<%= f.label :pushover_user_key, <%= f.label :twofa, t('.twofactorauth'), :class => "required" %>
raw(t(".pushover")), <span>
:class => "required" %> <% if @edit_user.totp_secret.present? %>
<%= link_to((f.object.pushover_user_key.present?? <span style="color: green; font-weight: bold;">
t('.managepushoversubscription') : t('.subscribewithpushover')), <%= t('.enabled2fa') %>
"/settings/pushover", :class => "pushover_button", :method => :post) %> </span> (<a href="/settings/2fa"><%= t('.disable2fa') %></a>)
<span class="hint"> <% else %>
<%= t('.foroptionalcomment') %> <%= t('.disabled2fa') %> (<a href="/settings/2fa"><%= t('.enroll2fa') %></a>)
<% end %>
</span> </span>
</div> </div>
@ -193,6 +204,10 @@
<%= f.check_box :show_avatars %> <%= f.check_box :show_avatars %>
</div> </div>
<div class="boxline">
<%= f.label :hide_dragons, t('.hidedragons'), :class => "required" %>
<%= f.check_box :hide_dragons %>
</div>
<br> <br>
<%= f.submit t('.saveallsettings') %> <%= f.submit t('.saveallsettings') %>
@ -201,6 +216,58 @@
<br> <br>
<br> <br>
<div class="legend">
External Accounts
</div>
<% if Pushover.enabled? %>
<div class="boxline">
<%= label_tag :pushover_user_key,
raw("<a href=\"https://pushover.net/\">Pushover</a>:"),
:class => "required" %>
<%= link_to((@edit_user.pushover_user_key.present??
"Manage Pushover Subscription" : "Subscribe With Pushover"),
"/settings/pushover_auth", :class => "pushover_button",
:method => :post) %>
<span class="hint">
For optional comment and message notifications above
</span>
</div>
<% end %>
<% if Github.enabled? %>
<div class="boxline">
<%= label_tag :github_username, "GitHub:", :class => "required" %>
<% if @edit_user.github_username.present? %>
Linked to
<strong><a href="https://github.com/<%= h(@edit_user.github_username)
%>"><%= h(@edit_user.github_username) %></a></strong>
(<%= link_to "Disconnect", "/settings/github_disconnect",
:method => :post %>)
<% else %>
<a href="/settings/github_auth">Connect</a>
<% end %>
</div>
<% end %>
<% if Twitter.enabled? %>
<div class="boxline">
<%= label_tag :twitter_username, "Twitter:", :class => "required" %>
<% if @edit_user.twitter_username.present? %>
Linked to
<strong><a href="https://twitter.com/<%= h(@edit_user.twitter_username)
%>">@<%= h(@edit_user.twitter_username) %></a></strong>
(<%= link_to "Disconnect", "/settings/twitter_disconnect",
:method => :post %>)
<% else %>
<a href="/settings/twitter_auth">Connect</a>
<% end %>
</div>
<% end %>
<br>
<br>
<a name="invite"></a> <a name="invite"></a>
<div class="legend"> <div class="legend">
<%= t('.inviteuser') %> <%= t('.inviteuser') %>

View file

@ -0,0 +1,30 @@
<div class="box wide">
<div class="legend right">
<a href="/settings"><%= t('.backtosettings') %></a>
</div>
<div class="legend">
<%= @title %>
</div>
<%= form_for @user, :url => twofa_auth_url, :method => :post do |f| %>
<p>
<% if @user.has_2fa? %>
<%= t('.turnoff') %>
<% else %>
<%= t('.turnon') %>
<% end %>
</p>
<div class="boxline">
<%= f.label :password, t('.currentpassword'), :class => "required" %>
<%= f.password_field :password, :size => 40, :autocomplete => "off" %>
</div>
<p>
<% if @user.has_2fa? %>
<%= submit_tag t('.disable2fa') %>
<% else %>
<%= submit_tag t('.continue') %>
<% end %>
<% end %>
</div>

View file

@ -0,0 +1,21 @@
<div class="box wide">
<div class="legend right">
<a href="/settings"><%= t('.backtosettings') %></a>
</div>
<div class="legend">
<%= @title %>
</div>
<p>
<%= raw(t('.scanqrcode')) %>
</p>
<%= raw @qr_svg %>
<p>
<%= t('.registring') %>
</p>
<p>
<%= button_to t('.verifyenable'), twofa_verify_url, :method => :get %>
</div>

View file

@ -0,0 +1,23 @@
<div class="box wide">
<div class="legend right">
<a href="/settings">Back to Settings</a>
</div>
<div class="legend">
<%= @title %>
</div>
<%= form_tag twofa_update_url do %>
<p>
<%= t('.enablecode') %>
</p>
<div class="boxline">
<%= label_tag :totp_code, "TOTP Code:", :class => "required" %>
<%= number_field_tag :totp_code, "", :size => 10, :autocomplete => "off",
:autofocus => true, :class => "totp_code" %>
</div>
<p>
<%= submit_tag t('.verifyenable') %>
<% end %>
</div>

View file

@ -3,8 +3,7 @@
Create an Account Create an Account
</div> </div>
<%= form_for @new_user, { :url => signup_path, <%= form_for @new_user, { :url => signup_path } do |f| %>
:autocomplete => "off" } do |f| %>
<%= hidden_field_tag "invitation_code", @invitation.code %> <%= hidden_field_tag "invitation_code", @invitation.code %>
<p> <p>
@ -28,7 +27,7 @@
<%= f.label :username, "Username:", :class => "required" %> <%= f.label :username, "Username:", :class => "required" %>
<%= f.text_field :username, :size => 30 %> <%= f.text_field :username, :size => 30 %>
<span class="hint"> <span class="hint">
<tt><%= User.username_regex %></tt> <tt><%= User.username_regex_s %></tt>
</span> </span>
<br /> <br />

View file

@ -38,7 +38,7 @@
<div class="boxline"> <div class="boxline">
<%= f.label :title, t('.title'), :class => "required" %> <%= f.label :title, t('.title'), :class => "required" %>
<%= f.text_field :title, :maxlength => 100, :autocomplete => "off" %> <%= f.text_field :title, :maxlength => 100 %>
</div> </div>
<% if f.object.id && !defined?(suggesting) %> <% if f.object.id && !defined?(suggesting) %>
@ -101,8 +101,7 @@
<div class="boxline"> <div class="boxline">
<%= f.label :description, t('.text'), :class => "required" %> <%= f.label :description, t('.text'), :class => "required" %>
<%= f.text_area :description, :rows => 15, <%= f.text_area :description, :rows => 15,
:placeholder => t('.placeholdertext'), :placeholder => t('.placeholdertext') %>
:autocomplete => "off" %>
</div> </div>
<div class="boxline actions markdown_help_toggler"> <div class="boxline actions markdown_help_toggler">

View file

@ -26,7 +26,8 @@ class="story <%= story.vote && story.vote[:vote] == 1 ? "upvoted" : "" %>
<% end %> <% end %>
</span> </span>
<% if story.markeddown_description.present? %> <% if story.markeddown_description.present? %>
<a class="description_present" title="<%= t '.additionaltext' %>" href="<%= story.comments_path %>">&#x2636;</a> <a class="description_present" title="<%= truncate(story.description,
:length => 500) %>" href="<%= story.comments_path %>">&#x2636;</a>
<% end %> <% end %>
<% if story.can_be_seen_by_user?(@user) %> <% if story.can_be_seen_by_user?(@user) %>
<span class="tags"> <span class="tags">
@ -62,10 +63,8 @@ class="story <%= story.vote && story.vote[:vote] == 1 ? "upvoted" : "" %>
<% end %> <% end %>
<span class="byline"> <span class="byline">
<% if (@user && @user.show_avatars?) || !@user %> <% if (@user && @user.show_avatars?) || !@user %>
<a href="/u/<%= ms.user.username %>"><img <a href="/u/<%= ms.user.username %>"><%=
src="<%= ms.user.avatar_url(16) %>" avatar_img(ms.user, 16) %></a>
srcset="<%= ms.user.avatar_url(16) %> 1x,
<%= ms.user.avatar_url(32) %> 2x" class="avatar"></a>
<% end %> <% end %>
<% if story.user_is_author? %> <% if story.user_is_author? %>
<%= t('.authoredby') %> <%= t('.authoredby') %>
@ -76,6 +75,10 @@ class="story <%= story.vote && story.vote[:vote] == 1 ? "upvoted" : "" %>
ms.html_class_for_user %>"><%= ms.user.username %></a> ms.html_class_for_user %>"><%= ms.user.username %></a>
<%= distance_of_time_in_words(ms.created_at, Time.now, :strip_about => true) %> <%= distance_of_time_in_words(ms.created_at, Time.now, :strip_about => true) %>
<% if ms.is_editable_by_user?(@user) %>
|
<a href="<%= edit_story_path(ms.short_id) %>"><%= t('.edit') %></a>
<% end %>
</span> </span>
<% end %> <% end %>
<% end %> <% end %>
@ -92,10 +95,8 @@ class="story <%= story.vote && story.vote[:vote] == 1 ? "upvoted" : "" %>
<div class="byline"> <div class="byline">
<% if (@user && @user.show_avatars?) || !@user %> <% if (@user && @user.show_avatars?) || !@user %>
<a href="/u/<%= story.user.username %>"><img <a href="/u/<%= story.user.username %>"><%=
src="<%= story.user.avatar_url(16) %>" avatar_img(story.user, 16) %></a>
srcset="<%= story.user.avatar_url(16) %> 1x,
<%= story.user.avatar_url(32) %> 2x" class="avatar"></a>
<% end %> <% end %>
<% if story.previewing %> <% if story.previewing %>
<% if story.user_is_author? %> <% if story.user_is_author? %>
@ -119,7 +120,8 @@ class="story <%= story.vote && story.vote[:vote] == 1 ? "upvoted" : "" %>
<% if story.is_editable_by_user?(@user) %> <% if story.is_editable_by_user?(@user) %>
| |
<a href="<%= edit_story_path(story.short_id) %>"><%= t('.edit') %></a> <a href="<%= edit_story_path(story.short_id) %>" class="<%=
story.has_suggestions? ? "story_has_suggestions" : "" %>"><%= t('.edit') %></a>
<% if story.is_gone? && story.is_undeletable_by_user?(@user) %> <% if story.is_gone? && story.is_undeletable_by_user?(@user) %>
| |
@ -163,7 +165,7 @@ class="story <%= story.vote && story.vote[:vote] == 1 ? "upvoted" : "" %>
<% end %> <% end %>
<% if story.url.present? %> <% if story.url.present? %>
| |
<a href="https://archive.is/<%= story.url %>" rel="nofollow" <a href="<%= story.archive_url %>" rel="nofollow"
target="_new"><%= t('.cached') %></a> target="_new"><%= t('.cached') %></a>
<% end %> <% end %>
<% if !story.is_gone? %> <% if !story.is_gone? %>

View file

@ -27,7 +27,7 @@
<div class="boxline"> <div class="boxline">
<%= f.label :moderation_reason, t('.modreason'), <%= f.label :moderation_reason, t('.modreason'),
:class => "required" %> :class => "required" %>
<%= f.text_field :moderation_reason, :autocomplete => "off" %> <%= f.text_field :moderation_reason %>
</div> </div>
<% end %> <% end %>
</div> </div>

View file

@ -45,9 +45,22 @@
<% comments_by_parent = @comments.group_by(&:parent_comment_id) %> <% comments_by_parent = @comments.group_by(&:parent_comment_id) %>
<% subtree = comments_by_parent[nil] %> <% subtree = comments_by_parent[nil] %>
<% ancestors = [] %> <% ancestors = [] %>
<% dragons = false %>
<% while subtree %> <% while subtree %>
<% if (comment = subtree.shift) %> <% if (comment = subtree.shift) %>
<% if comment.is_dragon? && !dragons %>
<div class="dragons">
<div class="dragon_text">
<a class="toggle_dragons">
<%= raw(t('.toggledragons')) %>
</a>
</div>
<div class="dragon_threads <%= @user && @user.hide_dragons? ?
"hidden" : "" %>">
<% dragons = true %>
<% end %>
<li> <li>
<%= render "comments/comment", :comment => comment, <%= render "comments/comment", :comment => comment,
:show_story => (comment.story_id != @story.id), :show_story => (comment.story_id != @story.id),
@ -64,5 +77,10 @@
</ol></li> </ol></li>
<% end %> <% end %>
<% end %> <% end %>
<% if dragons %>
</div>
</div>
<% end %>
</ol> </ol>
<% end %> <% end %>

View file

@ -9,7 +9,7 @@
<div class="boxline"> <div class="boxline">
<%= label_tag :email, t(:emailaddress), :class => "required" %> <%= label_tag :email, t(:emailaddress), :class => "required" %>
<%= text_field_tag :email, "", :size => 30, :autocomplete => "off" %> <%= email_field_tag :email, "", :size => 30, :autocomplete => "off" %>
</div> </div>
<div class="boxline"> <div class="boxline">

View file

@ -15,11 +15,10 @@
><%= user.username %></a> ><%= user.username %></a>
<% if user.is_admin? %> <% if user.is_admin? %>
<%= t('.moderator') %> <%= t('.moderator') %>
<% elsif user.is_moderator? %>
<%= t('.moderator') %>
<% else %> <% else %>
(<%= user.karma %>) (<%= user.karma %>)
<% if user.is_moderator? %>
<%= t('.administrator') %>
<% end %>
<% end %> <% end %>
</li> </li>
<% end %> <% end %>

View file

@ -17,9 +17,7 @@
<% if @showing_user.is_active? %> <% if @showing_user.is_active? %>
<div id="gravatar"> <div id="gravatar">
<img src="<%= @showing_user.avatar_url(100) %>" <%= avatar_img(@showing_user, 100) %>
srcset="<%= @showing_user.avatar_url(100) %> 1x,
<%= @showing_user.avatar_url(200) %> 2x">
</div> </div>
<% end %> <% end %>
@ -49,7 +47,7 @@
<span class="d"> <span class="d">
<%= distance_of_time_in_words(@showing_user.created_at, Time.now) %> <%= distance_of_time_in_words(@showing_user.created_at, Time.now) %>
<% if @showing_user.invited_by_user %> <% if @showing_user.invited_by_user %>
<%= t('.byinvitationfrom') %> <%= raw(t('.byinvitationfrom', :user => @showing_user.username)) %>
<%= link_to @showing_user.invited_by_user.try(:username), <%= link_to @showing_user.invited_by_user.try(:username),
@showing_user.invited_by_user %> @showing_user.invited_by_user %>
<% end %> <% end %>
@ -69,16 +67,6 @@
<br> <br>
<% end %> <% end %>
<% if @showing_user.hats.any? %>
<label class="required"><%= t('.hats') %></label>
<span class="d">
<% @showing_user.hats.each do |hat| %>
<%= hat.to_html_label %>
<% end %>
</span>
<br>
<% end %>
<% if @showing_user.deleted_at? %> <% if @showing_user.deleted_at? %>
<label class="required"><% t('.left') %></label> <label class="required"><% t('.left') %></label>
<span class="d"> <span class="d">
@ -117,6 +105,38 @@
</span> </span>
<br> <br>
<% if @showing_user.hats.any? %>
<label class="required"><%= t('.hats') %></label>
<div class="d">
<% @showing_user.hats.each do |hat| %>
<%= hat.to_html_label %>
<% end %>
</div>
<div style="clear: both;"></div>
<% end %>
<% if @showing_user.github_username.present? %>
<label class="required">GitHub:</label>
<span class="d">
<a href="https://github.com/<%= h(@showing_user.github_username) %>"
rel="nofollow">https://github.com/<%= h(@showing_user.github_username)
%></a>
</span>
<br>
<% end %>
<% if @showing_user.twitter_username.present? %>
<label class="required">Twitter:</label>
<span class="d">
<a href="https://twitter.com/<%= h(@showing_user.twitter_username) %>"
rel="nofollow">@<%= h(@showing_user.twitter_username) %></a>
</span>
<br>
<% end %>
<% if @showing_user.is_active? %> <% if @showing_user.is_active? %>
<label class="required"><%= t('.about') %></label> <label class="required"><%= t('.about') %></label>

View file

@ -3,21 +3,23 @@
<strong><%= @title %> (<%= @user_count %>)</strong> <strong><%= @title %> (<%= @user_count %>)</strong>
</p> </p>
<p> <% if @newest %>
<%= t('.newestusers') %> <p>
<%= raw @newest.map{|u| "<a href=\"/u/#{u.username}\" class=\"" << <%= t('.newestusers') %>
(u.is_new?? "new_user" : "") << "\">#{u.username}</a> " << <%= raw @newest.map{|u| "<a href=\"##{u.username}\" class=\"" <<
"(#{u.karma})" }.join(", ") %> (u.is_new?? "new_user" : "") << "\">#{u.username}</a> " <<
</p> "(#{u.karma})" }.join(", ") %>
</p>
<% end %>
<ul class="tree user_tree"> <ul class="tree user_tree noparent">
<% subtree = @users_by_parent[nil] %> <% subtree = @users_by_parent[nil] %>
<% ancestors = [] %> <% ancestors = [] %>
<% while subtree %> <% while subtree %>
<% if (user = subtree.pop) %> <% if (user = subtree.pop) %>
<li> <li class="<%= user.invited_by_user_id ? "" : "noparent" %>">
<a href="/u/<%= user.username %>" <a href="/u/<%= user.username %>"
<% if !user.is_active? %> <% if !user.is_active? %>
class="inactive_user" class="inactive_user"
@ -27,11 +29,10 @@
><%= user.username %></a> ><%= user.username %></a>
<% if user.is_admin? %> <% if user.is_admin? %>
<%= t('.administrator') %> <%= t('.administrator') %>
<% elsif user.is_moderator? %>
<%= t('.moderator') %>
<% else %> <% else %>
(<%= user.karma %>) (<%= user.karma %>)
<% if user.is_moderator? %>
<%= t('.moderator') %>(moderator)
<% end %>
<% end %> <% end %>
<% if (children = @users_by_parent[user.id]) %> <% if (children = @users_by_parent[user.id]) %>
<% # drill down deeper in the tree %> <% # drill down deeper in the tree %>

View file

@ -26,7 +26,15 @@ module Lobsters
# Raise an exception when using mass assignment with unpermitted attributes # Raise an exception when using mass assignment with unpermitted attributes
config.action_controller.action_on_unpermitted_parameters = :raise config.action_controller.action_on_unpermitted_parameters = :raise
config.active_record.raise_in_transactional_callbacks = true
config.cache_store = :file_store, "#{config.root}/tmp/cache/" config.cache_store = :file_store, "#{config.root}/tmp/cache/"
config.exceptions_app = self.routes
config.after_initialize do
require "#{Rails.root}/lib/monkey.rb"
end
end end
end end
@ -68,5 +76,3 @@ class << Rails.application
true true
end end
end end
require "#{Rails.root}/lib/monkey"

View file

@ -20,7 +20,7 @@ Lobsters::Application.configure do
# config.action_dispatch.rack_cache = true # config.action_dispatch.rack_cache = true
# Disable Rails's static asset server (Apache or nginx will already do this). # Disable Rails's static asset server (Apache or nginx will already do this).
config.serve_static_assets = false config.serve_static_files = false
# Compress JavaScripts and CSS. # Compress JavaScripts and CSS.
config.assets.js_compressor = :uglifier config.assets.js_compressor = :uglifier
@ -76,3 +76,7 @@ Lobsters::Application.configure do
# Do not dump schema after migrations. # Do not dump schema after migrations.
config.active_record.dump_schema_after_migration = false config.active_record.dump_schema_after_migration = false
end end
%w{render_template render_partial render_collection}.each do |event|
ActiveSupport::Notifications.unsubscribe "#{event}.action_view"
end

View file

@ -13,7 +13,7 @@ Lobsters::Application.configure do
config.eager_load = false config.eager_load = false
# Configure static asset server for tests with Cache-Control for performance. # Configure static asset server for tests with Cache-Control for performance.
config.serve_static_assets = true config.serve_static_files = true
config.static_cache_control = 'public, max-age=3600' config.static_cache_control = 'public, max-age=3600'
# Show full error reports and disable caching. # Show full error reports and disable caching.
@ -35,5 +35,7 @@ Lobsters::Application.configure do
config.active_support.deprecation = :stderr config.active_support.deprecation = :stderr
# Raises error for missing translations # Raises error for missing translations
# config.action_view.raise_on_missing_translations = true config.action_view.raise_on_missing_translations = true
end end
RSpec::Expectations.configuration.on_potential_false_positives = :nothing

View file

@ -22,6 +22,9 @@ en:
storyidcannotbeblank: "A story ID cannot be blank." storyidcannotbeblank: "A story ID cannot be blank."
deletedcomment: "deleted comment" deletedcomment: "deleted comment"
threadremovedby: "Thread removed by moderator %{modoname} : %{modreason}" threadremovedby: "Thread removed by moderator %{modoname} : %{modreason}"
turnedintodragon: "turned into a dragon"
slayeddragon: "slayed dragon"
metooerror: "Please just upvote the parent post instead."
moderation: moderation:
storyeditedby: "Your story has been edited by " storyeditedby: "Your story has been edited by "
usersuggestions: "user suggestions" usersuggestions: "user suggestions"
@ -61,11 +64,11 @@ en:
messageslink: "Messages" messageslink: "Messages"
loginlink: "Login" loginlink: "Login"
logoutlink: "Logout" logoutlink: "Logout"
confirmlogoutlink: "Are you sure you want to logout?"
moderationloglink: "Moderation Log" moderationloglink: "Moderation Log"
invitationqueuelink: "Invitation Queue" invitationqueuelink: "Invitation Queue"
chatlink: "Chat" chatlink: "Chat"
hatrequestlink: "Hat Requests" hatrequestlink: "Hat Requests"
hatslink: "Hats"
privacylink: "Privacy" privacylink: "Privacy"
aboutlink: "About" aboutlink: "About"
blog: "Blog" blog: "Blog"
@ -102,6 +105,8 @@ en:
delete: "delete" delete: "delete"
reply: "reply" reply: "reply"
about: "on:" about: "on:"
dragon: "dragon"
undragon: "undragon"
email_message: email_message:
notification.text: notification.text:
replyat: "Reply at " replyat: "Reply at "
@ -120,6 +125,41 @@ en:
pretextdesc: "prefix text with at least<tt>&nbsp;&nbsp;&nbsp;&nbsp;4 spaces</tt>" pretextdesc: "prefix text with at least<tt>&nbsp;&nbsp;&nbsp;&nbsp;4 spaces</tt>"
inlineimage: "(inline image)" inlineimage: "(inline image)"
inlineimagedesc: "![alt text](http://example.com/image.jpg)</tt> (only allowed in story text)" inlineimagedesc: "![alt text](http://example.com/image.jpg)</tt> (only allowed in story text)"
hats:
build_request:
title: "Request a Hat"
description: "<p>A hat is a formal, verified, way of posting a comment while speaking for a project, organization, or company. Each user may have multiple hats, one of which may be worn at any time when posting a comment or sending a private message.</p><p>Hats are intended for core project developers and company employees that are authorized to make comments on behalf of those organizations, and are usually reserved for notable projects and organizations. Hats are not intended to list every contribution a person has made to any software project.</p><p>Hats will not be authorized for occasional contributors to projects, developers of projects which are not widely known, past employees of companies, or other situations where it cannot be verified that one is authorized to represent that organization. In general, unless one has an e-mail address at a compay domain or have commit access to a project, a hat will not be authorized.</p><p>To request a hat for your account, provide a short description of the hat (e.g., \"OpenBSD Developer\"), a public link that will be shown when hovering over the hat that users can see, such as your e-mail address at that project or company, or a link to a company website showing your employment, and private comments that will be seen only by moderators during approval.</p><p>Once your hat is requested, a moderator will verify your request by e-mailing the address you submitted as the link, or doing some other manual verification of project association.</p>"
hat: "Hat:"
hatplaceholder: "XYZ Project Member"
link: "Link:"
linkplaceholder: "user@project.org, or a URL to an employment page"
comment: "Comment:"
commentplaceholder: "Will only be shown to moderators during approval"
requesthatbutton: "Request Hat"
create_request:
submittedhatrequest: "Successfully submitted hat request."
index:
title: "Hats"
description: "A hat is a formal, verified, way of posting a comment while speaking for a project, organization, or company. Each user may have multiple hats, one of which may be selected to be worn when posting a comment or sending a private message."
request: "Request Hat"
user: "User"
hat: "Hat"
link: "Link"
reject_request:
requestedhatrequest: "Successfully rejected hat request."
requests_index:
title: "Requested Hats"
nohatrequests: "No hat requests."
user: "User:"
hat: "Hat:"
link: "Link:"
comment: "Comment:"
approve: Approve Hat Request"
reason: "Reason:"
reject: "Reject Hat Request"
hator: "or"
approve_request:
approvedhatrequest: "Successfully approved hat request."
home: home:
index: index:
homerecentsdesc: "<em>The <a href=\"/newest\">newest</a> stories with a random sampling of recently submitted stories that have not yet reached the front page.</em>" homerecentsdesc: "<em>The <a href=\"/newest\">newest</a> stories with a random sampling of recently submitted stories that have not yet reached the front page.</em>"
@ -180,6 +220,16 @@ en:
password: "New Password:" password: "New Password:"
again: "(Again):" again: "(Again):"
setpassbutton: "Set New Password" setpassbutton: "Set New Password"
passwordreset: "Your password has been reset."
couldnotresetpassword: "Could not reset password."
invalidresettoken: "Invalid reset token. It may have already been used or you may have copied it incorrectly."
twofa:
login2fa: "Login - Two Factor Authentication"
logintotpcode: "Enter the current TOTP code from your TOTP application:"
loginbutton: "Login"
totpcode: "TOTP Code:"
twofa_verify:
totpcodenotmatch: "Your TOTP code did not match. Please try again."
messages: messages:
index: index:
viewreceived: "View Received" viewreceived: "View Received"
@ -245,10 +295,13 @@ en:
deleteaccountflash: "Your account has been deleted." deleteaccountflash: "Your account has been deleted."
verifypasswordflash: "Your password could not be verified." verifypasswordflash: "Your password could not be verified."
index: index:
logoutlink: "Logout"
confirmlogoutlink: "Are you sure you want to logout?"
viewprofile: "View Profile" viewprofile: "View Profile"
accountsettings: "Account Settings" accountsettings: "Account Settings"
username: "Username:" username: "Username:"
password: "New Password:" password: "New Password:"
currentpassword: "Current Password:"
confirmpassword: "Confirm Password:" confirmpassword: "Confirm Password:"
emailaddress: "E-mail Address:" emailaddress: "E-mail Address:"
gravatarized: "<a href=\"http://www.gravatar.com/\" target=\"_blank\">Gravatar</a>'ized" gravatarized: "<a href=\"http://www.gravatar.com/\" target=\"_blank\">Gravatar</a>'ized"
@ -263,7 +316,7 @@ en:
commentreplynotificationsettings: "Comment Reply Notification Settings" commentreplynotificationsettings: "Comment Reply Notification Settings"
receiveemail: "Receive E-mail:" receiveemail: "Receive E-mail:"
receivepushover: "Receive Pushover Alert:" receivepushover: "Receive Pushover Alert:"
requirepushover: "Requires Pushover subscription above" requirepushover: "Requires Pushover subscription below"
commentmentionnotificationsettings: "Comment Mention Notification Settings" commentmentionnotificationsettings: "Comment Mention Notification Settings"
privatemessagenotificationsettings: "Private Message Notification Settings" privatemessagenotificationsettings: "Private Message Notification Settings"
submittedstorycommentsettings: "Submitted Story Comment Settings" submittedstorycommentsettings: "Submitted Story Comment Settings"
@ -278,6 +331,7 @@ en:
miscsettings: "Miscellaneous Settings" miscsettings: "Miscellaneous Settings"
storypreview: "Show Story Previews:" storypreview: "Show Story Previews:"
useravatars: "Show User Avatars:" useravatars: "Show User Avatars:"
hidedragons: "Hide Dragons:"
saveallsettings: "Save All Settings" saveallsettings: "Save All Settings"
inviteuser: "Invite a New User" inviteuser: "Invite a New User"
cannotsendinvitations: "You cannot send invitations." cannotsendinvitations: "You cannot send invitations."
@ -285,6 +339,12 @@ en:
deleteaccounttext: "To permanently delete your account, verify your current password below. Your account will be put into a deleted state, your comments will be marked as deleted and no longer readable by any other users, and your private messages will be deleted. Your submitted stories will not be deleted. Your username will remain reserved and will not be available to be used on any other account." deleteaccounttext: "To permanently delete your account, verify your current password below. Your account will be put into a deleted state, your comments will be marked as deleted and no longer readable by any other users, and your private messages will be deleted. Your submitted stories will not be deleted. Your username will remain reserved and will not be available to be used on any other account."
verifypassword: "Verify Password:" verifypassword: "Verify Password:"
deleteaccountconfirmation: "Yes, Delete My Account" deleteaccountconfirmation: "Yes, Delete My Account"
securitysettings: "Security Settings"
twofactorauth: "Two-Factor Auth:"
disable2fa: "Disable"
enroll2fa: "Enroll"
disabled2fa: "Disabled"
enabled2fa: "Enabled"
pushover: pushover:
pushovernotconfigured: "This site is not configured for Pushover" pushovernotconfigured: "This site is not configured for Pushover"
pushover_callback: pushover_callback:
@ -292,8 +352,36 @@ en:
pushovernorandomtokenurl: "No random token present in URL" pushovernorandomtokenurl: "No random token present in URL"
accountsetuppushover: "Your account is now setup for Pushover notifications." accountsetuppushover: "Your account is now setup for Pushover notifications."
accountnolongersetuppushover: "Your account is no longer setup for Pushover notifications." accountnolongersetuppushover: "Your account is no longer setup for Pushover notifications."
twofa:
title: "Two-Factor Authentication"
backtosettings: "Back to Settings"
disable2fa: "Disable Two-Factor Authentication"
continue: "Continue"
currentpassword: "Current Password:"
turnoff: "To turn off two-factor authentication for your account, enter your current password:"
turnon: "To begin the two-factor authentication enrollment for your account, enter your current password:"
twofa_auth:
2fahasbeendisabled: "Two-Factor Authentication has been disabled on your account."
2fapassnotcorrect: "Your password was not correct."
twofa_enroll:
title: "Two-Factor Authentication"
backtosettings: "Back to Settings"
enrollmenttimeout: "Your enrollment period timed out."
scanqrcode: "Scan the QR code below or click on it to open in your <a href=\"https://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm\" target=\"_blank\">TOTP</a> application of choice:"
registring: "Once you have finished registering with your TOTP application, proceed to the next screen to verify your current TOTP code and actually enable Two-Factor Authentication on your account."
verifyenable: "Verify and Enable"
twofa_update:
enrollmenttimeout: "Your enrollment period timed out."
2fahasbeenenabled: "Two-Factor Authentication has been enabled on your account."
totpinvalid: "Your TOTP code was invalid, please verify the current code in your TOTP application."
twofa_verify:
title: "Two-Factor Authentication"
enrollmenttimeout: "Your enrollment period timed out."
enablecode: "To enable Two-Factor Authentication on your account using your new TOTP secret, enter the six-digit code from your TOTP application:"
verifyenable: "Verify and Enable"
update: update:
updatesettingsflash: "Successfully updated settings." updatesettingsflash: "Successfully updated settings."
passwordnotcorrect: "Your current password was not entered correctly."
stories: stories:
edit: edit:
edit: "Edit Story" edit: "Edit Story"
@ -355,6 +443,8 @@ en:
preview: "Preview" preview: "Preview"
submit: "Submit a Story" submit: "Submit a Story"
submitbutton: "Submit" submitbutton: "Submit"
show:
toggledragons: "&mdash; here be dragons &mdash;"
users: users:
list: list:
administrator: "administrator" administrator: "administrator"
@ -372,7 +462,7 @@ en:
storysubmissions: "with story submissions" storysubmissions: "with story submissions"
disabled: "disabled" disabled: "disabled"
joined: "Joined:" joined: "Joined:"
byinvitationfrom: "by invitation from" byinvitationfrom: "by <a href=\"/u/%{user}\">invitation</a> from"
banneduser: "Banned:" banneduser: "Banned:"
bannedby: "by" bannedby: "by"
hats: "Hats:" hats: "Hats:"
@ -439,6 +529,9 @@ en:
flashsuccessdeleteinvit: "Successfully deleted invitation request from %{name}" flashsuccessdeleteinvit: "Successfully deleted invitation request from %{name}"
login_controller: login_controller:
flashlogininvalid: "Invalid e-mail address and/or password." flashlogininvalid: "Invalid e-mail address and/or password."
totpinvalid: "Your TOTP code was invalid."
deletedaccount: "Your account has been deleted."
bannedaccount: "Your account has been banned."
messages_controller: messages_controller:
messagestitle: "Messages" messagestitle: "Messages"
messagessenttitle: "Messages Sent" messagessenttitle: "Messages Sent"
@ -449,6 +542,7 @@ en:
flashdeletedmessage: "Deleted message." flashdeletedmessage: "Deleted message."
search_controller: search_controller:
searchtitle: "Search" searchtitle: "Search"
flasherrorsearchcontroller: "Sorry, but the search engine is currently out of order"
stories_controller: stories_controller:
submitstorytitle: "Submit Story" submitstorytitle: "Submit Story"
editstorytitle: "Edit Story" editstorytitle: "Edit Story"

View file

@ -34,6 +34,9 @@ fr:
storyidcannotbeblank: "Un ID d'info ne peut pas être vide." storyidcannotbeblank: "Un ID d'info ne peut pas être vide."
deletedcomment: "commentaire supprimé" deletedcomment: "commentaire supprimé"
threadremovedby: "Fil supprimé par le modérateur %{modoname} : %{modreason}" threadremovedby: "Fil supprimé par le modérateur %{modoname} : %{modreason}"
turnedintodragon: "transformé en dragon"
slayeddragon: "le dragon a été terrassé"
metooerror: "Merci de voter pour le commentaire à la place."
moderation: moderation:
storyeditedby: "Votre info a été éditée par " storyeditedby: "Votre info a été éditée par "
usersuggestions: "suggestions d'utilisateur" usersuggestions: "suggestions d'utilisateur"
@ -78,6 +81,7 @@ fr:
invitationqueuelink: "File d'invitation" invitationqueuelink: "File d'invitation"
chatlink: "Chat" chatlink: "Chat"
hatrequestlink: "Porter le chapeau" hatrequestlink: "Porter le chapeau"
hatslink: "Chapeaux"
privacylink: "Confidentialité" privacylink: "Confidentialité"
aboutlink: "À propos" aboutlink: "À propos"
blog: "Blog" blog: "Blog"
@ -104,6 +108,8 @@ fr:
delete: "supprimer" delete: "supprimer"
reply: "répondre" reply: "répondre"
about: "sur :" about: "sur :"
dragon: "dragon"
undragon: "non-dragon"
global: global:
markdownhelp: markdownhelp:
emphasizedtext: "italique" emphasizedtext: "italique"
@ -120,6 +126,41 @@ fr:
pretextdesc: "précéder le texte avec au moins <tt>&nbsp;&nbsp;&nbsp;&nbsp;4 espaces</tt>" pretextdesc: "précéder le texte avec au moins <tt>&nbsp;&nbsp;&nbsp;&nbsp;4 espaces</tt>"
inlineimage: "(image en ligne)" inlineimage: "(image en ligne)"
inlineimagedesc: "![texte de substitution](http://example.com/image.jpg)</tt> (autorisé seulement dans les articles)" inlineimagedesc: "![texte de substitution](http://example.com/image.jpg)</tt> (autorisé seulement dans les articles)"
hats:
build_request:
title: "Demander un chapeau"
description: "<p>Un chapeau est un moyen formel, vérifié de poster un commentaire au nom d'un projet, d'une organisation ou d'une entreprise. Chaque utilisateur peut avoir plusieurs chapeaux, l'un d'eux pouvant être choisi d'être porté au moment de poster un commentaire ou d'envoyer un message privé.</p><p>Les chapeaux sont destinés aux développeurs principaux d'un projet et les employés d'entreprise qui sont autorisés à faire des commentaires au nom de leurs organisations, et sont habituellement réservés pour les projets notables et les organisations. Les chapeaux ne sont pas destinés à lister toutes les contributions d'une personne à des projets logiciels.</p><p>Les chapeaux ne vont pas être autorisés pour les contributeurs occasionnels d'un projet, développeurs d'un projet qui n'est pas largement connu, pas non plus pour les anciens employés d'une entreprise ou autres situations où il n'est pas possible de vérifier que quelqu'un est autorisé à représenter une organisation. En général à moins d'avoir un e-mail avec le nom de domaine de l'entreprise ou du projet ou les droits d'accès aux sources d'un projet, un chapeau ne sera pas autorisé.</p><p>Pour demander un chapeau pour votre compte, merci de fournir une courte description du chapeau (exemple \"développeur OpenBSD\"), un lien public qui sera affiché quand on passe la souris au-dessus du chapeau que les utilisateurs peuvent voir, comme votre adresse e-mail du projet ou de l'entreprise ou un lien vers le site web de l'entreprise puis une description privée qui sera seulement vue par les modérateurs durant le processus d'approbation.</p><p>Une fois qu'un chapeau a été demandé, un modérateur va vérifier votre demande en envoyant un e-mail à l'adresse soumise comme lien et/ou en effectuant d'autres vérifications manuelles de votre association au projet.</p>"
hat: "Chapeau:"
hatplaceholder: "Membre du projet XYZ"
link: "Lien :"
linkplaceholder: "membre@projet.org, ou l'URL de la page de l'employeur"
comment: "Commentaire :"
commentplaceholder: "Sera seulement montré aux modérateurs devant le processus d'approbation"
requesthatbutton: "Demander un chapeau"
create_request:
submittedhatrequest: "Demande de chapeau transmise avec succès."
index:
title: "Chapeaux"
description: "Un chapeau est un moyen formel, vérifié de poster un commentaire au nom d'un projet, d'une organisation ou d'une entreprise. Chaque utilisateur peut avoir plusieurs chapeaux, l'un d'eux pouvant être choisi d'être porté au moment de poster un commentaire ou d'envoyer un message privé."
request: "Demander un chapeau"
user: "Utilisateur"
hat: "Chapeau"
link: "Lien"
reject_request:
rejectedhatrequest: "Demande de chapeau rejetée avec succès."
requests_index:
title: "Chapeaux demandés"
nohatrequests: "Aucune demande de chapeau."
user: "Utilisateur :"
hat: "Chapeau :"
link: "Lien :"
comment: "Commentaire :"
approve: "Approuver la demande de chapeau"
reason: "Raison :"
reject: "Rejeter la demande de chapeau"
hator: "ou"
approve_request:
approvedhatrequest: "La demande de chapeau a été approuvée."
home: home:
index: index:
homerecentsdesc: "<em>Les <a href=\"/newest\">plus récentes</a> infos avec un panaché aléatoire des infos récentes soumises qui n'ont pas encore atteint la page principale.</em>" homerecentsdesc: "<em>Les <a href=\"/newest\">plus récentes</a> infos avec un panaché aléatoire des infos récentes soumises qui n'ont pas encore atteint la page principale.</em>"
@ -180,6 +221,16 @@ fr:
password: "Mot de passe :" password: "Mot de passe :"
again: "(encore):" again: "(encore):"
setpassbutton: "Changer le mot de passe" setpassbutton: "Changer le mot de passe"
passwordreset: "Votre mot de passe a été changé"
couldnotresetpassword: "Le mot de passe n'a pas pu être changé."
invalidresettoken: "Jeton de changement invalide. Il a pu déjà être utilisé ou mal copié."
twofa:
login2fa: "Identification par authentification à deux facteurs"
logintotpcode: "Entrez le code TOTP affiché par votre application :"
loginbutton: "S'identifier"
totpcode: "Code TOTP :"
twofa_verify:
totpcodenotmatch: "Code TOTP incorrect. Merci de ré-essayer."
filters: filters:
index: index:
filteredtags: "Marques filtrées" filteredtags: "Marques filtrées"
@ -255,10 +306,13 @@ fr:
deleteaccountflash: "Votre compte a été supprimé." deleteaccountflash: "Votre compte a été supprimé."
verifypasswordflash: "Votre mot de passe n'a pas pu être vérifié." verifypasswordflash: "Votre mot de passe n'a pas pu être vérifié."
index: index:
logoutlink: "Se déconnecter"
confirmlogoutlink: "Êtes-vous sûr de vouloir vous déconnecter?"
viewprofile: "Voir le profil" viewprofile: "Voir le profil"
accountsettings: "Paramètres du compte" accountsettings: "Paramètres du compte"
username: "Utilisateur :" username: "Utilisateur :"
password: "Nouveau mot de passe :" password: "Nouveau mot de passe :"
currentpassword: "Mot de passe actuel :"
confirmpassword: "Confirmer le mot de passe :" confirmpassword: "Confirmer le mot de passe :"
emailaddress: "Adresse e-mail :" emailaddress: "Adresse e-mail :"
gravatarized: "<a href=\"http://www.gravatar.com/\" target=\"_blank\">Gravatar</a>isé" gravatarized: "<a href=\"http://www.gravatar.com/\" target=\"_blank\">Gravatar</a>isé"
@ -273,7 +327,7 @@ fr:
commentreplynotificationsettings: "Paramètres de notification de réponse à un commentaire" commentreplynotificationsettings: "Paramètres de notification de réponse à un commentaire"
receiveemail: "Recevoir un e-mail :" receiveemail: "Recevoir un e-mail :"
receivepushover: "Recevoir une alerte Pushover :" receivepushover: "Recevoir une alerte Pushover :"
requirepushover: "Requière un abonnement Pushover ci-dessus" requirepushover: "Requière un abonnement Pushover ci-dessous"
commentmentionnotificationsettings: "Paramètres de notification de mention d'un commentaire" commentmentionnotificationsettings: "Paramètres de notification de mention d'un commentaire"
privatemessagenotificationsettings: "Paramètres de notification de message privé" privatemessagenotificationsettings: "Paramètres de notification de message privé"
submittedstorycommentsettings: "Paramètres de commentaires relatifs à vos infos" submittedstorycommentsettings: "Paramètres de commentaires relatifs à vos infos"
@ -288,6 +342,7 @@ fr:
miscsettings: "Paramètres variés" miscsettings: "Paramètres variés"
storypreview: "Montrer les aperçus des infos: " storypreview: "Montrer les aperçus des infos: "
useravatars: "Montrer les avatars des utilisateurs :" useravatars: "Montrer les avatars des utilisateurs :"
hidedragons: "Cacher les dragons :"
saveallsettings: "Sauver tous les paramètres" saveallsettings: "Sauver tous les paramètres"
inviteuser: "Inviter un nouvel utilisateur" inviteuser: "Inviter un nouvel utilisateur"
cannotsendinvitations: "Vous ne pouvez pas envoyer d'invitations." cannotsendinvitations: "Vous ne pouvez pas envoyer d'invitations."
@ -295,6 +350,12 @@ fr:
deleteaccounttext: "Pour supprimer définitivement votre compte, vérifiez votre mot de passe actuel ci-dessous. Votre compte sera passé à l'état supprimé, vos commentaires seront marqués comme supprimés et ne seront plus lisibles par les autres utilisateurs, vos messages privés seront également supprimés. Vos infos proposées ne seront pas supprimées. Votre nom d'utilisateur restera réservé et ne sera pas disponible pour un autre compte." deleteaccounttext: "Pour supprimer définitivement votre compte, vérifiez votre mot de passe actuel ci-dessous. Votre compte sera passé à l'état supprimé, vos commentaires seront marqués comme supprimés et ne seront plus lisibles par les autres utilisateurs, vos messages privés seront également supprimés. Vos infos proposées ne seront pas supprimées. Votre nom d'utilisateur restera réservé et ne sera pas disponible pour un autre compte."
verifypassword: "Vérification du mot de passe :" verifypassword: "Vérification du mot de passe :"
deleteaccountconfirmation: "Oui, supprimez mon compte" deleteaccountconfirmation: "Oui, supprimez mon compte"
securitysettings: "Paramètres de sécurité"
twofactorauth: "Authentification à deux facteurs :"
disable2fa: "Désactiver"
enroll2fa: "Activer"
disabled2fa: "Désactivée"
enabled2fa: "Activée"
pushover: pushover:
pushovernotconfigured: "Ce site n'est pas configuré pour le Pushover" pushovernotconfigured: "Ce site n'est pas configuré pour le Pushover"
pushover_callback: pushover_callback:
@ -302,8 +363,36 @@ fr:
pushovernorandomtokenurl: "Pas de jeton alétoire présent dans l'url" pushovernorandomtokenurl: "Pas de jeton alétoire présent dans l'url"
accountsetuppushover: "Votre compte est maintenant configuré pour les notifications Pushover." accountsetuppushover: "Votre compte est maintenant configuré pour les notifications Pushover."
accountnolongersetuppushover: "Votre compte n'est plus configuré pour les notifications Pushover." accountnolongersetuppushover: "Votre compte n'est plus configuré pour les notifications Pushover."
twofa:
title: "Authentification à deux facteurs"
backtosettings: "Retour aux paramètres"
disable2fa: "Désactiver l'authentification à deux facteurs"
continue: "Continuer"
currentpassword: "Mot de passe actuel :"
turnoff: "Pour désactiver l'authentification à deux facteurs, entrez votre mot de passe actuel :"
turnon: "Pour activer l'authentification à deux facteurs pour votre compte, entrez votre mot de passe actuel :"
twofa_auth:
2fahasbeendisabled: "L'authentification à deux facteurs a été désactivée sur votre compte"
2fapassnotcorrect: "Mot de passe incorrect"
twofa_enroll:
title: "Authentification à deux facteurs"
backtosettings: "Retour aux paramètres"
enrollmenttimeout: "Le délai d'attente est dépassé"
scanqrcode: "Scannez le QR code ci-dessous ou cliquez dessus pour l'ouvrir dans l'application <a href=\"https://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm\" target=\"_blank\">TOTP</a> de votre choix :"
registring: "Une fois le processus d'enregistrement de vos applications TOTP achevé, passez à l'écran suivant pour vérifier et activer l'authentification à deux facteurs pour votre compte."
verifyenable: "Vérifier et Activer"
twofa_update:
enrollmenttimeout: "Le délai d'attente est dépassé"
2fahasbeenenabled: "L'authentification à deux facteurs a été activée sur votre compte"
totpinvalid: "Votre code TOTP est invalide, merci de vérifier le code actuellement affiché par votre application TOTP"
twofa_verify:
title: "Authentification à deux facteurs"
enrollmenttimeout: "Le délai d'attente est dépassé"
enablecode: "Afin d'activer l'authentification à deux facteurs pour votre compte en utilisant votre nouveau secret TOTP, entrez le code à 6 chiffres de votre application :"
verifyenable: "Vérifier et Activer"
update: update:
updatesettingsflash: "Paramètres mis à jour avec succès." updatesettingsflash: "Paramètres mis à jour avec succès."
passwordnotcorrect: "Le mot de passe courant n'a pas été entré correctement."
stories: stories:
edit: edit:
edit: "Éditer l'info" edit: "Éditer l'info"
@ -365,6 +454,8 @@ fr:
preview: "Aperçu" preview: "Aperçu"
submit: "Soumettre une info" submit: "Soumettre une info"
submitbutton: "Soumettre" submitbutton: "Soumettre"
show:
toggledragons: "&mdash; voici les dragons &mdash;"
users: users:
list: list:
administrator: "administrateur" administrator: "administrateur"
@ -382,7 +473,7 @@ fr:
storysubmissions: "avec propositions d'infos" storysubmissions: "avec propositions d'infos"
disabled: "désactivé" disabled: "désactivé"
joined: "Inscrit :" joined: "Inscrit :"
byinvitationfrom: "par invitation de" byinvitationfrom: "par <a href=\"/u/%{user}\">invitation</a> de"
banneduser: "Banni :" banneduser: "Banni :"
bannedby: "par" bannedby: "par"
hats: "Chapeaux :" hats: "Chapeaux :"
@ -449,6 +540,10 @@ fr:
flashsuccessdeleteinvit: "Demande d'invitation de %{name} supprimée avec succès" flashsuccessdeleteinvit: "Demande d'invitation de %{name} supprimée avec succès"
login_controller: login_controller:
flashlogininvalid: "Adresse e-mail et/ou mot de passe invalide." flashlogininvalid: "Adresse e-mail et/ou mot de passe invalide."
flashlogininvalid: "Adresse e-mail et/ou mot de passe invalide."
totpinvalid: "Votre code TOTP est invalide."
deletedaccount: "Votre compte a été supprimé."
bannedaccount: "Votre compte a été banni."
messages_controller: messages_controller:
messagestitle: "Messages" messagestitle: "Messages"
messagessenttitle: "Messages envoyés" messagessenttitle: "Messages envoyés"
@ -459,6 +554,7 @@ fr:
flashdeletedmessage: "Message effacé." flashdeletedmessage: "Message effacé."
search_controller: search_controller:
searchtitle: "Rechercher" searchtitle: "Rechercher"
flasherrorsearchcontroller: "Désolé mais le moteur de recherche est actuellement cassé"
stories_controller: stories_controller:
submitstorytitle: "Soumettre une info" submitstorytitle: "Soumettre une info"
editstorytitle: "Éditer une info" editstorytitle: "Éditer une info"

View file

@ -4,6 +4,8 @@ Lobsters::Application.routes.draw do
:protocol => (Rails.application.config.force_ssl ? "https://" : "http://"), :protocol => (Rails.application.config.force_ssl ? "https://" : "http://"),
:as => "root" :as => "root"
get "/404" => "home#four_oh_four", :via => :all
get "/rss" => "home#index", :format => "rss" get "/rss" => "home#index", :format => "rss"
get "/hottest" => "home#index", :format => "json" get "/hottest" => "home#index", :format => "json"
@ -30,8 +32,10 @@ Lobsters::Application.routes.draw do
get "/threads/:user" => "comments#threads" get "/threads/:user" => "comments#threads"
get "/login" => "login#index" get "/login" => "login#index"
post "/login" => "login#login" post "/login" => "login#login", :format => /html|json/
post "/logout" => "login#logout" post "/logout" => "login#logout"
get "/login/2fa" => "login#twofa"
post "/login/2fa_verify" => "login#twofa_verify", :as => "twofa_login"
get "/signup" => "signup#index" get "/signup" => "signup#index"
post "/signup" => "signup#signup" post "/signup" => "signup#signup"
@ -72,6 +76,9 @@ Lobsters::Application.routes.draw do
post "delete" post "delete"
post "undelete" post "undelete"
post "dragon"
post "undragon"
end end
end end
get "/comments/page/:page" => "comments#index" get "/comments/page/:page" => "comments#index"
@ -84,12 +91,14 @@ Lobsters::Application.routes.draw do
post "keep_as_new" post "keep_as_new"
end end
get "/s/:id/:title/comments/:comment_short_id" => "stories#show"
get "/s/:id/(:title)" => "stories#show", :format => /html|json/
get "/c/:id" => "comments#redirect_from_short_id" get "/c/:id" => "comments#redirect_from_short_id"
get "/c/:id.json" => "comments#show_short_id", :format => "json" get "/c/:id.json" => "comments#show_short_id", :format => "json"
# deprecated
get "/s/:story_id/:title/comments/:id" => "comments#redirect_from_short_id"
get "/s/:id/(:title)" => "stories#show", :format => /html|json/
get "/u" => "users#tree" get "/u" => "users#tree"
get "/u/:username" => "users#show", :as => "user", :format => /html|json/ get "/u/:username" => "users#show", :as => "user", :format => /html|json/
@ -100,10 +109,25 @@ Lobsters::Application.routes.draw do
get "/settings" => "settings#index" get "/settings" => "settings#index"
post "/settings" => "settings#update" post "/settings" => "settings#update"
post "/settings/pushover" => "settings#pushover"
get "/settings/pushover_callback" => "settings#pushover_callback"
post "/settings/delete_account" => "settings#delete_account", post "/settings/delete_account" => "settings#delete_account",
:as => "delete_account" :as => "delete_account"
get "/settings/2fa" => "settings#twofa", :as => "twofa"
post "/settings/2fa_auth" => "settings#twofa_auth", :as => "twofa_auth"
get "/settings/2fa_enroll" => "settings#twofa_enroll",
:as => "twofa_enroll"
get "/settings/2fa_verify" => "settings#twofa_verify",
:as => "twofa_verify"
post "/settings/2fa_update" => "settings#twofa_update",
:as => "twofa_update"
post "/settings/pushover_auth" => "settings#pushover_auth"
get "/settings/pushover_callback" => "settings#pushover_callback"
get "/settings/github_auth" => "settings#github_auth"
get "/settings/github_callback" => "settings#github_callback"
post "/settings/github_disconnect" => "settings#github_disconnect"
get "/settings/twitter_auth" => "settings#twitter_auth"
get "/settings/twitter_callback" => "settings#twitter_callback"
post "/settings/twitter_disconnect" => "settings#twitter_disconnect"
get "/filters" => "filters#index" get "/filters" => "filters#index"
post "/filters" => "filters#update" post "/filters" => "filters#update"

View file

@ -0,0 +1,58 @@
class MoveUserSettings < ActiveRecord::Migration
def up
add_column :users, :settings, :text
[
:email_notifications,
:email_replies,
:pushover_replies,
:pushover_user_key,
:email_messages,
:pushover_messages,
:email_mentions,
:show_avatars,
:show_story_previews,
:show_submitted_story_threads,
].each do |col|
rename_column :users, col, "old_#{col}"
end
User.find_each do |u|
[
:email_notifications,
:email_replies,
:pushover_replies,
:pushover_user_key,
:email_messages,
:pushover_messages,
:email_mentions,
:show_avatars,
:show_story_previews,
:show_submitted_story_threads,
].each do |k|
u.settings[k] = u.send("old_#{k}")
end
u.save(:validate => false)
end
end
def down
remove_column :users, :settings
[
:email_notifications,
:email_replies,
:pushover_replies,
:pushover_user_key,
:email_messages,
:pushover_messages,
:email_mentions,
:show_avatars,
:show_story_previews,
:show_submitted_story_threads,
].each do |col|
rename_column :users, "old#{col}", col
end
end
end

View file

@ -0,0 +1,5 @@
class AddDragons < ActiveRecord::Migration
def change
add_column :comments, :is_dragon, :boolean, :default => false
end
end

View file

@ -0,0 +1,18 @@
class DeleteOldSettings < ActiveRecord::Migration
def change
[
:email_notifications,
:email_replies,
:pushover_replies,
:pushover_user_key,
:email_messages,
:pushover_messages,
:email_mentions,
:show_avatars,
:show_story_previews,
:show_submitted_story_threads,
].each do |col|
remove_column :users, "old_#{col}"
end
end
end

View file

@ -0,0 +1,7 @@
class AddIndexes < ActiveRecord::Migration
def change
add_index :votes, [ :comment_id ]
add_index :comments, [ :user_id ]
add_index :stories, [ :created_at ]
end
end

View file

@ -11,88 +11,90 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20160704022756) do ActiveRecord::Schema.define(version: 20170413161450) do
create_table "comments", force: true do |t| create_table "comments", force: :cascade do |t|
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at" t.datetime "updated_at"
t.string "short_id", limit: 10, default: "", null: false t.string "short_id", limit: 10, default: "", null: false
t.integer "story_id", null: false t.integer "story_id", limit: 4, null: false
t.integer "user_id", null: false t.integer "user_id", limit: 4, null: false
t.integer "parent_comment_id" t.integer "parent_comment_id", limit: 4
t.integer "thread_id" t.integer "thread_id", limit: 4
t.text "comment", limit: 16777215, null: false t.text "comment", limit: 16777215, null: false
t.integer "upvotes", default: 0, null: false t.integer "upvotes", limit: 4, default: 0, null: false
t.integer "downvotes", default: 0, null: false t.integer "downvotes", limit: 4, default: 0, null: false
t.decimal "confidence", precision: 20, scale: 19, default: 0.0, null: false t.decimal "confidence", precision: 20, scale: 19, default: 0.0, null: false
t.text "markeddown_comment", limit: 16777215 t.text "markeddown_comment", limit: 16777215
t.boolean "is_deleted", default: false t.boolean "is_deleted", default: false
t.boolean "is_moderated", default: false t.boolean "is_moderated", default: false
t.boolean "is_from_email", default: false t.boolean "is_from_email", default: false
t.integer "hat_id" t.integer "hat_id", limit: 4
t.boolean "is_dragon", default: false
end end
add_index "comments", ["confidence"], name: "confidence_idx", using: :btree add_index "comments", ["confidence"], name: "confidence_idx", using: :btree
add_index "comments", ["short_id"], name: "short_id", unique: true, using: :btree add_index "comments", ["short_id"], name: "short_id", unique: true, using: :btree
add_index "comments", ["story_id", "short_id"], name: "story_id_short_id", using: :btree add_index "comments", ["story_id", "short_id"], name: "story_id_short_id", using: :btree
add_index "comments", ["thread_id"], name: "thread_id", using: :btree add_index "comments", ["thread_id"], name: "thread_id", using: :btree
add_index "comments", ["user_id"], name: "index_comments_on_user_id", using: :btree
create_table "hat_requests", force: true do |t| create_table "hat_requests", force: :cascade do |t|
t.datetime "created_at" t.datetime "created_at"
t.datetime "updated_at" t.datetime "updated_at"
t.integer "user_id" t.integer "user_id", limit: 4
t.string "hat" t.string "hat", limit: 255
t.string "link" t.string "link", limit: 255
t.text "comment" t.text "comment", limit: 65535
end end
create_table "hats", force: true do |t| create_table "hats", force: :cascade do |t|
t.datetime "created_at" t.datetime "created_at"
t.datetime "updated_at" t.datetime "updated_at"
t.integer "user_id" t.integer "user_id", limit: 4
t.integer "granted_by_user_id" t.integer "granted_by_user_id", limit: 4
t.string "hat" t.string "hat", limit: 255
t.string "link" t.string "link", limit: 255
end end
create_table "hidden_stories", force: true do |t| create_table "hidden_stories", force: :cascade do |t|
t.integer "user_id" t.integer "user_id", limit: 4
t.integer "story_id" t.integer "story_id", limit: 4
end end
add_index "hidden_stories", ["user_id", "story_id"], name: "index_hidden_stories_on_user_id_and_story_id", unique: true, using: :btree add_index "hidden_stories", ["user_id", "story_id"], name: "index_hidden_stories_on_user_id_and_story_id", unique: true, using: :btree
create_table "invitation_requests", force: true do |t| create_table "invitation_requests", force: :cascade do |t|
t.string "code" t.string "code", limit: 255
t.boolean "is_verified", default: false t.boolean "is_verified", default: false
t.string "email" t.string "email", limit: 255
t.string "name" t.string "name", limit: 255
t.text "memo" t.text "memo", limit: 65535
t.string "ip_address" t.string "ip_address", limit: 255
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
end end
create_table "invitations", force: true do |t| create_table "invitations", force: :cascade do |t|
t.integer "user_id" t.integer "user_id", limit: 4
t.string "email" t.string "email", limit: 255
t.string "code" t.string "code", limit: 255
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.text "memo", limit: 16777215 t.text "memo", limit: 16777215
end end
create_table "keystores", id: false, force: true do |t| create_table "keystores", id: false, force: :cascade do |t|
t.string "key", limit: 50, default: "", null: false t.string "key", limit: 50, default: "", null: false
t.integer "value", limit: 8 t.integer "value", limit: 8
end end
add_index "keystores", ["key"], name: "key", unique: true, using: :btree add_index "keystores", ["key"], name: "key", unique: true, using: :btree
create_table "messages", force: true do |t| create_table "messages", force: :cascade do |t|
t.datetime "created_at" t.datetime "created_at"
t.integer "author_user_id" t.integer "author_user_id", limit: 4
t.integer "recipient_user_id" t.integer "recipient_user_id", limit: 4
t.boolean "has_been_read", default: false t.boolean "has_been_read", default: false
t.string "subject", limit: 100 t.string "subject", limit: 100
t.text "body", limit: 16777215 t.text "body", limit: 16777215
@ -103,39 +105,40 @@ ActiveRecord::Schema.define(version: 20160704022756) do
add_index "messages", ["short_id"], name: "random_hash", unique: true, using: :btree add_index "messages", ["short_id"], name: "random_hash", unique: true, using: :btree
create_table "moderations", force: true do |t| create_table "moderations", force: :cascade do |t|
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "moderator_user_id" t.integer "moderator_user_id", limit: 4
t.integer "story_id" t.integer "story_id", limit: 4
t.integer "comment_id" t.integer "comment_id", limit: 4
t.integer "user_id" t.integer "user_id", limit: 4
t.text "action", limit: 16777215 t.text "action", limit: 16777215
t.text "reason", limit: 16777215 t.text "reason", limit: 16777215
t.boolean "is_from_suggestions", default: false t.boolean "is_from_suggestions", default: false
end end
create_table "stories", force: true do |t| create_table "stories", force: :cascade do |t|
t.datetime "created_at" t.datetime "created_at"
t.integer "user_id" t.integer "user_id", limit: 4
t.string "url", limit: 250, default: "" t.string "url", limit: 250, default: ""
t.string "title", limit: 150, default: "", null: false t.string "title", limit: 150, default: "", null: false
t.text "description", limit: 16777215 t.text "description", limit: 16777215
t.string "short_id", limit: 6, default: "", null: false t.string "short_id", limit: 6, default: "", null: false
t.boolean "is_expired", default: false, null: false t.boolean "is_expired", default: false, null: false
t.integer "upvotes", default: 0, null: false t.integer "upvotes", limit: 4, default: 0, null: false
t.integer "downvotes", default: 0, null: false t.integer "downvotes", limit: 4, default: 0, null: false
t.boolean "is_moderated", default: false, null: false t.boolean "is_moderated", default: false, null: false
t.decimal "hotness", precision: 20, scale: 10, default: 0.0, null: false t.decimal "hotness", precision: 20, scale: 10, default: 0.0, null: false
t.text "markeddown_description", limit: 16777215 t.text "markeddown_description", limit: 16777215
t.text "story_cache", limit: 16777215 t.text "story_cache", limit: 16777215
t.integer "comments_count", default: 0, null: false t.integer "comments_count", limit: 4, default: 0, null: false
t.integer "merged_story_id" t.integer "merged_story_id", limit: 4
t.datetime "unavailable_at" t.datetime "unavailable_at"
t.string "twitter_id", limit: 20 t.string "twitter_id", limit: 20
t.boolean "user_is_author", default: false t.boolean "user_is_author", default: false
end end
add_index "stories", ["created_at"], name: "index_stories_on_created_at", using: :btree
add_index "stories", ["hotness"], name: "hotness_idx", using: :btree add_index "stories", ["hotness"], name: "hotness_idx", using: :btree
add_index "stories", ["is_expired", "is_moderated"], name: "is_idxes", using: :btree add_index "stories", ["is_expired", "is_moderated"], name: "is_idxes", using: :btree
add_index "stories", ["merged_story_id"], name: "index_stories_on_merged_story_id", using: :btree add_index "stories", ["merged_story_id"], name: "index_stories_on_merged_story_id", using: :btree
@ -143,35 +146,35 @@ ActiveRecord::Schema.define(version: 20160704022756) do
add_index "stories", ["twitter_id"], name: "index_stories_on_twitter_id", using: :btree add_index "stories", ["twitter_id"], name: "index_stories_on_twitter_id", using: :btree
add_index "stories", ["url"], name: "url", length: {"url"=>191}, using: :btree add_index "stories", ["url"], name: "url", length: {"url"=>191}, using: :btree
create_table "suggested_taggings", force: true do |t| create_table "suggested_taggings", force: :cascade do |t|
t.integer "story_id" t.integer "story_id", limit: 4
t.integer "tag_id" t.integer "tag_id", limit: 4
t.integer "user_id" t.integer "user_id", limit: 4
end end
create_table "suggested_titles", force: true do |t| create_table "suggested_titles", force: :cascade do |t|
t.integer "story_id" t.integer "story_id", limit: 4
t.integer "user_id" t.integer "user_id", limit: 4
t.string "title", limit: 150, null: false t.string "title", limit: 150, null: false
end end
create_table "tag_filters", force: true do |t| create_table "tag_filters", force: :cascade do |t|
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "user_id" t.integer "user_id", limit: 4
t.integer "tag_id" t.integer "tag_id", limit: 4
end end
add_index "tag_filters", ["user_id", "tag_id"], name: "user_tag_idx", using: :btree add_index "tag_filters", ["user_id", "tag_id"], name: "user_tag_idx", using: :btree
create_table "taggings", force: true do |t| create_table "taggings", force: :cascade do |t|
t.integer "story_id", null: false t.integer "story_id", limit: 4, null: false
t.integer "tag_id", null: false t.integer "tag_id", limit: 4, null: false
end end
add_index "taggings", ["story_id", "tag_id"], name: "story_id_tag_id", unique: true, using: :btree add_index "taggings", ["story_id", "tag_id"], name: "story_id_tag_id", unique: true, using: :btree
create_table "tags", force: true do |t| create_table "tags", force: :cascade do |t|
t.string "tag", limit: 25, default: "", null: false t.string "tag", limit: 25, default: "", null: false
t.string "description", limit: 100 t.string "description", limit: 100
t.boolean "privileged", default: false t.boolean "privileged", default: false
@ -182,39 +185,30 @@ ActiveRecord::Schema.define(version: 20160704022756) do
add_index "tags", ["tag"], name: "tag", unique: true, using: :btree add_index "tags", ["tag"], name: "tag", unique: true, using: :btree
create_table "users", force: true do |t| create_table "users", force: :cascade do |t|
t.string "username", limit: 50 t.string "username", limit: 50
t.string "email", limit: 100 t.string "email", limit: 100
t.string "password_digest", limit: 75 t.string "password_digest", limit: 75
t.datetime "created_at" t.datetime "created_at"
t.boolean "email_notifications", default: false t.boolean "is_admin", default: false
t.boolean "is_admin", default: false t.string "password_reset_token", limit: 75
t.string "password_reset_token", limit: 75 t.string "session_token", limit: 75, default: "", null: false
t.string "session_token", limit: 75, default: "", null: false t.text "about", limit: 16777215
t.text "about", limit: 16777215 t.integer "invited_by_user_id", limit: 4
t.integer "invited_by_user_id" t.boolean "is_moderator", default: false
t.boolean "email_replies", default: false t.boolean "pushover_mentions", default: false
t.boolean "pushover_replies", default: false t.string "rss_token", limit: 75
t.string "pushover_user_key" t.string "mailing_list_token", limit: 75
t.boolean "email_messages", default: true t.integer "mailing_list_mode", limit: 4, default: 0
t.boolean "pushover_messages", default: true t.integer "karma", limit: 4, default: 0, null: false
t.boolean "is_moderator", default: false
t.boolean "email_mentions", default: false
t.boolean "pushover_mentions", default: false
t.string "rss_token", limit: 75
t.string "mailing_list_token", limit: 75
t.integer "mailing_list_mode", default: 0
t.integer "karma", default: 0, null: false
t.datetime "banned_at" t.datetime "banned_at"
t.integer "banned_by_user_id" t.integer "banned_by_user_id", limit: 4
t.string "banned_reason", limit: 200 t.string "banned_reason", limit: 200
t.datetime "deleted_at" t.datetime "deleted_at"
t.boolean "show_avatars", default: false
t.boolean "show_story_previews", default: false
t.boolean "show_submitted_story_threads", default: true
t.datetime "disabled_invite_at" t.datetime "disabled_invite_at"
t.integer "disabled_invite_by_user_id" t.integer "disabled_invite_by_user_id", limit: 4
t.string "disabled_invite_reason", limit: 200 t.string "disabled_invite_reason", limit: 200
t.text "settings", limit: 65535
end end
add_index "users", ["mailing_list_mode"], name: "mailing_list_enabled", using: :btree add_index "users", ["mailing_list_mode"], name: "mailing_list_enabled", using: :btree
@ -224,14 +218,15 @@ ActiveRecord::Schema.define(version: 20160704022756) do
add_index "users", ["session_token"], name: "session_hash", unique: true, using: :btree add_index "users", ["session_token"], name: "session_hash", unique: true, using: :btree
add_index "users", ["username"], name: "username", unique: true, using: :btree add_index "users", ["username"], name: "username", unique: true, using: :btree
create_table "votes", force: true do |t| create_table "votes", force: :cascade do |t|
t.integer "user_id", null: false t.integer "user_id", limit: 4, null: false
t.integer "story_id", null: false t.integer "story_id", limit: 4, null: false
t.integer "comment_id" t.integer "comment_id", limit: 4
t.integer "vote", limit: 1, null: false t.integer "vote", limit: 1, null: false
t.string "reason", limit: 1 t.string "reason", limit: 1
end end
add_index "votes", ["comment_id"], name: "index_votes_on_comment_id", using: :btree
add_index "votes", ["user_id", "comment_id"], name: "user_id_comment_id", using: :btree add_index "votes", ["user_id", "comment_id"], name: "user_id_comment_id", using: :btree
add_index "votes", ["user_id", "story_id"], name: "user_id_story_id", using: :btree add_index "votes", ["user_id", "story_id"], name: "user_id_story_id", using: :btree

40
extras/github.rb Normal file
View file

@ -0,0 +1,40 @@
class Github
cattr_accessor :CLIENT_ID, :CLIENT_SECRET
# these need to be overridden in config/initializers/production.rb
@@CLIENT_ID = nil
@@CLIENT_SECRET = nil
def self.enabled?
self.CLIENT_ID.present?
end
def self.oauth_consumer
OAuth::Consumer.new(self.CLIENT_ID, self.CLIENT_SECRET,
{ :site => "https://api.github.com" })
end
def self.token_and_user_from_code(code)
s = Sponge.new
res = s.fetch("https://github.com/login/oauth/access_token", :post,
{ :client_id => self.CLIENT_ID, :client_secret => self.CLIENT_SECRET,
:code => code })
ps = CGI.parse(res)
tok = ps["access_token"].first
if tok.present?
res = s.fetch("https://api.github.com/user?access_token=#{tok}")
js = JSON.parse(res)
if js && js["login"].present?
return [ tok, js["login"] ]
end
end
return [ nil, nil ]
end
def self.oauth_auth_url(state)
"https://github.com/login/oauth/authorize?client_id=#{self.CLIENT_ID}&" <<
"state=#{state}"
end
end

View file

@ -1,55 +1,74 @@
class Markdowner class Markdowner
# opts[:allow_images] allows <img> tags # opts[:allow_images] allows <img> tags
# opts[:disable_profile_links] disables @username -> /u/username links
def self.to_html(text, opts = {}) def self.to_html(text, opts = {})
if text.blank? if text.blank?
return "" return ""
end end
args = [ :smart, :autolink, :safelink, :filter_styles, :filter_html, exts = [:tagfilter, :autolink, :strikethrough]
:strict ] root = CommonMarker.render_doc(text.to_s, [:SMART], exts)
if !opts[:allow_images]
args.push :no_image
end
ng = Nokogiri::HTML(RDiscount.new(text.to_s, *args).to_html) walk_text_nodes(root){|n| postprocess_text_node(n) }
ng = Nokogiri::HTML(root.to_html([:SAFE], exts))
# change <h1>, <h2>, etc. headings to just bold tags # change <h1>, <h2>, etc. headings to just bold tags
ng.css("h1, h2, h3, h4, h5, h6").each do |h| ng.css("h1, h2, h3, h4, h5, h6").each do |h|
h.name = "strong" h.name = "strong"
end end
if !opts[:allow_images]
ng.css("img").remove
end
# make links have rel=nofollow # make links have rel=nofollow
ng.css("a").each do |h| ng.css("a").each do |h|
h[:rel] = "nofollow" h[:rel] = "nofollow" unless (URI.parse(h[:href]).host.nil? rescue false)
end end
# XXX: t.replace(tx) unescapes HTML, so disable for now. this probably
# needs to split text into separate nodes and then replace the @username
# with a proper 'a' node
if false
unless opts[:disable_profile_links]
# make @username link to that user's profile
ng.search("//text()").each do |t|
if t.parent && t.parent.name.downcase == "a"
# don't replace inside <a>s
next
end
tx = t.text.gsub(/\B\@([\w\-]+)/) do |u|
if User.exists?(:username => u[1 .. -1])
"<a href=\"/u/#{u[1 .. -1]}\">#{u}</a>"
else
u
end
end
t.replace(tx)
end
end
end
ng.at_css("body").inner_html ng.at_css("body").inner_html
end end
def self.walk_text_nodes(node, &block)
return if node.type == :link
return block.call(node) if node.type == :text
node.each do |child|
walk_text_nodes(child, &block)
end
end
def self.postprocess_text_node(node)
while node
return unless node.string_content =~ /\B(@#{User::VALID_USERNAME})/
before, user, after = $`, $1, $'
node.string_content = before
if User.exists?(:username => user[1..-1])
link = CommonMarker::Node.new(:link)
link.url = "/u/#{user[1..-1]}"
node.insert_after(link)
link_text = CommonMarker::Node.new(:text)
link_text.string_content = user
link.append_child(link_text)
node = link
else
node.string_content += user
end
if after.length > 0
remainder = CommonMarker::Node.new(:text)
remainder.string_content = after
node.insert_after(remainder)
node = remainder
else
node = nil
end
end
end
end end

View file

@ -3,8 +3,12 @@ class Pushover
cattr_accessor :API_TOKEN cattr_accessor :API_TOKEN
cattr_accessor :SUBSCRIPTION_CODE cattr_accessor :SUBSCRIPTION_CODE
def self.enabled?
self.API_TOKEN.present?
end
def self.push(user, params) def self.push(user, params)
if !@@API_TOKEN if !self.enabled?
return return
end end
@ -15,7 +19,7 @@ class Pushover
s = Sponge.new s = Sponge.new
s.fetch("https://api.pushover.net/1/messages.json", :post, { s.fetch("https://api.pushover.net/1/messages.json", :post, {
:token => @@API_TOKEN, :token => self.API_TOKEN,
:user => user, :user => user,
}.merge(params)) }.merge(params))
rescue => e rescue => e
@ -24,7 +28,7 @@ class Pushover
end end
def self.subscription_url(params) def self.subscription_url(params)
u = "https://pushover.net/subscribe/#{@@SUBSCRIPTION_CODE}" u = "https://pushover.net/subscribe/#{self.SUBSCRIPTION_CODE}"
u << "?success=#{CGI.escape(params[:success])}" u << "?success=#{CGI.escape(params[:success])}"
u << "&failure=#{CGI.escape(params[:failure])}" u << "&failure=#{CGI.escape(params[:failure])}"
u u

View file

@ -6,18 +6,18 @@ class StoryCacher
DIFFBOT_API_URL = "http://www.diffbot.com/api/article" DIFFBOT_API_URL = "http://www.diffbot.com/api/article"
def self.get_story_text(url) def self.get_story_text(story)
if !@@DIFFBOT_API_KEY if !@@DIFFBOT_API_KEY
return return
end end
# XXX: diffbot tries to read pdfs as text, so disable for now # XXX: diffbot tries to read pdfs as text, so disable for now
if url.to_s.match(/\.pdf$/i) if story.url.to_s.match(/\.pdf$/i)
return nil return nil
end end
db_url = "#{DIFFBOT_API_URL}?token=#{@@DIFFBOT_API_KEY}&url=" << db_url = "#{DIFFBOT_API_URL}?token=#{@@DIFFBOT_API_KEY}&url=" <<
CGI.escape(url) CGI.escape(story.url)
begin begin
s = Sponge.new s = Sponge.new
@ -44,7 +44,7 @@ class StoryCacher
begin begin
s = Sponge.new s = Sponge.new
s.timeout = 45 s.timeout = 45
s.fetch("https://archive.is/#{db_url}") s.fetch(story.archive_url)
rescue => e rescue => e
Rails.logger.error "error caching #{db_url}: #{e.message}" Rails.logger.error "error caching #{db_url}: #{e.message}"
end end

View file

@ -4,6 +4,9 @@ class Twitter
# these need to be overridden in config/initializers/production.rb # these need to be overridden in config/initializers/production.rb
@@CONSUMER_KEY = nil @@CONSUMER_KEY = nil
@@CONSUMER_SECRET = nil @@CONSUMER_SECRET = nil
# these are set for the account used to post updates in
# script/post_to_twitter (needs read/write access)
@@AUTH_TOKEN = nil @@AUTH_TOKEN = nil
@@AUTH_SECRET = nil @@AUTH_SECRET = nil
@ -12,6 +15,10 @@ class Twitter
# https://t.co/eyW1U2HLtP # https://t.co/eyW1U2HLtP
TCO_LEN = 23 TCO_LEN = 23
def self.enabled?
self.CONSUMER_KEY.present?
end
def self.oauth_consumer def self.oauth_consumer
OAuth::Consumer.new(self.CONSUMER_KEY, self.CONSUMER_SECRET, OAuth::Consumer.new(self.CONSUMER_KEY, self.CONSUMER_SECRET,
{ :site => "https://api.twitter.com" }) { :site => "https://api.twitter.com" })
@ -47,4 +54,28 @@ class Twitter
end end
end end
end end
def self.token_secret_and_user_from_token_and_verifier(tok, verifier)
rt = OAuth::RequestToken.from_hash(self.oauth_consumer,
{ :oauth_token => tok })
at = rt.get_access_token({ :oauth_verifier => verifier })
res = at.get("/1.1/account/verify_credentials.json")
js = JSON.parse(res.body)
if !js["screen_name"].present?
return nil
end
[ at.token, at.secret, js["screen_name"] ]
end
def self.oauth_request_token(state)
self.oauth_consumer.get_request_token(:oauth_callback =>
Rails.application.root_url + "settings/twitter_callback?state=#{state}")
end
def self.oauth_auth_url(state)
self.oauth_request_token(state).authorize_url
end
end end

View file

@ -1,9 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>404</title>
</head>
<body>
<h1>gone fishin'</h1>
</body>
</html>

View file

@ -1,19 +1,2 @@
# block all spiders by default
User-agent: *
Disallow: /
# but allow major ones
User-agent: Googlebot
Allow: /
User-agent: Slurp
Allow: /
User-Agent: msnbot
Disallow:
User-agent: Baiduspider
Disallow: /
User-agent: * User-agent: *
Disallow: /search Disallow: /search

View file

@ -140,7 +140,7 @@ last_comment_id = (Keystore.value_for(LAST_COMMENT_KEY) ||
Comment.where("id > ? AND (is_deleted = ? AND is_moderated = ?)", Comment.where("id > ? AND (is_deleted = ? AND is_moderated = ?)",
last_comment_id, false, false).order(:id).each do |c| last_comment_id, false, false).order(:id).each do |c|
# allow some time for newer comments to be edited before sending them out # allow some time for newer comments to be edited before sending them out
if (Time.now - c.created_at) < 2.minutes if (Time.now - (c.updated_at || c.created_at)) < 2.minutes
break break
end end

View file

@ -7,9 +7,9 @@ require File.expand_path('../../config/boot', __FILE__)
require APP_PATH require APP_PATH
Rails.application.require_environment! Rails.application.require_environment!
MIN_STORY_SCORE = 2 MIN_STORY_SCORE = 3
Story.where("is_expired = ? AND #{Story.score_sql} > ? AND " << Story.where("is_expired = ? AND #{Story.score_sql} >= ? AND " <<
"twitter_id IS NULL AND created_at >= ?", false, MIN_STORY_SCORE, "twitter_id IS NULL AND created_at >= ?", false, MIN_STORY_SCORE,
Time.now - 1.days).order(:id).each_with_index do |s,x| Time.now - 1.days).order(:id).each_with_index do |s,x|
if s.tags.map(&:tag).include?("meta") if s.tags.map(&:tag).include?("meta")
@ -25,12 +25,18 @@ Time.now - 1.days).order(:id).each_with_index do |s,x|
tags += ' #' + tagging.tag.tag tags += ' #' + tagging.tag.tag
end end
tco_status = "\n" + via = ""
if s.user.twitter_username.present?
via = "\n" + (s.user_is_author? ? "by" : "via") +
" @#{s.user.twitter_username}"
end
tco_status = via + "\n" +
(s.url.present?? ("X" * Twitter::TCO_LEN) + "\n" : "") + (s.url.present?? ("X" * Twitter::TCO_LEN) + "\n" : "") +
("X" * Twitter::TCO_LEN) + ("X" * Twitter::TCO_LEN) +
tags tags
status = "\n" + status = via + "\n" +
(s.url.present?? s.url + "\n" : "") + (s.url.present?? s.url + "\n" : "") +
s.short_id_url + s.short_id_url +
tags tags

View file

@ -6,6 +6,16 @@ describe Markdowner do
"<p>hello there <em>italics</em> and <strong>bold</strong>!</p>" "<p>hello there <em>italics</em> and <strong>bold</strong>!</p>"
end end
it "turns @username into a link if @username exists" do
User.make!(:username => "blahblah")
Markdowner.to_html("hi @blahblah test").should ==
"<p>hi <a href=\"/u/blahblah\">@blahblah</a> test</p>"
Markdowner.to_html("hi @flimflam test").should ==
"<p>hi @flimflam test</p>"
end
# bug#209 # bug#209
it "keeps punctuation inside of auto-generated links when using brackets" do it "keeps punctuation inside of auto-generated links when using brackets" do
Markdowner.to_html("hi <http://example.com/a.> test").should == Markdowner.to_html("hi <http://example.com/a.> test").should ==
@ -25,4 +35,17 @@ describe Markdowner do
"<p>hi <a href=\"http://example.com/@blahblah/\" rel=\"nofollow\">" << "<p>hi <a href=\"http://example.com/@blahblah/\" rel=\"nofollow\">" <<
"test</a></p>" "test</a></p>"
end end
it "correctly adds nofollow" do
Markdowner.to_html("[ex](http://example.com)").should ==
"<p><a href=\"http://example.com\" rel=\"nofollow\">" <<
"ex</a></p>"
Markdowner.to_html("[ex](//example.com)").should ==
"<p><a href=\"//example.com\" rel=\"nofollow\">" <<
"ex</a></p>"
Markdowner.to_html("[ex](/u/abc)").should ==
"<p><a href=\"/u/abc\">ex</a></p>"
end
end end