diff --git a/.circleci/config.yml b/.circleci/config.yml
index d83e7122e..3df3f5de9 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1,13 +1,12 @@
version: 2.1
jobs:
- test:
+ test-ruby27:
docker:
- image: cimg/ruby:2.7-node
- image: cimg/mysql:8.0
command: [--default-authentication-plugin=mysql_native_password]
environment:
MYSQL_ROOT_HOST: '%'
- MYSQL_USER: 'root'
MYSQL_ROOT_PASSWORD: 'root'
MYSQL_DATABASE: 'qpixel_test'
- image: cimg/redis:7.0
@@ -23,21 +22,157 @@ jobs:
- checkout
- restore_cache:
keys:
- - qpixel-{{ checksum "Gemfile" }}
- - qpixel-
+ - qpixel-ruby27-{{ checksum "Gemfile.lock" }}
+ - qpixel-ruby27-
- run:
name: Install Bundler & gems
command: |
gem install bundler
bundle install --path=~/gems
+ - run:
+ name: Clean unnecessary gems
+ command: |
+ bundle clean --force
+ - save_cache:
+ key: qpixel-ruby27-{{ checksum "Gemfile.lock" }}
+ paths:
+ - ~/gems
+ - run:
+ name: Copy key
+ command: |
+ if [ -z "$MASTER_KEY" ]; then rm config/credentials.yml.enc; else echo "$MASTER_KEY" > config/master.key; fi
+ - run:
+ name: Prepare config & database
+ environment:
+ RAILS_ENV: test
+ command: |
+ cp config/database.sample.yml config/database.yml
+ cp config/storage.sample.yml config/storage.yml
+ bundle exec rails db:create
+ bundle exec rails db:schema:load
+ bundle exec rails db:migrate
+ bundle exec rails test:prepare
+ - run:
+ name: Current revision
+ command: |
+ git rev-parse $(git rev-parse --abbrev-ref HEAD)
+ - run:
+ name: Coveralls token
+ command: |
+ if [ -z "$COVERALLS_REPO_TOKEN" ]; then echo "Skipping coveralls"; else echo "repo_token: $COVERALLS_REPO_TOKEN" > .coveralls.yml; fi
+ - run:
+ name: Test
+ command: |
+ bundle exec rails test
+ - store_test_results:
+ path: "~/qpixel/test/reports"
+ system-test-ruby27:
+ docker:
+ - image: cimg/ruby:2.7-browsers
+ - image: cimg/mysql:8.0
+ command: [--default-authentication-plugin=mysql_native_password]
+ environment:
+ MYSQL_ROOT_HOST: '%'
+ MYSQL_ROOT_PASSWORD: 'root'
+ MYSQL_DATABASE: 'qpixel_test'
+ - image: cimg/redis:7.0
+
+ working_directory: ~/qpixel
+
+ steps:
+ - run:
+ name: Install packages
+ command: |
+ sudo apt-get --allow-releaseinfo-change -qq update
+ sudo apt-get -y install git libmariadb-dev libmagickwand-dev
+ - checkout
+ - restore_cache:
+ keys:
+ - qpixel-ruby27-{{ checksum "Gemfile.lock" }}
+ - qpixel-ruby27-
+ - run:
+ name: Install Bundler & gems
+ command: |
+ gem install bundler
+ bundle install --path=~/gems
+ - run:
+ name: Clean unnecessary gems
+ command: |
+ bundle clean --force
+ - save_cache:
+ key: qpixel-ruby27-{{ checksum "Gemfile.lock" }}
+ paths:
+ - ~/gems
+ - run:
+ name: Copy key
+ command: |
+ if [ -z "$MASTER_KEY" ]; then rm config/credentials.yml.enc; else echo "$MASTER_KEY" > config/master.key; fi
+ - run:
+ name: Prepare config & database
+ environment:
+ RAILS_ENV: test
+ command: |
+ cp config/database.sample.yml config/database.yml
+ cp config/storage.sample.yml config/storage.yml
+ bundle exec rails db:create
+ bundle exec rails db:schema:load
+ bundle exec rails db:migrate
+ bundle exec rails test:prepare
+ - run:
+ name: Current revision
+ command: |
+ git rev-parse $(git rev-parse --abbrev-ref HEAD)
+ - run:
+ name: Test
+ command: |
+ bundle exec rails test:system
+ - store_test_results:
+ path: "~/qpixel/test/reports"
+ - store_artifacts:
+ path: "~/qpixel/tmp/screenshots"
+ when: on_fail
+
+ test-ruby31:
+ docker:
+ - image: cimg/ruby:3.1-node
+ - image: cimg/mysql:8.0
+ command: [ --default-authentication-plugin=mysql_native_password ]
+ environment:
+ MYSQL_ROOT_HOST: '%'
+ MYSQL_ROOT_PASSWORD: 'root'
+ MYSQL_DATABASE: 'qpixel_test'
+ - image: cimg/redis:7.0
+
+ working_directory: ~/qpixel
+
+ steps:
+ - run:
+ name: Install packages
+ command: |
+ sudo apt-get --allow-releaseinfo-change -qq update
+ sudo apt-get -y install git libmariadb-dev libmagickwand-dev
+ - checkout
+ - restore_cache:
+ keys:
+ - qpixel-ruby31-{{ checksum "Gemfile.lock" }}
+ - qpixel-ruby31-
+ - run:
+ name: Install Bundler & gems
+ command: |
+ gem install bundler
+ bundle install --path=~/gems
+ - run:
+ name: Clean unnecessary gems
+ command: |
+ bundle clean --force
- save_cache:
- key: qpixel-{{ checksum "Gemfile" }}
+ key: qpixel-ruby31-{{ checksum "Gemfile.lock" }}
paths:
- ~/gems
- run:
name: Copy key
command: |
- echo "$MASTER_KEY" > config/master.key
+ if [ -z "$MASTER_KEY" ]; then rm config/credentials.yml.enc; else echo "$MASTER_KEY" > config/master.key; fi
- run:
name: Prepare config & database
environment:
@@ -56,17 +191,82 @@ jobs:
- run:
name: Coveralls token
command: |
- echo "repo_token: $COVERALLS_REPO_TOKEN" > .coveralls.yml
+ if [ -z "$COVERALLS_REPO_TOKEN" ]; then echo "Skipping coveralls"; else echo "repo_token: $COVERALLS_REPO_TOKEN" > .coveralls.yml; fi
- run:
name: Test
command: |
bundle exec rails test
- store_test_results:
path: "~/qpixel/test/reports"
+ system-test-ruby31:
+ docker:
+ - image: cimg/ruby:3.1-browsers
+ - image: cimg/mysql:8.0
+ command: [ --default-authentication-plugin=mysql_native_password ]
+ environment:
+ MYSQL_ROOT_HOST: '%'
+ MYSQL_ROOT_PASSWORD: 'root'
+ MYSQL_DATABASE: 'qpixel_test'
+ - image: cimg/redis:7.0
+
+ working_directory: ~/qpixel
+
+ steps:
+ - run:
+ name: Install packages
+ command: |
+ sudo apt-get --allow-releaseinfo-change -qq update
+ sudo apt-get -y install git libmariadb-dev libmagickwand-dev
+ - checkout
+ - restore_cache:
+ keys:
+ - qpixel-ruby31-{{ checksum "Gemfile.lock" }}
+ - qpixel-ruby31-
+ - run:
+ name: Install Bundler & gems
+ command: |
+ gem install bundler
+ bundle install --path=~/gems
+ - run:
+ name: Clean unnecessary gems
+ command: |
+ bundle clean --force
+ - save_cache:
+ key: qpixel-ruby31-{{ checksum "Gemfile.lock" }}
+ paths:
+ - ~/gems
+ - run:
+ name: Copy key
+ command: |
+ if [ -z "$MASTER_KEY" ]; then rm config/credentials.yml.enc; else echo "$MASTER_KEY" > config/master.key; fi
+ - run:
+ name: Prepare config & database
+ environment:
+ RAILS_ENV: test
+ command: |
+ cp config/database.sample.yml config/database.yml
+ cp config/storage.sample.yml config/storage.yml
+ bundle exec rails db:create
+ bundle exec rails db:schema:load
+ bundle exec rails db:migrate
+ bundle exec rails test:prepare
+ - run:
+ name: Current revision
+ command: |
+ git rev-parse $(git rev-parse --abbrev-ref HEAD)
+ - run:
+ name: Test
+ command: |
+ bundle exec rails test:system
+ - store_test_results:
+ path: "~/qpixel/test/reports"
+ - store_artifacts:
+ path: "~/qpixel/tmp/screenshots"
+ when: on_fail
rubocop:
docker:
- - image: cimg/ruby:2.7-node
+ - image: cimg/ruby:3.1-node
working_directory: ~/qpixel
@@ -79,15 +279,19 @@ jobs:
- checkout
- restore_cache:
keys:
- - qpixel-{{ checksum "Gemfile" }}
- - qpixel-
+ - qpixel-ruby31-{{ checksum "Gemfile.lock" }}
+ - qpixel-ruby31-
- run:
name: Install Bundler & gems
command: |
gem install bundler
bundle install --path=~/gems
+ - run:
+ name: Clean unnecessary gems
+ command: |
+ bundle clean --force
- save_cache:
- key: qpixel-{{ checksum "Gemfile" }}
+ key: qpixel-ruby31-{{ checksum "Gemfile.lock" }}
paths:
- ~/gems
- run:
@@ -97,7 +301,7 @@ jobs:
deploy:
docker:
- - image: cimg/ruby:2.7-node
+ - image: cimg/ruby:3.1-node
working_directory: ~/qpixel
@@ -115,11 +319,17 @@ jobs:
workflows:
test_lint:
jobs:
- - test
+ - test-ruby27
+ - test-ruby31
+ - system-test-ruby27
+ - system-test-ruby31
- rubocop
- deploy:
requires:
- - test
+ - test-ruby27
+ - test-ruby31
+ - system-test-ruby27
+ - system-test-ruby31
- rubocop
filters:
branches:
diff --git a/.github/ISSUE_TEMPLATE/bug-feature-via-meta.md b/.github/ISSUE_TEMPLATE/bug-feature-via-meta.md
new file mode 100644
index 000000000..9141bee85
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug-feature-via-meta.md
@@ -0,0 +1,16 @@
+---
+name: Bug/Feature via Meta
+about: Use when you're copying a bug/feature request here from Meta.
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+
+meta:123
+
+
+
+
+
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 000000000..3d933dccf
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,27 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Additional context**
+Add any other context about the problem here. Include device/browser/OS information if it's relevant.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 000000000..bbcbbe7d6
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.gitignore b/.gitignore
index 004c052c2..d665bfb07 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,13 +9,16 @@
# Docker environment (production)
docker/env
+# mount mysql volume so that its easy to interact with the database outside of the container.
+# This also allows persistent database storage
+docker/mysql
+# allow custom docker-compose files as users might have different needs
+docker-compose*.yml
+!docker-compose.yml
# Don't track changes to the docker-compose .env file only in project root
/.env
-# mount mysql volume so that its easy to interact with the database outside of the container. This also allows persistent database storage
-docker/mysql
-
# Ignore the default SQLite database.
/db/*.sqlite3
/db/*.sqlite3-journal
@@ -29,6 +32,7 @@ coverage/
coverage/*
.idea
+.vscode
test/reports
@@ -51,4 +55,11 @@ qpixel-import.tar.gz
# Ignore Vim stuff.
*.swp
+# Ignore emacs stuff.
+*~
+
dump.rdb
+
+# Ignore IRB files
+.irbrc
+.irb_history
diff --git a/.rubocop.yml b/.rubocop.yml
index 5e6705c20..edf12b0c0 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -4,7 +4,7 @@ require:
- ./lib/rubocop/path_in_helpers.rb
AllCops:
- TargetRubyVersion: 2.6
+ TargetRubyVersion: 2.7
Exclude:
- 'config/**/*'
- 'db/**/*'
@@ -43,13 +43,13 @@ Metrics/BlockLength:
Metrics/BlockNesting:
Max: 5
Metrics/ClassLength:
- Max: 300
+ Max: 350
Metrics/CyclomaticComplexity:
Max: 30
Metrics/MethodLength:
Max: 60
Metrics/ModuleLength:
- Max: 200
+ Max: 250
Metrics/PerceivedComplexity:
Enabled: false
diff --git a/.sample.irbrc b/.sample.irbrc
new file mode 100644
index 000000000..998084f49
--- /dev/null
+++ b/.sample.irbrc
@@ -0,0 +1 @@
+Qpixel.irb! if defined?(Qpixel)
diff --git a/CODE-STANDARDS.md b/CODE-STANDARDS.md
index ad30362a0..35e2888a9 100644
--- a/CODE-STANDARDS.md
+++ b/CODE-STANDARDS.md
@@ -121,7 +121,7 @@ Pseudo-classes and pseudo-element selectors should appear *after* the main selec
`@media` and other nested [*at-rules*](https://developer.mozilla.org/en-US/docs/Web/CSS/At-rule) should be added to
the end of the document, preceded by an extra blank line.
-See [*landing-page/primary.css @1ca2f671*](https://github.com/codidact/landing-page/blob/1ca2f671/dist/assets/css/primary.css)
+See [*landing-page/dist/assets/css/primary.css @1ca2f671*](https://github.com/codidact/landing-page/blob/1ca2f671/dist/assets/css/primary.css)
for an example of all of the above.
### Spacing
@@ -130,9 +130,9 @@ for an example of all of the above.
- Do not write more than one statement per line.
### Line breaks
-Rules should be separated by a blank line, except for the two special cases provided in item
-[**#3**](#Order-of-selectors) - namely, an extra blank line is expected between universal selectors and other
-selectors, as well as before nested _at-rules_. As such, these rule groups should be separated by *two* spaces.
+Rules should be separated by a blank line, except for the two special cases provided in
+[Order of selectors](#order-of-selectors) - namely, an extra blank line is expected between universal selectors and
+other selectors, as well as before nested _at-rules_. As such, these rule groups should be separated by *two* spaces.
All properties are written on their own line and end with a semicolon. The closing bracket must appear in its own
line.
@@ -259,7 +259,7 @@ When referencing external resources (including those local to the domain), do no
HTTPS access to resources if possible.
Prefer retrieving resources by canonical URIs when possible, i.e. those that do not redirect upon request. Check
-with a command-line tool or a service such as [apitester.com](https://apitester.com/) to be sure.
+with a command-line tool or a service such as [apitester.org](https://apitester.org/app) to be sure.
```html
@@ -379,20 +379,23 @@ When adding an ID or class to reference an element from JavaScript, prefix the v
#### ``
- If using `target="_blank"` to open links in a new tab, also include `rel="noopener noreferrer"`.
-- If a JS-enabled link is necessary (it normally shouldn't - see note below), prefer `href="#"` over `href="javascript:void(0)"` (and its equivalent `href="javascript:;"`). Please do combine this with `event.preventDefault()` in order to prevent unwanted scrolling and adding of pointless entries to the user's browsing history.
+- If a JS-enabled link is necessary (it normally shouldn't be - see note below), prefer `href="#"` over
+`href="javascript:void(0)"` (and its equivalent `href="javascript:;"`). Please do combine this with
+`event.preventDefault()` in order to prevent unwanted scrolling and adding of pointless entries to the user's browsing
+history.
**Note:** Since the above directive still requires JavaScript to be enabled, the RECOMMENDED first-line
approach is to either link to an actual page/resource that performs the same expected action, or use a
`` element styled as a link instead. The JS-enabled link (``) strategy MUST be reserved for the
rare cases, if any, where these are not possible - and ideally, they SHOULD be added ("injected") to the page
- using JavaScript.[\[1\]](https://stackoverflow.com/a/134957/3258851) [\[2\]](https://stackoverflow.com/a/20215524/3258851)
+ using JavaScript[^1][^2]
#### ` `
- Use a compressed image format or small file size where possible.
- Make use of ``/`srcset` where possible.
- Load images asynchronously where possible.
-#### `
+#### ``
- Ensure all pages have a level 1 header (``) that is not the website name.
- Pages MUST NOT have more than one `` element.
- Use headings in order; style via CSS rather than using a smaller heading level.
@@ -521,7 +524,7 @@ this.dataset = (Object.keys(data).length > 0) ? data : {};
Note the use of parentheses around the conditional expression - it makes it more obvious at first glance that this
is a conditional statement. **This is a requirement.**
-For very long or deeply indented expressions that exceed the 120-char line length limit ([item 8](#8-line-length)),
+For very long or deeply indented expressions that exceed the [120-char line length limit](#line-length),
use the following line-break and indenting style:
```js
@@ -597,4 +600,7 @@ first and then developing features, it can be helpful to create a commit where t
**Merges and commits to master** _must_ pass the tests every time. The master branch is considered the stable
channel - anything on there should be suitable for production deployment. Commits should generally not be made
directly to master - only organization and repository administrators have the ability to, and should avoid doing
-so if at all possible.
\ No newline at end of file
+so if at all possible.
+
+[^1]: [Which 'href' value should I use for JavaScript links, '#' or 'javascript:void(0)'?](https://stackoverflow.com/a/134957/3258851)
+[^2]: [Prevent href='#' link from changing the URL hash](https://stackoverflow.com/a/20215524/3258851)
diff --git a/Gemfile b/Gemfile
index f5bd290e3..b09b258fd 100644
--- a/Gemfile
+++ b/Gemfile
@@ -9,18 +9,19 @@ gem 'image_processing', '~> 1.12'
gem 'jquery-rails', '~> 4.5.0'
gem 'mysql2', '~> 0.5.4'
gem 'puma', '~> 5.6'
-gem 'rails', '~> 7.0.0'
-gem 'rails-html-sanitizer', '~> 1.4'
+gem 'rails', '~> 7.0.8'
+gem 'rails-html-sanitizer', '~> 1.6'
gem 'redis', '~> 4.8'
gem 'rotp', '~> 6.2'
gem 'sass-rails', '~> 6.0'
-gem 'sprockets', '~> 4.1'
+gem 'sprockets', '~> 4.1.0'
gem 'sprockets-rails', '~> 3.4', require: 'sprockets/railtie'
gem 'terser', '~> 1.1'
gem 'tzinfo-data', '~> 1.2022.3'
# Sign in
gem 'devise', '~> 4.8'
+gem 'devise_saml_authenticatable', '~> 1.9'
gem 'omniauth', '~> 2.1'
# Markdown support in both directions.
@@ -40,6 +41,7 @@ gem 'will_paginate-bootstrap', '~> 1.0'
# AWS for S3 (image storage) and SES (emails).
gem 'aws-sdk-s3', '~> 1.61', require: false
+gem 'aws-sdk-sns', '~> 1.72'
gem 'aws-ses-v4', require: 'aws/ses'
# Task scheduler.
@@ -69,7 +71,7 @@ gem 'net-smtp', '~> 0.3'
gem 'ruby-progressbar', '~> 1.11'
# Image generation
-gem 'rmagick'
+gem 'rmagick', '~> 5.3'
# Payments. Kinda important, y'know.
gem 'stripe', '~> 5.55'
@@ -82,6 +84,10 @@ group :test do
gem 'minitest-ci', '~> 3.4.0'
gem 'rails-controller-testing', '~> 1.0'
gem 'term-ansicolor', '~> 1.7'
+
+ gem 'capybara', '~> 3.38'
+ gem 'selenium-webdriver', '~> 4.7'
+ gem 'webdrivers', '~> 5.2'
end
group :development, :test do
@@ -94,3 +100,5 @@ group :development do
gem 'spring', '~> 4.0'
gem 'web-console', '~> 4.2'
end
+
+gem 'maintenance_tasks', '~> 2.1.1'
diff --git a/Gemfile.lock b/Gemfile.lock
index 4bac246fe..c97b4b4aa 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,81 +1,81 @@
GEM
remote: https://rubygems.org/
specs:
- actioncable (7.0.4)
- actionpack (= 7.0.4)
- activesupport (= 7.0.4)
+ actioncable (7.0.8.7)
+ actionpack (= 7.0.8.7)
+ activesupport (= 7.0.8.7)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
- actionmailbox (7.0.4)
- actionpack (= 7.0.4)
- activejob (= 7.0.4)
- activerecord (= 7.0.4)
- activestorage (= 7.0.4)
- activesupport (= 7.0.4)
+ actionmailbox (7.0.8.7)
+ actionpack (= 7.0.8.7)
+ activejob (= 7.0.8.7)
+ activerecord (= 7.0.8.7)
+ activestorage (= 7.0.8.7)
+ activesupport (= 7.0.8.7)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
- actionmailer (7.0.4)
- actionpack (= 7.0.4)
- actionview (= 7.0.4)
- activejob (= 7.0.4)
- activesupport (= 7.0.4)
+ actionmailer (7.0.8.7)
+ actionpack (= 7.0.8.7)
+ actionview (= 7.0.8.7)
+ activejob (= 7.0.8.7)
+ activesupport (= 7.0.8.7)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
- actionpack (7.0.4)
- actionview (= 7.0.4)
- activesupport (= 7.0.4)
- rack (~> 2.0, >= 2.2.0)
+ actionpack (7.0.8.7)
+ actionview (= 7.0.8.7)
+ activesupport (= 7.0.8.7)
+ rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
- actiontext (7.0.4)
- actionpack (= 7.0.4)
- activerecord (= 7.0.4)
- activestorage (= 7.0.4)
- activesupport (= 7.0.4)
+ actiontext (7.0.8.7)
+ actionpack (= 7.0.8.7)
+ activerecord (= 7.0.8.7)
+ activestorage (= 7.0.8.7)
+ activesupport (= 7.0.8.7)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
- actionview (7.0.4)
- activesupport (= 7.0.4)
+ actionview (7.0.8.7)
+ activesupport (= 7.0.8.7)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
- activejob (7.0.4)
- activesupport (= 7.0.4)
+ activejob (7.0.8.7)
+ activesupport (= 7.0.8.7)
globalid (>= 0.3.6)
- activemodel (7.0.4)
- activesupport (= 7.0.4)
- activerecord (7.0.4)
- activemodel (= 7.0.4)
- activesupport (= 7.0.4)
- activestorage (7.0.4)
- actionpack (= 7.0.4)
- activejob (= 7.0.4)
- activerecord (= 7.0.4)
- activesupport (= 7.0.4)
+ activemodel (7.0.8.7)
+ activesupport (= 7.0.8.7)
+ activerecord (7.0.8.7)
+ activemodel (= 7.0.8.7)
+ activesupport (= 7.0.8.7)
+ activestorage (7.0.8.7)
+ actionpack (= 7.0.8.7)
+ activejob (= 7.0.8.7)
+ activerecord (= 7.0.8.7)
+ activesupport (= 7.0.8.7)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
- activesupport (7.0.4)
+ activesupport (7.0.8.7)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
- addressable (2.8.1)
+ addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2)
awesome_print (1.9.2)
- aws-eventstream (1.2.0)
- aws-partitions (1.628.0)
- aws-sdk-core (3.145.0)
- aws-eventstream (~> 1, >= 1.0.2)
- aws-partitions (~> 1, >= 1.525.0)
- aws-sigv4 (~> 1.1)
+ aws-eventstream (1.3.0)
+ aws-partitions (1.908.0)
+ aws-sdk-core (3.191.6)
+ aws-eventstream (~> 1, >= 1.3.0)
+ aws-partitions (~> 1, >= 1.651.0)
+ aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.58.0)
aws-sdk-core (~> 3, >= 3.127.0)
@@ -84,17 +84,29 @@ GEM
aws-sdk-core (~> 3, >= 3.127.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
+ aws-sdk-sns (1.72.0)
+ aws-sdk-core (~> 3, >= 3.191.0)
+ aws-sigv4 (~> 1.1)
aws-ses-v4 (0.8.1)
builder
mail (> 2.2.5)
mime-types
xml-simple
- aws-sigv4 (1.5.1)
+ aws-sigv4 (1.8.0)
aws-eventstream (~> 1, >= 1.0.2)
bcrypt (3.1.18)
bindex (0.8.1)
- builder (3.2.4)
+ builder (3.3.0)
byebug (11.1.3)
+ capybara (3.38.0)
+ addressable
+ matrix
+ mini_mime (>= 0.1.3)
+ nokogiri (~> 1.8)
+ rack (>= 1.6.0)
+ rack-test (>= 0.6.3)
+ regexp_parser (>= 1.5, < 3.0)
+ xpath (~> 3.2)
chartkick (4.2.1)
chronic (0.10.2)
chunky_png (1.4.0)
@@ -105,8 +117,8 @@ GEM
coffee-script-source
execjs
coffee-script-source (1.12.2)
- commonmarker (0.23.5)
- concurrent-ruby (1.1.10)
+ commonmarker (0.23.10)
+ concurrent-ruby (1.3.4)
counter_culture (3.2.1)
activerecord (>= 4.2)
activesupport (>= 4.2)
@@ -117,30 +129,33 @@ GEM
thor (>= 0.19.4, < 2.0)
tins (~> 1.6)
crass (1.0.6)
- css_parser (1.11.0)
+ css_parser (1.16.0)
addressable
+ date (3.4.1)
devise (4.8.1)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
+ devise_saml_authenticatable (1.9.0)
+ devise (> 2.0.0)
+ ruby-saml (~> 1.7)
diffy (3.4.2)
- digest (3.1.0)
docile (1.4.0)
e2mmap (0.1.0)
- erubi (1.11.0)
+ erubi (1.13.0)
execjs (2.8.1)
fastimage (2.2.6)
ffi (1.15.5)
flamegraph (0.9.5)
- globalid (1.0.0)
- activesupport (>= 5.0)
+ globalid (1.2.1)
+ activesupport (>= 6.1)
groupdate (6.1.0)
activesupport (>= 5.2)
hashie (5.0.0)
htmlentities (4.3.4)
- i18n (1.12.0)
+ i18n (1.14.6)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@@ -149,6 +164,8 @@ GEM
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
jmespath (1.6.1)
+ job-iteration (1.3.6)
+ activejob (>= 5.2)
jquery-rails (4.5.0)
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
@@ -166,39 +183,44 @@ GEM
listen (3.7.1)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
- loofah (2.18.0)
+ loofah (2.23.1)
crass (~> 1.0.2)
- nokogiri (>= 1.5.9)
- mail (2.7.1)
+ nokogiri (>= 1.12.0)
+ mail (2.8.1)
mini_mime (>= 0.1.1)
- marcel (1.0.2)
+ net-imap
+ net-pop
+ net-smtp
+ maintenance_tasks (2.1.1)
+ actionpack (>= 6.0)
+ activejob (>= 6.0)
+ activerecord (>= 6.0)
+ job-iteration (~> 1.3.6)
+ railties (>= 6.0)
+ marcel (1.0.4)
+ matrix (0.4.2)
memory_profiler (1.0.0)
- method_source (1.0.0)
+ method_source (1.1.0)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2022.0105)
mini_magick (4.11.0)
- mini_mime (1.1.2)
+ mini_mime (1.1.5)
minitest (5.16.3)
minitest-ci (3.4.0)
minitest (>= 5.0.6)
mysql2 (0.5.4)
- net-imap (0.2.3)
- digest
+ net-imap (0.4.19)
+ date
net-protocol
- strscan
- net-pop (0.1.1)
- digest
+ net-pop (0.1.2)
net-protocol
+ net-protocol (0.2.2)
timeout
- net-protocol (0.1.3)
- timeout
- net-smtp (0.3.1)
- digest
+ net-smtp (0.4.0)
net-protocol
- timeout
- nio4r (2.5.8)
- nokogiri (1.13.8-x86_64-linux)
+ nio4r (2.7.3)
+ nokogiri (1.17.1-x86_64-linux)
racc (~> 1.4)
omniauth (2.1.0)
hashie (>= 3.4.6)
@@ -208,6 +230,7 @@ GEM
parallel (1.22.1)
parser (3.1.2.1)
ast (~> 2.4.1)
+ pkg-config (1.5.5)
premailer (1.16.0)
addressable
css_parser (>= 1.6.0)
@@ -215,49 +238,51 @@ GEM
premailer-rails (1.11.1)
actionmailer (>= 3)
premailer (~> 1.7, >= 1.7.9)
- public_suffix (5.0.0)
- puma (5.6.5)
+ public_suffix (5.0.4)
+ puma (5.6.9)
nio4r (~> 2.0)
- racc (1.6.0)
- rack (2.2.4)
+ racc (1.8.1)
+ rack (2.2.10)
rack-mini-profiler (3.0.0)
rack (>= 1.2.0)
rack-protection (2.2.2)
rack
- rack-test (2.0.2)
+ rack-test (2.1.0)
rack (>= 1.3)
- rails (7.0.4)
- actioncable (= 7.0.4)
- actionmailbox (= 7.0.4)
- actionmailer (= 7.0.4)
- actionpack (= 7.0.4)
- actiontext (= 7.0.4)
- actionview (= 7.0.4)
- activejob (= 7.0.4)
- activemodel (= 7.0.4)
- activerecord (= 7.0.4)
- activestorage (= 7.0.4)
- activesupport (= 7.0.4)
+ rails (7.0.8.7)
+ actioncable (= 7.0.8.7)
+ actionmailbox (= 7.0.8.7)
+ actionmailer (= 7.0.8.7)
+ actionpack (= 7.0.8.7)
+ actiontext (= 7.0.8.7)
+ actionview (= 7.0.8.7)
+ activejob (= 7.0.8.7)
+ activemodel (= 7.0.8.7)
+ activerecord (= 7.0.8.7)
+ activestorage (= 7.0.8.7)
+ activesupport (= 7.0.8.7)
bundler (>= 1.15.0)
- railties (= 7.0.4)
+ railties (= 7.0.8.7)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
- rails-dom-testing (2.0.3)
- activesupport (>= 4.2.0)
+ rails-dom-testing (2.2.0)
+ activesupport (>= 5.0.0)
+ minitest
nokogiri (>= 1.6)
- rails-html-sanitizer (1.4.3)
- loofah (~> 2.3)
- railties (7.0.4)
- actionpack (= 7.0.4)
- activesupport (= 7.0.4)
+ rails-html-sanitizer (1.6.1)
+ loofah (~> 2.21)
+ nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
+ railties (7.0.8.7)
+ actionpack (= 7.0.8.7)
+ activesupport (= 7.0.8.7)
method_source
rake (>= 12.2)
thor (~> 1.0)
zeitwerk (~> 2.5)
rainbow (3.1.1)
- rake (13.0.6)
+ rake (13.2.1)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
@@ -268,8 +293,9 @@ GEM
railties (>= 5.0)
reverse_markdown (2.1.1)
nokogiri
- rexml (3.2.5)
- rmagick (4.2.6)
+ rexml (3.3.9)
+ rmagick (5.3.0)
+ pkg-config (~> 1.4)
rotp (6.2.0)
rqrcode (2.1.2)
chunky_png (~> 1.0)
@@ -292,8 +318,12 @@ GEM
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
ruby-progressbar (1.11.0)
+ ruby-saml (1.17.0)
+ nokogiri (>= 1.13.10)
+ rexml
ruby-vips (2.1.4)
ffi (~> 1.12)
+ rubyzip (2.3.2)
sass-rails (6.0.0)
sassc-rails (~> 2.1, >= 2.1.1)
sassc (2.4.0)
@@ -304,6 +334,10 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
+ selenium-webdriver (4.7.1)
+ rexml (~> 3.2, >= 3.2.5)
+ rubyzip (>= 1.2.2, < 3.0)
+ websocket (~> 1.0)
simplecov (0.16.1)
docile (~> 1.1)
json (>= 1.8, < 3)
@@ -319,20 +353,19 @@ GEM
sprockets (>= 3.0.0)
stackprof (0.2.21)
stripe (5.55.0)
- strscan (3.0.4)
sync (0.5.0)
term-ansicolor (1.7.1)
tins (~> 1.0)
terser (1.1.12)
execjs (>= 0.3.0, < 3)
- thor (1.2.1)
+ thor (1.3.1)
thwait (0.2.0)
e2mmap
tilt (2.0.11)
- timeout (0.3.0)
+ timeout (0.4.3)
tins (1.31.1)
sync
- tzinfo (2.0.5)
+ tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2022.3)
tzinfo (>= 1.0.0)
@@ -344,7 +377,12 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
- websocket-driver (0.7.5)
+ webdrivers (5.2.0)
+ nokogiri (~> 1.6)
+ rubyzip (>= 1.3.0)
+ selenium-webdriver (~> 4.0)
+ websocket (1.2.9)
+ websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
whenever (1.0.0)
@@ -354,7 +392,9 @@ GEM
will_paginate (>= 3.0.3)
xml-simple (1.1.9)
rexml
- zeitwerk (2.6.0)
+ xpath (3.2.0)
+ nokogiri (~> 1.8)
+ zeitwerk (2.6.17)
PLATFORMS
x86_64-linux
@@ -362,14 +402,17 @@ PLATFORMS
DEPENDENCIES
awesome_print (~> 1.9)
aws-sdk-s3 (~> 1.61)
+ aws-sdk-sns (~> 1.72)
aws-ses-v4
byebug (~> 11.1)
+ capybara (~> 3.38)
chartkick (~> 4.2)
coffee-rails (~> 5.0.0)
commonmarker (~> 0.23)
counter_culture (~> 3.2)
coveralls (~> 0.8)
devise (~> 4.8)
+ devise_saml_authenticatable (~> 1.9)
diffy (~> 3.4)
e2mmap (~> 0.1)
fastimage (~> 2.2)
@@ -380,6 +423,7 @@ DEPENDENCIES
jquery-rails (~> 4.5.0)
letter_opener_web (~> 2.0)
listen (~> 3.7)
+ maintenance_tasks (~> 2.1.1)
memory_profiler (~> 1.0)
minitest (~> 5.16.0)
minitest-ci (~> 3.4.0)
@@ -389,20 +433,21 @@ DEPENDENCIES
premailer-rails (~> 1.11)
puma (~> 5.6)
rack-mini-profiler (~> 3.0)
- rails (~> 7.0.0)
+ rails (~> 7.0.8)
rails-controller-testing (~> 1.0)
- rails-html-sanitizer (~> 1.4)
+ rails-html-sanitizer (~> 1.6)
redis (~> 4.8)
reverse_markdown (~> 2.1)
- rmagick
+ rmagick (~> 5.3)
rotp (~> 6.2)
rqrcode (~> 2.1)
rubocop (~> 1)
rubocop-rails (~> 2.15)
ruby-progressbar (~> 1.11)
sass-rails (~> 6.0)
+ selenium-webdriver (~> 4.7)
spring (~> 4.0)
- sprockets (~> 4.1)
+ sprockets (~> 4.1.0)
sprockets-rails (~> 3.4)
stackprof (~> 0.2)
stripe (~> 5.55)
@@ -411,6 +456,7 @@ DEPENDENCIES
thwait (~> 0.2)
tzinfo-data (~> 1.2022.3)
web-console (~> 4.2)
+ webdrivers (~> 5.2)
whenever (~> 1.0)
will_paginate (~> 3.3)
will_paginate-bootstrap (~> 1.0)
@@ -419,4 +465,4 @@ RUBY VERSION
ruby 2.7.6p219
BUNDLED WITH
- 2.3.21
+ 2.4.13
diff --git a/INSTALLATION.md b/INSTALLATION.md
index 93e42a159..c65ebd5e2 100644
--- a/INSTALLATION.md
+++ b/INSTALLATION.md
@@ -4,7 +4,7 @@ These instructions are for setting up a development instance of QPixel. QPixel i
built with Ruby on Rails.
In that guide it is assumed that you already have a Unix environment available
-with Ruby and Bundler installed. WSL works as well. Windows (core) has not been tested.
+with Ruby and Bundler installed. WSL works as well. Windows (core) has not been tested.
For an installation with **Docker** see the README.md in the [docker](docker) folder
for further instructions.
@@ -13,6 +13,8 @@ If you don't already have Ruby installed, use [RVM](https://rvm.io/) or
[rbenv](https://github.com/rbenv/rbenv#installation) to install it before following
these instructions.
+QPixel is tested with Ruby 3 (and works with Ruby 2.7 as of December 2022).
+
## Prerequisites
For Debian-Based Linux:
@@ -43,7 +45,30 @@ brew install mysql bison openssl mysql-client
bundle config --global build.mysql2 --with-opt-dir="$(brew --prefix openssl)"
```
-QPixel requires Ruby 2.7+.
+
+## Environment
+
+The following lists environment variables provided for QPixel customization
+(this section is best-effort, please check for `ENV['']`) in source code for the full list of available variables (for Docker-specific variables, see [Docker README](/docker/README.md)):
+
+| Name | Value | Required? | Default | Description |
+| --------------------------------- | ------------------------------------------------------ | --------- | -------------------------- | -------------------------------------------------------------------------------------------------------------- |
+| `BACKTRACE` | `<1>` | no | - | Enables backtrace for libraries (see [backtrace_silencers.rb](/config/initializers/backtrace_silencers.rb)) |
+| `BUNDLE_GEMFILE` | | no | | |
+| `CONFIRMABLE_ALLOWED_ACCESS_DAYS` | `` | no | `0` | Sets for how long (in days) an unconfirmed account can access the instance |
+| `DRIVER` | `` | no | `headless_firefox` | Sets browser to use when running system tests |
+| `MAILER_PROTOCOL` | `http\|https` | no | `https` | Sets default URL protocol to use with mailes (f.e., confirmation emails) |
+| `PIDFILE` | `` | no | `tmp/pids/server.pid` | Sets pidfile (a file where the id of a process is written to) for Puma |
+| `PORT` | `` | no | `3000` | Sets the port on which the server will listen for incoming requests |
+| `RAILS_ENV` | `` | no | `development` | Sets the environment to use (see [config/environments](/config/environments/)) |
+| `RAILS_MAX_THREADS` | `` | no | `5` | Sets the maximum number of threads from the internal thread pool to use for requests |
+| `RAILS_MIN_THREADS` | `` | no | `5` | Sets the minimum number of threads from the internal pool to use for requests |
+| `RAILS_SERVE_STATIC_FILES` | `` | no | - | |
+| `REDIS_URL` | `` | no | `redis://localhost:6379/1` | |
+| `SECRET_KEY_BASE` | `` | yes | - | Sets the secret key for signed cookie verification (can be generated with `rake secret`, used in `production`) |
+| `SEEDS` | `` | no | - | Runs only a specified set of seeds from [db/seeds](/db/seeds/) |
+| `UPDATE_POSTS` | `` | no | - | If set to `true`, updates seeded posts when running post seeds |
+| `WEB_CONCURRENCY` | `` | no | `2` | |
### Install JS runtime
@@ -51,11 +76,27 @@ If you already have Node.JS installed, you can skip this step. If not,
[download and install it](https://nodejs.org/en/download/) or for example
`sudo apt install nodejs`.
+On Mac with homebrew, `brew install node` .
+
### Install Redis
If you haven't already got it, [download and install Redis](https://redis.io/download)
or for example `sudo apt install redis-server`.
+For mac with homebrew, `brew install redis` .
+
+### Install Imagemagick
+
+If you haven't already installed Imagemagick, you'll need to
+[install it for your system](https://imagemagick.org/script/download.php).
+
+If you install Imagemagick from APT on a Debian-based system, you may need to
+also install the `libmagickwand-dev` package.
+
+`sudo apt install libmagick++-dev` should also work.
+
+For Mac with homebrew, `brew install imagemagick` .
+
### Install Libvips
If you haven't already installed Libvips, you'll need to [install it for
@@ -64,6 +105,8 @@ your system](https://www.libvips.org/).
To install libvips from APT on a Debian-based system, use
`sudo apt install libvips`
+For Mac with homebrew, `brew install vips` .
+
## Install QPixel
Clone the repository and `cd` into the directory:
@@ -78,7 +121,7 @@ After downloading QPixel, you need to install all the dependencies. For that, yo
If Ruby complains, that the Bundler hasn't been installed yet, use `gem install bundler` and
then re-run the above command.
-### Setting up the Database
+### Set up the Database
If you weren't asked to set the root MySQL user password during `mysql-server` installation,
the installation is likely to be using Unix authentication instead. You'll need to sign into
@@ -95,10 +138,12 @@ Copy `config/database.sample.yml` to `config/database.yml` and fill in the corre
username, and password for your environment. If you've followed these instructions (i.e. you
have installed MySQL locally), the correct host is `localhost` or `127.0.0.1`.
-You'll also need to fill in details for the Redis connection. If you've followed these instructions,
+You will need to set the Redis connection details there too. If you've followed these instructions,
the sample file should already contain the correct values for you, but if you've customised your
setup you'll need to correct them.
+You'll also need to copy the Active Storage configuration from `config/storage.sample.yml` to `config/storage.yml`.
+
If you are using MariaDB instead of MySQL, you will need to replace all occurrences of
`utf8mb4_0900_ai_ci` with `utf8mb4_unicode_ci` in `db/schema.rb`.
@@ -109,6 +154,10 @@ Set up the database:
rails r db/scripts/create_tags_path_view.rb
rails db:migrate
+We also recommend you load the QPixel console extensions for easier development:
+
+ cp .sample.irbrc .irbrc
+
You'll need to create a Community record and purge the Rails cache before you can seed the database.
In a Rails console (`rails c`), run:
@@ -117,7 +166,7 @@ Community.create(name: 'Dev Community', host: 'localhost:3000')
Rails.cache.clear
```
-After that you can call `rails db:seed` to fill the database with necessary seed data, such as settings, help posts and default templates. (If you are preparing a production deployment, you might choose to edit some of the help seeds first. See "Help Topics" at the end of this guide.)
+After that you can run `rails db:seed` to fill the database with necessary seed data, such as settings, help posts and default templates. (If you are preparing a production deployment, you might choose to edit some of the help seeds first. The "policy" topics are not included in the initial seed. See "Help Topics" at the end of this guide.)
$ rails db:seed
Category: Created 2, skipped 0
@@ -129,6 +178,9 @@ Now comes the big moment: You can start the QPixel server for the first time. Ru
Open a web browser and visit your server, which should be running under [http://localhost:3000](http://localhost:3000).
+
+
+
### Create administrator account
You can create the first user account in the application through the "Sign up" route.
@@ -150,53 +202,52 @@ While being logged into your administrator account, go to [http://localhost:3000
Review the settings (if you want; you can change them later) and click "Save and continue" to complete
setting up the dev server.
-### Configure Categories
+## Create a Post
-Before you try to create a post we need to configure categories!
-Go to `http://localhost:3000/categories/`
+You can now create your first post. There are character requirements for the
+body and title, and you are required to add at least one tag.
-
+
- Click "edit" for each category and scroll down to see the "Tag Set" field. This
- will be empty on first setup.
+When you've met the length requirements and added a tag, the "Save Post in Q&A" button is enabled and you can click it.
-
+
-You will need to select a tag set for each category! For example, the Meta category can be
-associated with the "Meta" tag set, and the Q&A category can be associated with "Main"
-
+## Optional: Single Sign On
-Make sure to click save for each one.
-Note: You may need to run `rails db:seed` again.
+Please see our wiki for [detailed instructions](https://github.com/codidact/qpixel/wiki/Setting-up-SAML-Single-Sign-On) on setting up SAML Single Sign-On.
-## Create a Post
-You should then be able to create a post! There are character requirements for the
-body and title, and you are required at least one tag.
+## Optional: Required Tags
-
+The special Meta tags (discussion, bug, support, feature-request) are not seeded. (We do not assume that all deployments want to manage user feedback the same way.) You can create tags directly on the Meta Tags page:
-And then click to "Save Post in Q&A"
+
+
+Next, edit the Meta category settings:
+
+
+
+Add the tags to the "Required tags" section:
+
+
-
## Optional: Help Topics
-If you are running a development server, you might not care a lot about what's in the help. If you are planning to deploy a server for actual use, however, note that the seeds have some placeholder text you'll want to edit. We have provided starting points (to be edited) for the following topics:
+If you are running a development server, you might not care a lot about what's in the help. If you are planning to deploy a server for actual use, however, note that the seeds have some placeholder text you'll want to edit. We have provided starting points (to be edited) for the following topics:
-- Terms of service (TOS)
-- Code of conduct (COC)
-- Privacy policy
-- Spam policy
+- Terms of service (TOS)
+- Code of conduct (COC)
+- Privacy policy
+- Spam policy
- Global (network) FAQ
-The corresponding posts in db/seeds/posts have some places marked with "$EDIT" where you will probably want to insert URLs, email addresses, and the like. We recommend reviewing all of the content in these topics. There are two ways to edit these topics: in the source files before adding to your database, or through the UI in your running instance.
+The corresponding posts in db/seeds/posts have some places marked with "$EDIT" where you will probably want to insert URLs, email addresses, and the like. We recommend reviewing all of the content in these topics. There are two ways to edit these topics: in the source files before adding to your database, or through the UI in your running instance.
If you edit the seed files, use the following command to add them to your database:
`UPDATE_POSTS=true rails db:seed`
-You can also edit the topics in the UI. As an administrator, you'll see an edit button on help topics when you view them, and the editor provides an option to deploy changes across your network of communities. Administrators can update help topics in this way at any time.
-
-
+You can also edit the topics in the UI. As an administrator, you'll see an edit button on help topics when you view them, and the editor provides an option to deploy changes across your network of communities. Administrators can update help topics in this way at any time.
diff --git a/app/assets/images/favicon.ico b/app/assets/images/favicon.ico
new file mode 100644
index 000000000..07c724156
Binary files /dev/null and b/app/assets/images/favicon.ico differ
diff --git a/app/assets/images/scoring_table.png b/app/assets/images/scoring_table.png
index e3f703b1b..42c001f31 100644
Binary files a/app/assets/images/scoring_table.png and b/app/assets/images/scoring_table.png differ
diff --git a/app/assets/images/scoring_table.svg b/app/assets/images/scoring_table.svg
new file mode 100644
index 000000000..48835ef0f
--- /dev/null
+++ b/app/assets/images/scoring_table.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/javascripts/character_count.js b/app/assets/javascripts/character_count.js
index 13287284b..d2f827c1e 100644
--- a/app/assets/javascripts/character_count.js
+++ b/app/assets/javascripts/character_count.js
@@ -1,60 +1,89 @@
$(() => {
- const setIcon = (el, icon) => {
+ /**
+ * @typedef {'fa-ellipsis-h'|'fa-times'|'fa-exclamation-circle'|'fa-check'} CounterIcon
+ * @typedef {'info'|'warning'|'error'|'default'} CounterState
+ * @typedef {'valid'|'invalid'} InputValidationState
+ * @typedef {'disabled'|'enabled'} SubmitButtonDisabledState
+ */
+
+ /**
+ * Sets the icon to show before the counter, if any
+ * @param {CounterIcon} icon name of the icon to show
+ */
+ const setCounterIcon = (el, icon) => {
const icons = ['fa-ellipsis-h', 'fa-check', 'fa-exclamation-circle', 'fa-times'];
el.removeClass(icons.join(' ')).addClass(icon);
};
- $(document).on('keyup change paste', '[data-character-count]', ev => {
+ /**
+ * Sets the counter's state
+ * @param {CounterState} state the state to set
+ */
+ const setCounterState = (el, state) => {
+ if (state === 'info') {
+ el.removeClass('has-color-yellow-700 has-color-red-500').addClass('has-color-primary');
+ }
+ else if (state === 'warning') {
+ el.removeClass('has-color-red-500 has-color-primary').addClass('has-color-yellow-700');
+ }
+ else if (state === 'error') {
+ el.removeClass('has-color-yellow-700 has-color-primary').addClass('has-color-red-500');
+ }
+ else {
+ el.removeClass('has-color-red-500 has-color-yellow-700 has-color-primary');
+ }
+ };
+
+ /**
+ * Sets the input's validation state
+ * @param {InputValidationState} state the state to set
+ */
+ const setInputValidationState = (el, state) => {
+ const isInvalid = state === 'invalid';
+ el.toggleClass('failed-validation', isInvalid);
+ };
+
+ /**
+ * Sets the submit button's disabled state
+ * @param {SubmitButtonDisabledState} state the state to set
+ */
+ const setSubmitButtonDisabledState = (el, state) => {
+ const isDisabled = state === 'disabled';
+ el.attr('disabled', isDisabled).toggleClass('is-muted', isDisabled);
+ };
+
+ $(document).on('keyup change paste', '[data-character-count]', (ev) => {
const $tgt = $(ev.target);
const $counter = $($tgt.attr('data-character-count'));
- const $button = $counter.parents('form').find('input[type="submit"]');
+ const $button = $counter.parents('form').find('input[type="submit"],.js-suggested-edit-approve');
const $count = $counter.find('.js-character-count__count');
const $icon = $counter.find('.js-character-count__icon');
- const displayAt = parseFloat($counter.attr('data-display-at'));
+ const count = $tgt.val().length;
const max = parseInt($counter.attr('data-max'), 10);
const min = parseInt($counter.attr('data-min'), 10);
- const count = $tgt.val().length;
- const text = `${count} / ${max}`;
+ const threshold = parseFloat($counter.attr('data-threshold'));
- if (displayAt) {
- if (count >= displayAt * max) {
- $counter.removeClass('hide');
- }
- else {
- $counter.addClass('hide');
- }
- }
+ const gtnMax = count > max;
+ const ltnMin = count < min;
+ const gteThreshold = count >= threshold * max;
- if (count > max) {
- $counter.removeClass('has-color-yellow-700 has-color-primary').addClass('has-color-red-500');
- setIcon($icon, 'fa-times');
- if ($button) {
- $button.attr('disabled', true).addClass('is-muted');
- }
- }
- else if (count > 0.75 * max) {
- $counter.removeClass('has-color-red-500 has-color-primary').addClass('has-color-yellow-700');
- setIcon($icon, 'fa-exclamation-circle');
- if ($button) {
- $button.attr('disabled', false).removeClass('is-muted');
- }
- }
- else if (min && count < min) {
- $counter.removeClass('has-color-yellow-700 has-color-red-500').addClass('has-color-primary');
- setIcon($icon, 'fa-ellipsis-h');
- if ($button) {
- $button.attr('disabled', true).addClass('is-muted');
- }
- $tgt.addClass('failed-validation');
- }
- else {
- $counter.removeClass('has-color-red-500 has-color-yellow-700 has-color-primary');
- setIcon($icon, 'fa-check');
- if ($button) {
- $button.attr('disabled', false).removeClass('is-muted');
- }
- $tgt.removeClass('failed-validation');
+ const text = `${count} / ${ltnMin ? min : max}`;
+
+ if (gtnMax || ltnMin) {
+ setCounterState($counter, 'error');
+ setCounterIcon($icon, 'fa-times');
+ setSubmitButtonDisabledState($button, 'disabled');
+ setInputValidationState($tgt, 'invalid');
+ } else if (gteThreshold) {
+ setCounterState($counter, 'warning');
+ setCounterIcon($icon, 'fa-exclamation-circle');
+ setSubmitButtonDisabledState($button, 'enabled');
+ } else {
+ setCounterState($counter, 'default');
+ setCounterIcon($icon, 'fa-check');
+ setSubmitButtonDisabledState($button, 'enabled');
+ setInputValidationState($tgt, 'valid');
}
$count.text(text);
diff --git a/app/assets/javascripts/codeblocks.js b/app/assets/javascripts/codeblocks.js
new file mode 100644
index 000000000..a2f0db722
--- /dev/null
+++ b/app/assets/javascripts/codeblocks.js
@@ -0,0 +1,16 @@
+$(() => {
+ $(".post--content pre > code")
+ .parent()
+ .each(function() {
+ const content = $(this).text()
+ $(this)
+ .wrap('
')
+ .parent()
+ .prepend($('Copy ')
+ .click(function () {
+ navigator.clipboard.writeText(content);
+ $(this).text('Copied!');
+ setTimeout(() => { $(this).text('Copy'); }, 2000);
+ }))
+ });
+});
\ No newline at end of file
diff --git a/app/assets/javascripts/comments.js b/app/assets/javascripts/comments.js
index 508de72d6..b2af26bf7 100644
--- a/app/assets/javascripts/comments.js
+++ b/app/assets/javascripts/comments.js
@@ -77,7 +77,10 @@ $(() => {
const $tgt = $(evt.target);
const $comment = $tgt.parents('.comment');
const $commentBody = $comment.find('.comment--body');
+ const $thread = $comment.parents('.thread');
const commentId = $comment.attr('data-id');
+ const postId = $thread.attr('data-post');
+ const threadId = $thread.attr('data-thread');
const originalComment = $commentBody.clone();
const resp = await fetch(`/comments/${commentId}`, {
@@ -89,7 +92,7 @@ $(() => {
const formTemplate = `
mark ${notification.is_read ? 'unread' : 'read'}
@@ -67,8 +67,6 @@ $(() => {
const item = $(makeNotification(notification));
$inboxContainer.append(item);
});
-
- $inboxContainer.append(` See all your notifications » `);
}
});
@@ -122,4 +120,8 @@ $(() => {
const change = data.notification.is_read ? -1 : +1;
changeInboxCount(change);
});
+
+ $(document).on('click', '.notification-link', async ev => {
+ $(ev.target).parents('.inbox').removeClass('is-active');
+ });
});
\ No newline at end of file
diff --git a/app/assets/javascripts/post_histories.js b/app/assets/javascripts/post_histories.js
new file mode 100644
index 000000000..4d8c7d985
--- /dev/null
+++ b/app/assets/javascripts/post_histories.js
@@ -0,0 +1,9 @@
+$(() => {
+ const openRelevantEditOnly = () => {
+ $("details.history-event").prop('open', false);
+ $(location.hash).prop('open', true);
+ }
+
+ window.addEventListener("hashchange", openRelevantEditOnly);
+ openRelevantEditOnly();
+});
diff --git a/app/assets/javascripts/posts.js b/app/assets/javascripts/posts.js
index 2bdc896ad..2b0210eed 100644
--- a/app/assets/javascripts/posts.js
+++ b/app/assets/javascripts/posts.js
@@ -4,15 +4,30 @@ const ALLOWED_TAGS = ['a', 'p', 'span', 'b', 'i', 'em', 'strong', 'hr', 'h1', 'h
'summary', 'ins', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 's'];
const ALLOWED_ATTR = ['id', 'class', 'href', 'title', 'src', 'height', 'width', 'alt', 'rowspan', 'colspan', 'lang',
'start', 'dir'];
+// this is a list of constructors to ignore even if they are removed by sanitizer (mostly comments & body)
+const IGNORE_UNSUPPORTED = [Comment, HTMLBodyElement];
$(() => {
+ DOMPurify.addHook("uponSanitizeAttribute", (node, event) => {
+ const rowspan = node.getAttribute("rowspan");
+ const colspan = node.getAttribute("colspan");
+
+ if (rowspan && Number.isNaN(+rowspan)) {
+ event.keepAttr = false;
+ }
+
+ if (colspan && Number.isNaN(+colspan)) {
+ event.keepAttr = false;
+ }
+ });
+
const $uploadForm = $('.js-upload-form');
const stringInsert = (str, idx, insert) => str.slice(0, idx) + insert + str.slice(idx);
const placeholder = "![Uploading, please wait...]()";
- $uploadForm.find('input[type="file"]').on('change', async evt => {
+ $uploadForm.find('input[type="file"]').on('change', async (evt) => {
const $postField = $('.js-post-field');
const postText = $postField.val();
const cursorPos = $postField[0].selectionStart;
@@ -24,7 +39,7 @@ $(() => {
$form.submit();
});
- $uploadForm.on('submit', async evt => {
+ $uploadForm.on('submit', async (evt) => {
evt.preventDefault();
const $tgt = $(evt.target);
@@ -63,7 +78,7 @@ $(() => {
const $postField = $('.js-post-field');
const postText = $postField.val();
- $postField.val(postText.replace(placeholder, ``));
+ $postField.val(postText.replace(placeholder, ``));
$tgt.parents('.modal').removeClass('is-active');
});
@@ -80,7 +95,24 @@ $(() => {
tags: true
});
- const saveDraft = async (postText, $field, manual = false) => {
+ /**
+ * @typedef {{
+ * body: string
+ * comment?: string
+ * excerpt?: string
+ * license?: string
+ * tag_name?: string
+ * tags?: string[]
+ * title?: string
+ * }} PostDraft
+ *
+ * Attempts to save a post draft
+ * @param {PostDraft} draft post draft
+ * @param {JQuery} $field body input element
+ * @param {boolean} [manual] whether manual draft saving is enabled
+ * @returns {Promise}
+ */
+ const saveDraft = async (draft, $field, manual = false) => {
const autosavePref = await QPixel.preference('autosave', true);
if (autosavePref !== 'on' && !manual) {
return;
@@ -93,23 +125,63 @@ $(() => {
'X-CSRF-Token': QPixel.csrfToken(),
'Content-Type': 'application/json'
},
- body: JSON.stringify({
- post: postText,
- path: location.pathname
- })
+ body: JSON.stringify({ ...draft, path: location.pathname })
});
+
if (resp.status === 200) {
- const $el = $(`· draft saved `);
- $field.parents('.widget').find('.js-post-field-footer').append($el);
- $el.fadeOut(1500, function () { $(this).remove() });
+ const $statusEl = $field.parents('.widget').find('.js-post-draft-status');
+
+ $statusEl.removeClass('transparent');
+
+ setTimeout(() => {
+ $statusEl.addClass('transparent');
+ }, 1500);
}
};
- $('.js-save-draft').on('click', async ev => {
- const $tgt = $(ev.target);
- const $field = $tgt.parents('.widget').find('.js-post-field');
- const postText = $field.val();
- await saveDraft(postText, $field, true);
+ /**
+ * Extracts draft info from a given target
+ * @param {EventTarget} target post input field or "save draft" button
+ * @returns {{ draft: PostDraft, field: any }}
+ */
+ const parseDraft = (target) => {
+ const $tgt = $(target);
+ const $form = $tgt.parents('form');
+
+ const $bodyField = $form.find('.js-post-field');
+ const $licenseField = $form.find('.js-license-select');
+ const $excerptField = $form.find('.js-tag-excerpt');
+
+ const $tagsField = $form.find('#post_tags_cache');
+ const $titleField = $form.find('#post_title');
+ const $commentField = $form.find('#edit_comment');
+ const $tagNameField = $form.find('#tag_name');
+
+ const bodyText = $bodyField.val();
+ const commentText = $commentField.val();
+ const excerptText = $excerptField.val();
+ const license = $licenseField.val();
+ const tags = $tagsField.val();
+ const titleText = $titleField.val();
+ const tagName = $tagNameField.val();
+
+ /** @type {PostDraft} */
+ const draft = {
+ body: bodyText,
+ comment: commentText,
+ excerpt: excerptText,
+ license: license,
+ tags: tags,
+ tag_name: tagName,
+ title: titleText,
+ };
+
+ return { draft, field: $bodyField };
+ };
+
+ $('.js-save-draft').on('click', async (ev) => {
+ const { draft, field } = parseDraft(ev.target);
+ await saveDraft(draft, field, true);
});
let featureTimeout = null;
@@ -117,7 +189,27 @@ $(() => {
const postFields = $('.post-field');
- postFields.on('paste', async evt => {
+ const draftFieldsSelectors = [
+ '.js-post-field',
+ '.js-license-select',
+ '.js-tag-excerpt',
+ '#edit_comment',
+ '#post_tags_cache',
+ '#post_title',
+ '#tag_parent_id',
+ '#tag_name',
+ ];
+
+ // TODO: consider merging with post fields
+ $(draftFieldsSelectors.join(', ')).on('keyup change', (ev) => {
+ clearTimeout(draftTimeout);
+ draftTimeout = setTimeout(() => {
+ const { draft, field } = parseDraft(ev.target);
+ saveDraft(draft, field);
+ }, 1000);
+ });
+
+ postFields.on('paste', async (evt) => {
if (evt.originalEvent.clipboardData.files.length > 0) {
const $fileInput = $uploadForm.find('input[type="file"]');
$fileInput[0].files = evt.originalEvent.clipboardData.files;
@@ -125,52 +217,71 @@ $(() => {
}
});
- postFields.on('focus keyup paste change markdown', evt => {
- const $tgt = $(evt.target);
-
- if (!window.converter) {
- window.converter = window.markdownit({
- html: true,
- breaks: false,
- linkify: true
- });
- window.converter.use(window.markdownitFootnote);
- window.converter.use(window.latexEscape);
- }
- window.setTimeout(() => {
- const converter = window.converter;
+ postFields.on('focus keyup paste change markdown', (() => {
+ let previous = null;
+ return evt => {
+ const $tgt = $(evt.target);
const text = $(evt.target).val();
- const unsafe_html = converter.render(text);
- const html = DOMPurify.sanitize(unsafe_html, {
- USE_PROFILES: { html: true },
- ALLOWED_TAGS,
- ALLOWED_ATTR
- });
- $tgt.parents('.form-group').siblings('.post-preview').html(html);
- $tgt.parents('form').find('.js-post-html[name="__html"]').val(html + '');
- }, 0);
-
- if (featureTimeout) {
- clearTimeout(featureTimeout);
- }
-
- featureTimeout = setTimeout(() => {
- if (window['MathJax']) {
- MathJax.typeset();
+ // Don't bother re-rendering if nothing's changed
+ if (text === previous) { return; }
+ previous = text;
+ if (!window.converter) {
+ window.converter = window.markdownit({
+ html: true,
+ breaks: false,
+ linkify: true
+ });
+ window.converter.use(window.markdownitFootnote);
+ window.converter.use(window.latexEscape);
}
- if (window['hljs']) {
- hljs.highlightAll();
+ window.setTimeout(() => {
+ const converter = window.converter;
+ const unsafe_html = converter.render(text);
+ const html = DOMPurify.sanitize(unsafe_html, {
+ ALLOWED_TAGS,
+ ALLOWED_ATTR
+ });
+
+ const removedElements = [...new Set(DOMPurify.removed
+ .filter(entry => entry.element && !IGNORE_UNSUPPORTED.some((ctor) => entry.element instanceof ctor))
+ .map(entry => entry.element.localName))];
+
+ const removedAttributes = [...new Set(DOMPurify.removed
+ .filter(entry => entry.attribute)
+ .map(entry => [
+ entry.attribute.name + (entry.attribute.value ? `='${entry.attribute.value}'` : ''),
+ entry.from.localName
+ ]))]
+
+ $tgt.parents('form')
+ .find('.rejected-elements')
+ .toggleClass('hide', removedElements.length === 0 && removedAttributes.length === 0)
+ .find('ul')
+ .empty()
+ .append(
+ removedElements.map(name => $(`<${name}>
`)),
+ removedAttributes.map(([attr, elName]) => $(`${attr}
(in <${elName}>
) `)));
+
+ $tgt.parents('.form-group').siblings('.post-preview').html(html);
+ $tgt.parents('form').find('.js-post-html[name="__html"]').val(html + '');
+ }, 0);
+
+ if (featureTimeout) {
+ clearTimeout(featureTimeout);
}
- }, 1000);
- }).on('keyup', ev => {
- clearTimeout(draftTimeout);
- const text = $(ev.target).val();
- draftTimeout = setTimeout(() => {
- saveDraft(text, $(ev.target));
- }, 3000);
- }).trigger('markdown');
- postFields.parents('form').on('submit', async ev => {
+ featureTimeout = setTimeout(() => {
+ if (window['MathJax']) {
+ MathJax.typeset();
+ }
+ if (window['hljs']) {
+ hljs.highlightAll();
+ }
+ }, 1000);
+ };
+ })()).trigger('markdown');
+
+ postFields.parents('form').on('submit', async (ev) => {
const $tgt = $(ev.target);
const field = $tgt.find('.post-field');
@@ -252,24 +363,64 @@ $(() => {
$('.js-draft-loaded').each((i, e) => {
$(e).parents('.widget').after(`
Draft loaded.
- You've edited this post before but didn't save it. We loaded your edits here for you.
+ You had edited this before but haven't saved it. We loaded the edits for you.
`);
});
- $('.js-permalink > .js-text').text('Copy Link');
- $('.js-permalink').on('click', ev => {
- ev.preventDefault();
+ const setCopyButtonState = ($button, state) => {
+ const isSuccess = state === "success";
+ const buttonClass = isSuccess ? "is-green" : "is-danger";
+ const iconClass = isSuccess ? "fa-check" : "fa-times";
+
+ const $icon = $button.find(".fa");
+
+ $icon.removeClass("fa-copy");
+ $icon.addClass(iconClass);
+ $button.addClass(buttonClass);
- const $tgt = $(ev.target).is('a') ? $(ev.target) : $(ev.target).parents('a');
- const link = $tgt.attr('href');
- navigator.clipboard.writeText(link);
- $tgt.find('.js-text').text('Copied!');
setTimeout(() => {
- $tgt.find('.js-text').text('Copy Link');
- }, 1000);
+ $icon.removeClass(iconClass);
+ $button.removeClass(buttonClass);
+ $icon.addClass("fa-copy");
+ }, 1e3);
+ };
+
+ $(".js-permalink-trigger").removeAttr("hidden");
+
+ $(".js-permalink-copy").on("click", async (ev) => {
+ ev.preventDefault();
+
+ const $tgt = $(ev.target);
+
+ const $button = $tgt.hasClass("js-permalink-copy")
+ ? $tgt
+ : $tgt.parents(".js-permalink-copy");
+
+ const postId = $button.data("post-id");
+ const linkType = $button.data("link-type");
+
+ if (!postId || !linkType) {
+ return;
+ }
+
+ const $input = $(`#permalink-${postId}-${linkType}`);
+
+ const url = $input.val();
+
+ if (!url) {
+ return;
+ }
+
+ try {
+ await navigator.clipboard.writeText(url);
+ setCopyButtonState($button, "success");
+ }
+ catch (_e) {
+ setCopyButtonState($button, "error");
+ }
});
- $('.js-nominate-promotion').on('click', async ev => {
+ $('.js-nominate-promotion').on('click', async (ev) => {
ev.preventDefault();
const $tgt = $(ev.target);
@@ -291,7 +442,7 @@ $(() => {
$('.js-mod-tools').removeClass('is-active');
});
- $('.js-cancel-edit').on('click', async ev => {
+ $('.js-cancel-edit').on('click', async (ev) => {
ev.preventDefault();
let $btn = $(ev.target);
diff --git a/app/assets/javascripts/privileges.js b/app/assets/javascripts/privileges.js
index b61d17a0a..f63feeb69 100644
--- a/app/assets/javascripts/privileges.js
+++ b/app/assets/javascripts/privileges.js
@@ -27,7 +27,11 @@ $(() => {
const $input = $td.find('.js-privilege-edit');
const name = $input.data('name');
const type = $input.data('type');
- const value = parseFloat($input.val() || '') || null;
+
+ // incorrect input values will cause rawValue to be NaN
+ const rawValue = parseFloat($input.val())
+
+ const value = Number.isNaN(rawValue) ? null : rawValue;
const resp = await fetch(`/admin/privileges/${name}`, {
method: 'POST',
diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js
index df4236002..d63f6bdf4 100644
--- a/app/assets/javascripts/qpixel_api.js
+++ b/app/assets/javascripts/qpixel_api.js
@@ -3,6 +3,30 @@ const validators = [];
/** Counts notifications popped up at any time. */
let popped_modals_ct = 0;
+/**
+ * @typedef {{
+ * min_score: number | null,
+ * max_score: number | null,
+ * min_answers: number | null,
+ * max_answers: number | null,
+ * include_tags: [string, number][],
+ * exclude_tags: [string, number][],
+ * status: 'any' | 'closed' | 'open',
+ * system: boolean,
+ * }} Filter
+ *
+ * @typedef {{
+ * id: number,
+ * username: string,
+ * is_moderator: boolean,
+ * is_admin: boolean,
+ * is_global_moderator: boolean,
+ * is_global_admin: boolean,
+ * trust_level: number,
+ * se_acct_id: string | null,
+ * }} User
+ */
+
window.QPixel = {
/**
* Get the current CSRF anti-forgery token. Should be passed as the X-CSRF-Token header when
@@ -20,7 +44,7 @@ window.QPixel = {
* @param type the type to apply to the popup - warning, danger, etc.
* @param message the message to show
*/
- createNotification: function(type, message) {
+ createNotification: function (type, message) {
// Some messages include a date stamp, `append_date` governs that.
let append_date = false;
let message_with_date = message;
@@ -42,26 +66,26 @@ window.QPixel = {
}
const span = '× ';
const button = ('' +
- span + ' ');
- $("
")
- .addClass("notice has-shadow-3 is-" + type)
- .html(button + '' + message_with_date + '
')
- .css({
- 'position': 'fixed',
- 'top': '50px',
- 'left': '50%',
- 'transform': 'translateX(-50%)',
- 'width': '100%',
- 'max-width': '800px',
- 'cursor': 'pointer'
- })
- .on('click', function(ev) {
- $(this).fadeOut(200, function() {
- $(this).remove();
- popped_modals_ct = popped_modals_ct > 0 ? (popped_modals_ct - 1) : 0;
- });
- })
- .appendTo(document.body);
+ span + '');
+ $('
')
+ .addClass('notice has-shadow-3 is-' + type)
+ .html(button + '' + message_with_date + '
')
+ .css({
+ 'position': 'fixed',
+ 'top': '50px',
+ 'left': '50%',
+ 'transform': 'translateX(-50%)',
+ 'width': '100%',
+ 'max-width': '800px',
+ 'cursor': 'pointer'
+ })
+ .on('click', function (ev) {
+ $(this).fadeOut(200, function () {
+ $(this).remove();
+ popped_modals_ct = popped_modals_ct > 0 ? (popped_modals_ct - 1) : 0;
+ });
+ })
+ .appendTo(document.body);
popped_modals_ct += 1;
},
@@ -70,7 +94,7 @@ window.QPixel = {
* @param el the element for which to find the offset.
* @returns {{top: integer, left: integer, bottom: integer, right: integer}}
*/
- offset: function(el) {
+ offset: function (el) {
const topLeft = $(el).offset();
return {
top: topLeft.top,
@@ -151,21 +175,59 @@ window.QPixel = {
$field.val(prev.substring(0, $field[0].selectionStart) + text + prev.substring($field[0].selectionEnd));
},
+ /**
+ * Used to prevent launching multiple requests to /users/me
+ * @type {Promise|null}
+ */
+ _pendingUserResponse: null,
+
+ /**
+ * @type {User|null}
+ */
_user: null,
/**
- * Get the user object for the current user.
- * @returns {Promise} a JSON object containing user details
+ * FIFO-style fetch wrapper for /users/me requests
+ * @returns {Promise}
*/
- user: async () => {
- if (QPixel._user) return QPixel._user;
- const resp = await fetch('/users/me', {
+ _fetchUser () {
+ if (QPixel._pendingUserResponse) {
+ return QPixel._pendingUserResponse;
+ }
+
+ const myselfPromise = fetch('/users/me', {
credentials: 'include',
headers: {
'Accept': 'application/json'
}
});
- QPixel._user = await resp.json();
+
+ QPixel._pendingUserResponse = myselfPromise;
+
+ return myselfPromise;
+ },
+
+ /**
+ * Get the user object for the current user.
+ * @returns {Promise} a JSON object containing user details
+ */
+ user: async () => {
+ if (QPixel._user != null || document.body.dataset.userId === 'none') {
+ return QPixel._user;
+ }
+
+ try {
+ const resp = await QPixel._fetchUser();
+
+ if (!resp.bodyUsed) {
+ QPixel._user = await resp.json();
+ }
+ }
+ finally {
+ // ensures pending user is cleared regardless of network errors
+ QPixel._pendingUserResponse = null;
+ }
+
return QPixel._user;
},
@@ -176,30 +238,24 @@ window.QPixel = {
* localStorage, or Redis via AJAX.
* @returns {Promise} a JSON object containing user preferences
*/
- preferences: async () => {
- if (this._preferences == null && !!localStorage['qpixel.user_preferences']) {
- this._preferences = JSON.parse(localStorage['qpixel.user_preferences']);
-
- // If we don't have the global key, we're probably using an old preferences schema.
- if (!this._preferences.global) {
- delete localStorage['qpixel.user_preferences'];
- this._preferences = null;
- }
+ _getPreferences: async () => {
+ // Early return for the most frequent case (local variable already contains the preferences)
+ if (QPixel._preferences != null) {
+ return QPixel._preferences;
}
- else if (this._preferences == null) {
- // If they're still null (or undefined) after loading from localStorage, we're probably on a site we haven't
- // loaded them for yet. Load from Redis via AJAX.
- const resp = await fetch('/users/me/preferences', {
- credentials: 'include',
- headers: {
- 'Accept': 'application/json'
- }
- });
- const data = await resp.json();
- localStorage['qpixel.user_preferences'] = JSON.stringify(data);
- this._preferences = data;
+ // Early return the preferences from localStorage unless null or undefined
+ const key = QPixel._preferencesLocalStorageKey();
+ const localStoragePreferences = (key in localStorage)
+ ? JSON.parse(localStorage[key])
+ : null;
+ if (localStoragePreferences != null) {
+ QPixel._preferences = localStoragePreferences;
+ return QPixel._preferences;
}
- return this._preferences;
+ // Preferences are still null (or undefined) after loading from localStorage, so we're probably on a site we
+ // haven't loaded them for yet. Load from Redis via AJAX.
+ await QPixel._cachedFetchPreferences();
+ return QPixel._preferences;
},
/**
@@ -209,29 +265,25 @@ window.QPixel = {
* @returns {Promise<*>} the value of the requested preference
*/
preference: async (name, community = false) => {
- let prefs = await QPixel.preferences();
- let value = community ? prefs.community[name] : prefs.global[name];
-
- // Deliberate === here: null is a valid value for a preference, but undefined means we haven't fetched it.
- // If we haven't fetched a preference, that probably means it's new - run a full re-fetch.
- if (value === undefined) {
- const resp = await fetch('/users/me/preferences', {
- credentials: 'include',
- headers: {
- 'Accept': 'application/json'
- }
- });
- const data = await resp.json();
- localStorage['qpixel.user_preferences'] = JSON.stringify(data);
- this._preferences = data;
+ const user = await QPixel.user();
- prefs = await QPixel.preferences();
- value = community ? prefs.community[name] : prefs.global[name];
- return value;
+ if (!user) {
+ return null;
}
- else {
+
+ let prefs = await QPixel._getPreferences();
+ let value = community ? prefs.community[name] : prefs.global[name];
+
+ // Note that null is a valid value for a preference, but undefined means we haven't fetched it.
+ if (typeof (value) !== 'undefined') {
return value;
}
+ // If we haven't fetched a preference, that probably means it's new - run a full re-fetch.
+ await QPixel._cachedFetchPreferences();
+
+ prefs = await QPixel._getPreferences();
+ value = community ? prefs.community[name] : prefs.global[name];
+ return value;
},
/**
@@ -250,7 +302,7 @@ window.QPixel = {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
- body: JSON.stringify({ name, value, community })
+ body: JSON.stringify({name, value, community})
});
const data = await resp.json();
if (data.status !== 'success') {
@@ -258,11 +310,163 @@ window.QPixel = {
console.error(resp);
}
else {
- this._preferences = data.preferences;
- localStorage['qpixel.user_preferences'] = JSON.stringify(this._preferences);
+ QPixel._updatePreferencesLocally(data.preferences);
+ }
+ },
+
+ /**
+ * @returns {Promise>}
+ */
+ filters: async () => {
+ if (this._filters == null) {
+ // If they're still null (or undefined) after loading from localStorage, we're probably on a site we haven't
+ // loaded them for yet. Load via AJAX.
+ const resp = await fetch('/users/me/filters', {
+ credentials: 'include',
+ headers: {
+ 'Accept': 'application/json'
+ }
+ });
+ const data = await resp.json();
+ localStorage['qpixel.user_filters'] = JSON.stringify(data);
+ this._filters = data;
+ }
+
+ return this._filters;
+ },
+
+ /**
+ * Fetches default user filter for a given category
+ * @param categoryId id of the category to fetch
+ * @returns {Promise}
+ */
+ defaultFilter: async (categoryId) => {
+ const user = await QPixel.user();
+
+ if (!user) {
+ return '';
+ }
+
+ const resp = await fetch(`/users/me/filters/default?category=${categoryId}`, {
+ credentials: 'include',
+ headers: {
+ 'Accept': 'application/json'
+ }
+ });
+
+ const data = await resp.json();
+ return data.name;
+ },
+
+ setFilterAsDefault: async (categoryId, name) => {
+ const resp = await fetch(`/categories/${categoryId}/filters/default`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'X-CSRF-Token': QPixel.csrfToken(),
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({name})
+ });
+ },
+
+ setFilter: async (name, filter, category, isDefault) => {
+ const resp = await fetch('/users/me/filters', {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'X-CSRF-Token': QPixel.csrfToken(),
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(Object.assign(filter, {name, category, is_default: isDefault}))
+ });
+ const data = await resp.json();
+ if (data.status !== 'success') {
+ console.error(`Filter persist failed (${name})`);
+ console.error(resp);
+ }
+ else {
+ this._filters = data.filters;
+ localStorage['qpixel.user_filters'] = JSON.stringify(this._filters);
+ }
+ },
+
+ deleteFilter: async (name, system = false) => {
+ const resp = await fetch('/users/me/filters', {
+ method: 'DELETE',
+ credentials: 'include',
+ headers: {
+ 'X-CSRF-Token': QPixel.csrfToken(),
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({name, system})
+ });
+ const data = await resp.json();
+ if (data.status !== 'success') {
+ console.error(`Filter deletion failed (${name})`);
+ console.error(resp);
+ }
+ else {
+ this._filters = data.filters;
+ localStorage['qpixel.user_filters'] = JSON.stringify(this._filters);
}
},
+ /**
+ * Get the key to use for storing user preferences in localStorage, to avoid conflating users
+ * @returns string the localStorage key
+ */
+ _preferencesLocalStorageKey: () => {
+ const id = document.body.dataset.userId;
+ const key = `qpixel.user_${id}_preferences`;
+ QPixel._preferencesLocalStorageKey = () => key;
+ return key;
+ },
+
+ /**
+ * Call _fetchPreferences but only the first time to prevent redundant HTTP requests
+ * @returns {Promise}
+ */
+ _cachedFetchPreferences: async () => {
+ // No 'await' because we want the promise not its value
+ const cachedPromise = QPixel._fetchPreferences();
+ // Redefine this function to await this same initial promise on every subsequent call
+ // This prevents multiple calls from triggering multiple redundant '_fetchPreferences' calls
+ QPixel._cachedFetchPreferences = async () => {
+ await cachedPromise;
+ };
+ // Remember to await the promise so the very first call does not return before '_fetchPreferences' returns
+ await cachedPromise;
+ },
+
+ /**
+ * Update local variable _preferences and localStorage with an AJAX call for the user preferences
+ * @returns {Promise}
+ */
+ _fetchPreferences: async () => {
+ const resp = await fetch('/users/me/preferences', {
+ credentials: 'include',
+ headers: {
+ 'Accept': 'application/json'
+ }
+ });
+ const data = await resp.json();
+ QPixel._updatePreferencesLocally(data);
+ },
+
+ /**
+ * Set local variable _preferences and localStorage to new preferences data
+ * @param data an object, containing the new preferences data
+ */
+ _updatePreferencesLocally: data => {
+ QPixel._preferences = data;
+ const key = QPixel._preferencesLocalStorageKey();
+ localStorage[key] = JSON.stringify(QPixel._preferences);
+ },
+
/**
* Get the word in a string that the given position is in, and the position within that word.
* @param splat an array, containing the string already split by however you define a "word"
diff --git a/app/assets/javascripts/suggested_edit.js b/app/assets/javascripts/suggested_edit.js
index b4aae89a4..4763022eb 100644
--- a/app/assets/javascripts/suggested_edit.js
+++ b/app/assets/javascripts/suggested_edit.js
@@ -3,6 +3,7 @@ $(document).on('ready', function () {
ev.preventDefault();
const self = $(ev.target);
const editId = self.attr('data-suggested-edit-approve');
+ const comment = $('#summary').val();
const resp = await fetch(`/posts/suggested-edit/${editId}/approve`, {
method: 'POST',
@@ -10,7 +11,8 @@ $(document).on('ready', function () {
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': QPixel.csrfToken()
- }
+ },
+ body: JSON.stringify({ comment })
});
const data = await resp.json();
diff --git a/app/assets/javascripts/tags.js b/app/assets/javascripts/tags.js
index a2d71685f..460c215b5 100644
--- a/app/assets/javascripts/tags.js
+++ b/app/assets/javascripts/tags.js
@@ -14,7 +14,8 @@ $(() => {
};
const template = (tag) => {
- const tagSpan = `${tag.text} `;
+ const tagSynonyms = !!tag.synonyms ? ` (${tag.synonyms}) ` : '';
+ const tagSpan = `${tag.text}${tagSynonyms} `;
let desc = !!tag.desc ? splitWordsMaxLength(tag.desc, 120) : '';
const descSpan = !!tag.desc ?
`${desc[0]}${desc.length > 1 ? '...' : ''} ` :
@@ -22,7 +23,7 @@ $(() => {
return $(tagSpan + descSpan);
}
- $('.js-tag-select').each((i, el) => {
+ $('.js-tag-select').each((_i, el) => {
const $tgt = $(el);
let $this;
const useIds = $tgt.attr('data-use-ids') === 'true';
@@ -38,16 +39,16 @@ $(() => {
data: function (params) {
$this = $(this);
// (for the tour)
- if ($this.data('tag-set') === '-1') {
- return Object.assign(params, { tag_set: "1" });
+ if (Number($this.data('tag-set')) === -1) {
+ return Object.assign(params, { tag_set: '1' });
}
return Object.assign(params, { tag_set: $this.data('tag-set') });
},
headers: { 'Accept': 'application/json' },
delay: 100,
- processResults: data => {
+ processResults: (data) => {
// (for the tour)
- if ($this.data('tag-set') === '-1') {
+ if (Number($this.data('tag-set')) === -1) {
return {
results: [
{ id: 1, text: 'hot-red-firebreather', desc: 'Very cute dragon' },
@@ -61,17 +62,66 @@ $(() => {
results: data.map(t => ({
id: useIds ? t.id : t.name,
text: t.name.replace(//g, '>'),
+ synonyms: processSynonyms($this, t.tag_synonyms),
desc: t.excerpt
}))
};
},
},
+ placeholder: '',
templateResult: template,
allowClear: true
});
});
- $('.js-add-required-tag').on('click', ev => {
+ function processSynonyms($search, synonyms) {
+ if (!synonyms) return synonyms;
+
+ let displayedSynonyms;
+ if (synonyms.length > 3) {
+ const searchValue = $search.data('select2').selection.$search.val().toLowerCase();
+ displayedSynonyms = synonyms.filter(ts => ts.name.includes(searchValue)).slice(0, 3);
+ } else {
+ displayedSynonyms = synonyms;
+ }
+ let synonymsString = displayedSynonyms.map((ts) => `${ts.name.replace(//g, '>')}`).join(', ');
+ if (synonyms.length > displayedSynonyms.length) {
+ synonymsString += `, ${synonyms.length - displayedSynonyms.length} more synonyms`;
+ }
+ return synonymsString;
+ }
+
+ $('#add-tag-synonym').on('click', (ev) => {
+ const $wrapper = $('#tag-synonyms-wrapper');
+ const lastId = $wrapper.children('.tag-synonym').last().attr('data-id');
+ const newId = parseInt(lastId, 10) + 1;
+
+ //Duplicate the first element at the end of the wrapper
+ const newField = $wrapper.find('.tag-synonym[data-id="0"]')[0]
+ .outerHTML
+ .replace(/data-id="0"/g, 'data-id="' + newId + '"')
+ .replace(/(?attributes(\]\[)|(_))0/g, '$' + newId)
+ $wrapper.append(newField);
+
+ //Alter the newly added tag synonym
+ const $newTagSynonym = $wrapper.children().last();
+ $newTagSynonym.find('.tag-synonym-name').removeAttr('value').removeAttr('readonly').removeAttr('disabled');
+ $newTagSynonym.find('.destroy-tag-synonym').attr('value', 'false');
+ $newTagSynonym.show();
+
+ //Add handler for removing an element
+ $newTagSynonym.find(`.remove-tag-synonym`).click(removeTagSynonym);
+ });
+
+ $('.remove-tag-synonym').click(removeTagSynonym);
+
+ function removeTagSynonym() {
+ const synonym = $(this).closest('.tag-synonym');
+ synonym.find('.destroy-tag-synonym').attr('value', 'true');
+ synonym.hide();
+ }
+
+ $('.js-add-required-tag').on('click', (ev) => {
const $tgt = $(ev.target);
const useIds = $tgt.attr('data-use-ids') === 'true';
const tagId = $tgt.attr('data-tag-id');
@@ -87,7 +137,7 @@ $(() => {
}
});
- $('.js-rename-tag').on('click', async ev => {
+ $('.js-rename-tag').on('click', async (ev) => {
const $tgt = $(ev.target).is('a') ? $(ev.target) : $(ev.target).parents('a');
const categoryId = $tgt.attr('data-category');
const tagId = $tgt.attr('data-tag');
diff --git a/app/assets/javascripts/two_factor.js b/app/assets/javascripts/two_factor.js
new file mode 100644
index 000000000..406dadc05
--- /dev/null
+++ b/app/assets/javascripts/two_factor.js
@@ -0,0 +1,33 @@
+$(() => {
+ $('.js-backup-code-form').on('submit', async ev => {
+ ev.preventDefault();
+ const $tgt = $(ev.target);
+ const $input = $tgt.find('input[name="code"]');
+ const code = $input.val();
+ const req = await fetch('/users/two-factor/backup', {
+ method: 'POST',
+ headers: {
+ 'X-CSRF-Token': QPixel.csrfToken(),
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ code })
+ });
+ const res = await req.json();
+
+ if (res.status === 'error') {
+ const $label = $tgt.find('label[for="code"]');
+ $label.text(res.message);
+ $input.addClass('is-danger');
+ $tgt.find('input[type="submit"]').removeAttr('disabled');
+ }
+ else if (res.status === 'success') {
+ const codeForm = $(`
+ Show code
+ 2FA backup code
+
+ `);
+ $tgt.after(codeForm);
+ $tgt.remove();
+ }
+ });
+});
diff --git a/app/assets/javascripts/votes.js b/app/assets/javascripts/votes.js
index 611a78ffe..6dae33b0a 100644
--- a/app/assets/javascripts/votes.js
+++ b/app/assets/javascripts/votes.js
@@ -2,22 +2,29 @@ $(() => {
$(document).on('click', '.vote-button', async evt => {
const $tgt = $(evt.target).is('button') ? $(evt.target) : $(evt.target).parents('button');
const $post = $tgt.parents('.post');
- const $up = $post.find('.post--votes').find('.js-upvote-count');
- const $down = $post.find('.post--votes').find('.js-downvote-count');
+
+ const $container = $post.find(".post--votes");
+
+ const $up = $container.find('.js-upvote-count');
+ const $down = $container.find('.js-downvote-count');
const voteType = $tgt.data('vote-type');
const voted = $tgt.hasClass('is-active');
if (voted) {
const voteId = $tgt.attr('data-vote-id');
+
const resp = await fetch(`/votes/${voteId}`, {
method: 'DELETE',
credentials: 'include',
headers: { 'X-CSRF-Token': QPixel.csrfToken() }
});
+
const data = await resp.json();
+
if (data.status === 'OK') {
$up.text(`+${data.upvotes}`);
$down.html(`−${data.downvotes}`);
+ $container.attr("title", `Score: ${data.score}`);
$tgt.removeClass('is-active')
.removeAttr('data-vote-id');
}
@@ -34,10 +41,13 @@ $(() => {
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': QPixel.csrfToken() },
body: JSON.stringify({post_id: $post.data('post-id'), vote_type: voteType})
});
+
const data = await resp.json();
+
if (data.status === 'modified' || data.status === 'OK') {
$up.text(`+${data.upvotes}`);
$down.html(`−${data.downvotes}`);
+ $container.attr("title", `Score: ${data.score}`);
$tgt.addClass('is-active')
.attr('data-vote-id', data.vote_id);
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 2b105d33f..44a2e1d21 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -145,22 +145,6 @@ hr {
}
}
-.footnote-ref a::before {
- content: '[';
-}
-
-div.post-preview .footnote-ref a::before {
- content: '';
-}
-
-.footnote-ref a::after {
- content: ']';
-}
-
-div.post-preview .footnote-ref a::after {
- content: '';
-}
-
.footnotes-sep + .footnotes {
border-top: 0;
}
@@ -209,6 +193,10 @@ img {
border-top: 1px solid #9daeb7;
}
+.widget .widget--body .widget--body-extra {
+ margin-left: 0.75em;
+}
+
pre {
background: #f0f0f0;
border: 0;
diff --git a/app/assets/stylesheets/categories.scss b/app/assets/stylesheets/categories.scss
index 93b1c4f89..6989a8781 100644
--- a/app/assets/stylesheets/categories.scss
+++ b/app/assets/stylesheets/categories.scss
@@ -16,6 +16,12 @@
align-items: center;
justify-content: space-between;
}
+
+ & .category-meta--start {
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+ }
}
.category-header--nav {
diff --git a/app/assets/stylesheets/comments.scss b/app/assets/stylesheets/comments.scss
index df4686166..f80352758 100644
--- a/app/assets/stylesheets/comments.scss
+++ b/app/assets/stylesheets/comments.scss
@@ -65,6 +65,17 @@
font-style: italic;
}
+.post--comments-header {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 0.75rem;
+}
+
+.post--comments-container {
+ margin-bottom: 1rem;
+}
+
.post--comments-thread.is-inline {
padding: 0.5rem 0.25rem;
display: flex;
@@ -139,7 +150,7 @@
.new-thread-modal {
box-shadow: 0 3px 5px -2px #eee;
border: 1px solid #d0d9dd;
- margin-top: 10px;
+ margin-top: 1rem;
padding: 0.7em;
display: none;
}
diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss
index baa824c4d..284c3418e 100644
--- a/app/assets/stylesheets/forms.scss
+++ b/app/assets/stylesheets/forms.scss
@@ -17,14 +17,6 @@ select.form-element {
@media screen and (min-width: $screen-md) {
flex-direction: row;
-
- & > :first-child {
- margin: 0 0.5em 0 0 !important;
- }
-
- & > :last-child {
- margin: 0 0 0 0.5em !important;
- }
}
& > .form-group {
diff --git a/app/assets/stylesheets/post_history.scss b/app/assets/stylesheets/post_history.scss
index 55d9411b2..12cfdcaf9 100644
--- a/app/assets/stylesheets/post_history.scss
+++ b/app/assets/stylesheets/post_history.scss
@@ -13,6 +13,10 @@
display: unset;
font-weight: unset;
color: unset;
+
+ & .droppanel {
+ position: fixed;
+ }
}
&:last-of-type {
diff --git a/app/assets/stylesheets/posts.scss b/app/assets/stylesheets/posts.scss
index a1a548af6..67e911056 100644
--- a/app/assets/stylesheets/posts.scss
+++ b/app/assets/stylesheets/posts.scss
@@ -89,7 +89,25 @@ h1 .badge.is-tag.is-master-tag {
width: calc(100% + 2px);
+ .widget--footer {
- margin-bottom: 0;
+ border-top: none;
+ align-items: center;
+ margin: 0;
+
+ &.mdhint {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1em 0;
+ justify-content: space-between;
+
+ & > * {
+ padding: 0;
+ }
+ }
+
+ & > .draft-status {
+ text-align: center;
+ transition: opacity 0.5s ease-in-out;
+ }
}
}
diff --git a/app/assets/stylesheets/site_settings.scss b/app/assets/stylesheets/site_settings.scss
index f9a52d4d0..9cdec8029 100644
--- a/app/assets/stylesheets/site_settings.scss
+++ b/app/assets/stylesheets/site_settings.scss
@@ -1,4 +1,5 @@
.site-setting--value {
min-height: 1em;
min-width: 2em;
+ overflow-wrap: anywhere;
}
\ No newline at end of file
diff --git a/app/assets/stylesheets/users.scss b/app/assets/stylesheets/users.scss
index a79c0662a..867e962e3 100644
--- a/app/assets/stylesheets/users.scss
+++ b/app/assets/stylesheets/users.scss
@@ -43,6 +43,7 @@
}
}
+
.profile-text {
padding: 0.125em;
line-height: 1.5;
@@ -50,17 +51,35 @@
overflow: auto;
}
-.user-profile-heading {
- padding: 0.5em;
+.user-profile-heading-container {
+ align-items: center;
border-bottom: 1px solid #ddd;
- margin-bottom: 0;
-}
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 0.5em;
+
+ & > .user-profile-heading {
+ flex-grow: 1;
+ margin-bottom: 0;
+ margin-top: 0;
+ padding: 0.5em;
+ &:not(:last-child) {
+ padding-right: 0;
+ }
+ }
+
+ & > .button:last-child {
+ margin-right: 0;
+ }
+}
.user-profile--image {
text-align: center;
img {
width: 100%;
+ object-fit: contain;
}
}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index be684f5f9..2ed9f314b 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -95,6 +95,16 @@ pre.pre-wrap {
white-space: pre-wrap !important;
}
+.copy-button {
+ display: none;
+ position: absolute;
+ right: 0;
+}
+
+div:hover > .copy-button {
+ display: block;
+}
+
.stat-panel {
flex: 1;
border: 1px solid $muted-graphic;
@@ -271,3 +281,11 @@ span.spoiler {
color: $key;
}
}
+
+.clearfix {
+ overflow: hidden;
+}
+
+.transparent {
+ opacity: 0;
+}
diff --git a/app/controllers/active_storage/base_controller.rb b/app/controllers/active_storage/base_controller.rb
new file mode 100644
index 000000000..63cf54708
--- /dev/null
+++ b/app/controllers/active_storage/base_controller.rb
@@ -0,0 +1,17 @@
+class ActiveStorage::BaseController < ActionController::Base
+ before_action :enforce_signed_in
+ include ActiveStorage::SetCurrent
+ protect_from_forgery with: :exception
+
+ self.etag_with_template_digest = false
+
+ protected
+
+ def enforce_signed_in
+ # If not restricted, the user is signed in or the environment is test, allow all content.
+ return true if !SiteSetting['RestrictedAccess'] || user_signed_in? || Rails.env.test?
+
+ redirect_to '/', status: :forbidden
+ false
+ end
+end
diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb
index b7453e9ce..5d13f6d3b 100644
--- a/app/controllers/admin_controller.rb
+++ b/app/controllers/admin_controller.rb
@@ -3,7 +3,7 @@ class AdminController < ApplicationController
before_action :verify_admin, except: [:change_back, :verify_elevation]
before_action :verify_global_admin, only: [:admin_email, :send_admin_email, :new_site, :create_site, :setup,
:setup_save, :hellban]
- before_action :verify_developer, only: [:change_users, :impersonate]
+ before_action :verify_developer, only: [:change_users, :impersonate, :all_email, :send_all_email]
def index; end
@@ -52,6 +52,18 @@ def send_admin_email
redirect_to admin_path
end
+ def all_email; end
+
+ def send_all_email
+ Thread.new do
+ AdminMailer.with(body_markdown: params[:body_markdown], subject: params[:subject]).to_all_users.deliver_now
+ end
+ AuditLog.admin_audit(event_type: 'send_all_email', user: current_user,
+ comment: "Subject: #{params[:subject]}")
+ flash[:success] = t 'admin.email_being_sent'
+ redirect_to admin_path
+ end
+
def audit_log
@logs = if current_user.is_global_admin
AuditLog.unscoped.where.not(log_type: ['user_annotation', 'user_history'])
@@ -170,7 +182,12 @@ def verify_elevation
return not_found unless session[:impersonator_id].present?
@impersonator = User.find session[:impersonator_id]
- if @impersonator&.valid_password? params[:password]
+ if @impersonator&.sso_profile.present?
+ session.delete :impersonator_id
+ AuditLog.admin_audit(event_type: 'impersonation_end', related: current_user, user: @impersonator)
+ sign_out @impersonator
+ redirect_to new_saml_user_session_path
+ elsif @impersonator&.valid_password? params[:password]
session.delete :impersonator_id
AuditLog.admin_audit(event_type: 'impersonation_end', related: current_user, user: @impersonator)
sign_in @impersonator
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index ce8c81040..efa5963ca 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -7,9 +7,15 @@ class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :set_globals
+ before_action :enforce_signed_in, unless: :devise_controller?
before_action :check_if_warning_or_suspension_pending
before_action :distinguish_fake_community
before_action :stop_the_awful_troll
+
+ # Before checking 2fa enforcing or access, store the location that the user is trying to access.
+ # In case re-authentication is necessary / the user signs in, we can direct back to this location directly.
+ before_action :store_user_location!, if: :storable_location?
+
before_action :enforce_2fa
before_action :block_write_request, if: :read_only_mode?
@@ -17,15 +23,19 @@ class ApplicationController < ActionController::Base
def upload
if ActiveStorage::Blob.service.class.name.end_with?('S3Service')
- redirect_to helpers.upload_remote_url(params[:key]), status: 301
+ redirect_to helpers.upload_remote_url(params[:key]), status: 301, allow_other_host: true
else
blob = params[:key]
- redirect_to url_for(ActiveStorage::Blob.find_by(key: blob.is_a?(String) ? blob : blob.key))
+ redirect_to url_for(ActiveStorage::Blob.find_by(key: blob.is_a?(String) ? blob : blob.key)),
+ allow_other_host: true
end
end
def dashboard
@communities = Community.all
+ @edits = Post.unscoped do
+ SuggestedEdit.unscoped.joins(:post).where(active: true).group(Arel.sql('posts.category_id')).count
+ end
render layout: 'without_sidebar'
end
@@ -309,7 +319,8 @@ def enforce_2fa
# Enable users to log out even if 2fa is enforced
!request.fullpath.end_with?('/users/sign_out') &&
(current_user.is_global_admin ||
- current_user.is_global_moderator)
+ current_user.is_global_moderator) &&
+ (current_user.sso_profile.blank? || SiteSetting['Enable2FAForSsoUsers'])
redirect_path = '/users/two-factor'
unless request.fullpath.end_with?(redirect_path)
flash[:notice] = 'All global admins and global moderators must enable two-factor authentication to continue' \
@@ -319,6 +330,39 @@ def enforce_2fa
end
end
+ # Ensure that the user is signed in before showing any content. If the user is not signed in, display the main page.
+ #
+ # Exceptions:
+ # - 4** and 500 error pages
+ # - stylesheets and javascript
+ # - assets
+ # - /help, /policy, /help/* and /policy/*
+ def enforce_signed_in
+ # If not restricted, the user is signed in or the environment is test, allow all content.
+ return true if !SiteSetting['RestrictedAccess'] || user_signed_in? || Rails.env.test?
+
+ # Allow error pages and assets
+ path = request.fullpath
+ return true if path.start_with?('/4') || path == '/500' ||
+ path.start_with?('/assets/') ||
+ path.end_with?('.css') || path.end_with?('.js')
+
+ # Make available to controller that the we should not leak posts in the sidebar
+ @prevent_sidebar = true
+
+ # Allow /help (help center), /help/* and /policy/* depending on settings
+ help = SiteSetting['RestrictedAccessHelpPagesPublic']
+ policy = SiteSetting['RestrictedAccessPolicyPagesPublic']
+ return true if (help && path.start_with?('/help/')) ||
+ (policy && path.start_with?('/policy/')) ||
+ (path == '/help' && (help || policy))
+
+ store_location_for(:user, request.fullpath) if storable_location?
+
+ render 'errors/restricted_content', layout: 'without_sidebar', status: :forbidden
+ false
+ end
+
def block_write_request(**add)
respond_to do |format|
format.html do
@@ -342,12 +386,24 @@ def user_signed_in?
helpers.user_signed_in?
end
+ def sso_sign_in_enabled?
+ helpers.sso_sign_in_enabled?
+ end
+
+ def devise_sign_in_enabled?
+ helpers.devise_sign_in_enabled?
+ end
+
def authenticate_user!(_fav = nil, **_opts)
unless user_signed_in?
respond_to do |format|
format.html do
flash[:error] = 'You need to sign in or sign up to continue.'
- redirect_to new_user_session_path
+ if devise_sign_in_enabled?
+ redirect_to new_user_session_path
+ else
+ redirect_to new_saml_user_session_path
+ end
end
format.json do
render json: { error: 'You need to sign in or sign up to continue.' }, status: 401
@@ -355,4 +411,27 @@ def authenticate_user!(_fav = nil, **_opts)
end
end
end
+
+ # Checks if the requested location should be stored.
+ #
+ # Its important that the location is NOT stored if:
+ # - The request method is not GET (non idempotent)
+ # - The request is handled by a Devise controller such as Devise::SessionsController as that could cause an
+ # infinite redirect loop.
+ # - The request is an Ajax request as this can lead to very unexpected behaviour.
+ # - The request is to a location we dont want to store, such as:
+ # - Anything trying to fetch for the current user (filters, preferences, etc) as it is not the actual page
+ # - The mobile login, as it would redirect to the code url after the sign in
+ # - Uploaded files, as these appear in posts and are not the main route we would want to store
+ def storable_location?
+ request.get? && is_navigational_format? && !devise_controller? && !request.xhr? &&
+ !request.path.start_with?('/users/me') &&
+ !request.path.start_with?('/users/mobile-login') &&
+ !request.path.start_with?('/uploads/')
+ end
+
+ # Stores the location in the system for the current session, such that after login we send them back to the same page.
+ def store_user_location!
+ store_location_for(:user, request.fullpath)
+ end
end
diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb
index b8076e366..c319160f3 100644
--- a/app/controllers/categories_controller.rb
+++ b/app/controllers/categories_controller.rb
@@ -144,6 +144,7 @@ def category_params
:color_code, :min_view_trust_level, :license_id, :sequence,
:asking_guidance_override, :answering_guidance_override,
:use_for_hot_posts, :use_for_advertisement,
+ :min_title_length, :min_body_length, :default_filter_id,
display_post_types: [], post_type_ids: [], required_tag_ids: [],
topic_tag_ids: [], moderator_tag_ids: [])
end
@@ -161,8 +162,41 @@ def set_list_posts
native: Arel.sql('att_source IS NULL DESC, last_activity DESC') }
sort_param = sort_params[params[:sort]&.to_sym] || { last_activity: :desc }
@posts = @category.posts.undeleted.where(post_type_id: @category.display_post_types)
- .includes(:post_type, :tags).list_includes.paginate(page: params[:page], per_page: 50)
- .order(sort_param)
+ .includes(:post_type, :tags).list_includes
+ filter_qualifiers = helpers.params_to_qualifiers
+ @active_filter = helpers.active_filter
+
+ if filter_qualifiers.blank? && @active_filter[:name].blank?
+ if user_signed_in?
+ default_filter_id = helpers.default_filter(current_user.id, @category.id)
+ default_filter = Filter.find_by(id: default_filter_id)
+ default = :user if default_filter.present?
+ end
+
+ if default_filter.nil?
+ default_filter = @category.default_filter
+ default = :category if default_filter.present?
+ end
+
+ unless default_filter.nil?
+ filter_qualifiers = helpers.filter_to_qualifiers default_filter
+ @active_filter = {
+ default: default,
+ name: default_filter.name,
+ min_score: default_filter.min_score,
+ max_score: default_filter.max_score,
+ min_answers: default_filter.min_answers,
+ max_answers: default_filter.max_answers,
+ include_tags: default_filter.include_tags,
+ exclude_tags: default_filter.exclude_tags,
+ status: default_filter.status
+ }
+ end
+ end
+
+ @posts = helpers.qualifiers_to_sql(filter_qualifiers, @posts)
+ @filtered = filter_qualifiers.any?
+ @posts = @posts.paginate(page: params[:page], per_page: 50).order(sort_param)
end
def update_last_visit(category)
diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb
index 57894f297..607d391a8 100644
--- a/app/controllers/comments_controller.rb
+++ b/app/controllers/comments_controller.rb
@@ -101,12 +101,19 @@ def create
end
def update
+ @post = @comment.post
+ @comment_thread = @comment.comment_thread
before = @comment.content
+ before_pings = check_for_pings @comment_thread, before
if @comment.update comment_params
unless current_user.id == @comment.user_id
AuditLog.moderator_audit(event_type: 'comment_update', related: @comment, user: current_user,
comment: "from <<#{before}>>\nto <<#{@comment.content}>>")
end
+
+ after_pings = check_for_pings @comment_thread, @comment.content
+ apply_pings(after_pings - before_pings - @comment_thread.thread_follower.to_a)
+
render json: { status: 'success',
comment: render_to_string(partial: 'comments/comment', locals: { comment: @comment }) }
else
diff --git a/app/controllers/email_logs_controller.rb b/app/controllers/email_logs_controller.rb
new file mode 100644
index 000000000..40cded41f
--- /dev/null
+++ b/app/controllers/email_logs_controller.rb
@@ -0,0 +1,28 @@
+class EmailLogsController < ApplicationController
+ skip_forgery_protection only: [:log]
+ skip_before_action :set_globals, only: [:log]
+ skip_before_action :distinguish_fake_community, only: [:log]
+ skip_before_action :enforce_signed_in, only: [:log]
+
+ def log
+ message_type = request.headers['X-Amz-SNS-Message-Type']
+ if ['SubscriptionConfirmation', 'Notification'].include? message_type
+ verifier = Aws::SNS::MessageVerifier.new
+ body = request.body.read
+ if verifier.authentic? body
+ aws_data = JSON.parse body
+ if message_type == 'SubscriptionConfirmation'
+ EmailLog.create(log_type: 'SubscriptionConfirmation', data: aws_data)
+ else
+ message_data = JSON.parse aws_data['Message']
+ log_type = message_data['notificationType']
+ destination = message_data['mail']['destination'].join(', ')
+ EmailLog.create(log_type: log_type, destination: destination, data: aws_data['Message'])
+ end
+ render plain: 'OK'
+ else
+ render plain: "You're not AWS. Go away.", status: 401
+ end
+ end
+ end
+end
diff --git a/app/controllers/flags_controller.rb b/app/controllers/flags_controller.rb
index a83947d37..f505f08fc 100644
--- a/app/controllers/flags_controller.rb
+++ b/app/controllers/flags_controller.rb
@@ -41,7 +41,7 @@ def new
end
def history
- @user = User.find(params[:id])
+ @user = helpers.user_with_me params[:id]
unless @user == current_user || (current_user.is_admin || current_user.is_moderator)
not_found
return
diff --git a/app/controllers/moderator_controller.rb b/app/controllers/moderator_controller.rb
index ae482cd4f..04d2a94b5 100644
--- a/app/controllers/moderator_controller.rb
+++ b/app/controllers/moderator_controller.rb
@@ -48,8 +48,8 @@ def remove_promotion
render json: { status: 'success', success: true }
end
- VoteData = Struct.new(:cast, :received)
- VoteSummary = Struct.new(:breakdown, :types, :total)
+ VoteData = Struct.new(:cast, :received, keyword_init: true)
+ VoteSummary = Struct.new(:breakdown, :types, :total, keyword_init: true)
def user_vote_summary
@user = User.find params[:id]
diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb
index 02477f56a..b47d7fcfc 100644
--- a/app/controllers/notifications_controller.rb
+++ b/app/controllers/notifications_controller.rb
@@ -6,7 +6,7 @@ def index
@notifications = Notification.unscoped.where(user: current_user).paginate(page: params[:page], per_page: 100)
.order(Arel.sql('is_read ASC, created_at DESC'))
respond_to do |format|
- format.html { render :index }
+ format.html { render :index, layout: 'without_sidebar' }
format.json { render json: @notifications, methods: :community_name }
end
end
diff --git a/app/controllers/post_history_controller.rb b/app/controllers/post_history_controller.rb
index 050c4ba38..543771e02 100644
--- a/app/controllers/post_history_controller.rb
+++ b/app/controllers/post_history_controller.rb
@@ -6,8 +6,30 @@ def post
return not_found
end
- @history = PostHistory.where(post_id: params[:id]).includes(:post_history_type, :user, post_history_tags: [:tag])
- .order(created_at: :desc).paginate(per_page: 20, page: params[:page])
- render layout: 'without_sidebar'
+ @history = PostHistory.where(post_id: params[:id])
+ .includes(:post_history_type, :user, post_history_tags: [:tag])
+ .order(created_at: :desc, id: :desc)
+ .paginate(per_page: 20, page: params[:page])
+
+ if @post&.help_category.nil?
+ render layout: 'without_sidebar'
+ else
+ render 'post_history/post', layout: 'without_sidebar', locals: { show_content: false }
+ end
+ end
+
+ def slug_post
+ @post = Post.by_slug(params[:slug], current_user)
+
+ if @post.nil?
+ return not_found
+ end
+
+ @history = PostHistory.where(post_id: @post.id)
+ .includes(:post_history_type, :user)
+ .order(created_at: :desc, id: :desc)
+ .paginate(per_page: 20, page: params[:page])
+
+ render 'post_history/post', layout: 'without_sidebar', locals: { show_content: false }
end
end
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index d3295ea77..5e8b293a8 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -26,7 +26,7 @@ def new
return
end
- if ['HelpDoc', 'PolicyDoc'].include?(@post_type.name)
+ if @post_type.system?
check_permissions
# return # uncomment if you add more code after this
end
@@ -73,7 +73,7 @@ def create
return
end
- if ['HelpDoc', 'PolicyDoc'].include?(@post_type.name) && !check_permissions
+ if @post_type.system? && !check_permissions
return
end
@@ -144,12 +144,13 @@ def show
return not_found
end
+ # @post = @post.includes(:flags, flags: :post_flag_type)
@children = if current_user&.privilege?('flag_curate')
Post.where(parent_id: @post.id)
else
Post.where(parent_id: @post.id).undeleted
.or(Post.where(parent_id: @post.id, user_id: current_user&.id).where.not(user_id: nil))
- end.includes(:votes, :user, :comments, :license, :post_type)
+ end.includes(:votes, :user, :comments, :license, :post_type, :flags, flags: :post_flag_type)
.order(Post.arel_table[:id].not_eq(params[:answer]))
.user_sort({ term: params[:sort], default: Arel.sql('deleted ASC, score DESC, RAND()') },
score: Arel.sql('deleted ASC, score DESC, RAND()'), active: :last_activity,
@@ -159,6 +160,49 @@ def show
def edit; end
+ # Attempts to update a given post
+ # @param post [Post] post the user is attempting to update
+ # @param user [User] user attempting to update the post
+ # @param body_rendered [String] new post body
+ # @param edit_post_params [ActionController::Parameters] edit parameters
+ # @return [Boolean] status of the operation
+ def do_update(post, user, body_rendered, **edit_post_params)
+ post.update(edit_post_params.merge(body: body_rendered,
+ last_edited_at: DateTime.now,
+ last_edited_by: user,
+ last_activity: DateTime.now,
+ last_activity_by: user))
+ end
+
+ # Attempts to update a given post network-wide. The update is manual to avoid
+ # skipping validations and fail early if at least one validation fails.
+ # @param post [Post] post from which the network push is initiated
+ # @param posts [ActiveRecord::Result] network posts to be updated
+ # @param user [User] user attempting to push updates to network
+ # @param body_rendered [String] new post body
+ # @param edit_post_params [ActionController::Parameters] edit parameters
+ # @return [Boolean] status of the operation
+ def do_update_network(post, posts, user, body_rendered, **edit_post_params)
+ update_status = true
+
+ posts.each do |network_post|
+ network_post.update(edit_post_params.merge(body: body_rendered,
+ last_edited_at: DateTime.now,
+ last_edited_by_id: user.id,
+ last_activity: DateTime.now,
+ last_activity_by_id: user.id))
+
+ if network_post.errors.any?
+ post.errors.merge!(network_post.errors)
+ update_status = false
+ end
+
+ next if update_status == true
+ end
+
+ update_status
+ end
+
def update
before = { body: @post.body_markdown, title: @post.title, tags: @post.tags.to_a }
body_rendered = helpers.post_markdown(:post, :body_markdown)
@@ -169,39 +213,67 @@ def update
return redirect_to post_path(@post)
end
- if current_user.privilege?('edit_posts') || current_user.is_moderator || current_user == @post.user || \
- (@post_type.is_freely_editable && current_user.privilege?('unrestricted'))
- if ['HelpDoc', 'PolicyDoc'].include?(@post_type.name) && (current_user.is_global_moderator || \
- current_user.is_global_admin) && params[:network_push] == 'true'
- posts = Post.unscoped.where(post_type_id: [PolicyDoc.post_type_id, HelpDoc.post_type_id],
- doc_slug: @post.doc_slug, body: @post.body)
- update_params = edit_post_params.to_h.merge(body: body_rendered, last_edited_at: DateTime.now,
- last_edited_by_id: current_user.id, last_activity: DateTime.now,
- last_activity_by_id: current_user.id)
- posts.update_all(**update_params.symbolize_keys)
- posts.each do |post|
- PostHistory.post_edited(post, current_user, before: before[:body],
- after: @post.body_markdown, comment: params[:edit_comment],
- before_title: before[:title], after_title: @post.title,
- before_tags: before[:tags], after_tags: @post.tags)
+ if current_user.can_update(@post, @post_type)
+ if current_user.can_push_to_network(@post_type) && params[:network_push] == 'true'
+ # post network push & post histories creation must be atomic to prevent sync issues on error
+ @post.transaction do
+ posts = Post.unscoped.where(post_type_id: [PolicyDoc.post_type_id, HelpDoc.post_type_id],
+ doc_slug: @post.doc_slug,
+ body: @post.body)
+
+ update_status = do_update_network(@post, posts, current_user, body_rendered, **edit_post_params)
+
+ if update_status
+ posts.each do |post|
+ history_entry = PostHistory.post_edited(post, current_user, before: before[:body],
+ after: @post.body_markdown, comment: params[:edit_comment],
+ before_title: before[:title], after_title: @post.title,
+ before_tags: before[:tags], after_tags: @post.tags)
+
+ if history_entry&.errors&.any?
+ @post.errors.merge!(history_entry.errors)
+ raise ActiveRecord::Rollback
+ end
+ end
+
+ flash[:success] = "#{helpers.pluralize(posts.to_a.size, 'post')} updated."
+ redirect_to help_path(slug: @post.doc_slug)
+ end
+
+ next
end
- flash[:success] = "#{helpers.pluralize(posts.to_a.size, 'post')} updated."
- redirect_to help_path(slug: @post.doc_slug)
else
- if @post.update(edit_post_params.merge(body: body_rendered,
- last_edited_at: DateTime.now, last_edited_by: current_user,
- last_activity: DateTime.now, last_activity_by: current_user))
- PostHistory.post_edited(@post, current_user, before: before[:body],
- after: @post.body_markdown, comment: params[:edit_comment],
- before_title: before[:title], after_title: @post.title,
- before_tags: before[:tags], after_tags: @post.tags)
- Rails.cache.delete "community_user/#{current_user.community_user.id}/metric/E"
- do_draft_delete(URI(request.referer || '').path)
- redirect_to post_path(@post)
- else
- render :edit, status: :bad_request
+ # post update & post history creation must be atomic to prevent sync issues on error
+ @post.transaction do
+ update_status = do_update(@post, current_user, body_rendered, **edit_post_params)
+
+ if update_status
+ history_entry = PostHistory.post_edited(@post, current_user, before: before[:body],
+ after: @post.body_markdown, comment: params[:edit_comment],
+ before_title: before[:title], after_title: @post.title,
+ before_tags: before[:tags], after_tags: @post.tags)
+
+ if history_entry&.errors&.any?
+ @post.errors.merge!(history_entry.errors)
+ raise ActiveRecord::Rollback
+ end
+
+ if params[:redact]
+ PostHistory.redact(@post, current_user)
+ end
+ Rails.cache.delete "community_user/#{current_user.community_user.id}/metric/E"
+ do_draft_delete(URI(request.referer || '').path)
+ redirect_to post_path(@post)
+ end
+
+ next
end
end
+
+ # this is only reached if we rollback the transaction or fail validations
+ if @post.errors.any?
+ render :edit, status: :bad_request
+ end
else
new_user = !current_user.privilege?('unrestricted')
rate_limit = SiteSetting["RL_#{new_user ? 'NewUser' : ''}SuggestedEdits"]
@@ -232,7 +304,7 @@ def update
do_draft_delete(URI(request.referer || '').path)
redirect_to post_path(@post)
else
- @post.errors = edit.errors
+ @post.errors.copy!(edit.errors)
render :edit, status: :bad_request
end
end
@@ -315,6 +387,12 @@ def delete
return
end
+ if @post.post_type.is_freely_editable && !current_user&.is_moderator
+ flash[:danger] = helpers.i18ns('posts.cant_delete_community')
+ redirect_to post_path(@post)
+ return
+ end
+
if @post.children.any? { |a| !a.deleted? && a.score >= 0.5 } && !current_user&.is_moderator
flash[:danger] = helpers.i18ns('posts.cant_delete_responded')
redirect_to post_path(@post)
@@ -386,15 +464,14 @@ def restore
end
def document
- @post = Post.unscoped.where(doc_slug: params[:slug], community_id: [RequestContext.community_id, nil]).first
- not_found && return if @post.nil?
+ @post = Post.by_slug(params[:slug], current_user)
- if @post&.help_category == '$Disabled'
- not_found
- end
- if @post&.help_category == '$Moderator' && !current_user&.is_moderator
+ if @post.nil?
not_found
end
+
+ # Make sure we don't leak featured posts in the sidebar
+ render layout: 'without_sidebar' if @prevent_sidebar
end
def upload
@@ -417,6 +494,9 @@ def help_center
.order(:help_ordering, :title)
.group_by(&:post_type_id)
.transform_values { |posts| posts.group_by { |p| p.help_category.presence } }
+
+ # Make sure we don't leak featured posts in the sidebar
+ render layout: 'without_sidebar' if @prevent_sidebar
end
def change_category
@@ -506,14 +586,37 @@ def feature
render json: { status: 'success', success: true }
end
+ # saving by-field is kept for backwards compatibility with old drafts
def save_draft
- key = "saved_post.#{current_user.id}.#{params[:path]}"
- saved_at = "saved_post_at.#{current_user.id}.#{params[:path]}"
- RequestContext.redis.set key, params[:post]
- RequestContext.redis.set saved_at, DateTime.now.iso8601
- RequestContext.redis.expire key, 86_400 * 7
- RequestContext.redis.expire saved_at, 86_400 * 7
- render json: { status: 'success', success: true, key: key }
+ expiration_time = 86_400 * 7
+
+ base_key = "saved_post.#{current_user.id}.#{params[:path]}"
+
+ [:body, :comment, :excerpt, :license, :tag_name, :tags, :title].each do |key|
+ next unless params.key?(key)
+
+ key_name = [:body, :saved_at].include?(key) ? base_key : "#{base_key}.#{key}"
+
+ if key == :tags
+ valid_tags = params[key]&.select(&:present?)
+
+ RequestContext.redis.del(key_name)
+
+ if valid_tags.present?
+ RequestContext.redis.sadd(key_name, valid_tags)
+ end
+ else
+ RequestContext.redis.set(key_name, params[key])
+ end
+
+ RequestContext.redis.expire(key_name, expiration_time)
+ end
+
+ saved_at_key = "saved_post_at.#{current_user.id}.#{params[:path]}"
+ RequestContext.redis.set(saved_at_key, DateTime.now.iso8601)
+ RequestContext.redis.expire(saved_at_key, expiration_time)
+
+ render json: { status: 'success', success: true, key: base_key }
end
def delete_draft
@@ -580,9 +683,13 @@ def unless_locked
end
def do_draft_delete(path)
- key = "saved_post.#{current_user.id}.#{path}"
- saved_at = "saved_post_at.#{current_user.id}.#{path}"
- RequestContext.redis.del key, saved_at
+ keys = [:body, :comment, :excerpt, :license, :saved_at, :tags, :tag_name, :title].map do |key|
+ pfx = key == :saved_at ? 'saved_post_at' : 'saved_post'
+ base = "#{pfx}.#{current_user.id}.#{path}"
+ [:body, :saved_at].include?(key) ? base : "#{base}.#{key}"
+ end
+
+ RequestContext.redis.del(*keys)
end
end
# rubocop:enable Metrics/MethodLength
diff --git a/app/controllers/reactions_controller.rb b/app/controllers/reactions_controller.rb
index 8899d8fa4..23acd2311 100644
--- a/app/controllers/reactions_controller.rb
+++ b/app/controllers/reactions_controller.rb
@@ -33,13 +33,20 @@ def add
reaction = Reaction.new(user: current_user, post: @post, reaction_type: reaction_type, comment: comment)
- ActiveRecord::Base.transaction do
- thread&.save!
- comment&.save!
- reaction.save!
- end
+ begin
+ ActiveRecord::Base.transaction do
+ thread&.save!
+ comment&.save!
+ reaction.save!
+ end
- render json: { status: 'success' }
+ render json: { status: 'success' }
+ rescue
+ render json: { status: 'failed',
+ message: "Could not create comment thread: #{(thread&.errors&.full_messages.to_a \
+ + comment&.errors&.full_messages.to_a \
+ + reaction&.errors&.full_messages.to_a).join(', ')}" }
+ end
end
def retract
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index f2e6673ef..202a3636b 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -1,20 +1,11 @@
class SearchController < ApplicationController
def search
- @posts = if params[:search].present?
- search_data = helpers.parse_search(params[:search])
- posts = (current_user&.is_moderator || current_user&.is_admin ? Post : Post.undeleted)
- .qa_only.list_includes
- posts = helpers.qualifiers_to_sql(search_data[:qualifiers], posts)
- posts = posts.paginate(page: params[:page], per_page: 25)
+ @posts, @qualifiers = helpers.search_posts
+
+ @signed_out_me = @qualifiers.any? { |q| q[:param] == :user && q[:user_id].nil? }
+
+ @active_filter = helpers.active_filter
- if search_data[:search].present?
- posts.search(search_data[:search]).user_sort({ term: params[:sort], default: :search_score },
- relevance: :search_score, score: :score, age: :created_at)
- else
- posts.user_sort({ term: params[:sort], default: :score },
- score: :score, age: :created_at)
- end
- end
@count = begin
@posts&.count
rescue
diff --git a/app/controllers/site_settings_controller.rb b/app/controllers/site_settings_controller.rb
index 9134f8da7..256b54ecc 100644
--- a/app/controllers/site_settings_controller.rb
+++ b/app/controllers/site_settings_controller.rb
@@ -4,6 +4,14 @@ class SiteSettingsController < ApplicationController
before_action :verify_admin
before_action :verify_global_admin, only: [:global]
+ # Checks if a given user has access to site settings on a given community
+ # @param [User] user user to check access for
+ # @param [String, nil] community_id id of the community to check access on
+ # @return [Boolean]
+ def access?(user, community_id)
+ community_id.present? || user.is_global_admin
+ end
+
def index
# The weird argument to sort_by here sorts without throwing errors on nil values -
# see https://stackoverflow.com/a/35539062/3160466. 0:1,c sorts nil last, to switch
@@ -26,8 +34,41 @@ def show
render json: @setting&.as_json&.merge(typed: @setting.typed)
end
+ # Adds an audit log for a given site setting update event
+ # @param [User] user initiating user
+ # @param [SiteSetting] before current site setting
+ # @param [SiteSetting] after updated site setting
+ # @return [void]
+ def audit_update(user, before, after)
+ AuditLog.admin_audit(event_type: 'setting_update',
+ related: after,
+ user: user,
+ comment: "from <>\nto <>")
+ end
+
+ # Deletes cache for a given site setting for a given community
+ # @param [SiteSetting] setting site setting to clear cache for
+ # @param [String, nil] community_id community id to clear cache for
+ # @return [Boolean]
+ def clear_cache(setting, community_id)
+ Rails.cache.delete("SiteSettings/#{community_id}/#{setting.name}", include_community: false)
+ end
+
+ # Actually creates a given site setting
+ # @param [SiteSetting] setting site setting to create
+ # @param [String, nil] community_id community id to create a setting for
+ # @return [SiteSetting]
+ def do_create(setting, community_id)
+ SiteSetting.create(name: setting.name,
+ community_id: community_id,
+ value: '',
+ value_type: setting.value_type,
+ category: setting.category,
+ description: setting.description)
+ end
+
def update
- if params[:community_id].blank? && !current_user.is_global_admin
+ unless access?(current_user, params[:community_id])
not_found
return
end
@@ -36,20 +77,28 @@ def update
matches = SiteSetting.unscoped.where(community_id: RequestContext.community_id, name: params[:name])
if matches.count.zero?
global = SiteSetting.unscoped.where(community_id: nil, name: params[:name]).first
- SiteSetting.create(name: global.name, community_id: RequestContext.community_id, value: '',
- value_type: global.value_type, category: global.category,
- description: global.description)
+ do_create(global, RequestContext.community_id)
else
matches.first
end
else
SiteSetting.unscoped.where(community_id: nil, name: params[:name]).first
end
+
before = @setting.attributes_print
+
@setting.update(setting_params)
- AuditLog.admin_audit(event_type: 'setting_update', related: @setting, user: current_user,
- comment: "from <>\nto <>")
- Rails.cache.delete "SiteSettings/#{RequestContext.community_id}/#{@setting.name}"
+
+ audit_update(current_user, before, @setting)
+
+ if @setting.global?
+ Community.all.each do |c|
+ clear_cache(@setting, c.id)
+ end
+ else
+ clear_cache(@setting, RequestContext.community_id)
+ end
+
render json: { status: 'OK', setting: @setting&.as_json&.merge(typed: @setting.typed) }
end
diff --git a/app/controllers/suggested_edit_controller.rb b/app/controllers/suggested_edit_controller.rb
index 34e0bbc7b..c724df4a5 100644
--- a/app/controllers/suggested_edit_controller.rb
+++ b/app/controllers/suggested_edit_controller.rb
@@ -30,21 +30,56 @@ def approve
return
end
- opts = { before: @post.body_markdown, after: @edit.body_markdown, comment: @edit.comment,
- before_title: @post.title, after_title: @edit.title, before_tags: @post.tags, after_tags: @edit.tags }
-
- before = { before_body: @post.body, before_body_markdown: @post.body_markdown, before_tags_cache: @post.tags_cache,
- before_tags: @post.tags.to_a, before_title: @post.title }
+ comment = params[:comment].present? && !params[:comment].empty? ? params[:comment] : @edit.comment
+
+ # The to_a / dup methods called on the tags for `opts` and `before` are necessary.
+ # We need to work on a copy of them, because we update the post before the edit, which will change their values.
+ # (We would otherwise be pointing to the same instance, and only see the updated version).
+ opts = { before: @post.body_markdown,
+ after: @edit.body_markdown,
+ comment: comment,
+ before_title: @post.title,
+ after_title: @edit.title,
+ before_tags: @post.tags.to_a,
+ after_tags: @edit.tags }
+
+ before = { before_body: @post.body,
+ before_body_markdown: @post.body_markdown,
+ before_tags_cache: @post.tags_cache.dup,
+ before_tags: @post.tags.to_a,
+ before_title: @post.title }
+
+ @post.transaction do
+ post_update_status = @post.update(applied_details)
+
+ if post_update_status
+ edit_update_status = @edit.update(before.merge(active: false,
+ accepted: true,
+ comment: comment,
+ rejected_comment: '',
+ decided_at: DateTime.now,
+ decided_by: current_user,
+ updated_at: DateTime.now))
+
+ if @edit.errors.any?
+ @post.errors.merge!(@edit.errors)
+ raise ActiveRecord::Rollback
+ end
+
+ if edit_update_status
+ PostHistory.post_edited(@post, @edit.user, **opts)
+ AbilityQueue.add(@edit.user, "Suggested Edit Approved ##{@edit.id}")
+ end
+ end
+
+ next
+ end
- if @post.update(applied_details)
- @edit.update(before.merge(active: false, accepted: true, rejected_comment: '', decided_at: DateTime.now,
- decided_by: current_user, updated_at: DateTime.now))
- PostHistory.post_edited(@post, @edit.user, **opts)
+ if @post.errors.any?
+ render json: { status: 'error', message: @post.errors.full_messages.join(', ') }, status: :bad_request
+ else
flash[:success] = 'Edit approved successfully.'
- AbilityQueue.add(@edit.user, "Suggested Edit Approved ##{@edit.id}")
render json: { status: 'success', redirect_url: post_path(@post) }
- else
- render json: { status: 'error', message: @post.errors.full_messages.join(', ') }, status: :bad_request
end
end
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index f1e6dac20..98f133329 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -2,7 +2,8 @@ class TagsController < ApplicationController
before_action :authenticate_user!, only: [:new, :create, :edit, :update, :rename, :merge, :select_merge]
before_action :set_category, except: [:index]
before_action :set_tag, only: [:show, :edit, :update, :children, :rename, :merge, :select_merge, :nuke, :nuke_warning]
- before_action :verify_moderator, only: [:new, :create, :rename, :merge, :select_merge]
+ before_action :verify_tag_editor, only: [:new, :create]
+ before_action :verify_moderator, only: [:rename, :merge, :select_merge]
before_action :verify_admin, only: [:nuke, :nuke_warning]
def index
@@ -13,10 +14,10 @@ def index
(@tag_set&.tags || Tag).search(params[:term])
else
(@tag_set&.tags || Tag.all).order(:name)
- end.paginate(page: params[:page], per_page: 50)
+ end.includes(:tag_synonyms).paginate(page: params[:page], per_page: 50)
respond_to do |format|
format.json do
- render json: @tags
+ render json: @tags.to_json(include: { tag_synonyms: { only: :name } })
end
end
end
@@ -26,18 +27,27 @@ def category
@tags = if params[:q].present?
@tag_set.tags.search(params[:q])
elsif params[:hierarchical].present?
- @tag_set.tags_with_paths.order(:path)
+ @tag_set.with_paths(params[:no_excerpt])
elsif params[:no_excerpt].present?
- @tag_set.tags.where(excerpt: '').or(@tag_set.tags.where(excerpt: nil))
- .order(Arel.sql('COUNT(posts.id) DESC'))
+ @tag_set.tags.where(excerpt: ['', nil])
else
- @tag_set.tags.order(Arel.sql('COUNT(posts.id) DESC'))
+ @tag_set&.tags
end
- @count = @tags.count
+
table = params[:hierarchical].present? ? 'tags_paths' : 'tags'
- @tags = @tags.left_joins(:posts).group(Arel.sql("#{table}.id"))
- .select(Arel.sql("#{table}.*, COUNT(posts.id) AS post_count"))
- .paginate(per_page: 96, page: params[:page])
+
+ @tags = @tags&.left_joins(:posts)
+ &.group(Arel.sql("#{table}.id"))
+ &.select(Arel.sql("#{table}.*, COUNT(DISTINCT IF(posts.deleted = 0, posts.id, NULL)) AS post_count"))
+ &.paginate(per_page: 96, page: params[:page])
+
+ @tags = if params[:hierarchical].present?
+ @tags&.order(:path)
+ else
+ @tags&.order(Arel.sql('COUNT(posts.id) DESC'))
+ end
+
+ @count = @tags&.length || 0
end
def show
@@ -49,8 +59,9 @@ def show
else
@tag.all_children + [@tag.id]
end
- post_ids = helpers.post_ids_for_tags(tag_ids)
- @posts = Post.where(id: post_ids).undeleted.where(post_type_id: @category.display_post_types)
+ displayed_post_types = @tag.tag_set.categories.map(&:display_post_types).flatten
+ @posts = Post.joins(:tags).where(id: PostsTag.select(:post_id).distinct.where(tag_id: tag_ids))
+ .undeleted.where(post_type_id: displayed_post_types)
.includes(:post_type, :tags).list_includes.paginate(page: params[:page], per_page: 50)
.order(sort_param)
respond_to do |format|
@@ -61,6 +72,7 @@ def show
def new
@tag = Tag.new
+ @tag.tag_synonyms.build
end
def create
@@ -76,6 +88,7 @@ def create
def edit
check_your_privilege('edit_tags', nil, true)
+ @tag.tag_synonyms.build
end
def update
@@ -123,57 +136,68 @@ def merge
@subordinate = Tag.find params[:merge_with_id]
- AuditLog.moderator_audit event_type: 'tag_merge', related: @primary, user: current_user,
- comment: "#{@subordinate.name} (#{@subordinate.id}) into #{@primary.name} (#{@primary.id})"
-
- # Take the tag off posts
- posts_sql = 'UPDATE posts INNER JOIN posts_tags ON posts.id = posts_tags.post_id ' \
- 'SET posts.tags_cache = REPLACE(posts.tags_cache, ?, ?) ' \
- 'WHERE posts_tags.tag_id = ?'
- exec([posts_sql, "\n- #{@subordinate.name}", "\n- #{@primary.name}", @subordinate.id])
-
- # Break hierarchies
- tags_sql = 'UPDATE tags SET parent_id = NULL WHERE parent_id = ?'
- exec([tags_sql, @subordinate.id])
-
- # Remove references to the tag
- sql = 'UPDATE IGNORE $TABLENAME SET tag_id = ? WHERE tag_id = ?'
- exec([sql.gsub('$TABLENAME', 'posts_tags'), @primary.id, @subordinate.id])
- exec([sql.gsub('$TABLENAME', 'categories_moderator_tags'), @primary.id, @subordinate.id])
- exec([sql.gsub('$TABLENAME', 'categories_required_tags'), @primary.id, @subordinate.id])
- exec([sql.gsub('$TABLENAME', 'categories_topic_tags'), @primary.id, @subordinate.id])
- exec([sql.gsub('$TABLENAME', 'post_history_tags'), @primary.id, @subordinate.id])
- exec([sql.gsub('$TABLENAME', 'suggested_edits_tags'), @primary.id, @subordinate.id])
- exec([sql.gsub('$TABLENAME', 'suggested_edits_before_tags'), @primary.id, @subordinate.id])
-
- # Nuke it from orbit
- @subordinate.destroy
+ Post.transaction do
+ AuditLog.moderator_audit event_type: 'tag_merge', related: @primary, user: current_user, comment:
+ "#{@subordinate.name} (#{@subordinate.id}) into #{@primary.name} (#{@primary.id})"
+
+ # Replace subordinate with primary, except when a post already has primary (to avoid giving them a duplicate tag)
+ posts_sql = 'UPDATE posts INNER JOIN posts_tags ON posts.id = posts_tags.post_id ' \
+ 'SET posts.tags_cache = REPLACE(posts.tags_cache, ?, ?) ' \
+ 'WHERE posts_tags.tag_id = ? ' \
+ 'AND posts_tags.post_id NOT IN (SELECT post_id FROM posts_tags WHERE tag_id = ?)'
+ exec_sql([posts_sql, "\n- #{@subordinate.name}\n", "\n- #{@primary.name}\n", @subordinate.id, @primary.id])
+
+ # Remove the subordinate tag from posts that still have it (the ones that were excluded from our previous query)
+ posts2_sql = 'UPDATE posts INNER JOIN posts_tags ON posts.id = posts_tags.post_id ' \
+ 'SET posts.tags_cache = REPLACE(posts.tags_cache, ?, ?) ' \
+ 'WHERE posts_tags.tag_id = ?'
+ exec_sql([posts2_sql, "\n- #{@subordinate.name}\n", "\n", @subordinate.id])
+
+ # Break hierarchies
+ tags_sql = 'UPDATE tags SET parent_id = NULL WHERE parent_id = ?'
+ exec_sql([tags_sql, @subordinate.id])
+
+ # Remove references to the tag
+ sql = 'UPDATE IGNORE $TABLENAME SET tag_id = ? WHERE tag_id = ?'
+ exec_sql([sql.gsub('$TABLENAME', 'posts_tags'), @primary.id, @subordinate.id])
+ exec_sql([sql.gsub('$TABLENAME', 'categories_moderator_tags'), @primary.id, @subordinate.id])
+ exec_sql([sql.gsub('$TABLENAME', 'categories_required_tags'), @primary.id, @subordinate.id])
+ exec_sql([sql.gsub('$TABLENAME', 'categories_topic_tags'), @primary.id, @subordinate.id])
+ exec_sql([sql.gsub('$TABLENAME', 'post_history_tags'), @primary.id, @subordinate.id])
+ exec_sql([sql.gsub('$TABLENAME', 'suggested_edits_tags'), @primary.id, @subordinate.id])
+ exec_sql([sql.gsub('$TABLENAME', 'suggested_edits_before_tags'), @primary.id, @subordinate.id])
+
+ # Nuke it from orbit
+ @subordinate.destroy
+ end
flash[:success] = "Merged #{@subordinate.name} into #{@primary.name}."
redirect_to tag_path(id: @category.id, tag_id: @primary.id)
end
def nuke
- AuditLog.admin_audit event_type: 'tag_nuke', related: @tag, user: current_user,
- comment: "#{@tag.name} (#{@tag.id})"
-
- tables = ['posts_tags', 'categories_moderator_tags', 'categories_required_tags', 'categories_topic_tags',
- 'post_history_tags', 'suggested_edits_tags', 'suggested_edits_before_tags']
-
- # Remove tag from caches
- caches_sql = 'UPDATE posts INNER JOIN posts_tags ON posts.id = posts_tags.post_id ' \
- 'SET posts.tags_cache = REPLACE(posts.tags_cache, ?, ?) ' \
- 'WHERE posts_tags.tag_id = ?'
- exec([caches_sql, "\n- #{@tag.name}", '', @tag.id])
-
- # Delete all references to the tag
- tables.each do |tbl|
- sql = "DELETE FROM #{tbl} WHERE tag_id = ?"
- exec([sql, @tag.id])
- end
+ Post.transaction do
+ AuditLog.admin_audit event_type: 'tag_nuke', related: @tag, user: current_user,
+ comment: "#{@tag.name} (#{@tag.id})"
+
+ tables = ['posts_tags', 'categories_moderator_tags', 'categories_required_tags', 'categories_topic_tags',
+ 'post_history_tags', 'suggested_edits_tags', 'suggested_edits_before_tags']
+
+ # Remove tag from caches
+ caches_sql = 'UPDATE posts INNER JOIN posts_tags ON posts.id = posts_tags.post_id ' \
+ 'SET posts.tags_cache = REPLACE(posts.tags_cache, ?, ?) ' \
+ 'WHERE posts_tags.tag_id = ?'
+ exec_sql([caches_sql, "\n- #{@tag.name}\n", "\n", @tag.id])
+
+ # Delete all references to the tag
+ tables.each do |tbl|
+ sql = "DELETE FROM #{tbl} WHERE tag_id = ?"
+ exec_sql([sql, @tag.id])
+ end
- # Nuke it
- @tag.destroy
+ # Nuke it
+ @tag.destroy
+ end
flash[:success] = "Deleted #{@tag.name}"
redirect_to category_tags_path(@category)
@@ -192,10 +216,29 @@ def set_category
end
def tag_params
- params.require(:tag).permit(:excerpt, :wiki_markdown, :parent_id, :name)
+ params.require(:tag).permit(:excerpt, :wiki_markdown, :parent_id, :name,
+ tag_synonyms_attributes: [:id, :name, :_destroy])
end
- def exec(sql_array)
+ def exec_sql(sql_array)
ApplicationRecord.connection.execute(ActiveRecord::Base.sanitize_sql_array(sql_array))
end
+
+ def verify_tag_editor
+ unless user_signed_in? && (current_user.privilege?(:edit_tags) ||
+ current_user.is_moderator ||
+ current_user.is_admin)
+ respond_to do |format|
+ format.html do
+ render 'errors/not_found', layout: 'without_sidebar', status: :not_found
+ end
+ format.json do
+ render json: { status: 'failed', success: false, errors: ['not_found'] }, status: :not_found
+ end
+ end
+
+ return false
+ end
+ true
+ end
end
diff --git a/app/controllers/tour_controller.rb b/app/controllers/tour_controller.rb
index b7101473d..13ea7f27e 100644
--- a/app/controllers/tour_controller.rb
+++ b/app/controllers/tour_controller.rb
@@ -5,7 +5,9 @@ def index; end
def question1; end
- def question2; end
+ def question2
+ @tagset_id = TagSet.find_by(name: 'Tour')&.id || -1
+ end
def question3; end
diff --git a/app/controllers/two_factor_controller.rb b/app/controllers/two_factor_controller.rb
index 4d85ecba2..fea0dc9c6 100644
--- a/app/controllers/two_factor_controller.rb
+++ b/app/controllers/two_factor_controller.rb
@@ -5,11 +5,18 @@ class TwoFactorController < ApplicationController
def tf_status; end
def enable_2fa
+ if current_user.sso_profile.present? && !SiteSetting['Enable2FAForSsoUsers']
+ flash[:danger] = 'You cannot enable 2FA because you sign in through SSO.'
+ redirect_to two_factor_status_path
+ return
+ end
+
case params[:method]
when 'app'
- secret = ROTP::Base32.random
- current_user.update(two_factor_token: secret, two_factor_method: 'app')
- totp = ROTP::TOTP.new(secret, issuer: 'codidact.com')
+ @secret = ROTP::Base32.random
+ current_user.update(two_factor_token: @secret, two_factor_method: 'app',
+ backup_2fa_code: SecureRandom.alphanumeric(24))
+ totp = ROTP::TOTP.new(@secret, issuer: 'codidact.com')
uri = totp.provisioning_uri("#{current_user.id}@users-2fa.codidact.com")
qr_svg = RQRCode::QRCode.new(uri).as_svg
@qr_uri = "data:image/svg+xml;base64,#{Base64.encode64(qr_svg)}"
@@ -52,7 +59,7 @@ def confirm_disable_code
totp = ROTP::TOTP.new(current_user.two_factor_token)
if totp.verify(params[:code], drift_behind: 15, drift_ahead: 15)
- current_user.update(two_factor_token: nil, enabled_2fa: false)
+ current_user.update(two_factor_token: nil, enabled_2fa: false, backup_2fa_code: nil)
AuditLog.user_history(event_type: 'two_factor_disabled', related: current_user)
flash[:success] = 'Success! 2FA has been disabled on your account.'
redirect_to two_factor_status_path
@@ -81,4 +88,13 @@ def confirm_disable_link
flash[:success] = 'Success! 2FA has been disabled on your account.'
redirect_to two_factor_status_path
end
+
+ def show_backup_code
+ totp = ROTP::TOTP.new(current_user.two_factor_token)
+ if totp.verify(params[:code], drift_behind: 15, drift_ahead: 15)
+ render json: { status: 'success', code: current_user.backup_2fa_code }
+ else
+ render json: { status: 'error', message: 'Wrong code - please try again.' }, status: 401
+ end
+ end
end
diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb
index 72e61c7b5..00f0722b8 100644
--- a/app/controllers/users/registrations_controller.rb
+++ b/app/controllers/users/registrations_controller.rb
@@ -1,7 +1,18 @@
class Users::RegistrationsController < Devise::RegistrationsController
protected
+ layout 'without_sidebar', only: :edit
+
+ before_action :check_sso, only: :update
+
def after_update_path_for(resource)
edit_user_registration_path(resource)
end
+
+ def check_sso
+ if current_user && current_user.sso_profile.present?
+ flash['danger'] = 'You sign in with SSO, so updating your email/password is not possible.'
+ redirect_to edit_user_registration_path
+ end
+ end
end
diff --git a/app/controllers/users/saml_sessions_controller.rb b/app/controllers/users/saml_sessions_controller.rb
new file mode 100644
index 000000000..ff0ca3e58
--- /dev/null
+++ b/app/controllers/users/saml_sessions_controller.rb
@@ -0,0 +1,200 @@
+class Users::SamlSessionsController < Devise::SamlSessionsController
+ # Called when someone is redirected to sign into the application using SSO/SAML.
+ def new
+ # If this is not the base community, then redirect them there for the sign in
+ base = base_community
+ if base.id != RequestContext.community_id
+ redirect_to "//#{base.host}#{sign_in_request_from_other_path(RequestContext.community_id)}",
+ allow_other_host: true
+ return
+ end
+
+ # If we are the base community, use normal behavior
+ super
+ end
+
+ # This method is almost the same code as the Users::SessionsController#create, and any changes
+ # made here should probably also be applied over there.
+ def create
+ super do |user|
+ return unless post_sign_in(user, false)
+
+ # SSO Only - Redirect to filler endpoint to actually get the clients cookie values (not sent to us here).
+ # We need to check cookies because we may be signing in for another community.
+ redirect_to after_sign_in_check_path
+ return
+ end
+ end
+
+ # On the initial return from the SSO the client does not send along its cookies (CORS/CSRF/XSS protections).
+ # Instead, we redirect the user after the sign-in to this endpoint, such that we get their cookies.
+ # Then we can check whether we were supposed to sign them in for a different community.
+ def after_sign_in_check
+ if cookies.encrypted[:signing_in_for].present? &&
+ cookies.encrypted[:signing_in_for] != RequestContext.community_id
+ handle_sign_in_for_other_community(current_user)
+ return
+ end
+
+ return unless post_sign_in(current_user, true)
+
+ redirect_to after_sign_in_path_for(current_user)
+ end
+
+ # Another community requests to sign in via this community.
+ def sign_in_request_from_other
+ # Check whether the requested community actually exists
+ unless Community.exists?(params[:id])
+ raise ArgumentError, 'User is trying to sign in to non-existing community'
+ end
+
+ # Store in a cookie which community we are signing in for such that we can redirect back after the sign in.
+ cookies.encrypted[:signing_in_for] = {
+ value: params[:id],
+ httponly: true,
+ expires: 15.minutes.from_now
+ }
+
+ # If already signed in, sign them in in the other community as well. Otherwise redirect to SAML sign in.
+ if user_signed_in?
+ handle_sign_in_for_other_community(current_user)
+ else
+ redirect_to new_saml_user_session_path
+ end
+ end
+
+ # User was signed in at the base community, now sign in here.
+ def sign_in_return_from_base
+ # Figure out which user was signed in.
+ # If we get a blank result then the message is either too old or the user messed with it.
+ user_info = decrypt_user_info(params[:message])
+ if user_info.blank?
+ flash[:notice] = nil
+ flash[:danger] = 'Something went wrong signing in, please try again.'
+ redirect_to root_path
+ end
+
+ # Determine the user we are trying to sign in as and report error if we can't
+ user = User.find(user_info)
+ if user.nil?
+ flash[:notice] = nil
+ flash[:danger] = 'Something went wrong signing in, please contact support.'
+ redirect_to root_path
+ end
+
+ # Actually sign in the user and handle the post-sign-in behavior
+ sign_in(user)
+ return unless post_sign_in(user, true)
+
+ # Finish with default devise behavior for sign ins
+ redirect_to after_sign_in_path_for(user)
+ end
+
+ private
+
+ # After a sign in, this method is called to check whether special conditions apply to the user.
+ # The user may be signed out by this method.
+ #
+ # In general, this method should have similar behavior to the Users::SessionsController#post_sign_in method.
+ # If you make changes here, you may also have to update that method.
+ #
+ # @param user [User]
+ # @param final_destination [Boolean] whether the current community is the one the user is trying to sign into
+ # @return [Boolean] false if the user was redirected by this
+ def post_sign_in(user, final_destination = false)
+ # If the user was banished, let them know non-specifically.
+ if user.deleted? || user.community_user&.deleted?
+ # The IDP already confirmed the sign in, so we can't fool the user any more that their credentials were incorrect.
+ sign_out user
+ flash[:notice] = nil
+ flash[:danger] = 'We could not sign you in because of an issue with your account.'
+ redirect_to root_path
+ return false
+ end
+
+ # Enforce 2fa if enabled for SSO users
+ if SiteSetting['Enable2FAForSsoUsers'] && user.enabled_2fa
+ if final_destination
+ handle_2fa_login(user)
+ return false
+ else
+ # User needs to do 2FA, but we are (potentially) signing in for a different community.
+ # Sign them out and continue the sign in process, when they reach the final destination we will do 2FA there.
+ sign_out user
+ return true
+ end
+ end
+
+ true
+ end
+
+ def handle_2fa_login(user, host = nil)
+ host ||= request.hostname
+ sign_out user
+ case user.two_factor_method
+ when 'app'
+ id = user.id
+ Users::SessionsController.first_factor << id
+ redirect_to login_verify_2fa_path(uid: id)
+ when 'email'
+ TwoFactorMailer.with(user: user, host: host).login_email.deliver_now
+ flash[:notice] = nil
+ flash[:info] = 'Please check your email inbox for a link to sign in.'
+ redirect_to after_sign_in_path_for(user)
+ end
+ end
+
+ # Handles a successful sign in at the base community when it was requested to do from another community.
+ # @param user [User]
+ def handle_sign_in_for_other_community(user)
+ # Determine which community we are signing in for, log out if not found (user messed with encrypted cookie/expired)
+ community = Community.find(cookies.encrypted[:signing_in_for])
+ if community.nil?
+ sign_out(user)
+ flash[:notice] = nil
+ flash[:danger] = 'Something went wrong trying to sign you in.'
+ redirect_to root_path
+ return
+ end
+
+ # Clear the cookie to prevent future issues
+ cookies.delete(:signing_in_for)
+
+ # We signed in for a different community, we need to send them back to the original community with encrypted
+ # info about who signed in.
+ encrypted_user_info = encrypt_user_info(user)
+ redirect_to "//#{community.host}#{sign_in_return_from_base_path}?message=#{CGI.escape(encrypted_user_info)}",
+ allow_other_host: true
+ end
+
+ # Encrypts user information for sending them to a different community.
+ # @param user [User]
+ def encrypt_user_info(user)
+ len = ActiveSupport::MessageEncryptor.key_len - 1
+ key = Rails.application.secrets.secret_key_base || Rails.application.credentials.secret_key_base
+ crypt = ActiveSupport::MessageEncryptor.new(key[0..len])
+ crypt.encrypt_and_sign(user.id, expires_in: 1.minute)
+ end
+
+ # Decrypts the user information when received at a different community.
+ # @param data
+ def decrypt_user_info(data)
+ len = ActiveSupport::MessageEncryptor.key_len - 1
+ key = Rails.application.secrets.secret_key_base || Rails.application.credentials.secret_key_base
+ crypt = ActiveSupport::MessageEncryptor.new(key[0..len])
+ crypt.decrypt_and_verify(data)
+ end
+
+ # @return [Community] the community to which the SSO is connected, and which must be used to sign in via.
+ def base_community
+ uri = URI.parse(Devise.saml_config.assertion_consumer_service_url)
+ host = if uri.port != 80 && uri.port != 443 && !uri.port.nil?
+ "#{uri.hostname}:#{uri.port}"
+ else
+ uri.hostname
+ end
+ Community.find_by(host: host) || Community.first
+ rescue
+ Community.first
+ end
+end
diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb
index da34a1d18..9719fd745 100644
--- a/app/controllers/users/sessions_controller.rb
+++ b/app/controllers/users/sessions_controller.rb
@@ -1,42 +1,12 @@
class Users::SessionsController < Devise::SessionsController
protect_from_forgery except: [:create]
- @@first_factor = []
+ mattr_accessor :first_factor, default: [], instance_writer: false, instance_reader: false
+ # Any changes made here may also require changes to Users::SamlSessionsController#create.
def create
super do |user|
- if user.deleted?
- sign_out user
- flash[:notice] = nil
- flash[:danger] = 'Invalid Email or password.'
- render :new
- return
- end
-
- if user.community_user&.deleted?
- sign_out user
- flash[:notice] = nil
- flash[:danger] = 'Your profile on this community has been deleted.'
- render :new
- return
- end
-
- if user.present? && user.enabled_2fa
- sign_out user
- case user.two_factor_method
- when 'app'
- id = user.id
- @@first_factor << id
- redirect_to login_verify_2fa_path(uid: id)
- return
- when 'email'
- TwoFactorMailer.with(user: user, host: request.hostname).login_email.deliver_now
- flash[:notice] = nil
- flash[:info] = 'Please check your email inbox for a link to sign in.'
- redirect_to root_path
- return
- end
- end
+ return unless post_sign_in(user)
end
end
@@ -51,16 +21,26 @@ def verify_code
end
totp = ROTP::TOTP.new(target_user.two_factor_token)
- if totp.verify(params[:code], drift_ahead: 15, drift_behind: 15)
+ if totp.verify(params[:code], drift_ahead: 15, drift_behind: 15) || params[:code] == target_user.backup_2fa_code
if @@first_factor.include? params[:uid].to_i
+ if params[:code] == target_user.backup_2fa_code
+ target_user.update(enabled_2fa: false, two_factor_token: nil, backup_2fa_code: nil)
+ flash[:warning] = 'Two-factor authentication has been disabled for your account because you signed in with ' \
+ 'a backup code. Please re-configure two-factor authentication via your profile.'
+ end
+
AuditLog.user_history(event_type: 'two_factor_success', related: target_user)
@@first_factor.delete params[:uid].to_i
flash[:info] = 'Signed in successfully.'
- sign_in_and_redirect User.find(params[:uid])
+ sign_in_and_redirect target_user
else
AuditLog.user_history(event_type: 'two_factor_fail', related: target_user, comment: 'first factor not present')
flash[:danger] = "You haven't entered your password yet."
- redirect_to new_session_path(target_user)
+ if devise_sign_in_enabled?
+ redirect_to new_session_path(target_user)
+ else
+ redirect_to new_saml_user_session_path(target_user)
+ end
end
else
AuditLog.user_history(event_type: 'two_factor_fail', related: target_user, comment: 'wrong code')
@@ -68,4 +48,65 @@ def verify_code
redirect_to login_verify_2fa_path(uid: params[:uid])
end
end
+
+ private
+
+ # After a sign in, this method is called to check whether special conditions apply to the user.
+ # The user may be signed out by this method.
+ #
+ # In general, this method should have similar behavior to the Users::SamlSessionsController#post_sign_in method.
+ # If you make changes here, you may also have to update that method.
+ # @param user [User]
+ # @return [Boolean] false if the handling by the calling method should be stopped
+ def post_sign_in(user)
+ # For a deleted user (banished), tell them non-specifically that there was a mistake with their credentials.
+ if user.deleted?
+ sign_out user
+ flash[:notice] = nil
+ flash[:danger] = 'Invalid Email or password.'
+ render :new
+ return false
+ end
+
+ # If profile is deleted, the user was banished. Inform them and send them back to the sign in page.
+ if user.community_user&.deleted?
+ sign_out user
+ flash[:notice] = nil
+ flash[:danger] = 'Your profile on this community has been deleted.'
+ render :new
+ return false
+ end
+
+ # For users who are linked to an SSO Profile, disallow normal login and let them sign in through SSO instead.
+ if user.sso_profile.present?
+ sign_out user
+ flash[:notice] = nil
+ flash[:danger] = 'Please sign in using the Single Sign-On service of your institution.'
+ redirect_to new_saml_user_session_path
+ return false
+ end
+
+ # Enforce 2FA
+ if user.enabled_2fa
+ handle_2fa_login(user)
+ return false
+ end
+
+ true
+ end
+
+ def handle_2fa_login(user)
+ sign_out user
+ case user.two_factor_method
+ when 'app'
+ id = user.id
+ @@first_factor << id
+ redirect_to login_verify_2fa_path(uid: id)
+ when 'email'
+ TwoFactorMailer.with(user: user, host: request.hostname).login_email.deliver_now
+ flash[:notice] = nil
+ flash[:info] = 'Please check your email inbox for a link to sign in.'
+ redirect_to after_sign_in_path_for(user)
+ end
+ end
end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 11e95df0d..1b4aec6f0 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -5,12 +5,13 @@ class UsersController < ApplicationController
include Devise::Controllers::Rememberable
before_action :authenticate_user!, only: [:edit_profile, :update_profile, :stack_redirect, :transfer_se_content,
- :qr_login_code, :me, :preferences, :set_preference, :my_vote_summary]
+ :qr_login_code, :me, :preferences, :set_preference, :my_vote_summary,
+ :disconnect_sso, :confirm_disconnect_sso, :filters]
before_action :verify_moderator, only: [:mod, :destroy, :soft_delete, :role_toggle, :full_log,
:annotate, :annotations, :mod_privileges, :mod_privilege_action]
before_action :set_user, only: [:show, :mod, :destroy, :soft_delete, :posts, :role_toggle, :full_log, :activity,
:annotate, :annotations, :mod_privileges, :mod_privilege_action,
- :vote_summary, :avatar]
+ :vote_summary, :network, :avatar]
before_action :check_deleted, only: [:show, :posts, :activity]
def index
@@ -26,13 +27,20 @@ def index
def show
@abilities = Ability.on_user(@user)
- @posts = if current_user&.privilege?('flag_curate')
- @user.posts
- else
- @user.posts.undeleted
- end.list_includes.joins(:category)
- .where('IFNULL(categories.min_view_trust_level, 0) <= ?', current_user&.trust_level || 0)
- .order(score: :desc).first(15)
+
+ all_posts = if current_user&.privilege?('flag_curate') || @user == current_user
+ @user.posts
+ else
+ @user.posts.undeleted
+ end
+ .list_includes
+ .joins(:category)
+ .where('IFNULL(categories.min_view_trust_level, 0) <= ?', current_user&.trust_level || 0)
+ .user_sort({ term: params[:sort], default: :score },
+ age: :created_at, score: :score)
+
+ @posts = all_posts.first(15)
+ @total_post_count = all_posts.count
render layout: 'without_sidebar'
end
@@ -57,6 +65,7 @@ def preferences
prefs = current_user.preferences
@preferences = prefs[:global]
@community_prefs = prefs[:community]
+ render layout: 'without_sidebar'
end
format.json do
render json: current_user.preferences
@@ -64,6 +73,99 @@ def preferences
end
end
+ # Helper method to convert it to the form expected by the client
+ def filter_json(filter)
+ {
+ min_score: filter.min_score,
+ max_score: filter.max_score,
+ min_answers: filter.min_answers,
+ max_answers: filter.max_answers,
+ include_tags: Tag.where(id: filter.include_tags).map { |tag| [tag.name, tag.id] },
+ exclude_tags: Tag.where(id: filter.exclude_tags).map { |tag| [tag.name, tag.id] },
+ status: filter.status,
+ system: filter.user_id == -1
+ }
+ end
+
+ def filters_json
+ system_filters = Rails.cache.fetch 'default_system_filters', expires_in: 1.day do
+ User.find(-1).filters.to_h { |filter| [filter.name, filter_json(filter)] }
+ end
+
+ if user_signed_in?
+ current_user.filters.to_h { |filter| [filter.name, filter_json(filter)] }
+ .merge(system_filters)
+ else
+ system_filters
+ end
+ end
+
+ def filters
+ respond_to do |format|
+ format.html do
+ render layout: 'without_sidebar'
+ end
+ format.json do
+ render json: filters_json
+ end
+ end
+ end
+
+ def set_filter
+ if user_signed_in? && params[:name]
+ filter = Filter.find_or_create_by(user: current_user, name: params[:name])
+
+ filter.update(filter_params)
+
+ unless params[:category].nil? || params[:is_default].nil?
+ helpers.set_filter_default(current_user.id, filter.id, params[:category].to_i, params[:is_default])
+ end
+
+ render json: { status: 'success', success: true, filters: filters_json },
+ status: 200
+ else
+ render json: { status: 'failed', success: false, errors: ['Filter name is required'] },
+ status: 400
+ end
+ end
+
+ def delete_filter
+ unless params[:name]
+ return render json: { status: 'failed', success: false, errors: ['Filter name is required'] },
+ status: 400
+ end
+
+ as_user = current_user
+
+ if params[:system] == true
+ if current_user&.is_global_admin
+ as_user = User.find(-1)
+ else
+ return render json: { status: 'failed', success: false, errors: ['You do not have permission to delete'] },
+ status: 400
+ end
+ end
+
+ filter = Filter.find_by(user: as_user, name: params[:name])
+ if filter.destroy
+ render json: { status: 'success', success: true, filters: filters_json }
+ else
+ render json: { status: 'failed', success: false, errors: ['Failed to delete'] },
+ status: 400
+ end
+ end
+
+ def default_filter
+ if user_signed_in? && params[:category]
+ default_filter = helpers.default_filter(current_user.id, params[:category].to_i)
+ render json: { status: 'success', success: true, name: default_filter&.name },
+ status: 200
+ else
+ render json: { status: 'failed', success: false },
+ status: 400
+ end
+ end
+
def set_preference
if !params[:name].nil? && !params[:value].nil?
global_key = "prefs.#{current_user.id}"
@@ -80,7 +182,7 @@ def set_preference
end
def posts
- @posts = if current_user&.privilege?('flag_curate')
+ @posts = if current_user&.privilege?('flag_curate') || @user == current_user
Post.all
else
Post.undeleted
@@ -99,12 +201,22 @@ def posts
end
end
+ def my_network
+ redirect_to network_path(current_user)
+ end
+
+ def network
+ @communities = Community.all
+ render layout: 'without_sidebar'
+ end
+
def activity
@posts = Post.undeleted.where(user: @user).count
@comments = Comment.joins(:comment_thread, :post).undeleted.where(user: @user, comment_threads: { deleted: false },
posts: { deleted: false }).count
@suggested_edits = SuggestedEdit.where(user: @user).count
- @edits = PostHistory.joins(:post).where(user: @user, posts: { deleted: false }).count
+ @edits = PostHistory.joins(:post, :post_history_type).where(user: @user, posts: { deleted: false },
+ post_history_types: { name: 'post_edited' }).count
@all_edits = @suggested_edits + @edits
@@ -116,16 +228,17 @@ def activity
posts: { deleted: false })
when 'edits'
SuggestedEdit.where(user: @user) + \
- PostHistory.joins(:post).where(user: @user, posts: { deleted: false })
+ PostHistory.joins(:post, :post_history_type).where(user: @user, posts: { deleted: false },
+ post_history_types: { name: 'post_edited' })
else
Post.undeleted.where(user: @user) + \
Comment.joins(:comment_thread, :post).undeleted.where(user: @user, comment_threads: { deleted: false },
posts: { deleted: false }) + \
SuggestedEdit.where(user: @user).all + \
- PostHistory.joins(:post).where(user: @user, posts: { deleted: false })
+ PostHistory.joins(:post).where(user: @user, posts: { deleted: false }).all
end
- @items = items.sort_by(&:created_at).reverse
+ @items = items.sort_by(&:created_at).reverse.paginate(page: params[:page], per_page: 50)
render layout: 'without_sidebar'
end
@@ -168,7 +281,7 @@ def full_log
Post.where(user: @user).all + Comment.where(user: @user).all + Flag.where(user: @user).all + \
SuggestedEdit.where(user: @user).all + PostHistory.where(user: @user).all + \
ModWarning.where(community_user: @user.community_user).all
- end).sort_by(&:created_at).reverse
+ end).sort_by(&:created_at).reverse.paginate(page: params[:page], per_page: 50)
render layout: 'without_sidebar'
end
@@ -229,13 +342,7 @@ def soft_delete
return
end
- AuditLog.moderator_audit(event_type: 'user_delete', related: @user, user: current_user,
- comment: @user.attributes_print(join: "\n"))
- @user.assign_attributes(deleted: true, deleted_by_id: current_user.id, deleted_at: DateTime.now,
- username: "user#{@user.id}", email: "#{@user.id}@deleted.localhost",
- password: SecureRandom.hex(32))
- @user.skip_reconfirmation!
- @user.save
+ @user.do_soft_delete(current_user)
else
render json: { status: 'failed', message: 'Unrecognised deletion type.' }, status: 400
return
@@ -248,14 +355,24 @@ def edit_profile
render layout: 'without_sidebar'
end
- def update_profile
- profile_params = params.require(:user).permit(:username, :profile_markdown, :website, :twitter, :discord)
- profile_params[:twitter] = profile_params[:twitter].delete('@')
+ def cleaned_profile_websites(profile_params)
+ sites = profile_params[:user_websites_attributes]
- if profile_params[:website].present? && URI.parse(profile_params[:website]).instance_of?(URI::Generic)
- # URI::Generic indicates the user didn't include a protocol, so we'll add one now so that it can be
- # parsed correctly in the view later on.
- profile_params[:website] = "https://#{profile_params[:website]}"
+ sites.transform_values do |w|
+ w.merge({ label: w[:label].presence, url: w[:url].presence })
+ end
+ end
+
+ def update_profile
+ profile_params = params.require(:user).permit(:username,
+ :profile_markdown,
+ :website,
+ :discord,
+ :twitter,
+ user_websites_attributes: [:id, :label, :url])
+
+ if profile_params[:user_websites_attributes].present?
+ profile_params[:user_websites_attributes] = cleaned_profile_websites(profile_params)
end
@user = current_user
@@ -271,8 +388,14 @@ def update_profile
end
end
- profile_rendered = helpers.post_markdown(:user, :profile_markdown)
- if @user.update(profile_params.merge(profile: profile_rendered))
+ if params[:user][:profile_markdown].present?
+ profile_rendered = helpers.post_markdown(:user, :profile_markdown)
+ profile_params = profile_params.merge(profile: profile_rendered)
+ end
+
+ status = @user.update(profile_params)
+
+ if status
flash[:success] = 'Your profile details were updated.'
redirect_to user_path(current_user)
else
@@ -301,7 +424,7 @@ def role_toggle
# Set/update ability
if new_value
- @user.community_user.grant_privilege 'mod'
+ @user.community_user.grant_privilege! 'mod'
else
@user.community_user.privilege('mod').destroy
end
@@ -330,7 +453,7 @@ def mod_privilege_action
case params[:do]
when 'grant'
if ua.nil?
- @user.community_user.grant_privilege(ability.internal_id)
+ @user.community_user.grant_privilege!(ability.internal_id)
AuditLog.admin_audit(event_type: 'ability_grant', related: @user, user: current_user,
comment: ability.internal_id.to_s)
elsif ua.is_suspended
@@ -426,7 +549,7 @@ def do_qr_login
sign_in user
remember_me user
AuditLog.user_history(event_type: 'mobile_login', related: user)
- redirect_to root_path
+ redirect_to after_sign_in_path_for(user)
else
flash[:danger] = "That login link isn't valid. Codes expire after 5 minutes - if it's been longer than that, " \
'get a new code and try again.'
@@ -456,21 +579,27 @@ def my_vote_summary
end
def vote_summary
- @votes = Vote.where(recv_user: @user) \
- .includes(:post).group(:date_of, :post_id, :vote_type)
- @votes = @votes.select(:post_id, :vote_type) \
- .select('count(*) as vote_count') \
- .select('date(created_at) as date_of')
+ @votes = Vote.where(recv_user: @user)
+ .includes(:post)
+ .group(:date_of, :post_id, :vote_type)
+
+ @votes = @votes.select(:post_id, :vote_type)
+ .select('count(*) as vote_count')
+ .select('date(votes.created_at) as date_of')
+
@votes = @votes.order(date_of: :desc, post_id: :desc).all \
- .group_by(&:date_of).map { |k, vl| [k, vl.group_by(&:post) ] } \
+ .group_by(&:date_of).map do |k, vl|
+ [k, vl.group_by(&:post), vl.sum { |v| v.vote_type * v.vote_count }]
+ end \
.paginate(page: params[:page], per_page: 15)
- @votes
+
+ render layout: 'without_sidebar'
end
def avatar
respond_to do |format|
format.png do
- size = params[:size]&.to_i&.positive? ? params[:size]&.to_i : 64
+ size = params[:size]&.to_i&.positive? ? [params[:size]&.to_i, 256].min : 64
send_data helpers.user_auto_avatar(size, user: @user).to_blob, type: 'image/png', disposition: 'inline'
end
end
@@ -486,10 +615,41 @@ def specific_avatar
end
end
+ def disconnect_sso
+ render layout: 'without_sidebar'
+ end
+
+ def confirm_disconnect_sso
+ if current_user.sso_profile.blank? || !helpers.devise_sign_in_enabled? || !SiteSetting['AllowSsoDisconnect']
+ flash[:danger] = 'You cannot disable Single Sign-On.'
+ redirect_to edit_user_registration_path
+ return
+ end
+
+ if current_user.sso_profile.destroy
+ current_user.send_reset_password_instructions
+ flash[:success] = 'Successfully disconnected from Single Sign-On. Please see your email to set your password.'
+ redirect_to edit_user_registration_path
+ else
+ flash[:danger] = 'Failed to disconnect from Single Sign-On.'
+ redirect_to user_disconnect_sso_path
+ end
+ end
+
private
+ def filter_params
+ params.permit(:min_score, :max_score, :min_answers, :max_answers, :status, :include_tags, :exclude_tags,
+ include_tags: [], exclude_tags: [])
+ end
+
def set_user
- @user = user_scope.find_by(id: params[:id])
+ user_id = if params[:id] == 'me' && user_signed_in?
+ current_user.id
+ else
+ params[:id]
+ end
+ @user = user_scope.find_by(id: user_id)
not_found if @user.nil?
end
diff --git a/app/controllers/votes_controller.rb b/app/controllers/votes_controller.rb
index 84b57ce79..f39a98b44 100644
--- a/app/controllers/votes_controller.rb
+++ b/app/controllers/votes_controller.rb
@@ -42,8 +42,11 @@ def create
AbilityQueue.add(post.user, "Vote Change on ##{post.id}")
modified = !destroyed.empty?
- state = { status: (modified ? 'modified' : 'OK'), vote_id: vote.id, upvotes: post.upvote_count,
- downvotes: post.downvote_count }
+ state = { status: (modified ? 'modified' : 'OK'),
+ vote_id: vote.id,
+ upvotes: post.upvote_count,
+ downvotes: post.downvote_count,
+ score: post.score }
render json: state
end
@@ -59,7 +62,10 @@ def destroy
if vote.destroy
AbilityQueue.add(post.user, "Vote Change on ##{post.id}")
- render json: { status: 'OK', upvotes: post.upvote_count, downvotes: post.downvote_count }
+ render json: { status: 'OK',
+ upvotes: post.upvote_count,
+ downvotes: post.downvote_count,
+ score: post.score }
else
render json: { status: 'failed', message: vote.errors.full_messages.join('. ') }, status: :forbidden
end
diff --git a/app/helpers/advertisements/article_helper.rb b/app/helpers/advertisements/article_helper.rb
index 80a425c75..fc1dc9d61 100644
--- a/app/helpers/advertisements/article_helper.rb
+++ b/app/helpers/advertisements/article_helper.rb
@@ -21,8 +21,8 @@ def article_ad(article)
answer.font = './app/assets/imgfonts/Roboto-Bold.ttf'
answer.pointsize = 40
answer.gravity = CenterGravity
- answer.annotate ad, 600, 120, 0, 10, 'Check out this article' do
- self.fill = 'white'
+ answer.annotate ad, 600, 120, 0, 10, 'Check out this article' do |s|
+ s.fill = 'white'
end
icon_path = SiteSetting.find_by(name: 'SiteLogoPath', community: article.community).typed
@@ -37,8 +37,8 @@ def article_ad(article)
community_name.font = './app/assets/imgfonts/Roboto-Bold.ttf'
community_name.pointsize = 25
community_name.gravity = SouthWestGravity
- community_name.annotate ad, 0, 0, 20, 20, article.community.name do
- self.fill = '#4B68FF'
+ community_name.annotate ad, 0, 0, 20, 20, article.community.name do |s|
+ s.fill = '#4B68FF'
end
end
@@ -48,8 +48,8 @@ def article_ad(article)
community_url.font = './app/assets/imgfonts/Roboto-Bold.ttf'
community_url.pointsize = 20
community_url.gravity = SouthEastGravity
- community_url.annotate ad, 0, 0, 20, 20, article.community.host do
- self.fill = '#666666'
+ community_url.annotate ad, 0, 0, 20, 20, article.community.host do |s|
+ s.fill = '#666666'
end
title = Draw.new
@@ -62,15 +62,15 @@ def article_ad(article)
if article.title.length > 60
title.pointsize = 35
wrap_text(do_rtl_witchcraft(article.title), 500, 35).split("\n").each do |line|
- title.annotate ad, 500, 100, 50, 135 + (position * 55), line do
- self.fill = '#333333'
+ title.annotate ad, 500, 100, 50, 135 + (position * 55), line do |s|
+ s.fill = '#333333'
end
position += 1
end
else
wrap_text(do_rtl_witchcraft(article.title), 500, 55).split("\n").each do |line|
- title.annotate ad, 500, 100, 50, 160 + (position * 70), line do
- self.fill = '#333333'
+ title.annotate ad, 500, 100, 50, 160 + (position * 70), line do |s|
+ s.fill = '#333333'
end
position += 1
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 175469b04..ac3009673 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -34,6 +34,10 @@ def query_url(base_url = nil, **params)
uri.to_s
end
+ def sign_in_link(title)
+ link_to title, new_user_session_url
+ end
+
def license_link
link_to SiteSetting['ContentLicenseName'], SiteSetting['ContentLicenseLink']
end
@@ -91,6 +95,19 @@ def generic_share_link(post)
end
end
+ def generic_share_link_md(post)
+ "[#{post.title}](#{generic_share_link(post)})"
+ end
+
+ def post_history_share_link(post, history, index)
+ post_history_url(post, anchor: history.size - index)
+ end
+
+ def post_history_share_link_md(post, history, index)
+ rev_num = history.size - index
+ "[Revision #{rev_num} — #{post.title}](#{post_history_share_link(post, history, index)})"
+ end
+
def generic_edit_link(post)
edit_post_url(post)
end
@@ -177,4 +194,19 @@ def direct_request?
false
end
end
+
+ def current_commit
+ commit_info = Rails.cache.persistent('current_commit')
+ shasum, timestamp = commit_info
+
+ begin
+ date = DateTime.iso8601(timestamp)
+ rescue
+ date = DateTime.parse(timestamp)
+ end
+
+ [shasum, date]
+ rescue
+ [nil, nil]
+ end
end
diff --git a/app/helpers/categories_helper.rb b/app/helpers/categories_helper.rb
index 68c77d120..7efbc1617 100644
--- a/app/helpers/categories_helper.rb
+++ b/app/helpers/categories_helper.rb
@@ -23,4 +23,10 @@ def current_category
@article.category
end
end
+
+ def pending_suggestions?
+ Rails.cache.fetch "pending_suggestions/#{current_category.id}" do
+ SuggestedEdit.where(post: Post.undeleted.where(category: current_category), active: true).any?
+ end
+ end
end
diff --git a/app/helpers/comments_helper.rb b/app/helpers/comments_helper.rb
index 636d0a95d..548a269c9 100644
--- a/app/helpers/comments_helper.rb
+++ b/app/helpers/comments_helper.rb
@@ -81,8 +81,8 @@ def get_pingable(thread)
class CommentScrubber < Rails::Html::PermitScrubber
def initialize
super
- self.tags = %w[a b i em strong s strike del pre code p blockquote span sup sub br]
- self.attributes = %w[href title lang dir id class]
+ self.tags = %w[a b i em strong s strike del pre code p blockquote span sup sub br ul ol li]
+ self.attributes = %w[href title lang dir id class start]
end
def skip_node?(node)
diff --git a/app/helpers/edit_helper.rb b/app/helpers/edit_helper.rb
new file mode 100644
index 000000000..db4b05ad4
--- /dev/null
+++ b/app/helpers/edit_helper.rb
@@ -0,0 +1,5 @@
+module EditHelper
+ def max_edit_comment_length
+ [SiteSetting['MaxEditCommentLength'] || 255, 255].min
+ end
+end
diff --git a/app/helpers/email_logs_helper.rb b/app/helpers/email_logs_helper.rb
new file mode 100644
index 000000000..9b08eeb7a
--- /dev/null
+++ b/app/helpers/email_logs_helper.rb
@@ -0,0 +1,2 @@
+module EmailLogsHelper
+end
diff --git a/app/helpers/markdown_tools_helper.rb b/app/helpers/markdown_tools_helper.rb
index 0c4f44c75..9dbcaa85d 100644
--- a/app/helpers/markdown_tools_helper.rb
+++ b/app/helpers/markdown_tools_helper.rb
@@ -4,7 +4,8 @@ def md_button(name = nil, action: nil, label: nil, **attribs, &block)
class: "#{attribs[:class] || ''} button is-muted is-outlined js-markdown-tool",
data_action: action,
aria_label: label,
- title: label
+ title: label,
+ role: 'button'
attribs.transform_keys! { |k| k.to_s.tr('_', '-') }.symbolize_keys!
if name.nil? && block_given?
tag.a(**attribs, &block)
@@ -18,7 +19,8 @@ def md_list_item(name = nil, action: nil, label: nil, **attribs, &block)
class: "#{attribs[:class] || ''}js-markdown-tool",
data_action: action,
aria_label: label,
- title: label
+ title: label,
+ role: 'button'
attribs.transform_keys! { |k| k.to_s.tr('_', '-') }.symbolize_keys!
if name.nil? && block_given?
tag.a(**attribs, &block)
diff --git a/app/helpers/posts_helper.rb b/app/helpers/posts_helper.rb
index 2c9e19ab6..9fb810fd5 100644
--- a/app/helpers/posts_helper.rb
+++ b/app/helpers/posts_helper.rb
@@ -15,6 +15,35 @@ def cancel_redirect_path(post)
end
end
+ # @param category [Category, Nil]
+ # @return [Integer] the minimum length for post bodies
+ def min_body_length(category)
+ category&.min_body_length || 30
+ end
+
+ # @param _category [Category, Nil]
+ # @return [Integer] the maximum length for post bodies
+ def max_body_length(_category)
+ 30_000
+ end
+
+ # @param category [Category, Nil] post category, if any
+ # @param post_type [PostType] type of the post (system limits are relaxed)
+ # @return [Integer] the minimum length for post titles
+ def min_title_length(category, post_type)
+ if post_type.system?
+ 1
+ else
+ category&.min_title_length || 15
+ end
+ end
+
+ # @param _category [Category, Nil]
+ # @return [Integer] the maximum length for post titles
+ def max_title_length(_category)
+ [SiteSetting['MaxTitleLength'] || 255, 255].min
+ end
+
class PostScrubber < Rails::Html::PermitScrubber
def initialize
super
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index f5786839a..fe21b7d35 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -1,4 +1,104 @@
module SearchHelper
+ def check_posts_permissions
+ (current_user&.is_moderator || current_user&.is_admin ? Post : Post.undeleted)
+ .qa_only.list_includes
+ end
+
+ def search_posts
+ posts = check_posts_permissions
+
+ qualifiers = params_to_qualifiers
+ search_string = params[:search]
+
+ # Filter based on search string qualifiers
+ if search_string.present?
+ search_data = parse_search(search_string)
+ qualifiers += parse_qualifier_strings search_data[:qualifiers]
+ search_string = search_data[:search]
+ end
+
+ posts = qualifiers_to_sql(qualifiers, posts)
+ posts = posts.paginate(page: params[:page], per_page: 25)
+
+ posts = if search_string.present?
+ posts.search(search_data[:search]).user_sort({ term: params[:sort], default: :search_score },
+ relevance: :search_score, score: :score, age: :created_at)
+ else
+ posts.user_sort({ term: params[:sort], default: :score },
+ score: :score, age: :created_at)
+ end
+
+ [posts, qualifiers]
+ end
+
+ # Convert a Filter record into a form parseable by the search function
+ def filter_to_qualifiers(filter)
+ qualifiers = []
+ qualifiers.append({ param: :score, operator: '>=', value: filter.min_score }) unless filter.min_score.nil?
+ qualifiers.append({ param: :score, operator: '<=', value: filter.max_score }) unless filter.max_score.nil?
+ qualifiers.append({ param: :answers, operator: '>=', value: filter.min_answers }) unless filter.min_answers.nil?
+ qualifiers.append({ param: :answers, operator: '<=', value: filter.max_answers }) unless filter.max_answers.nil?
+ qualifiers.append({ param: :include_tags, tag_ids: filter.include_tags }) unless filter.include_tags.nil?
+ qualifiers.append({ param: :exclude_tags, tag_ids: filter.exclude_tags }) unless filter.exclude_tags.nil?
+ qualifiers.append({ param: :status, value: filter.status }) unless filter.status.nil?
+ qualifiers
+ end
+
+ def active_filter
+ {
+ default: nil,
+ name: params[:predefined_filter],
+ min_score: params[:min_score],
+ max_score: params[:max_score],
+ min_answers: params[:min_answers],
+ max_answers: params[:max_answers],
+ include_tags: params[:include_tags],
+ exclude_tags: params[:exclude_tags],
+ status: params[:status]
+ }
+ end
+
+ def params_to_qualifiers
+ valid_value = {
+ date: /^[\d.]+(?:s|m|h|d|w|mo|y)?$/,
+ status: /any|open|closed/,
+ numeric: /^[\d.]+$/,
+ integer: /^\d+$/
+ }
+
+ filter_qualifiers = []
+
+ if params[:min_score]&.match?(valid_value[:numeric])
+ filter_qualifiers.append({ param: :score, operator: '>=', value: params[:min_score].to_f })
+ end
+
+ if params[:max_score]&.match?(valid_value[:numeric])
+ filter_qualifiers.append({ param: :score, operator: '<=', value: params[:max_score].to_f })
+ end
+
+ if params[:min_answers]&.match?(valid_value[:numeric])
+ filter_qualifiers.append({ param: :answers, operator: '>=', value: params[:min_answers].to_i })
+ end
+
+ if params[:max_answers]&.match?(valid_value[:numeric])
+ filter_qualifiers.append({ param: :answers, operator: '<=', value: params[:max_answers].to_i })
+ end
+
+ if params[:status]&.match?(valid_value[:status])
+ filter_qualifiers.append({ param: :status, value: params[:status] })
+ end
+
+ if params[:include_tags]&.all? { |id| id.match? valid_value[:integer] }
+ filter_qualifiers.append({ param: :include_tags, tag_ids: params[:include_tags] })
+ end
+
+ if params[:exclude_tags]&.all? { |id| id.match? valid_value[:integer] }
+ filter_qualifiers.append({ param: :exclude_tags, tag_ids: params[:exclude_tags] })
+ end
+
+ filter_qualifiers
+ end
+
def parse_search(raw_search)
qualifiers_regex = /([\w\-_]+(?=]{0,2}[\d.]+(?:s|m|h|d|w|mo|y)?$/,
+ status: /any|open|closed/,
numeric: /^[<>=]{0,2}[\d.]+$/
}
- qualifiers.each do |qualifier| # rubocop:disable Metrics/BlockLength
+ qualifiers.map do |qualifier| # rubocop:disable Metrics/BlockLength
splat = qualifier.split ':'
parameter = splat[0]
value = splat[1]
@@ -27,59 +128,111 @@ def qualifiers_to_sql(qualifiers, query)
next unless value.match?(valid_value[:numeric])
operator, val = numeric_value_sql value
- query = query.where("score #{operator.presence || '='} ?", val.to_f)
+ { param: :score, operator: operator.presence || '=', value: val.to_f }
when 'created'
next unless value.match?(valid_value[:date])
operator, val, timeframe = date_value_sql value
- query = query.where("created_at #{operator.presence || '='} DATE_SUB(CURRENT_TIMESTAMP, " \
- "INTERVAL ? #{timeframe})",
- val.to_i)
+ { param: :created, operator: operator.presence || '=', timeframe: timeframe, value: val.to_i }
when 'user'
- next unless value.match?(valid_value[:numeric])
+ operator, val = if value.match?(valid_value[:numeric])
+ numeric_value_sql value
+ elsif value == 'me'
+ ['=', current_user&.id&.to_i]
+ else
+ next
+ end
- operator, val = numeric_value_sql value
- query = query.where("user_id #{operator.presence || '='} ?", val.to_i)
+ { param: :user, operator: operator.presence || '=', user_id: val }
when 'upvotes'
next unless value.match?(valid_value[:numeric])
operator, val = numeric_value_sql value
- query = query.where("upvotes #{operator.presence || '='} ?", val.to_i)
+ { param: :upvotes, operator: operator.presence || '=', value: val.to_i }
when 'downvotes'
next unless value.match?(valid_value[:numeric])
operator, val = numeric_value_sql value
- query = query.where("downvotes #{operator.presence || '='} ?", val.to_i)
+ { param: :downvotes, operator: operator.presence || '=', value: val.to_i }
when 'votes'
next unless value.match?(valid_value[:numeric])
operator, val = numeric_value_sql value
- query = query.where("(upvotes - downvotes) #{operator.presence || '='}", val.to_i)
+ { param: :net_votes, operator: operator.presence || '=', value: val.to_i }
when 'tag'
- query = query.where(posts: { id: PostsTag.where(tag_id: Tag.where(name: value).select(:id)).select(:post_id) })
+ { param: :include_tag, tag_id: Tag.where(name: value).select(:id) }
when '-tag'
- query = query.where.not(posts: { id: PostsTag.where(tag_id: Tag.where(name: value).select(:id))
- .select(:post_id) })
+ { param: :exclude_tag, tag_id: Tag.where(name: value).select(:id) }
when 'category'
next unless value.match?(valid_value[:numeric])
operator, val = numeric_value_sql value
- trust_level = current_user&.trust_level || 0
- allowed_categories = Category.where('IFNULL(min_view_trust_level, -1) <= ?', trust_level)
- query = query.where("category_id #{operator.presence || '='} ?", val.to_i)
- .where(category_id: allowed_categories)
+ { param: :category, operator: operator.presence || '=', category_id: val.to_i }
when 'post_type'
next unless value.match?(valid_value[:numeric])
operator, val = numeric_value_sql value
- query = query.where("post_type_id #{operator.presence || '='} ?", val.to_i)
+ { param: :post_type, operator: operator.presence || '=', post_type_id: val.to_i }
when 'answers'
next unless value.match?(valid_value[:numeric])
operator, val = numeric_value_sql value
+ { param: :answers, operator: operator.presence || '=', value: val.to_i }
+ when 'status'
+ next unless value.match?(valid_value[:status])
+
+ { param: :status, value: value }
+ end
+ end.compact
+ # Consider partitioning and telling the user which filters were invalid
+ end
+
+ def qualifiers_to_sql(qualifiers, query)
+ trust_level = current_user&.trust_level || 0
+ allowed_categories = Category.where('IFNULL(min_view_trust_level, -1) <= ?', trust_level)
+ query = query.where(category_id: allowed_categories)
+
+ qualifiers.each do |qualifier| # rubocop:disable Metrics/BlockLength
+ case qualifier[:param]
+ when :score
+ query = query.where("score #{qualifier[:operator]} ?", qualifier[:value])
+ when :created
+ query = query.where("created_at #{qualifier[:operator]} DATE_SUB(CURRENT_TIMESTAMP, " \
+ "INTERVAL ? #{qualifier[:timeframe]})",
+ qualifier[:value])
+ when :user
+ query = query.where("user_id #{qualifier[:operator]} ?", qualifier[:user_id])
+ when :upvotes
+ query = query.where("upvote_count #{qualifier[:operator]} ?", qualifier[:value])
+ when :downvotes
+ query = query.where("downvote_count #{qualifier[:operator]} ?", qualifier[:value])
+ when :net_votes
+ query = query.where("(upvote_count - downvote_count) #{qualifier[:operator]} ?", qualifier[:value])
+ when :include_tag
+ query = query.where(posts: { id: PostsTag.where(tag_id: qualifier[:tag_id]).select(:post_id) })
+ when :include_tags
+ qualifier[:tag_ids].each do |id|
+ query = query.where(id: PostsTag.where(tag_id: id).select(:post_id))
+ end
+ when :exclude_tag
+ query = query.where.not(posts: { id: PostsTag.where(tag_id: qualifier[:tag_id]).select(:post_id) })
+ when :exclude_tags
+ query = query.where.not(id: PostsTag.where(tag_id: qualifier[:tag_ids]).select(:post_id))
+ when :category
+ query = query.where("category_id #{qualifier[:operator]} ?", qualifier[:category_id])
+ when :post_type
+ query = query.where("post_type_id #{qualifier[:operator]} ?", qualifier[:post_type_id])
+ when :answers
post_types_with_answers = PostType.where(has_answers: true)
- query = query.where("answer_count #{operator.presence || '='} ?", val.to_i)
+ query = query.where("answer_count #{qualifier[:operator]} ?", qualifier[:value])
.where(post_type_id: post_types_with_answers)
+ when :status
+ case qualifier[:value]
+ when 'open'
+ query = query.where(closed: false)
+ when 'closed'
+ query = query.where(closed: true)
+ end
end
end
diff --git a/app/helpers/users/avatar_helper.rb b/app/helpers/users/avatar_helper.rb
index af86fc689..6a6686f27 100644
--- a/app/helpers/users/avatar_helper.rb
+++ b/app/helpers/users/avatar_helper.rb
@@ -35,8 +35,8 @@ def user_auto_avatar(size, user: nil, letter: nil, color: nil)
let.font = './app/assets/imgfonts/Roboto.ttf'
let.pointsize = size * 0.75
let.gravity = CenterGravity
- let.annotate ava, size, size * 1.16, 0, 0, letter.upcase do
- self.fill = text_color
+ let.annotate ava, size, size * 1.16, 0, 0, letter.upcase do |s|
+ s.fill = text_color
end
ava.format = 'PNG'
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index 2ebc8c27c..a689060d0 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -30,6 +30,21 @@ def preference_choice(pref_config)
end
end
+ def default_filter(user_id, category_id)
+ CategoryFilterDefault.find_by(user_id: user_id, category_id: category_id)&.filter
+ end
+
+ def set_filter_default(user_id, filter_id, category_id, keep)
+ if keep
+ CategoryFilterDefault.create_with(filter_id: filter_id)
+ .find_or_create_by(user_id: user_id, category_id: category_id)
+ .update(filter_id: filter_id)
+ else
+ CategoryFilterDefault.where(user_id: user_id, category_id: category_id)
+ .destroy_all
+ end
+ end
+
def user_preference(name, community: false)
return nil if current_user.nil?
@@ -44,11 +59,37 @@ def rtl_safe_username(user)
deleted_user?(user) ? 'deleted user' : user.rtl_safe_username
end
- def user_link(user, **link_opts)
- if deleted_user?(user)
- link_to 'deleted user', '#', { dir: 'ltr' }.merge(link_opts)
+ def user_link(user, url_opts = nil, **link_opts)
+ url_opts ||= {}
+ anchortext = link_opts[:anchortext]
+ link_opts_reduced = { dir: 'ltr' }.merge(link_opts).except(:anchortext)
+ if !anchortext.nil?
+ link_to anchortext, user_url(user, **url_opts), { dir: 'ltr' }.merge(link_opts)
+ elsif deleted_user?(user)
+ link_to 'deleted user', '#', link_opts_reduced
+ else
+ link_to user.rtl_safe_username, user_url(user, **url_opts), link_opts_reduced
+ end
+ end
+
+ def sso_sign_in_enabled?
+ SiteSetting['SsoSignIn']
+ end
+
+ def devise_sign_in_enabled?
+ SiteSetting['MixedSignIn'] || !sso_sign_in_enabled?
+ end
+
+ ##
+ # Returns a user corresponding to the ID provided, with the caveat that if +user_id+ is 'me' and there is a user
+ # signed in, the signed in user will be returned. Use for /users/me links.
+ # @param [String] user_id The user ID to find, from +params+
+ # @return [User] The User object
+ def user_with_me(user_id)
+ if user_id == 'me' && user_signed_in?
+ current_user
else
- link_to user.rtl_safe_username, user_url(user), { dir: 'ltr' }.merge(link_opts)
+ User.find(user_id)
end
end
end
diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb
new file mode 100644
index 000000000..d394c3d10
--- /dev/null
+++ b/app/jobs/application_job.rb
@@ -0,0 +1,7 @@
+class ApplicationJob < ActiveJob::Base
+ # Automatically retry jobs that encountered a deadlock
+ # retry_on ActiveRecord::Deadlocked
+
+ # Most jobs are safe to ignore if the underlying records are no longer available
+ # discard_on ActiveJob::DeserializationError
+end
diff --git a/app/jobs/clean_up_spammy_users_job.rb b/app/jobs/clean_up_spammy_users_job.rb
new file mode 100644
index 000000000..001afda5f
--- /dev/null
+++ b/app/jobs/clean_up_spammy_users_job.rb
@@ -0,0 +1,22 @@
+class CleanUpSpammyUsersJob < ApplicationJob
+ queue_as :default
+
+ def perform(created_after: 1.month.ago)
+ # Select potential spammers: users created within timeframe, who are not deleted, who have posted but all posts have
+ # since been deleted (no live posts).
+ possible_spammers = User.joins('inner join posts on users.id = posts.user_id')
+ .where('users.created_at >= ?', created_after)
+ .where(users: { deleted: false }).group('users.id').having('count(posts.id) > 0')
+ .having('count(distinct if(posts.deleted = true, null, posts.id)) = 0')
+ possible_spammers.each do |spammer|
+ all_posts_spam = spammer.posts.all? do |post|
+ # A post is considered spam if there are any helpful spam flags on it.
+ post.flags.any? { |flag| flag.post_flag_type.name == "it's spam" && flag.status == 'helpful' }
+ end
+ if all_posts_spam
+ spammer.block('automatic block from spam cleanup job', length: 2.years)
+ spammer.do_soft_delete(User.find(-1))
+ end
+ end
+ end
+end
diff --git a/app/jobs/cleanup_votes_job.rb b/app/jobs/cleanup_votes_job.rb
new file mode 100644
index 000000000..b54b26be7
--- /dev/null
+++ b/app/jobs/cleanup_votes_job.rb
@@ -0,0 +1,32 @@
+class CleanupVotesJob < ApplicationJob
+ queue_as :default
+
+ def perform
+ Community.all.each do |c|
+ RequestContext.community = c
+ orphan_votes = Vote.all.reject { |v| v.post.present? }
+
+ puts "[#{c.name}] destroying #{orphan_votes.length} #{'orphan vote'.pluralize(orphan_votes.length)}"
+
+ system_user = User.find(-1)
+
+ orphan_votes.each do |v|
+ result = v.destroy
+
+ if result
+ AuditLog.admin_audit(
+ comment: "Deleted orphaned vote for user ##{v.recv_user_id} " \
+ "on post ##{v.post_id} " \
+ "in community ##{c.id} (#{c.name})",
+ event_type: 'vote_delete',
+ related: v,
+ user: system_user
+ )
+ else
+ puts "[#{c.name}] failed to destroy vote \"#{v.id}\""
+ v.errors.each { |e| puts e.full_message }
+ end
+ end
+ end
+ end
+end
diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb
index a68f9c55c..f4f0d2606 100644
--- a/app/mailers/admin_mailer.rb
+++ b/app/mailers/admin_mailer.rb
@@ -1,5 +1,8 @@
class AdminMailer < ApplicationMailer
- default from: 'Codidact Admins '
+ default from: lambda {
+ "#{SiteSetting['ModeratorDistributionListSenderName']} " \
+ "<#{SiteSetting['ModeratorDistributionListSenderEmail']}>"
+ }
def to_moderators
@subject = params[:subject]
@@ -8,6 +11,19 @@ def to_moderators
"INNER JOIN community_users cu ON cu.user_id = u.id WHERE s.type = 'moderators' AND " \
'(u.is_global_admin = 1 OR u.is_global_moderator = 1 OR cu.is_admin = 1 OR cu.is_moderator = 1)'
emails = ActiveRecord::Base.connection.execute(query).to_a.flatten
- mail subject: "Codidact Moderators: #{@subject}", to: 'moderators-noreply@codidact.org', bcc: emails
+ from = "#{SiteSetting['ModeratorDistributionListSenderName']} " \
+ "<#{SiteSetting['ModeratorDistributionListSenderEmail']}>"
+ to = SiteSetting['ModeratorDistributionListSenderEmail']
+ mail subject: "Codidact Moderators: #{@subject}", to: to, from: from, bcc: emails
+ end
+
+ def to_all_users
+ @subject = params[:subject]
+ @body_markdown = params[:body_markdown]
+ @users = User.where('email NOT LIKE ?', '%localhost').select(:email).map(&:email)
+ to = SiteSetting['AllUsersSenderEmail']
+ from = "#{SiteSetting['AllUsersSenderName']} <#{SiteSetting['AllUsersSenderEmail']}>"
+ reply_to = SiteSetting['AllUsersReplyToEmail']
+ mail subject: @subject, to: to, from: from, reply_to: reply_to, bcc: @users
end
end
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
index 59f7ed59f..45f5f0f87 100644
--- a/app/mailers/application_mailer.rb
+++ b/app/mailers/application_mailer.rb
@@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base
- default from: 'Codidact '
+ default from: -> { "#{SiteSetting['NoReplySenderName']} <#{SiteSetting['NoReplySenderEmail']}>" }
layout 'mailer'
end
diff --git a/app/mailers/donation_mailer.rb b/app/mailers/donation_mailer.rb
index ee6b79854..587ae6009 100644
--- a/app/mailers/donation_mailer.rb
+++ b/app/mailers/donation_mailer.rb
@@ -4,9 +4,9 @@ def donation_successful
@amount = params[:amount]
@email = params[:email]
@name = params[:name]
- mail from: 'Codidact Donations ',
- reply_to: 'Codidact Support ',
- to: @email, subject: 'Thanks for your donation!'
+ from = "#{SiteSetting['DonationSenderName']} <#{SiteSetting['DonationSenderEmail']}>"
+ reply_to = "#{SiteSetting['DonationSupportReceiverName']} <#{SiteSetting['DonationSupportReceiverEmail']}>"
+ mail from: from, reply_to: reply_to, to: @email, subject: 'Thanks for your donation!'
end
def donation_uncaptured
@@ -15,8 +15,8 @@ def donation_uncaptured
@email = params[:email]
@name = params[:name]
@intent = params[:intent]
- mail from: 'Codidact Donations ',
- reply_to: 'Codidact Support ',
- to: @email, subject: 'Your donation is unfinished - was everything okay?'
+ from = "#{SiteSetting['DonationSenderName']} <#{SiteSetting['DonationSenderEmail']}>"
+ reply_to = "#{SiteSetting['DonationSupportReceiverName']} <#{SiteSetting['DonationSupportReceiverEmail']}>"
+ mail from: from, reply_to: reply_to, to: @email, subject: 'Your donation is unfinished - was everything okay?'
end
end
diff --git a/app/mailers/flag_mailer.rb b/app/mailers/flag_mailer.rb
index 9eb0f7168..b43d60310 100644
--- a/app/mailers/flag_mailer.rb
+++ b/app/mailers/flag_mailer.rb
@@ -7,7 +7,8 @@ def flag_escalated
.or(User.joins(:community_user)
.where(community_users: { is_admin: true, community_id: @flag.community_id }))
.select(:email).map(&:email)
- mail from: 'Codidact ', to: 'noreply@codidact.com', bcc: emails,
- subject: "New flag escalation on #{@flag.community.name}"
+ from = "#{SiteSetting['NoReplySenderName']} <#{SiteSetting['NoReplySenderEmail']}>"
+ to = SiteSetting['NoReplySenderEmail']
+ mail from: from, to: to, bcc: emails, subject: "New flag escalation on #{@flag.community.name}"
end
end
diff --git a/app/mailers/subscription_mailer.rb b/app/mailers/subscription_mailer.rb
index 2c6687890..6261e13bf 100644
--- a/app/mailers/subscription_mailer.rb
+++ b/app/mailers/subscription_mailer.rb
@@ -1,4 +1,6 @@
class SubscriptionMailer < ApplicationMailer
+ helper UsersHelper
+
def subscription
@subscription = params[:subscription]
@questions = @subscription.questions&.includes(:user) || []
@@ -9,14 +11,20 @@ def subscription
return
end
+ # Load request community to ensure we can access the settings/posts of the correct community
+ RequestContext.community = @subscription.community
+ name = @subscription.name
site_name = @subscription.community.name
- subject = if @subscription.name.present?
- "Latest questions from your '#{@subscription.name}' subscription on #{site_name}"
+ subject = if name.present?
+ "Latest questions from your '#{name}' subscription on #{site_name}"
else
"Latest questions from your subscription on #{site_name}"
end
@subscription.update(last_sent_at: DateTime.now)
- mail from: 'Codidact Subscriptions ', to: @subscription.user.email, subject: subject
+ from = "#{SiteSetting['SubscriptionSenderName']} <#{SiteSetting['SubscriptionSenderEmail']}>"
+ to = @subscription.user.email
+ mail from: from, to: to, subject: subject
+ Rails.logger.info "Sent subscription mail (sub ID ##{@subscription.id}, to: '#{to}', name: '#{name}'"
end
end
diff --git a/app/mailers/two_factor_mailer.rb b/app/mailers/two_factor_mailer.rb
index de656dd7c..2784ab9be 100644
--- a/app/mailers/two_factor_mailer.rb
+++ b/app/mailers/two_factor_mailer.rb
@@ -1,5 +1,5 @@
class TwoFactorMailer < ApplicationMailer
- default from: 'Codidact '
+ default from: -> { "#{SiteSetting['NoReplySenderName']} <#{SiteSetting['NoReplySenderEmail']}>" }
def disable_email
user = params[:user]
@@ -16,4 +16,10 @@ def login_email
user.update(login_token: @token, login_token_expires_at: 5.minutes.from_now)
mail to: user.email, subject: 'Your sign in link for Codidact'
end
+
+ def backup_code
+ @user = params[:user]
+ @host = params[:host]
+ mail to: @user.email, subject: 'Your 2FA backup code for Codidact'
+ end
end
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index 0e552701f..2f0d184b0 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -107,11 +107,11 @@ def user_sort(term_opts, **field_mappings)
end
end
-klasses = [::ActiveRecord::Relation]
-klasses << if defined? ::ActiveRecord::Associations::CollectionProxy
- ::ActiveRecord::Associations::CollectionProxy
+klasses = [ActiveRecord::Relation]
+klasses << if defined? ActiveRecord::Associations::CollectionProxy
+ ActiveRecord::Associations::CollectionProxy
else
- ::ActiveRecord::Associations::AssociationCollection
+ ActiveRecord::Associations::AssociationCollection
end
ActiveRecord::Base.extend UserSortable
diff --git a/app/models/category.rb b/app/models/category.rb
index 9cdaea765..f5951519c 100644
--- a/app/models/category.rb
+++ b/app/models/category.rb
@@ -9,6 +9,7 @@ class Category < ApplicationRecord
has_many :posts
belongs_to :tag_set
belongs_to :license
+ belongs_to :default_filter, class_name: 'Filter', optional: true
serialize :display_post_types, Array
diff --git a/app/models/category_filter_default.rb b/app/models/category_filter_default.rb
new file mode 100644
index 000000000..540f1cf78
--- /dev/null
+++ b/app/models/category_filter_default.rb
@@ -0,0 +1,5 @@
+class CategoryFilterDefault < ApplicationRecord
+ belongs_to :user
+ belongs_to :filter
+ belongs_to :category
+end
diff --git a/app/models/community_user.rb b/app/models/community_user.rb
index a62926dcc..f0a80e60f 100644
--- a/app/models/community_user.rb
+++ b/app/models/community_user.rb
@@ -14,6 +14,12 @@ class CommunityUser < ApplicationRecord
after_create :prevent_ulysses_case
+ delegate :url_helpers, to: 'Rails.application.routes'
+
+ def system?
+ user_id == -1
+ end
+
def suspended?
return true if is_suspended && !suspension_end.past?
@@ -24,6 +30,10 @@ def suspended?
false
end
+ def latest_warning
+ mod_warnings&.order(created_at: 'desc')&.first&.created_at
+ end
+
# Calculation functions for privilege scores
# These are quite expensive, so we'll cache them for a while
def post_score
@@ -54,8 +64,6 @@ def flag_score
end
end
- ## Privilege functions
-
def privilege?(internal_id, ignore_suspension: false, ignore_mod: false)
if internal_id != 'mod' && !ignore_mod && user.is_moderator
return true # includes: privilege? 'mod'
@@ -73,11 +81,26 @@ def privilege(internal_id)
UserAbility.joins(:ability).where(community_user_id: id, abilities: { internal_id: internal_id }).first
end
- def grant_privilege(internal_id)
+ ##
+ # Grant a specified ability to this CommunityUser.
+ # @param internal_id [String] The +internal_id+ of the ability to grant.
+ # @param notify [Boolean] Whether to send a notification to the user.
+ def grant_privilege!(internal_id, notify: true)
priv = Ability.where(internal_id: internal_id).first
UserAbility.create community_user_id: id, ability: priv
+ if notify
+ community_host = priv.community.host
+ user.create_notification("You've earned the #{priv.name} ability! Learn more.",
+ url_helpers.ability_url(priv.internal_id, host: community_host))
+ end
end
+ ##
+ # Recalculate a specified ability for this CommunityUser. Will not revoke abilities that have already been granted.
+ # @param internal_id [String] The +internal_id+ of the ability to be recalculated.
+ # @param sandbox [Boolean] Whether to run in sandbox mode - if sandboxed, the ability will not be granted but the
+ # return value indicates whether it would have been.
+ # @return [Boolean] Whether or not the ability was granted.
def recalc_privilege(internal_id, sandbox: false)
# Do not recalculate privileges already granted
return true if privilege?(internal_id, ignore_suspension: true, ignore_mod: false)
@@ -96,17 +119,27 @@ def recalc_privilege(internal_id, sandbox: false)
end
# If not sandbox mode, create new privilege entry
- grant_privilege(internal_id) unless sandbox
+ grant_privilege!(internal_id) unless sandbox
recalc_trust_level unless sandbox
true
end
+ ##
+ # Recalculate a list of standard abilities for this CommunityUser.
+ # @param sandbox [Boolean] Whether to run in sandbox mode - see {#recalc_privilege}.
+ # @return [Array]
def recalc_privileges(sandbox: false)
[:everyone, :unrestricted, :edit_posts, :edit_tags, :flag_close, :flag_curate].map do |ability|
recalc_privilege(ability, sandbox: sandbox)
end
end
+ alias ability? privilege?
+ alias ability privilege
+ alias grant_ability! grant_privilege!
+ alias recalc_ability recalc_privilege
+ alias recalc_abilities recalc_privileges
+
# This check makes sure that every user gets the
# 'everyone' permission upon creation. We do not want
# to create a no permissions user by accident.
diff --git a/app/models/concerns/edits_validations.rb b/app/models/concerns/edits_validations.rb
new file mode 100644
index 000000000..11b4b98fd
--- /dev/null
+++ b/app/models/concerns/edits_validations.rb
@@ -0,0 +1,21 @@
+# Common validations for unilateral & suggested edits
+module EditsValidations
+ extend ActiveSupport::Concern
+
+ included do
+ validate :max_edit_comment_length
+ end
+
+ def max_edit_comment_length
+ if comment.nil?
+ return
+ end
+
+ max_edit_comment_length = SiteSetting['MaxEditCommentLength']
+ max_length = [(max_edit_comment_length || 255), 255].min
+ if comment.length > max_length
+ msg = I18n.t('edits.max_edit_comment_length', { count: max_length }).gsub(':length', max_length.to_s)
+ errors.add(:base, msg)
+ end
+ end
+end
diff --git a/app/models/concerns/post_validations.rb b/app/models/concerns/post_validations.rb
new file mode 100644
index 000000000..a0986b345
--- /dev/null
+++ b/app/models/concerns/post_validations.rb
@@ -0,0 +1,84 @@
+# Validations for posts which are shared between posts and suggested edits.
+module PostValidations
+ extend ActiveSupport::Concern
+
+ included do
+ validate :tags_in_tag_set, if: -> { post_type.has_tags }
+ validate :maximum_tags, if: -> { post_type.has_tags }
+ validate :maximum_tag_length, if: -> { post_type.has_tags }
+ validate :no_spaces_in_tags, if: -> { post_type.has_tags }
+ validate :stripped_minimum_body, if: -> { !body_markdown.nil? }
+ validate :stripped_minimum_title, if: -> { !title.nil? }
+ validate :maximum_title_length, if: -> { !title.nil? }
+ validate :required_tags?, if: -> { post_type.has_tags && post_type.has_category }
+ end
+
+ def maximum_tags
+ if tags_cache.length > 5
+ errors.add(:base, "Post can't have more than 5 tags.")
+ elsif tags_cache.empty?
+ errors.add(:base, 'Post must have at least one tag.')
+ end
+ end
+
+ def maximum_tag_length
+ tags_cache.each do |tag|
+ max_len = SiteSetting['MaxTagLength']
+ if tag.length > max_len
+ errors.add(:tags, "can't be more than #{max_len} characters long each")
+ end
+ end
+ end
+
+ def no_spaces_in_tags
+ tags_cache.each do |tag|
+ if tag.include?(' ') || tag.include?('_')
+ errors.add(:tags, 'may not include spaces or underscores - use hyphens for multiple-word tags')
+ end
+ end
+ end
+
+ def stripped_minimum_body
+ min_body = category.nil? ? 30 : category.min_body_length
+ if (body_markdown&.gsub(/(?:^[\s\t\u2000-\u200F]+|[\s\t\u2000-\u200F]+$)/, '')&.length || 0) < min_body
+ errors.add(:body, "must be more than #{min_body} non-whitespace characters long")
+ end
+ end
+
+ def stripped_minimum_title
+ min_title = if ['HelpDoc', 'PolicyDoc'].include?(post_type.name)
+ 1
+ elsif category.nil?
+ 15
+ else
+ category.min_title_length
+ end
+
+ if (title&.gsub(/(?:^[\s\t\u2000-\u200F]+|[\s\t\u2000-\u200F]+$)/, '')&.length || 0) < min_title
+ errors.add(:title, "must be more than #{min_title} non-whitespace characters long")
+ end
+ end
+
+ def maximum_title_length
+ max_title_len = SiteSetting['MaxTitleLength']
+ if title.length > [(max_title_len || 255), 255].min
+ errors.add(:title, "can't be more than #{max_title_len} characters")
+ end
+ end
+
+ def tags_in_tag_set
+ tag_set = category.tag_set
+ unless tags.all? { |t| t.tag_set_id == tag_set.id }
+ errors.add(:base, "Not all of this question's tags are in the correct tag set.")
+ end
+ end
+
+ def required_tags?
+ required = category&.required_tag_ids
+ return unless required.present? && !required.empty?
+
+ unless tag_ids.any? { |t| required.include? t }
+ errors.add(:tags, "must contain at least one required tag (#{category.required_tags.pluck(:name).join(', ')})")
+ end
+ end
+end
diff --git a/app/models/concerns/saml_init.rb b/app/models/concerns/saml_init.rb
new file mode 100644
index 000000000..3709b87ec
--- /dev/null
+++ b/app/models/concerns/saml_init.rb
@@ -0,0 +1,128 @@
+# Module for saml based initalization.
+#
+# The saml_init_email method is used to initialize the email address after a successful SSO sign in.
+# The saml_init_identifier method is used to
+module SamlInit
+ extend ActiveSupport::Concern
+
+ included do
+ has_one :sso_profile, required: false, autosave: true, dependent: :destroy
+
+ before_validation :prepare_from_saml, if: -> { saml_identifier.present? }
+ end
+
+ # -----------------------------------------------------------------------------------------------
+ # Identifier
+ # -----------------------------------------------------------------------------------------------
+
+ # @return [String, Nil] the saml_identifier of this user, or nil if the user is not from SSO
+ def saml_identifier
+ sso_profile&.saml_identifier
+ end
+
+ # @param saml_identifier [String, Nil] sets (or clears) the saml_identifier of this user
+ def saml_identifier=(saml_identifier)
+ if saml_identifier.nil?
+ sso_profile&.destroy
+ else
+ build_sso_profile if sso_profile.nil?
+ sso_profile.saml_identifier = saml_identifier
+ end
+ end
+
+ # This method is added as a fallback to support the Single Logout Service.
+ #
+ # @return [String, Nil] the saml_identifier of this user, or nil if the user is not from SSO
+ # @see #saml_identifier
+ def saml_init_identifier
+ saml_identifier
+ end
+
+ # Sets the saml_identifier to the given saml_identifier upon initialization. In contrast to
+ # #saml_identifier=, this method does not delete the SSO profile in case the saml_identifier is
+ # not present (safety in case of SSO issues).
+ #
+ # @param saml_identifier [String, Nil] the saml_identifier
+ # @return [String, Nil] the saml_identifier of this user, should never be nil
+ def saml_init_identifier=(saml_identifier)
+ build_sso_profile if sso_profile.nil?
+
+ # Only update if non-empty
+ sso_profile.saml_identifier = saml_identifier if saml_identifier.present?
+ end
+
+ # -----------------------------------------------------------------------------------------------
+ # Email
+ # -----------------------------------------------------------------------------------------------
+
+ # This method is added as a fallback to support the Single Logout Service.
+ # @return [String, Nil] the email address of this user, or nil if the user is not from SSO
+ def saml_init_email
+ return nil if sso_profile.nil?
+
+ email
+ end
+
+ # Initializes email address, and prevents (re)confirmation in case it is changed.
+ #
+ # @param email [String] the email address
+ def saml_init_email=(email)
+ self.email = email
+ skip_confirmation!
+ skip_reconfirmation!
+ end
+
+ # -----------------------------------------------------------------------------------------------
+ # Email is identifier
+ # -----------------------------------------------------------------------------------------------
+
+ # Used in the case that email is the unique identifier from saml.
+ # @return [String, Nil] the email address of the user, or nil in the case the user is not from SSO
+ def saml_init_email_and_identifier
+ return nil if sso_profile.nil?
+
+ email
+ end
+
+ # Used in the case that email is the unique identifier from saml.
+ #
+ # @param email [String] the email address (and saml identifier)
+ def saml_init_email_and_identifier=(email)
+ self.saml_init_email = email
+ self.saml_init_identifier = email
+ end
+
+ # -----------------------------------------------------------------------------------------------
+ # Username
+ # -----------------------------------------------------------------------------------------------
+
+ # This method is added as fallback to support the Single Logout Service.
+ # @return [String] the username
+ def saml_init_username_no_update
+ username
+ end
+
+ # Sets the username from SAML in case it was not already set.
+ # This prevents overriding the user set username with the one from SAML all the time, while
+ # allowing for email updates to be applied.
+ #
+ # @param username [String] the username to set
+ def saml_init_username_no_update=(username)
+ self.username = username unless self.username.present?
+ end
+
+ # -----------------------------------------------------------------------------------------------
+ # Creation
+ # -----------------------------------------------------------------------------------------------
+
+ protected
+
+ # Prepare a (potentially) new user from saml for creation. If the user is actually new, a random
+ # password is created for them and email confirmation is skipped.
+ def prepare_from_saml
+ return unless new_record?
+
+ self.password = SecureRandom.hex
+ skip_confirmation!
+ end
+end
diff --git a/app/models/email_log.rb b/app/models/email_log.rb
new file mode 100644
index 000000000..9b171ddbc
--- /dev/null
+++ b/app/models/email_log.rb
@@ -0,0 +1,2 @@
+class EmailLog < ApplicationRecord
+end
diff --git a/app/models/filter.rb b/app/models/filter.rb
new file mode 100644
index 000000000..fb11e1d47
--- /dev/null
+++ b/app/models/filter.rb
@@ -0,0 +1,7 @@
+class Filter < ApplicationRecord
+ belongs_to :user
+ has_many :category_filter_defaults, dependent: :destroy
+ validates :name, uniqueness: { scope: :user }
+ serialize :include_tags, Array
+ serialize :exclude_tags, Array
+end
diff --git a/app/models/post.rb b/app/models/post.rb
index 458b91df7..4a1d1ba99 100644
--- a/app/models/post.rb
+++ b/app/models/post.rb
@@ -1,5 +1,6 @@
class Post < ApplicationRecord
include CommunityRelated
+ include PostValidations
belongs_to :user, optional: true
belongs_to :post_type
@@ -24,22 +25,20 @@ class Post < ApplicationRecord
has_many :reactions
counter_culture :parent, column_name: proc { |model| model.deleted? ? nil : 'answer_count' }
+ counter_culture [:user, :community_user], column_name: proc { |model| model.deleted? ? nil : 'post_count' }
serialize :tags_cache, Array
- validates :body, presence: true, length: { minimum: 30, maximum: 30_000 }
+ validates :body, presence: true, length: { maximum: 30_000 }
validates :doc_slug, uniqueness: { scope: [:community_id], case_sensitive: false }, if: -> { doc_slug.present? }
- validates :title, :body, :tags_cache, presence: true, if: -> { post_type.has_tags }
- validate :tags_in_tag_set, if: -> { post_type.has_tags }
- validate :maximum_tags, if: -> { post_type.has_tags }
- validate :maximum_tag_length, if: -> { post_type.has_tags }
- validate :no_spaces_in_tags, if: -> { post_type.has_tags }
- validate :stripped_minimum, if: -> { post_type.has_tags }
- validate :maximum_title_length, if: -> { post_type.has_tags }
+ validates :title, presence: true, if: -> { post_type.is_top_level? }
+ validates :tags_cache, presence: true, if: -> { post_type.has_tags }
+
validate :category_allows_post_type, if: -> { category_id.present? }
validate :license_valid, if: -> { post_type.has_license }
- validate :required_tags?, if: -> { post_type.has_tags && post_type.has_category }
- validate :moderator_tags, if: -> { post_type.has_tags && post_type.has_category }
+ validate :moderator_tags, if: -> { post_type.has_tags && post_type.has_category && tags_cache_changed? }
+
+ # Other validations (shared with suggested edits) are in concerns/PostValidations
scope :undeleted, -> { where(deleted: false) }
scope :deleted, -> { where(deleted: true) }
@@ -55,13 +54,32 @@ class Post < ApplicationRecord
after_save :modify_author_reputation
after_save :copy_last_activity_to_parent
after_save :break_description_cache
- after_save :update_category_activity, if: -> { post_type.has_category }
+ after_save :update_category_activity, if: -> { post_type.has_category && !destroyed? }
after_save :recalc_score
+ # @param term [String] the search term
+ # @return [ActiveRecord::Relation]
def self.search(term)
match_search term, posts: :body_markdown
end
+ def self.by_slug(slug, user)
+ post = Post.unscoped.where(
+ doc_slug: slug,
+ community_id: [RequestContext.community_id, nil]
+ ).first
+
+ if post&.help_category == '$Disabled'
+ return nil
+ end
+
+ if post&.help_category == '$Moderator' && !user&.is_moderator
+ return nil
+ end
+
+ post
+ end
+
# Double-define: initial definitions are less efficient, so if we have a record of the post type we'll
# override them later with more efficient methods.
['Question', 'Answer', 'PolicyDoc', 'HelpDoc', 'Article'].each do |pt|
@@ -76,14 +94,18 @@ def self.search(term)
end
end
+ # @return [TagSet]
def tag_set
parent.nil? ? category.tag_set : parent.category.tag_set
end
+ # @return [Boolean]
def meta?
false
end
+ # Used in the transfer of content from SE to reassign the owner of a post to the given user.
+ # @param new_user [User]
def reassign_user(new_user)
new_user.ensure_community_user!
@@ -94,10 +116,13 @@ def reassign_user(new_user)
update!(deleted: false, deleted_at: nil, deleted_by: nil)
end
+ # Removes the attribution notice from this post
+ # @return [Boolean] whether the action was successful
def remove_attribution_notice!
update(att_source: nil, att_license_link: nil, att_license_name: nil)
end
+ # @return [String] the type of the last activity on this post
def last_activity_type
case last_activity
when closed_at
@@ -121,37 +146,51 @@ def last_activity_type
end
end
+ # @return [String] the body with all markdown stripped
def body_plain
ApplicationController.helpers.strip_markdown(body_markdown)
end
+ # @return [Boolean] whether this post is a question
def question?
post_type_id == Question.post_type_id
end
+ # @return [Boolean] whether this post is an answer
def answer?
post_type_id == Answer.post_type_id
end
+ # @return [Boolean] whether this post is an article
def article?
post_type_id == Article.post_type_id
end
+ # @return [Boolean] whether there is a suggested edit pending for this post
def pending_suggested_edit?
SuggestedEdit.where(post_id: id, active: true).any?
end
+ # @return [SuggestedEdit, Nil] the suggested edit pending for this post (if any)
def pending_suggested_edit
SuggestedEdit.where(post_id: id, active: true).last
end
+ # Recalculates the score of this post based on its up and downvotes
def recalc_score
variable = SiteSetting['ScoringVariable'] || 2
sql = 'UPDATE posts SET score = (upvote_count + ?) / (upvote_count + downvote_count + (2 * ?)) WHERE id = ?'
sanitized = ActiveRecord::Base.sanitize_sql_array([sql, variable, variable, id])
ActiveRecord::Base.connection.execute sanitized
+
+ # ensures the updated score is immediately available
+ self.score = (upvote_count + variable).to_f / (upvote_count + downvote_count + (2 * variable))
+ # prevents AR from accidentally saving the dirty state
+ clear_attribute_changes([:score])
end
+ # This method will update the locked status of this post if locked_until is in the past.
+ # @return [Boolean] whether this post is locked
def locked?
return true if locked && locked_until.nil? # permanent lock
return true if locked && !locked_until.past?
@@ -159,14 +198,27 @@ def locked?
if locked
update(locked: false, locked_by: nil, locked_at: nil, locked_until: nil)
end
+
+ false
+ end
+
+ # The test here is for flags that are pending (no status). A spam flag
+ # could be marked helpful but the post wouldn't be deleted, and
+ # we don't necessarily want the post to be treated like it's a spam risk
+ # if that happens.
+ def spam_flag_pending?
+ flags.any? { |flag| flag.post_flag_type&.name == "it's spam" && !flag.status }
end
+ # @param user [User, Nil]
+ # @return [Boolean] whether the given user can view this post
def can_access?(user)
(!deleted? || user&.has_post_privilege?('flag_curate', self)) &&
(!category.present? || !category.min_view_trust_level.present? ||
category.min_view_trust_level <= (user&.trust_level || 0))
end
+ # @return [Hash] a hash with as key the reaction type and value the amount of reactions for that type
def reaction_list
reactions.includes(:reaction_type).group_by(&:reaction_type_id)
.to_h { |_k, v| [v.first.reaction_type, v] }
@@ -174,12 +226,21 @@ def reaction_list
private
+ ##
+ # Before-validation callback. Update the tags association from the tags_cache.
def update_tag_associations
tags_cache.each do |tag_name|
- tag = Tag.find_or_create_by name: tag_name, tag_set: category.tag_set
+ tag, name_used = Tag.find_or_create_synonymized name: tag_name, tag_set: category.tag_set
unless tags.include? tag
tags << tag
end
+
+ # If the tags_cache doesn't include name_used then tag_name was a synonym - remove the synonym from tags_cache
+ # and add the primary for it instead.
+ unless tags_cache.include? name_used
+ tags_cache.delete tag_name
+ tags_cache << name_used
+ end
end
tags.each do |tag|
unless tags_cache.include? tag.name
@@ -188,11 +249,20 @@ def update_tag_associations
end
end
+ ##
+ # Helper method for #check_attribution_notice validator. Produces a text-only attribution notice either based on
+ # values given or the current state of the post for use in post histories.
+ # @param source [String, Nil] where the post originally came from
+ # @param name [String, Nil] the name of the license
+ # @param url [String, Nil] the url of the license
+ # @return [String] an attribution notice corresponding to this post
def attribution_text(source = nil, name = nil, url = nil)
"Source: #{source || att_source}\nLicense name: #{name || att_license_name}\n" \
"License URL: #{url || att_license_link}"
end
+ # Intended to be called as callback after a save.
+ # If changes were made to the licensing of this post, this will insert the correct history items.
def check_attribution_notice
sc = saved_changes
attributes = ['att_source', 'att_license_name', 'att_license_link']
@@ -210,6 +280,8 @@ def check_attribution_notice
end
end
+ # Intended to be called as callback after a save.
+ # If the last activity of this post was changed and it has a parent, also updates the parent activity
def copy_last_activity_to_parent
sc = saved_changes
if parent.present? && (sc.include?('last_activity') || sc.include?('last_activity_by_id')) \
@@ -218,22 +290,28 @@ def copy_last_activity_to_parent
end
end
+ # Intended to be called as callback after a save.
+ # If this deletion status of this post was changed, then remove or re-add the reputation.
def modify_author_reputation
sc = saved_changes
if sc.include?('deleted') && sc['deleted'][0] != sc['deleted'][1] && created_at >= 60.days.ago
deleted = !!saved_changes['deleted']&.last
if deleted
- user.update(reputation: user.reputation - Vote.total_rep_change(votes))
+ user&.update(reputation: (user&.reputation || 1) - Vote.total_rep_change(votes))
else
- user.update(reputation: user.reputation + Vote.total_rep_change(votes))
+ user&.update(reputation: (user&.reputation || 1) + Vote.total_rep_change(votes))
end
end
end
+ # Intended to be called as callback after a save.
+ # @return [PostHistory] creates an initial revision for this post
def create_initial_revision
PostHistory.initial_revision(self, user, after: body_markdown, after_title: title, after_tags: tags)
end
+ # Intended to be used as validation.
+ # Will add an error if this post's post type is not allowed in the associated category.
def category_allows_post_type
return if category.nil?
@@ -242,6 +320,8 @@ def category_allows_post_type
end
end
+ # Intended to be called as callback after a save.
+ # Deletes this posts description from the cache such that it will be regenerated next time it is needed.
def break_description_cache
Rails.cache.delete "posts/#{id}/description"
if parent_id.present?
@@ -249,6 +329,8 @@ def break_description_cache
end
end
+ # Intended to be used as a validation.
+ # Checks whether the associated license is present and enabled.
def license_valid
# Don't validate license on edits
return unless id.nil?
@@ -263,63 +345,9 @@ def license_valid
end
end
- def maximum_tags
- if tags_cache.length > 5
- errors.add(:base, "Post can't have more than 5 tags.")
- elsif tags_cache.empty?
- errors.add(:base, 'Post must have at least one tag.')
- end
- end
-
- def maximum_tag_length
- tags_cache.each do |tag|
- max_len = SiteSetting['MaxTagLength']
- if tag.length > max_len
- errors.add(:tags, "can't be more than #{max_len} characters long each")
- end
- end
- end
-
- def no_spaces_in_tags
- tags_cache.each do |tag|
- if tag.include?(' ') || tag.include?('_')
- errors.add(:tags, 'may not include spaces or underscores - use hyphens for multiple-word tags')
- end
- end
- end
-
- def stripped_minimum
- if (body&.gsub(/(?:^[\s\t\u2000-\u200F]+|[\s\t\u2000-\u200F]+$)/, '')&.length || 0) < 30
- errors.add(:body, 'must be more than 30 non-whitespace characters long')
- end
- if (title&.gsub(/(?:^[\s\t\u2000-\u200F]+|[\s\t\u2000-\u200F]+$)/, '')&.length || 0) < 15
- errors.add(:title, 'must be more than 15 non-whitespace characters long')
- end
- end
-
- def maximum_title_length
- max_title_len = SiteSetting['MaxTitleLength']
- if title.length > [(max_title_len || 255), 255].min
- errors.add(:title, "can't be more than #{max_title_len} characters")
- end
- end
-
- def tags_in_tag_set
- tag_set = category.tag_set
- unless tags.all? { |t| t.tag_set_id == tag_set.id }
- errors.add(:base, "Not all of this question's tags are in the correct tag set.")
- end
- end
-
- def required_tags?
- required = category&.required_tag_ids
- return unless required.present? && !required.empty?
-
- unless tag_ids.any? { |t| required.include? t }
- errors.add(:tags, "must contain at least one required tag (#{category.required_tags.pluck(:name).join(', ')})")
- end
- end
-
+ # Intended to be used as validation.
+ # Checks whether there are any moderator tags present added, and if so whether the current user is allowed to add
+ # those.
def moderator_tags
mod_tags = category&.moderator_tags&.map(&:name)
return unless mod_tags.present? && !mod_tags.empty?
@@ -333,6 +361,8 @@ def moderator_tags
end
end
+ # Intended to be used as callback after a save.
+ # Updates the category activity indicator if the last activity of this post changed.
def update_category_activity
if saved_changes.include? 'last_activity'
category.update_activity(last_activity)
diff --git a/app/models/post_history.rb b/app/models/post_history.rb
index 2a742a99f..990518da2 100644
--- a/app/models/post_history.rb
+++ b/app/models/post_history.rb
@@ -1,5 +1,7 @@
class PostHistory < ApplicationRecord
include PostRelated
+ include EditsValidations
+
belongs_to :post_history_type
belongs_to :user
has_many :post_history_tags
@@ -13,13 +15,30 @@ def after_tags
tags.where(post_history_tags: { relationship: 'after' })
end
+ # @param user [User]
+ # @return [Boolean] whether the given user is allowed to see the details of this history item
+ def allowed_to_see_details?(user)
+ !hidden || user&.is_admin || user_id == user&.id || post.user_id == user&.id
+ end
+
+ # Hides all previous history
+ # @param post [Post]
+ # @param user [User]
+ def self.redact(post, user)
+ where(post: post).update_all(hidden: true)
+ history_hidden(post, user, after: post.body_markdown,
+ after_title: post.title,
+ after_tags: post.tags,
+ comment: 'Detailed history before this event is hidden because of a redaction.')
+ end
+
def self.method_missing(name, *args, **opts)
unless args.length >= 2
raise NoMethodError
end
object, user = args
- fields = [:before, :after, :comment, :before_title, :after_title, :before_tags, :after_tags]
+ fields = [:before, :after, :comment, :before_title, :after_title, :before_tags, :after_tags, :hidden]
values = fields.to_h { |f| [f, nil] }.merge(opts)
history_type_name = name.to_s
@@ -31,7 +50,7 @@ def self.method_missing(name, *args, **opts)
params = { post_history_type: history_type, user: user, post: object, community_id: object.community_id }
{ before: :before_state, after: :after_state, comment: :comment, before_title: :before_title,
- after_title: :after_title }.each do |arg, attr|
+ after_title: :after_title, hidden: :hidden }.each do |arg, attr|
next if values[arg].nil?
params = params.merge(attr => values[arg])
@@ -47,7 +66,10 @@ def self.method_missing(name, *args, **opts)
end
end.values.compact.flatten
- history.post_history_tags = PostHistoryTag.create(post_history_tags)
+ # do not create post history tags if post history validations failed
+ unless history.errors.any?
+ history.post_history_tags = PostHistoryTag.create(post_history_tags)
+ end
history
end
diff --git a/app/models/post_type.rb b/app/models/post_type.rb
index 7f3e7570b..2cf37fdfd 100644
--- a/app/models/post_type.rb
+++ b/app/models/post_type.rb
@@ -19,6 +19,11 @@ def reactions
end
end
+ # @return [Boolean] whether the post type is a system type
+ def system?
+ ['HelpDoc', 'PolicyDoc'].include?(name)
+ end
+
def self.mapping
Rails.cache.fetch 'network/post_types/post_type_ids', include_community: false do
PostType.all.to_h { |pt| [pt.name, pt.id] }
diff --git a/app/models/site_setting.rb b/app/models/site_setting.rb
index ab5c9504b..160bbd05b 100644
--- a/app/models/site_setting.rb
+++ b/app/models/site_setting.rb
@@ -11,14 +11,14 @@ class SiteSetting < ApplicationRecord
def self.[](name)
key = "SiteSettings/#{RequestContext.community_id}/#{name}"
- cached = Rails.cache.fetch key do
+ cached = Rails.cache.fetch key, include_community: false do
SiteSetting.applied_setting(name)&.typed
end
if cached.nil?
- Rails.cache.delete key
+ Rails.cache.delete key, include_community: false
value = SiteSetting.applied_setting(name)&.typed
- Rails.cache.write key, value
+ Rails.cache.write key, value, include_community: false
value
else
cached
@@ -26,10 +26,16 @@ def self.[](name)
end
def self.exist?(name)
- Rails.cache.exist?("SiteSettings/#{RequestContext.community_id}/#{name}") ||
+ Rails.cache.exist?("SiteSettings/#{RequestContext.community_id}/#{name}", include_community: false) ||
SiteSetting.where(name: name).count.positive?
end
+ # Checks whether the setting is a global site setting
+ # @return [Boolean]
+ def global?
+ community_id.nil?
+ end
+
def typed
SettingConverter.new(value).send("as_#{value_type.downcase}")
end
@@ -45,14 +51,14 @@ def self.applied_setting(name)
def self.all_communities(name)
communities = Community.all
keys = (communities.map { |c| [c.id, "SiteSetting/#{c.id}/#{name}"] } + [[nil, "SiteSetting//#{name}"]]).to_h
- cached = Rails.cache.read_multi(*keys.values)
+ cached = Rails.cache.read_multi(*keys.values, include_community: false)
missing = keys.reject { |_k, v| cached.include?(v) }.map { |k, _v| k }
settings = if missing.empty?
{}
else
SiteSetting.where(name: name, community_id: missing).to_h { |s| [s.community_id, s] }
end
- Rails.cache.write_multi(missing.to_h { |cid| [keys[cid], settings[cid]&.typed] })
+ Rails.cache.write_multi(missing.to_h { |cid| [keys[cid], settings[cid]&.typed] }, include_community: false)
communities.to_h do |c|
[
c.id,
diff --git a/app/models/sso_profile.rb b/app/models/sso_profile.rb
new file mode 100644
index 000000000..0559a0f0f
--- /dev/null
+++ b/app/models/sso_profile.rb
@@ -0,0 +1,5 @@
+class SsoProfile < ApplicationRecord
+ belongs_to :user, inverse_of: :sso_profile
+
+ validates :saml_identifier, uniqueness: true, presence: true
+end
diff --git a/app/models/suggested_edit.rb b/app/models/suggested_edit.rb
index 7730bbc61..0afbe4d90 100644
--- a/app/models/suggested_edit.rb
+++ b/app/models/suggested_edit.rb
@@ -1,5 +1,7 @@
class SuggestedEdit < ApplicationRecord
include PostRelated
+ include PostValidations
+ include EditsValidations
belongs_to :user
@@ -10,6 +12,15 @@ class SuggestedEdit < ApplicationRecord
has_and_belongs_to_many :tags
has_and_belongs_to_many :before_tags, class_name: 'Tag', join_table: 'suggested_edits_before_tags'
+ has_one :post_type, through: :post
+ has_one :category, through: :post
+
+ after_save :clear_pending_cache, if: proc { saved_change_to_attribute?(:active) }
+
+ def clear_pending_cache
+ Rails.cache.delete "pending_suggestions/#{post.category_id}"
+ end
+
def pending?
active
end
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 1b78a95fe..873a0ea95 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -5,6 +5,8 @@ class Tag < ApplicationRecord
has_many :children, class_name: 'Tag', foreign_key: :parent_id
has_many :children_with_paths, class_name: 'TagWithPath', foreign_key: :parent_id
has_many :post_history_tags
+ has_many :tag_synonyms, dependent: :destroy
+ accepts_nested_attributes_for :tag_synonyms, allow_destroy: true
belongs_to :tag_set
belongs_to :parent, class_name: 'Tag', optional: true
@@ -13,12 +15,57 @@ class Tag < ApplicationRecord
validates :name, presence: true, format: { with: /[^ \t]+/, message: 'Tag names may not include spaces' }
validate :parent_not_self
validate :parent_not_own_child
+ validate :synonym_unique
validates :name, uniqueness: { scope: [:tag_set_id], case_sensitive: false }
def self.search(term)
- where('name LIKE ?', "%#{sanitize_sql_like(term)}%")
- .or(where('excerpt LIKE ?', "%#{sanitize_sql_like(term)}%"))
- .order(Arel.sql(sanitize_sql_array(['name LIKE ? DESC, name', "#{sanitize_sql_like(term)}%"])))
+ stripped = term.strip
+ # Query to search on tags, the name is used for sorting.
+ q1 = where('tags.name LIKE ?', "%#{sanitize_sql_like(stripped)}%")
+ .or(where('tags.excerpt LIKE ?', "%#{sanitize_sql_like(stripped)}%"))
+ .select(Arel.sql('name AS sortname, tags.*'))
+
+ # Query to search on synonyms, the synonym name is used for sorting.
+ # The order clause here actually applies to the union of q1 and q2 (so not just q2).
+ q2 = joins(:tag_synonyms)
+ .where('tag_synonyms.name LIKE ?', "%#{sanitize_sql_like(stripped)}%")
+ .select(Arel.sql('tag_synonyms.name AS sortname, tags.*'))
+ .order(Arel.sql(sanitize_sql_array(['sortname LIKE ? DESC, sortname', "#{sanitize_sql_like(stripped)}%"])))
+
+ # Select from the union of the above queries, select only the tag columns such that we can distinct them
+ from(Arel.sql("(#{q1.to_sql} UNION #{q2.to_sql}) tags"))
+ .select(Tag.column_names.map { |c| "tags.#{c}" })
+ .distinct
+ end
+
+ ##
+ # Find or create a tag within a given tag set, considering synonyms. If a synonym is given as +name+ then the primary
+ # tag for it is returned instead.
+ # @param name [String] A tag name to find or create.
+ # @param tag_set [TagSet] The tag set within which to search for or create the tag.
+ # @return [Array(Tag, String)] The found or created tag, and the final name used. If a synonymized name was given as
+ # +name+ then this will be the primary tag name.
+ #
+ # @example +name+ does not yet exist: a new Tag is created
+ # Tag.find_or_create_synonymized name: 'new-tag', tag_set: ...
+ # # => [Tag, 'new-tag']
+ #
+ # @example +name+ already exists: the existing Tag is returned
+ # Tag.find_or_create_synonymized name: 'existing-tag', tag_set: ...
+ # # => [Tag, 'existing-tag']
+ #
+ # @example +name+ is a synonym of 'other-tag': the Tag for 'other-tag' is returned
+ # Tag.find_or_create_synonymized name: 'synonym', tag_set: ...
+ # # => [Tag, 'other-tag']
+ def self.find_or_create_synonymized(name:, tag_set:)
+ existing = Tag.find_by(name: name, tag_set: tag_set)
+ if existing.nil?
+ synonyms = TagSynonym.joins(:tag).where(name: name, tags: { tag_set: tag_set })
+ synonymized_name = synonyms.exists? ? synonyms.first.tag.name : name
+ [Tag.find_or_create_by(name: synonymized_name, tag_set: tag_set), synonymized_name]
+ else
+ [existing, name]
+ end
end
def all_children
@@ -54,4 +101,10 @@ def parent_not_own_child
errors.add(:base, "The #{parent.name} tag is already a child of this tag.")
end
end
+
+ def synonym_unique
+ if TagSynonym.joins(:tag).where(tags: { community_id: community_id }).exists?(name: name)
+ errors.add(:base, "A tag synonym with the name #{name} already exists.")
+ end
+ end
end
diff --git a/app/models/tag_set.rb b/app/models/tag_set.rb
index e71e33c89..150dab46a 100644
--- a/app/models/tag_set.rb
+++ b/app/models/tag_set.rb
@@ -13,4 +13,12 @@ def self.meta
def self.main
where(name: 'Main').first
end
+
+ def with_paths(no_excerpt = false)
+ if no_excerpt
+ tags_with_paths.where(excerpt: ['', nil])
+ else
+ tags_with_paths
+ end
+ end
end
diff --git a/app/models/tag_synonym.rb b/app/models/tag_synonym.rb
new file mode 100644
index 000000000..da0aec259
--- /dev/null
+++ b/app/models/tag_synonym.rb
@@ -0,0 +1,18 @@
+class TagSynonym < ApplicationRecord
+ belongs_to :tag
+ has_one :community, through: :tag
+
+ validates :name, presence: true, format: { with: /[^ \t]+/, message: 'Tag names may not include spaces' }
+ validate :name_unique
+
+ private
+
+ # Checks whether the name of this synonym is not already taken by a tag or synonym in the same community.
+ def name_unique
+ if TagSynonym.joins(:tag).where(tags: { community_id: tag.community_id }).exists?(name: name)
+ errors.add(:base, "A tag synonym with the name #{name} already exists.")
+ elsif Tag.unscoped.where(community_id: tag.community_id).exists?(name: name)
+ errors.add(:base, "A tag with the name #{name} already exists.")
+ end
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 84d79db01..04e1219f7 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -2,10 +2,11 @@
# application code (i.e. excluding Devise) is concerned, has many questions, answers, and votes.
class User < ApplicationRecord
include ::UserMerge
+ include ::SamlInit
devise :database_authenticatable, :registerable, :confirmable,
:recoverable, :rememberable, :trackable, :validatable,
- :lockable, :omniauthable
+ :lockable, :omniauthable, :saml_authenticatable
has_many :posts, dependent: :nullify
has_many :votes, dependent: :destroy
@@ -24,6 +25,10 @@ class User < ApplicationRecord
has_many :comments, dependent: :nullify
has_many :comment_threads_deleted, class_name: 'CommentThread', foreign_key: :deleted_by_id, dependent: :nullify
has_many :comment_threads_locked, class_name: 'CommentThread', foreign_key: :locked_by_id, dependent: :nullify
+ has_many :category_filter_defaults, dependent: :destroy
+ has_many :filters, dependent: :destroy
+ has_many :user_websites, dependent: :destroy
+ accepts_nested_attributes_for :user_websites
belongs_to :deleted_by, required: false, class_name: 'User'
validates :username, presence: true, length: { minimum: 3, maximum: 50 }
@@ -40,7 +45,7 @@ class User < ApplicationRecord
scope :active, -> { where(deleted: false) }
scope :deleted, -> { where(deleted: true) }
- after_create :send_welcome_tour_message
+ after_create :send_welcome_tour_message, :ensure_websites
def self.list_includes
includes(:posts, :avatar_attachment)
@@ -58,6 +63,12 @@ def trust_level
community_user.trust_level
end
+ # Checks whether this user is the same as a given user
+ # @param [User] user user to compare with
+ def same_as?(user)
+ id == user.id
+ end
+
# This class makes heavy use of predicate names, and their use is prevalent throughout the codebase
# because of the importance of these methods.
# rubocop:disable Naming/PredicateName
@@ -69,6 +80,22 @@ def has_post_privilege?(name, post)
end
end
+ # Checks if the user can push a given post type to network
+ # @param post_type [PostType] type of the post to be pushed
+ # @return [Boolean]
+ def can_push_to_network(post_type)
+ post_type.system? && (is_global_moderator || is_global_admin)
+ end
+
+ # Checks if the user can directly update a given post
+ # @param post [Post] updated post (owners can unilaterally update)
+ # @param post_type [PostType] type of the post (some are freely editable)
+ # @return [Boolean]
+ def can_update(post, post_type)
+ privilege?('edit_posts') || is_moderator || self == post.user || \
+ (post_type.is_freely_editable && privilege?('unrestricted'))
+ end
+
def metric(key)
Rails.cache.fetch("community_user/#{community_user.id}/metric/#{key}", expires_in: 24.hours) do
case key
@@ -111,6 +138,18 @@ def website_domain
website.nil? ? website : URI.parse(website).hostname
end
+ def valid_websites_for
+ user_websites.where.not(url: [nil, '']).order(position: :asc)
+ end
+
+ def ensure_websites
+ pos = user_websites.size
+ while pos < UserWebsite::MAX_ROWS
+ pos += 1
+ UserWebsite.create(user_id: id, position: pos)
+ end
+ end
+
def is_moderator
is_global_moderator || community_user&.is_moderator || is_admin || community_user&.privilege?('mod') || false
end
@@ -119,6 +158,42 @@ def is_admin
is_global_admin || community_user&.is_admin || false
end
+ # Used by network profile: does this user have a profile on that other comm?
+ def has_profile_on(community_id)
+ cu = community_users.where(community_id: community_id).first
+ !cu&.user_id.nil? || false
+ end
+
+ def reputation_on(community_id)
+ cu = community_users.where(community_id: community_id).first
+ cu&.reputation || 1
+ end
+
+ def post_count_on(community_id)
+ cu = community_users.where(community_id: community_id).first
+ cu&.post_count || 0
+ end
+
+ def is_moderator_on(community_id)
+ cu = community_users.where(community_id: community_id).first
+ # is_moderator is a DB check, not a call to is_moderator()
+ is_global_moderator || is_admin || cu&.is_moderator || cu&.privilege?('mod') || false
+ end
+
+ def has_ability_on(community_id, ability_internal_id)
+ cu = community_users.where(community_id: community_id).first
+ if cu&.is_moderator || cu&.is_admin || is_global_moderator || is_global_admin || cu&.privilege?('mod')
+ true
+ elsif cu.nil?
+ false
+ else
+ Ability.unscoped do
+ UserAbility.joins(:ability).where(community_user_id: cu&.id, is_suspended: false,
+ ability: { internal_id: ability_internal_id }).exists?
+ end
+ end
+ end
+
def rtl_safe_username
"#{username}\u202D"
end
@@ -177,7 +252,7 @@ def is_not_blocklisted
def email_not_bad_pattern
return unless File.exist?(Rails.root.join('../.qpixel-email-patterns.txt'))
- return unless saved_changes.include? 'email'
+ return unless changes.include? 'email'
patterns = File.read(Rails.root.join('../.qpixel-email-patterns.txt')).split("\n")
matched = patterns.select { |p| email.match? Regexp.new(p) }
@@ -214,7 +289,7 @@ def send_welcome_tour_message
'how this site works.', '/tour')
end
- def block(reason)
+ def block(reason, length: 180.days)
user_email = email
user_ip = [last_sign_in_ip]
@@ -222,10 +297,10 @@ def block(reason)
user_ip << current_sign_in_ip
end
- BlockedItem.create(item_type: 'email', value: user_email, expires: DateTime.now + 180.days,
+ BlockedItem.create(item_type: 'email', value: user_email, expires: length.from_now,
automatic: true, reason: "#{reason}: #" + id.to_s)
user_ip.compact.uniq.each do |ip|
- BlockedItem.create(item_type: 'ip', value: ip, expires: 180.days.from_now,
+ BlockedItem.create(item_type: 'ip', value: ip, expires: length.from_now,
automatic: true, reason: "#{reason}: #" + id.to_s)
end
end
@@ -234,13 +309,19 @@ def preferences
global_key = "prefs.#{id}"
community_key = "prefs.#{id}.community.#{RequestContext.community_id}"
{
- global: AppConfig.preferences.reject { |_, v| v['community'] }.transform_values { |v| v['default'] }
+ global: AppConfig.preferences.select { |_, v| v['global'] }.transform_values { |v| v['default'] }
.merge(RequestContext.redis.hgetall(global_key)),
community: AppConfig.preferences.select { |_, v| v['community'] }.transform_values { |v| v['default'] }
.merge(RequestContext.redis.hgetall(community_key))
}
end
+ def category_preference(category_id)
+ category_key = "prefs.#{id}.category.#{RequestContext.community_id}.category.#{category_id}"
+ AppConfig.preferences.select { |_, v| v['category'] }.transform_values { |v| v['default'] }
+ .merge(RequestContext.redis.hgetall(category_key))
+ end
+
def validate_prefs!
global_key = "prefs.#{id}"
community_key = "prefs.#{id}.community.#{RequestContext.community_id}"
@@ -260,5 +341,24 @@ def validate_prefs!
def preference(name, community: false)
preferences[community ? :community : :global][name]
end
+
+ def has_active_flags?(post)
+ !post.flags.where(user: self, status: nil).empty?
+ end
+
+ def active_flags(post)
+ post.flags.where(user: self, status: nil)
+ end
+
+ def do_soft_delete(attribute_to)
+ AuditLog.moderator_audit(event_type: 'user_delete', related: self, user: attribute_to,
+ comment: attributes_print(join: "\n"))
+ assign_attributes(deleted: true, deleted_by_id: attribute_to.id, deleted_at: DateTime.now,
+ username: "user#{id}", email: "#{id}@deleted.localhost",
+ password: SecureRandom.hex(32))
+ skip_reconfirmation!
+ save
+ end
+
# rubocop:enable Naming/PredicateName
end
diff --git a/app/models/user_website.rb b/app/models/user_website.rb
new file mode 100644
index 000000000..6b50d909f
--- /dev/null
+++ b/app/models/user_website.rb
@@ -0,0 +1,6 @@
+class UserWebsite < ApplicationRecord
+ belongs_to :user
+ default_scope { order(:position) }
+
+ MAX_ROWS = 3
+end
diff --git a/app/models/vote.rb b/app/models/vote.rb
index a7a3505ab..a7c42dcb3 100644
--- a/app/models/vote.rb
+++ b/app/models/vote.rb
@@ -34,37 +34,45 @@ def reverse_rep_change
end
def rep_change(direction)
+ return unless post.present?
+
change = CategoryPostType.rep_changes[[post.category_id, post.post_type_id]][vote_type] || 0
recv_user.update!(reputation: recv_user.reputation + (direction * change))
end
def post_not_deleted
- if post.deleted?
+ if post&.deleted?
errors.add(:base, 'Votes are locked on deleted posts')
end
end
def check_valid
- throw :abort unless valid?
+ throw :abort unless valid? || post.blank?
end
def add_counter
+ return unless post.present?
+
case vote_type
when 1
post.update(upvote_count: post.upvote_count + 1)
when -1
post.update(downvote_count: post.downvote_count + 1)
end
+
post.recalc_score
end
def remove_counter
+ return unless post.present?
+
case vote_type
when 1
post.update(upvote_count: [post.upvote_count - 1, 0].max)
when -1
post.update(downvote_count: [post.downvote_count - 1, 0].max)
end
+
post.recalc_score
end
end
diff --git a/app/tasks/maintenance/initialize_user_websites_task.rb b/app/tasks/maintenance/initialize_user_websites_task.rb
new file mode 100644
index 000000000..80a91b948
--- /dev/null
+++ b/app/tasks/maintenance/initialize_user_websites_task.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Maintenance
+ class InitializeUserWebsitesTask < MaintenanceTasks::Task
+ def collection
+ User.all
+ end
+
+ def process(user)
+ unless user.user_websites.exists?(position: 1)
+ if user.website.present?
+ UserWebsite.create!(user_id: user.id, position: 1, label: 'website', url: user.website)
+ else
+ UserWebsite.create!(user_id: user.id, position: 1)
+ end
+ end
+
+ unless user.user_websites.exists?(position: 2)
+ if user.twitter.present?
+ UserWebsite.create!(user_id: user.id, position: 2, label: 'Twitter',
+ url: "https://twitter.com/#{user.twitter}")
+ else
+ UserWebsite.create!(user_id: user.id, position: 2)
+ end
+ end
+
+ # This check *should* be superfluous, but just in case...
+ unless user.user_websites.exists?(position: 3)
+ UserWebsite.create!(user_id: user.id, position: 3)
+ end
+ end
+ end
+end
diff --git a/app/views/abilities/show.html.erb b/app/views/abilities/show.html.erb
index c06d2131e..a34e29ca2 100644
--- a/app/views/abilities/show.html.erb
+++ b/app/views/abilities/show.html.erb
@@ -49,20 +49,23 @@
<% unless @ability.manual? %>
<% unless params[:thresholds].nil? %>
-
You need to reach these thresholds to earn the ability:
-
+ <% if @user.id == current_user&.id %>
+
You need to reach these thresholds to earn the ability:
+ <% else %>
+
<%= @user.username %> needs to reach these thresholds to earn the ability:
+ <% end %>
<% unless @ability.post_score_threshold.nil? %>
<% post_score_percent = (linearize_progress(@user.community_user.post_score) / linearize_progress(@ability.post_score_threshold) * 100).to_i %>
Post score threshold
<% if post_score_percent < 100 %>
-
You need to have more well-received posts.
+
Need to have more well-received posts.
<%= post_score_percent %>%
<% else %>
-
You need to have many well-received posts.
+
Need to have many well-received posts.
100%
@@ -75,13 +78,13 @@
Edit score threshold
<% if edit_score_percent < 100 %>
-
You need to have more accepted suggested edits.
+
Need to have more accepted suggested edits.
<%= edit_score_percent %>%
<% else %>
-
You need to have many accepted suggested edits.
+
Need to have many accepted suggested edits.
100%
@@ -94,13 +97,13 @@
Flag score threshold
<% if flag_score_percent < 100 %>
-
You need to have more helpful flags.
+
Need to have more helpful flags.
<%= flag_score_percent %>%
<% else %>
-
You need to have many helpful flags.
+
Need to have many helpful flags.
100%
diff --git a/app/views/admin/all_email.html.erb b/app/views/admin/all_email.html.erb
new file mode 100644
index 000000000..ade37ea36
--- /dev/null
+++ b/app/views/admin/all_email.html.erb
@@ -0,0 +1,24 @@
+<%= render 'posts/markdown_script' %>
+
+
+
+ Please be careful, as this tool sends a lot of emails.
+
+
+
+
<%= t 'admin.tools.email_all' %>
+
<%= t 'admin.email_all_blurb' %>
+
+<%= form_with url: send_all_email_path do |f| %>
+
+ <%= f.label :subject, t('g.subject').capitalize, class: 'form-element' %>
+ <%= f.text_field :subject, class: 'form-element', required: true %>
+
+
+ <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: t('g.body').capitalize, post: nil %>
+
+
+
+ <%= f.submit t('g.send').capitalize, class: 'button is-filled',
+ onclick: "return confirm('Are you sure you want to send this email to all users?')" %>
+<% end %>
diff --git a/app/views/admin/audit_log.html.erb b/app/views/admin/audit_log.html.erb
index cb6003e1b..ea2b467af 100644
--- a/app/views/admin/audit_log.html.erb
+++ b/app/views/admin/audit_log.html.erb
@@ -4,15 +4,15 @@
<%= pluralize(@logs.count, t('g.log')) %>
<% classes = 'button is-outlined is-muted' %>
- <%= link_to t('g.age'), query_url(sort: 'age'),
+ <%= link_to t('g.age'), request.params.merge(sort: 'age'),
class: "#{classes} #{params[:sort] == 'age' || params[:sort].nil? ? 'is-active' : ''}" %>
- <%= link_to t('g.type'), query_url(sort: 'type'),
+ <%= link_to t('g.type'), request.params.merge(sort: 'type'),
class: "#{classes} #{params[:sort] == 'type' ? 'is-active' : ''}" %>
- <%= link_to t('g.event'), query_url(sort: 'event'),
+ <%= link_to t('g.event'), request.params.merge(sort: 'event'),
class: "#{classes} #{params[:sort] == 'event' ? 'is-active' : ''}" %>
- <%= link_to t('g.related'), query_url(sort: 'related'),
+ <%= link_to t('g.related'), request.params.merge(sort: 'related'),
class: "#{classes} #{params[:sort] == 'related' ? 'is-active' : ''}" %>
- <%= link_to t('g.user'), query_url(sort: 'user'),
+ <%= link_to t('g.user'), request.params.merge(sort: 'user'),
class: "#{classes} #{params[:sort] == 'user' ? 'is-active' : ''}" %>
diff --git a/app/views/admin/change_back.html.erb b/app/views/admin/change_back.html.erb
index 26e9e77c0..33cf3c7ea 100644
--- a/app/views/admin/change_back.html.erb
+++ b/app/views/admin/change_back.html.erb
@@ -1,14 +1,20 @@
Stop impersonating
- You (<%= @impersonator.username %>) are currently impersonating <%= current_user.username %>. To stop
- impersonating them, verify your password below
+ You (<%= @impersonator.username %>) are currently impersonating <%= current_user.username %>.
+ <% if @impersonator.sso_profile.present? %>
+ You can stop impersonating them with the button below, after which you will have to sign in again.
+ <% else %>
+ To stop impersonating them, verify your password below.
+ <% end %>
<%= form_tag stop_impersonating_path, class: 'form-horizontal' do %>
-
- <%= label_tag :password, 'Password', class: 'form-element' %>
- <%= password_field_tag :password, '', class: 'form-element' %>
-
+ <% unless @impersonator.sso_profile.present? %>
+
+ <%= label_tag :password, 'Password', class: 'form-element' %>
+ <%= password_field_tag :password, '', class: 'form-element' %>
+
+ <% end %>
<%= submit_tag 'Verify & Stop Impersonating', class: 'button is-primary is-filled' %>
diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb
index 89f299e13..2f4c5266d 100644
--- a/app/views/admin/index.html.erb
+++ b/app/views/admin/index.html.erb
@@ -30,17 +30,28 @@
+ <% if current_user.developer? %>
+
+ <% end %>
+
@@ -108,6 +119,7 @@
+
+
<% if current_user.is_global_admin %>
-
Posts
<% categories.each do |cat| %>
<% next if (cat.min_view_trust_level || -1) > (current_user&.trust_level || 0) %>
@@ -29,6 +29,15 @@
<% end %>
<% end %>
+
+ <% if current_user&.has_ability_on(c.id, 'edit_posts') %>
+ <% sug_edits = @edits[cat.id] || 0 %>
+ <% if sug_edits > 0 %>
+ <%= link_to suggested_edits_queue_url(cat, host: c.host), class: 'widget--body-extra' do %>
+ (<%= sug_edits %> pending <%= "edit".pluralize(sug_edits) %>)
+ <% end %>
+ <% end %>
+ <% end %>
<% end %>
<% if current_user&.is_global_moderator || current_user&.is_global_admin %>
diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb
index 7b172c7eb..55e04b314 100644
--- a/app/views/categories/_form.html.erb
+++ b/app/views/categories/_form.html.erb
@@ -1,4 +1,14 @@
<%= form_for @category, url: submit_path, method: :post do |f| %>
+ <% if @category.errors.any? %>
+
+
+ <% @category.errors.full_messages.each do |m| %>
+ <%= m %>
+ <% end %>
+
+
+ <% end %>
+
Basic Information
@@ -51,6 +61,18 @@
<%= f.select :tag_set_id, options_for_select(TagSet.all.map { |ts| [ts.name, ts.id] }, selected: @category.tag_set_id),
{ include_blank: true }, class: 'form-element js-category-tag-set-select' %>
+
+
+ <%= f.label :min_title_length, 'Minimum title length', class: 'form-element' %>
+ Titles of posts in this category must be at least this many characters long.
+ <%= f.number_field :min_title_length, class: 'form-element' %>
+
+
+
+ <%= f.label :min_body_length, 'Minimum body length', class: 'form-element' %>
+ Posts in this category must be at least this many characters long.
+ <%= f.number_field :min_body_length, class: 'form-element' %>
+
@@ -123,6 +145,13 @@
<%= f.number_field :sequence, class: 'form-element' %>
+
+
+ <%= f.label :default_filter_id, class: 'form-element' %>
+ The default filter for this category, used for anonymous users.
+ <% system_filters = User.find(-1).filters.to_h { |filter| [filter.name, filter.id] } %>
+ <%= f.select :default_filter_id, options_for_select(system_filters, selected: @category.default_filter_id), { include_blank: "No default" } %>
+
@@ -192,7 +221,7 @@
<% disabled = @category.tag_set.nil? %>
<%= f.select :required_tag_ids, options_for_select(@category.required_tags.map { |t| [t.name, t.id] },
selected: @category.required_tag_ids),
- { include_blank: true }, multiple: true, class: 'form-element js-tag-select js-required-tags',
+ {}, multiple: true, class: 'form-element js-tag-select js-required-tags',
data: { tag_set: @category.tag_set&.id, create: 'false', use_ids: 'true' }, disabled: disabled %>
@@ -210,7 +239,7 @@
<%= f.select :topic_tag_ids, options_for_select(@category.topic_tags.map { |t| [t.name, t.id] },
selected: @category.topic_tag_ids),
- { include_blank: true }, multiple: true, class: 'form-element js-tag-select js-topic-tags',
+ {}, multiple: true, class: 'form-element js-tag-select js-topic-tags',
data: { tag_set: @category.tag_set&.id, create: 'false', use_ids: 'true' }, disabled: disabled %>
@@ -227,7 +256,7 @@
<%= f.select :moderator_tag_ids, options_for_select(@category.moderator_tags.map { |t| [t.name, t.id] },
selected: @category.moderator_tag_ids),
- { include_blank: true }, multiple: true, class: 'form-element js-tag-select js-moderator-tags',
+ {}, multiple: true, class: 'form-element js-tag-select js-moderator-tags',
data: { tag_set: @category.tag_set&.id, create: 'false', use_ids: 'true' }, disabled: disabled %>
diff --git a/app/views/categories/show.html.erb b/app/views/categories/show.html.erb
index c5bfcce11..62a18a316 100644
--- a/app/views/categories/show.html.erb
+++ b/app/views/categories/show.html.erb
@@ -11,45 +11,62 @@
<% end %>
<% post_count = @posts.count %>
-
-
- <%= short_number_to_human post_count, precision: 1, significant: false %>
- <%= 'post'.pluralize(post_count) %> ·
-
- <% if current_user&.is_admin %>
- <%= link_to 'Edit Category', edit_category_path(@category) %>
- ·
- <% end %>
-
- <%= link_to 'Subscribe',
- new_subscription_path(type: 'category', qualifier: @category.id, return_to: request.path),
- class: 'button is-outlined' %>
-
+
+
+ Filters (<%= @filtered ? @active_filter[:name].empty? ? 'Custom' : @active_filter[:name] : 'None' %>)
+ <% if @active_filter[:default] == :user %>
+
+ You are currently filtering by <%= @active_filter[:name] %> because it is set as your default for this category
+
+ <% elsif @active_filter[:default] == :category and not user_signed_in? %>
+
+ You are currently filtering by <%= @active_filter[:name] %> because it is the default for this category
+
+ <% elsif @active_filter[:default] == :category and user_signed_in? %>
+
+ You are currently filtering by <%= @active_filter[:name] %> because it is the default for this category and you do not have a personal default set
+
+ <% end %>
+ <%= form_tag request.original_url, method: :get do %>
+ <%= render 'search/filters' %>
+ <% end %>
+
+
<% @posts.each do |post| %>
<%= render 'posts/type_agnostic', post: post %>
@@ -61,7 +78,5 @@
- <%= link_to category_feed_path(@category, format: 'rss') do %>
- Category RSS feed
- <% end %>
-
\ No newline at end of file
+ <%= render 'shared/rss_link', url: category_feed_path(@category, format: 'rss'), text: 'Category RSS feed' %>
+
diff --git a/app/views/close_reasons/_form.html.erb b/app/views/close_reasons/_form.html.erb
index e691772a6..03522077d 100644
--- a/app/views/close_reasons/_form.html.erb
+++ b/app/views/close_reasons/_form.html.erb
@@ -42,5 +42,5 @@
<%= f.submit 'Save', class: 'button is-filled' %>
- <%= link_to 'Cancel', close_reasons_path(global: @close_reason.community.nil? ? 1 : 0), class: 'button' %>
+ <%= link_to 'Cancel', close_reasons_path(global: @close_reason.community.nil? ? 1 : 0), class: 'button', role: 'button' %>
<% end %>
diff --git a/app/views/close_reasons/index.html.erb b/app/views/close_reasons/index.html.erb
index 324b7ecbc..1297a0279 100644
--- a/app/views/close_reasons/index.html.erb
+++ b/app/views/close_reasons/index.html.erb
@@ -33,7 +33,7 @@
<%= reason.requires_other_post ? "yes" : "no" %>
<%= reason.active ? "yes" : "no" %>
- <%= link_to "edit", close_reason_path(id: reason.id), class: "button is-outlined" %>
+ <%= link_to "edit", close_reason_path(id: reason.id), class: "button is-outlined", 'aria-label': "Edit close reason #{reason.name}" %>
<% end %>
@@ -46,5 +46,5 @@
Do not add reasons where you're not sure that your community needs or wants them.
It's not possible to remove reasons, once created. They can only be deactivated.
- <%= link_to "add reason", new_close_reason_path(global: params[:global]), class: "button is-outlined" %>
+ <%= link_to "add reason", new_close_reason_path(global: params[:global]), class: "button is-outlined", 'aria-label': 'Add new close reason' %>
diff --git a/app/views/comments/_comment.html.erb b/app/views/comments/_comment.html.erb
index 7dc7caad9..4d8e4f2c2 100644
--- a/app/views/comments/_comment.html.erb
+++ b/app/views/comments/_comment.html.erb
@@ -45,27 +45,27 @@
<% end %>
diff --git a/app/views/comments/_new_thread_modal.html.erb b/app/views/comments/_new_thread_modal.html.erb
index 5ba122f28..8ea24575f 100644
--- a/app/views/comments/_new_thread_modal.html.erb
+++ b/app/views/comments/_new_thread_modal.html.erb
@@ -20,11 +20,7 @@
Start the thread with a comment.
<%= text_area_tag :body, '', class: 'form-element js-comment-field', required: true,
data: { post: post.id, thread: '-1', character_count: ".js-character-count-#{post.id}" } %>
-
-
- 0 / 1000
-
+ <%= render 'shared/char_count', type: post.id, min: 15, max: 1000 %>
<%= label_tag :title, 'Comment thread title (optional)', class: 'form-element' %>
@@ -32,12 +28,7 @@
be shown.
<%= text_field_tag :title, '', class: 'form-element', data: { character_count: ".js-character-count-thread-title" } %>
-
-
-
- 0 / 255
-
+ <%= render 'shared/char_count', type: 'thread-title' %>
<%= submit_tag 'Create thread', class: 'button is-filled', id: "create_thread_button_#{post.id}", disabled: true %>
<% end %>
diff --git a/app/views/comments/_post.html.erb b/app/views/comments/_post.html.erb
index 4949f5012..4d7c00fcf 100644
--- a/app/views/comments/_post.html.erb
+++ b/app/views/comments/_post.html.erb
@@ -3,14 +3,14 @@
%>
<% comment_threads.each do |thread| %>
-
\ No newline at end of file
diff --git a/app/views/micro_auth/apps/index.html.erb b/app/views/micro_auth/apps/index.html.erb
index 8f9984adc..5e19d56f3 100644
--- a/app/views/micro_auth/apps/index.html.erb
+++ b/app/views/micro_auth/apps/index.html.erb
@@ -3,7 +3,7 @@
Your OAuth Apps
- <%= link_to new_oauth_app_path, class: 'button is-filled' do %>
+ <%= link_to new_oauth_app_path, class: 'button is-filled', 'aria-label': 'Create new OAuth App' do %>
Create
<% end %>
@@ -11,9 +11,9 @@
<% classes = 'button is-outlined is-muted' %>
<%= link_to 'yours', oauth_apps_path(request.query_parameters.except(:admin)),
- class: "#{classes} #{params[:admin].nil? ? 'is-active' : ''}" %>
+ class: "#{classes} #{params[:admin].nil? ? 'is-active' : ''}", 'aria-label': 'View your OAuth Apps' %>
<%= link_to 'admin', oauth_apps_path(request.query_parameters.merge(admin: true)),
- class: "#{classes} #{params[:admin].present? ? 'is-active' : ''}" %>
+ class: "#{classes} #{params[:admin].present? ? 'is-active' : ''}", 'aria-label': 'View Admin OAuth Apps' %>
<% end %>
@@ -29,7 +29,9 @@
<%= text_field_tag :search, params[:search], class: 'form-element' %>
- <%= submit_tag 'Search', class: 'button' %>
+ <%= button_tag type: :submit, class: 'button is-medium is-filled is-outlined', name: nil do %>
+
+ <% end %>
<% end %>
diff --git a/app/views/micro_auth/authentication/initiate.html.erb b/app/views/micro_auth/authentication/initiate.html.erb
index ff4f9222c..bc1e9bebb 100644
--- a/app/views/micro_auth/authentication/initiate.html.erb
+++ b/app/views/micro_auth/authentication/initiate.html.erb
@@ -24,5 +24,5 @@
You can choose to allow or deny this request.
-<%= link_to 'Allow', approve_oauth_path(request.query_parameters), method: :post, class: 'button is-green is-filled' %>
-<%= link_to 'Deny', reject_oauth_path, class: 'button is-danger is-outlined' %>
+<%= link_to 'Allow', approve_oauth_path(request.query_parameters), method: :post, class: 'button is-green is-filled', role: 'button' %>
+<%= link_to 'Deny', reject_oauth_path, class: 'button is-danger is-outlined', role: 'button' %>
diff --git a/app/views/mod_warning/current.html.erb b/app/views/mod_warning/current.html.erb
index c98f4bf93..f5f7eb3e9 100644
--- a/app/views/mod_warning/current.html.erb
+++ b/app/views/mod_warning/current.html.erb
@@ -10,7 +10,11 @@
<%= raw(sanitize(@warning.body_as_html, scrubber: scrubber)) %>
Your account has been temporarily suspended (ends in <%= time_ago_in_words(current_user.community_user.suspension_end) %> ). We look forward to your return and continued contributions to the site after this period. In the event of continued rule violations after this period, your account may be suspended for longer periods. If you have any questions about this suspension or would like to dispute it, contact us .
- <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined' %>
+ <% if devise_sign_in_enabled? %>
+ <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined', role: 'button' %>
+ <% else %>
+ <%= link_to 'Sign Out', destroy_saml_user_session_path, method: :delete, class: 'button is-danger is-outlined', role: 'button' %>
+ <% end %>
<% else %>
@@ -27,7 +31,11 @@
<%= submit_tag 'Continue', class: 'button is-filled' %>
- <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined' %>
+ <% if devise_sign_in_enabled? %>
+ <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined', role: 'button' %>
+ <% else %>
+ <%= link_to 'Sign Out', destroy_saml_user_session_path, method: :delete, class: 'button is-danger is-outlined', role: 'button' %>
+ <% end %>
<% end %>
<% end %>
@@ -46,7 +54,11 @@
<%= submit_tag 'Continue', class: 'button is-filled' %>
- <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined' %>
+ <% if devise_sign_in_enabled? %>
+ <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined', role: 'button' %>
+ <% else %>
+ <%= link_to 'Sign Out', destroy_saml_user_session_path, method: :delete, class: 'button is-danger is-outlined', role: 'button' %>
+ <% end %>
<% end %>
<% end %>
\ No newline at end of file
diff --git a/app/views/notifications/index.html.erb b/app/views/notifications/index.html.erb
index 0672c4558..33c2be2fb 100644
--- a/app/views/notifications/index.html.erb
+++ b/app/views/notifications/index.html.erb
@@ -1,3 +1,5 @@
+<%= render 'users/tabs', user: current_user %>
+
Your Inbox
You'll find all your inbox messages here, as far back as your account goes.
diff --git a/app/views/pinned_links/_form.html.erb b/app/views/pinned_links/_form.html.erb
index a69f010bb..80a3191e6 100644
--- a/app/views/pinned_links/_form.html.erb
+++ b/app/views/pinned_links/_form.html.erb
@@ -48,5 +48,5 @@
<%= f.submit "Update", class: "button is-filled" %>
- <%= link_to "Cancel", pinned_links_path(global: params[:global]), class: "button" %>
+ <%= link_to "Cancel", pinned_links_path(global: params[:global]), class: "button", role: 'button' %>
<% end %>
\ No newline at end of file
diff --git a/app/views/post_history/post.html.erb b/app/views/post_history/post.html.erb
index 5e2b284d6..c31b586ea 100644
--- a/app/views/post_history/post.html.erb
+++ b/app/views/post_history/post.html.erb
@@ -1,11 +1,15 @@
+<% @show_content = !!defined?(show_content) ? show_content : true %>
+
Post History
+<% if @show_content %>
<%= render 'posts/type_agnostic', post: @post, show_category_tag: true, show_type_tag: true, last_activity: false %>
+<% end %>
<% @history.each.with_index do |event, index| %>
-
+
#<%= @history.size - index %>: <%= event.post_history_type.name.humanize %>
@@ -24,15 +28,45 @@
<%= event.comment %>
<% end %>
+ <%= render 'shared/copy_link', classes: ["button", "is-small", "is-muted", "is-outlined"],
+ desc: "Copy a permanent link to revision #{@history.size - index}",
+ id: "#{@post.id}-#{@history.size - index}",
+ md: post_history_share_link_md(@post, @history, index),
+ raw: post_history_share_link(@post, @history, index)
+ %>
- <% if (event.before_title.present? || event.after_title.present?) && event.before_title != event.after_title %>
- <%= render 'diff', before: event.before_title, after: event.after_title, post: @post %>
- <% end %>
- <% if (event.before_state.present? || event.after_state.present?) && event.before_state != event.after_state %>
- <%= render 'diff', before: event.before_state, after: event.after_state, post: @post %>
- <% end %>
- <% if (event.before_tags.present? || event.after_tags.present?) && event.before_tags != event.after_tags %>
- <%= render 'diff', before: event.before_tags, after: event.after_tags, post: @post %>
+ <% if event.allowed_to_see_details?(current_user) %>
+ <% if event.hidden? %>
+
+
Hidden revision
+
+ This revision is hidden because of a redaction. You have access to the details because
+ <% if current_user == event.user %>
+ you performed the redaction,
+ <% elsif current_user == @post.user %>
+ you are the post author,
+ <% elsif current_user&.is_admin %>
+ you are an administrator,
+ <% end %>
+ but you should not share this revision with others.
+
+
+ <% end %>
+ <% if (event.before_title.present? || event.after_title.present?) && event.before_title != event.after_title %>
+ <%= render 'diff', before: event.before_title, after: event.after_title, post: @post %>
+ <% end %>
+ <% if (event.before_state.present? || event.after_state.present?) && event.before_state != event.after_state %>
+ <%= render 'diff', before: event.before_state, after: event.after_state, post: @post %>
+ <% end %>
+ <% if (event.before_tags.present? || event.after_tags.present?) && event.before_tags != event.after_tags %>
+ <%= render 'diff', before: event.before_tags, after: event.after_tags, post: @post %>
+ <% end %>
+ <% elsif [event.before_title, event.after_title,
+ event.before_state, event.after_state,
+ event.before_tags, event.after_tags].any?(&:present?) %>
+
+ The detailed changes of this event are hidden because of a redaction.
+
<% end %>
<% end %>
diff --git a/app/views/posts/_article_list.html.erb b/app/views/posts/_article_list.html.erb
index d8deeaf56..fb48d014d 100644
--- a/app/views/posts/_article_list.html.erb
+++ b/app/views/posts/_article_list.html.erb
@@ -3,7 +3,7 @@
<% @show_category_tag = !!defined?(show_category_tag) ? show_category_tag : false %>
<% @last_activity = !!defined?(last_activity) ? last_activity : true %>
-
+
<%= (post.score * 100).to_i %>%
@@ -17,7 +17,7 @@
<% if @show_category_tag %>
- <%= defined?(@category) ? @category.name : post.category.name %>
+ <%= post.category.name %>
<% end %>
<%= link_to post.title, generic_share_link(post), 'data-ckb-item-link' => '' %>
diff --git a/app/views/posts/_edit_comment.html.erb b/app/views/posts/_edit_comment.html.erb
new file mode 100644
index 000000000..80a952ccd
--- /dev/null
+++ b/app/views/posts/_edit_comment.html.erb
@@ -0,0 +1,39 @@
+<%#
+ Edit comment reusable partial.
+ Variables:
+ comment : [String, Nil] optional, initial value of the field (default '')
+ cur_length : [Integer, Nil] optional, current character length (default 0)
+ min_length : [Integer, Nil] optional, the minimum allowed length (default 0)
+ max_length : [Integer, Nil] optional, the maximum allowed length (default 255)
+%>
+
+<%
+ # Defaults
+ comment = (defined?(comment) ? comment : nil) || ''
+ cur_length = (defined?(cur_length) ? cur_length : nil) || 0
+ min_length = (defined?(min_length) ? min_length : nil) || 0
+ max_length = (defined?(max_length) ? max_length : nil) || 255
+%>
+
+
+
\ No newline at end of file
diff --git a/app/views/posts/_expanded.html.erb b/app/views/posts/_expanded.html.erb
index 82733701f..5f28e9d23 100644
--- a/app/views/posts/_expanded.html.erb
+++ b/app/views/posts/_expanded.html.erb
@@ -18,7 +18,7 @@
<% title = post.title +
(post.closed && !post.duplicate_post ? " [closed]" : "") +
(post.duplicate_post ? " [duplicate]" : "") %>
-
<%= title %>
+
<%= title %>
<% if category.display_post_types.reject { |e| e.to_s.empty? }.size > 1 %>
<%= post_type_badge(post_type) %>
<% end %>
@@ -36,33 +36,35 @@
<% end %>
- <% if post_type.has_votes %>
+ <% if post_type.has_votes || (user_signed_in? && post.post_type.has_reactions && post.post_type.reactions.any?) %>
-
- <% existing_vote = my_vote(post) %>
- <% unless post.locked? %>
-
-
-
-
-
- <% end %>
-
- +<%= post.upvote_count %>
-
-
- −<%= post.downvote_count %>
+ <% if post_type.has_votes %>
+
+ <% existing_vote = my_vote(post) %>
+ <% unless post.locked? %>
+
+
+
+
+
+ <% end %>
+
+ +<%= post.upvote_count %>
+
+
+ −<%= post.downvote_count %>
+
+ <% unless post.locked? %>
+
+
+
+
+
+ <% end %>
- <% unless post.locked? %>
-
-
-
-
-
- <% end %>
-
+ <% end %>
<% if user_signed_in? && post.post_type.has_reactions && post.post_type.reactions.any? %>
<% unless post.locked? %>
@@ -83,6 +85,15 @@
<%= render('reactions/list', post: post) if post.reactions %>
<% if post_type.is_closeable && post.closed %>
+ <% if current_user == post.user %>
+
+
+
+ <%= t 'posts.post_closed_guidance' %>
+
+
+ <% end %>
+
Closed
@@ -123,7 +134,7 @@
<% end %>
-
Users with the Curate privilege may vote to undelete this post if it has been deleted incorrectly.
+
Users with the Curate privilege may vote to restore this post if it has been deleted incorrectly.
<% end %>
@@ -134,22 +145,42 @@
on <%= post.locked_at.strftime('%b %e, %Y at %H:%M') %>.
- <% end %>
+ <% end %>
+ <% if post.spam_flag_pending? && user_signed_in? %>
+
+ <% if post.user == current_user %>
+
Your post has been flagged by members of our community. Please review our guidelines for promotional content .
+ <% else %>
+
Possible spam: this post has pending flags for spam. Be careful when following links.
+ <% end %>
+
+ <% end %>
<% if post_type.is_public_editable && post.pending_suggested_edit? %>
<% if check_your_post_privilege(post, 'edit_posts') %>
- <% elsif post.pending_suggested_edit.user.id == current_user&.id %>
+ <% elsif post.pending_suggested_edit.user&.id == current_user&.id %>
-
Your suggested edit on this post is <%= link_to 'pending review', suggested_edit_url(post.pending_suggested_edit.id) %> .
+
+
+ Your suggested edit on this post is
+ <%= link_to 'pending review', suggested_edit_url(post.pending_suggested_edit.id) %> .
<% end %>
- <% end %>
+ <% end %>
-
- <%= raw(sanitize(post.body, scrubber: scrubber)) %>
+
+ <% effective_post = raw(sanitize(post.body, scrubber: scrubber)) %>
+ <% if post.spam_flag_pending? && !user_signed_in? %>
+ <%= sanitize(effective_post, attributes: %w()) %>
+ <% else %>
+ <%= effective_post %>
+ <% end %>
<% been_edited = post.last_edited_by_id != nil %>
<% if been_edited then last_edited_by_self = post.user_id == post.last_edited_by_id end %>
@@ -200,11 +231,12 @@
- <%= link_to generic_share_link(post), class: 'tools--item js-permalink' do %>
-
-
Permalink
- <% end %>
- <%= link_to post_history_path(post), class: 'tools--item' do %>
+ <%= render "shared/copy_link", classes: ["tools--item"],
+ desc: "Copy a permanent link to this post",
+ id: post.id,
+ md: generic_share_link_md(post),
+ raw: generic_share_link(post) %>
+ <%= link_to post_history_path(post), class: 'tools--item', 'aria-label': 'View history of this post' do %>
History
<% end %>
@@ -216,7 +248,7 @@
Review suggested edit
<% end %>
<% else %>
- <%= link_to edit_post_path(post), class: 'tools--item' do %>
+ <%= link_to edit_post_path(post), class: 'tools--item', 'aria-label': 'Edit this post' do %>
Edit
<% end %>
@@ -224,8 +256,13 @@
<% elsif !current_user.nil? %>
<% if post.pending_suggested_edit? %>
suggested edit pending...
+ <% elsif post_type.is_freely_editable %>
+ <%= link_to edit_post_path(post), class: 'tools--item', 'aria-label': 'Edit this post' do %>
+
+ Edit
+ <% end %>
<% else %>
- <%= link_to edit_post_path(post), class: 'tools--item' do %>
+ <%= link_to edit_post_path(post), class: 'tools--item', 'aria-label': 'Suggest edit to this post' do %>
Suggest edit
<% end %>
@@ -233,7 +270,7 @@
<% end %>
<% end %>
<% if user_signed_in? %>
-
+
Flag
@@ -241,12 +278,12 @@
<% unless post.locked? || !post_type.is_closeable %>
<% if check_your_privilege('flag_close') || (post.user_id === current_user&.id)%>
<% if !post.closed %>
-
+
Close
<% elsif post.closed %>
- <%= link_to reopen_post_path(post), method: :post, class: 'reopen-question tools--item' do %>
+ <%= link_to reopen_post_path(post), method: :post, class: 'reopen-question tools--item', role: 'button', 'aria-label': 'Reopen this post' do %>
Reopen
<% end %>
@@ -257,13 +294,15 @@
<% unless post.locked? %>
<% if !post.deleted %>
<%= link_to delete_post_path(post), method: :post,
- data: { confirm: 'Are you sure you want to delete this post?' }, class: "tools--item is-danger" do %>
+ data: { confirm: 'Are you sure you want to delete this post?' }, class: "tools--item is-danger",
+ role: 'button', 'aria-label': 'Delete this post' do %>
Delete
<% end %>
<% else %>
<%= link_to restore_post_path(post), method: :post,
- data: { confirm: 'Restore this post, making it visible to regular users?' }, class: "tools--item is-danger is-filled" do %>
+ data: { confirm: 'Restore this post, making it visible to regular users?' }, class: "tools--item is-danger is-filled",
+ role: 'button', 'aria-label': 'Restore this post' do %>
Restore
<% end %>
@@ -271,7 +310,7 @@
<% end %>
<% end %>
<% if check_your_privilege('flag_curate') %>
-
+
Tools
@@ -288,7 +327,7 @@
end %>
<% if flags_count > 0 %>
-
+
Show <%= pluralize(flags_count - own_flags_count, 'flag') %>
@@ -299,9 +338,26 @@