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
public/assets
upstream-patches
# templates to be created per-site
app/views/home/privacy.*
app/views/home/about.*
app/views/home/chat.*
app/views/home/404.*
app/views/layouts/_footer.*
app/assets/stylesheets/local/*
public/favicon.ico
public/apple-touch-icon*

10
Gemfile
View File

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

View File

@ -116,7 +116,12 @@ var _Lobsters = Class.extend({
var li = $(voterEl).closest(".story, .comment");
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 = "";
if (li.hasClass("upvoted") && point > 0) {
@ -150,7 +155,8 @@ var _Lobsters = Class.extend({
action = "downvote";
}
scoreDiv.innerHTML = score;
if (showScore)
scoreDiv.innerHTML = score;
if (action == "upvote" || action == "unvote") {
li.find(".reason").html("");
@ -174,12 +180,7 @@ var _Lobsters = Class.extend({
if ($(form).find('#parent_comment_short_id').length) {
$(form).closest('.comment').replaceWith($.parseHTML(data));
} else {
if ($(form).attr("id").match(/^edit_comment_.+$/)) {
$(form).parent(".comment").replaceWith($.parseHTML(data));
} else {
$(form).closest('.comment').after($.parseHTML(data));
$(form).find('textarea').val('');
}
$(form).parent(".comment").replaceWith($.parseHTML(data));
}
});
},
@ -338,6 +339,16 @@ $(document).ready(function() {
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() {
if (!Lobsters.curUser) {
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() {
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();
$(document).on("click", "div.markdown_help_toggler .markdown_help_label",
@ -484,4 +513,11 @@ $(document).ready(function() {
$("#story_guidelines").toggle();
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 {
background-color: #fefefe;
line-height: 1.45em;
}
a {
@ -47,11 +48,11 @@ div.clear {
a.tag {
background-color: #fffcd7;
border: 1px solid #d5d458;
border-radius: 10px;
border-radius: 5px;
color: #555;
font-size: 8pt;
margin-left: 0.25em;
padding: 0px 0.5em 1px 0.5em;
padding: 0px 0.4em 1px 0.4em;
text-decoration: none;
vertical-align: text-top;
}
@ -117,17 +118,16 @@ select,
textarea {
color: #555;
background-color: white;
line-height: 1.2em;
padding: 3px 5px;
}
textarea {
line-height: 1.35em;
resize: vertical;
}
input[type="text"],
input[type="search"],
input[type="password"],
input[type="email"],
input[type="number"],
textarea {
border: 1px solid #ccc;
}
@ -232,6 +232,12 @@ button:disabled {
color: gray;
}
.totp_code::-webkit-inner-spin-button,
.totp_code::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* outliners */
@ -366,7 +372,6 @@ ol.comments {
margin-left: 20px;
margin-bottom: 0em;
padding-left: 6px;
line-height: 1.35em;
}
ol.comments1 {
margin-left: 0;
@ -449,8 +454,8 @@ li.story {
clear: both;
}
ol.stories li.story div.story_liner {
padding-top: 0.3em;
padding-bottom: 0.3em;
padding-top: 0.25em;
padding-bottom: 0.25em;
}
.comment {
clear: both;
@ -476,13 +481,17 @@ li div.details {
opacity: 0.7;
}
.negative_3 {
opacity: 0.5;
opacity: 0.6;
}
.negative_5 {
opacity: 0.2;
opacity: 0.5;
}
.comment.highlighted {
.comment.bad {
opacity: 0.7;
}
.comment:target {
background-color: #fffcd7;
border-radius: 20px;
}
@ -493,7 +502,7 @@ li .link {
}
li .link a {
font-size: 11pt;
font-size: 11.5pt;
text-decoration: none;
}
@ -555,9 +564,15 @@ li .comment_folder_button:checked ~ ol.comments li {
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 {
color: #888;
font-size: 9pt;
font-size: 9.5pt;
}
li .byline > img.avatar {
margin-bottom: -5px;
@ -601,6 +616,9 @@ span.user_is_author, a.user_is_author,
li .byline a.user_is_author {
color: #6081bd;
}
li .byline a.story_has_suggestions {
color: #6081bd;
}
li.story.hidden {
opacity: 0.25;
@ -628,7 +646,6 @@ div.story_content {
ol.stories.list div.story_content {
color: #777;
max-height: 2.6em;
line-height: 1.3em;
margin: 0.25em 40px 0.25em 0;
overflow: hidden;
text-overflow: clip;
@ -674,11 +691,10 @@ div.page_link_buttons span {
div.story_text {
margin-bottom: 1.5em;
max-width: 700px;
line-height: 1.4em;
word-wrap: break-word;
}
div.story_text p {
margin: 0.75em 0;
line-height: 1.4em;
}
div.story_text img {
max-width: 100%;
@ -692,7 +708,9 @@ a#story_text_expander {
}
div.comment_text {
font-size: 10.5pt;
max-width: 700px;
word-wrap: break-word;
}
div.comment_text blockquote,
@ -730,11 +748,21 @@ div.comment_text code {
line-height: 1.2em;
}
div.comment_actions a {
color: #888;
font-weight: bold;
font-size: 8.5pt;
text-decoration: none;
div.dragons {
}
.dragon_text {
background-color: #f8f8f8;
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 {
@ -837,6 +865,11 @@ div.comment_form_container form {
max-width: 700px;
}
div.comment_form_container textarea {
box-sizing: border-box;
width: 100%;
}
/* trees */
@ -844,7 +877,6 @@ div.comment_form_container form {
.tree ul {
margin: 0 0 0 0.5em;
padding: 0;
line-height: 1.5em;
list-style: none;
position: relative;
}
@ -890,6 +922,12 @@ div.comment_form_container form {
height: auto;
}
li.noparent:before,
ul.noparent:before {
border-top: 0 !important;
border-left: 0 !important;
}
ul.user_tree {
color: #888;
}
@ -1201,7 +1239,6 @@ div#flasher {
div#flasher div.flash-error,
div#flasher div.flash-notice,
div#flasher div.flash-success {
line-height: 1.5em;
display: inline-block;
padding-top: 25px;
margin-left: auto;

View File

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

View File

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

View File

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

View File

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

View File

@ -4,11 +4,24 @@ class HomeController < ApplicationController
before_filter { @page = page }
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
begin
@title = I18n.t 'controllers.home_controller.abouttitle'
render :action => "about"
rescue
rescue ActionView::MissingTemplate
render :text => I18n.t('controllers.home_controller.abouttext'), :layout => "application"
end
end
@ -17,8 +30,9 @@ class HomeController < ApplicationController
begin
@title = I18n.t 'controllers.home_controller.chattitle'
render :action => "chat"
rescue
rescue ActionView::MissingTemplate
render :text => "<div class=\"box wide\">" <<
"<div class=\"legend\">Chat</div>" <<
"Keep it on-site" <<
"</div>", :layout => "application"
end
@ -28,7 +42,7 @@ class HomeController < ApplicationController
begin
@title = I18n.t 'controllers.home_controller.privacytitle'
render :action => "privacy"
rescue
rescue ActionView::MissingTemplate
render :text => I18n.t('controllers.home_controller.licensetext'), :layout => "application"
end
end
@ -239,7 +253,12 @@ private
else
key = opts.merge(page: page).sort.map{|k,v| "#{k}=#{v.to_param}"
}.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

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
before_filter :authenticate_user
@ -22,53 +27,104 @@ class LoginController < ApplicationController
user = User.where(:username => params[:email]).first
end
fail_reason = nil
begin
if !user
raise "no user"
raise LoginFailedError
end
if !user.try(:authenticate, params[:password].to_s)
raise "authentication failed"
if !user.authenticate(params[:password].to_s)
# 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
if user.is_banned?
raise "user is banned"
raise LoginBannedError
end
if !user.is_active?
user.undelete!
flash[:success] = "Your account has been reactivated and your " <<
"unmoderated comments have been undeleted."
raise LoginDeletedError
end
session[:u] = user.session_token
if !user.password_digest.to_s.match(/^\$2a\$#{BCrypt::Engine::DEFAULT_COST}\$/)
user.password = user.password_confirmation = params[:password].to_s
user.save!
end
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
if user.has_2fa?
if params[:totp].present?
if user.authenticate_totp(params[:totp])
# ok, fall through
else
raise LoginTOTPFailedError
end
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
rescue => e
Rails.logger.error "error parsing referer: #{e}"
end
end
return redirect_to "/"
rescue
return respond_to do |format|
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
flash.now[:error] = I18n.t 'controllers.login_controller.flashlogininvalid'
@referer = params[:referer]
index
respond_to do |format|
format.html {
flash.now[:error] = fail_reason
@referer = params[:referer]
index
}
format.json {
render :json => { :status => 0, :error => fail_reason }
}
end
end
def forgot_password
@ -114,16 +170,48 @@ class LoginController < ApplicationController
end
if @reset_user.save && @reset_user.is_active?
session[:u] = @reset_user.session_token
return redirect_to "/"
if @reset_user.has_2fa?
flash[:success] = t('.passwordreset')
return redirect_to "/login"
else
session[:u] = @reset_user.session_token
return redirect_to "/"
end
else
flash[:error] = "Could not reset password."
flash[:error] = t('.couldnotresetpassword')
end
end
else
flash[:error] = "Invalid reset token. It may have already been " <<
"used or you may have copied it incorrectly."
flash[:error] = t('.invalidresettoken')
return redirect_to forgot_password_path
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

View File

@ -19,7 +19,11 @@ class SearchController < ApplicationController
end
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

View File

@ -1,6 +1,8 @@
class SettingsController < ApplicationController
before_filter :require_logged_in_user
TOTP_SESSION_TIMEOUT = (60 * 15)
def index
@title = t('.accountsettings')
@ -19,9 +21,107 @@ class SettingsController < ApplicationController
return redirect_to settings_path
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
flash[:error] = t('.pushovernotconfigured')
flash[:error] = "This site is not configured for Pushover"
return redirect_to "/settings"
end
@ -36,12 +136,12 @@ class SettingsController < ApplicationController
def pushover_callback
if !session[:pushover_rand].to_s.present?
flash[:error] = t('.pushovernorandomtokensession')
flash[:error] = "No random token present in session"
return redirect_to "/settings"
end
if !params[:rand].to_s.present?
flash[:error] = t('.pushovernorandomtokenurl')
flash[:error] = "No random token present in URL"
return redirect_to "/settings"
end
@ -54,23 +154,88 @@ class SettingsController < ApplicationController
@user.save!
if @user.pushover_user_key.present?
flash[:success] = t('.accountsetuppushover')
flash[:success] = "Your account is now setup for Pushover notifications."
else
flash[:success] = t('.accountnolongersetuppushover')
flash[:success] = "Your account is no longer setup for Pushover " <<
"notifications."
end
return redirect_to "/settings"
end
def update
@edit_user = @user.clone
def github_auth
session[:github_state] = SecureRandom.hex
return redirect_to Github.oauth_auth_url(session[:github_state])
end
if @edit_user.update_attributes(user_params)
flash.now[:success] = t('.updatesettingsflash')
@user = @edit_user
def github_callback
if !session[:github_state].present? || !params[:code].present? ||
(params[:state].to_s != session[:github_state].to_s)
flash[:error] = "Invalid OAuth state"
return redirect_to "/settings"
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
private
@ -81,7 +246,7 @@ private
:email_replies, :email_messages, :email_mentions,
:pushover_replies, :pushover_messages, :pushover_mentions,
:mailing_list_mode, :show_avatars, :show_story_previews,
:show_submitted_story_threads
:show_submitted_story_threads, :hide_dragons
)
end
end

View File

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

View File

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

View File

@ -1,6 +1,15 @@
module ApplicationHelper
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)
safe_join(str.split(" ").map{|w|
if w.length > len

View File

View File

View File

@ -10,7 +10,7 @@ class Comment < ActiveRecord::Base
:class_name => "Moderation"
belongs_to :hat
attr_accessor :current_vote, :previewing, :indent_level, :highlighted
attr_accessor :current_vote, :previewing, :indent_level
before_validation :on => :create do
self.assign_short_id_and_upvote
@ -26,9 +26,14 @@ class Comment < ActiveRecord::Base
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
MAX_EDIT_MINS = (60 * 6)
SCORE_RANGE_TO_HIDE = (-2 .. 4)
validate do
self.comment.to_s.strip == "" &&
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) &&
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
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
ordered = []
@ -164,6 +173,36 @@ class Comment < ActiveRecord::Base
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
# https://github.com/reddit/reddit/blob/master/r2/r2/lib/db/_sorts.pyx
def calculated_confidence
@ -223,7 +262,7 @@ class Comment < ActiveRecord::Base
if u.email_mentions?
begin
EmailReply.mention(self, u).deliver
EmailReply.mention(self, u).deliver_now
rescue => e
Rails.logger.error "error e-mailing #{u.email}: #{e}"
end
@ -247,7 +286,7 @@ class Comment < ActiveRecord::Base
u.id != self.user.id
if u.email_replies?
begin
EmailReply.reply(self, u).deliver
EmailReply.reply(self, u).deliver_now
rescue => e
Rails.logger.error "error e-mailing #{u.email}: #{e}"
end
@ -321,7 +360,7 @@ class Comment < ActiveRecord::Base
end
def is_downvotable?
if self.created_at
if self.created_at && self.score > DOWNVOTABLE_MIN_SCORE
Time.now - self.created_at <= DOWNVOTABLE_DAYS.days
else
false
@ -392,14 +431,24 @@ class Comment < ActiveRecord::Base
self.upvotes - self.downvotes
end
def short_id_path
self.story.short_id_path + "/c/#{self.short_id}"
def score_for_user(u)
if self.showing_downvotes_for_user?(u)
score
else
"-"
end
end
def short_id_url
Rails.application.root_url + "c/#{self.short_id}"
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
self.short_id
end
@ -409,13 +458,14 @@ class Comment < ActiveRecord::Base
end
def url
self.story.comments_url + "/comments/#{self.short_id}#c_#{self.short_id}"
self.story.comments_url + "#c_#{self.short_id}"
end
def vote_summary_for_user(u)
r_counts = {}
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] += v.vote

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,6 +33,9 @@ class Story < ActiveRecord::Base
DOWNVOTABLE_DAYS = 14
# the lowest a score can go
DOWNVOTABLE_MIN_SCORE = -5
# after this many minutes old, a story cannot be edited
MAX_EDIT_MINS = (60 * 6)
@ -123,7 +126,11 @@ class Story < ActiveRecord::Base
end
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!
end
true
@ -138,6 +145,10 @@ class Story < ActiveRecord::Base
Story.connection.adapter_name.match(/mysql/i) ? "signed" : "integer"
end
def archive_url
"https://archive.is/#{CGI.escape(self.url)}"
end
def as_json(options = {})
h = [
:short_id,
@ -185,21 +196,35 @@ class Story < ActiveRecord::Base
end
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
# negative), but ignore the story submitter's own comments
if base < 0
cpoints = 0
else
cpoints = self.comments.where("user_id <> ?", self.user_id).
select(:upvotes, :downvotes).map{|c| c.upvotes + 1 - c.downvotes }.
inject(&:+).to_f * 0.5
end
# give a story's comment votes some weight, ignoring submitter's comments
cpoints = self.comments.
where("user_id <> ?", self.user_id).
select(:upvotes, :downvotes).
map{|c|
if base < 0
# in stories already starting out with a bad hotness mod, only look
# at the downvotes to find out if this tire fire needs to be put out
c.downvotes * -0.5
else
c.upvotes + 1 - c.downvotes
end
}.
inject(&:+).to_f * 0.5
# mix in any stories this one cannibalized
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
order = Math.log([ (score + 1).abs + cpoints, 1 ].max, 10)
if score > 0
@ -227,7 +252,7 @@ class Story < ActiveRecord::Base
return false
end
if self.tags.select{|t| t.privileged? }.any?
if self.taggings.select{|t| t.tag && t.tag.privileged? }.any?
return false
end
@ -305,10 +330,24 @@ class Story < ActiveRecord::Base
def fetch_story_cache!
if self.url.present?
self.story_cache = StoryCacher.get_story_text(self.url)
self.story_cache = StoryCacher.get_story_text(self)
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
Markdowner.to_html(self.description, { :allow_images => true })
end
@ -323,6 +362,10 @@ class Story < ActiveRecord::Base
"hotness = '#{self.calculated_hotness}' WHERE id = #{self.id.to_i}")
end
def has_suggestions?
self.suggested_taggings.any? || self.suggested_titles.any?
end
def hider_count
@hider_count ||= HiddenStory.where(:story_id => self.id).count
end
@ -341,8 +384,7 @@ class Story < ActiveRecord::Base
end
def is_downvotable?
return true
if self.created_at
if self.created_at && self.score >= DOWNVOTABLE_MIN_SCORE
Time.now - self.created_at <= DOWNVOTABLE_DAYS.days
else
false
@ -467,20 +509,6 @@ class Story < ActiveRecord::Base
self.user_id, nil, false)
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
upvotes - downvotes
end
@ -788,6 +816,24 @@ class Story < ActiveRecord::Base
title = parsed.at_css("title").try(:text).to_s
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
# 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
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/ },
:uniqueness => { :case_sensitive => false }
validates :password, :presence => true, :on => :create
VALID_USERNAME = /[A-Za-z0-9][A-Za-z0-9_-]{0,24}/
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 }
validates_each :username do |record,attr,value|
@ -48,15 +69,17 @@ class User < ActiveRecord::Base
end
end
scope :active, -> { where(:banned_at => nil, :deleted_at => nil) }
before_save :check_session_token
before_validation :on => :create do
self.create_rss_token
self.create_mailing_list_token
end
BANNED_USERNAMES = [ "admin", "administrator", "hostmaster", "mailer-daemon",
"postmaster", "root", "security", "support", "webmaster", "moderator",
"moderators", "help", "contact", "fraud", "guest", "nobody", ]
BANNED_USERNAMES = [ "admin", "administrator", "contact", "fraud", "guest",
"help", "hostmaster", "mailer-daemon", "moderator", "moderators", "nobody",
"postmaster", "root", "security", "support", "sysop", "webmaster" ]
# days old accounts are considered new for
NEW_USER_DAYS = 7
@ -65,7 +88,7 @@ class User < ActiveRecord::Base
MIN_KARMA_TO_SUGGEST = 10
# 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
MIN_KARMA_TO_SUBMIT_STORIES = -4
@ -77,10 +100,8 @@ class User < ActiveRecord::Base
end
end
def self.username_regex
User.validators_on(:username).select{|v|
v.class == ActiveModel::Validations::FormatValidator }.first.
options[:with].inspect
def self.username_regex_s
"/^" + VALID_USERNAME.to_s.gsub(/(\?-mix:|\(|\))/, "") + "$/"
end
def as_json(options = {})
@ -98,10 +119,25 @@ class User < ActiveRecord::Base
attrs.push :about
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
end
def authenticate_totp(code)
totp = ROTP::TOTP.new(self.totp_secret)
totp.verify(code)
end
def avatar_url(size = 100)
"https://secure.gravatar.com/avatar/" +
Digest::MD5.hexdigest(self.email.strip.downcase) +
@ -176,7 +212,7 @@ class User < ActiveRecord::Base
# user can unvote
return true
end
elsif obj.is_a?(Comment)
elsif obj.is_a?(Comment) && obj.is_downvotable?
return !self.is_new? && (self.karma >= MIN_KARMA_TO_DOWNVOTE)
end
@ -262,6 +298,11 @@ class User < ActiveRecord::Base
end
end
def disable_2fa!
self.totp_secret = nil
self.save!
end
def grant_moderatorship_by_user!(user)
User.transaction do
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.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
def is_active?
@ -303,9 +348,7 @@ class User < ActiveRecord::Base
end
def linkified_about
# most users are probably mentioning "@username" to mean a twitter url, not
# a link to a profile on this site
Markdowner.to_html(self.about, { :disable_profile_links => true })
Markdowner.to_html(self.about)
end
def most_common_story_tag

View File

@ -1,13 +1,11 @@
<input id="comment_folder_<%= comment.short_id %>"
class="comment_folder_button" type="checkbox"
<%= comment.score <= -1 ? "checked" : "" %>>
<div id="comment_<%= comment.short_id %>"
<%= comment.score <= Comment::DOWNVOTABLE_MIN_SCORE ? "checked" : "" %>>
<div id="c_<%= comment.short_id %>"
data-shortid="<%= comment.short_id if comment.persisted? %>"
class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
"upvoted" : "downvoted") : "" %>
<%= comment.highlighted ? "highlighted" : "" %>
<%= comment.score <= 0 ? "negative" : "" %>
<%= comment.score <= -1 ? "negative_1" : "" %>">
<%= comment.score < Comment::SCORE_RANGE_TO_HIDE.first ? "bad" : "" %>">
<% if !comment.is_gone? %>
<div class="voters">
<% if @user %>
@ -15,7 +13,7 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
<% else %>
<%= link_to "", login_path, :class => "upvoter" %>
<% end %>
<div class="score"><%= comment.score %></div>
<div class="score"><%= comment.score_for_user(@user) %></div>
<% if @user && @user.can_downvote?(comment) %>
<a class="downvoter"></a>
<% else %>
@ -35,8 +33,8 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
<% end %>
<% if (@user && @user.show_avatars?) || !@user %>
<a href="/u/<%= comment.user.username %>"><img
src="<%= comment.user.avatar_url(16) %>" class="avatar"></a>
<a href="/u/<%= comment.user.username %>"><%=
avatar_img(comment.user, 16) %></a>
<% end %>
<a href="/u/<%= comment.user.username %>"
@ -61,7 +59,7 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
<% 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) %>
|
@ -80,6 +78,15 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
<% 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? %>
|
<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">
<% if comment.downvotes > 0 &&
((comment.score <= 0 && comment.user_id == @user.try(:id)) ||
@user.try("is_moderator?")) %>
comment.showing_downvotes_for_user?(@user) &&
(comment.user_id == @user.try(:id) || @user.try("is_moderator?")) %>
| <%= comment.vote_summary_for_user(@user).downcase %>
<% elsif comment.current_vote && comment.current_vote[:vote] == -1 %>
| -1

View File

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

View File

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

View File

@ -1,60 +1,29 @@
<div class="box wide">
<div class="legend">
Request a Hat
<%= t('.title') %>
</div>
<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>
<%= raw(t('.description')) %>
<%= form_for @hat_request, :url => create_hat_request_path do |f| %>
<p>
<%= f.label :hat, "Hat:" %>
<%= f.label :hat, t('.hat') %>
<%= f.text_field :hat, :size => 20,
:placeholder => "XYZ Project Member" %>
:placeholder => t('.hatplaceholder') %>
<br />
<%= f.label :link, "Link:" %>
<%= f.label :link, t('.link') %>
<%= f.text_field :link, :size => 50,
:placeholder => "user@project.org, or a URL to an employment page" %>
:placeholder => t('.linkplaceholder') %>
<br />
<%= f.label :comment, "Comment:" %>
<%= f.label :comment, t('.comment') %>
<%= f.text_area :comment, :rows => 4,
:placeholder => "Will only be shown to moderators during approval" %>
:placeholder => t('.commentplaceholder') %>
</p>
<p>
<%= submit_tag "Request Hat" %>
<%= submit_tag t('.requesthatbutton') %>
</p>
<% end %>
</div>

View File

@ -1,25 +1,22 @@
<div class="box wide">
<% if @user %>
<div class="legend right">
<a href="<%= request_hat_url %>">Request Hat</a>
<a href="<%= request_hat_url %>"><%= t('.request') %></a>
</div>
<% end %>
<div class="legend">
Hats
<%= t('.title') %>
</div>
<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 selected to be worn when posting a comment or sending a private
message.
<%= t('.description') %>
</p>
<table class="data" width="100%" cellspacing=0>
<tr>
<th style="width: 130px;">User</th>
<th>Hat</th>
<th>Link</th>
<th style="width: 130px;"><%= t('.user') %></th>
<th><%= t('.hat') %></th>
<th><%= t('.link') %></th>
</tr>
<% bit = 0 %>
<% @hat_groups.keys.sort_by{|a| a.downcase }.each do |hg| %>

View File

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

View File

@ -13,7 +13,7 @@
<br />
<%= f.label :email, t('.buildinvemail') %>
<%= f.text_field :email, :size => 30 %>
<%= f.email_field :email, :size => 30 %>
<br />
<%= 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="144x144" href="/apple-touch-icon-144.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noarchive,noodp,noydir" />
<meta name="referrer" content="always" />
<meta name="theme-color" content="#AC130D" />
<% if @meta_tags %>
@ -97,9 +96,6 @@
raw("class=\"cur_url\"") : "" %>><%= @user.username %>
(<%= @user.karma %>)</a>
<%= link_to t('.logoutlink'), { :controller => "login", :action => "logout" },
:data => { :confirm => t('.confirmlogoutlink') },
:method => "post" %>
<% else %>
<a href="/login"><%= t('.loginlink') %></a>
<% end %>
@ -119,21 +115,27 @@
<%= yield %>
<div id="footer">
<a href="/moderations"><%= t('.moderationloglink') %></a>
<% if @user && !@user.is_new? &&
(iqc = InvitationRequest.verified_count) > 0 %>
<a href="/invitations"><%= t('.invitationqueuelink') %>(<%= iqc %>)</a>
<% if lookup_context.template_exists?("footer", "layouts", true) %>
<%= render :partial => "layouts/footer" %>
<% else %>
<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 %>
<% 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 class="clear"></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">
<%= f.label :recipient_username, t('.tomsglabel'), :class => "required" %>
<%= f.text_field :recipient_username, :size => 20,
:autocomplete => "off" %>
<%= f.text_field :recipient_username, :size => 20 %>
</div>
<div class="boxline">
<%= f.label :subject, t('.subject'), :class => "required" %>
<%= f.text_field :subject, :style => "width: 500px;",
:autocomplete => "off" %>
<%= f.text_field :subject, :style => "width: 500px;" %>
</div>
<div class="boxline">
<%= f.label :body, t('.message'), :class => "required" %>
<%= f.text_area :body, :style => "width: 500px;", :rows => 5,
:autocomplete => "off" %>
<%= f.text_area :body, :style => "width: 500px;", :rows => 5 %>
</div>
<div class="boxline">

View File

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

View File

@ -5,14 +5,14 @@
<table class="data" width="100%" cellspacing=0>
<tr>
<th style="min-width: 130px;"><%= t('.datecolumn') %></th>
<th style="min-width: 150px;"><%= t('.datecolumn') %></th>
<th><%= t('.moderatorcolumn') %></th>
<th><%= t('.reasoncolumn') %></th>
</tr>
<% bit = 0 %>
<% @moderations.each do |mod| %>
<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 %>
<a href="/messages?to=<%= mod.moderator.try(:username) %>"><%=
mod.moderator.try(:username) %></a>

View File

@ -1,6 +1,10 @@
<div class="box wide">
<div class="legend right">
<a href="/u/<%= @user.username %>"><%= t('.viewprofile') %></a>
|
<%= link_to t('.logoutlink'), { :controller => "login", :action => "logout" },
:data => { :confirm => t('.confirmlogoutlink') },
:method => "post" %>
</div>
<div class="legend">
<%= t('.accountsettings') %>
@ -14,10 +18,16 @@
<%= f.label :username, t('.username'), :class => "required" %>
<%= f.text_field :username, :size => 15 %>
<span class="hint">
<tt><%= User.username_regex %></tt>
<tt><%= User.username_regex_s %></tt>
</span>
</div>
<div class="boxline">
<%= label_tag :current_password, t('.currentpassword'),
:class => "required" %>
<%= password_field_tag :current_password, nil, :size => 40 %>
</div>
<div class="boxline">
<%= f.label :password, t('.password'), :class => "required" %>
<%= f.password_field :password, :size => 40, :autocomplete => "off" %>
@ -32,7 +42,7 @@
<div class="boxline">
<%= f.label :email, t('.emailaddress'), :class => "required" %>
<%= f.text_field :email, :size => 40 %>
<%= f.email_field :email, :size => 40 %>
<span class="hint">
<%= raw(t('.gravatarized')) %>
</span>
@ -63,18 +73,19 @@
<br>
<div class="legend">
<%= t('.notificationsettings') %>
<%= t('.securitysettings') %>
</div>
<div class="boxline">
<%= f.label :pushover_user_key,
raw(t(".pushover")),
:class => "required" %>
<%= link_to((f.object.pushover_user_key.present??
t('.managepushoversubscription') : t('.subscribewithpushover')),
"/settings/pushover", :class => "pushover_button", :method => :post) %>
<span class="hint">
<%= t('.foroptionalcomment') %>
<%= f.label :twofa, t('.twofactorauth'), :class => "required" %>
<span>
<% if @edit_user.totp_secret.present? %>
<span style="color: green; font-weight: bold;">
<%= t('.enabled2fa') %>
</span> (<a href="/settings/2fa"><%= t('.disable2fa') %></a>)
<% else %>
<%= t('.disabled2fa') %> (<a href="/settings/2fa"><%= t('.enroll2fa') %></a>)
<% end %>
</span>
</div>
@ -193,6 +204,10 @@
<%= f.check_box :show_avatars %>
</div>
<div class="boxline">
<%= f.label :hide_dragons, t('.hidedragons'), :class => "required" %>
<%= f.check_box :hide_dragons %>
</div>
<br>
<%= f.submit t('.saveallsettings') %>
@ -201,6 +216,58 @@
<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>
<div class="legend">
<%= 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
</div>
<%= form_for @new_user, { :url => signup_path,
:autocomplete => "off" } do |f| %>
<%= form_for @new_user, { :url => signup_path } do |f| %>
<%= hidden_field_tag "invitation_code", @invitation.code %>
<p>
@ -28,7 +27,7 @@
<%= f.label :username, "Username:", :class => "required" %>
<%= f.text_field :username, :size => 30 %>
<span class="hint">
<tt><%= User.username_regex %></tt>
<tt><%= User.username_regex_s %></tt>
</span>
<br />

View File

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

View File

@ -26,7 +26,8 @@ class="story <%= story.vote && story.vote[:vote] == 1 ? "upvoted" : "" %>
<% end %>
</span>
<% 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 %>
<% if story.can_be_seen_by_user?(@user) %>
<span class="tags">
@ -62,10 +63,8 @@ class="story <%= story.vote && story.vote[:vote] == 1 ? "upvoted" : "" %>
<% end %>
<span class="byline">
<% if (@user && @user.show_avatars?) || !@user %>
<a href="/u/<%= ms.user.username %>"><img
src="<%= ms.user.avatar_url(16) %>"
srcset="<%= ms.user.avatar_url(16) %> 1x,
<%= ms.user.avatar_url(32) %> 2x" class="avatar"></a>
<a href="/u/<%= ms.user.username %>"><%=
avatar_img(ms.user, 16) %></a>
<% end %>
<% if story.user_is_author? %>
<%= t('.authoredby') %>
@ -76,6 +75,10 @@ class="story <%= story.vote && story.vote[:vote] == 1 ? "upvoted" : "" %>
ms.html_class_for_user %>"><%= ms.user.username %></a>
<%= 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>
<% end %>
<% end %>
@ -92,10 +95,8 @@ class="story <%= story.vote && story.vote[:vote] == 1 ? "upvoted" : "" %>
<div class="byline">
<% if (@user && @user.show_avatars?) || !@user %>
<a href="/u/<%= story.user.username %>"><img
src="<%= story.user.avatar_url(16) %>"
srcset="<%= story.user.avatar_url(16) %> 1x,
<%= story.user.avatar_url(32) %> 2x" class="avatar"></a>
<a href="/u/<%= story.user.username %>"><%=
avatar_img(story.user, 16) %></a>
<% end %>
<% if story.previewing %>
<% 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) %>
|
<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) %>
|
@ -163,7 +165,7 @@ class="story <%= story.vote && story.vote[:vote] == 1 ? "upvoted" : "" %>
<% end %>
<% 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>
<% end %>
<% if !story.is_gone? %>

View File

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

View File

@ -45,9 +45,22 @@
<% comments_by_parent = @comments.group_by(&:parent_comment_id) %>
<% subtree = comments_by_parent[nil] %>
<% ancestors = [] %>
<% dragons = false %>
<% while subtree %>
<% 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>
<%= render "comments/comment", :comment => comment,
:show_story => (comment.story_id != @story.id),
@ -64,5 +77,10 @@
</ol></li>
<% end %>
<% end %>
<% if dragons %>
</div>
</div>
<% end %>
</ol>
<% end %>

View File

@ -9,7 +9,7 @@
<div class="boxline">
<%= 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 class="boxline">

View File

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

View File

@ -17,9 +17,7 @@
<% if @showing_user.is_active? %>
<div id="gravatar">
<img src="<%= @showing_user.avatar_url(100) %>"
srcset="<%= @showing_user.avatar_url(100) %> 1x,
<%= @showing_user.avatar_url(200) %> 2x">
<%= avatar_img(@showing_user, 100) %>
</div>
<% end %>
@ -49,7 +47,7 @@
<span class="d">
<%= distance_of_time_in_words(@showing_user.created_at, Time.now) %>
<% if @showing_user.invited_by_user %>
<%= t('.byinvitationfrom') %>
<%= raw(t('.byinvitationfrom', :user => @showing_user.username)) %>
<%= link_to @showing_user.invited_by_user.try(:username),
@showing_user.invited_by_user %>
<% end %>
@ -69,16 +67,6 @@
<br>
<% 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? %>
<label class="required"><% t('.left') %></label>
<span class="d">
@ -117,6 +105,38 @@
</span>
<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? %>
<label class="required"><%= t('.about') %></label>

View File

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

View File

@ -26,7 +26,15 @@ module Lobsters
# Raise an exception when using mass assignment with unpermitted attributes
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.exceptions_app = self.routes
config.after_initialize do
require "#{Rails.root}/lib/monkey.rb"
end
end
end
@ -68,5 +76,3 @@ class << Rails.application
true
end
end
require "#{Rails.root}/lib/monkey"

View File

@ -20,7 +20,7 @@ Lobsters::Application.configure do
# config.action_dispatch.rack_cache = true
# 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.
config.assets.js_compressor = :uglifier
@ -76,3 +76,7 @@ Lobsters::Application.configure do
# Do not dump schema after migrations.
config.active_record.dump_schema_after_migration = false
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
# 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'
# Show full error reports and disable caching.
@ -35,5 +35,7 @@ Lobsters::Application.configure do
config.active_support.deprecation = :stderr
# Raises error for missing translations
# config.action_view.raise_on_missing_translations = true
config.action_view.raise_on_missing_translations = true
end
RSpec::Expectations.configuration.on_potential_false_positives = :nothing

View File

@ -22,6 +22,9 @@ en:
storyidcannotbeblank: "A story ID cannot be blank."
deletedcomment: "deleted comment"
threadremovedby: "Thread removed by moderator %{modoname} : %{modreason}"
turnedintodragon: "turned into a dragon"
slayeddragon: "slayed dragon"
metooerror: "Please just upvote the parent post instead."
moderation:
storyeditedby: "Your story has been edited by "
usersuggestions: "user suggestions"
@ -61,11 +64,11 @@ en:
messageslink: "Messages"
loginlink: "Login"
logoutlink: "Logout"
confirmlogoutlink: "Are you sure you want to logout?"
moderationloglink: "Moderation Log"
invitationqueuelink: "Invitation Queue"
chatlink: "Chat"
hatrequestlink: "Hat Requests"
hatslink: "Hats"
privacylink: "Privacy"
aboutlink: "About"
blog: "Blog"
@ -102,6 +105,8 @@ en:
delete: "delete"
reply: "reply"
about: "on:"
dragon: "dragon"
undragon: "undragon"
email_message:
notification.text:
replyat: "Reply at "
@ -120,6 +125,41 @@ en:
pretextdesc: "prefix text with at least<tt>&nbsp;&nbsp;&nbsp;&nbsp;4 spaces</tt>"
inlineimage: "(inline image)"
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:
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>"
@ -180,6 +220,16 @@ en:
password: "New Password:"
again: "(Again):"
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:
index:
viewreceived: "View Received"
@ -245,10 +295,13 @@ en:
deleteaccountflash: "Your account has been deleted."
verifypasswordflash: "Your password could not be verified."
index:
logoutlink: "Logout"
confirmlogoutlink: "Are you sure you want to logout?"
viewprofile: "View Profile"
accountsettings: "Account Settings"
username: "Username:"
password: "New Password:"
currentpassword: "Current Password:"
confirmpassword: "Confirm Password:"
emailaddress: "E-mail Address:"
gravatarized: "<a href=\"http://www.gravatar.com/\" target=\"_blank\">Gravatar</a>'ized"
@ -263,7 +316,7 @@ en:
commentreplynotificationsettings: "Comment Reply Notification Settings"
receiveemail: "Receive E-mail:"
receivepushover: "Receive Pushover Alert:"
requirepushover: "Requires Pushover subscription above"
requirepushover: "Requires Pushover subscription below"
commentmentionnotificationsettings: "Comment Mention Notification Settings"
privatemessagenotificationsettings: "Private Message Notification Settings"
submittedstorycommentsettings: "Submitted Story Comment Settings"
@ -278,6 +331,7 @@ en:
miscsettings: "Miscellaneous Settings"
storypreview: "Show Story Previews:"
useravatars: "Show User Avatars:"
hidedragons: "Hide Dragons:"
saveallsettings: "Save All Settings"
inviteuser: "Invite a New User"
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."
verifypassword: "Verify Password:"
deleteaccountconfirmation: "Yes, Delete My Account"
securitysettings: "Security Settings"
twofactorauth: "Two-Factor Auth:"
disable2fa: "Disable"
enroll2fa: "Enroll"
disabled2fa: "Disabled"
enabled2fa: "Enabled"
pushover:
pushovernotconfigured: "This site is not configured for Pushover"
pushover_callback:
@ -292,8 +352,36 @@ en:
pushovernorandomtokenurl: "No random token present in URL"
accountsetuppushover: "Your account is now 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:
updatesettingsflash: "Successfully updated settings."
passwordnotcorrect: "Your current password was not entered correctly."
stories:
edit:
edit: "Edit Story"
@ -355,6 +443,8 @@ en:
preview: "Preview"
submit: "Submit a Story"
submitbutton: "Submit"
show:
toggledragons: "&mdash; here be dragons &mdash;"
users:
list:
administrator: "administrator"
@ -372,7 +462,7 @@ en:
storysubmissions: "with story submissions"
disabled: "disabled"
joined: "Joined:"
byinvitationfrom: "by invitation from"
byinvitationfrom: "by <a href=\"/u/%{user}\">invitation</a> from"
banneduser: "Banned:"
bannedby: "by"
hats: "Hats:"
@ -439,6 +529,9 @@ en:
flashsuccessdeleteinvit: "Successfully deleted invitation request from %{name}"
login_controller:
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:
messagestitle: "Messages"
messagessenttitle: "Messages Sent"
@ -449,6 +542,7 @@ en:
flashdeletedmessage: "Deleted message."
search_controller:
searchtitle: "Search"
flasherrorsearchcontroller: "Sorry, but the search engine is currently out of order"
stories_controller:
submitstorytitle: "Submit Story"
editstorytitle: "Edit Story"

View File

@ -34,6 +34,9 @@ fr:
storyidcannotbeblank: "Un ID d'info ne peut pas être vide."
deletedcomment: "commentaire supprimé"
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:
storyeditedby: "Votre info a été éditée par "
usersuggestions: "suggestions d'utilisateur"
@ -78,6 +81,7 @@ fr:
invitationqueuelink: "File d'invitation"
chatlink: "Chat"
hatrequestlink: "Porter le chapeau"
hatslink: "Chapeaux"
privacylink: "Confidentialité"
aboutlink: "À propos"
blog: "Blog"
@ -104,6 +108,8 @@ fr:
delete: "supprimer"
reply: "répondre"
about: "sur :"
dragon: "dragon"
undragon: "non-dragon"
global:
markdownhelp:
emphasizedtext: "italique"
@ -120,6 +126,41 @@ fr:
pretextdesc: "précéder le texte avec au moins <tt>&nbsp;&nbsp;&nbsp;&nbsp;4 espaces</tt>"
inlineimage: "(image en ligne)"
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:
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>"
@ -180,6 +221,16 @@ fr:
password: "Mot de passe :"
again: "(encore):"
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:
index:
filteredtags: "Marques filtrées"
@ -255,10 +306,13 @@ fr:
deleteaccountflash: "Votre compte a été supprimé."
verifypasswordflash: "Votre mot de passe n'a pas pu être vérifié."
index:
logoutlink: "Se déconnecter"
confirmlogoutlink: "Êtes-vous sûr de vouloir vous déconnecter?"
viewprofile: "Voir le profil"
accountsettings: "Paramètres du compte"
username: "Utilisateur :"
password: "Nouveau mot de passe :"
currentpassword: "Mot de passe actuel :"
confirmpassword: "Confirmer le mot de passe :"
emailaddress: "Adresse e-mail :"
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"
receiveemail: "Recevoir un e-mail :"
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"
privatemessagenotificationsettings: "Paramètres de notification de message privé"
submittedstorycommentsettings: "Paramètres de commentaires relatifs à vos infos"
@ -288,6 +342,7 @@ fr:
miscsettings: "Paramètres variés"
storypreview: "Montrer les aperçus des infos: "
useravatars: "Montrer les avatars des utilisateurs :"
hidedragons: "Cacher les dragons :"
saveallsettings: "Sauver tous les paramètres"
inviteuser: "Inviter un nouvel utilisateur"
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."
verifypassword: "Vérification du mot de passe :"
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:
pushovernotconfigured: "Ce site n'est pas configuré pour le Pushover"
pushover_callback:
@ -302,8 +363,36 @@ fr:
pushovernorandomtokenurl: "Pas de jeton alétoire présent dans l'url"
accountsetuppushover: "Votre compte est maintenant 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:
updatesettingsflash: "Paramètres mis à jour avec succès."
passwordnotcorrect: "Le mot de passe courant n'a pas été entré correctement."
stories:
edit:
edit: "Éditer l'info"
@ -365,6 +454,8 @@ fr:
preview: "Aperçu"
submit: "Soumettre une info"
submitbutton: "Soumettre"
show:
toggledragons: "&mdash; voici les dragons &mdash;"
users:
list:
administrator: "administrateur"
@ -382,7 +473,7 @@ fr:
storysubmissions: "avec propositions d'infos"
disabled: "désactivé"
joined: "Inscrit :"
byinvitationfrom: "par invitation de"
byinvitationfrom: "par <a href=\"/u/%{user}\">invitation</a> de"
banneduser: "Banni :"
bannedby: "par"
hats: "Chapeaux :"
@ -449,6 +540,10 @@ fr:
flashsuccessdeleteinvit: "Demande d'invitation de %{name} supprimée avec succès"
login_controller:
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:
messagestitle: "Messages"
messagessenttitle: "Messages envoyés"
@ -459,6 +554,7 @@ fr:
flashdeletedmessage: "Message effacé."
search_controller:
searchtitle: "Rechercher"
flasherrorsearchcontroller: "Désolé mais le moteur de recherche est actuellement cassé"
stories_controller:
submitstorytitle: "Soumettre 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://"),
:as => "root"
get "/404" => "home#four_oh_four", :via => :all
get "/rss" => "home#index", :format => "rss"
get "/hottest" => "home#index", :format => "json"
@ -30,8 +32,10 @@ Lobsters::Application.routes.draw do
get "/threads/:user" => "comments#threads"
get "/login" => "login#index"
post "/login" => "login#login"
post "/login" => "login#login", :format => /html|json/
post "/logout" => "login#logout"
get "/login/2fa" => "login#twofa"
post "/login/2fa_verify" => "login#twofa_verify", :as => "twofa_login"
get "/signup" => "signup#index"
post "/signup" => "signup#signup"
@ -72,6 +76,9 @@ Lobsters::Application.routes.draw do
post "delete"
post "undelete"
post "dragon"
post "undragon"
end
end
get "/comments/page/:page" => "comments#index"
@ -84,12 +91,14 @@ Lobsters::Application.routes.draw do
post "keep_as_new"
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.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/:username" => "users#show", :as => "user", :format => /html|json/
@ -100,10 +109,25 @@ Lobsters::Application.routes.draw do
get "/settings" => "settings#index"
post "/settings" => "settings#update"
post "/settings/pushover" => "settings#pushover"
get "/settings/pushover_callback" => "settings#pushover_callback"
post "/settings/delete_account" => "settings#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"
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.
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 "updated_at"
t.string "short_id", limit: 10, default: "", null: false
t.integer "story_id", null: false
t.integer "user_id", null: false
t.integer "parent_comment_id"
t.integer "thread_id"
t.integer "story_id", limit: 4, null: false
t.integer "user_id", limit: 4, null: false
t.integer "parent_comment_id", limit: 4
t.integer "thread_id", limit: 4
t.text "comment", limit: 16777215, null: false
t.integer "upvotes", default: 0, null: false
t.integer "downvotes", default: 0, null: false
t.integer "upvotes", limit: 4, 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.text "markeddown_comment", limit: 16777215
t.boolean "is_deleted", default: false
t.boolean "is_moderated", 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
add_index "comments", ["confidence"], name: "confidence_idx", 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", ["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 "updated_at"
t.integer "user_id"
t.string "hat"
t.string "link"
t.text "comment"
t.integer "user_id", limit: 4
t.string "hat", limit: 255
t.string "link", limit: 255
t.text "comment", limit: 65535
end
create_table "hats", force: true do |t|
create_table "hats", force: :cascade do |t|
t.datetime "created_at"
t.datetime "updated_at"
t.integer "user_id"
t.integer "granted_by_user_id"
t.string "hat"
t.string "link"
t.integer "user_id", limit: 4
t.integer "granted_by_user_id", limit: 4
t.string "hat", limit: 255
t.string "link", limit: 255
end
create_table "hidden_stories", force: true do |t|
t.integer "user_id"
t.integer "story_id"
create_table "hidden_stories", force: :cascade do |t|
t.integer "user_id", limit: 4
t.integer "story_id", limit: 4
end
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|
t.string "code"
t.boolean "is_verified", default: false
t.string "email"
t.string "name"
t.text "memo"
t.string "ip_address"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
create_table "invitation_requests", force: :cascade do |t|
t.string "code", limit: 255
t.boolean "is_verified", default: false
t.string "email", limit: 255
t.string "name", limit: 255
t.text "memo", limit: 65535
t.string "ip_address", limit: 255
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "invitations", force: true do |t|
t.integer "user_id"
t.string "email"
t.string "code"
create_table "invitations", force: :cascade do |t|
t.integer "user_id", limit: 4
t.string "email", limit: 255
t.string "code", limit: 255
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.text "memo", limit: 16777215
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.integer "value", limit: 8
end
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.integer "author_user_id"
t.integer "recipient_user_id"
t.integer "author_user_id", limit: 4
t.integer "recipient_user_id", limit: 4
t.boolean "has_been_read", default: false
t.string "subject", limit: 100
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
create_table "moderations", force: true do |t|
create_table "moderations", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "moderator_user_id"
t.integer "story_id"
t.integer "comment_id"
t.integer "user_id"
t.integer "moderator_user_id", limit: 4
t.integer "story_id", limit: 4
t.integer "comment_id", limit: 4
t.integer "user_id", limit: 4
t.text "action", limit: 16777215
t.text "reason", limit: 16777215
t.boolean "is_from_suggestions", default: false
end
create_table "stories", force: true do |t|
create_table "stories", force: :cascade do |t|
t.datetime "created_at"
t.integer "user_id"
t.integer "user_id", limit: 4
t.string "url", limit: 250, default: ""
t.string "title", limit: 150, default: "", null: false
t.text "description", limit: 16777215
t.string "short_id", limit: 6, default: "", null: false
t.boolean "is_expired", default: false, null: false
t.integer "upvotes", default: 0, null: false
t.integer "downvotes", default: 0, null: false
t.integer "upvotes", limit: 4, default: 0, null: false
t.integer "downvotes", limit: 4, default: 0, null: false
t.boolean "is_moderated", default: false, null: false
t.decimal "hotness", precision: 20, scale: 10, default: 0.0, null: false
t.text "markeddown_description", limit: 16777215
t.text "story_cache", limit: 16777215
t.integer "comments_count", default: 0, null: false
t.integer "merged_story_id"
t.integer "comments_count", limit: 4, default: 0, null: false
t.integer "merged_story_id", limit: 4
t.datetime "unavailable_at"
t.string "twitter_id", limit: 20
t.boolean "user_is_author", default: false
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", ["is_expired", "is_moderated"], name: "is_idxes", 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", ["url"], name: "url", length: {"url"=>191}, using: :btree
create_table "suggested_taggings", force: true do |t|
t.integer "story_id"
t.integer "tag_id"
t.integer "user_id"
create_table "suggested_taggings", force: :cascade do |t|
t.integer "story_id", limit: 4
t.integer "tag_id", limit: 4
t.integer "user_id", limit: 4
end
create_table "suggested_titles", force: true do |t|
t.integer "story_id"
t.integer "user_id"
create_table "suggested_titles", force: :cascade do |t|
t.integer "story_id", limit: 4
t.integer "user_id", limit: 4
t.string "title", limit: 150, null: false
end
create_table "tag_filters", force: true do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "user_id"
t.integer "tag_id"
create_table "tag_filters", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "user_id", limit: 4
t.integer "tag_id", limit: 4
end
add_index "tag_filters", ["user_id", "tag_id"], name: "user_tag_idx", using: :btree
create_table "taggings", force: true do |t|
t.integer "story_id", null: false
t.integer "tag_id", null: false
create_table "taggings", force: :cascade do |t|
t.integer "story_id", limit: 4, null: false
t.integer "tag_id", limit: 4, null: false
end
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 "description", limit: 100
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
create_table "users", force: true do |t|
t.string "username", limit: 50
t.string "email", limit: 100
t.string "password_digest", limit: 75
create_table "users", force: :cascade do |t|
t.string "username", limit: 50
t.string "email", limit: 100
t.string "password_digest", limit: 75
t.datetime "created_at"
t.boolean "email_notifications", default: false
t.boolean "is_admin", default: false
t.string "password_reset_token", limit: 75
t.string "session_token", limit: 75, default: "", null: false
t.text "about", limit: 16777215
t.integer "invited_by_user_id"
t.boolean "email_replies", default: false
t.boolean "pushover_replies", default: false
t.string "pushover_user_key"
t.boolean "email_messages", default: true
t.boolean "pushover_messages", default: true
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.boolean "is_admin", default: false
t.string "password_reset_token", limit: 75
t.string "session_token", limit: 75, default: "", null: false
t.text "about", limit: 16777215
t.integer "invited_by_user_id", limit: 4
t.boolean "is_moderator", 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", limit: 4, default: 0
t.integer "karma", limit: 4, default: 0, null: false
t.datetime "banned_at"
t.integer "banned_by_user_id"
t.string "banned_reason", limit: 200
t.integer "banned_by_user_id", limit: 4
t.string "banned_reason", limit: 200
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.integer "disabled_invite_by_user_id"
t.string "disabled_invite_reason", limit: 200
t.integer "disabled_invite_by_user_id", limit: 4
t.string "disabled_invite_reason", limit: 200
t.text "settings", limit: 65535
end
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", ["username"], name: "username", unique: true, using: :btree
create_table "votes", force: true do |t|
t.integer "user_id", null: false
t.integer "story_id", null: false
t.integer "comment_id"
create_table "votes", force: :cascade do |t|
t.integer "user_id", limit: 4, null: false
t.integer "story_id", limit: 4, null: false
t.integer "comment_id", limit: 4
t.integer "vote", limit: 1, null: false
t.string "reason", limit: 1
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", "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
# opts[:allow_images] allows <img> tags
# opts[:disable_profile_links] disables @username -> /u/username links
def self.to_html(text, opts = {})
if text.blank?
return ""
end
args = [ :smart, :autolink, :safelink, :filter_styles, :filter_html,
:strict ]
if !opts[:allow_images]
args.push :no_image
end
exts = [:tagfilter, :autolink, :strikethrough]
root = CommonMarker.render_doc(text.to_s, [:SMART], exts)
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
ng.css("h1, h2, h3, h4, h5, h6").each do |h|
h.name = "strong"
end
if !opts[:allow_images]
ng.css("img").remove
end
# make links have rel=nofollow
ng.css("a").each do |h|
h[:rel] = "nofollow"
h[:rel] = "nofollow" unless (URI.parse(h[:href]).host.nil? rescue false)
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
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

View File

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

View File

@ -6,18 +6,18 @@ class StoryCacher
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
return
end
# 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
end
db_url = "#{DIFFBOT_API_URL}?token=#{@@DIFFBOT_API_KEY}&url=" <<
CGI.escape(url)
CGI.escape(story.url)
begin
s = Sponge.new
@ -44,7 +44,7 @@ class StoryCacher
begin
s = Sponge.new
s.timeout = 45
s.fetch("https://archive.is/#{db_url}")
s.fetch(story.archive_url)
rescue => e
Rails.logger.error "error caching #{db_url}: #{e.message}"
end

View File

@ -4,6 +4,9 @@ class Twitter
# these need to be overridden in config/initializers/production.rb
@@CONSUMER_KEY = 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_SECRET = nil
@ -12,6 +15,10 @@ class Twitter
# https://t.co/eyW1U2HLtP
TCO_LEN = 23
def self.enabled?
self.CONSUMER_KEY.present?
end
def self.oauth_consumer
OAuth::Consumer.new(self.CONSUMER_KEY, self.CONSUMER_SECRET,
{ :site => "https://api.twitter.com" })
@ -47,4 +54,28 @@ class Twitter
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

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: *
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 = ?)",
last_comment_id, false, false).order(:id).each do |c|
# 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
end

View File

@ -7,9 +7,9 @@ require File.expand_path('../../config/boot', __FILE__)
require APP_PATH
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,
Time.now - 1.days).order(:id).each_with_index do |s,x|
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
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" : "") +
("X" * Twitter::TCO_LEN) +
tags
status = "\n" +
status = via + "\n" +
(s.url.present?? s.url + "\n" : "") +
s.short_id_url +
tags

View File

@ -6,6 +6,16 @@ describe Markdowner do
"<p>hello there <em>italics</em> and <strong>bold</strong>!</p>"
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
it "keeps punctuation inside of auto-generated links when using brackets" do
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\">" <<
"test</a></p>"
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