diff --git a/.gitignore b/.gitignore index b17eeb8..b59a649 100644 --- a/.gitignore +++ b/.gitignore @@ -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* diff --git a/Gemfile b/Gemfile index 18c560e..7b26f69 100644 --- a/Gemfile +++ b/Gemfile @@ -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" diff --git a/app/assets/javascripts/application.js.erb b/app/assets/javascripts/application.js.erb index 622bab4..7d907c5 100644 --- a/app/assets/javascripts/application.js.erb +++ b/app/assets/javascripts/application.js.erb @@ -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(); + } + }); }); diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 0b0868d..e6ecc52 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -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; diff --git a/app/assets/stylesheets/mobile.css b/app/assets/stylesheets/mobile.css index 909febb..228eda1 100644 --- a/app/assets/stylesheets/mobile.css +++ b/app/assets/stylesheets/mobile.css @@ -242,6 +242,5 @@ div#footer { text-align: center; float: none; - padding-left: 10px; } } diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 26a18ea..f3db4cb 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb index 27fc2a3..dc8a0c9 100644 --- a/app/controllers/comments_controller.rb +++ b/app/controllers/comments_controller.rb @@ -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) diff --git a/app/controllers/hats_controller.rb b/app/controllers/hats_controller.rb index dd5bd24..d53de81 100644 --- a/app/controllers/hats_controller.rb +++ b/app/controllers/hats_controller.rb @@ -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 diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 941cb4f..7a444d3 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -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 => "
" << + "
404
" << + "Resource not found" << + "
", :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 => "
" << + "
Chat
" << "Keep it on-site" << "
", :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 diff --git a/app/controllers/login_controller.rb b/app/controllers/login_controller.rb index 2ee683f..3ef5ff7 100644 --- a/app/controllers/login_controller.rb +++ b/app/controllers/login_controller.rb @@ -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 diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index e29ee00..b515352 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -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 diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 317c607..a597080 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -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 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 = "#{qr}" + 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 diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 0b9a39a..2ba3169 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -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" diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 4371d25..2b13ee3 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 69869f7..f294671 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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 diff --git a/app/mailers/.gitkeep b/app/mailers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/models/.gitkeep b/app/models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/models/comment.rb b/app/models/comment.rb index 92f646f..90fc4f8 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -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 diff --git a/app/models/invitation.rb b/app/models/invitation.rb index 1977364..a246d64 100644 --- a/app/models/invitation.rb +++ b/app/models/invitation.rb @@ -24,6 +24,6 @@ class Invitation < ActiveRecord::Base end def send_email - InvitationMailer.invitation(self).deliver + InvitationMailer.invitation(self).deliver_now end end diff --git a/app/models/invitation_request.rb b/app/models/invitation_request.rb index 5abe529..2678a76 100644 --- a/app/models/invitation_request.rb +++ b/app/models/invitation_request.rb @@ -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 diff --git a/app/models/message.rb b/app/models/message.rb index 1797de7..e71c127 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -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 diff --git a/app/models/search.rb b/app/models/search.rb index 3a10a6a..5a758e3 100644 --- a/app/models/search.rb +++ b/app/models/search.rb @@ -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 diff --git a/app/models/stories_paginator.rb b/app/models/stories_paginator.rb index 5aba99f..7afcf03 100644 --- a/app/models/stories_paginator.rb +++ b/app/models/stories_paginator.rb @@ -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 diff --git a/app/models/story.rb b/app/models/story.rb index 76d2475..d390474 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 6fc1a4d..b0a0166 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/views/comments/_comment.html.erb b/app/views/comments/_comment.html.erb index 49937f7..bd506ff 100644 --- a/app/views/comments/_comment.html.erb +++ b/app/views/comments/_comment.html.erb @@ -1,13 +1,11 @@ > -
> +
- <%= 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? %>
<% if @user %> @@ -15,7 +13,7 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ? <% else %> <%= link_to "", login_path, :class => "upvoter" %> <% end %> -
<%= comment.score %>
+
<%= comment.score_for_user(@user) %>
<% if @user && @user.can_downvote?(comment) %> <% else %> @@ -35,8 +33,8 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ? <% end %> <% if (@user && @user.show_avatars?) || !@user %> - + <%= + avatar_img(comment.user, 16) %> <% end %> "><%= t('.link') %> + <%= t('.link') %> <% 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? %> + <%= t('.undragon') %> + <% else %> + <%= t('.dragon') %> + <% end %> + <% end %> + <% if @user && !comment.story.is_gone? && !comment.is_gone? %> | <%= t('.reply') %> @@ -93,8 +100,8 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ? <% 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 diff --git a/app/views/comments/_commentbox.html.erb b/app/views/comments/_commentbox.html.erb index 65614ab..5188e31 100644 --- a/app/views/comments/_commentbox.html.erb +++ b/app/views/comments/_commentbox.html.erb @@ -15,7 +15,7 @@ data-shortid="<%= comment.short_id if comment.persisted? %>">
<%= text_area_tag "comment", comment.comment, :rows => 5, - :style => "width: 100%;", :autocomplete => "off", :disabled => !@user, + :disabled => !@user, :placeholder => (@user ? "" : t('.mustbelogged')) %> diff --git a/app/views/global/_markdownhelp.html.erb b/app/views/global/_markdownhelp.html.erb index 6338476..58fb178 100644 --- a/app/views/global/_markdownhelp.html.erb +++ b/app/views/global/_markdownhelp.html.erb @@ -8,12 +8,10 @@ <%= t('.strongtext') %> <%= raw t('.strongtextdesc') %> - <%= t('.fixedwidth') %> <%= raw t('.fixedwidthdesc') %> diff --git a/app/views/hats/build_request.html.erb b/app/views/hats/build_request.html.erb index 5c5b8a8..10e6273 100644 --- a/app/views/hats/build_request.html.erb +++ b/app/views/hats/build_request.html.erb @@ -1,60 +1,29 @@
- Request a Hat + <%= t('.title') %>
-

