Commit 593aac47 authored by David Lucadou's avatar David Lucadou

Merge branch 'development' into 'master'

0.8.7 - A bans system, tests, an admin panel, and more!

Closes #76, #77, #59, #57, #53, #54, #55, #52, #51, #40, #35, #24, #60, #62, #65, #48, #39, and #29

See merge request !12
parents 8c7e94fc e5d93b4d
Pipeline #61326452 canceled with stage
in 17 seconds
......@@ -15,6 +15,7 @@
/tmp/*
!/log/.keep
!/tmp/.keep
/config/config.yml
/config/secrets.yml
/node_modules
......
image: selenium/hub:latest
image: selenium/standalone-chrome:latest
image: "ruby:2.5"
services:
- postgres:10
- selenium/hub:latest
- selenium/standalone-chrome:latest
variables:
POSTGRES_DB: irc-log-explorer_test
POSTGRES_USER: ircsearch
POSTGRES_PASSWORD: "rubypass"
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
SELENIUM_HOST: selenium-chrome
SELENIUM_PORT: 4444
before_script:
- apt-get update -qq && apt-get install -y -qq build-essential patch ruby-dev zlib1g-dev liblzma-dev imagemagick libssl-dev xvfb
- gem install bundler
- bundler install
- export RAILS_ENV=test
- mv config/config.yml.example config/config.yml
- mv config/secrets.yml.example config/secrets.yml
- bundler exec rake db:migrate:reset db:test:prepare
rspec-integration:
services:
- name: postgres
- name: selenium/hub
alias: selenium
- name: selenium/standalone-chrome
alias: selenium-chrome
script:
- xvfb-run -a bundle exec rspec --pattern spec/requests/*_spec.rb
# Changelog
All notable changes to this project will be documented in this file.
## [0.8.8] - 2019-??-??
### Added
- Cleaning test DB before each test run
- Tests for changing password on account security page
- Tests for chat log editing & deletion
- Tests for items unable to use integration tests in issue #31
- Tests for TOTP and U2F based 2FA
- Tests for U2F based 2FA
### Changed
- Increased ease of deploying to Heroku
- Moved all views to use new error messages view
## [0.8.7] - 2019-05-14
### Added
- Bans system
- Easy way to promote to admin or demote to user
- Easy way to disable PNG QR code generation via config file
- Error message when user executes blank basic search
- GitLab CI for tests
- Instructions for deploying with Nginx and auto-starting Puma on boot
- Logging all searches
- Logging report and search browsing
- Notes about deploying to Heroku in readme
- Password change field in security settings
- Permissions for reports views
- Starting work on admin console
- Search on reports page
- Tests for admin report searches
- Tests for chat log basic & advanced searches
- Tests for report creation
- Tests for report deletion
- Tests for report editing
- Tests for report viewing
- Tests for TOTP based 2FA
- Tests for recovery codes
- Tests for user report searches
- Title for chat logs index
- Title for EULA view
- Removing TOTP can be done with U2F tokens
- Removing U2F tokens requires 2FA
- User management admin panel
### Changed
- Fixed broken datetime picker
- Fixed issue with 2FA allowing TOS acceptance bypass
- Fixed issue with flashes not clearing until second refresh
- Fixed issue with invalid per page values being used sometimes
- Fixed issue with TOTP code not copying to clipboard until refresh
- Fixed issue with TOTP QR code being generated with invalid format
- Fixed issue with rake db commands failing due to factories in tests
- Fixed issue with some themes having tough to read text in tables
- Fixed issues with model associations & field names preventing user deletion
- Fixed mobile formatting for chat logs & reports
- Fixed spacing issues for advanced search page
- Increased spacing consistency for many views
- Made navbar be fixed to top
- Moved Devise error messages into their own view
- Revised phrasing in account overview tab
- Removed Delete buttons from Admin reports view
- Removed Show/Resolve buttons from User reports view
- Removed unnecessary borders from Settings views
- Updated Boostrap, datetime picker, webauth, and other Gems
- Validation for preferences moved from controller to concerns
## [0.8.6] - 2019-01-25
### Added
- Decimal restriction for editing chat log time
......
......@@ -25,10 +25,10 @@ gem 'uglifier', '>= 1.3.0'
# gem 'therubyracer', platforms: :ruby
# Nokogiri
gem 'nokogiri', '~> 1.9.1'
gem 'nokogiri', '~> 1.10.1'
# Use CoffeeScript for .coffee assets and views
gem 'coffee-rails', '~> 4.2'
gem 'coffee-rails', '~> 5.0.0'
# Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks
gem 'turbolinks', '~> 5'
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
......@@ -42,8 +42,8 @@ gem 'jbuilder', '~> 2.5'
# gem 'capistrano-rails', group: :development
# Use Bootstrap & Jquery
gem 'bootstrap', '~> 4.2.1'
gem 'bootstrap4-datetime-picker-rails', '~>0.2.0'
gem 'bootstrap', '~> 4.3.1'
gem 'bootstrap4-datetime-picker-rails', '~>0.3.1'
gem 'momentjs-rails', '~>2.20.1'
gem 'sprockets-rails', '~> 3.2.1'
gem 'jquery-rails'
......@@ -56,7 +56,7 @@ gem 'kaminari', '~>1.1.1'
gem 'font-awesome-sass'
# Authentication
gem 'devise', '~>4.5.0'
gem 'devise', '~>4.6.1'
gem 'omniauth'
gem 'omniauth-discord'
gem 'omniauth-facebook'
......@@ -70,9 +70,9 @@ gem 'omniauth-twitch'
# 2FA - TOTP and U2F
gem 'devise-two-factor', '~>3.0.3'
gem 'rqrcode-rails3', '~>0.1.7'
gem 'mini_magick', '~>4.9.2'
gem 'mini_magick', '~>4.9.3'
gem 'attr_encrypted', '~> 3.1.0'
gem 'webauthn', '~> 1.7.0'
gem 'webauthn', '~> 1.14.0'
gem 'rollbar', '~> 2.16'
# Clipboard.JS
......@@ -87,13 +87,21 @@ group :development, :test do
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
gem 'pry-byebug'
# Adds support for Capybara system testing and selenium driver
gem 'capybara', '~> 3.12'
gem 'capybara', '~> 3.18'
gem 'webdrivers', '~> 3.0'
gem 'headless'
gem 'selenium-webdriver'
gem 'factory_bot_rails', '~>5.0.1', :require => false
# The reason I have this not required is because if I remove it, whenever
# rake db commands execute, it executes factories. I don't know why it
# runs factory code if it's not running a test, but in any case, it causes
# all rake db commands to fail - migrate, drop, create, etc.
# https://stackoverflow.com/a/12425729
end
group :development do
# Access an IRB console on exception pages or by using <%= console %> anywhere in the code.
gem 'web-console', '>= 3.3.0'
gem 'web-console', '>= 3.7.0'
gem 'listen', '>= 3.0.5', '< 3.2'
# Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
gem 'spring'
......
This diff is collapsed.
This diff is collapsed.
# Place all the behaviors and hooks related to the matching controller here.
# All this logic will automatically be available in application.js.
# You can use CoffeeScript in this file: http://coffeescript.org/
accordionWatcher = ->
$('.accordion-row-toggler').on 'click', (event) ->
console.log($(this)[0].children[0]);
if $(this).has('i')
if $(this)[0].children[0].classList.contains('fa-chevron-circle-down')
$(this)[0].children[0].className = $(this)[0].children[0].className.replace('fa-chevron-circle-down', 'fa-chevron-circle-up')
else
$(this)[0].children[0].className = $(this)[0].children[0].className.replace('fa-chevron-circle-up', 'fa-chevron-circle-down')
$(document).on('turbolinks:load', accordionWatcher)
# Prevent Turbolinks errors (hasn't happened yet, but just in case)
\ No newline at end of file
......@@ -16,7 +16,7 @@
//= require popper
//= require bootstrap
//= require moment
//= require tempusdominus-bootstrap-4.js
//= require tempusdominus-bootstrap-4
//= require clipboard
//= require_tree .
......
$(document).ready(function(){
//$(document).ready(function(){
$(window).bind('turbolinks:load', function(){
/* I use 'turbolinks:load' here because I need to wait for Turbolinks or
* this does not work. I'm not entirely sure why since theoretically waiting
* for document ready should ensure Turbolinks has done all it needs to,
* and changing a value shouldn't break the ability to select an element
* by its ID, but that's not how it works in practice. Without this, you
* have to refresh the page to get the copy to clipboard working.
* I had a similar issue with credentials.js partially working if you didn't
* refresh the page that was also solved by waiting for Turbolinks.
* https://stackoverflow.com/a/40698512
*/
$('.clipboard-btn').tooltip({
trigger: 'click',
placement: 'bottom'
......
......@@ -24,6 +24,8 @@ function callback(url, body) {
var redirectUrl;
if (window.location.pathname === "/account/auth/2fa/u2f/prompt") {
redirectUrl = "/";
} else if (window.location.pathname === "/account/security/2fa/totp/u2f") {
redirectUrl = "/account/security";
} else {
redirectUrl = "/account/security/2fa/u2f/keys";
}
......@@ -55,11 +57,7 @@ function get(credentialOptions) {
navigator.credentials.get({ "publicKey": credentialOptions }).then(function(credential) {
var assertionResponse = credential.response;
var callbackUrl;
if (window.location.pathname === "/account/auth/2fa/u2f/prompt") {
callbackUrl = window.location.pathname;
} else {
callbackUrl = "/account/security/2fa/u2f/add";
}
callbackUrl = window.location.pathname;
callback(callbackUrl, {
id: binToStr(credential.rawId),
......
document.addEventListener("DOMContentLoaded", function(event) {
var sessionForm = document.querySelector("#new-session")
//document.addEventListener("DOMContentLoaded", function(event) {
$(document).on('turbolinks:load', function() {
var sessionForm = document.querySelector("#new-session");
if (sessionForm) {
sessionForm.addEventListener("ajax:success", function(event) {
......@@ -11,7 +12,7 @@ document.addEventListener("DOMContentLoaded", function(event) {
if (credentialOptions["user"]) {
credentialOptions["user"]["id"] = strToBin(credentialOptions["user"]["id"]);
credential_nickname = document.querySelector("#new-session input[name='session[nickname]']").value;
callback_url = `/callback?credential_nickname=${credential_nickname}`
callback_url = `/callback?credential_nickname=${credential_nickname}`;
create(encodeURI(callback_url), credentialOptions);
} else {
......
// Place all the styles related to the admin/dashboard controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/
......@@ -12,7 +12,7 @@
*
*/
@import "bootstrap";
@import "tempusdominus-bootstrap-4.css";
@import "tempusdominus-bootstrap-4";
@import "font-awesome-sprockets";
@import "font-awesome";
@import "chat_logs_style";
......@@ -44,3 +44,31 @@ div.pagination.justify-content-center nav {
div, which only exists when the paginator is there, thus bringing back
the margin only when there are results to paginate. */
}
.force-left-align {
align-items: normal !important;
justify-content: left !important;
/* Some Bootstrap elements force center alignment, this forces left alignment
when "text-left" is being ignored. */
}
/* Force 2 columns on md+ devices to match the behavior of col-md-6's
* rows.
*/
div.card-columns.settings-columns {
-webkit-column-count: 2;
-moz-column-count: 2;
column-count: 2;
}
/* Force 1 column on sm devices to match the behavior of col-md-6's
* inside rows.
*/
@media (max-width: 767px) {
div.card-columns.settings-columns {
-webkit-column-count: 1;
-moz-column-count: 1;
column-count: 1;
}
}
......@@ -4,3 +4,16 @@
*@import "themes/dark/darkly_bootswatch";
* Not entirely sure I need these, the site seems just fine without them.
*/
/* Make table text more readable */
.table {
color: inherit;
}
/* Href colors in highlighted table rows is invisible because
* it matches the background, this overrides the default color
* and makes them visible again.
*/
.table tr.table-primary td a.text-primary {
color: #FFF !important;
}
......@@ -27,3 +27,16 @@ input.form-control.placeholder-text::-ms-input-placeholder {
button.btn.btn-outline-dark {
color: #F5F5F5;
}
/* Make table text more readable */
.table {
color: inherit;
}
/* Href colors in highlighted table rows is invisible because
* it matches the background, this overrides the default color
* and makes them visible again.
*/
.table tr.table-primary td a.text-primary {
color: #F5F5F5 !important;
}
class Admin::BansController < ApplicationController
after_action :clear_flashes
before_action :authenticate_user!
before_action :admin_tab_badge_data
before_action :set_ban, only: [:show, :edit, :update, :destroy]
before_action :set_user, only: [:show, :new, :edit, :create, :update]
before_action :verify_permissions
def index
end
def new
@admin_user_profile_page = true
# current_page? cannot properly target this page, so I have to use
# this variable instead.
@prefs = {}
@prefs[:max_external_reason_length] = Ban.max_external_reason_length
# Set datetime format variable for date picker in view to reference
@prefs[:js_dt_format] = date_format = date_formats(action: :match, format_value: user_datetime_format(current_user.id, format: :date)).to_s
@prefs[:js_dt_format] << " "
@prefs[:js_dt_format] << time_format(format: :momentjs, use_24hr_time: UserPreference.where(:user_id => current_user.id).first[:use_24hr_time], seconds: false)
# https://tempusdominus.github.io/bootstrap-4/Options/#format
# http://momentjs.com/docs/#/displaying/format/
# Set datetime format variable for help menu in view to reference
if @prefs[:js_dt_format].end_with?(" A")
@prefs[:user_dt_format] = @prefs[:js_dt_format][0...@prefs[:js_dt_format].length - 1]
@prefs[:user_dt_format] << "AM/PM"
# Remove "A" from end and replace with "AM/PM" if 12 hour time
else
@prefs[:user_dt_format] = @prefs[:js_dt_format]
# 24 hour time needs no modifications
end
if params[:type] == 'adjust'
last_ban = @user.bans.last
@ban = Ban.new(internal_reason: last_ban.internal_reason, external_reason: last_ban.external_reason, ban_end_time: datetime_with_offset(last_ban.ban_end_time, show_offset: false, use_seconds: false))
@prefs[:revise] = @user.is_banned?
# I check if the user is banned to prevent incorrect text being
# displayed if the user is unbanned before the page loads
@prefs[:unban] = false
else
@ban = Ban.new
@prefs[:unban] = @user.is_banned?
end
end
def create
@admin_user_profile_page = true
# current_page? cannot properly target this page, so I have to use
# this variable instead.
@prefs = {}
@prefs[:max_external_reason_length] = Ban.max_external_reason_length
# Set datetime format variable for date picker in view to reference
@prefs[:js_dt_format] = date_format = date_formats(action: :match, format_value: user_datetime_format(current_user.id, format: :date)).to_s
@prefs[:js_dt_format] << " "
@prefs[:js_dt_format] << time_format(format: :momentjs, use_24hr_time: UserPreference.where(:user_id => current_user.id).first[:use_24hr_time], seconds: false)
# https://tempusdominus.github.io/bootstrap-4/Options/#format
# http://momentjs.com/docs/#/displaying/format/
# Set datetime format variable for help menu in view to reference
if @prefs[:js_dt_format].end_with?(" A")
@prefs[:user_dt_format] = @prefs[:js_dt_format][0...@prefs[:js_dt_format].length - 1]
@prefs[:user_dt_format] << "AM/PM"
# Remove "A" from end and replace with "AM/PM" if 12 hour time
else
@prefs[:user_dt_format] = @prefs[:js_dt_format]
# 24 hour time needs no modifications
end
if params[:type] == 'adjust'
@prefs[:revise] = @user.is_banned?
# I check if the user is banned to prevent incorrect text being
# displayed if the user is unbanned before the page loads
@prefs[:unban] = false
else
@prefs[:unban] = @user.is_banned?
end
# Convert DateTime to String
if ban_params[:ban_end_time] && ban_params[:ban_end_time].length > 0
params[:ban][:ban_end_time] = DateTime.strptime(datetime_from_str(ban_params[:ban_end_time], apply_offset: true, uses_seconds: false).to_s, '%Q')
end
# Set some fields if ban_reversal (if a user does fill these in, they
# tampered with the form and it is safe to overwrite them)
if ban_params[:ban_reversal] && ban_params[:ban_reversal] == 'true'
params[:ban][:banned_until] = nil
params[:ban][:permanently_banned] = false
end
@ban = Ban.new(ban_params)
@ban.user_id = @user.id
@ban.banning_user = current_user.id
# Check for bad_adjustment param, which indicates the user is changing the
# ban length of the most recent ban (the render logic below ensures this
# is param is not lost, which would mean the form would adjust to the
# unban state, which is very different from the adjust/ban form)
if params[:ban][:ban_adjustment]
adjustment = true
end
respond_to do |format|
if @ban.save
@user.banned_until = @ban.ban_end_time
@user.permanently_banned = @ban.permanent_ban
@user.ban_reason_internal = @ban.internal_reason
@user.ban_reason_external = @ban.external_reason
if @user.save
format.html { redirect_to admin_manage_user_path(@user), notice: 'Ban instantiated.' }
format.json { render :show, status: :created, location: @ban }
else
if adjustment
format.html { render :new, params[:type] => 'adjust' }
else
format.html { render :new }
end
format.json { render json: @user.errors, status: :unprocessable_entity }
end
else
if adjustment
format.html { render :new, params[:type] => 'adjust' }
else
format.html { render :new }
end
format.json { render json: @ban.errors, status: :unprocessable_entity }
end
end
end
def show
end
def update
end
def destroy
end
private
def ban_params
params.require(:ban).permit(:user_id, :banning_user, :ban_end_time, :permanent_ban, :ban_reversal, :internal_reason, :external_reason)
end
def verify_permissions
if permissions_list[action_name.to_sym]&.index(current_user.role.to_sym).nil?
# action_name is a Rails method for the controller method name
# ( https://stackoverflow.com/a/4274222 )
# Safe navigation operator (&) only calls if not nil.
flash[:error] = I18n.t('.activerecord.errors.permissions.general.unauthorized')
redirect_to root_path #, :status => :bad_request
# I cannot return HTTP 403 with content, so the user gets a page that
# says "You are being redirected." with a link to the root page.
# Returning 200 is not best practice here but it looks best to the
# user who never has to see an unformatted redirect page.
end
end
def permissions_list
{ :index => [:admin], :new => [:admin], :create => [:admin], :show => [:admin], :delete => [:admin], :update => [:admin] }
end
def set_ban
@ban = Ban.find(params[:id])
end
def set_user
@user = User.find_by_id(params[:user])
end
end
\ No newline at end of file
This diff is collapsed.
require 'base64'
class ApplicationController < ActionController::Base
before_action :set_current_user, :clear_flashes
after_action :clear_flashes, except: [:authenticate_user!]
before_action :set_current_user
before_action :configure_permitted_parameters, if: :devise_controller?
protect_from_forgery with: :exception, prepend: true
helper_method :registerable, :datetime_with_offset, :datetime_to_epoch, :user_datetime_format
protect_from_forgery with: :exception, prepend: true
def registerable
# eventually, this will read in from a settings file
# Eventually, this will read in from a settings file
registerable = true
return registerable
end
protected
def admin_tab_badge_data
# For the unread reports tab badge
@UnresolvedReports = Report.where(:resolved_at => nil).count
end
def clear_flashes
flash.discard
end
......@@ -33,13 +38,35 @@ class ApplicationController < ActionController::Base
# Credit to https://stackoverflow.com/a/23585023 for this method
# (however, before_filter doesn't work so I have to use before_action)
# and https://stackoverflow.com/a/4683890 for *args
if user_signed_in?
if user_signed_in? && !current_user.is_banned?
super
elsif user_signed_in? # User is banned
if current_user.permanently_banned
notice = "You have been permanently banned. Reason: #{current_user.ban_reason_external}<br />You may appeal this decision by contacting the administrators."
else
notice = "You have been banned until #{datetime_with_offset(current_user.banned_until, show_offset: true, use_seconds: false)}. Reason: #{current_user.ban_reason_external}<br />You may appeal this decision by contacting the administrators."
end
sign_out current_user
redirect_to login_path, :notice => notice
else
redirect_to login_path, :notice => 'You must be signed in to complete this action'
end
end
def verify_not_banned! (*args)
if user_signed_in? && current_user.is_banned?
# user_signed_in? prevents exceptions from current_user being nil
# when not signed in
if current_user.permanently_banned
notice = "You have been permanently banned. Reason: #{current_user.ban_reason_external}<br />You may appeal this decision by contacting the administrators."
else
notice = "You have been banned until #{datetime_with_offset(current_user.banned_until, show_offset: true, use_seconds: false)}. Reason: #{current_user.ban_reason_external}<br />You may appeal this decision by contacting the administrators."
end
sign_out current_user
flash[:notice] = notice
end
end
private
def str_to_bin(str)
......@@ -73,13 +100,14 @@ class ApplicationController < ActionController::Base
end
end
def datetime_with_offset(datetime, user_id: nil, format: nil, apply_offset: true, show_offset: false)
def datetime_with_offset(datetime, user_id: nil, format: nil, apply_offset: true, show_offset: false, use_seconds: true)
# This method takes a datetime (as an int (epoch timestamp), DateTime,
# or ActiveSupport::TimeWithZone) and converts it into a String
# representation in the signed in user's format with their offset.
return nil if datetime.nil?
user_id ||= current_user.id
user_id ||= Current.user.id
format ||= user_datetime_format(user_id)
format ||= user_datetime_format(user_id, seconds: use_seconds)
if datetime.is_a?(ActiveSupport::TimeWithZone) || datetime.is_a?(DateTime)
datetime = datetime.utc.to_i * 1000 # convert to millisecond epochtime
......@@ -96,6 +124,24 @@ class ApplicationController < ActionController::Base
return date
end
def datetime_from_str(date_str, format: nil, user_id: nil, apply_offset: false, undo_offset: false, uses_seconds: true)
if !apply_offset.nil? && !undo_offset.nil? && apply_offset && undo_offset
raise ArgumentError, "Cannot have both apply_offset and undo_offset"
end
# Get user's datetime format
user_id ||= Current.user.id
user_id ||= current_user.id
format ||= user_datetime_format(user_id, seconds: uses_seconds)
tz_offset = user_tz_offset(user_id) if apply_offset
tz_offset = user_tz_offset(user_id) * -1 if undo_offset
tz_offset ||= 0
# Convert str to epoch time (in milliseconds)
date_dt = DateTime.strptime(date_str, format).to_time.utc.to_i * 1000 - tz_offset
end
# Convert human readable timestamp to epoch timestamp
def datetime_to_epoch(datetime, format: nil, user_id: nil, apply_offset: true)
# Get user's datetime format
......@@ -107,7 +153,16 @@ class ApplicationController < ActionController::Base
tz_offset ||= 0
# Convert DateTime/ActiveSupport::TimeWithZone objects straight to epochtime
if datetime.class == (ActiveSupport::TimeWithZone || DateTime)
if ([datetime.class] & [ActiveSupport::TimeWithZone, DateTime]).length > 0
# & is the set intersection operator. Basically, I put datetime.class in
# 1 set, and intersect it with the set of date/time objects this method
# can handle, and the resulting array will be either:
# [] (no intersection, length 0),
# [DateTime] (1 intersection, length 1), or
# [ActiveSupport::TimeWithZone] (1 intersection, length 1).
# I use Set and Array interchangably in this context. They are not the
# same (Ruby has a Set type), but both have an intersection operator,
# so I'm simplifying a bit.
datetime = datetime.utc.to_i
return datetime * 1000 - tz_offset