- 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. -

-

- 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. -

-

- 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. -

-

- 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. -

-

- 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. -

+ <%= raw(t('.description')) %> <%= form_for @hat_request, :url => create_hat_request_path do |f| %>

- <%= f.label :hat, "Hat:" %> + <%= f.label :hat, t('.hat') %> <%= f.text_field :hat, :size => 20, - :placeholder => "XYZ Project Member" %> + :placeholder => t('.hatplaceholder') %>
- <%= 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') %>
- <%= 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') %>

- <%= submit_tag "Request Hat" %> + <%= submit_tag t('.requesthatbutton') %>

<% end %>
diff --git a/app/views/hats/index.html.erb b/app/views/hats/index.html.erb index 08a33d8..24e18ac 100644 --- a/app/views/hats/index.html.erb +++ b/app/views/hats/index.html.erb @@ -1,25 +1,22 @@
<% if @user %> <% end %>
- Hats + <%= t('.title') %>

- 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') %>

- - - + + + <% bit = 0 %> <% @hat_groups.keys.sort_by{|a| a.downcase }.each do |hg| %> diff --git a/app/views/hats/requests_index.html.erb b/app/views/hats/requests_index.html.erb index 155b590..7161072 100644 --- a/app/views/hats/requests_index.html.erb +++ b/app/views/hats/requests_index.html.erb @@ -1,10 +1,10 @@
- Requested Hats + <%= t('.title') %>
<% 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| %>

- <%= f.label :user_id, "User:", :class => "required" %> + <%= f.label :user_id, t('.user'), :class => "required" %> <%= hr.user.username %>
- <%= 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 %>
- <%= 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 %>
- <%= f.label :link, "Comment:", :class => "required" %> + <%= f.label :link, t('.comment'), :class => "required" %>
<%= raw(h(hr.comment.to_s).gsub(/\n/, "
")) %>

- <%= submit_tag "Approve Hat Request" %> + <%= submit_tag t('.approve') %>

<% end %>

- or + <%= t('.hator') %>

<%= form_for hr, :url => reject_hat_request_url(:id => hr), :method => :post do |f| %>
- <%= f.label :link, "Reason:", :class => "required" %> + <%= f.label :link, t('.reason'), :class => "required" %> <%= f.text_area :rejection_comment, :rows => 4 %>

- <%= submit_tag "Reject Hat Request" %> + <%= submit_tag t('.reject') %>

<% end %> <% end %> diff --git a/app/views/invitations/build.html.erb b/app/views/invitations/build.html.erb index 5d068de..ce03086 100644 --- a/app/views/invitations/build.html.erb +++ b/app/views/invitations/build.html.erb @@ -13,7 +13,7 @@
<%= f.label :email, t('.buildinvemail') %> - <%= f.text_field :email, :size => 30 %> + <%= f.email_field :email, :size => 30 %>
<%= f.label :memo, t('.buildinvurl') %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index e718a09..69b546f 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -9,7 +9,6 @@ - <% if @meta_tags %> @@ -97,9 +96,6 @@ raw("class=\"cur_url\"") : "" %>><%= @user.username %> (<%= @user.karma %>) - <%= link_to t('.logoutlink'), { :controller => "login", :action => "logout" }, - :data => { :confirm => t('.confirmlogoutlink') }, - :method => "post" %> <% else %> <%= t('.loginlink') %> <% end %> @@ -119,21 +115,27 @@ <%= yield %>
diff --git a/app/views/login/twofa.html.erb b/app/views/login/twofa.html.erb new file mode 100644 index 0000000..4161258 --- /dev/null +++ b/app/views/login/twofa.html.erb @@ -0,0 +1,23 @@ +
+
+ <%= t('.login2fa') %> +
+ + <%= form_tag twofa_login_url do %> +

+ <%= t('.logintotpcode') %> +

+ +

+ <%= label_tag :totp_code, t('.totpcode') %> + <%= number_field_tag :totp_code, "", :size => 10, :autocomplete => "off", + :autofocus => true, :class => "totp_code" %> +
+

+ +

+ <%= submit_tag t('.loginbutton') %> +

+ <% end %> +
+ diff --git a/app/views/messages/index.html.erb b/app/views/messages/index.html.erb index bb6bef2..fa21464 100644 --- a/app/views/messages/index.html.erb +++ b/app/views/messages/index.html.erb @@ -73,20 +73,17 @@
<%= f.label :recipient_username, t('.tomsglabel'), :class => "required" %> - <%= f.text_field :recipient_username, :size => 20, - :autocomplete => "off" %> + <%= f.text_field :recipient_username, :size => 20 %>
<%= f.label :subject, t('.subject'), :class => "required" %> - <%= f.text_field :subject, :style => "width: 500px;", - :autocomplete => "off" %> + <%= f.text_field :subject, :style => "width: 500px;" %>
<%= 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 %>
diff --git a/app/views/messages/show.html.erb b/app/views/messages/show.html.erb index 8983200..c52d4d1 100644 --- a/app/views/messages/show.html.erb +++ b/app/views/messages/show.html.erb @@ -69,13 +69,11 @@ <%= error_messages_for @new_message %>
- <%= f.text_field :subject, :style => "width: 500px;", - :autocomplete => "off" %> + <%= f.text_field :subject, :style => "width: 500px;" %>
- <%= f.text_area :body, :style => "width: 500px;", :rows => 5, - :autocomplete => "off" %> + <%= f.text_area :body, :style => "width: 500px;", :rows => 5 %>
diff --git a/app/views/moderations/index.html.erb b/app/views/moderations/index.html.erb index 002384d..83adc4e 100644 --- a/app/views/moderations/index.html.erb +++ b/app/views/moderations/index.html.erb @@ -5,14 +5,14 @@
UserHatLink<%= t('.user') %><%= t('.hat') %><%= t('.link') %>
- + <% bit = 0 %> <% @moderations.each do |mod| %> - +
<%= t('.datecolumn') %><%= t('.datecolumn') %> <%= t('.moderatorcolumn') %> <%= t('.reasoncolumn') %>
<%= l mod.created_at %><%= mod.created_at.strftime("%Y-%m-%d %H:%M %z") %> <% if mod.moderator %> <%= mod.moderator.try(:username) %> diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb index 73d800a..e9665a4 100644 --- a/app/views/settings/index.html.erb +++ b/app/views/settings/index.html.erb @@ -1,6 +1,10 @@
<%= t('.viewprofile') %> + | + <%= link_to t('.logoutlink'), { :controller => "login", :action => "logout" }, + :data => { :confirm => t('.confirmlogoutlink') }, + :method => "post" %>
<%= t('.accountsettings') %> @@ -14,10 +18,16 @@ <%= f.label :username, t('.username'), :class => "required" %> <%= f.text_field :username, :size => 15 %> - <%= User.username_regex %> + <%= User.username_regex_s %>
+
+ <%= label_tag :current_password, t('.currentpassword'), + :class => "required" %> + <%= password_field_tag :current_password, nil, :size => 40 %> +
+
<%= f.label :password, t('.password'), :class => "required" %> <%= f.password_field :password, :size => 40, :autocomplete => "off" %> @@ -32,7 +42,7 @@
<%= f.label :email, t('.emailaddress'), :class => "required" %> - <%= f.text_field :email, :size => 40 %> + <%= f.email_field :email, :size => 40 %> <%= raw(t('.gravatarized')) %> @@ -63,18 +73,19 @@
- <%= t('.notificationsettings') %> + <%= t('.securitysettings') %>
- <%= 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) %> - - <%= t('.foroptionalcomment') %> + <%= f.label :twofa, t('.twofactorauth'), :class => "required" %> + + <% if @edit_user.totp_secret.present? %> + + <%= t('.enabled2fa') %> + (<%= t('.disable2fa') %>) + <% else %> + <%= t('.disabled2fa') %> (<%= t('.enroll2fa') %>) + <% end %>
@@ -193,6 +204,10 @@ <%= f.check_box :show_avatars %>
+
+ <%= f.label :hide_dragons, t('.hidedragons'), :class => "required" %> + <%= f.check_box :hide_dragons %> +

<%= f.submit t('.saveallsettings') %> @@ -201,6 +216,58 @@

+
+ External Accounts +
+ + <% if Pushover.enabled? %> +
+ <%= label_tag :pushover_user_key, + raw("Pushover:"), + :class => "required" %> + <%= link_to((@edit_user.pushover_user_key.present?? + "Manage Pushover Subscription" : "Subscribe With Pushover"), + "/settings/pushover_auth", :class => "pushover_button", + :method => :post) %> + + For optional comment and message notifications above + +
+ <% end %> + + <% if Github.enabled? %> +
+ <%= label_tag :github_username, "GitHub:", :class => "required" %> + <% if @edit_user.github_username.present? %> + Linked to + <%= h(@edit_user.github_username) %> + (<%= link_to "Disconnect", "/settings/github_disconnect", + :method => :post %>) + <% else %> + Connect + <% end %> +
+ <% end %> + + <% if Twitter.enabled? %> +
+ <%= label_tag :twitter_username, "Twitter:", :class => "required" %> + <% if @edit_user.twitter_username.present? %> + Linked to + @<%= h(@edit_user.twitter_username) %> + (<%= link_to "Disconnect", "/settings/twitter_disconnect", + :method => :post %>) + <% else %> + Connect + <% end %> +
+ <% end %> + +
+
+
<%= t('.inviteuser') %> diff --git a/app/views/settings/twofa.html.erb b/app/views/settings/twofa.html.erb new file mode 100644 index 0000000..f687429 --- /dev/null +++ b/app/views/settings/twofa.html.erb @@ -0,0 +1,30 @@ +
+ +
+ <%= @title %> +
+ + <%= form_for @user, :url => twofa_auth_url, :method => :post do |f| %> +

+ <% if @user.has_2fa? %> + <%= t('.turnoff') %> + <% else %> + <%= t('.turnon') %> + <% end %> +

+ +
+ <%= f.label :password, t('.currentpassword'), :class => "required" %> + <%= f.password_field :password, :size => 40, :autocomplete => "off" %> +
+ +

+ <% if @user.has_2fa? %> + <%= submit_tag t('.disable2fa') %> + <% else %> + <%= submit_tag t('.continue') %> + <% end %> + <% end %> +

diff --git a/app/views/settings/twofa_enroll.html.erb b/app/views/settings/twofa_enroll.html.erb new file mode 100644 index 0000000..2a49355 --- /dev/null +++ b/app/views/settings/twofa_enroll.html.erb @@ -0,0 +1,21 @@ +
+ +
+ <%= @title %> +
+ +

+ <%= raw(t('.scanqrcode')) %> +

+ + <%= raw @qr_svg %> + +

+ <%= t('.registring') %> +

+ +

+ <%= button_to t('.verifyenable'), twofa_verify_url, :method => :get %> +

diff --git a/app/views/settings/twofa_verify.html.erb b/app/views/settings/twofa_verify.html.erb new file mode 100644 index 0000000..d281e3e --- /dev/null +++ b/app/views/settings/twofa_verify.html.erb @@ -0,0 +1,23 @@ +
+ +
+ <%= @title %> +
+ + <%= form_tag twofa_update_url do %> +

+ <%= t('.enablecode') %> +

+ +
+ <%= label_tag :totp_code, "TOTP Code:", :class => "required" %> + <%= number_field_tag :totp_code, "", :size => 10, :autocomplete => "off", + :autofocus => true, :class => "totp_code" %> +
+ +

+ <%= submit_tag t('.verifyenable') %> + <% end %> +

diff --git a/app/views/signup/invited.html.erb b/app/views/signup/invited.html.erb index 74fe4dc..8ccb84c 100644 --- a/app/views/signup/invited.html.erb +++ b/app/views/signup/invited.html.erb @@ -3,8 +3,7 @@ Create an Account
- <%= 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 %>

@@ -28,7 +27,7 @@ <%= f.label :username, "Username:", :class => "required" %> <%= f.text_field :username, :size => 30 %> - <%= User.username_regex %> + <%= User.username_regex_s %>
diff --git a/app/views/stories/_form.html.erb b/app/views/stories/_form.html.erb index 6eb32bb..288d2d1 100644 --- a/app/views/stories/_form.html.erb +++ b/app/views/stories/_form.html.erb @@ -38,7 +38,7 @@

<%= f.label :title, t('.title'), :class => "required" %> - <%= f.text_field :title, :maxlength => 100, :autocomplete => "off" %> + <%= f.text_field :title, :maxlength => 100 %>
<% if f.object.id && !defined?(suggesting) %> @@ -101,8 +101,7 @@
<%= f.label :description, t('.text'), :class => "required" %> <%= f.text_area :description, :rows => 15, - :placeholder => t('.placeholdertext'), - :autocomplete => "off" %> + :placeholder => t('.placeholdertext') %>
diff --git a/app/views/stories/_listdetail.html.erb b/app/views/stories/_listdetail.html.erb index e62f0b0..c88bc8b 100644 --- a/app/views/stories/_listdetail.html.erb +++ b/app/views/stories/_listdetail.html.erb @@ -26,7 +26,8 @@ class="story <%= story.vote && story.vote[:vote] == 1 ? "upvoted" : "" %> <% end %> <% if story.markeddown_description.present? %> - + <% end %> <% if story.can_be_seen_by_user?(@user) %> @@ -62,10 +63,8 @@ class="story <%= story.vote && story.vote[:vote] == 1 ? "upvoted" : "" %> <% end %> <% end %> <% end %> @@ -92,10 +95,8 @@ class="story <%= story.vote && story.vote[:vote] == 1 ? "upvoted" : "" %> diff --git a/app/views/stories/show.html.erb b/app/views/stories/show.html.erb index d88eb91..efe6449 100644 --- a/app/views/stories/show.html.erb +++ b/app/views/stories/show.html.erb @@ -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 %> +
+ +
"> + <% dragons = true %> + <% end %> +
  • <%= render "comments/comment", :comment => comment, :show_story => (comment.story_id != @story.id), @@ -64,5 +77,10 @@
  • <% end %> <% end %> + + <% if dragons %> +
    +
    + <% end %> <% end %> diff --git a/app/views/users/_invitationform.html.erb b/app/views/users/_invitationform.html.erb index 3e78ec7..5869b96 100644 --- a/app/views/users/_invitationform.html.erb +++ b/app/views/users/_invitationform.html.erb @@ -9,7 +9,7 @@
    <%= label_tag :email, t(:emailaddress), :class => "required" %> - <%= text_field_tag :email, "", :size => 30, :autocomplete => "off" %> + <%= email_field_tag :email, "", :size => 30, :autocomplete => "off" %>
    diff --git a/app/views/users/list.html.erb b/app/views/users/list.html.erb index 3815cbe..d4e051e 100644 --- a/app/views/users/list.html.erb +++ b/app/views/users/list.html.erb @@ -15,11 +15,10 @@ ><%= user.username %> <% if user.is_admin? %> <%= t('.moderator') %> + <% elsif user.is_moderator? %> + <%= t('.moderator') %> <% else %> (<%= user.karma %>) - <% if user.is_moderator? %> - <%= t('.administrator') %> - <% end %> <% end %> <% end %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 025cec0..19f3679 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -17,9 +17,7 @@ <% if @showing_user.is_active? %>
    - + <%= avatar_img(@showing_user, 100) %>
    <% end %> @@ -49,7 +47,7 @@ <%= 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 @@
    <% end %> - <% if @showing_user.hats.any? %> - - - <% @showing_user.hats.each do |hat| %> - <%= hat.to_html_label %> - <% end %> - -
    - <% end %> - <% if @showing_user.deleted_at? %> @@ -117,6 +105,38 @@
    + <% if @showing_user.hats.any? %> + +
    + <% @showing_user.hats.each do |hat| %> + <%= hat.to_html_label %> + <% end %> +
    +
    + <% end %> + + + <% if @showing_user.github_username.present? %> + + + + https://github.com/<%= h(@showing_user.github_username) + %> + +
    + <% end %> + + <% if @showing_user.twitter_username.present? %> + + + + @<%= h(@showing_user.twitter_username) %> + +
    + <% end %> + <% if @showing_user.is_active? %> diff --git a/app/views/users/tree.html.erb b/app/views/users/tree.html.erb index 17843cf..99625cf 100644 --- a/app/views/users/tree.html.erb +++ b/app/views/users/tree.html.erb @@ -3,21 +3,23 @@ <%= @title %> (<%= @user_count %>)

    -

    - <%= t('.newestusers') %> - <%= raw @newest.map{|u| "#{u.username} " << - "(#{u.karma})" }.join(", ") %> -

    + <% if @newest %> +

    + <%= t('.newestusers') %> + <%= raw @newest.map{|u| "#{u.username} " << + "(#{u.karma})" }.join(", ") %> +

    + <% end %> -
      +
        <% subtree = @users_by_parent[nil] %> <% ancestors = [] %> <% while subtree %> <% if (user = subtree.pop) %> -
      • +
      • "> class="inactive_user" @@ -27,11 +29,10 @@ ><%= user.username %> <% 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 %> diff --git a/config/application.rb b/config/application.rb index e0b69f0..3401516 100644 --- a/config/application.rb +++ b/config/application.rb @@ -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" diff --git a/config/environments/production.rb b/config/environments/production.rb index b4ee2c7..102dc15 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -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 diff --git a/config/environments/test.rb b/config/environments/test.rb index e21872a..635a19d 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index fe366c4..60c701e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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    4 spaces" inlineimage: "(inline image)" inlineimagedesc: "![alt text](http://example.com/image.jpg) (only allowed in story text)" + hats: + build_request: + title: "Request a Hat" + description: "

        A hat is a formal, verified, way of posting a comment while speaking for a project, organization, or company. Each user may have multiple hats, one of which may be worn at any time when posting a comment or sending a private message.

        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.

        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.

        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.

        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.

        " + 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: "The newest stories with a random sampling of recently submitted stories that have not yet reached the front page." @@ -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: "Gravatar'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 TOTP 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: "— here be dragons —" users: list: administrator: "administrator" @@ -372,7 +462,7 @@ en: storysubmissions: "with story submissions" disabled: "disabled" joined: "Joined:" - byinvitationfrom: "by invitation from" + byinvitationfrom: "by invitation 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" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 84f19f4..9a9231f 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -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     4 espaces" inlineimage: "(image en ligne)" inlineimagedesc: "![texte de substitution](http://example.com/image.jpg) (autorisé seulement dans les articles)" + hats: + build_request: + title: "Demander un chapeau" + 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é.

        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.

        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é.

        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.

        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.

        " + 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: "Les plus récentes infos avec un panaché aléatoire des infos récentes soumises qui n'ont pas encore atteint la page principale." @@ -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: "Gravatarisé" @@ -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 TOTP 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: "— voici les dragons —" 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 invitation 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" diff --git a/config/routes.rb b/config/routes.rb index e2320d3..5d47fe2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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" diff --git a/db/migrate/20170119172852_move_user_settings.rb b/db/migrate/20170119172852_move_user_settings.rb new file mode 100644 index 0000000..a0a19eb --- /dev/null +++ b/db/migrate/20170119172852_move_user_settings.rb @@ -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 diff --git a/db/migrate/20170119192703_add_dragons.rb b/db/migrate/20170119192703_add_dragons.rb new file mode 100644 index 0000000..ed03dd0 --- /dev/null +++ b/db/migrate/20170119192703_add_dragons.rb @@ -0,0 +1,5 @@ +class AddDragons < ActiveRecord::Migration + def change + add_column :comments, :is_dragon, :boolean, :default => false + end +end diff --git a/db/migrate/20170225201811_delete_old_settings.rb b/db/migrate/20170225201811_delete_old_settings.rb new file mode 100644 index 0000000..05d51ef --- /dev/null +++ b/db/migrate/20170225201811_delete_old_settings.rb @@ -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 diff --git a/db/migrate/20170413161450_add_indexes.rb b/db/migrate/20170413161450_add_indexes.rb new file mode 100644 index 0000000..85b2a69 --- /dev/null +++ b/db/migrate/20170413161450_add_indexes.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index fe2d5ef..2389c98 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/extras/github.rb b/extras/github.rb new file mode 100644 index 0000000..5904dae --- /dev/null +++ b/extras/github.rb @@ -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 diff --git a/extras/markdowner.rb b/extras/markdowner.rb index 095238d..1be448a 100644 --- a/extras/markdowner.rb +++ b/extras/markdowner.rb @@ -1,55 +1,74 @@ class Markdowner # opts[:allow_images] allows 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

        ,

        , 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 s - next - end - - tx = t.text.gsub(/\B\@([\w\-]+)/) do |u| - if User.exists?(:username => u[1 .. -1]) - "#{u}" - 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 diff --git a/extras/pushover.rb b/extras/pushover.rb index ad1cb5d..163f325 100644 --- a/extras/pushover.rb +++ b/extras/pushover.rb @@ -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 diff --git a/extras/story_cacher.rb b/extras/story_cacher.rb index a1f344e..21cea43 100644 --- a/extras/story_cacher.rb +++ b/extras/story_cacher.rb @@ -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 diff --git a/extras/twitter.rb b/extras/twitter.rb index 6a7f5db..8650b07 100644 --- a/extras/twitter.rb +++ b/extras/twitter.rb @@ -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 diff --git a/public/404.html b/public/404.html deleted file mode 100644 index 86724f7..0000000 --- a/public/404.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - 404 - - -

        gone fishin'

        - - diff --git a/public/robots.txt b/public/robots.txt index aa94cad..87a97c0 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -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 diff --git a/script/mail_new_activity b/script/mail_new_activity index 1214263..a084264 100755 --- a/script/mail_new_activity +++ b/script/mail_new_activity @@ -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 diff --git a/script/post_to_twitter b/script/post_to_twitter index aea3583..d59f495 100755 --- a/script/post_to_twitter +++ b/script/post_to_twitter @@ -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 diff --git a/spec/models/markdowner_spec.rb b/spec/models/markdowner_spec.rb index 63c3eee..81776c6 100644 --- a/spec/models/markdowner_spec.rb +++ b/spec/models/markdowner_spec.rb @@ -6,6 +6,16 @@ describe Markdowner do "

        hello there italics and bold!

        " end + it "turns @username into a link if @username exists" do + User.make!(:username => "blahblah") + + Markdowner.to_html("hi @blahblah test").should == + "

        hi @blahblah test

        " + + Markdowner.to_html("hi @flimflam test").should == + "

        hi @flimflam test

        " + end + # bug#209 it "keeps punctuation inside of auto-generated links when using brackets" do Markdowner.to_html("hi test").should == @@ -25,4 +35,17 @@ describe Markdowner do "

        hi " << "test

        " end + + it "correctly adds nofollow" do + Markdowner.to_html("[ex](http://example.com)").should == + "

        " << + "ex

        " + + Markdowner.to_html("[ex](//example.com)").should == + "

        " << + "ex

        " + + Markdowner.to_html("[ex](/u/abc)").should == + "

        ex

        " + end end