diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index cb7f4fbcf..000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,337 +0,0 @@ -version: 2.1 -orbs: - codecov: codecov/codecov@5 -jobs: - 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-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 - environment: - RAILS_ENV: 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 - environment: - RAILS_ENV: test - command: | - bundle exec rails test:system - - store_test_results: - path: "~/qpixel/test/reports" - - store_artifacts: - path: "~/qpixel/tmp/screenshots" - when: on_fail - - test-ruby32: - docker: - - image: cimg/ruby:3.2-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-ruby32-{{ checksum "Gemfile.lock" }} - - qpixel-ruby32- - - 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-ruby32-{{ 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 - environment: - RAILS_ENV: test - command: | - bundle exec rails test - - store_test_results: - path: "~/qpixel/test/reports" - - codecov/upload - system-test-ruby32: - docker: - - image: cimg/ruby:3.2-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-ruby32-{{ checksum "Gemfile.lock" }} - - qpixel-ruby32- - - 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-ruby32-{{ 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 - environment: - RAILS_ENV: 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:3.2-node - - 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-ruby32-{{ checksum "Gemfile.lock" }} - - qpixel-ruby32- - - 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-ruby32-{{ checksum "Gemfile.lock" }} - paths: - - ~/gems - - run: - name: Rubocop - command: | - bundle exec rubocop - - deploy: - docker: - - image: cimg/ruby:3.2-node - - working_directory: ~/qpixel - - steps: - - run: - name: Import SSH key - command: | - echo "$DEV_SSH_KEY" | base64 --decode > ~/deploy.key - chmod 0700 ~/deploy.key - - run: - name: Run deploy - command: | - ssh -o 'StrictHostKeyChecking no' "$SSH_USER"@"$SSH_IP" -p "$SSH_PORT" -i ~/deploy.key "sudo su -l qpixel /var/apps/deploy-dev" - -workflows: - test_lint: - jobs: - - test-ruby31 - - system-test-ruby31 - - test-ruby32 - - system-test-ruby32 - - rubocop - - deploy: - requires: - - test-ruby32 - - system-test-ruby32 - - rubocop - filters: - branches: - only: develop diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 000000000..b52fedc41 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,147 @@ +name: ci-cd + +on: + push: + branches: + - develop + pull_request: + branches: + - develop + +env: + RAILS_ENV: test + +jobs: + rubocop: + name: Rubocop checking + runs-on: ubuntu-latest + + strategy: + matrix: + ruby_version: [3.1, 3.2] + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + - name: Setup dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -yqq libmagickwand-dev + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby_version }} + bundler-cache: true + - run: bundle exec rubocop + + typescript: + name: TypeScript type checking + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + - name: Install Node + uses: actions/setup-node@v1 + with: + node-version: 22 + - run: npm install + - run: tsc + + tests: + name: Ruby on Rails tests + runs-on: ubuntu-latest + + strategy: + matrix: + ruby_version: [3.1, 3.2] + test_type: [test, test:system] + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_HOST: '%' + MYSQL_ROOT_PASSWORD: 'root' + MYSQL_DATABASE: 'qpixel_test' + ports: + - 3306:3306 + redis: + image: redis:8.0 + ports: + - 6379:6379 + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + - name: Setup dependencies + run: | + sudo apt-get -qq update + sudo apt-get -yqq install libmariadb-dev libmagickwand-dev + - name: Setup Firefox + if: ${{ matrix.test_type == 'test:system' }} + uses: browser-actions/setup-firefox@v1.5.4 + - name: Check Firefox setup + if: ${{ matrix.test_type == 'test:system' }} + run: firefox --version + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby_version }} + bundler-cache: true + - name: Prepare for testing + run: | + 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: bundle exec rails ${{ matrix.test_type }} + - name: Upload screenshots + if: ${{ matrix.test_type == 'test:system' }} + uses: actions/upload-artifact@v4 + with: + name: screenshots-${{ matrix.ruby_version }} + path: tmp/screenshots + if-no-files-found: ignore + - uses: codecov/codecov-action@v5 + if: ${{ matrix.ruby_version == 3.2 && matrix.test_type == 'test' && github.actor != 'dependabot[bot]' }} + with: + directory: coverage + fail_ci_if_error: true + report_type: coverage + token: ${{ secrets.CODECOV_TOKEN }} + + deploy: + name: Dev server deployment + runs-on: ubuntu-latest + if: ${{ github.event_name == 'push' && github.actor != 'dependabot[bot]' }} + needs: + - rubocop + - typescript + - tests + + env: + SSH_IP: ${{ secrets.DEV_SSH_IP }} + SSH_KEY: ${{ secrets.DEV_SSH_KEY }} + SSH_PORT: ${{ secrets.DEV_SSH_PORT }} + SSH_USER: ${{ secrets.DEV_SSH_USER }} + + steps: + - name: Check SSH version + run: ssh -V + - name: Import key + run: | + mkdir -p ~/.ssh + echo "$SSH_KEY" > ~/.ssh/deploy.key + chmod 0700 ~/.ssh/deploy.key + - name: Deploy + run: | + ssh -o 'StrictHostKeyChecking no' \ + -o 'KbdInteractiveAuthentication no' \ + -o 'PasswordAuthentication no' \ + -p "$SSH_PORT" \ + -i ~/.ssh/deploy.key \ + "$SSH_USER"@"$SSH_IP" \ + "sudo su -l qpixel /var/apps/deploy-dev" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 1e373b7db..000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: "CodeQL analysis" - -on: - push: - branches: - - develop - pull_request: - branches: - - develop - schedule: - - cron: '0 21 * * *' - -jobs: - analyze: - name: Analyze JS and Ruby - runs-on: ubuntu-latest - # Apply principle of least privilege to GITHUB_TOKEN - permissions: - actions: read - contents: read - security-events: write - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: javascript, ruby - - # Actually perform an analysis - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 diff --git a/.gitignore b/.gitignore index dd00b8367..aa5f3201a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,10 @@ docker/mysql # allow custom docker-compose files as users might have different needs docker-compose*.yml !docker-compose.yml +# Ignore Dockerfiles in project root +Dockerfile +Dockerfile.* +*.Dockerfile # Don't track changes to the docker-compose .env file only in project root /.env @@ -38,10 +42,13 @@ test/reports TODO.md +# configuration files config/storage.yml config/email.yml config/database.yml +storage + deploy # Ignore master key for decrypting credentials and more. diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..cad57977c --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +save-exact = true \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml index edf12b0c0..7c85865b5 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -4,13 +4,14 @@ require: - ./lib/rubocop/path_in_helpers.rb AllCops: - TargetRubyVersion: 2.7 + TargetRubyVersion: 3.1 Exclude: - 'config/**/*' - 'db/**/*' - 'scripts/**/*' - 'bin/**/*' - 'lib/namespaced_env_cache.rb' + - 'vendor/bundle/**/*' NewCops: enable SuggestExtensions: false @@ -23,7 +24,7 @@ Layout/LineLength: Layout/SpaceAroundMethodCallOperator: Enabled: true Layout/SpaceInsideArrayLiteralBrackets: - Enabled: false + Enabled: true Lint/DeprecatedOpenSSLConstant: Enabled: true @@ -53,6 +54,16 @@ Metrics/ModuleLength: Metrics/PerceivedComplexity: Enabled: false +Naming/BlockForwarding: + EnforcedStyle: explicit +Naming/PredicateMethod: + Mode: conservative + AllowedPatterns: ['verify_.*', 'check_.*', 'enforce_.*', 'setup_.*'] + AllowedMethods: + - post_sign_in # returns a boolean but is a necessary after-sign-in method for both normal and SAML flows + - comment_rate_limited? # returns a tuple with the boolean as the first value + AllowBangMethods: true + Style/ClassAndModuleChildren: Enabled: false Style/ClassVars: @@ -72,6 +83,9 @@ Style/GuardClause: Enabled: false Style/HashEachMethods: Enabled: true +Style/HashSyntax: + EnforcedStyle: ruby19_no_mixed_keys + EnforcedShorthandSyntax: never Style/HashTransformKeys: Enabled: true Style/HashTransformValues: diff --git a/CODE-STANDARDS.md b/CODE-STANDARDS.md index 35e2888a9..d22162b29 100644 --- a/CODE-STANDARDS.md +++ b/CODE-STANDARDS.md @@ -2,8 +2,9 @@ ## Ruby -There is a .rubocop.yml file provided in the project and rubocop is included in the bundle; please run -`bundle exec rubocop` for Ruby style checking. +For Ruby style guidance see the [style guide](https://codidact.atlassian.net/wiki/spaces/OPS/pages/766803969/Style+guide+Ruby) +in Confluence (or [public mirror](https://github.com/codidact/docs/blob/master/Developer-Docs/code-style-guides.pdf) in +our docs repository). ## CSS @@ -205,213 +206,9 @@ allowed where transparency is a requirement. ``` ## HTML -All HTML contributions MUST adhere to this document unless there's a good reason not to; such reasons MUST have a -linter ignore applied to them, and SHOULD be documented using a comment above the relevant code. - -### Document - -#### HTML Version & Doctype -Use HTML5. Always declare the correct doctype as the very first element in the document. Doctype declarations MUST -be written in `UPPERCASE`. - -```html - -``` - -#### Language -Always declare the correct ISO 639-1 language (and reading direction, if applicable) for the document. Standardize -on `en-US` for documents in English. - -```html - ... -``` - -#### Casing -Write all tags and attributes in `lowercase`. Write attribute values in `lowercase` where possible. - -#### Quotes -Use double quotes around attribute values in all cases, and use the HTML entity for quotes (`"`) when the -attribute value itself contains a double quote. - -```html - -``` - -#### Void elements -Always close all void elements. While this is not required by HTML5, it is *allowed*, and helps avoid confusion -from programmers who are less used to the rule. Additionally, it might be beneficial to anyone using an XML parser -on our page contents (not that we *recommend* it). - -A space MUST appear between the last character in the HTML tag and the trailing slash. - -```html - -
-... - - -
-... -``` - -#### External resources -When referencing external resources (including those local to the domain), do not omit the protocol. Always use -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.org](https://apitester.org/app) to be sure. - -```html - - -``` - -#### Indentation -Use four spaces per level of indentation. Do not use tab stops. - -```html - - - -``` - -On element declarations whose attributes span over more than one line, align subsequent lines with the first -attribute on the first line. - -```html - -``` - -#### Semantic elements -Prefer semantic elements where possible: use `
` over `
`, or `
` over `

- CircleCI Build Status + Pipeline status Coverage Status DOI

diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 8d8b426aa..4eb8247fb 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -37,36 +37,6 @@ document.addEventListener('DOMContentLoaded', async () => { dialog.classList.toggle('is-active'); }); - QPixel.DOM.addSelectorListener('click', '.flag-resolve', async (ev) => { - ev.preventDefault(); - const tgt = /** @type {HTMLElement} */(ev.target); - const id = tgt.dataset.flagId; - const data = { - result: tgt.dataset.result, - message: tgt.parentNode.parentNode.querySelector('.flag-resolve-comment').value - }; - - const req = await fetch(`/mod/flags/${id}/resolve`, { - method: 'POST', - body: JSON.stringify(data), - credentials: 'include', - headers: { 'X-CSRF-Token': QPixel.csrfToken() } - }); - if (req.status === 200) { - const res = await req.json(); - if (res.status === 'success') { - const flagContainer = /** @type {HTMLElement} */(tgt.parentNode.parentNode.parentNode); - QPixel.DOM.fadeOut(flagContainer, 200); - } - else { - QPixel.createNotification('danger', `Failed: ${res.message}`); - } - } - else { - QPixel.createNotification('danger', `Failed: Unexpected status (${req.status})`); - } - }); - if (document.cookie.indexOf('dismiss_fvn') === -1) { QPixel.DOM.addSelectorListener('click', '#fvn-dismiss', (_ev) => { document.cookie = 'dismiss_fvn=true; path=/; expires=Fri, 31 Dec 9999 23:59:59 GMT'; diff --git a/app/assets/javascripts/caret.js b/app/assets/javascripts/caret.js index d3c85e11f..ea742bdfc 100644 --- a/app/assets/javascripts/caret.js +++ b/app/assets/javascripts/caret.js @@ -68,7 +68,22 @@ var isBrowser = (typeof window !== 'undefined'); var isFirefox = (isBrowser && window.mozInnerScreenX != null); + + /** + * @typedef CaretLocation + * @type {Object} + * @property {Number} top The distance in pixels from the top of the page. + * @property {Number} left The distance in pixels from the left of the page. + * @property {Number} height The height of the caret in pixels. + */ + /** + * Get the exact screen coordinates of the text caret within a text input. + * @param {HTMLInputElement | HTMLTextAreaElement} element The input field in which the caret is located. + * @param {Number} position The position of the caret within the field - usually via #selectionStart. + * @param {Object} options An options object. The only supported key is `debug`. + * @returns {CaretLocation} The location of the caret. + */ function getCaretCoordinates(element, position, options) { if (!isBrowser) { throw new Error('textarea-caret-position#getCaretCoordinates should only be called in a browser'); @@ -86,7 +101,7 @@ document.body.appendChild(div); var style = div.style; - var computed = window.getComputedStyle ? window.getComputedStyle(element) : element.currentStyle; // currentStyle for IE < 9 + var computed = window.getComputedStyle(element); var isInput = element.nodeName === 'INPUT'; // Default textarea styles diff --git a/app/assets/javascripts/categories.js b/app/assets/javascripts/categories.js index 30d61713c..2f98d45f8 100644 --- a/app/assets/javascripts/categories.js +++ b/app/assets/javascripts/categories.js @@ -10,7 +10,7 @@ $(() => { $caption.find('[data-state="absent"]').hide(); $caption.find('[data-state="present"]').show(); - $el.find('.js-tag-select').attr('data-tag-set', tagSetId).attr('disabled', false); + $el.find('.js-tag-select').attr('data-tag-set', tagSetId).attr('disabled', null); }); } else { @@ -20,7 +20,7 @@ $(() => { $caption.find('[data-state="absent"]').show(); $caption.find('[data-state="present"]').hide(); - $el.find('.js-tag-select').attr('data-tag-set', null).attr('disabled', true); + $el.find('.js-tag-select').attr('data-tag-set', null).attr('disabled', 'true'); }); } }); @@ -28,7 +28,7 @@ $(() => { $('.js-add-required-topic').on('click', (_ev) => { const $required = $('.js-required-tags'); const $topic = $('.js-topic-tags'); - const union = ($required.val() || []).concat($topic.val() || []); + const union = /** @type {string[]} */($required.val() || []).concat(/** @type {string[]} */ ($topic.val() || [])); const options = $topic.find('option').toArray(); const optionIds = options.map((x) => $(x).attr('value')); @@ -70,15 +70,9 @@ $(() => { const upvoteRep = parseInt($widget.find('.js-cpt-upvote-rep').val()?.toString(), 10) || 0; const downvoteRep = parseInt($widget.find('.js-cpt-downvote-rep').val()?.toString(), 10) || 0; - const resp = await fetch(`/categories/${categoryId}/edit/post-types`, { - method: 'POST', - credentials: 'include', - body: JSON.stringify({ post_type: postTypeId, upvote_rep: upvoteRep, downvote_rep: downvoteRep }), - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': QPixel.csrfToken() - } - }); + const resp = await QPixel.fetchJSON(`/categories/${categoryId}/edit/post-types`, + { post_type: postTypeId, upvote_rep: upvoteRep, downvote_rep: downvoteRep }); + const data = await resp.json(); const status = resp.status; @@ -95,21 +89,19 @@ $(() => { }); $(document).on('click', '.js-delete-cpt', async (ev) => { - const $tgt = $(ev.target); - const categoryId = $tgt.attr('data-category'); - const postTypeId = $tgt.attr('data-post-type'); + const tgt = ev.target; + const categoryId = tgt.dataset.category; + const postTypeId = tgt.dataset.postType; - await fetch(`/categories/${categoryId}/edit/post-types`, { - method: 'DELETE', - credentials: 'include', - body: JSON.stringify({ post_type: postTypeId }), - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': QPixel.csrfToken() - } - }); - $tgt.parents('.widget').fadeOut(200, function () { - $(this).remove(); + const resp = await QPixel.fetchJSON(`/categories/${categoryId}/edit/post-types`, { post_type: postTypeId }, { + method: 'DELETE' }); + + if (resp.status === 200) { + QPixel.DOM.fadeOut(tgt.closest('.widget'), 200); + } + else { + QPixel.createNotification('danger', `Unexpected status: ${resp.status}. Tell a developer.`); + } }); -}); \ No newline at end of file +}); diff --git a/app/assets/javascripts/character_count.js b/app/assets/javascripts/character_count.js index 024a1b395..a0f856ea6 100644 --- a/app/assets/javascripts/character_count.js +++ b/app/assets/javascripts/character_count.js @@ -52,13 +52,14 @@ $(() => { */ const setSubmitButtonDisabledState = (el, state) => { const isDisabled = state === 'disabled'; - el.attr('disabled', isDisabled).toggleClass('is-muted', isDisabled); + el.attr('disabled', isDisabled ? 'true' : null).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"],.js-suggested-edit-approve'); + const $form = $counter.parents('form'); + const $button = $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'); @@ -76,19 +77,24 @@ $(() => { 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'); } + const submittable = $form[0]?.checkValidity() ?? false; + + if (!submittable || gtnMax || ltnMin) { + setSubmitButtonDisabledState($button, 'disabled'); + } else { + setSubmitButtonDisabledState($button, 'enabled'); + } + $count.text(text); }); diff --git a/app/assets/javascripts/closure.js b/app/assets/javascripts/closure.js index 207c6f0fe..e8e66926d 100644 --- a/app/assets/javascripts/closure.js +++ b/app/assets/javascripts/closure.js @@ -3,6 +3,7 @@ document.addEventListener('DOMContentLoaded', async () => { ev.preventDefault(); const self = /** @type {HTMLElement} */(ev.target); + /** @type {HTMLInputElement} */ const activeRadio = self.closest('.js-close-box').querySelector("input[type='radio'][name='close-reason']:checked"); if (!activeRadio) { @@ -10,6 +11,7 @@ document.addEventListener('DOMContentLoaded', async () => { return; } + /** @type {HTMLInputElement} */ const otherPostInput = activeRadio.closest('.widget--body').querySelector('.js-close-other-post'); const otherPostRequired = activeRadio.dataset.rop === 'true'; const data = { @@ -27,15 +29,8 @@ document.addEventListener('DOMContentLoaded', async () => { return; } - const req = await fetch(`/posts/${self.dataset.postId}/close`, { - method: 'POST', - body: JSON.stringify(data), - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': QPixel.csrfToken() - } - }); + const req = await QPixel.fetchJSON(`/posts/${self.dataset.postId}/close`, data); + if (req.status === 200) { const res = await req.json(); if (res.status === 'success') { diff --git a/app/assets/javascripts/comments.js b/app/assets/javascripts/comments.js index 4e9099da1..f582d4a17 100644 --- a/app/assets/javascripts/comments.js +++ b/app/assets/javascripts/comments.js @@ -1,54 +1,70 @@ $(() => { - $('.js-more-comments').on('click', async (evt) => { + $(document).on('click', '.js-more-comments', async (evt) => { evt.preventDefault(); const $tgt = $(evt.target); const $anchor = $tgt.is('a') ? $tgt : $tgt.parents('a'); const postId = $anchor.attr('data-post-id'); - const resp = await fetch(`/comments/post/${postId}`, { - headers: { 'Accept': 'text/html' } - }); - const data = await resp.text(); + const data = await QPixel.getThreadsListContent(postId); + $tgt.parents('.post--comments').find('.post--comments-container').html(data).trigger('ajax:success'); $tgt.parents('.post--comments').find('.js-more-comments').remove(); }); + /** + * @param {JQuery} $tgt + * @returns {HTMLElement | null} + */ + const getCommentThreadWrapper = ($tgt) => { + return $tgt.closest('.js-comment-thread-wrapper')[0] ?? null; + }; + $(document).on('click', '.post--comments-thread.is-inline a', async (evt) => { if (evt.ctrlKey) { return; } evt.preventDefault(); const $tgt = $(evt.target); - openThread($tgt.closest('.post--comments-thread-wrapper')[0], $tgt.attr("href")); - }); + const $threadId = $tgt.data('thread'); + const wrapper = getCommentThreadWrapper($tgt); - async function openThread(wrapper, targetUrl, showDeleted = false) { - const resp = await fetch(`${targetUrl}?inline=true&show_deleted_comments=${showDeleted ? 1 : 0}`, { - headers: { 'Accept': 'text/html' } - }); - let data = await resp.text(); + openThread(wrapper, $threadId); + }); - data = data.split("")[1]; - data = data.split("")[0]; + /** + * @param {HTMLElement} wrapper + * @param {string} threadId + * @param {GetThreadContentOptions} [options] + */ + async function openThread(wrapper, threadId, options) { + const data = await QPixel.getThreadContent(threadId, options); wrapper.innerHTML = data; - $('a.show-deleted-comments').click(async (evt) => { - if (evt.ctrlKey) { return; } - evt.preventDefault(); - openThread(wrapper, targetUrl, true); - }); - window.MathJax && MathJax.typeset(); window.hljs && hljs.highlightAll(); } + $(document).on('click', '.js-show-deleted-comments', (ev) => { + if (ev.ctrlKey) { return; } // do we really need it? + + ev.preventDefault(); + + const $tgt = $(ev.target); + const $inline = $tgt.data('inline'); + const $threadId = $tgt.data('thread'); + const wrapper = getCommentThreadWrapper($tgt); + + openThread(wrapper, $threadId, { inline: $inline, showDeleted: true }); + }); + $(document).on('click', '.js-collapse-thread', async (ev) => { const $tgt = $(ev.target); const $widget = $tgt.parents('.widget'); const $embed = $tgt.parents('.post--comments-thread'); const threadId = $widget.data('thread'); + const isLocked = $widget.data('locked'); const isDeleted = $widget.data('deleted'); const isArchived = $widget.data('archived'); const threadTitle = $widget.find('.js-thread-title').text(); @@ -58,14 +74,21 @@ $(() => { const $link = $(``); $link.text(threadTitle); + if (isLocked) { + $container.append(``); + $container.addClass('is-locked'); + } + if (isDeleted) { $container.append(``); $container.addClass('is-deleted'); } + if (isArchived) { $container.append(``); $container.addClass('is-archived'); } + $container.append($link); $container.append(`(${replyCount} comment${replyCount !== 1 ? 's' : ''})`); $embed[0].outerHTML = $container[0].outerHTML; @@ -78,35 +101,48 @@ $(() => { 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'); + + // if this matches, this means we are already in edit mode + if ($(`.js-discard-edit[data-comment-id="${commentId}"]`).length) { + return; + } + const originalComment = $commentBody.clone(); - const resp = await fetch(`/comments/${commentId}`, { - credentials: 'include', - headers: { 'Accept': 'application/json' } - }); - const data = await resp.json(); - const content = data.content; + const data = await QPixel.getComment(commentId); const formTemplate = `
- + - + - ${content.length} / 1000 + ${data.content.length} / 1000
`; $commentBody.html(formTemplate); + $commentBody.find('textarea#comment-content').trigger('focus'); $commentBody.find(`#comment-content`).on('keyup', pingable_popup); - $(`.js-discard-edit[data-comment-id="${commentId}"]`).click(() => { + $(`.js-discard-edit[data-comment-id="${commentId}"]`).on('click', () => { $commentBody.html(originalComment.html()); }); }); @@ -115,13 +151,10 @@ $(() => { const $tgt = $(evt.target); const $comment = $tgt.parents('.comment'); - if (data.status === 'success') { + QPixel.handleJSONResponse(data, (data) => { const newComment = $(data.comment); $comment.html(newComment[0].innerHTML); - } - else { - QPixel.createNotification('danger', data.message); - } + }); }); $(document).on('click', '.js-comment-delete, .js-comment-undelete', async (evt) => { @@ -132,26 +165,18 @@ $(() => { const commentId = $comment.attr('data-id'); const isDelete = !$comment.hasClass('deleted-content'); - const resp = await fetch(`/comments/${commentId}/delete`, { - method: isDelete ? 'DELETE' : 'PATCH', - credentials: 'include', - headers: { 'X-CSRF-Token': QPixel.csrfToken() } - }); - const data = await resp.json(); + const data = await (isDelete ? QPixel.deleteComment(commentId) : QPixel.undeleteComment(commentId)); - if (data.status === 'success') { + QPixel.handleJSONResponse(data, () => { if (isDelete) { $comment.addClass('deleted-content'); - $tgt.removeClass('js-comment-delete').addClass('js-comment-undelete').text('undelete'); + $tgt.removeClass('js-comment-delete').addClass('js-comment-undelete').val('undelete'); } else { $comment.removeClass('deleted-content'); - $tgt.removeClass('js-comment-undelete').addClass('js-comment-delete').text('delete'); + $tgt.removeClass('js-comment-undelete').addClass('js-comment-delete').val('delete'); } - } - else { - QPixel.createNotification('danger', data.message); - } + }); }); $(document).on('click', '.js--show-followers', async (evt) => { @@ -166,32 +191,40 @@ $(() => { credentials: 'include', headers: { 'Accept': 'text/html' } }); + const data = await resp.text(); + $modal.find('.js-follower-display').html(data); }); + $(document).on('click', '[class*=js--lock-thread] form', async (evt) => { + evt.preventDefault(); + + const $tgt = $(evt.target); + const threadID = $tgt.data("thread"); + + const data = await QPixel.lockThread(threadID); + + QPixel.handleJSONResponse(data, () => { + window.location.reload(); + }); + }); + $(document).on('click', '.js--restrict-thread, .js--unrestrict-thread', async (evt) => { evt.preventDefault(); const $tgt = $(evt.target); - const threadID = $tgt.data("thread") - const action = $tgt.data("action") + const threadID = $tgt.data("thread"); + const action = $tgt.data("action"); const route = $tgt.hasClass("js--restrict-thread") ? 'restrict' : 'unrestrict'; - const resp = await fetch(`/comments/thread/${threadID}/${route}`, { - method: 'POST', - credentials: 'include', - headers: { 'X-CSRF-Token': QPixel.csrfToken(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ type: action }) - }); + const resp = await QPixel.fetchJSON(`/comments/thread/${threadID}/${route}`, { type: action }); + const data = await resp.json(); - if (data.status === 'success') { + QPixel.handleJSONResponse(data, () => { window.location.reload(); - } - else { - QPixel.createNotification('danger', data.message); - } + }); }); $(document).on('click', '.comment-form input[type="submit"]', async (evt) => { @@ -199,9 +232,15 @@ $(() => { $(evt.target).attr('data-disable-with', 'Posting...'); }); + /** + * @type {Record<`${number}-${number}`, Record>} + */ const pingable = {}; $(document).on('keyup', '.js-comment-field', pingable_popup); + /** + * @type {QPixelPingablePopupCallback} + */ async function pingable_popup(ev) { if (QPixel.Popup.isSpecialKey(ev.keyCode)) { return; @@ -214,6 +253,10 @@ $(() => { const [currentWord, posInWord] = QPixel.currentCaretSequence(splat, caretPos); const itemTemplate = $(''); + + /** + * @type {QPixelPopupCallback} + */ const callback = (ev, popup) => { const $item = $(ev.target).hasClass('item') ? $(ev.target) : $(ev.target).parents('.item'); const id = $item.data('user-id'); @@ -251,7 +294,7 @@ $(() => { } } - $('.js-new-thread-link').on('click', async (ev) => { + $(document).on('click', '.js-new-thread-link', async (ev) => { ev.preventDefault(); const $tgt = $(ev.target); const postId = $tgt.attr('data-post'); @@ -259,12 +302,28 @@ $(() => { if ($thread.is(':hidden')) { $thread.show(); + $thread.find('.js-comment-field').trigger('focus'); } else { $thread.hide(); } }); + $(document).on('click', '.js-reply-to-thread-link', async (ev) => { + ev.preventDefault(); + const $tgt = $(ev.target); + const postId = $tgt.attr('data-post'); + const $reply = $(`#reply-to-thread-form-${postId}`); + + if ($reply.is(':hidden')) { + $reply.show(); + $reply.find('.js-comment-field').trigger('focus'); + } + else { + $reply.hide(); + } + }); + $('.js-comment-permalink > .js-text').text('copy link'); $(document).on('click', '.js-comment-permalink', (ev) => { ev.preventDefault(); diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index 4b91d07aa..7312bbe89 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -1,5 +1,5 @@ $(() => { - $('.js-filter-select').each(async (_, el) => { + $('.js-filter-select').toArray().forEach(async (el) => { const $select = $(el); const $form = $select.closest('form'); const $formFilters = $form.find('.form--filter'); @@ -35,7 +35,7 @@ $(() => { const hasChanges = [...$formFilters].some((el) => { const filterValue = filter[el.dataset.name]; - let elValue = $(el).val(); + let elValue = /** @type {string | undefined[]} */ ($(el).val()); if (filterValue?.constructor == Array) { elValue = elValue ?? []; return filterValue.length != elValue.length || filterValue.some((v, i) => v[1] != elValue[i]); @@ -68,14 +68,23 @@ $(() => { } // Clear out any old options - $select.children().filter((_, option) => option.value && !filters[option.value]).detach(); + $select.children().filter((_, /** @type{HTMLOptionElement} */ option) => { + return option.value && !filters[option.value]; + }).detach(); + $select.select2({ - data: Object.keys(filters), + data: Object.keys(filters).map((filterName) => { + return { + id: filterName, + text: filterName + } + }), tags: true, - templateResult: template, templateSelection: template - }).on('select2:select', async (evt) => { + }); + + $select.on('select2:select', /** @type {(event: Select2.Event) => void} */ (async (evt) => { const filterName = evt.params.data.id; const preset = filters[filterName]; @@ -92,14 +101,15 @@ $(() => { if (value?.constructor == Array) { $el.val(null); for (const val of value) { - $el.append(new Option(val[0], val[1], false, true)); + $el.append(new Option(val[0], val[1].toString(), false, true)); } $el.trigger('change'); - } else { - $el.val(value).trigger('change'); + } + else { + $el.val(/** @type {string} */ (value)).trigger('change'); } } - }); + })); computeEnables(); } @@ -145,4 +155,4 @@ $(() => { $form.find('.filter-clear').on('click', clear); }); -}); \ No newline at end of file +}); diff --git a/app/assets/javascripts/flags.js b/app/assets/javascripts/flags.js index 08c2a69a8..9d308cef2 100644 --- a/app/assets/javascripts/flags.js +++ b/app/assets/javascripts/flags.js @@ -85,15 +85,9 @@ $(() => { const $comment = $('.js-escalation-comment'); const flagId = $modal.data('flag'); const comment = $comment.val(); - const resp = await fetch(`/mod/flags/${flagId}/escalate`, { - method: 'POST', - credentials: 'include', - headers: { - 'X-CSRF-Token': QPixel.csrfToken(), - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ comment }) - }); + + const resp = await QPixel.fetchJSON(`/mod/flags/${flagId}/escalate`, { comment }); + if (resp.status === 200) { QPixel.createNotification('success', 'This flag has been escalated for admin review.'); $modal.toggleClass('is-active'); diff --git a/app/assets/javascripts/licenses.js b/app/assets/javascripts/licenses.js index 473dfa116..d63a41977 100644 --- a/app/assets/javascripts/licenses.js +++ b/app/assets/javascripts/licenses.js @@ -14,6 +14,10 @@ $(() => { }); $('.js-license-select').select2({ + /** + * @param {Select2.DataFormat & { element?: HTMLOptionElement }} option + * @returns {string | JQuery} + */ templateResult: (option) => { if (option.element) { const title = $(option.element).attr('data-title'); @@ -31,4 +35,4 @@ $(() => { } } }); -}); \ No newline at end of file +}); diff --git a/app/assets/javascripts/markdown.js b/app/assets/javascripts/markdown.js index dfaf00fd3..46e2b19e1 100644 --- a/app/assets/javascripts/markdown.js +++ b/app/assets/javascripts/markdown.js @@ -19,6 +19,8 @@ $(() => { const $tgt = $(ev.target); const $button = $tgt.is('a') ? $tgt : $tgt.parents('a'); const action = $button.attr('data-action'); + + /** @type {JQuery} */ const $field = $('.js-post-field'); const actions = { @@ -92,6 +94,8 @@ $(() => { const $url = $('#markdown-link-url'); const url = $url.val(); const markdown = `[${text}](${url})`; + + /** @type {JQuery} */ const $field = $('.js-post-field'); if ($field[0].selectionStart != null && $field[0].selectionStart !== $field[0].selectionEnd) { @@ -108,7 +112,9 @@ $(() => { }); $(document).on('click', '[data-modal="#markdown-link-insert"]', (_ev) => { + /** @type {JQuery} */ const $field = $('.js-post-field'); + const selection = $field.val().substring($field[0].selectionStart, $field[0].selectionEnd); if (selection) { $('#markdown-link-name').val(selection); diff --git a/app/assets/javascripts/micro_auth/apps.js b/app/assets/javascripts/micro_auth/apps.js index 9b22ebade..bf63bd6ce 100644 --- a/app/assets/javascripts/micro_auth/apps.js +++ b/app/assets/javascripts/micro_auth/apps.js @@ -1,7 +1,7 @@ document.addEventListener('DOMContentLoaded', () => { QPixel.DOM.addSelectorListener('click', '.js-copy-key', (ev) => { - const label = ev.target.closest('label'); - const field = document.querySelector(`#${label.getAttribute('for')}`); + const label = /** @type {HTMLElement} */ (ev.target).closest('label'); + const field = /** @type {HTMLInputElement} */ (document.querySelector(`#${label.getAttribute('for')}`)); navigator.clipboard.writeText(field.value); field.focus(); field.setSelectionRange(0, field.value.length); diff --git a/app/assets/javascripts/mod_warning.js b/app/assets/javascripts/mod_warning.js index 365bcdeb0..0f586c6c9 100644 --- a/app/assets/javascripts/mod_warning.js +++ b/app/assets/javascripts/mod_warning.js @@ -1,6 +1,7 @@ document.addEventListener('DOMContentLoaded', () => { QPixel.DOM.addSelectorListener('input', '.js--warning-template-selection', (ev) => { - const tgt = ev.target; - document.querySelector('.js--warning-template-target textarea').value = atob(tgt.value); + const tgt = /** @type {HTMLInputElement} */ (ev.target); + const input = /** @type {HTMLInputElement} */ (document.querySelector('.js--warning-template-target textarea')); + input.value = atob(tgt.value); }); }); diff --git a/app/assets/javascripts/moderator.js b/app/assets/javascripts/moderator.js index f20984e42..0b955eba5 100644 --- a/app/assets/javascripts/moderator.js +++ b/app/assets/javascripts/moderator.js @@ -9,4 +9,33 @@ $(() => { $(this).remove(); }); }); -}); \ No newline at end of file + + QPixel.DOM.addSelectorListener('click', '.flag-resolve', async (ev) => { + ev.preventDefault(); + const tgt = /** @type {HTMLElement} */(ev.target); + const id = tgt.dataset.flagId; + + const resolveCommentElem = tgt.parentNode?.parentNode?.querySelector('.flag-resolve-comment'); + + const data = { + result: tgt.dataset.result, + message: resolveCommentElem instanceof HTMLTextAreaElement ? resolveCommentElem.value : '' + }; + + const req = await QPixel.fetchJSON(`/mod/flags/${id}/resolve`, data); + + if (req.status === 200) { + const res = await req.json(); + if (res.status === 'success') { + const flagContainer = /** @type {HTMLElement} */(tgt.parentNode.parentNode.parentNode); + QPixel.DOM.fadeOut(flagContainer, 200); + } + else { + QPixel.createNotification('danger', `Failed: ${res.message}`); + } + } + else { + QPixel.createNotification('danger', `Failed: Unexpected status (${req.status})`); + } + }); +}); diff --git a/app/assets/javascripts/notifications.js b/app/assets/javascripts/notifications.js index bb9205e0e..df8258180 100644 --- a/app/assets/javascripts/notifications.js +++ b/app/assets/javascripts/notifications.js @@ -50,6 +50,7 @@ $(() => { credentials: 'include', headers: { 'Accept': 'application/json' } }); + const data = await resp.json(); const $inboxContainer = $inbox.find(".inbox--container"); $inboxContainer.html(''); @@ -73,10 +74,8 @@ $(() => { $('.js-read-all-notifs').on('click', async (ev) => { ev.preventDefault(); - await fetch(`/notifications/read_all`, { - method: 'POST', - credentials: 'include', - headers: { 'Accept': 'application/json', 'X-CSRF-Token': QPixel.csrfToken() } + await QPixel.fetchJSON('/notifications/read_all', {}, { + headers: { 'Accept': 'application/json' } }); $('.inbox-count').remove(); @@ -89,11 +88,11 @@ $(() => { $(document).on('click', '.inbox a:not(.no-unread):not(.read):not(.js-notification-toggle)', async (evt) => { const $tgt = $(evt.target); const id = $tgt.data('id'); - const resp = await fetch(`/notifications/${id}/read`, { - method: 'POST', - credentials: 'include', - headers: { 'Accept': 'application/json', 'X-CSRF-Token': QPixel.csrfToken() } + + const resp = await QPixel.fetchJSON(`/notifications/${id}/read`, {}, { + headers: { 'Accept': 'application/json' } }); + const data = await resp.json(); $tgt.parents('.js-notification')[0].outerHTML = makeNotification(data.notification); changeInboxCount(-1); @@ -104,11 +103,11 @@ $(() => { const $tgt = $(ev.target).is('a') ? $(ev.target) : $(ev.target).parents('a'); const id = $tgt.attr('data-notif-id'); - const resp = await fetch(`/notifications/${id}/read`, { - method: 'POST', - credentials: 'include', - headers: { 'Accept': 'application/json', 'X-CSRF-Token': QPixel.csrfToken() } + + const resp = await QPixel.fetchJSON(`/notifications/${id}/read`, {}, { + headers: { 'Accept': 'application/json' } }); + const data = await resp.json(); if (data.status !== 'success') { console.error('Failed to toggle notification read state. Wat?'); @@ -124,4 +123,4 @@ $(() => { $(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/posts.js b/app/assets/javascripts/posts.js index c655d86ec..3d98dc2d2 100644 --- a/app/assets/javascripts/posts.js +++ b/app/assets/javascripts/posts.js @@ -21,22 +21,31 @@ $(() => { } }); + const $postFields = $('.post-field'); + + /** @type {JQuery} */ const $uploadForm = $('.js-upload-form'); + /** + * Inserts text at a given {@link idx} in a given {@link str} + * @param {string} str text to insert into + * @param {number} idx position to insert at + * @param {string} insert text to insert + * @returns {string} + */ 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) => { - const $postField = $('.js-post-field'); - const postText = $postField.val(); - const cursorPos = $postField[0].selectionStart; + /** @type {HTMLInputElement} */ + const postField = document.querySelector('.js-post-field'); + const postText = postField.value; + const cursorPos = postField.selectionStart; - $postField.val(stringInsert(postText, cursorPos, placeholder)); + postField.value = stringInsert(postText, cursorPos, placeholder); - const $tgt = $(evt.target); - const $form = $tgt.parents('form'); - $form.submit(); + $uploadForm.trigger('submit') }); $uploadForm.on('submit', async (evt) => { @@ -45,14 +54,24 @@ $(() => { const $tgt = $(evt.target); const $fileInput = $tgt.find('input[type="file"]'); - const files = $fileInput[0].files; + const files = /** @type {HTMLInputElement} */ ($fileInput[0]).files; + + // TODO: MaxUploadSize is a site setting and can be changed if (files.length > 0 && files[0].size >= 2000000) { - $tgt.find('.js-max-size').addClass('has-color-red-700 error-shake'); + const isUploadModalOpened = $('#markdown-image-upload').hasClass('is-active'); + const postField = $('.js-post-field'); postField.val(postField.val()?.toString().replace(placeholder, '')); - setTimeout(() => { - $tgt.find('.js-max-size').removeClass('error-shake'); - }, 1000); + + if (!isUploadModalOpened) { + QPixel.createNotification('danger', `Can't upload files with size more than 2MB`); + } else { + $tgt.find('.js-max-size').addClass('has-color-red-700 error-shake'); + setTimeout(() => { + $tgt.find('.js-max-size').removeClass('error-shake'); + }, 1000); + } + return; } else { @@ -61,9 +80,11 @@ $(() => { const resp = await fetch($tgt.attr('action'), { method: $tgt.attr('method'), - body: new FormData($tgt[0]) + body: new FormData(/** @type {HTMLFormElement} */ ($tgt[0])) }); + const data = await resp.json(); + if (resp.status === 200) { $tgt.trigger('ajax:success', data); } @@ -74,12 +95,14 @@ $(() => { $uploadForm.on('ajax:success', async (evt, data) => { const $tgt = $(evt.target); - $tgt[0].reset(); + /** @type {HTMLFormElement} */ ($tgt[0]).reset(); const $postField = $('.js-post-field'); const postText = $postField.val()?.toString(); $postField.val(postText.replace(placeholder, `![Image_alt_text](${data.link})`)); $tgt.parents('.modal').removeClass('is-active'); + + $postFields.trigger('change') }); $uploadForm.on('ajax:failure', async (evt, data) => { @@ -118,15 +141,7 @@ $(() => { return; } - const resp = await fetch('/posts/save-draft', { - method: 'POST', - credentials: 'include', - headers: { - 'X-CSRF-Token': QPixel.csrfToken(), - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ ...draft, path: location.pathname }) - }); + const resp = await QPixel.fetchJSON('/posts/save-draft', { ...draft, path: location.pathname }); if (resp.status === 200) { const $statusEl = $field.parents('.widget').find('.js-post-draft-status'); @@ -187,8 +202,6 @@ $(() => { let featureTimeout = null; let draftTimeout = null; - const postFields = $('.post-field'); - const draftFieldsSelectors = [ '.js-post-field', '.js-license-select', @@ -209,15 +222,20 @@ $(() => { }, 1000); }); - postFields.on('paste', async (evt) => { - if (evt.originalEvent.clipboardData.files.length > 0) { + $postFields.on('paste', async (evt) => { + const eventData = /** @type {ClipboardEvent} */ (evt.originalEvent); + if (eventData.clipboardData.files.length > 0) { + // must be called to prevent raw file name to be inserted after the placeholder + evt.preventDefault() + + /** @type {JQuery} */ const $fileInput = $uploadForm.find('input[type="file"]'); - $fileInput[0].files = evt.originalEvent.clipboardData.files; + $fileInput[0].files = eventData.clipboardData.files; $fileInput.trigger('change'); } }); - postFields.on('focus keyup paste change markdown', (() => { + $postFields.on('focus keyup paste change markdown', (() => { let previous = null; return (evt) => { const $tgt = $(evt.target); @@ -281,7 +299,7 @@ $(() => { }; })()).trigger('markdown'); - postFields.parents('form').on('submit', async (ev) => { + $postFields.parents('form').on('submit', async (ev) => { const $tgt = $(ev.target); const field = $tgt.find('.post-field'); @@ -295,15 +313,7 @@ $(() => { // Draft handling if (!draftDeleted) { - const resp = await fetch('/posts/delete-draft', { - method: 'POST', - credentials: 'include', - headers: { - 'X-CSRF-Token': QPixel.csrfToken(), - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ path: location.pathname }) - }); + const resp = await QPixel.fetchJSON('/posts/delete-draft', { path: location.pathname }); if (resp.status === 200) { $tgt.attr('data-draft-deleted', 'true'); @@ -355,7 +365,7 @@ $(() => { } setTimeout(() => { - $tgt.find('input[type="submit"]').attr('disabled', false); + $tgt.find('input[type="submit"]').attr('disabled', null); }, 1000); } }); @@ -425,14 +435,11 @@ $(() => { const $tgt = $(ev.target); const postId = $tgt.attr('data-post-id'); - const resp = await fetch(`/posts/${postId}/promote`, { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'X-CSRF-Token': QPixel.csrfToken() - } - }); + + const resp = await QPixel.fetchJSON(`/posts/${postId}/promote`, {}); + const data = await resp.json(); + if (data.success) { QPixel.createNotification('success', 'Added post to promotion list.'); } @@ -451,15 +458,7 @@ $(() => { return; } - await fetch('/posts/delete-draft', { - method: 'POST', - credentials: 'include', - headers: { - 'X-CSRF-Token': QPixel.csrfToken(), - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ path: location.pathname }) - }); + await QPixel.fetchJSON('/posts/delete-draft', { path: location.pathname }); location.href = $btn.attr('href'); }); diff --git a/app/assets/javascripts/preferences.js b/app/assets/javascripts/preferences.js index 8f314c5d2..25395d7da 100644 --- a/app/assets/javascripts/preferences.js +++ b/app/assets/javascripts/preferences.js @@ -13,7 +13,8 @@ $(() => { await QPixel.setPreference(prefName, value, community); }); - $('.item-list--item').find('.badge.is-tag').each(async (_i, e) => { + // We're not using jQuery .each() because it (& TypeScript) has problems accepting async functions. + $('.item-list--item').find('.badge.is-tag').toArray().forEach(async e => { const prefValue = await QPixel.preference('favorite_tags', true); if (!prefValue) { return; diff --git a/app/assets/javascripts/privileges.js b/app/assets/javascripts/privileges.js index 0ab96612a..5a038c31a 100644 --- a/app/assets/javascripts/privileges.js +++ b/app/assets/javascripts/privileges.js @@ -11,14 +11,14 @@ $(() => { const name = $tgt.data('name'); const type = $tgt.data('type'); - const resp = await fetch(`/admin/privileges/${name}`, { - credentials: 'include' - }); + const resp = await QPixel.getJSON(`/admin/privileges/${name}`); + const data = await resp.json(); - const value = data[`${type}_score_threshold`]; + const value = data[`${type}_score_threshold`]; + const form = editField.clone().val(value ? value.toString() : '').attr('data-name', name).attr('data-type', type); - $tgt.addClass('editing').html(form).append(``); + $tgt.addClass('editing').html(form[0]).append(``); }); $(document).on('click', '.js-privilege-submit', async (evt) => { @@ -33,11 +33,8 @@ $(() => { const value = Number.isNaN(rawValue) ? null : rawValue; - const resp = await fetch(`/admin/privileges/${name}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': QPixel.csrfToken() }, - body: JSON.stringify({type, threshold: value}) - }); + const resp = await QPixel.fetchJSON(`/admin/privileges/${name}`, { type, threshold: value }); + const data = await resp.json(); $td.removeClass('editing').html('').text((data.privilege[`${type}_score_threshold`] || '-').toString()); diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index 442767daa..6acdc83fe 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -21,6 +21,7 @@ let popped_modals_ct = 0; * @typedef {{ * id: number, * username: string, + * is_standard: boolean, * is_moderator: boolean, * is_admin: boolean, * is_global_moderator: boolean, @@ -31,22 +32,12 @@ let popped_modals_ct = 0; */ window.QPixel = { - /** - * Get the current CSRF anti-forgery token. Should be passed as the X-CSRF-Token header when - * making AJAX POST requests. - * @returns {string} - */ csrfToken: () => { const token = $('meta[name="csrf-token"]').attr('content'); QPixel.csrfToken = () => token; return token; }, - /** - * Create a notification popup - not an inbox notification. - * @param type the type to apply to the popup - warning, danger, etc. - * @param message the message to show - */ createNotification: function (type, message) { // Some messages include a date stamp, `append_date` governs that. let append_date = false; @@ -57,7 +48,7 @@ window.QPixel = { * over and over again is going to create multiple error modals in the same exact place. While this happens this * way, an user closing the error modal will not have an immediate visual action feedback if two or more error * modals have been printed. A date is stamped in order to cope with that. Could be anything. Probably a cycle - * of different emoji characters would be cuter while having the purpose met. But if so make sure character in + * of different emoji characters would be cuter while having the purpose met. But if so, make sure character in * step `i` is actually different than character in step `i + 1`. And then you could print an emoji just every * time a modal is popped up, not just from the second one; removing `message_with_date`, using only `message`, * and removing `append_date` and the different situations guarded by it. */ @@ -92,11 +83,6 @@ window.QPixel = { popped_modals_ct += 1; }, - /** - * Get the absolute offset of an element. - * @param el the element for which to find the offset. - * @returns {{top: number, left: number, bottom: number, right: number}} - */ offset: function (el) { const topLeft = $(el).offset(); return { @@ -107,14 +93,6 @@ window.QPixel = { }; }, - /** - * Add a button to the Markdown editor. - * @param $buttonHtml the HTML content that the button should show - just text, if you like, or - * something more complex if you want to. - * @param shortName a short name for the action that will be used as the title and aria-label attributes. - * @param callback a function that will be passed as the click event callback - should take one - * parameter, which is the event object. - */ addEditorButton: function ($buttonHtml, shortName, callback) { const html = ``; @@ -136,27 +114,10 @@ window.QPixel = { insertButton(); }, - /** - * Add a validator that will be called before creating a post. - * callback should take one parameter, the post text, and should return an array in - * the following format: - * - * [ - * true | false, // is the post valid for this check? - * [ - * { type: 'warning', message: 'warning message - will not block posting' }, - * { type: 'error', message: 'error message - will block posting' } - * ] - * ] - */ addPrePostValidation: function (callback) { validators.push(callback); }, - /** - * Internal. Called just before a post is sent to the server to validate that it passes - * all custom checks. - */ validatePost: function (postText) { const results = validators.map((x) => x(postText)); const valid = results.every((x) => x[0]); @@ -168,11 +129,6 @@ window.QPixel = { } }, - /** - * Replace the selected text in an input field with a provided replacement. - * @param $field the field in which to replace text - * @param text the text with which to replace the selection - */ replaceSelection: ($field, text) => { const prev = $field.val()?.toString(); $field.val(prev.substring(0, $field[0].selectionStart) + text + prev.substring($field[0].selectionEnd)); @@ -194,10 +150,6 @@ window.QPixel = { */ _user: null, - /** - * FIFO-style fetch wrapper for /users/me requests - * @returns {Promise} - */ _fetchUser () { if (QPixel._pendingUserResponse) { return QPixel._pendingUserResponse; @@ -215,10 +167,6 @@ window.QPixel = { 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; @@ -241,11 +189,6 @@ window.QPixel = { _preferences: null, - /** - * Get an object containing the current user's preferences. Loads, in order of precedence, from local variable, - * localStorage, or Redis via AJAX. - * @returns {Promise} a JSON object containing user preferences - */ _getPreferences: async () => { // Early return for the most frequent case (local variable already contains the preferences) if (QPixel._preferences != null) { @@ -266,12 +209,6 @@ window.QPixel = { return QPixel._preferences; }, - /** - * Get a single user preference by name. - * @param name the name of the requested preference - * @param community is the requested preference community-local (true), or network-wide (false)? - * @returns {Promise} the value of the requested preference - */ preference: async (name, community = false) => { const user = await QPixel.user(); @@ -294,25 +231,13 @@ window.QPixel = { return value; }, - /** - * Set a user preference by name to the value provided. - * @param name the name of the preference to set - * @param value the value to set to - must respond to toString() for localStorage and Redis - * @param community is this preference community-local (true), or network-wide (false)? - * @returns {Promise} - */ setPreference: async (name, value, community = false) => { - const resp = await fetch('/users/me/preferences', { - method: 'POST', - credentials: 'include', - headers: { - 'X-CSRF-Token': QPixel.csrfToken(), - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({name, value, community}) + const resp = await QPixel.fetchJSON('/users/me/preferences', { name, value, community }, { + headers: { 'Accept': 'application/json' } }); + const data = await resp.json(); + if (data.status !== 'success') { console.error(`Preference persist failed (${name})`); console.error(resp); @@ -322,9 +247,6 @@ window.QPixel = { } }, - /** - * @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 @@ -343,11 +265,6 @@ window.QPixel = { 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(); @@ -367,29 +284,17 @@ window.QPixel = { }, setFilterAsDefault: async (categoryId, name) => { - 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}) + await QPixel.fetchJSON(`/categories/${categoryId}/filters/default`, { name }, { + headers: { 'Accept': 'application/json' } }); }, 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 resp = await QPixel.fetchJSON('/users/me/filters', + Object.assign(filter, {name, category, is_default: isDefault}), { + headers: { 'Accept': 'application/json' } + }); + const data = await resp.json(); if (data.status !== 'success') { console.error(`Filter persist failed (${name})`); @@ -402,17 +307,12 @@ window.QPixel = { }, 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 resp = await QPixel.fetchJSON('/users/me/filters', { name, system }, { + headers: { 'Accept': 'application/json' } }); + const data = await resp.json(); + if (data.status !== 'success') { console.error(`Filter deletion failed (${name})`); console.error(resp); @@ -423,10 +323,6 @@ window.QPixel = { } }, - /** - * 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`; @@ -434,10 +330,6 @@ window.QPixel = { 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(); @@ -450,10 +342,6 @@ window.QPixel = { 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', @@ -465,22 +353,12 @@ window.QPixel = { 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" - * @param posIdx the index to search for - * @returns {[string, number]} the word the given position is in, and the position within that word - */ currentCaretSequence: (splat, posIdx) => { let searchIdx = 0; let splatIdx = 0; @@ -493,5 +371,116 @@ window.QPixel = { splatIdx += 1; } while (searchIdx < posIdx); return [currentSequence, posInSeq]; + }, + + fetchJSON: async (uri, data, options) => { + const defaultHeaders = { + 'X-CSRF-Token': QPixel.csrfToken(), + 'Content-Type': 'application/json', + }; + + const { headers = {}, ...otherOptions } = options ?? {}; + + /** @type {RequestInit} */ + const requestInit = { + method: 'POST', + headers: { + ...defaultHeaders, + ...headers, + }, + credentials: 'include', + body: otherOptions.method === 'GET' ? void 0 : JSON.stringify(data), + ...otherOptions, + }; + + return fetch(uri, requestInit); + }, + + getJSON: async (uri, options = {}) => { + return QPixel.fetchJSON(uri, {}, { + ...options, + method: 'GET', + }); + }, + + getComment: async (id) => { + const resp = await fetch(`/comments/${id}`, { + credentials: 'include', + headers: { 'Accept': 'application/json' } + }); + + const data = await resp.json(); + + return data; + }, + + getThreadContent: async (id, options) => { + const inline = options?.inline ?? true; + const showDeleted = options?.showDeleted ?? false; + + const url = new URL(`/comments/thread/${id}/content`, window.location.origin); + url.searchParams.append('inline', `${inline}`); + url.searchParams.append('show_deleted_comments', `${showDeleted ? 1 : 0}`); + + const resp = await fetch(url.toString(), { + headers: { 'Accept': 'text/html' } + }); + + const content = await resp.text(); + + return content; + }, + + getThreadsListContent: async (id) => { + const url = new URL(`/comments/post/${id}`, window.location.origin); + + const resp = await fetch(url.toString(), { + headers: { 'Accept': 'text/html' } + }); + + const content = await resp.text(); + + return content; + }, + + handleJSONResponse: (data, onSuccess) => { + if (data.status === 'success') { + onSuccess(data) + } + else { + QPixel.createNotification('danger', data.message); + } + }, + + deleteComment: async (id) => { + const resp = await QPixel.fetchJSON(`/comments/${id}/delete`, {}, { + headers: { 'Accept': 'application/json' }, + method: 'DELETE' + }); + + const data = await resp.json(); + + return data; + }, + + undeleteComment: async (id) => { + const resp = await QPixel.fetchJSON(`/comments/${id}/delete`, {}, { + headers: { 'Accept': 'application/json' }, + method: 'PATCH' + }); + + const data = await resp.json(); + + return data; + }, + + lockThread: async (id) => { + const resp = await QPixel.fetchJSON(`/comments/thread/${id}/restrict`, { + type: 'lock' + }); + + const data = await resp.json(); + + return data; } }; diff --git a/app/assets/javascripts/reactions.js b/app/assets/javascripts/reactions.js index d6b5ee7b4..e3bf4342c 100644 --- a/app/assets/javascripts/reactions.js +++ b/app/assets/javascripts/reactions.js @@ -1,68 +1,52 @@ $(() => { - $(".reaction-submit").on("click", async (e) => { - e.preventDefault(); - const $this = $(e.target); + $(".reaction-submit").on("click", async (ev) => { + ev.preventDefault(); + + const $this = $(ev.target); const $rt = $this.parent().find('.reaction-type:checked'); const $comment = $this.parent().find('.reaction-comment-field'); const postId = $this.attr("data-post-id") - if($rt.length == 0) { + if ($rt.length === 0) { QPixel.createNotification("danger", "You need to select a reaction type."); return; } - if($rt.is("[data-reaction-require-comment]") && !$comment.val()?.toString().trim().length) { + if ($rt.is("[data-reaction-require-comment]") && !$comment.val()?.toString().trim().length) { QPixel.createNotification("danger", "This reaction type requires a comment with an explanation."); return; } - const resp = await fetch(`/posts/reactions/add`, { - method: "POST", - headers: { - 'X-CSRF-Token': QPixel.csrfToken(), - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - reaction_id: $rt.val(), - comment: $comment.val()?.toString().trim() || null, - post_id: postId - }) + const resp = await QPixel.fetchJSON('/posts/reactions/add', { + reaction_id: $rt.val(), + comment: $comment.val()?.toString()?.trim() || null, + post_id: postId + }, { + headers: { 'Accept': 'application/json' } }); const data = await resp.json(); - if (data.status === 'success') { + QPixel.handleJSONResponse(data, () => { window.location.reload(); - } else { - QPixel.createNotification("danger", data.message); - } + }); }); - $(".reaction-retract").on("click", async (e) => { - e.preventDefault(); - const $this = $(e.target); + + $(".reaction-retract").on("click", async (ev) => { + ev.preventDefault(); + + const $this = $(ev.target); const postId = $this.attr("data-post") const reactionType = $this.attr("data-reaction"); - const resp = await fetch(`/posts/reactions/retract`, { - method: "POST", - headers: { - 'X-CSRF-Token': QPixel.csrfToken(), - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - reaction_id: reactionType, - post_id: postId - }) + const resp = await QPixel.fetchJSON('/posts/reactions/retract', { reaction_id: reactionType, post_id: postId }, { + headers: { 'Accept': 'application/json' } }); const data = await resp.json(); - if (data.status === 'success') { + QPixel.handleJSONResponse(data, () => { window.location.reload(); - } else { - QPixel.createNotification("danger", data.message); - } + }); }); -}); \ No newline at end of file +}); diff --git a/app/assets/javascripts/search.js b/app/assets/javascripts/search.js index 435ec04ae..99a7f28f2 100644 --- a/app/assets/javascripts/search.js +++ b/app/assets/javascripts/search.js @@ -18,6 +18,9 @@ $(() => { return; } + /** + * @type {QPixelPopupCallback} + */ const callback = (ev, popup) => { const $item = $(ev.target).hasClass('item') ? $(ev.target) : $(ev.target).parents('.item'); const id = $item.data('post-type-id'); diff --git a/app/assets/javascripts/sidebar.js b/app/assets/javascripts/sidebar.js new file mode 100644 index 000000000..cc1540e8f --- /dev/null +++ b/app/assets/javascripts/sidebar.js @@ -0,0 +1,28 @@ +document.addEventListener('DOMContentLoaded', () => { + QPixel.DOM.addDelegatedListener('click', '.js-widget-hide, .js-widget-hide *', ev => { + let tgt = /** @type {HTMLElement} */ (ev.target); + if (!tgt.classList.contains('js-widget-hide')) { + tgt = tgt.closest('.js-widget-hide'); + } + const icon = tgt.querySelector('i'); + const widget = /** @type {HTMLElement} */ (tgt.closest('.widget')); + const isHidden = widget.dataset.collapsed === 'true'; + if (isHidden) { + widget.querySelectorAll('.widget--body').forEach(el => { + el.classList.remove('hiding', 'hidden'); + }); + icon.classList.remove('fa-chevron-down'); + icon.classList.add('fa-chevron-up'); + widget.dataset.collapsed = 'false'; + } + else { + widget.querySelectorAll('.widget--body').forEach(el => { + el.classList.add('hiding'); + setTimeout(() => el.classList.add('hidden'), 200); + }); + icon.classList.remove('fa-chevron-up'); + icon.classList.add('fa-chevron-down'); + widget.dataset.collapsed = 'true'; + } + }); +}); diff --git a/app/assets/javascripts/site_settings.js b/app/assets/javascripts/site_settings.js index 36f8894e7..04f3d9b4b 100644 --- a/app/assets/javascripts/site_settings.js +++ b/app/assets/javascripts/site_settings.js @@ -22,6 +22,7 @@ $(() => { const resp = await fetch(`/admin/settings/${name}${!!communityId ? '?community_id=' + communityId : ''}`, { credentials: 'include' }); + const data = await resp.json(); const value = data.typed; @@ -43,11 +44,8 @@ $(() => { body = Object.assign(body, {community_id: communityId}); } - const resp = await fetch(`/admin/settings/${name}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': QPixel.csrfToken() }, - body: JSON.stringify(body) - }); + const resp = await QPixel.fetchJSON(`/admin/settings/${name}`, body); + const data = await resp.json(); $td.removeClass('editing').html('').text(data.setting.typed.toString()); diff --git a/app/assets/javascripts/subscriptions.js b/app/assets/javascripts/subscriptions.js index 201758ae7..6a6280d45 100644 --- a/app/assets/javascripts/subscriptions.js +++ b/app/assets/javascripts/subscriptions.js @@ -5,16 +5,13 @@ $(() => { const subscriptionId = $sub.data('sub-id'); const value = !!$tgt.is(':checked'); - const resp = await fetch(`/subscriptions/${subscriptionId}/enable`, { - method: 'POST', - headers: { 'Accept': 'application/json', 'X-CSRF-Token': QPixel.csrfToken(), 'Content-Type': 'application/json' }, - body: JSON.stringify({enabled: value}) + const resp = await QPixel.fetchJSON(`/subscriptions/${subscriptionId}/enable`, { enabled: value }, { + headers: { 'Accept': 'application/json' } }); + const data = await resp.json(); - if (data.status !== 'success') { - QPixel.createNotification('danger', 'Failed to update your subscription. Please report this bug on Meta.'); - } + QPixel.handleJSONResponse(data, () => {}); }); $('.js-remove-subscription').on('click', async (evt) => { @@ -24,17 +21,15 @@ $(() => { const $sub = $tgt.parents('details'); const subscriptionId = $sub.data('sub-id'); - const resp = await fetch(`/subscriptions/${subscriptionId}`, { + const resp = await QPixel.fetchJSON(`/subscriptions/${subscriptionId}`, {}, { + headers: { 'Accept': 'application/json' }, method: 'DELETE', - headers: { 'Accept': 'application/json', 'X-CSRF-Token': QPixel.csrfToken() } }); + const data = await resp.json(); - if (data.status === 'success') { + QPixel.handleJSONResponse(data, () => { $sub.remove(); - } - else { - QPixel.createNotification('danger', 'Failed to remove your subscription. Please report this bug on Meta.'); - } + }); }); -}); \ No newline at end of file +}); diff --git a/app/assets/javascripts/suggested_edit.js b/app/assets/javascripts/suggested_edit.js index 4763022eb..40fe37e7c 100644 --- a/app/assets/javascripts/suggested_edit.js +++ b/app/assets/javascripts/suggested_edit.js @@ -1,25 +1,27 @@ -$(document).on('ready', function () { +/** + * @typedef {{ + * message?: string + * redirect_url?: string + * status: 'success' | 'error' + * }} SuggestedEditActionResult + */ + +$(() => { $('[data-suggested-edit-approve]').on('click', async (ev) => { 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', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': QPixel.csrfToken() - }, - body: JSON.stringify({ comment }) - }); + const resp = await QPixel.fetchJSON(`/posts/suggested-edit/${editId}/approve`, { comment }); + + /** @type {SuggestedEditActionResult} */ const data = await resp.json(); if (data.status !== 'success') { QPixel.createNotification('danger', 'Failed: ' + data.message); } - else { + else if (data.redirect_url) { location.href = data.redirect_url; } }); @@ -36,22 +38,16 @@ $(document).on('ready', function () { const editId = self.attr('data-suggested-edit-reject'); const comment = $('.js-rejection-reason').val(); - const resp = await fetch(`/posts/suggested-edit/${editId}/reject`, { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': QPixel.csrfToken() - }, - body: JSON.stringify({ rejection_comment: comment }) - }); + const resp = await QPixel.fetchJSON(`/posts/suggested-edit/${editId}/reject`, { rejection_comment: comment }); + + /** @type {SuggestedEditActionResult} */ const data = await resp.json(); if (data.status !== 'success') { QPixel.createNotification('danger', 'Failed: ' + data.message); } - else { + else if (data.redirect_url) { location.href = data.redirect_url; } }); -}); \ No newline at end of file +}); diff --git a/app/assets/javascripts/tag_sets.js b/app/assets/javascripts/tag_sets.js index b7e1a08a2..e913133d8 100644 --- a/app/assets/javascripts/tag_sets.js +++ b/app/assets/javascripts/tag_sets.js @@ -2,12 +2,13 @@ $(() => { $(document).on('click', '.js-tag-set-name', async (ev) => { const $tgt = $(ev.target); const tagSetId = $tgt.data('set-id'); + const response = await fetch(`/admin/tag-sets/${tagSetId}`, { - headers: { - 'Accept': 'application/json' - } + headers: { 'Accept': 'application/json' } }); + const data = await response.json(); + const name = data.name; const $form = ` `; @@ -20,26 +21,18 @@ $(() => { $(document).on('click', '.js-edit-name-submit', async (ev) => { const $tgt = $(ev.target); - console.log($tgt); const tagSetId = $tgt.data('set-id'); const $name = $tgt.parents('.js-tag-set-name'); const newName = $tgt.parent().children('.js-edit-set-name').val(); - const response = await fetch(`/admin/tag-sets/${tagSetId}/edit`, { - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'X-CSRF-Token': QPixel.csrfToken() - }, - method: 'POST', - body: JSON.stringify({ name: newName }) + + const response = await QPixel.fetchJSON(`/admin/tag-sets/${tagSetId}/edit`, { name: newName }, { + headers: { 'Accept': 'application/json' } }); + const data = await response.json(); - if (data.status === 'success') { + QPixel.handleJSONResponse(data, (data) => { $name.text(data.tag_set.name); - } - else { - QPixel.createNotification('danger', `Failed to change name (${response.status})`); - } + }); }); -}); \ No newline at end of file +}); diff --git a/app/assets/javascripts/tags.js b/app/assets/javascripts/tags.js index ba1a9ca92..c552cfbe4 100644 --- a/app/assets/javascripts/tags.js +++ b/app/assets/javascripts/tags.js @@ -1,6 +1,38 @@ +/** + * @typedef {{ + * name: string + * }} RemoteTagSynonym + * + * @typedef {{ + * id: number + * community_id: number + * parent_id: number | null + * tag_set_id: number + * excerpt: string + * name: string + * tag_synonyms: RemoteTagSynonym[] + * wiki: string | null + * wiki_markdown: string + * created_at: string + * updated_at: string + * }} RemoteTag + * + * @typedef {{ + * id: number | string + * text: string + * desc: string + * synonyms?: string | RemoteTagSynonym[] + * }} ProcessedTag + */ + $(() => { - const sum = (ary) => ary.reduce((a, b) => a + b, 0); + const sum = (/** @type {number[]} */ ary) => ary.reduce((a, b) => a + b, 0); + /** + * @param {string} text + * @param {number} max + * @returns {string[]} + */ const splitWordsMaxLength = (text, max) => { const words = text.split(' '); const splat = [[]]; @@ -13,6 +45,10 @@ $(() => { return splat.map((s) => s.join(' ')); }; + /** + * @param {ProcessedTag} tag + * @returns {JQuery} + */ const template = (tag) => { const tagSynonyms = !!tag.synonyms ? ` (${tag.synonyms})` : ''; const tagSpan = `${tag.text}${tagSynonyms}`; @@ -29,6 +65,10 @@ $(() => { const useIds = $tgt.attr('data-use-ids') === 'true'; $tgt.select2({ tags: $tgt.attr('data-create') !== 'false', + /** + * @param {Select2.IdTextPair[]} data + * @param {Select2.IdTextPair & { desc?: string }} tag + */ insertTag: function (data, tag) { tag.desc = "(Create new tag)" // Insert the tag at the end of the results @@ -46,6 +86,10 @@ $(() => { }, headers: { 'Accept': 'application/json' }, delay: 100, + /** + * @param {RemoteTag[]} data + * @returns {Select2.ProcessedResult} + */ processResults: (data) => { // (for the tour) if (Number($this.data('tag-set')) === -1) { @@ -74,6 +118,11 @@ $(() => { }); }); + /** + * @param {JQuery} $search + * @param {RemoteTagSynonym[]} synonyms + * @returns {string | RemoteTagSynonym[]} + */ function processSynonyms($search, synonyms) { if (!synonyms) return synonyms; @@ -145,13 +194,10 @@ $(() => { const renameTo = prompt(`Rename tag ${tagName} to:`); if (!!renameTo) { - const resp = await fetch(`/categories/${categoryId}/tags/${tagId}/rename`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': QPixel.csrfToken() }, - body: JSON.stringify({ name: renameTo }) - }); + const resp = await QPixel.fetchJSON(`/categories/${categoryId}/tags/${tagId}/rename`, { name: renameTo }); + const data = await resp.json(); + if (data.success) { location.reload(); } diff --git a/app/assets/javascripts/textarea_popup.js b/app/assets/javascripts/textarea_popup.js index 1cd1c6366..abc90d905 100644 --- a/app/assets/javascripts/textarea_popup.js +++ b/app/assets/javascripts/textarea_popup.js @@ -25,9 +25,8 @@ QPixel.Popup = class Popup { * Get a popup for a given input field. You should generally use this method instead of directly * calling the constructor, as this accounts for pre-existing popups. * @param {JQuery[]} items an array of jQuery-wrappable elements to include - apply the `item` class to each one - * @param {JQuery} field the parent textarea HTMLElement that this popup is for - * @param {(ev: JQuery.Event, popup: QPixelPopup) => void} cb a callback that will be called when an item is clicked - it will be passed the click event - * @returns {QPixelPopup} + * @param {HTMLInputElement | HTMLTextAreaElement} field parent field that the popup is for + * @param {QPixelPopupCallback} cb a callback that will be called when an item is clicked */ static getPopup (items, field, cb) { const popupId = $(field).attr('data-popup'); @@ -44,10 +43,10 @@ QPixel.Popup = class Popup { } /** - * Create a textarea 'suggestions'-type popup that drops down from the current caret position. + * Create a 'suggestions'-type popup that drops down from the current caret position. * @param {JQuery[]} items an array of jQuery-wrappable elements to include - apply the `item` class to each one - * @param {JQuery} field the parent textarea HTMLElement that this popup is for - * @param {(ev: JQuery.Event, popup: QPixelPopup) => void} cb a callback that will be called when an item is clicked - it will be passed the click event + * @param {HTMLInputElement | HTMLTextAreaElement} field parent field that the popup is for + * @param {QPixelPopupCallback} cb a callback to call when an item is clicked * @constructor */ constructor (items, field, cb) { @@ -69,7 +68,7 @@ QPixel.Popup = class Popup { }); }); - const caretPos = getCaretCoordinates(this.field, this.field.prop('selectionStart')); + const caretPos = getCaretCoordinates(this.field, this.field.selectionStart); const fieldOffset = QPixel.offset(this.field); this.$popup.css({ top: `${fieldOffset.top + caretPos.top + 20}px`, @@ -102,7 +101,7 @@ QPixel.Popup = class Popup { * Update the position of the popup to the current cursor location. */ updatePosition () { - const caretPos = getCaretCoordinates(this.field, this.field.prop('selectionStart')); + const caretPos = getCaretCoordinates(this.field, this.field.selectionStart); const fieldOffset = QPixel.offset(this.field); this.$popup.css({ top: `${fieldOffset.top + caretPos.top + 20}px`, @@ -114,7 +113,7 @@ QPixel.Popup = class Popup { * Change the callback function to the provided function. * Necessary because if the callback is in a closure, old variable values (like cursor position) * will remain unless we update the callback to a new function in an updated closure. - * @param {(ev: JQuery.Event, popup: QPixelPopup) => void} cb the new callback function to apply + * @param {QPixelPopupCallback} cb the new callback function to apply */ setCallback (cb) { this.callback = cb; @@ -185,7 +184,6 @@ QPixel.Popup = class Popup { break; case 13: // Enter const selected = self.$popup.find('.item.active'); - console.log('enter, selected: ', selected); if (selected.length > 0) { ev.stopPropagation(); ev.preventDefault(); diff --git a/app/assets/javascripts/two_factor.js b/app/assets/javascripts/two_factor.js index d98084f48..1997bedbc 100644 --- a/app/assets/javascripts/two_factor.js +++ b/app/assets/javascripts/two_factor.js @@ -4,14 +4,9 @@ $(() => { 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 req = await QPixel.fetchJSON('/users/two-factor/backup', { code }); + const res = await req.json(); if (res.status === 'error') { diff --git a/app/assets/javascripts/users.js b/app/assets/javascripts/users.js index e16a65302..f3d28dfb7 100644 --- a/app/assets/javascripts/users.js +++ b/app/assets/javascripts/users.js @@ -1,18 +1,17 @@ $(() => { if ((location.pathname === '/users/sign_up' || location.pathname === '/users/sign_in') && !navigator.cookieEnabled) { - $('input[type="submit"]').attr('disabled', true).addClass('is-muted is-outlined'); + $('input[type="submit"]').attr('disabled', 'true').addClass('is-muted is-outlined'); $('.js-errors').text('Cookies must be enabled in your browser for you to be able to sign up or sign in.'); } $('.js-role-grant-btn').on('click', async (ev) => { const $tgt = $(ev.target); - const resp = await fetch(`/users/${$tgt.attr('data-user')}/mod/toggle-role`, { - method: 'POST', - body: JSON.stringify({ role: $tgt.attr('data-role') }), - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': QPixel.csrfToken() }, - credentials: 'include' - }); + + const resp = await QPixel.fetchJSON(`/users/${$tgt.attr('data-user')}/mod/toggle-role`, + { role: $tgt.attr('data-role') }); + const data = await resp.json(); + if (resp.status !== 200 || data.status !== 'success') { QPixel.createNotification('danger', `Failed: ${data.message}`); } @@ -23,13 +22,14 @@ $(() => { $('.js-ability-grant-btn').on('click', async (ev) => { const $tgt = $(ev.target); - const resp = await fetch(`/users/${$tgt.attr('data-user')}/mod/privileges`, { - method: 'POST', - body: JSON.stringify({ do: 'grant', ability: $tgt.attr('data-ability') }), - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': QPixel.csrfToken() }, - credentials: 'include' + + const resp = await QPixel.fetchJSON(`/users/${$tgt.attr('data-user')}/mod/privileges`, { + do: 'grant', + ability: $tgt.attr('data-ability') }); + const data = await resp.json(); + if (resp.status !== 200 || data.status !== 'success') { QPixel.createNotification('danger', `Failed: ${data.message}`); } @@ -41,13 +41,14 @@ $(() => { $('.js-ability-delete-btn').on('click', async (ev) => { if (!confirm('Delete this ability?\n\nThis will remove the ability but it will come back when the abilities are recalculated,\nas long as the requirements are still met.\n\nYou\'ll probably want to use ability suspensions instead.')) return; const $tgt = $(ev.target); - const resp = await fetch(`/users/${$tgt.attr('data-user')}/mod/privileges`, { - method: 'POST', - body: JSON.stringify({ do: 'delete', ability: $tgt.attr('data-ability') }), - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': QPixel.csrfToken() }, - credentials: 'include' + + const resp = await QPixel.fetchJSON(`/users/${$tgt.attr('data-user')}/mod/privileges`, { + do: 'delete', + ability: $tgt.attr('data-ability') }); + const data = await resp.json(); + if (resp.status !== 200 || data.status !== 'success') { QPixel.createNotification('danger', `Failed: ${data.message}`); } @@ -59,18 +60,16 @@ $(() => { $('.js-ability-suspend-btn').on('click', async (ev) => { const $tgt = $(ev.target); const ability = $tgt.attr('data-ability'); - const resp = await fetch(`/users/${$tgt.attr('data-user')}/mod/privileges`, { - method: 'POST', - body: JSON.stringify({ - do: 'suspend', - ability, - duration: $("#suspend-ability-" + ability + "-duration").val(), - message: $("#suspend-ability-" + ability + "-message").val() - }), - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': QPixel.csrfToken() }, - credentials: 'include' + + const resp = await QPixel.fetchJSON(`/users/${$tgt.attr('data-user')}/mod/privileges`, { + do: 'suspend', + ability, + duration: $("#suspend-ability-" + ability + "-duration").val(), + message: $("#suspend-ability-" + ability + "-message").val() }); + const data = await resp.json(); + if (resp.status !== 200 || data.status !== 'success') { QPixel.createNotification('danger', `Failed: ${data.message}`); } @@ -78,4 +77,4 @@ $(() => { location.reload(); } }); -}); \ No newline at end of file +}); diff --git a/app/assets/javascripts/votes.js b/app/assets/javascripts/votes.js index 3d6f0240f..226cc75eb 100644 --- a/app/assets/javascripts/votes.js +++ b/app/assets/javascripts/votes.js @@ -13,11 +13,7 @@ $(() => { 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 resp = await QPixel.fetchJSON(`/votes/${voteId}`, {}, { method: 'DELETE' }); const data = await resp.json(); @@ -35,11 +31,9 @@ $(() => { } } else { - const resp = await fetch('/votes/new', { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': QPixel.csrfToken() }, - body: JSON.stringify({post_id: $post.data('post-id'), vote_type: voteType}) + const resp = await QPixel.fetchJSON('/votes/new', { + post_id: $post.data('post-id'), + vote_type: voteType }); const data = await resp.json(); diff --git a/app/assets/stylesheets/_variables.scss b/app/assets/stylesheets/_variables.scss index 43b804aaf..8c3bd01ff 100644 --- a/app/assets/stylesheets/_variables.scss +++ b/app/assets/stylesheets/_variables.scss @@ -1,3 +1,5 @@ +@use "sass:map"; + // Breakpoints $screen-sm: 576px; $screen-md: 780px; @@ -44,4 +46,17 @@ $data: ( $font-stack-plain: 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; $font-stack-display: 'Red Hat Display', $font-stack-plain; -$font-stack-code: 'Cascadia Code', 'Roboto Mono', monospace; \ No newline at end of file +$font-stack-code: 'Cascadia Code', 'Roboto Mono', monospace; + +$sides: top, right, bottom, left; + +$font-sizes: ( + xxs: 0.8em, + xs: 0.85em, + sm: 0.9em, + md: 1em, + lg: 1.2em, + xl: 1.5em +); + +$text-aligns: left, center, right, justify; diff --git a/app/assets/stylesheets/categories.scss b/app/assets/stylesheets/categories.scss index 6989a8781..9011206b2 100644 --- a/app/assets/stylesheets/categories.scss +++ b/app/assets/stylesheets/categories.scss @@ -29,6 +29,10 @@ white-space: nowrap; } +.category-filters:not([open]) { + border-top: 1px solid $muted-graphic; +} + .category--description { color: $muted-text; } diff --git a/app/assets/stylesheets/comment_threads.scss b/app/assets/stylesheets/comment_threads.scss new file mode 100644 index 000000000..4378ce742 --- /dev/null +++ b/app/assets/stylesheets/comment_threads.scss @@ -0,0 +1,51 @@ +.post--comments-thread-wrapper { + .post--comments-thread { + .widget--body:not(.widget--deleted-comments):not(.widget--more-comments) { + padding: 0; + + .comment { + margin: 0; + } + } + } + + .droppanel { + max-width: 15em; + + .droppanel--menu { + max-width: unset; + } + } +} + +.thread { + + .widget--full-thread, + .widget--more-comments, + .widget--deleted-comments { + font-size: 0.8em; + padding: 0.5em; + text-align: center; + + p { + margin: 0; + } + } +} + +.thread-followers-list { + display: flex; + flex-direction: column; + gap: 0.75em; + + .user-card--content { + max-width: 75%; + } +} + +.new-thread-wrapper, +.reply-to-thread-wrapper { + align-items: center; + gap: 0.25em; + display: inline-flex; +} diff --git a/app/assets/stylesheets/comments.scss b/app/assets/stylesheets/comments.scss index f80352758..d856fc6db 100644 --- a/app/assets/stylesheets/comments.scss +++ b/app/assets/stylesheets/comments.scss @@ -57,14 +57,6 @@ padding-top: 0.125rem; } -.deleted-comments { - margin: -0.75rem 0; - padding: 0 0.25rem; - font-size: 0.8rem; - color: #666; - font-style: italic; -} - .post--comments-header { align-items: center; display: flex; @@ -154,3 +146,7 @@ padding: 0.7em; display: none; } + +.reply-to-thread-form { + display: none; +} diff --git a/app/assets/stylesheets/fonts.scss b/app/assets/stylesheets/fonts.scss new file mode 100644 index 000000000..5f26da147 --- /dev/null +++ b/app/assets/stylesheets/fonts.scss @@ -0,0 +1,7 @@ +@import 'variables'; + +@each $key, $value in $font-sizes { + .font-#{$key} { + font-size: $value; + } +} diff --git a/app/assets/stylesheets/posts.scss b/app/assets/stylesheets/posts.scss index 8a6ba70fb..842404d48 100644 --- a/app/assets/stylesheets/posts.scss +++ b/app/assets/stylesheets/posts.scss @@ -15,6 +15,10 @@ .post--container { max-width: 100%; + &.grid { + margin: 0; + } + .grid--cell.is-flexible { flex-grow: 1; flex-shrink: 1; diff --git a/app/assets/stylesheets/site_settings.scss b/app/assets/stylesheets/site_settings.scss index 9cdec8029..01d0eade9 100644 --- a/app/assets/stylesheets/site_settings.scss +++ b/app/assets/stylesheets/site_settings.scss @@ -2,4 +2,4 @@ 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 867e962e3..237072aff 100644 --- a/app/assets/stylesheets/users.scss +++ b/app/assets/stylesheets/users.scss @@ -100,6 +100,10 @@ .user-card { display: flex; + &.deleted-content { + padding: 0.5rem; + } + .user-card--avatar { align-self: flex-start; padding: 0 0.5rem; @@ -115,11 +119,6 @@ } } - &.deleted-content .user-card--content { - display: flex; - align-items: center; - } - .user-card--content { padding: 0 0.25rem; diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 2b3748d8f..14be9ac7c 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -358,3 +358,19 @@ span.spoiler { .wrap-anywhere { overflow-wrap: anywhere; } + +.nowrap { + white-space: nowrap; +} + +@each $side in $sides { + .border-#{$side}-none { + border-#{$side}: none; + } +} + +@each $align in $text-aligns { + .text-#{$align} { + text-align: $align; + } +} diff --git a/app/assets/stylesheets/widgets.scss b/app/assets/stylesheets/widgets.scss new file mode 100644 index 000000000..cae849245 --- /dev/null +++ b/app/assets/stylesheets/widgets.scss @@ -0,0 +1,14 @@ +.widget--body { + transition: all 0.2s ease; +} + +.widget--body.hiding { + scale: 0; + height: 0; + margin: 0; + padding: 0; +} + +.widget--body.hidden { + display: none; +} diff --git a/app/controllers/abilities_controller.rb b/app/controllers/abilities_controller.rb index d08022ad4..015cf6d96 100644 --- a/app/controllers/abilities_controller.rb +++ b/app/controllers/abilities_controller.rb @@ -8,13 +8,13 @@ def index def show @ability = Ability.where(internal_id: params[:id]).first - return not_found if @ability.nil? + return not_found! if @ability.nil? @your_ability = @user&.community_user&.privilege @ability.internal_id end def recalc - @user.community_user.recalc_privileges + @user.community_user.recalc_privileges! redirect_to user_privileges_url(@user.id) end diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 5d13f6d3b..231e9014b 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -2,8 +2,8 @@ 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, :all_email, :send_all_email] + :setup_save, :hellban, :all_email, :send_all_email] + before_action :verify_developer, only: [:change_users, :impersonate] def index; end @@ -14,7 +14,7 @@ def error_reports ErrorLog.all else ErrorLog.where(community: RequestContext.community) - end.order(created_at: :desc).paginate(page: params[:page], per_page: 50) + end.newest_first.paginate(page: params[:page], per_page: 50) end def privileges @@ -31,9 +31,9 @@ def show_privilege def update_privilege @ability = Ability.find_by internal_id: params[:name] type = ['post', 'edit', 'flag'].include?(params[:type]) ? params[:type] : nil - return not_found if type.nil? + return not_found! if type.nil? - pre = @ability.send("#{type}_score_threshold".to_sym) + pre = @ability.send(:"#{type}_score_threshold") @ability.update("#{type}_score_threshold" => params[:threshold]) AuditLog.admin_audit(event_type: 'ability_threshold_update', related: @ability, user: current_user, comment: "#{params[:type]} score\nfrom <<#{pre}>>\nto <<#{params[:threshold]}>>") @@ -43,20 +43,35 @@ def update_privilege def admin_email; end def send_admin_email - Thread.new do - AdminMailer.with(body_markdown: params[:body_markdown], subject: params[:subject]).to_moderators.deliver_now - end + community = RequestContext.community + + AdminMailer.with(body_markdown: params[:body_markdown], + subject: params[:subject], + community: community) + .to_moderators + .deliver_later + AuditLog.admin_audit(event_type: 'send_admin_email', user: current_user, comment: "Subject: #{params[:subject]}") - flash[:success] = t 'admin.email_being_sent' + + flash[:success] = t('admin.email_being_sent') redirect_to admin_path end def all_email; end def send_all_email + community = RequestContext.community + Thread.new do - AdminMailer.with(body_markdown: params[:body_markdown], subject: params[:subject]).to_all_users.deliver_now + emails = User.where.not(confirmed_at: nil).where('email NOT LIKE ?', '%localhost').select(:email).map(&:email) + emails.each_slice(50) do |slice| + AdminMailer.with(body_markdown: params[:body_markdown], + subject: params[:subject], + emails: slice, community: community) + .to_all_users + .deliver_later + end end AuditLog.admin_audit(event_type: 'send_all_email', user: current_user, comment: "Subject: #{params[:subject]}") @@ -65,6 +80,9 @@ def send_all_email end def audit_log + @page = helpers.safe_page(params) + @per_page = helpers.safe_per_page(params) + @logs = if current_user.is_global_admin AuditLog.unscoped.where.not(log_type: ['user_annotation', 'user_history']) else @@ -72,7 +90,8 @@ def audit_log end.user_sort({ term: params[:sort], default: :created_at }, age: :created_at, type: :log_type, event: :event_type, related: Arel.sql('related_type DESC, related_id DESC'), user: :user_id) - .paginate(page: params[:page], per_page: 100) + .paginate(page: @page, per_page: @per_page) + render layout: 'without_sidebar' end @@ -110,7 +129,7 @@ def setup_save # Set settings from config page { primary_color: 'SiteCategoryHeaderDefaultColor', logo_url: 'SiteLogoPath', ad_slogan: 'SiteAdSlogan', mathjax: 'MathJaxEnabled', syntax_highlighting: 'SyntaxHighlightingEnabled', chat_link: 'ChatLink', - analytics_url: 'AnalyticsURL', analytics_id: 'AnalyticsSiteId', content_transfer: 'AllowContentTransfer' } \ + analytics_url: 'AnalyticsURL', analytics_id: 'AnalyticsSiteId', content_transfer: 'AllowContentTransfer' } .each do |key, setting| settings.find_by(name: setting).update(value: params[key]) end @@ -173,13 +192,13 @@ def change_users end def change_back - return not_found unless session[:impersonator_id].present? + return not_found! unless session[:impersonator_id].present? @impersonator = User.find session[:impersonator_id] end def verify_elevation - return not_found unless session[:impersonator_id].present? + return not_found! unless session[:impersonator_id].present? @impersonator = User.find session[:impersonator_id] if @impersonator&.sso_profile.present? @@ -197,4 +216,15 @@ def verify_elevation render :change_back end end + + def do_email_query + users = User.where(email: params[:email]) + if users.any? + @user = users.first + @profiles = @user.community_users.includes(:community).where(community: current_user.admin_communities) + else + flash[:danger] = helpers.i18ns('admin.errors.email_query_not_found') + end + render :email_query + end end diff --git a/app/controllers/advertisement_controller.rb b/app/controllers/advertisement_controller.rb index 6e143d0db..6247795f1 100644 --- a/app/controllers/advertisement_controller.rb +++ b/app/controllers/advertisement_controller.rb @@ -19,16 +19,16 @@ def community end def specific_question - @post = Post.unscoped.find(params[:id]) + @post = Post.unscoped.find_by(id: params[:id]) if @post.nil? - not_found + not_found! elsif @post.question? send_resp helpers.question_ad(@post) elsif @post.article? send_resp helpers.article_ad(@post) else - not_found + not_found! false end end @@ -37,17 +37,17 @@ def specific_category @category = Category.unscoped.find(params[:id]) @post = Rails.cache.fetch "ca_random_category_post/#{params[:id]}", expires_in: 5.minutes do - select_random_post(@category) + select_random_post(@category, days: params[:days]&.to_i, score: params[:score]&.to_f) end if @post.nil? - not_found + not_found! elsif @post.question? send_resp helpers.question_ad(@post) elsif @post.article? send_resp helpers.article_ad(@post) else - not_found + not_found! false end end @@ -65,7 +65,7 @@ def random_question elsif @post.article? send_resp helpers.article_ad(@post) else - not_found + not_found! end end @@ -85,15 +85,28 @@ def promoted_post private - def select_random_post(category = nil) + ## + # Select a random top-level post for advertisement. If a category is not specified, all categories where + # +use_for_advertisement+ is set to true will be considered. Uses HotPosts SiteSettings for score and limit settings + # by default and looks within the last week by default, but these can be overridden with parameters. + # @param category [Category, ActiveRecord::Collection] a single Category or collection of categories from which to + # find the post + # @param days [Integer] how many days back to search within + # @param score [Float] the minimum post score to consider + # @param count [Integer] a maximum number of posts to query for; the final post will be randomly selected from this + # @return [Post] + def select_random_post(category = nil, days: nil, score: nil, count: nil) if category.nil? category = Category.where(use_for_advertisement: true) end - Post.undeleted.joins(:post_type).where(post_types: { is_top_level: true }) \ - .where(posts: { last_activity: (Rails.env.development? ? 365 : 7).days.ago..DateTime.now }) \ - .where(posts: { category: category }) \ - .where('posts.score > ?', SiteSetting['HotPostsScoreThreshold']) \ - .order('posts.score DESC').limit(SiteSetting['HotQuestionsCount']).all.sample + if days.nil? + days = Rails.env.development? || Rails.env.test? ? 365 : 7 + end + Post.undeleted.joins(:post_type).where(post_types: { is_top_level: true }) + .where(posts: { last_activity: days.days.ago..DateTime.now }) + .where(posts: { category: category }) + .where('posts.score > ?', score.nil? ? SiteSetting['HotPostsScoreThreshold'] : score) + .order('posts.score DESC').limit(count.nil? ? SiteSetting['HotQuestionsCount'] : count).all.sample end def send_resp(data) diff --git a/app/controllers/answers_controller.rb b/app/controllers/answers_controller.rb index 9efa48cb7..3ad3aa1a6 100644 --- a/app/controllers/answers_controller.rb +++ b/app/controllers/answers_controller.rb @@ -6,7 +6,7 @@ class AnswersController < ApplicationController before_action :check_if_answer_locked, only: [:convert_to_comment] def convert_to_comment - return not_found unless current_user.has_post_privilege?('flag_curate', @answer) + return not_found! unless current_user.post_privilege?('flag_curate', @answer) text = @answer.body_markdown comments = helpers.split_words_max_length(text, 500) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index efa5963ca..119869a3d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -48,20 +48,20 @@ def configure_permitted_parameters devise_parameter_sanitizer.permit(:account_update, keys: [:profile, :website, :twitter]) end - def not_found(**add) + def not_found!(**add) respond_to do |format| format.json do render json: { status: 'failed', success: false, errors: ['not_found'] }.merge(add), status: :not_found end format.any do - render 'errors/not_found', layout: 'without_sidebar', status: :not_found + render 'errors/not_found', formats: [:html], layout: 'without_sidebar', status: :not_found end end false end def verify_moderator - if !user_signed_in? || !(current_user.is_moderator || current_user.is_admin) + if !user_signed_in? || !current_user.at_least_moderator? respond_to do |format| format.html do render 'errors/not_found', layout: 'without_sidebar', status: :not_found @@ -77,7 +77,7 @@ def verify_moderator end def verify_admin - if !user_signed_in? || !current_user.is_admin + if !user_signed_in? || !current_user.admin? render 'errors/not_found', layout: 'without_sidebar', status: :not_found return false end @@ -93,7 +93,7 @@ def verify_global_admin end def verify_global_moderator - if !user_signed_in? || !(current_user.is_global_moderator || current_user.is_global_admin) + if !user_signed_in? || !current_user.at_least_global_moderator? render 'errors/not_found', layout: 'without_sidebar', status: :not_found return false end @@ -109,7 +109,7 @@ def verify_developer end def check_your_privilege(name, post = nil, render_error = true) - unless current_user&.privilege?(name) || (current_user&.has_post_privilege?(name, post) if post) + unless current_user&.privilege?(name) || (current_user&.post_privilege?(name, post) if post) @privilege = Ability.find_by(name: name) render 'errors/forbidden', layout: 'without_sidebar', privilege_name: name, status: :forbidden if render_error return false @@ -118,7 +118,7 @@ def check_your_privilege(name, post = nil, render_error = true) end def check_if_locked(post) - return if current_user.is_moderator + return if current_user.at_least_moderator? if post.locked? respond_to do |format| @@ -136,33 +136,6 @@ def second_level_post_types helpers.post_type_ids(is_top_level: false, has_parent: true) end - def check_edits_limit!(post) - recent_edits = SuggestedEdit.where(created_at: 24.hours.ago..DateTime.now, user: current_user) \ - .where('active = TRUE OR accepted = FALSE').count - - max_edits = SiteSetting[if current_user.privilege?('unrestricted') - 'RL_SuggestedEdits' - else - 'RL_NewUserSuggestedEdits' - end] - - edit_limit_msg = if current_user.privilege? 'unrestricted' - "You may only suggest #{max_edits} edits per day." - else - "You may only suggest #{max_edits} edits per day. " \ - 'Once you have some well-received posts, that limit will increase.' - end - - if recent_edits >= max_edits - post.errors.add :base, edit_limit_msg - AuditLog.rate_limit_log(event_type: 'suggested_edits', related: post, user: current_user, - comment: "limit: #{max_edits}") - render :edit, status: :bad_request - return true - end - false - end - private def distinguish_fake_community @@ -170,22 +143,22 @@ def distinguish_fake_community return redirect_to :fc_communities if request.fullpath == '/' unless devise_controller? || ['fake_community', 'admin', 'users', 'site_settings'].include?(controller_name) - not_found + not_found! end else - return not_found if ['fake_community'].include?(controller_name) + not_found! if ['fake_community'].include?(controller_name) end end def stop_the_awful_troll # There shouldn't be any trolls in the test environment... :D - return true if Rails.env.test? + return if Rails.env.test? # Only stop trolls doing things, not looking at them. - return true if request.method.upcase == 'GET' + return if request.method.upcase == 'GET' # Trolls can't be awful without user accounts. User model is already checking for creation cases. - return true if current_user.nil? + return if current_user.nil? ip = current_user.extract_ip_from(request) email_domain = current_user.email.split('@')[-1] @@ -207,9 +180,7 @@ def stop_the_awful_troll format.html { render 'errors/stat', layout: 'without_sidebar', status: 418 } format.json { render json: { status: 'failed', message: ApplicationRecord.useful_err_msg.sample }, status: 418 } end - return false end - true end def set_globals @@ -219,13 +190,13 @@ def set_globals pull_pinned_links_and_hot_questions pull_categories - if user_signed_in? && current_user.is_moderator + if user_signed_in? && current_user.at_least_moderator? @open_flags = Flag.unhandled.count end @first_visit_notice = !user_signed_in? && cookies[:dismiss_fvn] != 'true' - if current_user&.is_admin + if current_user&.admin? Rack::MiniProfiler.authorize_request end end @@ -278,8 +249,7 @@ def pull_pinned_links_and_hot_questions @hot_questions = Rails.cache.fetch('hot_questions', expires_in: 4.hours) do Rack::MiniProfiler.step 'hot_questions: cache miss' do - Post.undeleted.where(closed: false) - .where(locked: false) + Post.undeleted.not_locked.where(closed: false) .where(last_activity: (Rails.env.development? ? 365 : 7).days.ago..DateTime.now) .where(post_type_id: [Question.post_type_id, Article.post_type_id]) .joins(:category).where(categories: { use_for_hot_posts: true }) @@ -299,7 +269,7 @@ def pull_categories def check_if_warning_or_suspension_pending return if current_user.nil? - warning = ModWarning.where(community_user: current_user.community_user, active: true).any? + warning = ModWarning.to(current_user).active.any? return unless warning # Ignore devise and warning routes @@ -434,4 +404,12 @@ def storable_location? def store_user_location! store_location_for(:user, request.fullpath) end + + def require_sudo + unless user_signed_in? && session[:sudo].present? && + DateTime.iso8601(session[:sudo]) >= AppConfig.server_settings['user_sudo_duration'].minutes.ago + session[:sudo_return] = request.fullpath + redirect_to user_sudo_path + end + end end diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index c319160f3..7d5e4834f 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -127,9 +127,11 @@ def rss_feed end def post_types - @post_types = @category.post_types.where(is_top_level: true) - if @post_types.count == 1 + @post_types = @category.top_level_post_types + if @post_types.one? redirect_to new_category_post_path(post_type: @post_types.first, category: @category) + elsif @post_types.empty? && current_user&.admin? + redirect_to edit_category_post_types_path(@category, no_return: '1') end end @@ -150,9 +152,7 @@ def category_params end def verify_view_access - unless (current_user&.trust_level || 0) >= (@category.min_view_trust_level || -1) - not_found - end + not_found! unless @category.public? || current_user&.can_see_category?(@category) end def set_list_posts @@ -163,7 +163,7 @@ def set_list_posts 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 - filter_qualifiers = helpers.params_to_qualifiers + filter_qualifiers = helpers.params_to_qualifiers(params) @active_filter = helpers.active_filter if filter_qualifiers.blank? && @active_filter[:name].blank? @@ -194,7 +194,7 @@ def set_list_posts end end - @posts = helpers.qualifiers_to_sql(filter_qualifiers, @posts) + @posts = helpers.qualifiers_to_sql(filter_qualifiers, @posts, current_user) @filtered = filter_qualifiers.any? @posts = @posts.paginate(page: params[:page], per_page: 50).order(sort_param) end diff --git a/app/controllers/close_reasons_controller.rb b/app/controllers/close_reasons_controller.rb index 9f7ea7f9a..7384b203a 100644 --- a/app/controllers/close_reasons_controller.rb +++ b/app/controllers/close_reasons_controller.rb @@ -1,5 +1,7 @@ class CloseReasonsController < ApplicationController before_action :verify_moderator + before_action :set_close_reason, only: [:edit, :update] + before_action :verify_admin_for_global_reasons, only: [:edit, :update] def index @close_reasons = if current_user.is_global_admin && params[:global] == '1' @@ -9,23 +11,9 @@ def index end end - def edit - @close_reason = CloseReason.unscoped.find(params[:id]) - - if !current_user.is_global_admin && @close_reason.community.nil? - not_found - nil - end - end + def edit; end def update - @close_reason = CloseReason.unscoped.find(params[:id]) - - if !current_user.is_global_admin && @close_reason.community.nil? - not_found - return - end - before = @close_reason.attributes.map { |k, v| "#{k}: #{v}" }.join(' ') @close_reason.update close_reason_params after = @close_reason.attributes.map { |k, v| "#{k}: #{v}" }.join(' ') @@ -41,7 +29,7 @@ def update def new if !current_user.is_global_admin && params[:global] == '1' - not_found + not_found! return end @@ -50,7 +38,7 @@ def new def create if !current_user.is_global_admin && params[:global] == '1' - not_found + not_found! return end @@ -78,4 +66,14 @@ def create def close_reason_params params.require(:close_reason).permit(:name, :description, :requires_other_post, :active) end + + def set_close_reason + @close_reason = CloseReason.unscoped.find(params[:id]) + end + + def verify_admin_for_global_reasons + if !current_user.global_admin? && @close_reason.community.nil? + not_found! + end + end end diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb index 3e0e41252..bf7289031 100644 --- a/app/controllers/comments_controller.rb +++ b/app/controllers/comments_controller.rb @@ -1,28 +1,27 @@ # Provides mainly web actions for using and making comments. class CommentsController < ApplicationController - before_action :authenticate_user!, except: [:post, :show, :thread] + before_action :authenticate_user!, except: [:post, :show, :thread, :thread_content] + before_action :set_comment, only: [:update, :destroy, :undelete, :show] - before_action :set_thread, only: [:thread, :thread_rename, :thread_restrict, :thread_unrestrict, :thread_followers] + before_action :set_post, only: [:create_thread] + before_action :set_thread, + only: [:create, :thread, :thread_content, :thread_rename, :thread_restrict, :thread_unrestrict, + :thread_followers] + + before_action :check_post_access, only: [:create_thread, :create] before_action :check_privilege, only: [:update, :destroy, :undelete] + before_action :check_create_access, only: [:create_thread, :create] + before_action :check_reply_access, only: [:create] + before_action :check_restrict_access, only: [:thread_restrict] + before_action :check_thread_access, only: [:thread, :thread_content, :thread_followers] + before_action :check_unrestrict_access, only: [:thread_unrestrict] before_action :check_if_target_post_locked, only: [:create, :post_follow] before_action :check_if_parent_post_locked, only: [:update, :destroy] def create_thread - @post = Post.find(params[:post_id]) - if @post.comments_disabled && !current_user.is_moderator && !current_user.is_admin - render json: { status: 'failed', message: 'Comments have been disabled on this post.' }, status: :forbidden - return - elsif !@post.can_access?(current_user) - return not_found - end - title = params[:title] unless title.present? - title = if params[:body].length > 100 - "#{params[:body][0..100]}..." - else - params[:body] - end + title = helpers.generate_thread_title(params[:body]) end body = params[:body] @@ -30,18 +29,18 @@ def create_thread @comment_thread = CommentThread.new(title: title, post: @post) @comment = Comment.new(post: @post, content: body, user: current_user, comment_thread: @comment_thread) - pings = check_for_pings @comment_thread, body - - rate_limited, limit_message = helpers.comment_rate_limited?(current_user, @post) - if rate_limited - flash[:danger] = limit_message - redirect_to helpers.generic_share_link(@post) - return - end + pings = check_for_pings(@comment_thread, body) success = ActiveRecord::Base.transaction do - @comment_thread.save! - @comment.save! + thread_success = @comment_thread.save + comment_success = @comment.save + full_success = thread_success && comment_success + + unless full_success + raise ActiveRecord::Rollback + end + + full_success end if success @@ -57,7 +56,7 @@ def create_thread ThreadFollower.create(user: tf.user, comment_thread: @comment_thread) end - apply_pings pings + apply_pings(pings) else flash[:danger] = "Could not create comment thread: #{(@comment_thread.errors.full_messages \ + @comment.errors.full_messages).join(', ')}" @@ -66,30 +65,16 @@ def create_thread end def create - @comment_thread = CommentThread.find(params[:id]) - @post = @comment_thread.post - if @post.comments_disabled && !current_user.is_moderator && !current_user.is_admin - render json: { status: 'failed', message: 'Comments have been disabled on this post.' }, status: :forbidden - return - elsif !@post.can_access?(current_user) - return not_found - end - body = params[:content] - pings = check_for_pings @comment_thread, body + pings = check_for_pings(@comment_thread, body) @comment = Comment.new(post: @post, content: body, user: current_user, comment_thread: @comment_thread, has_reference: false) - rate_limited, limit_message = helpers.comment_rate_limited?(current_user, @post) - if rate_limited - flash[:danger] = limit_message - redirect_to helpers.generic_share_link(@post) - return - end + status = @comment.save - if @comment.save - apply_pings pings + if status + apply_pings(pings) @comment_thread.thread_follower.each do |follower| next if follower.user_id == current_user.id next if pings.include? follower.user_id @@ -107,25 +92,31 @@ def create else flash[:danger] = @comment.errors.full_messages.join(', ') end - redirect_to comment_thread_path(@comment_thread.id) + + if params[:inline] == 'true' + redirect_to helpers.generic_share_link(@post, comment_id: status ? @comment.id : nil, + thread_id: @comment_thread.id) + else + redirect_to comment_thread_path(@comment_thread.id) + end end def update @post = @comment.post @comment_thread = @comment.comment_thread before = @comment.content - before_pings = check_for_pings @comment_thread, before + 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}>>") + audit('comment_update', @comment, "from <<#{before}>>\nto <<#{@comment.content}>>") end - after_pings = check_for_pings @comment_thread, @comment.content + 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 }) } + comment: render_to_string(partial: 'comments/comment', + locals: { comment: @comment, pingable: after_pings }) } else render json: { status: 'failed', message: "Comment failed to save (#{@comment.errors.full_messages.join(', ')})" }, @@ -136,26 +127,46 @@ def update def destroy if @comment.update(deleted: true) @comment_thread = @comment.comment_thread + unless current_user.id == @comment.user_id - AuditLog.moderator_audit(event_type: 'comment_delete', related: @comment, user: current_user, - comment: "content <<#{@comment.content}>>") + audit('comment_delete', @comment, "content <<#{@comment.content}>>") + end + + respond_to do |format| + format.html { redirect_to comment_thread_path(@comment_thread.id) } + format.json { render json: { status: 'success' } } end - render json: { status: 'success' } else - render json: { status: 'failed' }, status: :internal_server_error + respond_to do |format| + format.html do + flash[:danger] = I18n.t('comments.errors.delete_comment_server_error') + redirect_to comment_thread_path(@comment_thread.id) + end + format.json { render json: { status: 'failed' }, status: :internal_server_error } + end end end def undelete if @comment.update(deleted: false) @comment_thread = @comment.comment_thread + unless current_user.id == @comment.user_id - AuditLog.moderator_audit(event_type: 'comment_undelete', related: @comment, user: current_user, - comment: "content <<#{@comment.content}>>") + audit('comment_undelete', @comment, "content <<#{@comment.content}>>") + end + + respond_to do |format| + format.html { redirect_to comment_thread_path(@comment_thread.id) } + format.json { render json: { status: 'success' } } end - render json: { status: 'success' } else - render json: { status: 'failed' }, status: :internal_server_error + respond_to do |format| + format.html do + flash[:danger] = I18n.t('comments.errors.undelete_comment_server_error') + redirect_to comment_thread_path(@comment_thread.id) + end + format.json { render json: { status: 'failed' }, status: :internal_server_error } + end end end @@ -167,27 +178,31 @@ def show end def thread - not_found unless @comment_thread.can_access?(current_user) + respond_to do |format| + format.html { render 'comments/thread' } + format.json { render json: @comment_thread } + end + end + + def thread_content + render partial: 'comment_threads/expanded', locals: { inline: params[:inline] == 'true', + show_deleted: params[:show_deleted_comments] == '1', + thread: @comment_thread } end def thread_followers - return not_found unless @comment_thread.can_access?(current_user) - return not_found unless current_user.is_moderator || current_user.is_admin + return not_found! unless current_user&.at_least_moderator? @followers = ThreadFollower.where(comment_thread: @comment_thread).joins(:user, user: :community_user) .includes(:user, user: [:community_user, :avatar_attachment]) respond_to do |format| - format.json do - render json: @followers - end - format.html do - render layout: false - end + format.html { render layout: false } + format.json { render json: @followers } end end def thread_rename - if @comment_thread.read_only? && !current_user.is_moderator + if @comment_thread.read_only? && !current_user.at_least_moderator? flash[:danger] = 'This thread has been locked.' redirect_to comment_thread_path(@comment_thread.id) return @@ -200,28 +215,19 @@ def thread_rename def thread_restrict case params[:type] when 'lock' - return not_found unless current_user.privilege?('flag_curate') && !@comment_thread.locked? - lu = nil unless params[:duration].blank? lu = params[:duration].to_i.days.from_now end @comment_thread.update(locked: true, locked_by: current_user, locked_until: lu) - - redirect_to comment_thread_path(@comment_thread.id) - return when 'archive' - return not_found unless current_user.privilege?('flag_curate') && !@comment_thread.archived? - @comment_thread.update(archived: true, archived_by: current_user) when 'delete' - return not_found unless current_user.privilege?('flag_curate') && !@comment_thread.deleted? - @comment_thread.update(deleted: true, deleted_by: current_user) when 'follow' ThreadFollower.create comment_thread: @comment_thread, user: current_user else - return not_found + return not_found! end render json: { status: 'success' } @@ -230,19 +236,12 @@ def thread_restrict def thread_unrestrict case params[:type] when 'lock' - return not_found unless current_user.privilege?('flag_curate') && @comment_thread.locked? - @comment_thread.update(locked: false, locked_by: nil, locked_until: nil) when 'archive' - return not_found unless current_user.privilege?('flag_curate') && @comment_thread.archived? - @comment_thread.update(archived: false, archived_by: nil, ever_archived_before: true) when 'delete' - return not_found unless current_user.privilege?('flag_curate') && @comment_thread.deleted? - - if @comment_thread.deleted_by.is_moderator && !current_user.is_moderator - render json: { status: 'error', - message: 'Threads deleted by a moderator can only be undeleted by a moderator.' } + if @comment_thread.deleted_by.at_least_moderator? && !current_user.at_least_moderator? + render json: { status: 'error', message: I18n.t('comments.errors.mod_only_undelete') } return end @comment_thread.update(deleted: false, deleted_by: nil) @@ -250,7 +249,7 @@ def thread_unrestrict tf = ThreadFollower.find_by(comment_thread: @comment_thread, user: current_user) tf&.destroy else - return not_found + return not_found! end render json: { status: 'success' } @@ -258,7 +257,7 @@ def thread_unrestrict def post @post = Post.find(params[:post_id]) - @comment_threads = if helpers.moderator? || current_user&.has_post_privilege?('flag_curate', @post) + @comment_threads = if current_user&.at_least_moderator? || current_user&.post_privilege?('flag_curate', @post) CommentThread else CommentThread.undeleted @@ -281,8 +280,7 @@ def post_follow def pingable thread = params[:id] == '-1' ? CommentThread.new(post_id: params[:post]) : CommentThread.find(params[:id]) - ids = helpers.get_pingable(thread) - users = User.where(id: ids) + users = User.where(id: thread.pingable) render json: users.to_h { |u| [u.username, u.id] } end @@ -296,17 +294,83 @@ def set_comment @comment = Comment.unscoped.find params[:id] end + def set_post + @post = Post.find(params[:post_id]) + end + def set_thread @comment_thread = CommentThread.find(params[:id]) @post = @comment_thread.post end + def check_post_access + if !@post.comments_allowed? && current_user&.standard? + respond_to do |format| + format.html { render template: 'errors/forbidden', status: :forbidden } + format.json do + message = helpers.comments_post_error_msg(@post) + render json: { status: 'failed', message: message }, + status: :forbidden + end + end + elsif !@post.can_access?(current_user) + not_found! + end + end + + def check_thread_access + not_found! unless @comment_thread.can_access?(current_user) + end + def check_privilege - unless current_user.is_moderator || current_user.is_admin || current_user == @comment.user + unless current_user&.at_least_moderator? || current_user == @comment.user render template: 'errors/forbidden', status: :forbidden end end + def check_create_access + rate_limited, limit_message = helpers.comment_rate_limited?(current_user, @post) + if rate_limited + flash[:danger] = limit_message + redirect_to helpers.generic_share_link(@post) + end + end + + def check_reply_access + if @comment_thread.read_only? && current_user&.standard? + respond_to do |format| + format.html { render template: 'errors/forbidden', status: :forbidden } + format.json do + message = helpers.comments_thread_error_msg(@comment_thread) + render json: { status: 'failed', message: message }, + status: :forbidden + end + end + end + end + + def check_restrict_access + case params[:type] + when 'lock' + not_found! unless current_user.can_lock?(@comment_thread) + when 'archive' + not_found! unless current_user.can_archive?(@comment_thread) + when 'delete' + not_found! unless current_user.can_delete?(@comment_thread) + end + end + + def check_unrestrict_access + case params[:type] + when 'lock' + not_found! unless current_user.can_unlock?(@comment_thread) + when 'archive' + not_found! unless current_user.can_unarchive?(@comment_thread) + when 'delete' + not_found! unless current_user.can_undelete?(@comment_thread) + end + end + def check_if_parent_post_locked check_if_locked(@comment.post) end @@ -315,12 +379,16 @@ def check_if_target_post_locked check_if_locked(Post.find(params[:post_id])) end + # @param thread [CommentThread] thread to extract pings for + # @param content [String] content to extract pings from + # @return [Array] list of pinged user ids def check_for_pings(thread, content) - pingable = helpers.get_pingable(thread) + pingable = thread.pingable matches = content.scan(/@#(\d+)/) matches.flatten.select { |m| pingable.include?(m.to_i) }.map(&:to_i) end + # @param pings [Array] list of pinged user ids def apply_pings(pings) pings.each do |p| user = User.where(id: p).first @@ -334,4 +402,14 @@ def apply_pings(pings) helpers.comment_link(@comment)) end end + + # @param event_type [String] audit log event type + # @param comment [Comment] comment the audit is about + # @param audit_comment [String] additional info to log + def audit(event_type, comment, audit_comment = '') + AuditLog.moderator_audit(event_type: event_type, + comment: audit_comment, + related: comment, + user: current_user) + end end diff --git a/app/controllers/email_logs_controller.rb b/app/controllers/email_logs_controller.rb index 40cded41f..3a9e4e6ed 100644 --- a/app/controllers/email_logs_controller.rb +++ b/app/controllers/email_logs_controller.rb @@ -15,7 +15,7 @@ def log EmailLog.create(log_type: 'SubscriptionConfirmation', data: aws_data) else message_data = JSON.parse aws_data['Message'] - log_type = message_data['notificationType'] + log_type = message_data['notificationType'] || message_data['eventType'] destination = message_data['mail']['destination'].join(', ') EmailLog.create(log_type: log_type, destination: destination, data: aws_data['Message']) end diff --git a/app/controllers/flags_controller.rb b/app/controllers/flags_controller.rb index f505f08fc..e98eb97c6 100644 --- a/app/controllers/flags_controller.rb +++ b/app/controllers/flags_controller.rb @@ -10,7 +10,7 @@ def new PostFlagType.find params[:flag_type] end - recent_flags = Flag.where(created_at: 24.hours.ago..DateTime.now, user: current_user).count + recent_flags = Flag.by(current_user).recent.count max_flags_per_day = SiteSetting[current_user.privilege?('unrestricted') ? 'RL_Flags' : 'RL_NewUserFlags'] if recent_flags >= max_flags_per_day @@ -42,8 +42,8 @@ def new def history @user = helpers.user_with_me params[:id] - unless @user == current_user || (current_user.is_admin || current_user.is_moderator) - not_found + unless @user == current_user || current_user.at_least_moderator? + not_found! return end @flags = @user.flags.includes(:post).order(id: :desc).paginate(page: params[:page], per_page: 50) @@ -55,7 +55,7 @@ def queue end def handled - @flags = Flag.handled.includes(:post, :user, :handled_by).order(created_at: :desc) + @flags = Flag.handled.includes(:post, :user, :handled_by).newest_first .paginate(page: params[:page], per_page: 50) end @@ -86,13 +86,16 @@ def escalated_queue def flag_verify @flag = Flag.find params[:id] + return false if current_user.nil? type = @flag.post_flag_type - unless current_user.is_moderator - return not_found unless current_user.privilege? 'flag_curate' - return not_found if type.nil? || type.confidential - return not_found if current_user.id == @flag.user.id + + unless current_user.at_least_moderator? + return not_found! unless current_user.privilege? 'flag_curate' + return not_found! if type.nil? || type.confidential + + not_found! if current_user.same_as?(@flag.user) end end diff --git a/app/controllers/micro_auth/apps_controller.rb b/app/controllers/micro_auth/apps_controller.rb index 635e2b703..12912a022 100644 --- a/app/controllers/micro_auth/apps_controller.rb +++ b/app/controllers/micro_auth/apps_controller.rb @@ -23,9 +23,9 @@ def new def create @app = MicroAuth::App.new(app_params.merge({ public_key: SecureRandom.base58(32), - secret_key: SecureRandom.base58(32), - app_id: generate_app_id, - user: current_user + secret_key: SecureRandom.base58(32), + app_id: generate_app_id, + user: current_user })) if @app.save redirect_to oauth_app_path(@app.app_id) @@ -66,7 +66,7 @@ def set_app end def verify_ownership - not_found unless @app.user == current_user || helpers.admin? + not_found! unless @app.user.same_as?(current_user) || helpers.admin? end def app_params diff --git a/app/controllers/micro_auth/authentication_controller.rb b/app/controllers/micro_auth/authentication_controller.rb index fa7f65f38..83365550f 100644 --- a/app/controllers/micro_auth/authentication_controller.rb +++ b/app/controllers/micro_auth/authentication_controller.rb @@ -73,7 +73,7 @@ def token def set_app @app = MicroAuth::App.find_by app_id: params[:app_id] - not_found if @app.nil? + not_found! if @app.nil? end def clean_scope(scope) diff --git a/app/controllers/mod_warning_controller.rb b/app/controllers/mod_warning_controller.rb index d8579a08d..fa292c731 100644 --- a/app/controllers/mod_warning_controller.rb +++ b/app/controllers/mod_warning_controller.rb @@ -9,7 +9,7 @@ def current end def approve - return not_found if @warning.suspension_active? + return not_found! if @warning.suspension_active? if params[:approve_checkbox].nil? @failed_to_click_checkbox = true @@ -21,13 +21,13 @@ def approve end def log - @warnings = ModWarning.where(community_user: @user.community_user).order(created_at: :desc).all + @warnings = ModWarning.to(@user).newest_first.all render layout: 'without_sidebar' end def new @templates = WarningTemplate.where(active: true).all - @prior_warning_count = ModWarning.where(community_user: @user.community_user).order(created_at: :desc).count + @prior_warning_count = ModWarning.to(@user).newest_first.count @warning = ModWarning.new(author: current_user, community_user: @user.community_user) render layout: 'without_sidebar' end @@ -58,8 +58,8 @@ def create end def lift - @warning = ModWarning.where(community_user: @user.community_user, active: true).last - return not_found if @warning.nil? + @warning = ModWarning.to(@user).active.last + return not_found! if @warning.nil? @warning.update(active: false, read: false) @user.community_user.update is_suspended: false, suspension_public_comment: nil, suspension_end: nil @@ -75,13 +75,13 @@ def lift private def set_warning - @warning = ModWarning.where(community_user: current_user.community_user, active: true).last - not_found if @warning.nil? + @warning = ModWarning.to(current_user).active.last + not_found! if @warning.nil? end def set_user @user = user_scope.find_by(id: params[:user_id]) - not_found if @user.nil? + not_found! if @user.nil? end def user_scope diff --git a/app/controllers/moderator_controller.rb b/app/controllers/moderator_controller.rb index 04d2a94b5..57870a00d 100644 --- a/app/controllers/moderator_controller.rb +++ b/app/controllers/moderator_controller.rb @@ -1,25 +1,27 @@ # Web controller. Provides authenticated actions for use by moderators. A lot of the stuff in here, and hence a lot of # the tools, are rather repetitive. class ModeratorController < ApplicationController - before_action :verify_moderator, except: [:nominate_promotion, :promotions, :remove_promotion] - before_action :authenticate_user!, only: [:nominate_promotion, :promotions, :remove_promotion] + before_action :verify_can_see_deleted_posts, only: [:recently_deleted_posts] + before_action :verify_moderator, + except: [:nominate_promotion, :promotions, :remove_promotion, :recently_deleted_posts] + before_action :authenticate_user!, + only: [:nominate_promotion, :promotions, :remove_promotion, :recently_deleted_posts] before_action :set_post, only: [:nominate_promotion, :remove_promotion] before_action :unless_locked, only: [:nominate_promotion, :remove_promotion] def index; end def recently_deleted_posts - @posts = Post.unscoped.where(community: @community, deleted: true).order('deleted_at DESC') - .paginate(page: params[:page], per_page: 50) + @posts = Post.unscoped.on(@community).deleted.order(deleted_at: :desc).paginate(page: params[:page], per_page: 50) end def recent_comments - @comments = Comment.all.includes(:user, :post).order(created_at: :desc).paginate(page: params[:page], per_page: 50) + @comments = Comment.all.includes(:user, :post).newest_first.paginate(page: params[:page], per_page: 50) end def nominate_promotion - return not_found(errors: ['no_privilege']) unless current_user.privilege? 'flag_curate' - return not_found(errors: ['unavailable_for_type']) unless top_level_post_types.include? @post.post_type_id + return not_found!(errors: ['no_privilege']) unless current_user.privilege? 'flag_curate' + return not_found!(errors: ['unavailable_for_type']) unless top_level_post_types.include? @post.post_type_id PostHistory.nominated_for_promotion(@post, current_user) nominations = helpers.promoted_posts @@ -30,7 +32,7 @@ def nominate_promotion end def promotions - return not_found(errors: ['no_privilege']) unless current_user.privilege? 'flag_curate' + return not_found!(errors: ['no_privilege']) unless current_user.privilege? 'flag_curate' # This is network-wide, but the Post selection will default to current site only, so not a problem. @promotions = helpers.promoted_posts @@ -38,10 +40,10 @@ def promotions end def remove_promotion - return not_found(errors: ['no_privilege']) unless current_user.privilege? 'flag_curate' + return not_found!(errors: ['no_privilege']) unless current_user.privilege? 'flag_curate' promotions = helpers.promoted_posts - return not_found(errors: ['not_promoted']) unless promotions.keys.include? @post.id.to_s + return not_found!(errors: ['not_promoted']) unless promotions.keys.include? @post.id.to_s promotions = promotions.reject { |k, _v| k == @post.id.to_s } RequestContext.redis.set 'network/promoted_posts', JSON.dump(promotions) @@ -53,18 +55,18 @@ def remove_promotion def user_vote_summary @user = User.find params[:id] - @users = User.where(id: Vote.where(user: @user).select(:recv_user_id).distinct) - .or(User.where(id: Vote.where(recv_user: @user).select(:user_id).distinct)) + @users = User.where(id: Vote.by(@user).select(:recv_user_id).distinct) + .or(User.where(id: Vote.for(@user).select(:user_id).distinct)) @vote_data = VoteData.new( cast: VoteSummary.new( - breakdown: Vote.where(user: @user).group(:recv_user_id, :vote_type).count, - types: Vote.where(user: @user).group(:vote_type).count, - total: Vote.where(user: @user).count + breakdown: Vote.by(@user).group(:recv_user_id, :vote_type).count, + types: Vote.by(@user).group(:vote_type).count, + total: Vote.by(@user).count ), received: VoteSummary.new( - breakdown: Vote.where(recv_user: @user).group(:user_id, :vote_type).count, - types: Vote.where(recv_user: @user).group(:vote_type).count, - total: Vote.where(recv_user: @user).count + breakdown: Vote.for(@user).group(:user_id, :vote_type).count, + types: Vote.for(@user).group(:vote_type).count, + total: Vote.for(@user).count ) ) end @@ -78,4 +80,12 @@ def set_post def unless_locked check_if_locked(@post) end + + def verify_can_see_deleted_posts + if !user_signed_in? || !current_user.can_see_deleted_posts? + render 'errors/not_found', layout: 'without_sidebar', status: :not_found + return false + end + true + end end diff --git a/app/controllers/pinned_links_controller.rb b/app/controllers/pinned_links_controller.rb index 835e55c12..b01f33105 100644 --- a/app/controllers/pinned_links_controller.rb +++ b/app/controllers/pinned_links_controller.rb @@ -1,11 +1,12 @@ class PinnedLinksController < ApplicationController before_action :verify_moderator before_action :set_pinned_link, only: [:edit, :update] + before_action :verify_mod_on_current_community, only: [:edit, :update] def index - links = if current_user.is_global_moderator && params[:global] == '2' + links = if current_user.at_least_global_moderator? && params[:global] == '2' PinnedLink.unscoped - elsif current_user.is_global_moderator && params[:global] == '1' + elsif current_user.at_least_global_moderator? && params[:global] == '1' PinnedLink.where(community: nil) else PinnedLink.where(community: @community) @@ -36,17 +37,9 @@ def create redirect_to pinned_links_path end - def edit - if !current_user.is_global_moderator && @link.community_id != RequestContext.community_id - not_found - end - end + def edit; end def update - if !current_user.is_global_moderator && @link.community_id != RequestContext.community_id - return not_found - end - before = @link.attributes_print @link.update pinned_link_params after = @link.attributes_print @@ -60,7 +53,7 @@ def update private def set_pinned_link - @link = if current_user.is_global_moderator + @link = if current_user.at_least_global_moderator? PinnedLink.unscoped.find params[:id] else PinnedLink.find params[:id] @@ -68,11 +61,17 @@ def set_pinned_link end def pinned_link_params - if current_user.is_global_moderator + if current_user.at_least_global_moderator? params.require(:pinned_link).permit(:label, :link, :post_id, :active, :shown_before, :shown_after, :community_id) else params.require(:pinned_link).permit(:label, :link, :post_id, :active, :shown_before, :shown_after) .merge(community_id: RequestContext.community_id) end end + + def verify_mod_on_current_community + if !current_user.at_least_global_moderator? && @link.community_id != RequestContext.community_id + not_found! + end + end end diff --git a/app/controllers/post_history_controller.rb b/app/controllers/post_history_controller.rb index 543771e02..6407144e5 100644 --- a/app/controllers/post_history_controller.rb +++ b/app/controllers/post_history_controller.rb @@ -3,7 +3,7 @@ def post @post = Post.find(params[:id]) unless @post.can_access?(current_user) - return not_found + return not_found! end @history = PostHistory.where(post_id: params[:id]) @@ -22,7 +22,7 @@ def slug_post @post = Post.by_slug(params[:slug], current_user) if @post.nil? - return not_found + return not_found! end @history = PostHistory.where(post_id: @post.id) diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 2026e0da7..745f0fb96 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -67,7 +67,7 @@ def create return end - if @category.present? && @category.min_trust_level.present? && @category.min_trust_level > current_user.trust_level + if @category.present? && !current_user.can_post_in?(@category) @post.errors.add(:base, helpers.i18ns('posts.category_low_trust_level', name: @category.name)) render :new, status: :forbidden return @@ -79,8 +79,7 @@ def create level_name = @post_type.is_top_level? ? 'TopLevel' : 'SecondLevel' level_type_ids = @post_type.is_top_level? ? top_level_post_types : second_level_post_types - recent_level_posts = Post.where(created_at: 24.hours.ago..DateTime.now, user: current_user) - .where(post_type_id: level_type_ids).count + recent_level_posts = Post.by(current_user).recent.where(post_type_id: level_type_ids).count setting_name = current_user.privilege?('unrestricted') ? "RL_#{level_name}Posts" : "RL_NewUser#{level_name}Posts" max_posts = SiteSetting[setting_name] limit_msg = if current_user.privilege?('unrestricted') @@ -131,17 +130,17 @@ def show redirect_to policy_path(@post.doc_slug) end - if @post.deleted? && !current_user&.has_post_privilege?('flag_curate', @post) - return not_found + if @post.deleted? && !current_user&.post_privilege?('flag_curate', @post) + return not_found! end @top_level_post_types = top_level_post_types @second_level_post_types = second_level_post_types - if @post.category_id.present? && @post.category.min_view_trust_level.present? && \ - (!user_signed_in? || current_user.trust_level < @post.category.min_view_trust_level) && \ + if @post.category_id.present? && @post.category.min_view_trust_level.present? && + (!user_signed_in? || current_user.trust_level < @post.category.min_view_trust_level) && @post.category.min_view_trust_level.positive? - return not_found + return not_found! end # @post = @post.includes(:flags, flags: :post_flag_type) @@ -213,8 +212,8 @@ def update return redirect_to post_path(@post) end - if current_user.can_update(@post, @post_type) - if current_user.can_push_to_network(@post_type) && params[:network_push] == 'true' + 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], @@ -276,8 +275,8 @@ def update end else new_user = !current_user.privilege?('unrestricted') - rate_limit = SiteSetting["RL_#{new_user ? 'NewUser' : ''}SuggestedEdits"] - recent_edits = SuggestedEdit.where(user: current_user, active: true).where('created_at > ?', 24.hours.ago).count + rate_limit = SiteSetting["RL_#{'NewUser' if new_user}SuggestedEdits"] + recent_edits = SuggestedEdit.by(current_user).where(active: true).recent.count if recent_edits >= rate_limit key = new_user ? 'rate_limit.new_user_suggested_edits' : 'rate_limit.suggested_edits' msg = helpers.i18ns key, count: rate_limit @@ -380,6 +379,30 @@ def reopen redirect_to post_path(@post) end + # Attempts to delete a given post + # @param post [Post] post to delete + # @param user [User] user attempting to delete the post + # @return [Boolean] status of the operation + def do_delete(post, user) + post.update(deleted: true, + deleted_at: DateTime.now, + deleted_by: user, + last_activity: DateTime.now, + last_activity_by: user) + end + + # Attempts to delete children of a given post + # @param post [Post] post to delete children of + # @param user [User] user attempting to delete the post's children + # @return [Boolean] status of the operation + def do_delete_children(post, user) + post.children.undeleted.update_all(deleted: true, + deleted_at: DateTime.now, + deleted_by_id: user.id, + last_activity: DateTime.now, + last_activity_by_id: user.id) + end + def delete unless check_your_privilege('flag_curate', @post, false) flash[:danger] = helpers.ability_err_msg(:flag_curate, 'delete this post') @@ -387,13 +410,13 @@ def delete return end - if @post.post_type.is_freely_editable && !current_user&.is_moderator + if @post.post_type.is_freely_editable && !current_user&.at_least_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 + if @post.children.any? { |a| !a.deleted? && a.score >= 0.5 } && !current_user&.at_least_moderator? flash[:danger] = helpers.i18ns('posts.cant_delete_responded') redirect_to post_path(@post) return @@ -405,21 +428,32 @@ def delete return end - if @post.update(deleted: true, deleted_at: DateTime.now, deleted_by: current_user, - last_activity: DateTime.now, last_activity_by: current_user) - PostHistory.post_deleted(@post, current_user) - if @post.children.where(deleted: false).any? - @post.children.where(deleted: false).update_all(deleted: true, deleted_at: DateTime.now, - deleted_by_id: current_user.id, last_activity: DateTime.now, - last_activity_by_id: current_user.id) + # post deletion, its children deletion, and post history creation must all be made as one atomic operation + @post.transaction do + unless do_delete(@post, current_user) + flash[:danger] = helpers.i18ns('posts.cant_delete_post') + raise ActiveRecord::Rollback + end + + history_entry = PostHistory.post_deleted(@post, current_user) + + if history_entry&.errors&.any? + @post.errors.merge!(history_entry.errors) + raise ActiveRecord::Rollback + end + + if @post.children.undeleted.any? + unless do_delete_children(@post, current_user) + raise ActiveRecord::Rollback + end + histories = @post.children.map do |c| { post_history_type: PostHistoryType.find_by(name: 'post_deleted'), user: current_user, post: c, community: RequestContext.community } end - PostHistory.create(histories) + + PostHistory.create!(histories) end - else - flash[:danger] = helpers.i18ns('posts.cant_delete_post') end redirect_to post_path(@post) @@ -438,7 +472,7 @@ def restore return end - if @post.deleted_by.is_moderator && !current_user.is_moderator + if @post.deleted_by.at_least_moderator? && !current_user&.at_least_moderator? flash[:danger] = helpers.i18ns('posts.cant_restore_deleted_by_moderator') redirect_to post_path(@post) return @@ -467,7 +501,7 @@ def document @post = Post.by_slug(params[:slug], current_user) if @post.nil? - not_found + not_found! end # Make sure we don't leak featured posts in the sidebar @@ -520,8 +554,12 @@ def change_category end @post.tags = new_tags success = @post.save - AuditLog.action_audit(event_type: 'change_category', related: @post, user: current_user, - comment: "from <<#{before.id}: #{before.name}>>\nto <<#{@target.id}: #{@target.name}>>") + if success + PostHistory.category_changed(@post, current_user, before: "#{before.name} (##{before.id})", + after: "#{@target.name} (##{@target.id})") + AuditLog.action_audit(event_type: 'change_category', related: @post, user: current_user, + comment: "from <<#{before.id}: #{before.name}>>\nto <<#{@target.id}: #{@target.name}>>") + end render json: { success: success, errors: success ? [] : @post.errors.full_messages }, status: success ? 200 : 409 end @@ -535,40 +573,45 @@ def toggle_comments end def lock - return not_found unless current_user.privilege? 'flag_curate' - return not_found if @post.locked? + return not_found! unless current_user&.can_lock?(@post) length = params[:length].present? ? params[:length].to_i : nil if length - if !current_user.is_moderator && length > 30 + if !current_user&.at_least_moderator? && length > 30 length = 30 end end_date = length.days.from_now - elsif current_user.is_moderator + elsif current_user&.at_least_moderator? end_date = nil else end_date = 7.days.from_now end - @post.update locked: true, locked_by: current_user, - locked_at: DateTime.now, locked_until: end_date + ApplicationRecord.transaction do + @post.update! locked: true, locked_by: current_user, + locked_at: DateTime.now, locked_until: end_date + PostHistory.post_locked @post, current_user, before: end_date.nil? ? '' : "Locked until: #{end_date.iso8601}" + end render json: { status: 'success', success: true } end def unlock - return not_found(errors: ['no_privilege']) unless current_user.privilege? 'flag_curate' - return not_found(errors: ['not_locked']) unless @post.locked? - if @post.locked_by.is_moderator && !current_user.is_moderator - return not_found(errors: ['locked_by_mod']) + return not_found! unless current_user&.can_unlock?(@post) + + if @post.locked_by.at_least_moderator? && !current_user&.at_least_moderator? + return not_found!(errors: ['locked_by_mod']) end - @post.update locked: false, locked_by: nil, - locked_at: nil, locked_until: nil + ApplicationRecord.transaction do + @post.update! locked: false, locked_by: nil, + locked_at: nil, locked_until: nil + PostHistory.post_unlocked @post, current_user + end render json: { status: 'success', success: true } end def feature - return not_found(errors: ['no_privilege']) unless current_user.is_moderator + return not_found!(errors: ['no_privilege']) unless current_user&.at_least_moderator? data = { label: @post.parent.nil? ? @post.title : @post.parent.title, @@ -658,7 +701,7 @@ def check_permissions elsif @post.post_type_id == PolicyDoc.post_type_id verify_admin else - not_found + not_found! end end @@ -672,7 +715,7 @@ def edit_checks redirect_back fallback_location: root_path end - if !@post_type.is_public_editable && !(@post.user == current_user || current_user.is_moderator) + if !@post_type.is_public_editable && !(@post.user == current_user || current_user&.at_least_moderator?) flash[:danger] = helpers.i18ns('posts.not_public_editable') redirect_back fallback_location: root_path end diff --git a/app/controllers/reactions_controller.rb b/app/controllers/reactions_controller.rb index 23acd2311..d0b705576 100644 --- a/app/controllers/reactions_controller.rb +++ b/app/controllers/reactions_controller.rb @@ -10,7 +10,7 @@ def add comment = nil if !comment_text.blank? - if @post.comments_disabled && !current_user.is_moderator && !current_user.is_admin + if @post.comments_disabled && !current_user.at_least_moderator? render json: { status: 'failed', message: 'Comments have been disabled on this post.' }, status: :forbidden return end @@ -88,7 +88,7 @@ def create def set_post @post = Post.find(params[:post_id]) unless @post.can_access?(current_user) - not_found + not_found! end end diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 66a9a79b8..1c8ea4eb9 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -4,25 +4,26 @@ class ReportsController < ApplicationController before_action :verify_global_moderator, only: [:users_global, :subs_global, :posts_global] def users - @users_all = User.joins(:community_users).where(community_users: { community_id: RequestContext.community_id }) - .where('users.created_at >= ?', 1.year.ago) + @users_all = User.joins(:community_users) + .where(community_users: { community_id: RequestContext.community_id }) + .recent(1.year.ago) @users = @users_all.where("users.email NOT LIKE '%localhost'") @users_se = @users_all.where("users.email LIKE '%localhost'") end def subscriptions - @subs = Subscription.where('created_at >= ?', 1.year.ago) + @subs = Subscription.recent(1.year.ago) @types = Subscription.all.group(:type).count end def posts - @questions = Question.where('created_at >= ?', 1.year.ago).undeleted - @answers = Answer.where('created_at >= ?', 1.year.ago).undeleted - @comments = Comment.where('created_at >= ?', 1.year.ago).undeleted - @this_month = Post.where('created_at >= ?', 1.month.ago).undeleted + @questions = Question.recent(1.year.ago).undeleted + @answers = Answer.recent(1.year.ago).undeleted + @comments = Comment.recent(1.year.ago).undeleted + @this_month = Post.recent(1.month.ago).undeleted @categories = Category.where('IFNULL(categories.min_view_trust_level, 0) <= ?', current_user&.trust_level || 0) .order(:sequence) - @posts_categories = Post.where(category: @categories).group(:category_id).count + @posts_categories = Post.in(@categories).group(:category_id).count end def reactions @@ -32,27 +33,27 @@ def reactions end def users_global - @users_all = User.where('users.created_at >= ?', 1.year.ago) + @users_all = User.recent(1.year.ago) @users = @users_all.where("users.email NOT LIKE '%localhost'") @users_se = @users_all.where("users.email LIKE '%localhost'") render :users end def subs_global - @subs = Subscription.unscoped.where('created_at >= ?', 1.year.ago) + @subs = Subscription.unscoped.recent(1.year.ago) @types = Subscription.unscoped.all.group(:type).count render :subscriptions end def posts_global - @questions = Post.unscoped.where(post_type_id: Question.post_type_id).where('created_at >= ?', 1.year.ago).undeleted - @answers = Post.unscoped.where(post_type_id: Answer.post_type_id).where('created_at >= ?', 1.year.ago).undeleted - @comments = Comment.unscoped.where('created_at >= ?', 1.year.ago).undeleted - @this_month = Post.unscoped.where('created_at >= ?', 1.month.ago).undeleted + @questions = Post.unscoped.where(post_type_id: Question.post_type_id).recent(1.year.ago).undeleted + @answers = Post.unscoped.where(post_type_id: Answer.post_type_id).recent(1.year.ago).undeleted + @comments = Comment.unscoped.recent(1.year.ago).undeleted + @this_month = Post.unscoped.recent(1.month.ago).undeleted @categories = Category.unscoped .where('IFNULL(categories.min_view_trust_level, 0) <= ?', current_user&.trust_level || 0) .includes(:community).order(:community_id, :sequence) - @posts_categories = Post.unscoped.where(category: @categories).group(:category_id).count + @posts_categories = Post.unscoped.in(@categories).group(:category_id).count @global = true render :posts end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 202a3636b..eb47d38c4 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -1,6 +1,6 @@ class SearchController < ApplicationController def search - @posts, @qualifiers = helpers.search_posts + @posts, @qualifiers = helpers.search_posts(current_user, params) @signed_out_me = @qualifiers.any? { |q| q[:param] == :user && q[:user_id].nil? } diff --git a/app/controllers/site_settings_controller.rb b/app/controllers/site_settings_controller.rb index 256b54ecc..da9212966 100644 --- a/app/controllers/site_settings_controller.rb +++ b/app/controllers/site_settings_controller.rb @@ -4,10 +4,10 @@ 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] + # Does a given user have access to site settings on a given community? + # @param user [User] user to check access for + # @param community_id [String, nil] id of the community to check access on + # @return [Boolean] Check result def access?(user, community_id) community_id.present? || user.is_global_admin end @@ -35,9 +35,9 @@ def show 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 + # @param user [User] initiating user + # @param before [SiteSetting] current site setting + # @param after [SiteSetting] updated site setting # @return [void] def audit_update(user, before, after) AuditLog.admin_audit(event_type: 'setting_update', @@ -47,16 +47,16 @@ def audit_update(user, before, after) 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] + # @param setting [SiteSetting] site setting to clear cache for + # @param community_id [String, nil] community id to clear cache for + # @return [Boolean] Whether th cache has been successfully deleted 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 + # @param setting [SiteSetting] site setting to create + # @param community_id [String, nil] community id to create a setting for # @return [SiteSetting] def do_create(setting, community_id) SiteSetting.create(name: setting.name, @@ -69,13 +69,13 @@ def do_create(setting, community_id) def update unless access?(current_user, params[:community_id]) - not_found + not_found! return end @setting = if params[:community_id].present? matches = SiteSetting.unscoped.where(community_id: RequestContext.community_id, name: params[:name]) - if matches.count.zero? + if matches.none? global = SiteSetting.unscoped.where(community_id: nil, name: params[:name]).first do_create(global, RequestContext.community_id) else diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb index 5e867048a..8113e7f5c 100644 --- a/app/controllers/subscriptions_controller.rb +++ b/app/controllers/subscriptions_controller.rb @@ -24,28 +24,34 @@ def index def enable @subscription = Subscription.find params[:id] - if current_user.is_admin || current_user.id == @subscription.user_id + if current_user.admin? || current_user.id == @subscription.user_id if @subscription.update(enabled: params[:enabled] || false) render json: { status: 'success', subscription: @subscription } else - render json: { status: 'failed' }, status: :internal_server_error + render json: { status: 'failed', + message: 'Failed to update your subscription. Please report this bug on Meta.' }, + status: :internal_server_error end else - render json: { status: 'failed', message: 'You do not have permission to update this subscription.' }, + render json: { status: 'failed', + message: 'You do not have permission to update this subscription.' }, status: :forbidden end end def destroy @subscription = Subscription.find params[:id] - if current_user.is_admin || current_user.id == @subscription.user_id + if current_user.admin? || current_user.id == @subscription.user_id if @subscription.destroy render json: { status: 'success' } else - render json: { status: 'failed' }, status: :internal_server_error + render json: { status: 'failed', + message: 'Failed to remove your subscription. Please report this bug on Meta.' }, + status: :internal_server_error end else - render json: { status: 'failed', message: 'You do not have permission to remove this subscription.' }, + render json: { status: 'failed', + message: 'You do not have permission to remove this subscription.' }, status: :forbidden end end diff --git a/app/controllers/sudo_controller.rb b/app/controllers/sudo_controller.rb new file mode 100644 index 000000000..eade2d99e --- /dev/null +++ b/app/controllers/sudo_controller.rb @@ -0,0 +1,15 @@ +class SudoController < ApplicationController + before_action :authenticate_user! + + def sudo; end + + def enter_sudo + if current_user.valid_password? params[:password] + session[:sudo] = DateTime.now.iso8601 + redirect_to session[:sudo_return] + else + flash[:danger] = 'The password you entered was incorrect.' + render :sudo + end + end +end diff --git a/app/controllers/suggested_edit_controller.rb b/app/controllers/suggested_edit_controller.rb index c724df4a5..c6fce38fc 100644 --- a/app/controllers/suggested_edit_controller.rb +++ b/app/controllers/suggested_edit_controller.rb @@ -4,11 +4,9 @@ class SuggestedEditController < ApplicationController def category_index @category = params[:category].present? ? Category.find(params[:category]) : nil @edits = if params[:show_decided].present? && params[:show_decided] == '1' - SuggestedEdit.where(post: Post.undeleted.where(category: @category), active: false) \ - .order('created_at DESC') + SuggestedEdit.where(post: Post.undeleted.in(@category), active: false).newest_first else - SuggestedEdit.where(post: Post.undeleted.where(category: @category), active: true) \ - .order('created_at ASC') + SuggestedEdit.where(post: Post.undeleted.in(@category), active: true).oldest_first end end @@ -23,10 +21,11 @@ def approve end @post = @edit.post - unless check_your_privilege('edit_posts', @post, false) - render(json: { status: 'error', message: helpers.ability_err_msg(:edit_posts, 'review suggested edits') }, - status: :bad_request) + unless current_user&.can_approve?(@edit) + render(json: { status: 'error', + message: helpers.ability_err_msg(:edit_posts, 'review suggested edits') }, + status: :forbidden) return end @@ -85,16 +84,18 @@ def approve def reject unless @edit.active? - render json: { status: 'error', message: 'This edit has already been reviewed.' }, status: :conflict + render json: { status: 'error', + message: 'This edit has already been reviewed.' }, + status: :conflict return end @post = @edit.post - unless check_your_privilege('edit_posts', @post, false) - render(json: { status: 'error', redirect_url: helpers.ability_err_msg(:edit_posts, 'review suggested edits') }, - status: :bad_request) - + unless current_user&.can_reject?(@edit) + render json: { status: 'error', + message: helpers.ability_err_msg(:edit_posts, 'review suggested edits') }, + status: :forbidden return end @@ -104,10 +105,16 @@ def reject decided_by: current_user, updated_at: now) flash[:success] = 'Edit rejected successfully.' AbilityQueue.add(@edit.user, "Suggested Edit Rejected ##{@edit.id}") - render json: { status: 'success', redirect_url: helpers.generic_share_link(@post) } + render json: { + status: 'success', + redirect_url: helpers.generic_share_link(@post) + } else - render json: { status: 'error', redirect_url: 'Cannot reject this suggested edit... Strange.' }, - status: :bad_request + render json: { + status: 'error', + message: 'Cannot reject this suggested edit... Strange.' + }, + status: :internal_server_error end end diff --git a/app/controllers/tag_sets_controller.rb b/app/controllers/tag_sets_controller.rb index dc23904ec..221bc41f7 100644 --- a/app/controllers/tag_sets_controller.rb +++ b/app/controllers/tag_sets_controller.rb @@ -29,7 +29,8 @@ def update AuditLog.admin_audit(event_type: 'tag_set_update', related: @tag_set, user: current_user, comment: "from <>\nto <>") else - render json: { tag_set: @tag_set, status: 'failed' }, status: :internal_server_error + render json: { tag_set: @tag_set, status: 'failed', message: 'Failed to change tag set name' }, + status: :internal_server_error end end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 98f133329..322a7f76a 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -24,6 +24,12 @@ def index def category @tag_set = @category.tag_set + + if @tag_set.nil? + not_found! + return + end + @tags = if params[:q].present? @tag_set.tags.search(params[:q]) elsif params[:hierarchical].present? @@ -31,23 +37,23 @@ def category elsif params[:no_excerpt].present? @tag_set.tags.where(excerpt: ['', nil]) else - @tag_set&.tags + @tag_set.tags end table = params[:hierarchical].present? ? 'tags_paths' : 'tags' - @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 = @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) + @tags.order(:path) else - @tags&.order(Arel.sql('COUNT(posts.id) DESC')) + @tags.order(Arel.sql('COUNT(posts.id) DESC')) end - @count = @tags&.length || 0 + @count = @tags.length || 0 end def show @@ -118,7 +124,23 @@ def children end def rename - status = @tag.update(name: params[:name]) + status = false + + @tag.transaction do + old_tag_name = @tag.name + + status = @tag.update(name: params[:name]) + + if status + AuditLog.moderator_audit(event_type: 'tag_rename', + related: @tag, + user: current_user, + comment: "#{old_tag_name} renamed to #{params[:name]}") + else + raise ActiveRecord::Rollback + end + end + render json: { success: status, tag: @tag } end @@ -226,8 +248,7 @@ def exec_sql(sql_array) def verify_tag_editor unless user_signed_in? && (current_user.privilege?(:edit_tags) || - current_user.is_moderator || - current_user.is_admin) + current_user.at_least_moderator?) respond_to do |format| format.html do render 'errors/not_found', layout: 'without_sidebar', status: :not_found diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index 00f0722b8..0a5f3a04d 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -1,9 +1,58 @@ class Users::RegistrationsController < Devise::RegistrationsController - protected - layout 'without_sidebar', only: :edit before_action :check_sso, only: :update + before_action :authenticate_user!, only: [:delete, :do_delete] + before_action :require_sudo, only: [:delete, :do_delete] + + def create + super do |user| + unless user.errors.any? + rate_limit = AppConfig.server_settings['registration_rate_limit'] + ip_list = [user.current_sign_in_ip, request.remote_ip].compact + previous_ip_users = User.where(current_sign_in_ip: ip_list).or(User.where(last_sign_in_ip: ip_list)) + .where(created_at: rate_limit.seconds.ago..DateTime.now) + .where.not(id: user.id) + if previous_ip_users.empty? + user.send_welcome_tour_message + user.ensure_websites + else + user.delete + flash[:danger] = 'You cannot create an account right now because of the volume of accounts originating ' \ + 'from your network. Try again later.' + end + end + end + end + + def delete + @user = current_user + end + + def do_delete + @user = current_user + if @user.admin? + @user.errors.add(:base, I18n.t('users.errors.no_admin_self_delete')) + render :delete + elsif @user.moderator? + @user.errors.add(:base, I18n.t('users.errors.no_mod_self_delete')) + render :delete + elsif @user.enabled_2fa + @user.errors.add(:base, I18n.t('users.errors.no_2fa_self_delete')) + render :delete + elsif params[:username] != @user.username + @user.errors.add(:base, I18n.t('users.errors.self_delete_wrong_username')) + render :delete + else + UserMailer.with(user: @user, host: RequestContext.community.host, community: RequestContext.community) + .deletion_confirmation.deliver_later + @user.do_soft_delete(@user) + flash[:info] = 'Sorry to see you go!' + redirect_to root_path + end + end + + protected def after_update_path_for(resource) edit_user_registration_path(resource) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 1be6a0f97..4c9dc4548 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -15,13 +15,19 @@ class UsersController < ApplicationController before_action :check_deleted, only: [:show, :posts, :activity] def index - sort_param = { reputation: :reputation, age: :created_at }[params[:sort]&.to_sym] || :reputation + @sort_param = { reputation: :reputation, age: :created_at }[params[:sort]&.to_sym] || :reputation + @users = if params[:search].present? user_scope.search(params[:search]) else - user_scope.order(sort_param => :desc) - end.where.not(deleted: true).where.not(community_users: { deleted: true }) - .paginate(page: params[:page], per_page: 48) # rubocop:disable Layout/MultilineMethodCallIndentation + user_scope + end + + @users = @users.where.not(deleted: true) + .where.not(community_users: { deleted: true }) + .order(@sort_param => :desc) + .paginate(page: params[:page], per_page: 48) + @post_counts = Post.where(user_id: @users.pluck(:id).uniq).group(:user_id).count end @@ -51,9 +57,17 @@ def me redirect_to user_path(@user) end format.json do - data = [:id, :username, :is_moderator, :is_admin, :is_global_moderator, :is_global_admin, :trust_level, - :se_acct_id].to_h { |a| [a, @user.send(a)] } - render json: data + data = [:id, :username, :trust_level, :se_acct_id].to_h { |a| [a, @user.send(a)] } + + data_with_ac = data.merge({ + is_standard: @user.standard?, + is_admin: @user.admin?, + is_global_admin: @user.global_admin?, + is_moderator: @user.at_least_moderator?, + is_global_moderator: @user.at_least_global_moderator? + }) + + render json: data_with_ac end end end @@ -186,10 +200,12 @@ def posts Post.all else Post.undeleted - end.where(user: @user).list_includes.joins(:category) + end.by(@user).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) + activity: :last_activity, + age: :created_at, + score: :score) .paginate(page: params[:page], per_page: 25) respond_to do |format| format.html do @@ -211,31 +227,28 @@ def network 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, :post_history_type).where(user: @user, posts: { deleted: false }, - post_history_types: { name: 'post_edited' }).count + @posts = Post.undeleted.by(@user).count + @comments = Comment.by(@user).joins(:comment_thread, :post).undeleted.where(comment_threads: { deleted: false }, + posts: { deleted: false }).count + @suggested_edits = SuggestedEdit.by(@user).count + @edits = PostHistory.by(@user).of_type('post_edited').on_undeleted.count @all_edits = @suggested_edits + @edits items = case params[:filter] when 'posts' - Post.undeleted.where(user: @user) + Post.undeleted.by(@user) when 'comments' - Comment.joins(:comment_thread, :post).undeleted.where(user: @user, comment_threads: { deleted: false }, - posts: { deleted: false }) + Comment.by(@user).joins(:comment_thread, :post).undeleted.where(comment_threads: { deleted: false }, + posts: { deleted: false }) when 'edits' - SuggestedEdit.where(user: @user) + \ - PostHistory.joins(:post, :post_history_type).where(user: @user, posts: { deleted: false }, - post_history_types: { name: 'post_edited' }) + SuggestedEdit.by(@user) + PostHistory.by(@user).of_type('post_edited').on_undeleted 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 }).all + Post.undeleted.by(@user) + + Comment.by(@user).joins(:comment_thread, :post).undeleted.where(comment_threads: { deleted: false }, + posts: { deleted: false }) + + SuggestedEdit.by(@user).all + + PostHistory.by(@user).on_undeleted.all end @items = items.sort_by(&:created_at).reverse.paginate(page: params[:page], per_page: 50) @@ -245,42 +258,42 @@ def activity def mod; end def full_log - @posts = Post.where(user: @user).count - @comments = Comment.where(user: @user).count - @flags = Flag.where(user: @user).count - @suggested_edits = SuggestedEdit.where(user: @user).count - @edits = PostHistory.where(user: @user).count - @mod_warnings_received = ModWarning.where(community_user: @user.community_user).count + @posts = Post.by(@user).count + @comments = Comment.by(@user).count + @flags = Flag.by(@user).count + @suggested_edits = SuggestedEdit.by(@user).count + @edits = PostHistory.by(@user).count + @mod_warnings_received = ModWarning.to(@user).count @all_edits = @suggested_edits + @edits - @interesting_comments = Comment.where(user: @user, deleted: true).count - @interesting_flags = Flag.where(user: @user, status: 'declined').count - @interesting_edits = SuggestedEdit.where(user: @user, active: false, accepted: false).count - @interesting_posts = Post.where(user: @user).where('score < 0.25 OR deleted=1').count + @interesting_comments = Comment.by(@user).deleted.count + @interesting_flags = Flag.by(@user).declined.count + @interesting_edits = SuggestedEdit.by(@user).rejected.count + @interesting_posts = Post.by(@user).problematic.count - @interesting = @interesting_comments + @interesting_flags + @mod_warnings_received + \ + @interesting = @interesting_comments + @interesting_flags + @mod_warnings_received + @interesting_edits + @interesting_posts @items = (case params[:filter] when 'posts' - Post.where(user: @user).all + Post.by(@user).all when 'comments' - Comment.where(user: @user).all + Comment.by(@user).all when 'flags' - Flag.where(user: @user).all + Flag.by(@user).all when 'edits' - SuggestedEdit.where(user: @user).all + PostHistory.where(user: @user).all + SuggestedEdit.by(@user).all + PostHistory.by(@user).all when 'warnings' - ModWarning.where(community_user: @user.community_user).all + ModWarning.to(@user).all when 'interesting' - Comment.where(user: @user, deleted: true).all + Flag.where(user: @user, status: 'declined').all + \ - SuggestedEdit.where(user: @user, active: false, accepted: false).all + \ - Post.where(user: @user).where('score < 0.25 OR deleted=1').all + Comment.by(@user).deleted.all + Flag.by(@user).declined.all + + SuggestedEdit.by(@user).rejected.all + + Post.by(@user).problematic.all else - 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 + Post.by(@user).all + Comment.by(@user).all + Flag.by(@user).all + + SuggestedEdit.by(@user).all + PostHistory.by(@user).all + + ModWarning.to(@user).all end).sort_by(&:created_at).reverse.paginate(page: params[:page], per_page: 50) render layout: 'without_sidebar' @@ -297,7 +310,7 @@ def destroy return end - if @user.is_admin || @user.is_moderator + if @user.at_least_moderator? render json: { status: 'failed', message: 'Admins and moderators cannot be destroyed.' }, status: :unprocessable_entity return @@ -307,13 +320,13 @@ def destroy @user.block('user destroyed') if @user.destroy - Post.unscoped.where(user_id: @user.id).update_all(user_id: SiteSetting['SoftDeleteTransferUser'], - deleted: true, deleted_at: DateTime.now, - deleted_by_id: SiteSetting['SoftDeleteTransferUser']) - Comment.unscoped.where(user_id: @user.id).update_all(user_id: SiteSetting['SoftDeleteTransferUser'], - deleted: true) - Flag.unscoped.where(user_id: @user.id).update_all(user_id: SiteSetting['SoftDeleteTransferUser']) - SuggestedEdit.unscoped.where(user_id: @user.id).update_all(user_id: SiteSetting['SoftDeleteTransferUser']) + Post.unscoped.by(@user).update_all(user_id: SiteSetting['SoftDeleteTransferUser'], + deleted: true, deleted_at: DateTime.now, + deleted_by_id: SiteSetting['SoftDeleteTransferUser']) + Comment.unscoped.by(@user).update_all(user_id: SiteSetting['SoftDeleteTransferUser'], + deleted: true) + Flag.unscoped.by(@user).update_all(user_id: SiteSetting['SoftDeleteTransferUser']) + SuggestedEdit.unscoped.by(@user).update_all(user_id: SiteSetting['SoftDeleteTransferUser']) AuditLog.moderator_audit(event_type: 'user_destroy', user: current_user, comment: "<>") render json: { status: 'success' } else @@ -324,7 +337,7 @@ def destroy end def soft_delete - if @user.is_admin || @user.is_moderator + if @user.at_least_moderator? render json: { status: 'failed', message: 'Admins and moderators cannot be deleted.' }, status: :unprocessable_entity return @@ -405,10 +418,23 @@ def update_profile end def role_toggle - role_map = { mod: :is_moderator, admin: :is_admin, mod_global: :is_global_moderator, admin_global: :is_global_admin, - staff: :staff } - permission_map = { mod: :is_admin, admin: :is_global_admin, mod_global: :is_global_admin, - admin_global: :is_global_admin, staff: :staff } + role_map = { + mod: :is_moderator, + admin: :is_admin, + mod_global: :is_global_moderator, + admin_global: :is_global_admin, + staff: :staff + } + + # values must match methods on the User model + permission_map = { + mod: :admin?, + admin: :global_admin?, + mod_global: :global_admin?, + admin_global: :global_admin?, + staff: :staff + } + unless role_map.keys.include?(params[:role].underscore.to_sym) render json: { status: 'error', message: "Role not found: #{params[:role]}" }, status: :bad_request end @@ -416,7 +442,7 @@ def role_toggle key = params[:role].underscore.to_sym attrib = role_map[key] permission = permission_map[key] - return not_found unless current_user.send(permission) + return not_found! unless current_user.send(permission) case key when :mod @@ -424,9 +450,9 @@ 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 + @user.community_user.privilege('mod')&.destroy end @user.community_user.update(attrib => new_value) @@ -437,6 +463,9 @@ def role_toggle new_value = !@user.send(attrib) @user.update(attrib => new_value) end + + @user.community_user.recalc_trust_level + AuditLog.admin_audit(event_type: 'role_toggle', related: @user, user: current_user, comment: "#{attrib} to #{new_value}") AbilityQueue.add(@user, 'Role Change') @@ -446,7 +475,7 @@ def role_toggle def mod_privilege_action ability = Ability.find_by internal_id: params[:ability] - return not_found if ability.internal_id == 'mod' + return not_found! if ability.internal_id == 'mod' ua = @user.community_user.privilege(ability.internal_id) @@ -463,7 +492,7 @@ def mod_privilege_action end when 'suspend' - return not_found if ua.nil? + return not_found! if ua.nil? duration = params[:duration]&.to_i duration = duration <= 0 ? nil : duration.days.from_now @@ -476,7 +505,7 @@ def mod_privilege_action comment: "#{ability.internal_id} ability suspended\n\n#{message}") when 'delete' - return not_found if ua.nil? + return not_found! if ua.nil? ua.destroy AuditLog.admin_audit(event_type: 'ability_remove', related: @user, user: current_user, @@ -485,7 +514,7 @@ def mod_privilege_action AuditLog.user_history(event_type: 'deleted_ability', related: nil, user: @user, comment: ability.internal_id) else - return not_found + return not_found! end render json: { status: 'success' } end @@ -553,12 +582,13 @@ def do_qr_login 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.' - not_found + not_found! end end def annotations - @logs = AuditLog.where(log_type: 'user_annotation', related: @user).order(created_at: :desc) + @logs = AuditLog.where(log_type: 'user_annotation', related: @user) + .newest_first .paginate(page: params[:page], per_page: 20) render layout: 'without_sidebar' end @@ -579,18 +609,16 @@ def my_vote_summary end def vote_summary - @votes = Vote.where(recv_user: @user) - .includes(:post) - .group(:date_of, :post_id, :vote_type) + @votes = Vote.for(@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 \ + @votes = @votes.order(date_of: :desc, post_id: :desc).all .group_by(&:date_of).map do |k, vl| [k, vl.group_by(&:post), vl.sum { |v| v.vote_type * v.vote_count }] - end \ + end .paginate(page: params[:page], per_page: 15) render layout: 'without_sidebar' @@ -650,19 +678,22 @@ def set_user params[:id] end @user = user_scope.find_by(id: user_id) - not_found if @user.nil? + not_found! if @user.nil? end def user_scope - if helpers.moderator? + if current_user&.at_least_moderator? User.all else - User.active + User.undeleted end.joins(:community_user).includes(:community_user, :avatar_attachment) end def check_deleted - if (@user.deleted? || @user.community_user.deleted?) && (!helpers.moderator? || params[:deleted_screen].present?) + deleted = @user.deleted? || @user.community_user.deleted? + go_to_not_found = !current_user&.at_least_moderator? || params[:deleted_screen].present? + + if deleted && go_to_not_found render :deleted_user, layout: 'without_sidebar', status: 404 end end diff --git a/app/controllers/votes_controller.rb b/app/controllers/votes_controller.rb index f39a98b44..653d01ed5 100644 --- a/app/controllers/votes_controller.rb +++ b/app/controllers/votes_controller.rb @@ -7,12 +7,12 @@ def create post = Post.find(params[:post_id]) if post.user == current_user && !SiteSetting['AllowSelfVotes'] - render(json: { status: 'failed', message: 'You may not vote on your own posts.' }, status: :forbidden) && return + render(json: { status: 'failed', message: 'You may not vote on your own posts.' }, status: :forbidden) + return end - recent_votes = Vote.where(created_at: 24.hours.ago..DateTime.now, user: current_user) \ - .where.not(post: Post.includes(:parent).where(parents_posts: { user_id: current_user.id })).count - max_votes_per_day = SiteSetting[current_user.privilege?('unrestricted') ? 'RL_Votes' : 'RL_NewUserVotes'] + recent_votes = current_user.recent_votes_count + max_votes_per_day = current_user.max_votes_per_day if !post.parent&.user_id == current_user.id && recent_votes >= max_votes_per_day vote_limit_msg = "You have used your daily vote limit of #{recent_votes} votes. " \ diff --git a/app/helpers/advertisement_helper.rb b/app/helpers/advertisement_helper.rb index 9bd9db8e0..b1dec23e5 100644 --- a/app/helpers/advertisement_helper.rb +++ b/app/helpers/advertisement_helper.rb @@ -55,7 +55,7 @@ def rtl?(char) return false if char.nil? raise ArgumentError, 'More than one character provided' if char.length > 1 - char.ord >= RTL_BLOCK_START && char.ord <= RTL_BLOCK_END + char.ord.between?(RTL_BLOCK_START, RTL_BLOCK_END) end ## diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 74e40665c..6680f5811 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -3,17 +3,31 @@ module ApplicationHelper include Warden::Test::Helpers ## - # Is the current user a moderator on the current community? + # Is the current user a moderator or admin on the current community? # @return [Boolean] - def moderator? - user_signed_in? && (current_user.is_moderator || current_user.is_admin) + def at_least_moderator? + user_signed_in? && current_user.at_least_moderator? end ## # Is the current user an admin on the current community? # @return [Boolean] def admin? - user_signed_in? && current_user.is_admin + user_signed_in? && current_user.admin? + end + + ## + ## Does the current user have access to deleted posts? + # @return [Boolean] + def can_see_deleted_posts? + user_signed_in? && current_user.can_see_deleted_posts? + end + + ## + # Is the current user a standard user (not a moderator or an admin)? + # @return [Boolean] check result + def standard? + !at_least_moderator? end ## @@ -22,7 +36,7 @@ def admin? # @param privilege [String] The +internal_id+ of the privilege to query. # @return [Boolean] def check_your_post_privilege(post, privilege) - !current_user.nil? && current_user&.has_post_privilege?(privilege, post) + !current_user.nil? && current_user&.post_privilege?(privilege, post) end ## @@ -88,8 +102,6 @@ def stat_panel(heading, value, caption: nil) end end - # rubocop:disable Layout/LineLength because obviously rubocop has a problem with documentation - ## # Converts a number to short-form humanized display, i.e. 100,000 = 100k. Parameters as for # {ActiveSupport::NumberHelper#number_to_human}[https://www.rubydoc.info/gems/activesupport/ActiveSupport/NumberHelper#number_to_human-instance_method] @@ -100,8 +112,6 @@ def short_number_to_human(*args, **opts) ActiveSupport::NumberHelper.number_to_human(*args, **opts) end - # rubocop:enable Layout/LineLength - ## # Render a markdown string to HTML with consistent options. # @param markdown [String] The markdown string to render. @@ -146,12 +156,21 @@ def second_level_post_types ## # Gets a shareable URL to the specified post, taking into account post type. # @param post [Post] The post in question. + # @param params [Hash{Symbol => #to_s}] additional URL params # @return [String] - def generic_share_link(post) + def generic_share_link(post, **params) + unless params.key?(:host) + params.store(:host, post.community.host) + end + if second_level_post_types.include?(post.post_type_id) - answer_post_url(id: post.parent_id, answer: post.id, anchor: "answer-#{post.id}") + answer_post_url({ + id: post.parent_id, + answer: post.id, + anchor: "answer-#{post.id}" + }.merge(params)) else - post_url(post) + post_url(post, params) end end @@ -330,4 +349,21 @@ def current_commit rescue [nil, nil] end + + ## + # Extracts boundary-safe page num from parameters + # @param params [ActionController::Parameters] parameters to parse + # @return [Integer] boundary-safe page num + def safe_page(params) + params[:page].nil? ? 1 : params[:page].to_i + end + + ## + # Extracts boundary-safe page limit from parameters + # @param params [ActionController::Parameters] parameters to parse + # @param min [Integer, nil] minimum limit per page + # @return [Integer] boundary-safe page limit + def safe_per_page(params, min = 20) + params[:per_page].nil? || params[:per_page].to_i < min ? min : params[:per_page].to_i + end end diff --git a/app/helpers/categories_helper.rb b/app/helpers/categories_helper.rb index 36de508e3..315c2948c 100644 --- a/app/helpers/categories_helper.rb +++ b/app/helpers/categories_helper.rb @@ -17,7 +17,7 @@ def expandable? (defined?(@post) && !@post&.category.nil?) || (defined?(@question) && !@question&.category.nil?) || (defined?(@article) && !@article&.category.nil?) || - (defined?(@edit) && !@edit&.post&.category&.nil?) + (defined?(@edit) && !@edit&.post&.category.nil?) end ## @@ -43,7 +43,7 @@ def current_category # @return [Boolean] def pending_suggestions? Rails.cache.fetch "pending_suggestions/#{current_category.id}" do - SuggestedEdit.where(post: Post.undeleted.where(category: current_category), active: true).any? + SuggestedEdit.where(post: Post.undeleted.in(current_category), active: true).any? end end end diff --git a/app/helpers/comments_helper.rb b/app/helpers/comments_helper.rb index fa9eced0f..1f803bd2e 100644 --- a/app/helpers/comments_helper.rb +++ b/app/helpers/comments_helper.rb @@ -1,5 +1,19 @@ # Helpers related to comments. module CommentsHelper + # Generates a comment thread title from its body + # @param body [String] coment thread body + # @return [String] generated title + def generate_thread_title(body) + body = strip_markdown(body) + body = body.gsub(/^>.+?$/, '') # also remove leading blockquotes + + if body.length > 100 + "#{body[0..100]}..." + else + body + end + end + ## # Get a link to the specified comment, accounting for deleted comments. # @param comment [Comment] @@ -13,26 +27,54 @@ def comment_link(comment) end end + # Gets a link to a given comment's user + # @param comment [Comment] comment to link the user for + # @return [String] comment user link + def comment_user_link(comment) + user_link(comment.user, { host: comment.community.host }) + end + + # Gets a list of pinged users for a given content + # @param content [String] content to get pinged users from + # @return [Hash{String => User}] list of pinged users + def pinged_users(content) + user_ids = content.scan(/@#(\d+)/).map { |g| g[0].to_i } + User.where(id: user_ids).to_a.to_h { |u| [u.id, u] } + end + ## - # Process a comment and convert ping-strings (i.e. @#1234) into links. - # @param comment [String] The text of the comment to process. - # @param pingable [Array, nil] A list of user IDs that should be pingable in this comment. Any user IDs not - # present in the list will be displayed as 'unpingable'. + # Converts all ping-strings (i.e. @#1234) into links. + # @param content [String] content to convert ping-strings for + # @param pingable [Array, nil] A list of user IDs. Any user ID not present will be displayed as 'unpingable'. # @return [ActiveSupport::SafeBuffer] - def render_pings(comment, pingable: nil) - comment.gsub(/@#\d+/) do |id| - u = User.where(id: id[2..-1].to_i).first - if u.nil? - id + def render_pings(content, pingable: nil) + users = pinged_users(content) + + content.gsub(/@#(\d+)/) do |ping| + user = users[Regexp.last_match(1).to_i] + if user.nil? + ping else - was_pung = pingable.present? && pingable.include?(u.id) - classes = "ping #{u.id == current_user&.id ? 'me' : ''} #{was_pung ? '' : 'unpingable'}" - user_link u, class: classes, dir: 'ltr', - title: was_pung ? '' : 'This user was not notified because they have not participated in this thread.' + was_pung = pingable.present? && pingable.include?(user.id) + classes = "ping #{'me' if user.same_as?(current_user)} #{'unpingable' unless was_pung}" + user_link user, class: classes, dir: 'ltr', + title: was_pung ? '' : I18n.t('comments.warnings.unrelated_user_not_pinged') end end.html_safe end + # Converts all ping-strings (i.e. @#1234) in content into usernames for use in text-only contexts + # @param content [String] content to convert ping-strings for + # @return [String] processed content + def render_pings_text(content) + users = pinged_users(content) + + content.gsub(/@#(\d+)/) do |ping| + user = users[Regexp.last_match(1).to_i] + user.nil? ? ping : "@#{rtl_safe_username(user)}" + end + end + ## # Process comment text and convert helper links (like [help] and [flags]) into real links. # @param comment_text [String] The text of the comment to process. @@ -69,70 +111,76 @@ def render_comment_helpers(comment_text, user = current_user) comment_text end - ## - # Get a list of user IDs who should be pingable in a specified comment thread. This combines the post author, answer - # authors, recent history event authors, recent comment authors on the post (in any thread), and all thread followers. - # @param thread [CommentThread] - # @return [Array] - def get_pingable(thread) - post = thread.post - - # post author + - # answer authors + - # last 500 history event users + - # last 500 comment authors + - # all thread followers - query = <<~END_SQL - SELECT posts.user_id FROM posts WHERE posts.id = #{post.id} - UNION DISTINCT - SELECT DISTINCT posts.user_id FROM posts WHERE posts.parent_id = #{post.id} - UNION DISTINCT - SELECT DISTINCT ph.user_id FROM post_histories ph WHERE ph.post_id = #{post.id} - UNION DISTINCT - SELECT DISTINCT comments.user_id FROM comments WHERE comments.post_id = #{post.id} - UNION DISTINCT - SELECT DISTINCT tf.user_id FROM thread_followers tf WHERE tf.comment_thread_id = #{thread.id || '-1'} - END_SQL - - ActiveRecord::Base.connection.execute(query).to_a.flatten + # Gets a standard comments error message for a given post + # @param post [Post] target post + # @return [String] error message + def comments_post_error_msg(post) + if post.locked? + I18n.t('comments.errors.disabled_on_locked_posts') + elsif post.deleted? + I18n.t('comments.errors.disabled_on_deleted_posts') + elsif post.comments_disabled + I18n.t('comments.errors.disabled_on_post_specific') + else + I18n.t('comments.errors.disabled_on_post_generic') + end.strip + end + + # Gets a standard comments error message for a given thread + # @param thread [CommentThread] target thread + # @return [String] error message + def comments_thread_error_msg(thread) + if thread.locked? + I18n.t('comments.errors.disabled_on_locked_threads') + elsif thread.deleted + I18n.t('comments.errors.disabled_on_deleted_threads') + elsif thread.archived + I18n.t('comments.errors.disabled_on_archived_threads') + else + I18n.t('comments.errors.disabled_on_thread_generic') + end.strip + end + + # Gets a standard comments rate limit error message for a given user & post + # @param user [User] user to get the comments count for + # @param post [Post] post to get the comments count for + def rate_limited_error_msg(user, post) + comments_count = user.recent_comments_count(post) + I18n.t('comments.errors.rate_limited', count: comments_count) end ## # Is the specified user comment rate limited for the specified post? - # @param [User] user The user to check. - # @param [Post] post The post on which the user proposes to comment. - # @param [Boolean] create_audit_log Whether to create an AuditLog if the user is rate limited. + # @param user [User] The user to check. + # @param post [Post] The post on which the user proposes to comment. + # @param create_audit_log [Boolean] Whether to create an AuditLog if the user is rate limited. # @return [Array(Boolean, String)] 2-tuple: boolean indicating if the user is rate-limited, and a string containing # a rate limit message if the user is rate-limited. def comment_rate_limited?(user, post, create_audit_log: true) - # Comments created by the current user in the last 24 hours, excluding comments on own posts and responses to them. - recent_comments = Comment.where(created_at: 24.hours.ago..DateTime.now, user: user).where \ - .not(post: Post.includes(:parent).where(parents_posts: { user_id: user.id })) \ - .where.not(post: Post.where(user_id: user.id)).count - max_comments_per_day = SiteSetting[user.privilege?('unrestricted') ? 'RL_Comments' : 'RL_NewUserComments'] - - if post.user_id != user.id && post.parent&.user_id != user.id - if !user.privilege?('unrestricted') - message = 'As a new user, you can only comment on your own posts and on answers to them.' - if create_audit_log - AuditLog.rate_limit_log(event_type: 'comment', related: post, user: user, - comment: "limit: #{max_comments_per_day}") - end - [true, message] - elsif recent_comments >= max_comments_per_day - message = "You have used your daily comment limit of #{recent_comments} comments. Come back tomorrow to " \ - 'continue commenting. Comments on your own posts and on answers to own posts are exempt.' - if create_audit_log - AuditLog.rate_limit_log(event_type: 'comment', related: post, user: user, - comment: "limit: #{max_comments_per_day}") - end - [true, message] - else - [false, nil] + comments_count = user.recent_comments_count(post) + comments_limit = user.max_comments_per_day(post) + is_rate_limited = comments_count >= comments_limit + + unless is_rate_limited && user.standard? + return [false, nil] + end + + if user.new? && !user.owns_post_or_parent?(post) && comments_limit.zero? + message = I18n.t('comments.errors.new_user_rate_limited') + + if create_audit_log + AuditLog.rate_limit_log(event_type: 'comment', related: post, user: user, + comment: "'unrestricted' ability required to comment on non-owned posts") end else - [false, nil] + message = rate_limited_error_msg(user, post) + + if create_audit_log + AuditLog.rate_limit_log(event_type: 'comment', related: post, user: user, comment: "limit: #{comments_limit}") + end end + + [true, message] end end diff --git a/app/helpers/markdown_tools_helper.rb b/app/helpers/markdown_tools_helper.rb index 47411775f..0720e821e 100644 --- a/app/helpers/markdown_tools_helper.rb +++ b/app/helpers/markdown_tools_helper.rb @@ -22,6 +22,7 @@ def md_button(name = nil, action: nil, label: nil, **attribs, &block) attribs.merge! href: 'javascript:void(0)', class: "#{attribs[:class] || ''} button is-muted is-outlined js-markdown-tool", data_action: action, + draggable: false, aria_label: label, title: label, role: 'button' @@ -42,6 +43,7 @@ def md_list_item(name = nil, action: nil, label: nil, **attribs, &block) attribs.merge! href: 'javascript:void(0)', class: "#{attribs[:class] || ''}js-markdown-tool", data_action: action, + draggable: false, aria_label: label, title: label, role: 'button' diff --git a/app/helpers/posts_helper.rb b/app/helpers/posts_helper.rb index 41f425fa6..ef1015585 100644 --- a/app/helpers/posts_helper.rb +++ b/app/helpers/posts_helper.rb @@ -1,4 +1,13 @@ module PostsHelper + # Gets a link to a given post's user + # @param post [Post] post to link the user for + # @param active [Boolean] if +true+, will link to the user with the last activity on the post + # @return [String] user link + def post_user_link(post, active: false) + user = active ? post.last_activity_by || post.user : post.user + user_link(user, { host: post.community.host }) + end + ## # Get HTML for a field - should only be used in Markdown create/edit requests. Prioritises using the client-side # rendered HTML over rendering server-side. diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index d3c2edb13..7d8a47b0d 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -1,33 +1,26 @@ module SearchHelper - def check_posts_permissions - (current_user&.is_moderator || current_user&.is_admin ? Post : Post.undeleted) - .qa_only.list_includes - end - ## # Search & sort a default posts list based on parameters in the current request. # - # Generates initial post list using {Post#qa_only}, including deleted posts for mods and admins. Takes search string - # from params[:search], applies any qualifiers, and searches post bodies for the remaining term(s). + # Search uses MySQL FTS in boolean mode which is what provides advanced search syntax (excluding qualifiers) + # see {MySQL manual 14.9.2}[https://dev.mysql.com/doc/refman/8.4/en/fulltext-boolean.html]. # - # Search uses MySQL fulltext search in boolean mode which is what provides advanced search syntax (excluding - # qualifiers) - see {MySQL manual 14.9.2}[https://dev.mysql.com/doc/refman/8.4/en/fulltext-boolean.html]. - # - # @return [ActiveRecord::Relation] - def search_posts - posts = check_posts_permissions - - qualifiers = params_to_qualifiers + # @param user [User] user for search context + # @param params [ActionController::Parameters] search parameters + # @return [[ActiveRecord::Relation, Array Object}>]] + def search_posts(user, params) + posts = Post.accessible_to(user) + qualifiers = params_to_qualifiers(params) 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] + qualifiers += parse_qualifier_strings(search_data[:qualifiers]) search_string = search_data[:search] end - posts = qualifiers_to_sql(qualifiers, posts) + posts = qualifiers_to_sql(qualifiers, posts, user) posts = posts.paginate(page: params[:page], per_page: 25) posts = if search_string.present? @@ -79,8 +72,9 @@ def active_filter ## # Retrieves parameters from +params+, validates their values, and adds them to a qualifiers hash. + # @param params [ActionController::Parameters] params to convert to qualifiers # @return [Array Object}>] - def params_to_qualifiers + def params_to_qualifiers(params) valid_value = { date: /^[\d.]+(?:s|m|h|d|w|mo|y)?$/, status: /any|open|closed/, @@ -131,7 +125,7 @@ def parse_search(raw_search) qualifiers.each do |q| search = search.gsub(q, '') end - search = search.gsub(/\\:/, ':').strip + search = search.gsub('\\:', ':').strip { qualifiers: qualifiers, search: search } end @@ -222,10 +216,9 @@ def parse_qualifier_strings(qualifiers) # @param qualifiers [Array Object}>] A qualifiers hash, as returned by other methods in this module. # @param query [ActiveRecord::Relation] An ActiveRecord query to which to add conditions based on the qualifiers. # @return [ActiveRecord::Relation] - 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) + def qualifiers_to_sql(qualifiers, query, user) + categories = Category.accessible_to(user) + query = query.where(category_id: categories) qualifiers.each do |qualifier| # rubocop:disable Metrics/BlockLength case qualifier[:param] diff --git a/app/helpers/sudo_helper.rb b/app/helpers/sudo_helper.rb new file mode 100644 index 000000000..36c2014a9 --- /dev/null +++ b/app/helpers/sudo_helper.rb @@ -0,0 +1,2 @@ +module SudoHelper +end diff --git a/app/helpers/tabs_helper.rb b/app/helpers/tabs_helper.rb index 83943731a..ea0eff6fa 100644 --- a/app/helpers/tabs_helper.rb +++ b/app/helpers/tabs_helper.rb @@ -24,9 +24,9 @@ def tab(text = nil, link_url = nil, **opts, &block) active = opts[:is_active] || false opts.delete :is_active opts[:class] = if opts[:class] - "#{opts[:class]} tabs--tab#{active ? ' tab__active' : ''}" + "#{opts[:class]} tabs--tab#{' tab__active' if active}" else - "tabs--tab#{active ? ' tab__active' : ''}" + "tabs--tab#{' tab__active' if active}" end @building_tabs << if block_given? diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 4cb0750e6..b71561afd 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -31,7 +31,7 @@ def stack_oauth_url # @return [Boolean] def can_change_category(user, target) user.privilege?('flag_curate') && - (user.is_moderator || user.is_admin || target.min_trust_level.nil? || target.min_trust_level <= user.trust_level) + (user.at_least_moderator? || target.min_trust_level.nil? || target.min_trust_level <= user.trust_level) end ## @@ -89,9 +89,9 @@ def user_preference(name, community: false) ## # Is the specified user deleted, either globally or on the current community? # @param user [User] - # @return [Boolean, nil] True/false, or +nil+ if the user is +nil+. + # @return [Boolean] check result - true if the user is +nil+. def deleted_user?(user) - return nil if user.nil? + return true if user.nil? user.deleted? || user.community_user&.deleted? end @@ -113,7 +113,7 @@ def rtl_safe_username(user) def user_link(user, url_opts = {}, **link_opts) anchortext = link_opts[:anchortext] link_opts_reduced = { dir: 'ltr' }.merge(link_opts).except(:anchortext) - if user.nil? || (deleted_user?(user) && !moderator?) + if user.nil? || (deleted_user?(user) && standard?) link_to 'deleted user', '#', link_opts_reduced elsif !anchortext.nil? link_to anchortext, user_url(user, **url_opts), { dir: 'ltr' }.merge(link_opts) @@ -139,7 +139,7 @@ def devise_sign_in_enabled? ## # 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+ + # @param user_id [String] id of the user to find, from +params+ # @return [User] The User object def user_with_me(user_id) if user_id == 'me' && user_signed_in? diff --git a/app/jobs/send_summary_emails_job.rb b/app/jobs/send_summary_emails_job.rb new file mode 100644 index 000000000..1cb8d6ba3 --- /dev/null +++ b/app/jobs/send_summary_emails_job.rb @@ -0,0 +1,19 @@ +class SendSummaryEmailsJob < ApplicationJob + queue_as :default + + def perform + staff = User.where(staff: true) + posts = Post.unscoped.qa_only.where(created_at: SummaryMailer::TIMEFRAME.ago..DateTime.now) + .includes(:community, :user) + flags = Flag.unscoped.where(created_at: SummaryMailer::TIMEFRAME.ago..DateTime.now) + .includes(:post, :community, :user) + comments = Comment.unscoped.where(created_at: SummaryMailer::TIMEFRAME.ago..DateTime.now) + .includes(:user, :post, :comment_thread, post: :community) + users = User.where(created_at: SummaryMailer::TIMEFRAME.ago..DateTime.now).includes(:community_users) + staff.each do |u| + SummaryMailer.with(to: u.email, posts: posts.to_a, flags: flags.to_a, comments: comments.to_a, users: users.to_a) + .content_summary + .deliver_later + end + end +end diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index f4f0d2606..301c963a4 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -7,23 +7,26 @@ class AdminMailer < ApplicationMailer def to_moderators @subject = params[:subject] @body_markdown = params[:body_markdown] + @community = params[:community] query = 'SELECT DISTINCT u.email FROM subscriptions s INNER JOIN users u ON s.user_id = u.id ' \ "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 - from = "#{SiteSetting['ModeratorDistributionListSenderName']} " \ - "<#{SiteSetting['ModeratorDistributionListSenderEmail']}>" - to = SiteSetting['ModeratorDistributionListSenderEmail'] + from = "#{SiteSetting['ModeratorDistributionListSenderName', community: @community]} " \ + "<#{SiteSetting['ModeratorDistributionListSenderEmail', community: @community]}>" + to = SiteSetting['ModeratorDistributionListSenderEmail', community: @community] 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'] + @users = params[:emails] + @community = params[:community] + to = SiteSetting['AllUsersSenderEmail', community: @community] + from = "#{SiteSetting['AllUsersSenderName', community: @community]} " \ + "<#{SiteSetting['AllUsersSenderEmail', community: @community]}>" + reply_to = SiteSetting['AllUsersReplyToEmail', community: @community] mail subject: @subject, to: to, from: from, reply_to: reply_to, bcc: @users end end diff --git a/app/mailers/flag_mailer.rb b/app/mailers/flag_mailer.rb index b43d60310..6fcf9e021 100644 --- a/app/mailers/flag_mailer.rb +++ b/app/mailers/flag_mailer.rb @@ -1,5 +1,5 @@ class FlagMailer < ApplicationMailer - helper :application, :post_types, :users, :tags, :comments + helper :application, :post_types, :users, :tags, :posts, :comments def flag_escalated @flag = params[:flag] diff --git a/app/mailers/summary_mailer.rb b/app/mailers/summary_mailer.rb new file mode 100644 index 000000000..cf6722db0 --- /dev/null +++ b/app/mailers/summary_mailer.rb @@ -0,0 +1,16 @@ +class SummaryMailer < ApplicationMailer + TIMEFRAME = 30.minutes + + helper :application, :post_types, :users + + def content_summary + @posts = params[:posts] + @flags = params[:flags] + @comments = params[:comments] + @users = params[:users] + + mail(from: "#{SiteSetting['NoReplySenderName']} <#{SiteSetting['NoReplySenderEmail']}>", + subject: 'Codidact Content Summary', + to: params[:to]) + end +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 000000000..0434d579f --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,12 @@ +class UserMailer < ApplicationMailer + helper :application, :users + + def deletion_confirmation + @user = params[:user] + @host = params[:host] + @community = params[:community] + mail to: @user.email, subject: 'Your Codidact account has been deleted as you requested', + from: "#{SiteSetting['NoReplySenderName', community: @community]} " \ + "<#{SiteSetting['NoReplySenderEmail', community: @community]}>" + end +end diff --git a/app/models/audit_log.rb b/app/models/audit_log.rb index b51cb183a..dfecac249 100644 --- a/app/models/audit_log.rb +++ b/app/models/audit_log.rb @@ -1,9 +1,12 @@ class AuditLog < ApplicationRecord include CommunityRelated + include Timestamped belongs_to :related, polymorphic: true, optional: true belongs_to :user, optional: true + scope :of_type, ->(name) { where(event_type: name) } + class << self [:admin_audit, :moderator_audit, :action_audit, :user_annotation, :user_history, :pii_history, :block_log, :rate_limit_log].each do |log_type| diff --git a/app/models/blocked_item.rb b/app/models/blocked_item.rb index f231b72cd..63bc72168 100644 --- a/app/models/blocked_item.rb +++ b/app/models/blocked_item.rb @@ -6,6 +6,6 @@ class BlockedItem < ApplicationRecord define_method "#{bt.underscore}?" do item_type == bt end - scope "#{bt}s".to_sym, -> { active.where(item_type: bt) } + scope :"#{bt}s", -> { active.where(item_type: bt) } end end diff --git a/app/models/category.rb b/app/models/category.rb index f5951519c..bb915ad1d 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -11,10 +11,21 @@ class Category < ApplicationRecord belongs_to :license belongs_to :default_filter, class_name: 'Filter', optional: true - serialize :display_post_types, Array + serialize :display_post_types, coder: YAML, type: Array validates :name, uniqueness: { scope: [:community_id], case_sensitive: false } + # Can anyone view the category (even if not logged in)? + # @return [Boolean] check result + def public? + trust_level = min_view_trust_level || -1 + trust_level <= 0 + end + + def top_level_post_types + post_types.where(is_top_level: true) + end + def new_posts_for?(user) key = "#{community_id}/#{user.id}/#{id}/last_visit" Rails.cache.fetch key, expires_in: 5.minutes do @@ -31,6 +42,18 @@ def update_activity(last_activity) RequestContext.redis.set("#{community_id}/#{id}/last_activity", last_activity) end + # Gets categories appropriately scoped for a given user + # @param user [User] user to check + # @return [ActiveRecord::Relation] + def self.accessible_to(user) + if user&.at_least_moderator? + return Category.all + end + + trust_level = user&.trust_level || 0 + Category.where('IFNULL(min_view_trust_level, -1) <= ?', trust_level) + end + def self.by_lowercase_name(name) categories = Rails.cache.fetch 'categories/by_lowercase_name' do Category.all.to_h { |c| [c.name.downcase, c] } diff --git a/app/models/comment.rb b/app/models/comment.rb index 12dfd651f..7164cdd34 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -1,9 +1,10 @@ # Represents a comment. Comments are attached to both a post and a user. class Comment < ApplicationRecord include PostRelated + include SoftDeletable + include Timestamped - scope :deleted, -> { where(deleted: true) } - scope :undeleted, -> { where(deleted: false) } + scope :by, ->(user) { where(user: user) } belongs_to :user belongs_to :comment_thread @@ -32,6 +33,12 @@ def content_length end end + def pings + pingable = thread.pingable + matches = content.scan(/@#(\d+)/) + matches.flatten.select { |m| pingable.include?(m.to_i) }.map(&:to_i) + end + private def create_follower @@ -41,7 +48,7 @@ def create_follower end def delete_thread - if deleted? && comment_thread.comments.undeleted.count.zero? + if deleted? && comment_thread.comments.undeleted.none? comment_thread.update(deleted: true, deleted_by_id: -1) end end diff --git a/app/models/comment_thread.rb b/app/models/comment_thread.rb index 39f5dcf5c..e657c4606 100644 --- a/app/models/comment_thread.rb +++ b/app/models/comment_thread.rb @@ -1,26 +1,24 @@ class CommentThread < ApplicationRecord + include Lockable include PostRelated + include SoftDeletable has_many :comments has_many :thread_follower - belongs_to :locked_by, class_name: 'User', optional: true belongs_to :archived_by, class_name: 'User', optional: true - belongs_to :deleted_by, class_name: 'User', optional: true - scope :deleted, -> { where(deleted: true) } - scope :undeleted, -> { where(deleted: false) } scope :initially_visible, -> { where(deleted: false, archived: false).where('reply_count > 0') } scope :publicly_available, -> { where(deleted: false).where('reply_count > 0') } scope :archived, -> { where(archived: true) } after_create :create_follower - def read_only? - locked? || archived? || deleted? + def self.post_followed?(post, user) + ThreadFollower.where(post: post, user: user).any? end - def locked? - locked && (locked_until.nil? || locked_until > DateTime.now) + def read_only? + locked? || archived? || deleted? end def followed_by?(user) @@ -28,12 +26,31 @@ def followed_by?(user) end def can_access?(user) - (!deleted? || user&.privilege?('flag_curate') || user&.has_post_privilege?('flag_curate', post)) && + (!deleted? || user&.privilege?('flag_curate') || user&.post_privilege?('flag_curate', post)) && post.can_access?(user) end - def self.post_followed?(post, user) - ThreadFollower.where(post: post, user: user).any? + # Gets a list of user IDs who should be pingable in the thread. + # @return [Array] + def pingable + # post author + + # answer authors + + # last 500 history event users + + # last 500 comment authors + + # all thread followers + query = <<~END_SQL + SELECT posts.user_id FROM posts WHERE posts.id = #{post.id} + UNION DISTINCT + SELECT DISTINCT posts.user_id FROM posts WHERE posts.parent_id = #{post.id} + UNION DISTINCT + SELECT DISTINCT ph.user_id FROM post_histories ph WHERE ph.post_id = #{post.id} + UNION DISTINCT + SELECT DISTINCT comments.user_id FROM comments WHERE comments.post_id = #{post.id} + UNION DISTINCT + SELECT DISTINCT tf.user_id FROM thread_followers tf WHERE tf.comment_thread_id = #{id || '-1'} + END_SQL + + ActiveRecord::Base.connection.execute(query).to_a.flatten end private diff --git a/app/models/community_user.rb b/app/models/community_user.rb index f0a80e60f..f2ff5f197 100644 --- a/app/models/community_user.rb +++ b/app/models/community_user.rb @@ -1,16 +1,15 @@ class CommunityUser < ApplicationRecord + include SoftDeletable + belongs_to :community belongs_to :user has_many :mod_warnings, dependent: :nullify has_many :user_abilities, dependent: :destroy - belongs_to :deleted_by, required: false, class_name: 'User' validates :user_id, uniqueness: { scope: [:community_id], case_sensitive: false } scope :for_context, -> { where(community_id: RequestContext.community_id) } - scope :active, -> { where(deleted: false) } - scope :deleted, -> { where(deleted: true) } after_create :prevent_ulysses_case @@ -31,7 +30,7 @@ def suspended? end def latest_warning - mod_warnings&.order(created_at: 'desc')&.first&.created_at + mod_warnings.order(created_at: 'desc')&.first&.created_at end # Calculation functions for privilege scores @@ -39,8 +38,8 @@ def latest_warning def post_score Rails.cache.fetch("privileges/#{id}/post_score", expires_in: 3.hours) do exclude_types = ApplicationController.helpers.post_type_ids(is_freely_editable: true) - good_posts = Post.where(user: user).where('score > 0.5').where.not(post_type_id: exclude_types).count - bad_posts = Post.where(user: user).where('score < 0.5').where.not(post_type_id: exclude_types).count + good_posts = Post.by(user).good.where.not(post_type_id: exclude_types).count + bad_posts = Post.by(user).bad.where.not(post_type_id: exclude_types).count (good_posts + 2.0) / (good_posts + bad_posts + 4.0) end @@ -48,8 +47,8 @@ def post_score def edit_score Rails.cache.fetch("privileges/#{id}/edit_score", expires_in: 3.hours) do - good_edits = SuggestedEdit.where(user: user).where(active: false, accepted: true).count - bad_edits = SuggestedEdit.where(user: user).where(active: false, accepted: false).count + good_edits = SuggestedEdit.by(user).approved.count + bad_edits = SuggestedEdit.by(user).rejected.count (good_edits + 2.0) / (good_edits + bad_edits + 4.0) end @@ -57,15 +56,18 @@ def edit_score def flag_score Rails.cache.fetch("privileges/#{id}/flag_score", expires_in: 3.hours) do - good_flags = Flag.where(user: user).where(status: 'helpful').count - bad_flags = Flag.where(user: user).where(status: 'declined').count + good_flags = Flag.by(user).helpful.count + bad_flags = Flag.by(user).declined.count (good_flags + 2.0) / (good_flags + bad_flags + 4.0) end end + # Checks if the community user has a given ability + # @param internal_id [String] The +internal_id+ of the ability to check + # @return [Boolean] check result def privilege?(internal_id, ignore_suspension: false, ignore_mod: false) - if internal_id != 'mod' && !ignore_mod && user.is_moderator + if internal_id != 'mod' && !ignore_mod && user.at_least_moderator? return true # includes: privilege? 'mod' end @@ -101,7 +103,7 @@ def grant_privilege!(internal_id, notify: true) # @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) + 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) @@ -126,36 +128,54 @@ def recalc_privilege(internal_id, sandbox: false) ## # Recalculate a list of standard abilities for this CommunityUser. - # @param sandbox [Boolean] Whether to run in sandbox mode - see {#recalc_privilege}. + # @param sandbox [Boolean] Whether to run in sandbox mode - see {#recalc_privilege!}. # @return [Array] - def recalc_privileges(sandbox: false) + def recalc_privileges!(sandbox: false) [:everyone, :unrestricted, :edit_posts, :edit_tags, :flag_close, :flag_curate].map do |ability| - recalc_privilege(ability, sandbox: sandbox) + 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 + 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. # Polyphemus is very grateful for this. def prevent_ulysses_case - recalc_privileges + recalc_privileges! end def trust_level attributes['trust_level'] || recalc_trust_level end + # Checks if the community user is an admin (global or on the current community) + # @return [Boolean] check result + def admin? + is_admin || user&.global_admin? || false + end + + # Checks if the community user is a moderator (global or on the current community) + # @return [Boolean] check result + def moderator? + is_moderator || user&.global_moderator? || false + end + + # Checks if the community user is a moderator or has higher access (global or on the current community) + # @return [Boolean] check result + def at_least_moderator? + moderator? || admin? + end + def recalc_trust_level trust = if user.staff? 5 - elsif is_moderator || user.is_global_moderator || is_admin || user.is_global_admin + elsif at_least_moderator? 4 elsif privilege?('flag_close') || privilege?('edit_posts') 3 diff --git a/app/models/concerns/edits_validations.rb b/app/models/concerns/edits_validations.rb index 11b4b98fd..8eb61a25d 100644 --- a/app/models/concerns/edits_validations.rb +++ b/app/models/concerns/edits_validations.rb @@ -11,8 +11,8 @@ def max_edit_comment_length return end - max_edit_comment_length = SiteSetting['MaxEditCommentLength'] - max_length = [(max_edit_comment_length || 255), 255].min + max_edit_comment_length = SiteSetting['MaxEditCommentLength'] || 255 + max_length = [max_edit_comment_length, 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) diff --git a/app/models/concerns/identity.rb b/app/models/concerns/identity.rb new file mode 100644 index 000000000..29a758088 --- /dev/null +++ b/app/models/concerns/identity.rb @@ -0,0 +1,12 @@ +module Identity + extend ActiveSupport::Concern + + included do + # Is this record the same as a given other record? + # @param other [Class, nil] record to compare with + # @return [Boolean] check result + def same_as?(other) + instance_of?(other.class) && id == other.id + end + end +end diff --git a/app/models/concerns/inspectable.rb b/app/models/concerns/inspectable.rb new file mode 100644 index 000000000..f93a730a2 --- /dev/null +++ b/app/models/concerns/inspectable.rb @@ -0,0 +1,9 @@ +module Inspectable + extend ActiveSupport::Concern + + included do + def inspect + "##{self.class.name} #{attributes.compact.map { |k, v| "#{k}: #{v}" }.join(', ')}>" + end + end +end diff --git a/app/models/concerns/lockable.rb b/app/models/concerns/lockable.rb new file mode 100644 index 000000000..11b334f94 --- /dev/null +++ b/app/models/concerns/lockable.rb @@ -0,0 +1,26 @@ +module Lockable + extend ActiveSupport::Concern + + included do + belongs_to :locked_by, class_name: 'User', optional: true + + scope :locked, -> { where(locked: true) } + scope :not_locked, -> { where(locked: false) } + end + + # Checks whether the record has a lock & that it's not expired + def lock_active? + locked && (locked_until.nil? || !locked_until.past?) + end + + # TODO: predicate methods should not have side-effects! This is for backwards compatibility only + def locked? + return true if lock_active? + + if locked + update(locked: false, locked_by: nil, locked_at: nil, locked_until: nil) + end + + false + end +end diff --git a/app/models/concerns/post_validations.rb b/app/models/concerns/post_validations.rb index a0986b345..e202e5fc9 100644 --- a/app/models/concerns/post_validations.rb +++ b/app/models/concerns/post_validations.rb @@ -10,7 +10,7 @@ module PostValidations 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 } + validate :check_required_tags, if: -> { post_type.has_tags && post_type.has_category } end def maximum_tags @@ -60,8 +60,8 @@ def stripped_minimum_title end def maximum_title_length - max_title_len = SiteSetting['MaxTitleLength'] - if title.length > [(max_title_len || 255), 255].min + max_title_len = SiteSetting['MaxTitleLength'] || 255 + if title.length > [max_title_len, 255].min errors.add(:title, "can't be more than #{max_title_len} characters") end end @@ -73,8 +73,8 @@ def tags_in_tag_set end end - def required_tags? - required = category&.required_tag_ids + def check_required_tags + required = category&.required_tag_ids || [] return unless required.present? && !required.empty? unless tag_ids.any? { |t| required.include? t } diff --git a/app/models/concerns/soft_deletable.rb b/app/models/concerns/soft_deletable.rb new file mode 100644 index 000000000..07243dcae --- /dev/null +++ b/app/models/concerns/soft_deletable.rb @@ -0,0 +1,10 @@ +module SoftDeletable + extend ActiveSupport::Concern + + included do + belongs_to :deleted_by, class_name: 'User', optional: true + + scope :deleted, -> { where(deleted: true) } + scope :undeleted, -> { where(deleted: false) } + end +end diff --git a/app/models/concerns/timestamped.rb b/app/models/concerns/timestamped.rb new file mode 100644 index 000000000..919016840 --- /dev/null +++ b/app/models/concerns/timestamped.rb @@ -0,0 +1,9 @@ +module Timestamped + extend ActiveSupport::Concern + + included do + scope :newest_first, -> { reorder(created_at: :desc) } + scope :oldest_first, -> { reorder(created_at: :asc) } + scope :recent, ->(cutoff = 24.hours.ago) { where(created_at: cutoff..DateTime.now) } + end +end diff --git a/app/models/concerns/user_merge.rb b/app/models/concerns/user_merge.rb index acaae6886..c72c7763b 100644 --- a/app/models/concerns/user_merge.rb +++ b/app/models/concerns/user_merge.rb @@ -57,7 +57,7 @@ def merge_into(target_user, attribute_to) private def copy_abilities(_target_user, cu_ids) - cu_ids.each do |_cid, ids| + cu_ids.each_value do |ids| target_abilities = UserAbility.where(community_user_id: ids[:target]) copy_abilities = UserAbility.where(community_user_id: ids[:source]) .where.not(ability_id: target_abilities.map(&:ability_id).uniq) @@ -87,7 +87,7 @@ def copy_votes(target_user) end def copy_warnings(_target_user, cu_ids) - cu_ids.each do |_cid, ids| + cu_ids.each_value do |ids| ModWarning.where(community_user_id: ids[:source]).update_all(community_user_id: ids[:target]) end end diff --git a/app/models/concerns/user_rate_limits.rb b/app/models/concerns/user_rate_limits.rb new file mode 100644 index 000000000..2b68146f9 --- /dev/null +++ b/app/models/concerns/user_rate_limits.rb @@ -0,0 +1,66 @@ +module UserRateLimits + extend ActiveSupport::Concern + + # Gets the max number of comments the user can make per day on posts made by other users + # @return [Integer] + def max_comments_per_day_on_posts_of_others + SiteSetting[new? ? 'RL_NewUserComments' : 'RL_Comments'] || 0 + end + + # Gets the max number of comments the user can make per day on their own posts or answers to them + # @return [Integer] + def max_comments_per_day_on_own_posts + SiteSetting[new? ? 'RL_NewUserCommentsOwnPosts' : 'RL_CommentsOwnPosts'] || 0 + end + + # Gets the max number of comments the user can make on a given post + # @param post [Post] post to get the limit for + # @return [Integer] + def max_comments_per_day(post) + owns_post_or_parent?(post) ? max_comments_per_day_on_own_posts : max_comments_per_day_on_posts_of_others + end + + # Gets the max number of votes the user can make per day + # @return [Integer] + def max_votes_per_day + SiteSetting[new? ? 'RL_NewUserVotes' : 'RL_Votes'] || 0 + end + + # Number of comments by the user based on whether they own a given post + # @param post [Post] post to use for the check + # @return [Integer] + def recent_comments_count(post) + owns_post_or_parent?(post) ? recent_comments_on_own_posts_count : recent_comments_on_posts_of_others_count + end + + # Number of comments by the user on own posts or answers to them in the last 24 hours + # @return [Integer] + def recent_comments_on_own_posts_count + Comment.recent.by(self) + .where(post: Post.parent_by(self)) + .or(Comment.recent.by(self).where(post: Post.by(self))) + .count + end + + # Number of comments by the user on posts made by other users in the last 24 hours + # @return [Integer] + def recent_comments_on_posts_of_others_count + Comment.recent.by(self) + .where.not(post: Post.parent_by(self)) + .where.not(post: Post.by(self)) + .count + end + + # Number of votes by the user on posts of others in the last 24 hours + # @return [Integer] number of recent votes + def recent_votes_count + Vote.recent.by(self).where.not(post: Post.parent_by(self)).count + end + + # Has the user reached comment limit for a given post? + # @param post [Post] post to check + # @return [Boolean] check result + def comment_rate_limited?(post) + recent_comments_count(post) >= max_comments_per_day(post) + end +end diff --git a/app/models/concerns/username_validations.rb b/app/models/concerns/username_validations.rb new file mode 100644 index 000000000..342c1e720 --- /dev/null +++ b/app/models/concerns/username_validations.rb @@ -0,0 +1,37 @@ +module UsernameValidations + extend ActiveSupport::Concern + + included do + validates :username, presence: true, length: { minimum: 3, maximum: 50 } + + validate :no_blank_unicode_in_username + validate :no_links_in_username + validate :username_not_fake_admin + + def no_blank_unicode_in_username + not_valid = !username.scan(/[\u200B-\u200D\uFEFF]/).empty? + if not_valid + errors.add(:username, 'may not contain blank unicode characters') + end + end + + def no_links_in_username + if %r{(?:http|ftp)s?://(?:\w+\.)+[a-zA-Z]{2,10}}.match?(username) + errors.add(:username, 'cannot contain links') + AuditLog.block_log(event_type: 'user_username_link_blocked', + comment: "username: #{username}") + end + end + + def username_not_fake_admin + admin_badge = SiteSetting['AdminBadgeCharacter'] + mod_badge = SiteSetting['ModBadgeCharacter'] + + [admin_badge, mod_badge].each do |badge| + if badge.present? && username.include?(badge) + errors.add(:username, "may not include the #{badge} character") + end + end + end + end +end diff --git a/app/models/error_log.rb b/app/models/error_log.rb index 179803990..891b04c0e 100644 --- a/app/models/error_log.rb +++ b/app/models/error_log.rb @@ -1,4 +1,6 @@ class ErrorLog < ApplicationRecord + include Timestamped + belongs_to :community, optional: true belongs_to :user, optional: true end diff --git a/app/models/filter.rb b/app/models/filter.rb index fb11e1d47..9b1b3343d 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -2,6 +2,6 @@ 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 + serialize :include_tags, coder: YAML, type: Array + serialize :exclude_tags, coder: YAML, type: Array end diff --git a/app/models/flag.rb b/app/models/flag.rb index 93699bb46..59af5dc74 100644 --- a/app/models/flag.rb +++ b/app/models/flag.rb @@ -1,15 +1,29 @@ # Represents a flag. Flags are attached to both a user and a post, and have a single status. class Flag < ApplicationRecord include CommunityRelated + include Timestamped + belongs_to :post, polymorphic: true belongs_to :user belongs_to :handled_by, class_name: 'User', optional: true belongs_to :post_flag_type, optional: true belongs_to :escalated_by, class_name: 'User', optional: true + scope :by, ->(user) { where(user: user) } + scope :declined, -> { where(status: 'declined') } + scope :helpful, -> { where(status: 'helpful') } + scope :handled, -> { where.not(status: nil) } scope :unhandled, -> { where(status: nil) } scope :confidential, -> { where(post_flag_type: PostFlagType.confidential).or(where(post_flag_type: nil)) } scope :not_confidential, -> { where(post_flag_type: PostFlagType.not_confidential) } + + scope :escalated, -> { where(escalated: true) } + + # Checks if the flag is confidential as per its type + # @return [Boolean] check result + def confidential? + post_flag_type&.confidential || false + end end diff --git a/app/models/micro_auth/app.rb b/app/models/micro_auth/app.rb index eda9db114..37b71b3a3 100644 --- a/app/models/micro_auth/app.rb +++ b/app/models/micro_auth/app.rb @@ -1,5 +1,5 @@ class MicroAuth::App < ApplicationRecord - has_many :tokens, class_name: 'MicroAuth::Token' + has_many :tokens, class_name: 'MicroAuth::Token', dependent: :destroy has_many :users, through: :tokens belongs_to :user belongs_to :deactivated_by, class_name: 'User', required: false diff --git a/app/models/micro_auth/token.rb b/app/models/micro_auth/token.rb index c24d01c4d..4dcf13cee 100644 --- a/app/models/micro_auth/token.rb +++ b/app/models/micro_auth/token.rb @@ -2,7 +2,7 @@ class MicroAuth::Token < ApplicationRecord belongs_to :app, class_name: 'MicroAuth::App' belongs_to :user - serialize :scope, JSON + serialize :scope, coder: YAML scope :active, -> { where('expires_at > ?', DateTime.now) } diff --git a/app/models/mod_warning.rb b/app/models/mod_warning.rb index 67430b260..769c9424a 100644 --- a/app/models/mod_warning.rb +++ b/app/models/mod_warning.rb @@ -1,10 +1,15 @@ class ModWarning < ApplicationRecord + include Timestamped + # Warning class name not accepted by Rails, hence this needed self.table_name = 'warnings' belongs_to :community_user belongs_to :author, class_name: 'User' + scope :active, -> { where(active: true) } + scope :to, ->(user) { where(community_user: user.community_user) } + def suspension_active? active && is_suspension && !suspension_end.past? end diff --git a/app/models/post.rb b/app/models/post.rb index 9545bda5e..3f72411ea 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -1,14 +1,15 @@ class Post < ApplicationRecord include CommunityRelated + include Lockable include PostValidations + include SoftDeletable + include Timestamped belongs_to :user, optional: true belongs_to :post_type belongs_to :parent, class_name: 'Post', optional: true belongs_to :closed_by, class_name: 'User', optional: true - belongs_to :deleted_by, class_name: 'User', optional: true belongs_to :last_activity_by, class_name: 'User', optional: true - belongs_to :locked_by, class_name: 'User', optional: true belongs_to :last_edited_by, class_name: 'User', optional: true belongs_to :category, optional: true belongs_to :license, optional: true @@ -22,12 +23,13 @@ class Post < ApplicationRecord has_many :flags, as: :post, dependent: :destroy has_many :children, class_name: 'Post', foreign_key: 'parent_id', dependent: :destroy has_many :suggested_edits, dependent: :destroy - has_many :reactions + has_many :reactions, dependent: :destroy + has_many :inbound_duplicates, class_name: 'Post', foreign_key: 'duplicate_post_id', dependent: :nullify 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 + serialize :tags_cache, coder: YAML, type: Array validates :body, presence: true, length: { maximum: 30_000 } validates :doc_slug, uniqueness: { scope: [:community_id], case_sensitive: false }, if: -> { doc_slug.present? } @@ -40,13 +42,19 @@ class Post < ApplicationRecord # Other validations (shared with suggested edits) are in concerns/PostValidations - scope :undeleted, -> { where(deleted: false) } - scope :deleted, -> { where(deleted: true) } + scope :bad, -> { where('score < 0.5') } + scope :by, ->(user) { where(user: user) } + scope :good, -> { where('score > 0.5') } + scope :in, ->(category) { where(category: category) } + scope :on, ->(community) { where(community: community) } + scope :problematic, -> { where('score < 0.25 OR deleted=1') } + scope :parent_by, ->(user) { includes(:parent).where(parents_posts: { user_id: user.id }) } scope :qa_only, -> { where(post_type_id: [Question.post_type_id, Answer.post_type_id, Article.post_type_id]) } scope :list_includes, lambda { includes(:user, :tags, :post_type, :category, :last_activity_by, user: :avatar_attachment) } + scope :has_duplicates, -> { joins(:inbound_duplicates) } # uses INNER JOIN by default so no where required before_validation :update_tag_associations, if: -> { post_type&.has_tags } after_create :create_initial_revision @@ -57,6 +65,13 @@ class Post < ApplicationRecord after_save :update_category_activity, if: -> { post_type.has_category && !destroyed? } after_save :recalc_score + # Gets posts appropriately scoped for a given user + # @param user [User] user to check + # @return [ActiveRecord::Relation] + def self.accessible_to(user) + (user&.at_least_moderator? ? Post : Post.undeleted).qa_only.list_includes + end + # @param term [String] the search term # @return [ActiveRecord::Relation] def self.search(term) @@ -73,7 +88,7 @@ def self.by_slug(slug, user) return nil end - if post&.help_category == '$Moderator' && !user&.is_moderator + if post&.help_category == '$Moderator' && !user&.at_least_moderator? return nil end @@ -189,17 +204,10 @@ def recalc_score 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? - - if locked - update(locked: false, locked_by: nil, locked_at: nil, locked_until: nil) - end - - false + # Checks whether the post allows users to comment on it + # @return [Boolean] check result + def comments_allowed? + !locked? && !deleted && !comments_disabled end # The test here is for flags that are pending (no status). A spam flag @@ -210,15 +218,17 @@ 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 + # Checks whether a given user can access the post at all + # @param user [User, Nil] user to check access for + # @return [Boolean] access check result def can_access?(user) - (!deleted? || user&.has_post_privilege?('flag_curate', self)) && + (!deleted? || user&.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 + # Maps reaction types to number of reactions of that type + # @return [Hash{ReactionType => Integer}] def reaction_list reactions.includes(:reaction_type).group_by(&:reaction_type_id) .to_h { |_k, v| [v.first.reaction_type, v] } @@ -351,13 +361,13 @@ def license_valid def moderator_tags mod_tags = category&.moderator_tags&.map(&:name) return unless mod_tags.present? && !mod_tags.empty? - return if RequestContext.user&.is_moderator + return if RequestContext.user&.at_least_moderator? sc = changes return unless sc.include? 'tags_cache' if (sc['tags_cache'][0] || []) & mod_tags != (sc['tags_cache'][1] || []) & mod_tags - errors.add(:base, "You don't have permission to change moderator-only tags.") + errors.add(:mod_tags, "You don't have permission to change moderator-only tags.") end end diff --git a/app/models/post_history.rb b/app/models/post_history.rb index 990518da2..4e1fdfd6b 100644 --- a/app/models/post_history.rb +++ b/app/models/post_history.rb @@ -7,6 +7,10 @@ class PostHistory < ApplicationRecord has_many :post_history_tags has_many :tags, through: :post_history_tags + scope :by, ->(user) { where(user: user) } + scope :of_type, ->(name) { joins(:post_history_type).where(post_history_types: { name: name }) } + scope :on_undeleted, -> { joins(:post).where(posts: { deleted: false }) } + def before_tags tags.where(post_history_tags: { relationship: 'before' }) end @@ -15,15 +19,16 @@ 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 + # Checks whether a given user is allowed to see post history item details + # @param user [User] user to check for + # @return [Boolean] check result def allowed_to_see_details?(user) - !hidden || user&.is_admin || user_id == user&.id || post.user_id == user&.id + !hidden || user&.admin? || user_id == user&.id || post.user_id == user&.id end # Hides all previous history - # @param post [Post] - # @param user [User] + # @param post [Post] post to redact history for + # @param user [User] user that is redacting the history def self.redact(post, user) where(post: post).update_all(hidden: true) history_hidden(post, user, after: post.body_markdown, diff --git a/app/models/reaction.rb b/app/models/reaction.rb index 28b039ced..518088454 100644 --- a/app/models/reaction.rb +++ b/app/models/reaction.rb @@ -1,4 +1,6 @@ class Reaction < ApplicationRecord + include Timestamped + belongs_to :reaction_type belongs_to :user belongs_to :post diff --git a/app/models/site_setting.rb b/app/models/site_setting.rb index 160bbd05b..6b4d48e24 100644 --- a/app/models/site_setting.rb +++ b/app/models/site_setting.rb @@ -9,15 +9,15 @@ class SiteSetting < ApplicationRecord scope :global, -> { for_community_id(nil) } scope :priority_order, -> { order(Arel.sql('IF(site_settings.community_id IS NULL, 1, 0)')) } - def self.[](name) - key = "SiteSettings/#{RequestContext.community_id}/#{name}" + def self.[](name, community: nil) + key = "SiteSettings/#{community.present? ? community.id : RequestContext.community_id}/#{name}" cached = Rails.cache.fetch key, include_community: false do - SiteSetting.applied_setting(name)&.typed + SiteSetting.applied_setting(name, community: community)&.typed end if cached.nil? Rails.cache.delete key, include_community: false - value = SiteSetting.applied_setting(name)&.typed + value = SiteSetting.applied_setting(name, community: community)&.typed Rails.cache.write key, value, include_community: false value else @@ -25,9 +25,21 @@ def self.[](name) end end + def self.[]=(name, value) + key = "SiteSettings/#{RequestContext.community_id}/#{name}" + + setting = applied_setting(name) + + typed_value = SettingConverter.new(value).send("as_#{setting.value_type.downcase}") + + setting.update(value: typed_value) + + Rails.cache.write key, typed_value, include_community: false + end + def self.exist?(name) Rails.cache.exist?("SiteSettings/#{RequestContext.community_id}/#{name}", include_community: false) || - SiteSetting.where(name: name).count.positive? + SiteSetting.where(name: name).any? end # Checks whether the setting is a global site setting @@ -36,6 +48,36 @@ def global? community_id.nil? end + # Is the setting boolean-valued? + # @return [Boolena] check result + def boolean? + value_type.downcase == 'boolean' + end + + # Is the setting floating point number-valued? + # @return [Boolena] check result + def float? + value_type.downcase == 'float' + end + + # Is the setting integer-valued? + # @return [Boolena] check result + def integer? + value_type.downcase == 'integer' + end + + # Is the setting string-valued (plain text)? + # @return [Boolean] check result + def string? + value_type.downcase == 'string' + end + + # Is the setting text-valued (HTML-aware text)? + # @return [Boolean] check result + def text? + value_type.downcase == 'text' + end + def typed SettingConverter.new(value).send("as_#{value_type.downcase}") end @@ -44,8 +86,9 @@ def self.typed(setting) SettingConverter.new(setting.value).send("as_#{setting.value_type.downcase}") end - def self.applied_setting(name) - SiteSetting.for_community_id(RequestContext.community_id).or(global).where(name: name).priority_order.first + def self.applied_setting(name, community: nil) + SiteSetting.for_community_id(community.present? ? community.id : RequestContext.community_id) + .or(global).where(name: name).priority_order.first end def self.all_communities(name) diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 149a35ec3..dfc90742f 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -2,6 +2,7 @@ class Subscription < ApplicationRecord self.inheritance_column = 'sti_type' include CommunityRelated + include Timestamped belongs_to :user @@ -13,23 +14,23 @@ class Subscription < ApplicationRecord def questions case type when 'all' - Question.unscoped.where(community: community, post_type_id: Question.post_type_id) + Question.unscoped.on(community).where(post_type_id: Question.post_type_id) .where(Question.arel_table[:created_at].gteq(last_sent_at || created_at)) when 'tag' - Question.unscoped.where(community: community, post_type_id: Question.post_type_id) + Question.unscoped.on(community).where(post_type_id: Question.post_type_id) .where(Question.arel_table[:created_at].gteq(last_sent_at || created_at)) .joins(:tags).where(tags: { name: qualifier }) when 'user' - Question.unscoped.where(community: community, post_type_id: Question.post_type_id) + Question.unscoped.on(community).where(post_type_id: Question.post_type_id) .where(Question.arel_table[:created_at].gteq(last_sent_at || created_at)) .where(user_id: qualifier) when 'interesting' RequestContext.community = community # otherwise SiteSetting#[] doesn't work - Question.unscoped.where(community: community, post_type_id: Question.post_type_id) + Question.unscoped.on(community).where(post_type_id: Question.post_type_id) .where('score >= ?', SiteSetting['InterestingSubscriptionScoreThreshold']) .order(Arel.sql('RAND()')) when 'category' - Question.unscoped.where(community: community, post_type_id: Question.post_type_id) + Question.unscoped.on(community).where(post_type_id: Question.post_type_id) .where(Question.arel_table[:created_at].gteq(last_sent_at || created_at)) .where(category_id: qualifier) end&.order(created_at: :desc)&.limit(25) diff --git a/app/models/suggested_edit.rb b/app/models/suggested_edit.rb index 0afbe4d90..83bf8c31c 100644 --- a/app/models/suggested_edit.rb +++ b/app/models/suggested_edit.rb @@ -2,11 +2,12 @@ class SuggestedEdit < ApplicationRecord include PostRelated include PostValidations include EditsValidations + include Timestamped belongs_to :user - serialize :tags_cache, Array - serialize :before_tags_cache, Array + serialize :tags_cache, coder: YAML, type: Array + serialize :before_tags_cache, coder: YAML, type: Array belongs_to :decided_by, class_name: 'User', optional: true has_and_belongs_to_many :tags @@ -17,6 +18,10 @@ class SuggestedEdit < ApplicationRecord after_save :clear_pending_cache, if: proc { saved_change_to_attribute?(:active) } + scope :approved, -> { where(active: false, accepted: true) } + scope :by, ->(user) { where(user: user) } + scope :rejected, -> { where(active: false, accepted: false) } + def clear_pending_cache Rails.cache.delete "pending_suggestions/#{post.category_id}" end diff --git a/app/models/user.rb b/app/models/user.rb index c724fef5f..6a2ed2c61 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,13 +1,18 @@ -# Represents a user. Most of the User's logic is controlled by Devise and its overrides. A user, as far as the -# application code (i.e. excluding Devise) is concerned, has many questions, answers, and votes. class User < ApplicationRecord + include ::UsernameValidations + include ::UserRateLimits include ::UserMerge + include ::Timestamped + include ::SoftDeletable include ::SamlInit + include ::Inspectable + include ::Identity devise :database_authenticatable, :registerable, :confirmable, :recoverable, :rememberable, :trackable, :validatable, :lockable, :omniauthable, :saml_authenticatable + has_many :apps, class_name: 'MicroAuth::App', dependent: :destroy has_many :posts, dependent: :nullify has_many :votes, dependent: :destroy has_many :notifications, dependent: :destroy @@ -29,71 +34,153 @@ class User < ApplicationRecord 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 } validates :login_token, uniqueness: { allow_blank: true, case_sensitive: false } - validate :no_links_in_username - validate :username_not_fake_admin - validate :no_blank_unicode_in_username validate :email_domain_not_blocklisted - validate :is_not_blocklisted + validate :not_blocklisted? validate :email_not_bad_pattern delegate :reputation, :reputation=, :privilege?, :privilege, to: :community_user - scope :active, -> { where(deleted: false) } - scope :deleted, -> { where(deleted: true) } - - after_create :send_welcome_tour_message, :ensure_websites - def self.list_includes includes(:posts, :avatar_attachment) end def self.search(term) - where('username LIKE ?', "#{sanitize_sql_like(term)}%") + where('username LIKE ?', "%#{sanitize_sql_like(term)}%") end - def inspect - "#" + # Safely gets the user's trust level even if they don't have a community user + # @return [Integer] user's trust level + def trust_level + community_user&.trust_level || 0 end - def trust_level - community_user.trust_level + # Is the user a new user? + # @return [Boolean] check result + def new? + !privilege?('unrestricted') 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 + # Does the user own a given post or its parent, if any? + # @param post [Post] post to check + # @return [Boolean] check result + def owns_post_or_parent?(post) + post.user_id == id || post.parent&.user_id == 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 + def post_privilege?(name, post) + post.user == self || privilege?(name) + end - def has_post_privilege?(name, post) - if post.user == self - true - else - privilege?(name) - end + # Can the user archive a given comment thread? + # @param thread [CommentThread] thread to archive + # @return [Boolean] check result + def can_archive?(thread) + privilege?('flag_curate') && !thread.archived? + end + + # Can the user unarchive a given comment thread? + # @param thread [CommentThread] thread to archive + # @return [Boolean] check result + def can_unarchive?(thread) + privilege?('flag_curate') && thread.archived? + end + + # Can the user decide (approve or reject) a given suggested edit? + # @param edit [SuggestedEdit] edit to check + # @return [Boolean] check result + def can_decide?(edit) + edit.post.present? && can_update?(edit.post, edit.post.post_type) + end + + alias can_approve? can_decide? + alias can_reject? can_decide? + + # Can the user comment on a given post? + # @param post [Post] post to check + # @return [Boolean] check result + def can_comment_on?(post) + return true if at_least_moderator? + + post.comments_allowed? && !comment_rate_limited?(post) + end + + # Can the user delete a given target? + # @param target [ApplicationRecord] record to delete + # @return [Boolean] check result + def can_delete?(target) + privilege?('flag_curate') && !target.deleted? end - # Checks if the user can push a given post type to network + # Can the user undelete a given target? + # @param target [ApplicationRecord] record to undelete + # @return [Boolean] check result + def can_undelete?(target) + privilege?('flag_curate') && target.deleted? + end + + # Can the user lock a given target? + # @param target [Lockable] record to lock + # @return [Boolean] check result + def can_lock?(target) + privilege?('flag_curate') && !target.locked? + end + + # Can the user unlock a given target? + # @param target [Lockable] record to unlock + # @return [Boolean] check result + def can_unlock?(target) + privilege?('flag_curate') && target.locked? + end + + # Can the user post in the current category? + # @param category [Category, nil] category to check + # @return [Boolean] check result + def can_post_in?(category) + category.blank? || category.min_trust_level.blank? || category.min_trust_level <= trust_level + end + + # Can the user reply to a given comment thread? + # @param [CommentThread] thread to check + # @return [Boolean] check result + def can_reply_to?(thread) + return true if at_least_moderator? + + can_comment_on?(thread.post) && !thread.read_only? + end + + # Can the user see a given category at all? + # @param category [Category] category to check + # @return [Boolean] check result + def can_see_category?(category) + category_trust_level = category.min_view_trust_level || -1 + trust_level >= category_trust_level + end + + # Is the user allowed to see deleted posts? + # @return [Boolean] check result + def can_see_deleted_posts? + privilege?('flag_curate') || false + end + + # Can the user 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) + # @return [Boolean] check result + 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 + # Can the user 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 || \ + # @return [Boolean] check result + def can_update?(post, post_type) + return false unless can_post_in?(post.category) + + post_privilege?('edit_posts', post) || at_least_moderator? || (post_type.is_freely_editable && privilege?('unrestricted')) end @@ -101,20 +188,19 @@ def metric(key) Rails.cache.fetch("community_user/#{community_user.id}/metric/#{key}", expires_in: 24.hours) do case key when 'p' - Post.qa_only.undeleted.where(user: self).count + Post.qa_only.undeleted.by(self).count when '1' - Post.undeleted.where(post_type: PostType.top_level, user: self).count + Post.undeleted.by(self).where(post_type: PostType.top_level).count when '2' - Post.undeleted.where(post_type: PostType.second_level, user: self).count + Post.undeleted.by(self).where(post_type: PostType.second_level).count when 's' - Vote.where(recv_user_id: id, vote_type: 1).count - \ - Vote.where(recv_user_id: id, vote_type: -1).count + Vote.for(self).where(vote_type: 1).count - Vote.for(self).where(vote_type: -1).count when 'v' - Vote.where(recv_user_id: id).count + Vote.for(self).count when 'V' votes.count when 'E' - PostHistory.where(user: self, post_history_type: PostHistoryType.find_by(name: 'post_edited')).count + PostHistory.by(self).of_type('post_edited').count end end end @@ -151,16 +237,83 @@ def ensure_websites end end - def is_moderator - is_global_moderator || community_user&.is_moderator || is_admin || community_user&.privilege?('mod') || false + # Is the user a global admin (ensures consistent return type & naming scheme)? + # @return [Boolean] check result + def global_admin? + is_global_admin || false + end + + # Is the user a global moderator (ensures consistent return type & naming scheme)? + # @return [Boolean] check result + def global_moderator? + is_global_moderator || false end - def is_admin - is_global_admin || community_user&.is_admin || false + # Is the user either a global admin or an admin on the current community? + # @return [Boolean] check result + def admin? + global_admin? || community_user&.admin? || false end - # Used by network profile: does this user have a profile on that other comm? - def has_profile_on(community_id) + # Is the user either a global moderator or a moderator on the current community? + # @return [Boolean] check result + def moderator? + global_moderator? || community_user&.moderator? || false + end + + # Is the user at least a moderator, meaning the user is either: + # - a global moderator or a moderator on the current community + # - a global admin or an admin on the current community + # - has an explicit moderator privilege on the current community + # @return [Boolean] check result + def at_least_moderator? + moderator? || admin? || community_user&.privilege?('mod') || false + end + + # Is the user neither a moderator nor an admin (global or on the current community)? + # @return [Boolean] check result + def standard? + !at_least_moderator? + end + + # Is the user is a global moderator or a global admin? + # @return [Boolean] check result + def at_least_global_moderator? + global_moderator? || global_admin? || false + end + + # Which communities is this user a moderator (local or global) on? + # @return [Community[]] list of communities + def moderator_communities + if global_moderator? + Community.all + else + Community.joins(:community_users).where(community_users: { user_id: id, is_moderator: true }) + end + end + + # Which communities is this user an admin (local or global) of? + # @return [Community[]] list of communities + def admin_communities + if global_admin? + Community.all + else + Community.joins(:community_users).where(community_users: { user_id: id, is_admin: true }) + end + end + + # Is the user a moderator on a given community? + # @param community_id [Integer] community id to check for + # @return [Boolean] check result + def moderator_on?(community_id) + cu = community_users.where(community_id: community_id).first + cu&.at_least_moderator? || cu&.privilege?('mod') || false + end + + # Does the user have a profile on a given community? + # @param community_id [Integer] id of the community to check + # @return [Boolean] check result + def profile_on?(community_id) cu = community_users.where(community_id: community_id).first !cu&.user_id.nil? || false end @@ -175,15 +328,13 @@ def post_count_on(community_id) cu&.post_count || 0 end - def is_moderator_on(community_id) + # Does the user have an ability on a given community? + # @param community_id [Integer] community id to check for + # @param ability_internal_id [String] internal ability id + # @return [Boolean] check result + def ability_on?(community_id, ability_internal_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') + if cu&.at_least_moderator? || cu&.privilege?('mod') true elsif cu.nil? false @@ -199,24 +350,6 @@ def rtl_safe_username "#{username}\u202D" end - def username_not_fake_admin - admin_badge = SiteSetting['AdminBadgeCharacter'] - mod_badge = SiteSetting['ModBadgeCharacter'] - - [admin_badge, mod_badge].each do |badge| - if badge.present? && username.include?(badge) - errors.add(:username, "may not include the #{badge} character") - end - end - end - - def no_blank_unicode_in_username - not_valid = !username.scan(/[\u200B-\u200D\uFEFF]/).empty? - if not_valid - errors.add(:username, 'may not contain blank unicode characters') - end - end - def email_domain_not_blocklisted return unless File.exist?(Rails.root.join('../.qpixel-domain-blocklist.txt')) return unless saved_changes.include? 'email' @@ -232,8 +365,8 @@ def email_domain_not_blocklisted end end - def is_not_blocklisted - return unless saved_changes.include? 'email' + def not_blocklisted? + return true unless saved_changes.include? 'email' email_domain = email.split('@')[-1] is_mail_blocked = BlockedItem.emails.where(value: email) @@ -269,14 +402,6 @@ def ensure_community_user! community_user || create_community_user(reputation: SiteSetting['NewUserInitialRep']) end - def no_links_in_username - if %r{(?:http|ftp)s?://(?:\w+\.)+[a-zA-Z]{2,10}}.match?(username) - errors.add(:username, 'cannot contain links') - AuditLog.block_log(event_type: 'user_username_link_blocked', - comment: "username: #{username}") - end - end - def extract_ip_from(request) # Customize this to your environment: if you're not behind a reverse proxy like Cloudflare, you probably # don't need this (or you can change it to another header if that's what your reverse proxy uses). @@ -332,7 +457,7 @@ def validate_prefs! }.each do |key, prefs| saved = RequestContext.redis.hgetall(key) valid_prefs = prefs.keys - deprecated = saved.reject { |k, _v| valid_prefs.include? k }.map { |k, _v| k } + deprecated = saved.except(*valid_prefs).map { |k, _v| k } unless deprecated.empty? RequestContext.redis.hdel key, *deprecated end @@ -343,7 +468,7 @@ def preference(name, community: false) preferences[community ? :community : :global][name] end - def has_active_flags?(post) + def active_flags_on?(post) !post.flags.where(user: self, status: nil).empty? end @@ -378,6 +503,4 @@ def votes_by_type def votes_by_post_type votes.joins(:post).group(Arel.sql('posts.post_type_id')).count(Arel.sql('posts.post_type_id')) end - - # rubocop:enable Naming/PredicateName end diff --git a/app/models/vote.rb b/app/models/vote.rb index a7c42dcb3..e48f63065 100644 --- a/app/models/vote.rb +++ b/app/models/vote.rb @@ -2,14 +2,18 @@ # association), and to a user. class Vote < ApplicationRecord include PostRelated + include Timestamped + belongs_to :user, optional: false belongs_to :recv_user, class_name: 'User', optional: false + scope :by, ->(user) { where(user: user) } + scope :for, ->(user) { where(recv_user: user) } + after_create :apply_rep_change after_create :add_counter before_destroy :check_valid before_destroy :reverse_rep_change - after_destroy :remove_counter validates :vote_type, inclusion: [1, -1] diff --git a/app/views/abilities/show.html.erb b/app/views/abilities/show.html.erb index a34e29ca2..23ccdfb27 100644 --- a/app/views/abilities/show.html.erb +++ b/app/views/abilities/show.html.erb @@ -123,7 +123,7 @@

<%= @user.username %> has already earned this ability.

<% end %>
- <% if @your_ability&.is_suspended && (@user.id == current_user&.id || current_user&.is_moderator) %> + <% if @your_ability&.is_suspended && (@user.id == current_user&.id || current_user&.at_least_moderator?) %>
<% if @your_ability.suspension_end.nil? %> diff --git a/app/views/admin/audit_log.html.erb b/app/views/admin/audit_log.html.erb index ea2b467af..57e6c1deb 100644 --- a/app/views/admin/audit_log.html.erb +++ b/app/views/admin/audit_log.html.erb @@ -17,4 +17,8 @@
-<%= render 'log_table' %> \ No newline at end of file +<%= render 'log_table' %> + +
+ <%= will_paginate @logs, renderer: BootstrapPagination::Rails %> +
diff --git a/app/views/admin/email_query.html.erb b/app/views/admin/email_query.html.erb new file mode 100644 index 000000000..b809686ae --- /dev/null +++ b/app/views/admin/email_query.html.erb @@ -0,0 +1,18 @@ +

<%= t 'admin.tools.email_query' %>

+

<%= t 'admin.email_query_blurb' %>

+ +<% if defined?(@user) %> +

Your query for <%= params[:email] %> returned profiles in these communities:

+ +

These results only include communities where you are an admin.

+<% else %> + <%= form_tag do_email_query_path, method: 'POST' do %> + <%= label_tag :email, 'Email', class: 'form-element' %> + <%= text_field_tag :email, params[:email], class: 'form-element' %> + <%= submit_tag 'Search', class: 'button is-filled' %> + <% end %> +<% end %> diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb index 2f4c5266d..0bb9bf619 100644 --- a/app/views/admin/index.html.erb +++ b/app/views/admin/index.html.erb @@ -139,4 +139,13 @@ <% end %> + +
+
+
+ + <%= link_to 'User Lookup by Email', admin_email_query_path, 'data-ckb-item-link' => '' %> +
+
+
diff --git a/app/views/application/dashboard.html.erb b/app/views/application/dashboard.html.erb index 397e020fc..8fec9b4cf 100644 --- a/app/views/application/dashboard.html.erb +++ b/app/views/application/dashboard.html.erb @@ -30,7 +30,7 @@ <% end %> <% end %> - <% if current_user&.has_ability_on(c.id, 'edit_posts') %> + <% if current_user&.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 %> diff --git a/app/views/categories/category_post_types.html.erb b/app/views/categories/category_post_types.html.erb index 808630027..c9d53c8d9 100644 --- a/app/views/categories/category_post_types.html.erb +++ b/app/views/categories/category_post_types.html.erb @@ -1,8 +1,17 @@ -

- <%= link_to edit_category_path(@category) do %> - « Back to category edit - <% end %> -

+<%# + View for managing allowed category post types + + Parameters: + params[:no_return] : whether to suppress the return link +%> + +<% unless params[:no_return] == '1' %> +

+ <%= link_to edit_category_path(@category) do %> + « Back to category edit + <% end %> +

+<% end %>

Allowed post types for <%= @category.name %>

Only post types listed here are allowed to be posted in this category. Not all will be displayed as available options diff --git a/app/views/categories/index.html.erb b/app/views/categories/index.html.erb index 2c507c1fe..6999cd10b 100644 --- a/app/views/categories/index.html.erb +++ b/app/views/categories/index.html.erb @@ -18,15 +18,15 @@ <%= link_to category_path(cat), class: 'button is-filled' do %> See posts » <% end %> - <% if current_user&.is_admin %> + <% if current_user&.admin? %> <%= link_to 'Edit', edit_category_path(cat), class: 'button is-outlined' %> <% end %> <% end %> -<% if current_user&.is_admin %> +<% if current_user&.admin? %> <%= link_to new_category_path, class: 'button is-outlined' do %> Add new category » <% end %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/categories/post_types.html.erb b/app/views/categories/post_types.html.erb index 3b594b327..3ab6bd43f 100644 --- a/app/views/categories/post_types.html.erb +++ b/app/views/categories/post_types.html.erb @@ -1,6 +1,12 @@ -

What kind of post?

+<% header_title = @post_types.any? ? 'What kind of post?' : 'No allowed post types' + header_subtitle = @post_types.any? \ + ? 'This category has more than one type of post available. Pick a post type to get started.' + : 'This category does not have any post types available.' +%> + +

<%= header_title %>

- This category has more than one type of post available. Pick a post type to get started. + <%= header_subtitle %>

<% @post_types.each do |pt| %> diff --git a/app/views/categories/show.html.erb b/app/views/categories/show.html.erb index 62a18a316..96e10ea2f 100644 --- a/app/views/categories/show.html.erb +++ b/app/views/categories/show.html.erb @@ -11,7 +11,7 @@ <% end %> <% post_count = @posts.count %> -
+
<%= short_number_to_human post_count, precision: 1, significant: false %> @@ -19,7 +19,7 @@ <%= render 'shared/rss_link', url: category_feed_path(@category, format: 'rss') %> - <% if current_user&.is_admin %> + <% if current_user&.admin? %> <%= link_to 'Edit Category', edit_category_path(@category) %> <% end %> @@ -47,7 +47,7 @@
-
+
Filters (<%= @filtered ? @active_filter[:name].empty? ? 'Custom' : @active_filter[:name] : 'None' %>) <% if @active_filter[:default] == :user %>
diff --git a/app/views/comment_threads/_collapsed.html.erb b/app/views/comment_threads/_collapsed.html.erb new file mode 100644 index 000000000..6bc28d9cf --- /dev/null +++ b/app/views/comment_threads/_collapsed.html.erb @@ -0,0 +1,26 @@ +<%# + Collapsed thread view + + Variables: + thread : thread to display +%> + +<% + state_class = [ + thread.deleted ? 'is-deleted' : nil, + thread.archived ? 'is-archived' : nil, + thread.locked? ? 'is-locked' : nil + ].compact.join(' ') +%> + +
+ <% if thread.deleted %> + + <% elsif thread.archived %> + + <% elsif thread.locked? %> + + <% end %> + <%= link_to render_pings_text(thread.title), comment_thread_path(thread), class: 'js--comment-link', data: { thread: thread.id }, role: 'button' %> + (<%= pluralize(thread.reply_count, 'comment') %>) +
diff --git a/app/views/comment_threads/_expanded.html.erb b/app/views/comment_threads/_expanded.html.erb new file mode 100644 index 000000000..b422ac7db --- /dev/null +++ b/app/views/comment_threads/_expanded.html.erb @@ -0,0 +1,112 @@ +<%# + Full thread view with all the details & actions + + Variables: + ? comment_id : Comment ID to show even if it would be hidden otherwise + inline : whether the thread is diplayed inline with the parent post + show_deleted : whether to display deleted comments for those who can see them + thread : CommentThread to display +%> + +<% max_shown_comments = 5 %> +<% comment_id ||= defined?(comment_id) ? comment_id : nil %> +<% pingable = thread.pingable %> +<% shown_comments_count = [max_shown_comments, thread.reply_count].min %> + +<% + wrapper_classes = [] + if thread.deleted; wrapper_classes << 'h-bg-red-050'; end + if inline; wrapper_classes += ['post--comments-thread', 'is-embedded']; end + + widget_classes = ['widget', 'thread'] + if thread.deleted; widget_classes << 'is-red'; end +%> + +<%= content_tag 'div', class: wrapper_classes.empty? ? nil : wrapper_classes do %> + <%= content_tag 'div', class: widget_classes.empty? ? nil : widget_classes, + data: { + archived: thread.archived, + comments: thread.reply_count, + deleted: thread.deleted, + inline: inline, + locked: thread.locked, + post: thread.post.id, + thread: thread.id, + } do %> +
+ <% if inline %> + show thread + collapse + <% end %> + <% if current_user&.privilege?('flag_curate') %> + <%= render 'comments/thread_tools_link', thread: thread %> + <% end %> + <% unless current_user.nil? %> + <%= render 'comments/thread_follow_link', thread: thread, user: current_user %> + <% end %> + <% if thread.deleted %> + + <% elsif thread.archived %> + + <% elsif thread.locked? %> + + <% end %> + <%= render_pings_text(thread.title) %> +
+ <% skipped_deleted = 0 %> + <% comments = thread.comments %> + <% if inline %> + <% count = 0 %> + <% comments = comments.select do |comment| %> + <% count += 1 unless comment.deleted %> + <% count <= max_shown_comments || comment.id.to_i == comment_id.to_i %> + <% end %> + <% if comments.size > max_shown_comments %> + <% shown_comments_count += comments.select { |c| c.id.to_i == comment_id.to_i }.size %> + <% end %> + <% end %> +
+ <% comments.each do |comment| %> + <% if comment.deleted && !(current_user&.at_least_moderator? && show_deleted) %> + <% skipped_deleted += 1%> + <% next %> + <% elsif skipped_deleted > 0 %> + <%= render 'comments/skip_deleted', inline: inline, num_skipped: skipped_deleted, thread: thread %> + <% skipped_deleted = 0 %> + <% end %> + <% if shown_comments_count > max_shown_comments && comment.id.to_i == comment_id.to_i %> + <%= render 'comments/skip_more', shown_count: shown_comments_count, thread: thread %> + <% end %> +
+ <%= render 'comments/comment', comment: comment, pingable: pingable %> +
+ <% end %> + <% if skipped_deleted > 0 %> + <%= render 'comments/skip_deleted', inline: inline, num_skipped: skipped_deleted, thread: thread %> + <% skipped_deleted = 0 %> + <% end %> +
+ + <% if inline && shown_comments_count < thread.reply_count %> + + <% end %> + + <% unless current_user.nil? %> + + <% end %> + <% end %> +<% end %> + +<% if current_user&.privilege?('flag_curate') %> + <%= render 'comments/thread_actions_modal', thread: thread, user: current_user %> +<% end %> diff --git a/app/views/comments/_comment.html.erb b/app/views/comments/_comment.html.erb index 4d8e4f2c2..41cabae0d 100644 --- a/app/views/comments/_comment.html.erb +++ b/app/views/comments/_comment.html.erb @@ -1,8 +1,10 @@ <%# Single comment view. + Variables: + comment : Comment to display ? with_post_link : includes share labelled post - ? pingable : ??? + ? pingable : an Array of user IDs to ping %> <% with_post_link ||= false %> @@ -38,29 +40,33 @@
<% end %>
- <%= user_link comment.user %> wrote + <%= comment_user_link(comment) %> wrote <%= time_ago_in_words(comment.created_at) %> ago <% if comment.updated_at > comment.created_at %> · edited <%= time_ago_in_words(comment.updated_at) %> ago <% end %>
- -
- -
-
- <% if params[:inline] == 'true' %> - - show thread - - - collapse - - <% else %> - <% if current_user&.privilege? 'flag_curate' %> - - tools - - <% end %> - <% unless current_user.nil? %> - <% if @comment_thread.followed_by? current_user %> - - unfollow - - <% else %> - - follow - - <% end %> - <% end %> - <% end %> - <% if @comment_thread.deleted %> - - <% elsif @comment_thread.archived %> - - <% elsif @comment_thread.locked? %> - - <% end %> - <%= @comment_thread.title %> -
- <% skipped_deleted = 0 %> - <% comments = @comment_thread.comments %> - <% if params[:inline] == 'true' %> - <% count = 0 %> - <% comments = comments.take_while do |comment| %> - <% count += 1 unless comment.deleted %> - <% count <= 5 %> - <% end %> - <% end %> -
- <% comments.each do |comment| %> - <% if comment.deleted && !(current_user&.is_moderator && params[:show_deleted_comments] == "1") %> - <% skipped_deleted += 1%> - <% next %> - <% elsif skipped_deleted > 0 %> -
- <%= render 'comments/skip_deleted', skipped_deleted: skipped_deleted%> -
- <% skipped_deleted = 0 %> - <% end %> -
- <%= render 'comments/comment', comment: comment, pingable: pingable %> -
- <% end %> - <% if skipped_deleted > 0 %> -
- <%= render 'comments/skip_deleted', skipped_deleted: skipped_deleted%> -
- <% skipped_deleted = 0 %> - <% end %> -
- <% unless current_user.nil? || params[:inline] == 'true' %> - - <% end %> - <% if params[:inline] == 'true' %> - - <% end %> -
- +
+ <%= render 'comment_threads/expanded', inline: params[:inline] == 'true', + show_deleted: params[:show_deleted_comments] == '1', + thread: @comment_thread %>
-<% if current_user&.privilege?('flag_curate') && params[:inline] != 'true' %> -
-
thread options
-
- <% if current_user.is_moderator || !@comment_thread.read_only? %> - - rename - - <% end %> - - <% unless @comment_thread.archived || @comment_thread.deleted %> - <% if @comment_thread.locked? %> - - unlock - - <% else %> - - lock - - <% end %> - <% end %> - - <% unless @comment_thread.locked? || @comment_thread.deleted %> - <% if @comment_thread.archived %> - - restore - - <% else %> - - archive - - <% end %> - <% end %> - - <% unless @comment_thread.locked? || @comment_thread.archived %> - <% if @comment_thread.deleted %> - - undelete - - <% else %> - - delete - - <% end %> - <% end %> - - <% if current_user&.is_moderator || current_user&.is_admin %> - - followers - - <% end %> -
-
- - - - - - <% if current_user&.is_moderator || current_user&.is_admin %> - - <% end %> -<% end %> - - diff --git a/app/views/comments/thread_followers.html.erb b/app/views/comments/thread_followers.html.erb index e39d04ec2..e68d4e72a 100644 --- a/app/views/comments/thread_followers.html.erb +++ b/app/views/comments/thread_followers.html.erb @@ -1,5 +1,7 @@

<%= pluralize(@followers.count, 'follower') %>

-<% @followers.each do |tf| %> - <%= render 'users/common_card', user: tf.user %> -<% end %> +
+ <% @followers.each do |tf| %> + <%= render 'users/common_card', user: tf.user %> + <% end %> +
diff --git a/app/views/devise/mailer/confirmation_instructions.html.erb b/app/views/devise/mailer/confirmation_instructions.html.erb index 84617ad42..a1d2302db 100644 --- a/app/views/devise/mailer/confirmation_instructions.html.erb +++ b/app/views/devise/mailer/confirmation_instructions.html.erb @@ -1,6 +1,6 @@ -

Welcome to Codidact, <%= @resource.username %>! We're glad you're here.

+

Welcome to Codidact, <%= @resource.username %>! We're glad you're here. (If this wasn't you, please ignore this email.)

-

Please confirm your registration by clicking below (or copy and paste the URL into your browser).

+

Please confirm your registration by clicking below (or copy and paste the URL into your browser). You cannot sign in to your account until you do this.

<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token, host: RequestContext.community.host), diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index 644611675..87f6b933c 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -58,3 +58,23 @@ <%= f.submit "Update", class: 'button is-filled is-very-large', disabled: sso %> <% end %>
+ +<% if current_user.at_least_moderator? %> + <%= link_to 'javascript:void(0)', class: 'button is-outlined is-danger', disabled: true do %> + Delete my account » + <% end %> +

+ Moderators and admins cannot be self-deleted. Contact support if you wish to delete your account. +

+<% elsif current_user.enabled_2fa %> + <%= link_to 'javascript:void(8)', class: 'button is-outlined is-danger', disabled: true do %> + Delete my account » + <% end %> +

+ Your account uses two-factor authentication (2FA). In order to delete your account, you must first disable 2FA. +

+ <% else %> + <%= link_to delete_account_path, class: 'button is-outlined is-danger' do %> + Delete my account » + <% end %> +<% end %> diff --git a/app/views/flags/_flag.html.erb b/app/views/flags/_flag.html.erb index d3fb85779..0cd32d05f 100644 --- a/app/views/flags/_flag.html.erb +++ b/app/views/flags/_flag.html.erb @@ -16,16 +16,16 @@ <%= render 'posts/type_agnostic', show_type_tag: true, show_category_tag: true, post: flag.post %>
<% elsif flag.post_type == 'Comment' %> - <%= render 'comments/comment', comment: flag.post, with_post_link: true %> + <%= render 'comments/comment', comment: flag.post, with_post_link: true, pingable: flag.post.comment_thread.pingable %> <% end %>

<%= flag.post_flag_type&.name || 'Flag reason' %>: <%= flag.reason %> — - <%= user_link flag.user %> at <%= flag.created_at.iso8601 %> + <%= user_link flag.user, { host: flag.community.host } %> at <%= flag.created_at.iso8601 %> <% if flag.post_type == 'Post' && flag.post.updated_at > flag.created_at %> - <%= link_to post_history_path(flag.post) do %> - (post modified after flag) - <% end %> + <%= link_to post_history_url(flag.post, { host: flag.community.host }) do %> + (post modified after flag) + <% end %> <% end %>

@@ -33,16 +33,18 @@

Escalation reason: <%= flag.escalation_comment %> — - <%= user_link flag.escalated_by %> <%= flag.escalated_at.iso8601 %> + <%= user_link flag.escalated_by, { host: flag.community.host } %> at <%= flag.escalated_at.iso8601 %>

- Attention: The reply to the flag will be shown to the flagger, not the mod escalating this flag. Do not share sensitive, mod-only information. + Attention: The reply to the flag will be shown to the flagger, not the mod escalating this + flag. Do not share sensitive, mod-only information.

<% end %> <% if controls %> diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index 9f608f5f8..d3c1fb20c 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -14,38 +14,81 @@ <% unless @community.is_fake %> - <% if Rails.env.development? || @hot_questions.to_a.size > 0 || @pinned_links.to_a.size > 0 %> -
- <% if Rails.env.development? || @pinned_links.to_a.size > 0 %> -
Featured
- <% @pinned_links.each do |pl| %> -
- <% pl_link = pl.post.nil? ? pl.link : generic_share_link(pl.post) %> - <% pl_label = pl.post.nil? ? pl.label : (pl.post.parent.nil? ? pl.post.title : pl.post.parent.title) %> - <%= link_to pl_link, class: 'h-fw-bold' do %> - <%= pl_label %> - <% end %> - <% unless pl.shown_before.nil? %> -
- — - <% if !pl.shown_after.nil? %> - <% if pl.shown_after < DateTime.now %> - ends in <%= time_ago_in_words(pl.shown_before) %> + <%# Featured widget %> + <% if Rails.env.development? || @pinned_links.to_a.size > 0 %> + <% cache @pinned_links do %> +
+ <% if Rails.env.development? || @pinned_links.to_a.size > 0 %> +
Featured
+ <% @pinned_links.each do |pl| %> +
+ <% pl_link = pl.post.nil? ? pl.link : generic_share_link(pl.post) %> + <% pl_label = pl.post.nil? ? pl.label : (pl.post.parent.nil? ? pl.post.title : pl.post.parent.title) %> + <%= link_to pl_link, class: 'h-fw-bold' do %> + <%= pl_label %> + <% end %> + <% unless pl.shown_before.nil? %> +
+ — + <% if !pl.shown_after.nil? %> + <% if pl.shown_after < DateTime.now %> + ends in <%= time_ago_in_words(pl.shown_before) %> + <% else %> + starts in <%= time_ago_in_words(pl.shown_after) %> + <% end %> <% else %> - starts in <%= time_ago_in_words(pl.shown_after) %> + in <%= time_ago_in_words(pl.shown_before) %> <% end %> - <% else %> - in <%= time_ago_in_words(pl.shown_before) %> - <% end %> -
+
+ <% end %> +
+ <% end %> + <% end %> +
+ <% end %> + <% end %> + + <%# Related Posts widget %> + <% if defined?(@post) && @post.inbound_duplicates.any? %> + <% collapse_related = user_preference('collapse_related_posts') == 'true' %> + <% cache [@post, @post.inbound_duplicates] do %> +
+
+ Related Posts + +
+ <% @post.inbound_duplicates.each do |dp| %> + <% end %> - <% end %> - <% if Rails.env.development? || @hot_questions.to_a.size > 0 %> -
Hot Posts
+
+ <% end %> + <% end %> + + <%# Hot Posts widget %> + <% if Rails.env.development? || @hot_questions.to_a.size > 0 %> + <% collapse_hot = user_preference('collapse_hot_posts') == 'true' %> + <% cache @hot_questions do %> +
+
+ Hot Posts + +
<% @hot_questions.each do |hq| %> -
+
<% unless hq.category.nil? %> <%= hq.category.name %> — @@ -55,8 +98,8 @@ <% end %>
<% end %> - <% end %> -
+
+ <% end %> <% end %> <% end %> @@ -71,12 +114,16 @@ <% end %> <% end %> - <% if moderator? || admin? %> + <% if can_see_deleted_posts? || at_least_moderator? %>
-

Moderator Tools

+

Tools

    - <% if moderator? %> + <% if can_see_deleted_posts? && !at_least_moderator? %> +
  • Recent Deletions
  • + <% end %> + <%# this calls into application_helper, not the user model! %> + <% if at_least_moderator? %>
  • <%= link_to 'Moderator Tools', moderator_path %>
  • <% end %> <% if admin? %> diff --git a/app/views/moderator/index.html.erb b/app/views/moderator/index.html.erb index e07bfc300..4924aed6f 100644 --- a/app/views/moderator/index.html.erb +++ b/app/views/moderator/index.html.erb @@ -1,6 +1,6 @@ <% content_for :title, "Moderator Dashboard" %> -<% if current_user.is_admin %> +<% if current_user.admin? %> <%= link_to admin_path, class: "has-font-size-small" do %> Switch to Admin Tools <% end %> @@ -97,4 +97,4 @@ <% chat = SiteSetting['ChatLink'] %> <% if chat.present? %>

    As a moderator, you should join our <%= link_to 'community chat server', chat %>. Ping a Codidact team member there and you'll receive access to a special moderator-only lounge, where you can discuss moderation questions with your fellow moderators.

    -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/moderator/recent_comments.html.erb b/app/views/moderator/recent_comments.html.erb index 9901ee5bb..c9923ee3d 100644 --- a/app/views/moderator/recent_comments.html.erb +++ b/app/views/moderator/recent_comments.html.erb @@ -9,8 +9,16 @@ communities; consider using activity logs on user profiles instead.

    +<% + thread_pingables = @comments.map(&:comment_thread).to_set.to_h do |thread| + [thread, thread.pingable] + end +%> + <% @comments.each do |comment| %> - <%= render 'comments/comment', comment: comment, with_post_link: true %> + <%= render 'comments/comment', comment: comment, + pingable: thread_pingables[comment.comment_thread], + with_post_link: true %> <% end %>
    diff --git a/app/views/moderator/recently_deleted_posts.html.erb b/app/views/moderator/recently_deleted_posts.html.erb index 1eb328d2f..b6366d0f5 100644 --- a/app/views/moderator/recently_deleted_posts.html.erb +++ b/app/views/moderator/recently_deleted_posts.html.erb @@ -1,9 +1,17 @@ <% content_for :title, "Recently Deleted Posts" %> -<%= link_to moderator_path, class: 'has-font-size-small' do %> - « Return to moderator tools + +<% if current_user.at_least_moderator? %> + <%= link_to moderator_path, class: 'has-font-size-small' do %> + « Return to moderator tools + <% end %> <% end %>

    Recently Deleted Posts

    + +<% unless current_user.at_least_moderator? %> +

    Because you have the Curate ability, you can see deleted posts regardless of who deleted them. If you feel a post has been deleted in error, you can restore it. If a post was deleted by its author and you decide to restore it, consider leaving a comment explaining why (to reduce confusion).

    +<% end %> +
    <% @posts.each do |post| %> <%= render 'posts/type_agnostic', post: post %> @@ -11,3 +19,4 @@
    <%= will_paginate @posts, renderer: BootstrapPagination::Rails %> + diff --git a/app/views/post_history/post.html.erb b/app/views/post_history/post.html.erb index f5e9df6ab..3444c67c9 100644 --- a/app/views/post_history/post.html.erb +++ b/app/views/post_history/post.html.erb @@ -15,7 +15,7 @@ by - <% if deleted_user?(event.user) && !moderator? %> + <% if deleted_user?(event.user) && !at_least_moderator? %> (deleted user) <% else %> user avatar @@ -45,7 +45,7 @@ you performed the redaction, <% elsif current_user == @post.user %> you are the post author, - <% elsif current_user&.is_admin %> + <% elsif current_user&.admin? %> you are an administrator, <% end %> but you should not share this revision with others. diff --git a/app/views/posts/_edit_link.html.erb b/app/views/posts/_edit_link.html.erb new file mode 100644 index 000000000..a21a13917 --- /dev/null +++ b/app/views/posts/_edit_link.html.erb @@ -0,0 +1,11 @@ +<%# + Helper for rendering post edit link buttons + + Variables: + post : Post to create the link for +%> + +<%= link_to edit_post_path(post), class: 'tools--item', 'aria-label': 'Edit this post' do %> + + Edit +<% end %> diff --git a/app/views/posts/_expanded.html.erb b/app/views/posts/_expanded.html.erb index 5f28e9d23..feb21a457 100644 --- a/app/views/posts/_expanded.html.erb +++ b/app/views/posts/_expanded.html.erb @@ -1,8 +1,18 @@ <%# - Full post view, containing all details and interactions. - Variables: - post : the Post instance to display - float_notice : whether to display the float notice + Full post view, containing all details and interactions. + + Instance variables: + ? @category : Category the post belongs to, if any + ? @post_type : PostType of the post + + Parameters: + ? params[:comment_id] : Comment ID to show even if it would be hidden otherwise + ? params[:thread_id] : CommentThread ID to render expanded view for + ? params[:sort] : sorting type for the post's children + + Variables: + post : the Post instance to display + ? float_notice : whether to display the float notice %> <% float_notice ||= false %> @@ -14,7 +24,7 @@
    <% if is_top_level %> -

    +

    <% title = post.title + (post.closed && !post.duplicate_post ? " [closed]" : "") + (post.duplicate_post ? " [duplicate]" : "") %> @@ -30,7 +40,7 @@

    You are accessing this answer with a direct link, so it's being shown above all other answers regardless of its <%= sort_type %>. - You can <%= link_to 'return to the normal view', generic_share_link(post.parent), class: 'is-teal' %>. + You can <%= link_to 'return to the normal view', generic_share_link(post.parent, sort: params[:sort]), class: 'is-teal' %>.

    <% end %> @@ -157,7 +167,7 @@

    <% end %> <% if post_type.is_public_editable && post.pending_suggested_edit? %> - <% if check_your_post_privilege(post, 'edit_posts') %> + <% if current_user&.can_update?(post, post_type) %>

    @@ -240,27 +250,18 @@ History <% end %> - <% if (post_type.is_public_editable && !post.locked?) || current_user&.is_moderator || post.user == current_user %> - <% if check_your_post_privilege(post, 'edit_posts') %> + <% if (post_type.is_public_editable && !post.locked?) || current_user&.at_least_moderator? || post.user == current_user %> + <% if current_user&.can_update?(post, post_type) %> <% if post.pending_suggested_edit? %> - <%= link_to suggested_edit_url(post.pending_suggested_edit.id), class: 'tools--item is-danger is-filled' do %> - - Review suggested edit - <% end %> + <%= render 'posts/review_edit_link', post: post %> <% else %> - <%= link_to edit_post_path(post), class: 'tools--item', 'aria-label': 'Edit this post' do %> - - Edit - <% end %> + <%= render 'posts/edit_link', post: post %> <% end %> - <% elsif !current_user.nil? %> + <% elsif current_user.present? %> <% 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 %> + <% elsif post_type.is_freely_editable %> + <%= render 'posts/edit_link', post: post %> <% else %> <%= link_to edit_post_path(post), class: 'tools--item', 'aria-label': 'Suggest edit to this post' do %> @@ -314,13 +315,13 @@ Tools - <% flags_count = if current_user&.is_moderator + <% flags_count = if current_user&.at_least_moderator? post.flags else post.flags.not_confidential end.where(handled_by_id: nil).count %> - <% own_flags_count = if current_user&.is_moderator + <% own_flags_count = if current_user&.at_least_moderator? 0 else post.flags.not_confidential.where(user: current_user, handled_by_id: nil).count @@ -339,7 +340,7 @@

    Why does this post require attention from curators or moderators?
    - <% if current_user&.has_active_flags?(post) %> + <% if current_user&.active_flags_on?(post) %>
    You already have active flags on this post: @@ -475,8 +476,8 @@
    Flags on this post
    <% post.flags.where(handled_by_id: nil).each do |flag| %> - <% next if !current_user.is_moderator && (flag.post_flag_type.nil? || flag.post_flag_type.confidential) %> - <% next if !current_user.is_moderator && (current_user.id == flag.user.id) %> + <% next if !current_user.at_least_moderator? && (flag.post_flag_type.nil? || flag.post_flag_type.confidential) %> + <% next if !current_user.at_least_moderator? && (current_user.id == flag.user.id) %>

    @@ -501,7 +502,7 @@ <% sorted_answers = post.children.sort_by { |answer| answer.score }.reverse! %> <% sorted_answers.each do |answer| %> - <% next if answer.deleted? && !moderator? %> + <% next if answer.deleted? && !at_least_moderator? %> <%= render 'posts/toc_entry', answer: answer %> <% end %>

    @@ -522,7 +523,7 @@
    <% comment_threads = post.comment_threads.initially_visible.order(updated_at: :desc) %> <% public_count = comment_threads.count %> - <% available_count = current_user&.has_post_privilege?('flag_curate', post) ? + <% available_count = current_user&.post_privilege?('flag_curate', post) ? post.comment_threads.count : post.comment_threads.publicly_available.count %>

    @@ -549,7 +550,9 @@ <% end %>

    - <%= render 'comments/post', comment_threads: comment_threads.first(5) %> + <%= render 'comments/threads_list', comment_threads: comment_threads.first(5), + comment_id: params[:comment_id], + thread_id: params[:thread_id] %>
    <% if available_count > [comment_threads.count, 5].min %> @@ -558,23 +561,7 @@ <% end %> <% if user_signed_in? %> - <% if post.locked? && !moderator? && !admin? %> -

    Comments are disabled on locked posts.

    - <% elsif post.deleted %> -

    Comments are disabled on deleted posts.

    - <% elsif post.comments_disabled && !moderator? && !admin? %> -

    Comments have been disabled on this post.

    - <% else %> - <% if post.locked? %> -

    Comments are disabled on locked posts, but as a moderator you are exempt from that block.

    - <% elsif post.comments_disabled %> -

    Comments have been disabled on this post, but as a moderator you are exempt from that block.

    - <% end %> - <% end %> - - Start new comment thread - - <%= render 'comments/new_thread_modal', post: post %> + <%= render 'comments/new_thread', post: post, user: current_user %> <% end %>
    @@ -583,7 +570,7 @@
    -<% if moderator? || current_user&.has_post_privilege?('flag_curate', post) %> +<% if at_least_moderator? || current_user&.post_privilege?('flag_curate', post) %> <%= render 'posts/post_tools', post: post %> <% end %> diff --git a/app/views/posts/_help_center_posts.html.erb b/app/views/posts/_help_center_posts.html.erb index a0ea01b02..5ccc8b405 100644 --- a/app/views/posts/_help_center_posts.html.erb +++ b/app/views/posts/_help_center_posts.html.erb @@ -1,6 +1,6 @@ <% posts.to_a.in_groups_of(3).map(&:compact).each do |row| %> <% row.each do |row_data| %> - <% next if row_data[0] == '$Moderator' && !current_user&.is_moderator %> + <% next if row_data[0] == '$Moderator' && !current_user&.at_least_moderator? %>
    <% if row_data[0].present? %> diff --git a/app/views/posts/_list.html.erb b/app/views/posts/_list.html.erb index b08d59496..edad9d216 100644 --- a/app/views/posts/_list.html.erb +++ b/app/views/posts/_list.html.erb @@ -1,7 +1,6 @@ <%# is_question = post.post_type_id == Question.post_type_id %> <%# is_article = post.post_type_id == Article.post_type_id %> <%# is_meta = (is_question && post.meta?) || (!is_question && post.parent&.meta?) %> -<% active_user = post.last_activity_by || post.user %> <% @show_type_tag = !!defined?(show_type_tag) ? show_type_tag : false %> <% @show_category_tag = !!defined?(show_category_tag) ? show_category_tag : false %> <% @last_activity = !!defined?(last_activity) ? last_activity : true %> @@ -47,11 +46,11 @@  ·  <% end %> posted <%= time_ago_in_words(post.created_at, locale: :en_abbrev) %> ago - by <%= user_link post.user %> + by <%= post_user_link(post) %> <% if post.last_activity != post.created_at %>  ·  <%= post.last_activity_type %> <%= time_ago_in_words(post.last_activity, locale: :en_abbrev) %> ago - by <%= user_link active_user %> + by <%= post_user_link(post, active: true) %> <% end %>

    @@ -60,16 +59,15 @@ <%= post_type_badge(post.post_type) %> <% end %> <% if post.post_type.has_tags %> - <% required_ids = post.category&.required_tag_ids %> - <% moderator_ids = post.category&.moderator_tag_ids %> - <% topic_ids = post.category&.topic_tag_ids %> - <% category_sort_tags(post.tags, required_ids, topic_ids, moderator_ids).each do |tag| %> - <% required = required_ids&.include?(tag.id) ? 'is-filled' : '' %> - <% topic = topic_ids&.include?(tag.id) ? 'is-outlined' : '' %> - <% moderator = moderator_ids.include?(tag.id) ? 'is-red is-outlined' : '' %> - <%= link_to tag.name, tag_url(id: post.category_id, tag_id: tag.id), - class: "badge is-tag #{required} #{topic} #{moderator}", - title: tag.excerpt.present? ? tag.excerpt : "(no description yet)" %> + <% required_tag_ids = post.category&.required_tag_ids %> + <% moderator_tag_ids = post.category&.moderator_tag_ids %> + <% topic_tag_ids = post.category&.topic_tag_ids %> + <% category_sort_tags(post.tags, required_tag_ids, topic_tag_ids, moderator_tag_ids).each do |tag| %> + <%= render 'posts/tag_badge', post: post, + tag: tag, + moderator_tag_ids: moderator_tag_ids, + required_tag_ids: required_tag_ids, + topic_tag_ids: topic_tag_ids %> <% end %> <% end %> <% display_label = user_preference('display_import_labels', community: true) == 'true' %> diff --git a/app/views/posts/_post_tools.html.erb b/app/views/posts/_post_tools.html.erb index fe7a5893c..9c433f68e 100644 --- a/app/views/posts/_post_tools.html.erb +++ b/app/views/posts/_post_tools.html.erb @@ -5,7 +5,7 @@ Moderator Tools
    -
    \ No newline at end of file +
    diff --git a/app/views/posts/_review_edit_link.html.erb b/app/views/posts/_review_edit_link.html.erb new file mode 100644 index 000000000..5fce0876c --- /dev/null +++ b/app/views/posts/_review_edit_link.html.erb @@ -0,0 +1,11 @@ +<%# + Helper for rendering suggested edit review link buttons + + Variables: + post : Post to create the link for +%> + +<%= link_to suggested_edit_url(post.pending_suggested_edit.id), class: 'tools--item is-danger is-filled' do %> + + Review suggested edit +<% end %> diff --git a/app/views/posts/_tag_badge.html.erb b/app/views/posts/_tag_badge.html.erb new file mode 100644 index 000000000..b4ccc78ba --- /dev/null +++ b/app/views/posts/_tag_badge.html.erb @@ -0,0 +1,19 @@ +<%# + Renders a linked badge for a given post & tag + + Variables: + post : Post the tag is on + tag : Tag to render the badge for + ? required_tag_ids : list of tag IDs required for the post's category + ? topic_tag_ids : list of tag IDs topical for the post's category + ? moderator_tag_ids : list of mod-only tags IDs for the post's category +%> + +<% required = required_tag_ids&.include?(tag.id) ? 'is-filled' : '' %> +<% topic = topic_tag_ids&.include?(tag.id) ? 'is-outlined' : '' %> +<% mod_only = moderator_tag_ids&.include?(tag.id) ? 'is-red is-outlined' : '' %> + +<%= link_to tag.name, + tag_url(id: post.category_id, tag_id: tag.id, host: post.community.host), + class: "badge is-tag #{required} #{topic} #{mod_only}", + title: tag.excerpt.present? ? tag.excerpt : "(no description yet)" %> diff --git a/app/views/posts/document.html.erb b/app/views/posts/document.html.erb index e08d748ab..ba15aa9c8 100644 --- a/app/views/posts/document.html.erb +++ b/app/views/posts/document.html.erb @@ -7,7 +7,7 @@ is_policy = @post.post_type_id == PolicyDoc.post_type_id history_path = is_hc ? help_post_history_path(@post.doc_slug) : policy_post_history_path(@post.doc_slug) %> - <% if (moderator? && is_hc) || (admin? && is_policy) %> + <% if (at_least_moderator? && is_hc) || (admin? && is_policy) %> <%= link_to 'edit', edit_post_path(@post), class: "button is-outlined is-muted" %> <% end %> <%= link_to 'history', history_path, class: "button is-outlined is-muted" %> diff --git a/app/views/posts/show.html.erb b/app/views/posts/show.html.erb index 0b2ab5f7b..615065e64 100644 --- a/app/views/posts/show.html.erb +++ b/app/views/posts/show.html.erb @@ -19,20 +19,25 @@ <%= render 'posts/expanded', post: @post, float_notice: false %> +<% num_answers = @children.count %> + <% if @post.post_type.has_answers %> -

    <%= pluralize(@post.children.where(deleted: false).count, 'answer') %>

    - -
    - <%= link_to 'Score', request.params.merge(sort: 'score'), - class: "button is-muted is-outlined #{params[:sort].nil? || params[:sort] == 'score' ? 'is-active' : ''}", - title: 'highest score first (not the same as net votes)' %> - <%= link_to 'Active', request.params.merge(sort: 'active'), - class: "button is-muted is-outlined #{params[:sort] == 'active' ? 'is-active' : ''}", - title: 'most recent changes first: new answers, edits, delete/undelete' %> - <%= link_to 'Age', request.params.merge(sort: 'age'), - class: "button is-muted is-outlined #{params[:sort] == 'age' ? 'is-active' : ''}", - title: 'newest posts first (ignores other activity)' %> -
    + <% if num_answers > 0 %> +

    <%= pluralize(num_answers, 'answer') %>

    + <% end %> + <% if num_answers > 1 %> +
    + <%= link_to 'Score', request.params.merge(sort: 'score'), + class: "button is-muted is-outlined #{params[:sort].nil? || params[:sort] == 'score' ? 'is-active' : ''}", + title: 'highest score first (not the same as net votes)' %> + <%= link_to 'Active', request.params.merge(sort: 'active'), + class: "button is-muted is-outlined #{params[:sort] == 'active' ? 'is-active' : ''}", + title: 'most recent changes first: new answers, edits, delete/undelete' %> + <%= link_to 'Age', request.params.merge(sort: 'age'), + class: "button is-muted is-outlined #{params[:sort] == 'age' ? 'is-active' : ''}", + title: 'newest posts first (ignores other activity)' %> +
    + <% end %>
    @@ -45,7 +50,7 @@
    <% if user_signed_in? && !@post.closed %> -

    Your Answer

    +

    <%=@children.count > 0 ? 'Add an answer' : 'Be the first to answer' %>

    <%= render 'posts/form', post: Post.new(post_type_id: Answer.post_type_id, category: @post.category, parent: @post), type_summary: false, category: @post.category, post_type: PostType['Answer'], inline_parent: false, diff --git a/app/views/reports/reactions.html.erb b/app/views/reports/reactions.html.erb index e722606f2..b69b30d90 100644 --- a/app/views/reports/reactions.html.erb +++ b/app/views/reports/reactions.html.erb @@ -6,7 +6,7 @@ <%= stat_panel 'users', @users.count %>

    Reaction usage

    -<%= line_chart Reaction.where('reactions.created_at >= ?', 1.year.ago).where(reaction_type: @reaction_types) +<%= line_chart Reaction.recent(1.year.ago).where(reaction_type: @reaction_types) .group(:reaction_type_id).group_by_week(:created_at).count .reduce({}) { |acc, val| acc[val[0][0]] ||= []; acc[val[0][0]] << ({val[0][1] => val[1]}); acc } .map { |k, v| { name: @reaction_types.select { |x| x.id == k }[0].name, diff --git a/app/views/reports/subscriptions.html.erb b/app/views/reports/subscriptions.html.erb index eb3a5dede..30e8e649c 100644 --- a/app/views/reports/subscriptions.html.erb +++ b/app/views/reports/subscriptions.html.erb @@ -8,4 +8,4 @@

    New subscriptions

    -<%= line_chart Subscription.where('created_at >= ?', 1.year.ago).group_by_week(:created_at).count %> \ No newline at end of file +<%= line_chart Subscription.recent(1.year.ago).group_by_week(:created_at).count %> diff --git a/app/views/shared/_lock_notice.html.erb b/app/views/shared/_lock_notice.html.erb new file mode 100644 index 000000000..e73a51741 --- /dev/null +++ b/app/views/shared/_lock_notice.html.erb @@ -0,0 +1,16 @@ +<%# + Reusable helper view for lock notices (posts & comments). + + Variables: + level : notice severity level (one of: 'info', 'error', default 'error') + text : notice text to display +%> + +<% + #defaults + level ||= defined?(level) && !level.nil? ? level : 'error' +%> + +

    + <%= sanitize(text) %> +

    \ No newline at end of file diff --git a/app/views/shared/_markdown_tools.html.erb b/app/views/shared/_markdown_tools.html.erb index daa3ea3d3..a7d95d34d 100644 --- a/app/views/shared/_markdown_tools.html.erb +++ b/app/views/shared/_markdown_tools.html.erb @@ -39,6 +39,15 @@ <% end %>
    +
    + <%= md_button action: 'bullet', label: 'Bullet list', class: 'is-icon-only' do %> + + <% end %> + <%= md_button action: 'numbered', label: 'Numbered list', class: 'is-icon-only' do %> + + <% end %> +
    +
    <%= md_button label: 'Link', class: 'is-icon-only', data_modal: '#markdown-link-insert' do %> @@ -49,31 +58,25 @@ <%= md_button label: 'Insert image', class: 'is-icon-only', data_modal: '#markdown-image-upload' do %> <% end %> + <%= md_button action: 'table', label: 'Insert table', class: 'is-icon-only' do %> + + <% end %>
    + data-drop-self-class-toggle="is-active" aria-label="Tools" title="Tools: headings, lines" role="button">
    - <%= md_list_item action: 'bullet', label: 'Bullet list' do %> - Bullet list - <% end %> - <%= md_list_item action: 'numbered', label: 'Numbered list' do %> - Numbered list - <% end %> <%= md_list_item action: 'heading', label: 'Heading' do %> Heading <% end %> <%= md_list_item action: 'hr', label: 'Horizontal line' do %> Horizontal line <% end %> - <%= md_list_item action: 'table', label: 'Table' do %> - Table - <% end %>
    diff --git a/app/views/site_settings/index.html.erb b/app/views/site_settings/index.html.erb index 9562d314c..b7bbc22c4 100644 --- a/app/views/site_settings/index.html.erb +++ b/app/views/site_settings/index.html.erb @@ -12,7 +12,7 @@ <% @settings.each do |category, settings| %>
    <%= category&.underscore&.humanize || 'Uncategorized' %> - +
    <% settings.each do |setting| %> @@ -26,7 +26,7 @@

    <%= setting.name %>

    <%= setting.description %>
    - diff --git a/app/views/subscriptions/index.html.erb b/app/views/subscriptions/index.html.erb index 8c8561f2a..12b0ef996 100644 --- a/app/views/subscriptions/index.html.erb +++ b/app/views/subscriptions/index.html.erb @@ -11,7 +11,7 @@ ] end end.sort_by { |a| a }.map { |_, v| v }.each do |name, sub| %> -
    +
    <%= name %>

    Subscription to <%= phrase_for sub.type, sub.qualifier %>, emailed every <%= pluralize(sub.frequency, 'day') @@ -20,4 +20,4 @@ end.sort_by { |a| a }.map { |_, v| v }.each do |name, sub| %> <%= label_tag :enabled, 'Enabled?' %> · Remove

    -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/sudo/sudo.html.erb b/app/views/sudo/sudo.html.erb new file mode 100644 index 000000000..c7eefa8d9 --- /dev/null +++ b/app/views/sudo/sudo.html.erb @@ -0,0 +1,16 @@ +

    Re-enter your password

    + +
    + +

    + You are entering sudo mode, where you are required to verify your password to take certain + security-sensitive actions. This mode will last for <%= AppConfig.server_settings['user_sudo_duration'] %> + minutes, during which time you will not be asked for your password again. +

    +
    + +<%= form_tag enter_sudo_path, method: :post do %> + <%= label_tag :password, 'Re-enter your password', class: 'form-element' %> + <%= password_field_tag :password, nil, class: 'form-element' %> + <%= submit_tag 'Verify', class: 'button is-primary is-filled' %> +<% end %> diff --git a/app/views/suggested_edit/category_index.html.erb b/app/views/suggested_edit/category_index.html.erb index f9c6b126c..f38f46f0a 100644 --- a/app/views/suggested_edit/category_index.html.erb +++ b/app/views/suggested_edit/category_index.html.erb @@ -27,7 +27,7 @@
    <% categories.each do |cat| %> <% next if (cat.min_view_trust_level || -1) > (current_user&.trust_level || 0) %> - <% sug_edits = SuggestedEdit.where(post: Post.undeleted.where(category: cat), active: true).count %> + <% sug_edits = SuggestedEdit.where(post: Post.undeleted.in(cat), active: true).count %> <% @communities.each do |c| %> - <% if @user.has_profile_on(c) %> + <% if @user.profile_on?(c) %> diff --git a/app/views/users/activity.html.erb b/app/views/users/activity.html.erb index e86b14425..fc7dd47a3 100644 --- a/app/views/users/activity.html.erb +++ b/app/views/users/activity.html.erb @@ -1,6 +1,6 @@ <%= render 'tabs', user: @user %> -<% if moderator? && deleted_user?(@user) %> +<% if at_least_moderator? && deleted_user?(@user) %> <%= render 'deleted', user: @user %> <% end %> diff --git a/app/views/users/deleted_user.html.erb b/app/views/users/deleted_user.html.erb index 4f9f96904..85562eb95 100644 --- a/app/views/users/deleted_user.html.erb +++ b/app/views/users/deleted_user.html.erb @@ -14,7 +14,7 @@ Codidactyl

    -<% if moderator? %> +<% if at_least_moderator? %> <% deleted_object = @user.deleted? ? @user : @user.community_user %> <% deletion_type = @user.deleted? ? 'User' : 'Profile' %>

    diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index f0ada48e5..38ddf37ea 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -7,6 +7,7 @@

    <%= label_tag :search, "Search", class: "form-element" %> <%= text_field_tag :search, params[:search], class: 'form-element' %> + <%= hidden_field_tag :sort, params[:sort] || @sort_param %>
    diff --git a/app/views/users/mod_privileges.html.erb b/app/views/users/mod_privileges.html.erb index 1765d6536..ed62efb6d 100644 --- a/app/views/users/mod_privileges.html.erb +++ b/app/views/users/mod_privileges.html.erb @@ -57,7 +57,7 @@ Recalc Abilities
    -<% if current_user.is_admin %> +<% if current_user.admin? %>
    Roles @@ -75,7 +75,7 @@ and may impose restrictions on user accounts.

    - <% if @user.is_moderator %> + <% if @user.community_user&.is_moderator %> <% else %> @@ -84,7 +84,7 @@
    <% end %> -<% if current_user.is_global_admin %> +<% if current_user.global_admin? %>
    @@ -97,7 +97,7 @@

    Administrators can edit site settings and user roles.

    - <% if @user.is_global_moderator %> + <% if @user.community_user&.is_admin %> <% else %> @@ -117,7 +117,7 @@

    This user will have moderator status on every site in this network.

    - <% if @user.is_global_moderator %> + <% if @user.global_moderator? %> <% else %> @@ -137,7 +137,7 @@

    This user will have admin status on every site in this network.

    - <% if @user.is_global_admin %> + <% if @user.global_admin? %> <% if @user.id == current_user.id %> <% else %> @@ -150,7 +150,7 @@
    <% end %> -<% if current_user.is_global_admin && current_user.staff? %> +<% if current_user.global_admin? && current_user.staff? %>
    diff --git a/app/views/users/posts.html.erb b/app/views/users/posts.html.erb index 83f02b24e..9f9ab4cdb 100644 --- a/app/views/users/posts.html.erb +++ b/app/views/users/posts.html.erb @@ -1,6 +1,6 @@ <% content_for :title, "Posts by #{rtl_safe_username(@user)}" %> -<% if moderator? && deleted_user?(@user) %> +<% if at_least_moderator? && deleted_user?(@user) %> <%= render 'deleted', user: @user %> <% end %> @@ -14,10 +14,15 @@
    - <%= link_to 'Score', request.params.merge(sort: 'score'), class: 'button is-muted is-outlined ' + (active_search?('score') ? 'is-active' : ''), - role: 'button', 'aria-label': 'Sort by score' %> - <%= link_to 'Age', request.params.merge(sort: 'age'), class: 'button is-muted is-outlined ' + (active_search?('created_at') ? 'is-active' : ''), - role: 'button', 'aria-label': 'Sort by age' %> + <%= link_to 'Activity', request.params.merge(sort: 'activity'), + class: 'button is-muted is-outlined ' + (active_search?('last_activity') ? 'is-active' : ''), + role: 'button', 'aria-label': 'Sort by activity' %> + <%= link_to 'Age', request.params.merge(sort: 'age'), + class: 'button is-muted is-outlined ' + (active_search?('created_at') ? 'is-active' : ''), + role: 'button', 'aria-label': 'Sort by age' %> + <%= link_to 'Score', request.params.merge(sort: 'score'), + class: 'button is-muted is-outlined ' + (active_search?('score') ? 'is-active' : ''), + role: 'button', 'aria-label': 'Sort by score' %>
    diff --git a/app/views/users/qr_login_code.html.erb b/app/views/users/qr_login_code.html.erb index 3ad98fd75..7174213ff 100644 --- a/app/views/users/qr_login_code.html.erb +++ b/app/views/users/qr_login_code.html.erb @@ -7,7 +7,7 @@

    Caution

    - The QR code below, when scanned, provides immediate access to your <%= t 'platform.network_name' %> account, + The QR code below, when scanned, provides immediate access to your <%= t 'platform.network_name' %> network account, without asking for your password again. This makes it easier to sign in on your phone, but make sure nobody's looking over your shoulder! Take extra care in public places.

    diff --git a/app/views/users/registrations/delete.html.erb b/app/views/users/registrations/delete.html.erb new file mode 100644 index 000000000..63a5d0b42 --- /dev/null +++ b/app/views/users/registrations/delete.html.erb @@ -0,0 +1,38 @@ +

    Delete my account

    +

    + If you no longer want to use our network of communities, you can delete your account. This means: +

    +
      +
    • Your profiles will be deleted on every community in the network. +
    • Your user account will be deleted and anonymized.
    • +
    • + The content of your posts and comments will remain, under the licenses you set for them. Your username will no + longer be shown alongside them. +
    • +
    + +
    + +

    + This will take effect immediately and cannot be undone. If you're sure, type your username below to + confirm. +

    +
    + +<% if @user.errors.any? %> +
    +

    Couldn't delete your account:

    +
      + <% @user.errors.full_messages.each do |msg| %> +
    • <%= msg %>
    • + <% end %> +
    +
    +<% end %> + +<%= form_tag do_delete_account_path, method: :post do %> + <%= label_tag :username, 'Type your username', class: 'form-element' %> + <%= text_field_tag :username, params[:username], class: 'form-element' %> + + <%= submit_tag 'Delete my account', class: 'button is-danger is-filled' %> +<% end %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 11816d171..f5cbff1d4 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -2,7 +2,7 @@ <% content_for :title, "User #{rtl_safe_username(@user)}" %> -<% if moderator? && deleted_user?(@user) %> +<% if at_least_moderator? && deleted_user?(@user) %> <%= render 'deleted', user: @user %> <% end %> @@ -71,7 +71,7 @@ Subscribe to user <% end %> <% end %> - <% if current_user&.is_moderator %> + <% if current_user&.at_least_moderator? %> Moderator Tools <% if @user.community_user.mod_warnings&.size.positive? %> (<%= pluralize(@user.community_user.mod_warnings.count, 'message') %>) <% end %>
    quick actions
    @@ -129,11 +129,11 @@
    Staff
    - <% elsif @user.is_admin %> + <% elsif @user.admin? %>
    Administrator
    - <% elsif @user.is_moderator %> + <% elsif @user.at_least_moderator? %>
    Moderator
    @@ -161,7 +161,7 @@
    - <% if current_user&.id == @user.id || current_user&.is_moderator %> + <% if current_user&.id == @user.id || current_user&.at_least_moderator? %> <% end %>
    <%= setting.typed %>
    <% if cat == current_cat %> @@ -67,6 +67,10 @@ ><%= edit.active ? 'Pending' : (edit.approved? ? 'Approved' : 'Rejected' ) %> suggested edit by <%= user_link edit.user %>, <%= time_ago_in_words(edit.created_at) %> ago + <% unless edit.active %> +
    Decided by <%= user_link edit.decided_by %>, + <%= time_ago_in_words(edit.decided_at) %> ago + <% end %> <% end %> diff --git a/app/views/summary_mailer/content_summary.html.erb b/app/views/summary_mailer/content_summary.html.erb new file mode 100644 index 000000000..279bb1284 --- /dev/null +++ b/app/views/summary_mailer/content_summary.html.erb @@ -0,0 +1,76 @@ +

    Codidact Content Summary

    +

    + Covering + <%= SummaryMailer::TIMEFRAME.ago.strftime '%a %d %b, %H:%M' %> + to + <%= DateTime.now.strftime '%a %d %b, %H:%M' %> +

    + +

    New Posts

    +<% if @posts.any? %> + <% @posts.first(10).each do |p| %> +

    + <%= link_to p.title, generic_share_link(p) %>
    + by <%= user_link p.user, { host: p.community.host } %> on <%= p.community.name %> +

    + <% end %> + <% if @posts.size > 10 %> +

    and <%= @posts.size - 10 %> more

    + <% end %> +<% else %> +

    No new posts in this timeframe.

    +<% end %> + +

    New Flags

    +<% if @flags.any? %> + <% @flags.first(10).each do |f| %> +

    + <%= f.post_flag_type&.name || 'other' %> + flag: <%= f.reason.present? ? "\"#{f.reason}\"" : "" %> + <% status_colors = { helpful: 'is-green', declined: 'is-red', pending: 'is-muted' } %> + + <%= f.status || 'pending' %> +
    + by <%= user_link f.user, { host: f.community.host } %> + on <%= link_to f.post.title, generic_share_link(f.post) %> + on <%= f.community.name %>
    +

    + <% end %> + <% if @flags.size > 10 %> +

    and <%= @flags.size - 10 %> more

    + <% end %> +<% else %> +

    No new flags in this timeframe

    +<% end %> + +

    New Comments

    +<% if @comments.any? %> + <% @comments.first(10).each do |c| %> +
    <%= c.content %>
    +

    + by <%= user_link c.user, { host: c.post.community.host } %> + on <%= link_to c.post.title, generic_share_link(c.post) %> + on <%= c.post.community.name %> +

    + <% end %> + <% if @comments.size > 10 %> +

    and <%= @comments.size - 10 %> more

    + <% end %> +<% else %> +

    No new comments in this timeframe

    +<% end %> + +

    New Users

    +<% if @users.any? %> + <% @users.first(10).each do |u| %> +

    + <%= user_link u, { host: u.community_users.unscoped.first.community.host } %> + on <%= u.community_users.unscoped.first.community.name %> +

    + <% end %> + <% if @users.size > 10 %> +

    and <%= @users.size - 10 %> more

    + <% end %> +<% else %> +

    No new users in this timeframe

    +<% end %> diff --git a/app/views/tags/category.html.erb b/app/views/tags/category.html.erb index 7f1c55307..cbda131a6 100644 --- a/app/views/tags/category.html.erb +++ b/app/views/tags/category.html.erb @@ -10,7 +10,7 @@ <% unless @tags == nil %> -<% if current_user&.is_moderator || check_your_privilege('edit_tags') %> +<% if current_user&.at_least_moderator? || check_your_privilege('edit_tags') %> <%= link_to 'New', new_tag_path(id: @category.id), class: 'button is-muted is-outlined', 'aria-label': 'Create new tag' %> <% end %> diff --git a/app/views/tags/show.html.erb b/app/views/tags/show.html.erb index d1110af44..28b753132 100644 --- a/app/views/tags/show.html.erb +++ b/app/views/tags/show.html.erb @@ -2,7 +2,7 @@

    Posts tagged <%= @tag.name %> - <% if moderator? %> + <% if at_least_moderator? %> diff --git a/app/views/tour/question3.html.erb b/app/views/tour/question3.html.erb index 0c783c16a..ebd85f284 100644 --- a/app/views/tour/question3.html.erb +++ b/app/views/tour/question3.html.erb @@ -21,7 +21,7 @@

    + class="post--title has-margin-top-0 has-padding-2"> How do I teach my dragon not to chase my sheep around? Question

    diff --git a/app/views/user_mailer/deletion_confirmation.html.erb b/app/views/user_mailer/deletion_confirmation.html.erb new file mode 100644 index 000000000..2a55ef1af --- /dev/null +++ b/app/views/user_mailer/deletion_confirmation.html.erb @@ -0,0 +1,23 @@ +

    + You are receiving this email because you have <%= user_link @user, { host: @host }, anchortext: 'an account' %> on the + <%= t('platform.network_name') %> network. +

    + +

    Your Codidact account has been deleted

    + +

    + This email is to confirm that your request to delete your account has been received, and the deletion is being + processed. This means: +

    +
      +
    • Your profiles are being deleted on every community in the network. +
    • Your user account is being deleted and anonymized.
    • +
    • + The content of your posts and comments will remain, under the licenses you set for them. Your username will no + longer be shown alongside them. +
    • +
    + +

    + We're sorry to see you go. If you change your mind, you're welcome back any time — just create an account again. +

    diff --git a/app/views/users/_common_card.html.erb b/app/views/users/_common_card.html.erb index 96bed1f0b..72f3f8348 100644 --- a/app/views/users/_common_card.html.erb +++ b/app/views/users/_common_card.html.erb @@ -9,7 +9,7 @@ <% ckb ||= false %> <% small ||= false %> -<% if user.nil? || (deleted_user?(user) && !moderator?) %> +<% if user.nil? || (deleted_user?(user) && standard?) %>
    @@ -33,9 +33,9 @@ %> <%= link_to user_path(user), dir: 'ltr', class: small ? :'user-card--link-small' :'user-card--link', data: data do %> <%= rtl_safe_username(user) %> - <% if user.is_admin && SiteSetting['AdminBadgeCharacter'] %> + <% if user.admin? && SiteSetting['AdminBadgeCharacter'] %> - <% elsif user.is_moderator && SiteSetting['ModBadgeCharacter'] %> + <% elsif user.at_least_moderator? && SiteSetting['ModBadgeCharacter'] %> <% end %> <% if user.staff? %> diff --git a/app/views/users/_deleted.html.erb b/app/views/users/_deleted.html.erb index af98e451f..7c3fce577 100644 --- a/app/views/users/_deleted.html.erb +++ b/app/views/users/_deleted.html.erb @@ -11,6 +11,10 @@

    <%= deletion_type.capitalize %> deleted <%= time_ago_in_words(deleted_object.deleted_at) %> ago - by <%= link_to deleted_object.deleted_by.username, user_path(deleted_object.deleted_by) %>. + <% if deleted_object.deleted_by == user %> + (self-deleted). + <% else %> + by <%= link_to deleted_object.deleted_by.username, user_path(deleted_object.deleted_by) %>. + <% end %>

    \ No newline at end of file diff --git a/app/views/users/_network.html.erb b/app/views/users/_network.html.erb index c06d70bee..6aa3956a2 100644 --- a/app/views/users/_network.html.erb +++ b/app/views/users/_network.html.erb @@ -6,10 +6,10 @@

    <%= user_link @user, { host: c.host}, anchortext: c.name %> - <% if @user.is_moderator_on(c) %> + <% if @user.moderator_on?(c) %> (moderator) <% end %> Number of edits made <%= @user.metric 'E' %>
    User since <%= @user.created_at %>
    @@ -241,7 +241,7 @@ Count <%= @user.votes.count %> - <% if @user.id == current_user&.id || current_user&.is_admin %> + <% if @user.id == current_user&.id || current_user&.admin? %>
    @@ -290,7 +290,7 @@ Count - <% if current_user&.id == @user.id || moderator? %> + <% if current_user&.id == @user.id || at_least_moderator? %> <%= link_to @user.flags.count, flag_history_path(@user.id), class: 'is-muted', 'aria-label': "View flag history for #{@user.flags.count} flags" %> <% else %> diff --git a/bin/rails b/bin/rails index 7a8ff81e6..efc037749 100755 --- a/bin/rails +++ b/bin/rails @@ -1,9 +1,4 @@ #!/usr/bin/env ruby -begin - load File.expand_path('spring', __dir__) -rescue LoadError => e - raise unless e.message.include?('spring') -end -APP_PATH = File.expand_path('../config/application', __dir__) -require_relative '../config/boot' -require 'rails/commands' +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake index 0ba8c48cb..4fbf10b96 100755 --- a/bin/rake +++ b/bin/rake @@ -1,9 +1,4 @@ #!/usr/bin/env ruby -begin - load File.expand_path('spring', __dir__) -rescue LoadError => e - raise unless e.message.include?('spring') -end -require_relative '../config/boot' -require 'rake' +require_relative "../config/boot" +require "rake" Rake.application.run diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 000000000..40330c0ff --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# explicit rubocop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup index a8e630c6a..934cd058f 100755 --- a/bin/setup +++ b/bin/setup @@ -1,11 +1,11 @@ #!/usr/bin/env ruby -require 'fileutils' +require "fileutils" -# path to your application root. -APP_ROOT = File.expand_path('..', __dir__) +APP_ROOT = File.expand_path("..", __dir__) +APP_NAME = "qpixel" def system!(*args) - system(*args) || abort("\n== Command #{args} failed ==") + system(*args, exception: true) end FileUtils.chdir APP_ROOT do @@ -13,21 +13,25 @@ FileUtils.chdir APP_ROOT do # This script is idempotent, so that you can run it at any time and get an expectable outcome. # Add necessary setup steps to this file. - puts '== Installing dependencies ==' - system! 'gem install bundler --conservative' - system('bundle check') || system!('bundle install') + puts "== Installing dependencies ==" + system! "gem install bundler --conservative" + system("bundle check") || system!("bundle install") # puts "\n== Copying sample files ==" - # unless File.exist?('config/database.yml') - # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" # end puts "\n== Preparing database ==" - system! 'bin/rails db:prepare' + system! "bin/rails db:prepare" puts "\n== Removing old logs and tempfiles ==" - system! 'bin/rails log:clear tmp:clear' + system! "bin/rails log:clear tmp:clear" puts "\n== Restarting application server ==" - system! 'bin/rails restart' + system! "bin/rails restart" + + # puts "\n== Configuring puma-dev ==" + # system "ln -nfs #{APP_ROOT} ~/.puma-dev/#{APP_NAME}" + # system "curl -Is https://#{APP_NAME}.test/up | head -n 1" end diff --git a/config/boot.rb b/config/boot.rb index d69bd27dc..282011619 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -1,3 +1,3 @@ -ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) require "bundler/setup" # Set up gems listed in the Gemfile. diff --git a/config/config/preferences.yml b/config/config/preferences.yml index 5d72b1204..45e7f29bb 100644 --- a/config/config/preferences.yml +++ b/config/config/preferences.yml @@ -83,4 +83,18 @@ sticky_header: default_filter_name: type: ~ default: none - category: true \ No newline at end of file + category: true + +collapse_hot_posts: + type: boolean + description: > + Collapse the Hot Posts widget in the sidebar by default. + default: 'false' + global: true + +collapse_related_posts: + type: boolean + description: > + Collapse the Related Posts widget in the sidebar by default. + default: 'false' + global: true diff --git a/config/config/server_settings.yml b/config/config/server_settings.yml new file mode 100644 index 000000000..60f36f727 --- /dev/null +++ b/config/config/server_settings.yml @@ -0,0 +1,2 @@ +registration_rate_limit: 300 +user_sudo_duration: 30 diff --git a/config/environments/development.rb b/config/environments/development.rb index f6fa52367..18e37f31f 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -85,6 +85,8 @@ host: 'meta.codidact.com', protocol: ENV['MAILER_PROTOCOL'] || 'https' } + config.active_job.queue_adapter = :inline + # Ensure docker ip added to allowed, given that we are in container if File.file?('/.dockerenv') == true host_ip = `/sbin/ip route|awk '/default/ { print $3 }'`.strip diff --git a/config/environments/test.rb b/config/environments/test.rb index 8212caac0..feb02ce6a 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -13,6 +13,8 @@ config.cache_classes = false config.action_view.cache_template_loading = true + config.log_level = :info + # Do not eager load code on boot. This avoids loading your whole application # just for the purpose of running a single test. If you are using a tool that # preloads Rails for running tests, you may have to set it to true. @@ -61,6 +63,8 @@ protocol: ENV['MAILER_PROTOCOL'] || 'https' } + config.active_job.queue_adapter = :test + # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr @@ -75,4 +79,7 @@ # Annotate rendered view with file names. # config.action_view.annotate_rendered_view_with_filenames = true + + # Don't colorize logs - we are writing to log files directly + config.colorize_logging = false end diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 54f47cf15..b3076b38f 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -16,9 +16,9 @@ # # policy.report_uri "/csp-violation-report-endpoint" # end # -# # Generate session nonces for permitted importmap and inline scripts +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } -# config.content_security_policy_nonce_directives = %w(script-src) +# config.content_security_policy_nonce_directives = %w(script-src style-src) # # # Report violations without enforcing the policy. # # config.content_security_policy_report_only = true diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 9eadc984e..d07064545 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -11,7 +11,6 @@ # Devise will use the `secret_key_base` as its `secret_key` # by default. You can change it below and use your own secret key. # config.secret_key = 'a01993900cb61b8d32ff3a777b37164cc49d880aaa8e03c3f9a2990612fa1b4c588d934556e60ea4f51dd334d23caa8ca9ba6a84d651cb412a3d155096b74556' - config.secret_key = 'd379fc6a0bc73bc2faf863d6846f1bd676af3adb46b594f8b3287c52b54cd303556961e03716e370c0031692ec9fc698aee177676f939f3825befbf4b5ef8e9a' # ==> Mailer Configuration # Configure the e-mail address which will be shown in Devise::Mailer, @@ -110,7 +109,7 @@ # config.pepper = '87767bd8e2fa1b42fdf61c33717d80f306570b0c0eb964f090023e7e23f3f196ea847ea7a1c7dd16efff145511e9aa0a80094024708dbe3406b1a184f43ee18a' # Send a notification email when the user's password is changed - # config.send_password_change_notification = false + config.send_password_change_notification = true # ==> Configuration for :confirmable # A period that the user is allowed to access the website even without @@ -126,7 +125,7 @@ # their account can't be confirmed with the token any more. # Default is nil, meaning there is no restriction on how long a user can take # before confirming their account. - # config.confirm_within = 3.days + config.confirm_within = 3.hours # If true, requires any email changes to be confirmed (exactly the same way as # initial account confirmation) to be applied. Requires additional unconfirmed_email @@ -169,27 +168,27 @@ # Defines which strategy will be used to lock an account. # :failed_attempts = Locks an account after a number of failed attempts to sign in. # :none = No lock strategy. You should handle locking by yourself. - # config.lock_strategy = :failed_attempts + config.lock_strategy = :failed_attempts # Defines which key will be used when locking and unlocking an account - # config.unlock_keys = [:email] + config.unlock_keys = [:email] # Defines which strategy will be used to unlock an account. # :email = Sends an unlock link to the user email # :time = Re-enables login after a certain amount of time (see :unlock_in below) # :both = Enables both strategies # :none = No unlock strategy. You should handle unlocking by yourself. - # config.unlock_strategy = :both + config.unlock_strategy = :email # Number of authentication tries before locking an account if lock_strategy # is failed attempts. - # config.maximum_attempts = 20 + config.maximum_attempts = 10 # Time interval to unlock the account if :time is enabled as unlock_strategy. # config.unlock_in = 1.hour # Warn on the last attempt before the account is locked. - # config.last_attempt_warning = true + config.last_attempt_warning = true # ==> Configuration for :recoverable # @@ -199,7 +198,7 @@ # Time interval you can reset your password with a reset password key. # Don't put a too small interval or your users won't have the time to # change their passwords. - config.reset_password_within = 6.hours + config.reset_password_within = 3.hours # When set to false, does not sign a user in automatically after their password is # reset. Defaults to true, so a user is signed in automatically after a reset. diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index adc6568ce..c010b83dd 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -1,8 +1,8 @@ # Be sure to restart your server when you modify this file. -# Configure parameters to be filtered from the log file. Use this to limit dissemination of -# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported -# notations and behaviors. +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. Rails.application.config.filter_parameters += [ - :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn ] diff --git a/config/initializers/new_framework_defaults_7_2.rb b/config/initializers/new_framework_defaults_7_2.rb new file mode 100644 index 000000000..b549c4a25 --- /dev/null +++ b/config/initializers/new_framework_defaults_7_2.rb @@ -0,0 +1,70 @@ +# Be sure to restart your server when you modify this file. +# +# This file eases your Rails 7.2 framework defaults upgrade. +# +# Uncomment each configuration one by one to switch to the new default. +# Once your application is ready to run with all new defaults, you can remove +# this file and set the `config.load_defaults` to `7.2`. +# +# Read the Guide for Upgrading Ruby on Rails for more info on each option. +# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html + +### +# Controls whether Active Job's `#perform_later` and similar methods automatically defer +# the job queuing to after the current Active Record transaction is committed. +# +# Example: +# Topic.transaction do +# topic = Topic.create(...) +# NewTopicNotificationJob.perform_later(topic) +# end +# +# In this example, if the configuration is set to `:never`, the job will +# be enqueued immediately, even though the `Topic` hasn't been committed yet. +# Because of this, if the job is picked up almost immediately, or if the +# transaction doesn't succeed for some reason, the job will fail to find this +# topic in the database. +# +# If `enqueue_after_transaction_commit` is set to `:default`, the queue adapter +# will define the behaviour. +# +# Note: Active Job backends can disable this feature. This is generally done by +# backends that use the same database as Active Record as a queue, hence they +# don't need this feature. +#++ +# Rails.application.config.active_job.enqueue_after_transaction_commit = :default + +### +# Adds image/webp to the list of content types Active Storage considers as an image +# Prevents automatic conversion to a fallback PNG, and assumes clients support WebP, as they support gif, jpeg, and png. +# This is possible due to broad browser support for WebP, but older browsers and email clients may still not support +# WebP. Requires imagemagick/libvips built with WebP support. +#++ +# Rails.application.config.active_storage.web_image_content_types = %w[image/png image/jpeg image/gif image/webp] + +### +# Enable validation of migration timestamps. When set, an ActiveRecord::InvalidMigrationTimestampError +# will be raised if the timestamp prefix for a migration is more than a day ahead of the timestamp +# associated with the current time. This is done to prevent forward-dating of migration files, which can +# impact migration generation and other migration commands. +# +# Applications with existing timestamped migrations that do not adhere to the +# expected format can disable validation by setting this config to `false`. +#++ +# Rails.application.config.active_record.validate_migration_timestamps = true + +### +# Controls whether the PostgresqlAdapter should decode dates automatically with manual queries. +# +# Example: +# ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.select_value("select '2024-01-01'::date") #=> Date +# +# This query used to return a `String`. +#++ +# Rails.application.config.active_record.postgresql_adapter_decode_dates = true + +### +# Enables YJIT as of Ruby 3.3, to bring sizeable performance improvements. If you are +# deploying to a memory constrained environment you may want to set this to `false`. +#++ +# Rails.application.config.yjit = true diff --git a/config/initializers/permissions_policy.rb b/config/initializers/permissions_policy.rb index 00f64d71b..7db3b9577 100644 --- a/config/initializers/permissions_policy.rb +++ b/config/initializers/permissions_policy.rb @@ -1,11 +1,13 @@ +# Be sure to restart your server when you modify this file. + # Define an application-wide HTTP permissions policy. For further -# information see https://developers.google.com/web/updates/2018/06/feature-policy -# -# Rails.application.config.permissions_policy do |f| -# f.camera :none -# f.gyroscope :none -# f.microphone :none -# f.usb :none -# f.fullscreen :self -# f.payment :self, "https://secure.example.com" +# information see: https://developers.google.com/web/updates/2018/06/feature-policy + +# Rails.application.config.permissions_policy do |policy| +# policy.camera :none +# policy.gyroscope :none +# policy.microphone :none +# policy.usb :none +# policy.fullscreen :self +# policy.payment :self, "https://secure.example.com" # end diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml index bd4c3ebc6..3469b2f27 100644 --- a/config/locales/devise.en.yml +++ b/config/locales/devise.en.yml @@ -18,11 +18,11 @@ en: unconfirmed: "You have to confirm your email address before continuing." mailer: confirmation_instructions: - subject: "Confirmation instructions" + subject: "New Codidact account confirmation instructions" reset_password_instructions: - subject: "Reset password instructions" + subject: "Codidact reset password instructions" unlock_instructions: - subject: "Unlock instructions" + subject: "Codidact unlock instructions" password_change: subject: "Password Changed" omniauth_callbacks: diff --git a/config/locales/strings/en.admin.yml b/config/locales/strings/en.admin.yml index ac389d08a..d311aaf31 100644 --- a/config/locales/strings/en.admin.yml +++ b/config/locales/strings/en.admin.yml @@ -18,6 +18,9 @@ en: error_search_uuid: 'Search for an error UUID' privileges_blurb: > Here you can define the reputation required to gain each available privilege. Click on a value to edit it. + email_query_blurb: > + This tool allows you to query for a user by their email address. If a user is found, you will be shown a list of + their profiles on communities on which you are an admin. tools: g_site_settings: 'Global Site Settings' g_tag_sets: 'Global Tag Sets' @@ -31,4 +34,8 @@ en: licenses: 'Licenses' audit_log: 'Audit Log' post_types: 'Post Types' + email_query: 'User Lookup by Email' user_fed_stat: 'User fed to STAT.' + errors: + email_query_not_found: + No user found with that email address. diff --git a/config/locales/strings/en.comments.yml b/config/locales/strings/en.comments.yml new file mode 100644 index 000000000..044ee1a4b --- /dev/null +++ b/config/locales/strings/en.comments.yml @@ -0,0 +1,39 @@ +en: + comments: + errors: + disabled_on_archived_threads: > + Archived threads cannot be replied to. + disabled_on_deleted_posts: > + Comments are disabled on deleted posts. + disabled_on_deleted_threads: > + Deleted threads cannot be replied to. + disabled_on_locked_posts: > + Comments are disabled on locked posts. + disabled_on_locked_threads: > + Locked threads cannot be replied to. + disabled_on_post_specific: > + Comments on this post are disabled. + disabled_on_post_generic: > + This post cannot be commented on. + disabled_on_thread_generic: > + This thread cannot be replied to. + mod_only_undelete: > + Threads deleted by a moderator can only be undeleted by a moderator. + new_user_rate_limited: > + As a new user, you can only comment on your own posts and on answers to them. + rate_limited: > + You have used your daily limit of %{count} comments. Come back tomorrow to continue. + exempt_from_disabled: > + However, you are exempt as a moderator. + delete_comment_server_error: > + Something went wrong when trying to delete the comment. + undelete_comment_server_error: > + Something went wrong when trying to undelete the comment. + labels: + create_new_thread: > + Start new comment thread + reply_to_thread: > + Reply to this thread + warnings: + unrelated_user_not_pinged: > + This user was not notified because they have not participated in this thread. diff --git a/config/locales/strings/en.g.yml b/config/locales/strings/en.g.yml index 1fdf073cc..2a6d8db17 100644 --- a/config/locales/strings/en.g.yml +++ b/config/locales/strings/en.g.yml @@ -25,4 +25,4 @@ en: user: 'user' platform: - network_name: 'Codidact network' + network_name: 'Codidact' diff --git a/config/locales/strings/en.users.yml b/config/locales/strings/en.users.yml new file mode 100644 index 000000000..bf2863bca --- /dev/null +++ b/config/locales/strings/en.users.yml @@ -0,0 +1,11 @@ +en: + users: + errors: + no_admin_self_delete: > + Admin accounts cannot be self-deleted. Contact support. + no_mod_self_delete: > + Moderator accounts cannot be self-deleted. Contact support. + no_2fa_self_delete: > + Accounts using 2FA cannot be self-deleted. Disable 2FA first. + self_delete_wrong_username: > + The username you entered was incorrect. diff --git a/config/routes.rb b/config/routes.rb index 5a9c2f225..61f60b596 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -15,11 +15,14 @@ get 'users/saml/sign_in_request_from_other/:id', to: 'users/saml_sessions#sign_in_request_from_other', as: :sign_in_request_from_other get 'users/saml/sign_in_return_from_base', to: 'users/saml_sessions#sign_in_return_from_base', as: :sign_in_return_from_base get 'users/saml/after_sign_in_check', to: 'users/saml_sessions#after_sign_in_check', as: :after_sign_in_check + get 'users/delete', to: 'users/registrations#delete', as: :delete_account + post 'users/delete', to: 'users/registrations#do_delete', as: :do_delete_account end root to: 'categories#homepage' mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development? + mount Rack::Directory.new('coverage/'), at: '/coverage' if Rails.env.development? mount MaintenanceTasks::Engine, at: '/maintenance' scope 'admin' do @@ -55,6 +58,9 @@ post 'impersonate/:id', to: 'admin#change_users', as: :impersonate get 'impersonate/:id', to: 'admin#impersonate', as: :start_impersonating + get 'email-query', to: 'admin#email_query', as: :admin_email_query + post 'email-query', to: 'admin#do_email_query', as: :do_email_query + scope 'post-types' do root to: 'post_types#index', as: :post_types get 'new', to: 'post_types#new', as: :new_post_type @@ -200,6 +206,8 @@ get '/avatar/:letter/:color/:size', to: 'users#specific_avatar', as: :specific_auto_avatar get '/disconnect-sso', to: 'users#disconnect_sso', as: :user_disconnect_sso post '/disconnect-sso', to: 'users#confirm_disconnect_sso', as: :user_confirm_disconnect_sso + get '/sudo', to: 'sudo#sudo', as: :user_sudo + post '/sudo', to: 'sudo#enter_sudo', as: :enter_sudo get '/:id', to: 'users#show', as: :user get '/:id/flags', to: 'flags#history', as: :flag_history get '/:id/activity', to: 'users#activity', as: :user_activity @@ -239,6 +247,7 @@ post 'post/:post_id/follow', to: 'comments#post_follow', as: :follow_post_comments get ':id', to: 'comments#show', as: :comment get 'thread/:id', to: 'comments#thread', as: :comment_thread + get 'thread/:id/content', to: 'comments#thread_content', as: :comment_thread_content post ':id/edit', to: 'comments#update', as: :update_comment delete ':id/delete', to: 'comments#destroy', as: :delete_comment patch ':id/delete', to: 'comments#undelete', as: :undelete_comment @@ -363,7 +372,7 @@ get '418', to: 'errors#stat' get '422', to: 'errors#unprocessable_entity' get '423', to: 'errors#read_only' - get '500', to: 'errors#internal_server_error' + get '500', to: 'errors#internal_server_error', as: :server_error get 'osd', to: 'application#osd', as: :osd diff --git a/config/schedule.rb b/config/schedule.rb index 1b1c9c343..35d707b38 100644 --- a/config/schedule.rb +++ b/config/schedule.rb @@ -25,3 +25,7 @@ every 6.hours do runner 'scripts/recalc_abilities.rb' end + +every 30.minutes do + runner 'scripts/run_summary_mailer.rb' +end diff --git a/db/seeds/post_history_types.yml b/db/seeds/post_history_types.yml index d12b6e28c..d2bfbf55b 100644 --- a/db/seeds/post_history_types.yml +++ b/db/seeds/post_history_types.yml @@ -10,4 +10,7 @@ - name: imported_from_external_source - name: nominated_for_promotion - name: promotion_removed -- name: history_hidden \ No newline at end of file +- name: history_hidden +- name: category_changed +- name: post_locked +- name: post_unlocked \ No newline at end of file diff --git a/db/seeds/site_settings.yml b/db/seeds/site_settings.yml index 48a1ca475..ffc1b6301 100644 --- a/db/seeds/site_settings.yml +++ b/db/seeds/site_settings.yml @@ -223,6 +223,15 @@ value: Codidact value_type: string category: Email + description: > + Community-specific name of the sender of no-reply emails + (see NoReplySenderName in global site settings for details) + +- name: NoReplySenderName + value: Codidact + value_type: string + category: Email + community_id: ~ description: > The name of the sender of no-reply emails. @@ -230,6 +239,15 @@ value: noreply@codidact.com value_type: string category: Email + description: > + Community-specific address to send no-reply emails from + (see NoReplySenderEmail in global site settings for details). + +- name: NoReplySenderEmail + value: noreply@codidact.com + value_type: string + category: Email + community_id: ~ description: > The address to send no-reply emails from (can be a fake address). Example uses of this address are 2FA emails, flag notifications, account emails, and more. @@ -411,84 +429,98 @@ value_type: integer category: RateLimits description: > - The amount of votes on questions, articles and answers to non-own questions new users may cast within 24h. + The number of votes on questions, articles and answers to non-own questions new users may cast within 24h. - name: RL_Votes value: 30 value_type: integer category: RateLimits description: > - The amount of votes on questions, articles and answers to non-own questions users with the unrestricted ability may cast within 24h. + The number of votes on questions, articles and answers to non-own questions users with the unrestricted ability may cast within 24h. - name: RL_NewUserTopLevelPosts value: 3 value_type: integer category: RateLimits description: > - The amount of questions and articles new users may post within 24h. + The number of questions and articles new users may post within 24h. - name: RL_TopLevelPosts value: 20 value_type: integer category: RateLimits description: > - The amount of questions and articles users with the unrestricted ability may post within 24h. + The number of questions and articles users with the unrestricted ability may post within 24h. - name: RL_NewUserSecondLevelPosts value: 10 value_type: integer category: RateLimits description: > - The amount of answers new users may post within 24h. + The number of answers new users may post within 24h. - name: RL_SecondLevelPosts value: 30 value_type: integer category: RateLimits description: > - The amount of answers users with the unrestricted ability may post within 24h. + The number of answers users with the unrestricted ability may post within 24h. - name: RL_NewUserFlags value: 10 value_type: integer category: RateLimits description: > - The amount of flags new users may raise within 24h. + The number of flags new users may raise within 24h. - name: RL_Flags value: 30 value_type: integer category: RateLimits description: > - The amount of flags users with the unrestricted ability may raise within 24h. + The number of flags users with the unrestricted ability may raise within 24h. - name: RL_NewUserSuggestedEdits value: 3 value_type: integer category: RateLimits description: > - The amount of edits new users may suggest within 24h. + The number of edits new users may suggest within 24h. - name: RL_SuggestedEdits value: 20 value_type: integer category: RateLimits description: > - The amount of edits users with the unrestricted ability may suggest within 24h. + The number of edits users with the unrestricted ability may suggest within 24h. - name: RL_NewUserComments value: 0 value_type: integer category: RateLimits description: > - The amount of comments new users may add on other people's posts within 24h. + The number of comments new users may add on other people's posts within 24h. + +- name: RL_NewUserCommentsOwnPosts + value: 30 + value_type: integer + category: RateLimits + description: > + The number of comments new users may add on their own posts or answers to them within 24h. - name: RL_Comments value: 50 value_type: integer category: RateLimits description: > - The amount of comments users with the unrestricted ability may add on other people's posts within 24h. + The number of comments users with the unrestricted ability may add on other people's posts within 24h. + +- name: RL_CommentsOwnPosts + value: 50 + value_type: integer + category: RateLimits + description: > + The number of comments users with the unrestricted ability may add on their posts or answers to them within 24h. - name: TableOfContentsThreshold value: 5 diff --git a/docker/Dockerfile b/docker/Dockerfile index 8944ffb63..7ee55471c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,18 +22,20 @@ RUN bundle install # cherry pick only what we really need to run Node.js COPY --from=node /usr/local/bin/node /usr/local/bin -COPY --from=node /usr/local/bin/nodejs /usr/local/bin -COPY --from=node /usr/local/bin/npm /usr/local/bin -COPY --from=node /usr/local/bin/npx /usr/local/bin -COPY --from=node /usr/local/bin/yarn /usr/local/bin -COPY --from=node /usr/local/bin/yarnpkg /usr/local/bin COPY --from=node /usr/local/include/node /usr/local/include -COPY --from=node /usr/local/lib/node_modules /usr/local/lib +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules COPY --from=node /usr/local/share/doc/node /usr/local/share/doc COPY --from=node /usr/local/share/man/man1/node.1 /usr/local/share/man/man1 COPY --from=node /usr/local/share/systemtap/tapset/node.stp /usr/local/share/systemtap/tapset COPY --from=node /opt/yarn-v1.22.4 /opt/yarn-v1.22.4 +# create symlinks needed to run Node.js & NPM +RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm +RUN ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx +RUN ln -s /opt/yarn-v1.22.4/bin/yarn /usr/local/bin/yarn +RUN ln -s /opt/yarn-v1.22.4/bin/yarnpkg /usr/local/bin/yarnpkg +RUN ln -s /usr/local/bin/node /usr/local/bin/nodejs + FROM build # setup a dedicated user for Node.js diff --git a/docker/Dockerfile.arm b/docker/Dockerfile.arm deleted file mode 100644 index b6790b416..000000000 --- a/docker/Dockerfile.arm +++ /dev/null @@ -1,55 +0,0 @@ -FROM ruby:3.1.2-bullseye AS ruby -FROM node:12.18.3-slim AS node - -FROM ruby AS build - -# Set all encoding to UTF-8 -ENV RUBYOPT="-KU -E utf-8:utf-8" - -# Install additional dependencies not present in the base image -RUN apt-get update && \ - apt-get install -y bison \ - build-essential \ - libxslt-dev \ - default-mysql-server - -# Add core code to container -WORKDIR /code -COPY . /code - -RUN gem install bundler:2.4.13 -RUN bundle install - -# cherry pick only what we really need to run Node.js -COPY --from=node /usr/local/bin/node /usr/local/bin -COPY --from=node /usr/local/bin/nodejs /usr/local/bin -COPY --from=node /usr/local/bin/npm /usr/local/bin -COPY --from=node /usr/local/bin/npx /usr/local/bin -COPY --from=node /usr/local/bin/yarn /usr/local/bin -COPY --from=node /usr/local/bin/yarnpkg /usr/local/bin -COPY --from=node /usr/local/include/node /usr/local/include -COPY --from=node /usr/local/lib/node_modules /usr/local/lib -COPY --from=node /usr/local/share/doc/node /usr/local/share/doc -COPY --from=node /usr/local/share/man/man1/node.1 /usr/local/share/man/man1 -COPY --from=node /usr/local/share/systemtap/tapset/node.stp /usr/local/share/systemtap/tapset -COPY --from=node /opt/yarn-v1.22.4 /opt/yarn-v1.22.4 - -FROM build - -# setup a dedicated user for Node.js -RUN groupadd --gid 1000 node -RUN useradd --uid 1000 \ - --gid node \ - --shell /bin/bash \ - --create-home node - -# setup Node.js environment -ENV NODEJS_HOME=/usr/local/bin/node -ENV PATH=$NODEJS_HOME:$PATH - -WORKDIR /code - -EXPOSE 80 443 3000 -ENTRYPOINT ["/bin/bash"] -CMD ["/code/docker/entrypoint.sh"] - diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 994452878..e1e3abea1 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -11,7 +11,8 @@ RUN apt-get update && \ apt-get install -y bison \ build-essential \ libxslt-dev \ - default-mysql-server + default-mysql-server \ + firefox-esr # Add core code to container WORKDIR /code @@ -22,18 +23,20 @@ RUN bundle install # cherry pick only what we really need to run Node.js COPY --from=node /usr/local/bin/node /usr/local/bin -COPY --from=node /usr/local/bin/nodejs /usr/local/bin -COPY --from=node /usr/local/bin/npm /usr/local/bin -COPY --from=node /usr/local/bin/npx /usr/local/bin -COPY --from=node /usr/local/bin/yarn /usr/local/bin -COPY --from=node /usr/local/bin/yarnpkg /usr/local/bin COPY --from=node /usr/local/include/node /usr/local/include -COPY --from=node /usr/local/lib/node_modules /usr/local/lib +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules COPY --from=node /usr/local/share/doc/node /usr/local/share/doc COPY --from=node /usr/local/share/man/man1/node.1 /usr/local/share/man/man1 COPY --from=node /usr/local/share/systemtap/tapset/node.stp /usr/local/share/systemtap/tapset COPY --from=node /opt/yarn-v1.22.4 /opt/yarn-v1.22.4 +# create symlinks needed to run Node.js & NPM +RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm +RUN ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx +RUN ln -s /opt/yarn-v1.22.4/bin/yarn /usr/local/bin/yarn +RUN ln -s /opt/yarn-v1.22.4/bin/yarnpkg /usr/local/bin/yarnpkg +RUN ln -s /usr/local/bin/node /usr/local/bin/nodejs + FROM build # setup a dedicated user for Node.js diff --git a/docker/README.md b/docker/README.md index 7aafe9b9f..58bc64c33 100644 --- a/docker/README.md +++ b/docker/README.md @@ -156,7 +156,7 @@ That's it! Running in this docker-compose setup, the system does not actually send emails. However, you can see the emails that would have been sent by going to [http://localhost:3000/letter_opener](http://localhost:3000/letter_opener). This is especially useful to confirm other accounts that you make in the container. -### 9. Running commands in the docker container +## 9. Running commands in the docker container Often, it may be useful to run some ruby/rails code directly, e.g. for debugging purposes. You can do so with the following command: ```bash @@ -178,7 +178,31 @@ RequestContext.community = Community.first This correctly scopes all database actions to the first (and probably only) community in your system. -### 10. Stop Containers +## 10. Running tests +For full details, refer to [The Rails Test Runner](https://guides.rubyonrails.org/testing.html#the-rails-test-runner). + +To run the tests (except the system tests): + +```bash +$ docker compose exec uwsgi rails test +``` + +To run the system tests: + +```bash +$ docker compose exec uwsgi rails test:system +``` + +The system tests require a browser to be available (by default Firefox). Firefox is included in the uwsgi container if +you use `Dockerfile.dev`, otherwise you will need to install a browser. + +To run all of the tests (including the system tests): + +```bash +$ docker compose exec uwsgi rails test:all +``` + +## 11. Stop Containers When you are finished, don't forget to clean up. @@ -187,7 +211,7 @@ docker compose stop docker compose rm ``` -### 11. Next steps +## 12. Next steps The current goal of this container is to provide a development environment for working on QPixel. This deployment has not been tested with email notifications diff --git a/global.d.ts b/global.d.ts index 488a9f0fe..8828b7467 100644 --- a/global.d.ts +++ b/global.d.ts @@ -67,27 +67,61 @@ interface QPixelKeyboard { type NotificationType = "warning" | "success" | "danger"; +type QPixelPopupCallback = (ev: JQuery.ClickEvent, popup: QPixelPopup) => void + +type QPixelPingablePopupCallback = (ev: JQuery.KeyUpEvent)=> Promise + declare class QPixelPopup { static destroyAll: () => void; static getPopup: ( items: JQuery[], - field: JQuery, - callback: (ev: JQuery.Event, popup: QPixelPopup) => void + field: HTMLInputElement | HTMLTextAreaElement, + callback: QPixelPopupCallback ) => QPixelPopup; static isSpecialKey: (keyCode: number) => boolean; - constructor(items: JQuery[], field: JQuery, callback: (ev: JQuery.Event, popup: QPixelPopup) => void); + constructor( + items: JQuery[], + field: HTMLInputElement | HTMLTextAreaElement, + callback: QPixelPopupCallback + ); destroy: () => void; getActiveIdx: () => number | null; setActive: (index: number) => void; - setCallback: (callback: (ev: JQuery.Event, popup: QPixelPopup) => void) => void; + setCallback: (callback: QPixelPopupCallback) => void; getClickHandler: () => (ev: JQuery.Event) => void; getKeyHandler: () => (ev: JQuery.KeyboardEventBase) => void; updateItems: (items: JQuery[]) => void; updatePosition: () => void; } +type QPixelResponseJSON = { + status: 'success' | 'failed', + message?: string, + errors?: string[] +} + +type QPixelComment = { + id: number + created_at: string + updated_at: string + post_id: number + content: string + deleted: boolean + user_id: number + community_id: number + comment_thread_id: number + has_reference: false + reference_text: string | null + references_comment_id: string | null +} + +interface GetThreadContentOptions { + inline?: boolean, + showDeleted?: boolean +} + interface QPixel { // private properties _filters?: Filter[] | null; @@ -97,31 +131,202 @@ interface QPixel { _user?: User | null; // private methods + + /** + * Call _fetchPreferences but only the first time to prevent redundant HTTP requests + */ _cachedFetchPreferences?: () => Promise; + + /** + * Update local variable _preferences and localStorage with an AJAX call for the user preferences + */ _fetchPreferences?: () => Promise; + + /** + * FIFO-style fetch wrapper for /users/me requests + */ _fetchUser?: () => Promise; + + /** + * Get an object containing the current user's preferences. Loads, in order of precedence, from local variable, + * {@link localStorage}, or Redis via AJAX. + * @returns JSON object containing user preferences + */ _getPreferences?: () => Promise; + + /** + * Get the key to use for storing user preferences in localStorage, to avoid conflating users + * @returns string the localStorage key + */ _preferencesLocalStorageKey?: () => string; + + /** + * Set local variable _preferences and localStorage to new preferences data + * @param data an object, containing the new preferences data + */ _updatePreferencesLocally?: (data: UserPreferences) => void; // public methods + + /** + * Add a button to the Markdown editor. + * @param $buttonHtml the HTML content that the button should show - just text, if you like, or + * something more complex if you want to. + * @param shortName a short name for the action that will be used as the title and aria-label attributes. + * @param callback a function that will be passed as the click event callback. + */ addEditorButton?: ($buttonHtml: JQuery.htmlString, shortName: string, callback: () => void) => void; + + /** + * Add a validator that will be called before creating a post. + * callback should take one parameter, the post text, and should return an array in + * the following format: + * + * [ + * true | false, // is the post valid for this check? + * [ + * { type: 'warning', message: 'warning message - will not block posting' }, + * { type: 'error', message: 'error message - will block posting' } + * ] + * ] + */ addPrePostValidation?: (callback: PostValidator) => void; + + /** + * Get the current CSRF anti-forgery token. Should be passed as the X-CSRF-Token header when + * making AJAX POST requests. + */ csrfToken?: () => string; + + /** + * Create a notification popup - not an inbox notification. + * @param type the type to apply to the popup - warning, danger, etc. + * @param message the message to show + */ createNotification?: (type: NotificationType, message: string) => void; + + /** + * 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" + * @param posIdx the index to search for + * @returns the word the given position is in, and the position within that word + */ currentCaretSequence?: (splat: string[], posIdx: number) => [string, number]; + /** + * Fetches default user filter for a given category + * @param categoryId id of the category to fetch + */ defaultFilter?: (categoryId: string) => Promise; deleteFilter?: (name: string, system?: boolean) => Promise; filters?: () => Promise>; - offset?: ($el: JQuery) => ElementOffset; + + /** + * Get the absolute offset of an element. + * @param element the element for which to find the offset. + * @returns element offset information + */ + offset?: (element: HTMLElement) => ElementOffset; + + /** + * Get a single user preference by name. + * @param name the name of the requested preference + * @param community is the requested preference community-local (true), or network-wide (false)? + * @returns the value of the requested preference + */ preference?: (name: string, community?: boolean) => Promise; - replaceSelection?: ($field: JQuery, text: string) => void; + + /** + * Replace the selected text in an input field with a provided replacement. + * @param $field the field in which to replace text + * @param text the text with which to replace the selection + */ + replaceSelection?: ($field: JQuery, text: string) => void; setFilter?: (name: string, filter: Filter, category: string, isDefault: boolean) => Promise; setFilterAsDefault?: (categoryId: string, name: string) => Promise; + + /** + * Set a user preference by name to the value provided. + * @param name the name of the preference to set + * @param value the value to set to - must respond to toString() for {@link localStorage} and Redis + * @param community is this preference community-local (true), or network-wide (false)? + */ setPreference?: (name: string, value: unknown, community?: boolean) => Promise; + + /** + * Get the user object for the current user. + * @returns JSON object containing user details + */ user?: () => Promise; + + /** + * Internal. Called just before a post is sent to the server to validate that it passes + * all custom checks. + */ validatePost?: (postText: string) => [boolean, PostValidatorMessage[]]; + /** + * Send a request with JSON data, pre-authorized with QPixel credentials for the signed in user. + * @param uri The URI to which to send the request. + * @param data An object containing data to send as the request body. Must be acceptable by JSON.stringify. + * @param options An optional {@link RequestInit} to override the defaults provided by this method. + * @returns The Response promise returned from {@link fetch}. + */ + fetchJSON?: (uri: string, data: any, options?: RequestInit) => Promise; + + /** + * @param uri The URI to which to send the request. + * @param options An optional {@link RequestInit} to override the defaults provided by {@link fetchJSON} + * @returns + */ + getJSON?: (uri: string, options?: Omit) => Promise; + + /** + * Attempts get a JSON reprentation of a comment + * @param id id of the comment to get + */ + getComment?: (id: string) => Promise + + /** + * Attempts to dynamically load thread content + * @param id id of the comment thread + * @param options configuration options + */ + getThreadContent?: (id: string, options?: GetThreadContentOptions) => Promise + + /** + * Attempts to dynamically load a list of comment threads for a given post + * @param id id of the post to load + */ + getThreadsListContent?: (id: string) => Promise + + /** + * Processes JSON responses from QPixel API + * @param data + * @param onSuccess callback to call for successful requests + */ + handleJSONResponse?: (data: T, onSuccess: (data: T) => void) => void + + /** + * Attempts to delete a comment + * @param id id of the comment to delete + * @returns result of the operation + */ + deleteComment?: (id: string) => Promise + + /** + * Attempts to undelete a comment + * @param id id of the comment to undelete + * @returns result of the operation + */ + undeleteComment?: (id: string) => Promise + + /** + * Attempts to lock a comment thread + * @param id id of the comment thread to lock + * @returns result of the operation + */ + lockThread?: (id: string) => Promise; + // qpixel_dom DOM?: QPixelDOM; Popup?: typeof QPixelPopup; diff --git a/lib/rubocop/path_in_helpers.rb b/lib/rubocop/path_in_helpers.rb index c078815b6..452c75e28 100644 --- a/lib/rubocop/path_in_helpers.rb +++ b/lib/rubocop/path_in_helpers.rb @@ -5,7 +5,7 @@ class PathInHelpers < RuboCop::Cop::Base def_node_matcher :method_call, '(send nil? $_ ...)' MSG = "Don't use _path URL helpers in helper methods; use _url instead.".freeze - PATH_HELPER = /^[\da-zA-z_]+_path$/.freeze + PATH_HELPER = /^[\da-zA-Z_]+_path$/ def on_send(node) method_call(node) do |name| diff --git a/package-lock.json b/package-lock.json index 785d0a1d3..8e19e8c1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,8 +6,8 @@ "": { "name": "qpixel", "devDependencies": { - "@types/jquery": "3.5.32", - "@types/select2": "4.0.63", + "@types/jquery": "^3.5.32", + "@types/select2": "^4.0.63", "typescript": "5.6.3" } }, @@ -16,6 +16,7 @@ "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.32.tgz", "integrity": "sha512-b9Xbf4CkMqS02YH8zACqN1xzdxc3cO735Qe5AbSUFmyOiaWAbcpqh9Wna+Uk0vgACvoQHpWDg2rGdHkYPLmCiQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/sizzle": "*" } @@ -25,6 +26,7 @@ "resolved": "https://registry.npmjs.org/@types/select2/-/select2-4.0.63.tgz", "integrity": "sha512-/DXUfPSj3iVTGlRYRYPCFKKSogAGP/j+Z0fIMXbBiBtmmZj0WH7vnfNuckafq9C43KnqPPQW2TI/Rj/vTSGnQQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/jquery": "*" } diff --git a/package.json b/package.json index f18337027..c3e3860bf 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "typecheck": "tsc" }, "devDependencies": { - "@types/jquery": "3.5.32", - "@types/select2": "4.0.63", + "@types/jquery": "^3.5.32", + "@types/select2": "^4.0.63", "typescript": "5.6.3" } } diff --git a/public/404.html b/public/404.html index b612547fc..2be3af26f 100644 --- a/public/404.html +++ b/public/404.html @@ -4,7 +4,7 @@ The page you were looking for doesn't exist (404) - +
    diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html new file mode 100644 index 000000000..7cf1e168e --- /dev/null +++ b/public/406-unsupported-browser.html @@ -0,0 +1,66 @@ + + + + Your browser is not supported (406) + + + + + + +
    +
    +

    Your browser is not supported.

    +

    Please upgrade your browser to continue.

    +
    +
    + + diff --git a/public/422.html b/public/422.html index a21f82b3b..c08eac0d1 100644 --- a/public/422.html +++ b/public/422.html @@ -4,7 +4,7 @@ The change you wanted was rejected (422) - +
    diff --git a/public/500.html b/public/500.html index 061abc587..78a030af2 100644 --- a/public/500.html +++ b/public/500.html @@ -4,7 +4,7 @@ We're sorry, but something went wrong (500) - +
    diff --git a/public/assets/community/codegolf.css b/public/assets/community/codegolf.css index 19288197e..1bd7538d5 100644 --- a/public/assets/community/codegolf.css +++ b/public/assets/community/codegolf.css @@ -13,6 +13,8 @@ } .cg-leaderboard .row-summary { + align-items: center; + display: flex; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/public/assets/community/codegolf.js b/public/assets/community/codegolf.js index 05f370deb..1f61d3e59 100644 --- a/public/assets/community/codegolf.js +++ b/public/assets/community/codegolf.js @@ -3,6 +3,24 @@ * License: AGPLv3 */ +/** + * @typedef {{ + * answerID: string + * answerURL?: string + * page: number + * username: string + * userid: string + * full_language?: string + * language?: string + * variant?: string + * extensions?: string + * code?: string + * score?: number + * }} ChallengeEntry + * + * @typedef {(a: ChallengeEntry, b: ChallengeEntry) => number} SortComparator + */ + (() => { const dom_parser = new DOMParser(); const match = location.pathname.match(/(?<=posts\/)\d+/); @@ -14,6 +32,10 @@ const CHALLENGE_ID = match[0]; let leaderboard; + + /** + * @type {SortComparator | undefined} + */ let sort; console.log(`CG Leaderboard active, challenge ID ${CHALLENGE_ID}`); @@ -59,6 +81,10 @@ } }; + /** + * @param {string} id challenge id for which to get the leaderboard + * @returns {Promise} + */ async function getLeaderboard(id) { const response = await fetch(`/posts/${id}`); const text = await response.text(); @@ -73,6 +99,7 @@ pagePromises.push(fetch(`/posts/${id}?sort=active&page=${i}`).then((response) => response.text())); } + /** @type {ChallengeEntry[]} */ const leaderboard = []; for (let i = 0; i < pagePromises.length; i++) { @@ -83,24 +110,32 @@ for (const answerPost of non_deleted_answers) { + /** @type {HTMLElement | null} */ const header = answerPost.querySelector('h1, h2, h3'); - const code = header.parentElement.querySelector(':scope > pre > code'); - const full_language = header ? header.innerText.split(',')[0].trim() : undefined + const code = header?.parentElement.querySelector(':scope > pre > code'); + const full_language = header?.innerText.split(',')[0].trim(); const regexGroups = full_language?.match(/(?.+?)(?: \((?.+)\))?(?: \+ (?.+))?$/)?.groups ?? {}; const { language, variant, extensions } = regexGroups; + const userlink = answerPost.querySelector( + ".user-card--content .user-card--link", + ); + // https://regex101.com/r/BjIjk5/2 + const matchedScore = header?.innerText.match(/\d+(?:\.\d+)?/g)?.pop(); + + /** @type {ChallengeEntry} */ const entry = { answerID: answerPost.id, answerURL: answerPost.querySelector('.js-permalink').href, page: i + 1, // +1 because pages are 1-indexed while arrays are 0-indexed - username: answerPost.querySelector('.user-card--link').firstChild.data.trim(), - userid: answerPost.querySelector('.user-card--link').href.match(/\d+/)[0], - full_language, full_language, - language: language, - variant: variant, - extensions: extensions, + username: userlink.firstChild.data.trim(), + userid: userlink.href.match(/\d+/)[0], + full_language, + language, + variant, + extensions, code: code?.innerText, - score: header ? header.innerText.match(/\d+/g)?.pop() : undefined + score: isFinite(+matchedScore) ? +matchedScore : void 0 }; leaderboard.push(entry); @@ -110,6 +145,11 @@ return leaderboard; } + /** + * @param {ChallengeEntry[]} leaderboard list of challenge entries to augment + * @param {SortComparator} comparator compare function for sorting + * @returns {void} + */ function augmentLeaderboardWithPlacements(leaderboard, comparator) { leaderboard.sort(comparator); @@ -157,7 +197,7 @@ const toggle = embed.querySelector('#leaderboards-header'); toggle.addEventListener('click', (_) => { if (leaderboardsTable.style.display === 'none') { - refreshBoard(); + refreshBoard(sort); leaderboardsTable.style.display = 'block'; } else { leaderboardsTable.style.display = 'none'; @@ -168,27 +208,34 @@ groupByLanguageInput.addEventListener('click', (_) => { settings.groupByLanguage = groupByLanguageInput.checked; - refreshBoard(); + refreshBoard(sort); }); + showPlacementsInput.addEventListener('click', (_) => { settings.showPlacements = showPlacementsInput.checked; - refreshBoard(); + refreshBoard(sort); }); - function refreshBoard() { + /** + * @param {SortComparator} comparator + */ + function refreshBoard(comparator) { // Clear table leaderboardsTable.querySelectorAll('a').forEach((el) => el.remove()); if (settings.groupByLanguage) { - renderLeaderboardsByLanguage(); + renderLeaderboardsByLanguage(comparator); } else { - renderLeaderboardsByByteCount(); + renderLeaderboardsByByteCount(comparator); } } /** - * Helper function * Turns arrays into associative arrays + * @template {unknown} T + * @param {T[]} array array to group + * @param {(item: T) => string} categorizer + * @returns {Record} */ function createGroups(array, categorizer) { const groups = {}; @@ -205,6 +252,10 @@ return groups; } + /** + * @param {ChallengeEntry} answer challenge entry to create row for + * @returns {HTMLAnchorElement} + */ function createRow(answer) { const row = document.createElement('a'); row.classList.add('toc--entry'); @@ -212,7 +263,7 @@ row.innerHTML = `
    ${answer.score}
    -

    +

    ${answer.placement === 1 ? '
    ' : (settings.showPlacements ? `
    #${answer.placement}
    ` : '')}
    `; @@ -222,19 +273,25 @@ if (answer.code) { row.querySelector('.username').after(document.createElement('code')); row.querySelector('code').innerText = answer.code.split('\n')[0].substring(0, 200); - } else { + } else if (answer.code !== '') { row.querySelector('.username').insertAdjacentHTML('afterend', 'Invalid entry format'); } return row; } - async function renderLeaderboardsByLanguage() { + /** + * @param {SortComparator} comparator + */ + async function renderLeaderboardsByLanguage(comparator) { leaderboard = leaderboard || await getLeaderboard(CHALLENGE_ID); const languageLeaderboards = createGroups(leaderboard, (entry) => entry.full_language); - for (const language in languageLeaderboards) { - augmentLeaderboardWithPlacements(languageLeaderboards[language], sort); + // sorted using default alphanumeric sort + const sortedLanguageKeys = Object.keys(languageLeaderboards).sort() + + for (const language of sortedLanguageKeys) { + augmentLeaderboardWithPlacements(languageLeaderboards[language], comparator); for (const answer of languageLeaderboards[language]) { const row = createRow(answer); @@ -243,9 +300,12 @@ } } - async function renderLeaderboardsByByteCount() { + /** + * @param {SortComparator} comparator + */ + async function renderLeaderboardsByByteCount(comparator) { leaderboard = leaderboard || await getLeaderboard(CHALLENGE_ID); - augmentLeaderboardWithPlacements(leaderboard, sort); + augmentLeaderboardWithPlacements(leaderboard, comparator); for (const answer of leaderboard) { const row = createRow(answer); @@ -253,19 +313,41 @@ } } - window.addEventListener('DOMContentLoaded', (_) => { - if (document.querySelector('.category-header--name').innerText.trim() === 'Challenges') { - const question_tags = [...document.querySelector('.post--tags').children].map((el) => el.innerText); - - if (question_tags.includes('code-golf') || question_tags.includes('lowest-score')) { - sort = (x, y) => x.score - y.score; - document.querySelector('.post:first-child').nextElementSibling.insertAdjacentElement('afterend', embed); - refreshBoard(); - } else if (question_tags.includes('code-bowling') || question_tags.includes('highest-score')) { - sort = (x, y) => y.score - x.score; - document.querySelector('.post:first-child').nextElementSibling.insertAdjacentElement('afterend', embed); - refreshBoard(); - } + window.addEventListener("DOMContentLoaded", (_) => { + const categoryName = document.querySelector(".category-header--name").innerText.trim(); + + if (categoryName !== 'Challenges') { + return; + } + + const question_tags = [ + ...document.querySelector(".post--tags").children, + ].map((el) => el.innerText); + + if ( + question_tags.includes("code-golf") || + question_tags.includes("lowest-score") + ) { + // If x were undefined, it would be automatically sorted to the end, but not so if x.score is undefined, so this needs to be stated explicitly. + sort = (x, y) => typeof x.score === "undefined" ? 1 : x.score - y.score; + + document + .querySelector(".post:first-child") + .nextElementSibling.insertAdjacentElement("afterend", embed); + + refreshBoard(sort); + } else if ( + question_tags.includes("code-bowling") || + question_tags.includes("highest-score") + ) { + // If x were undefined, it would be automatically sorted to the end, but not so if x.score is undefined, so this needs to be stated explicitly. + sort = (x, y) => typeof x.score === "undefined" ? 1 : y.score - x.score; + + document + .querySelector(".post:first-child") + .nextElementSibling.insertAdjacentElement("afterend", embed); + + refreshBoard(sort); } }); })(); diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 000000000..f3b5abcbd Binary files /dev/null and b/public/icon.png differ diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 000000000..78307ccd4 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/scripts/recalc_abilities.rb b/scripts/recalc_abilities.rb index 17c99d4fd..cb2566483 100644 --- a/scripts/recalc_abilities.rb +++ b/scripts/recalc_abilities.rb @@ -39,7 +39,7 @@ cu.recalc_abilities # Grant mod ability if mod status is given - if (cu.is_moderator || cu.is_admin || u.is_global_moderator || u.is_global_admin) && !cu.ability?('mod') + if cu.at_least_moderator? && !cu.ability?('mod') cu.grant_ability!('mod') end @@ -55,4 +55,4 @@ unless options.quiet puts "Completed, #{resolved.size}/#{all.size} tasks successful, #{destroy.size} tasks invalid" -end \ No newline at end of file +end diff --git a/scripts/recalc_abilities_upon_first_migration.rb b/scripts/recalc_abilities_upon_first_migration.rb index 32b3c48e4..b7394be55 100644 --- a/scripts/recalc_abilities_upon_first_migration.rb +++ b/scripts/recalc_abilities_upon_first_migration.rb @@ -3,14 +3,18 @@ User.unscoped.all.map do |u| puts "Scope: User.Id=#{u.id}" - CommunityUser.unscoped.where(user: u).all.map do |cu| + + u.community_users.each do |cu| puts " Attempt CommunityUser.Id=#{cu.id}" RequestContext.community = cu.community - cu.recalc_privileges + cu.recalc_privileges! - if (cu.is_moderator || cu.is_admin || u.is_global_moderator || u.is_global_admin) && !cu.privilege?('mod') + if cu.at_least_moderator? && !cu.privilege?('mod') + puts " Granting mod privilege to CommunityUser.Id=#{cu.id}" cu.grant_privilege!('mod') end + + puts " Recalc success for CommunityUser.Id=#{cu.id}" rescue puts " !!! Error recalcing for CommunityUser.Id=#{cu.id}" end diff --git a/scripts/run_summary_mailer.rb b/scripts/run_summary_mailer.rb new file mode 100644 index 000000000..ce82f1544 --- /dev/null +++ b/scripts/run_summary_mailer.rb @@ -0,0 +1 @@ +SendSummaryEmailsJob.perform_later diff --git a/test/.rubocop.yml b/test/.rubocop.yml index ebc9063e1..259d9869f 100644 --- a/test/.rubocop.yml +++ b/test/.rubocop.yml @@ -6,6 +6,14 @@ Layout/LineLength: Metrics/BlockLength: CountAsOne: ['array'] + AllowedMethods: + - included Metrics/ClassLength: - Enabled: false \ No newline at end of file + Enabled: false + +Style/MethodCallWithArgsParentheses: + Enabled: true + AllowedMethods: ['get', 'post', 'put', 'delete', 'puts', 'patch'] + AllowedPatterns: ['assert$', 'assert_equal', 'raise', '^click_', '^fill_', 'visit', 'within'] + IncludedMacros: ['assert_response'] diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index da6f04b00..73a444150 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -56,7 +56,7 @@ def confirm_email(user_or_fixture) # # @param user_or_fixture [User, Symbol] either a user or a symbol referring to a fixture def user(user_or_fixture) - if user_or_fixture.is_a? User + if user_or_fixture.is_a?(User) user_or_fixture else users(user_or_fixture) @@ -73,29 +73,26 @@ def post_form_select_tag(tag_name, create_new = false) find('.select2-search__field').fill_in(with: tag_name) end - # Get the first item listed that is not the "Searching..." item - first_option = find('#select2-post_tags_cache-results li:first-child') { |el| el.text != 'Searching…' } + # Wait for tag search to finish + assert_text('Searching…') + assert_no_text('Searching…') - if first_option.first('span').text == tag_name - # If the text matches the tag name, first check whether we are creating a new tag. - # If so, confirm that we are allowed to. If all is good, actually click on the item. - if create_new || !first_option.text.include?('Create new tag') - first_option.click - else - raise "Expected to find tag with the name #{tag_name}, " \ - 'but could not select it from options without creating a new tag.' - end + # Get the first and last options listed + first_option = find('#select2-post_tags_cache-results li:first-child') + last_option = find('#select2-post_tags_cache-results li:last-child') + + # If the first option matches the tag name, and the tag already exists, click it. + if first_option.first('span').text == tag_name && !first_option.text.include?('Create new tag') + first_option.click + # If we are allowed to create a tag, select the last option from the list, which is always the tag creation. elsif create_new - # The first item returned is not the tag we were looking for (another tag partial match + not existing) - # If we are allowed to create a tag, select the last option from the list, which is always the tag creation. - last_option = find('#select2-post_tags_cache-results li:last-child') if last_option.first('span').text == tag_name last_option.click else raise "Tried to select tag #{tag_name} for creation, but it does not seem to be a presented option." end + # The first option is not the tag we were looking for, and we are not allowed to create a tag. else - # The first item returned is not the tag we were looking for, and we are not allowed to create a tag. raise "Expected to find tag with the name #{tag_name}, " \ 'but could not select it from options without creating a new tag.' end diff --git a/test/comments_test_helpers.rb b/test/comments_test_helpers.rb new file mode 100644 index 000000000..797a69f5f --- /dev/null +++ b/test/comments_test_helpers.rb @@ -0,0 +1,124 @@ +module CommentsControllerTestHelpers + extend ActiveSupport::Concern + + private + + # Attempts to archive a given comment thread + # @param thread [CommentThread] thread to archive + def try_archive_thread(thread) + post :thread_restrict, params: { id: thread.id, type: 'archive' } + end + + # Attempts to create a comment thread on a given post + # @param post [Post] post to create the thread on + # @param mentions [Array] list of user @-mentions, if any + # @param content [String] content of the initial thread comment + # @param title [String] title of the thread, if any + def try_create_thread(post, + mentions: [], + content: 'sample comment content', + title: 'sample thread title', + format: :html) + body_parts = [content] + mentions.map { |u| "@##{u.id}" } + + post(:create_thread, params: { post_id: post.id, + title: title, + body: body_parts.join(' ') }, + format: format) + end + + # Attempts to create a comment in a given thread + # @param thread [CommentThread] thread to create the comment in + # @param mentions [Array] list of user @-mentions, if any + # @param content [String] content of the comment, if any + # @param format [Symbol] whether to respond with HTML or JSON + # @param inline [Boolean] whether to stay on the post page + def try_create_comment(thread, + mentions: [], + content: 'sample comment content', + format: :html, + inline: false) + content_parts = [content] + mentions.map { |u| "@##{u.id}" } + + post(:create, params: { id: thread.id, + post_id: thread.post.id, + content: content_parts.join(' '), + inline: inline }, + format: format) + end + + # Attempts to delete a given comment thread + # @param thread [CommentThread] thread to delete + def try_delete_thread(thread) + post :thread_restrict, params: { id: thread.id, type: 'delete' } + end + + # Attempts to undelete a given comment thread + # @param thread [CommentThread] thread to undelete + def try_undelete_thread(thread) + post :thread_unrestrict, params: { id: thread.id, type: 'delete' } + end + + # Attempts to follow a given comment thread + # @param thread [CommentThread] thread to follow + def try_follow_thread(thread) + post :thread_restrict, params: { id: thread.id, type: 'follow' } + end + + # Attempts to unfollow a given comment thread + # @param thread [CommentThread] thread to unfollow + def try_unfollow_thread(thread) + post :thread_unrestrict, params: { id: thread.id, type: 'follow' } + end + + # Attempts to lock a given comment thread + # @param thread [CommentThread] thread to lock + # @param duration [Integer] lock duration, in days + def try_lock_thread(thread, duration: nil) + post :thread_restrict, params: { duration: duration, id: thread.id, type: 'lock' } + end + + # Attempts to unlock a given comment thread + # @param thread [CommentThread] thread to unlock + def try_unlock_thread(thread) + post :thread_unrestrict, params: { id: thread.id, type: 'lock' } + end + + # Attempts to rename a given comment thread + # @param thread [CommentThread] thread to rename + # @param title [String] new thread title, if any + def try_rename_thread(thread, title: 'new thread title') + post :thread_rename, params: { id: thread.id, title: title } + end + + # Attempts to show a single comment + # @param comment [Comment] comment to show + def try_show_comment(comment, format: :html) + get :show, params: { id: comment.id, format: format } + end + + # Attempts to show a single comment thread + # @param thread [CommentThread] comment thread to show + def try_show_thread(thread, format: :html) + get :thread, params: { id: thread.id, format: format } + end + + # Attempts to delete a single comment + # @param comment [Comment] comment to delete + def try_delete_comment(comment, format: :html) + delete :destroy, params: { id: comment.id, format: format } + end + + # Attempts to undelete a single comment + # @param comment [Comment] comment to undelete + def try_undelete_comment(comment, format: :html) + patch :undelete, params: { id: comment.id, format: format } + end + + # Attempts to update a given comment + # @param comment [Comment] comment to update + # @param content [String] new content of the comment, if any + def try_update_comment(comment, content: 'Edited comment content') + post :update, params: { id: comment.id, comment: { content: content } } + end +end diff --git a/test/controllers/abilities_controller_test.rb b/test/controllers/abilities_controller_test.rb index 8d993ff80..a9ea0fe94 100644 --- a/test/controllers/abilities_controller_test.rb +++ b/test/controllers/abilities_controller_test.rb @@ -6,7 +6,7 @@ class AbilitiesControllerTest < ActionController::TestCase test 'should get index when logged in' do sign_in users(:standard_user) get :index - assert_response 200 + assert_response(:success) assert_not_nil assigns(:abilities) assert_not_nil assigns(:user) end @@ -14,13 +14,13 @@ class AbilitiesControllerTest < ActionController::TestCase test 'should get index when not logged in' do sign_out :user get :index - assert_response 200 + assert_response(:success) end test 'should get index for other user when logged in' do sign_in users(:standard_user) get :index, params: { for: users(:closer).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:abilities) assert_not_nil assigns(:user) end @@ -28,7 +28,7 @@ class AbilitiesControllerTest < ActionController::TestCase test 'should get index for other user when not logged in' do sign_out :user get :index, params: { for: users(:closer).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:abilities) assert_not_nil assigns(:user) end @@ -36,7 +36,7 @@ class AbilitiesControllerTest < ActionController::TestCase test 'should get show when logged in' do sign_in users(:standard_user) get :show, params: { id: 'unrestricted' } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:ability) assert_not_nil assigns(:user) assert_not_nil assigns(:your_ability) @@ -45,13 +45,13 @@ class AbilitiesControllerTest < ActionController::TestCase test 'should get show when not logged in' do sign_out :user get :show, params: { id: 'unrestricted' } - assert_response 200 + assert_response(:success) end test 'should get show for other user when logged in' do sign_in users(:standard_user) get :show, params: { id: 'unrestricted', for: users(:closer).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:ability) assert_not_nil assigns(:user) assert_not_nil assigns(:your_ability) @@ -60,7 +60,7 @@ class AbilitiesControllerTest < ActionController::TestCase test 'should get show for other user when not logged in' do sign_out :user get :show, params: { id: 'unrestricted', for: users(:closer).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:ability) assert_not_nil assigns(:user) assert_not_nil assigns(:your_ability) diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb index dd5037ee0..c1f6af3b4 100644 --- a/test/controllers/admin_controller_test.rb +++ b/test/controllers/admin_controller_test.rb @@ -3,19 +3,19 @@ class AdminControllerTest < ActionController::TestCase include Devise::Test::ControllerHelpers - PARAM_LESS_ACTIONS = [:index, :error_reports, :privileges, :audit_log].freeze + PARAM_LESS_ACTIONS = [:index, :error_reports, :privileges, :audit_log, :email_query, :admin_email, :all_email].freeze test 'should get index' do sign_in users(:admin) get :index - assert_response :success + assert_response(:success) end test 'should deny anonymous users access' do sign_out :user PARAM_LESS_ACTIONS.each do |path| get path - assert_response(404) + assert_response(:not_found) end end @@ -23,7 +23,7 @@ class AdminControllerTest < ActionController::TestCase sign_in users(:standard_user) PARAM_LESS_ACTIONS.each do |path| get path - assert_response(404) + assert_response(:not_found) end end @@ -31,7 +31,7 @@ class AdminControllerTest < ActionController::TestCase sign_in users(:editor) PARAM_LESS_ACTIONS.each do |path| get path - assert_response(404) + assert_response(:not_found) end end @@ -39,7 +39,7 @@ class AdminControllerTest < ActionController::TestCase sign_in users(:deleter) PARAM_LESS_ACTIONS.each do |path| get path - assert_response(404) + assert_response(:not_found) end end @@ -47,7 +47,7 @@ class AdminControllerTest < ActionController::TestCase sign_in users(:moderator) PARAM_LESS_ACTIONS.each do |path| get path - assert_response(404) + assert_response(:not_found) end end @@ -60,7 +60,7 @@ class AdminControllerTest < ActionController::TestCase sign_in users(:admin) PARAM_LESS_ACTIONS.each do |path| get path - assert_response(404) + assert_response(:not_found) end end @@ -73,50 +73,88 @@ class AdminControllerTest < ActionController::TestCase sign_in users(:global_admin) PARAM_LESS_ACTIONS.each do |path| get path - assert_response(200) + assert_response(:success) end end test 'should get single privilege' do sign_in users(:admin) get :show_privilege, params: { name: 'unrestricted', format: :json } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:ability) - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response end test 'should update privilege threshold' do sign_in users(:admin) post :update_privilege, params: { name: 'unrestricted', threshold: 0.6, type: 'post' } - assert_response 202 + + assert_response(:accepted) assert_not_nil assigns(:ability) assert_equal 0.6, assigns(:ability).post_score_threshold - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'OK', JSON.parse(response.body)['status'] end test 'should access error reports' do sign_in users(:admin) get :error_reports - assert_response 200 + assert_response(:success) assert_not_nil assigns(:reports) end test 'should search error reports' do sign_in users(:admin) get :error_reports, params: { uuid: error_logs(:without_context).uuid } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:reports) end test 'should get audit log' do sign_in users(:admin) get :audit_log - assert_response 200 + assert_response(:success) assert_not_nil assigns(:logs) end + + test 'should do email query' do + sign_in users(:admin) + post :do_email_query, params: { email: users(:standard_user).email } + assert_response(:success) + assert_not_nil assigns(:user) + assert_not_nil assigns(:profiles) + end + + test 'do_email_query should add a notice if the email does not exist' do + sign_in users(:admin) + post :do_email_query, params: { email: 'spock@vulcan.ufp' } + assert_response(:success) + assert_equal flash[:danger], I18n.t('admin.errors.email_query_not_found') + end + + test 'send email methods should require auth' do + [:send_admin_email, :send_all_email].each do |action| + post action, params: { subject: 'test', body_markdown: 'test' } + assert_response(:not_found) + end + end + + test 'send email methods should require global admin' do + [:send_admin_email, :send_all_email].each do |action| + sign_in users(:admin) + post action, params: { subject: 'test', body_markdown: 'test' } + assert_response(:not_found) + end + end + + test 'send email methods should work for global admins' do + [:send_admin_email, :send_all_email].each do |action| + sign_in users(:global_admin) + post action, params: { subject: 'test', body_markdown: 'test' } + assert_response(:found) + assert_redirected_to admin_path + assert_not_nil flash[:success] + end + end end diff --git a/test/controllers/advertisement_controller_test.rb b/test/controllers/advertisement_controller_test.rb index eed236ff7..d9180e37a 100644 --- a/test/controllers/advertisement_controller_test.rb +++ b/test/controllers/advertisement_controller_test.rb @@ -5,23 +5,41 @@ class AdvertisementControllerTest < ActionController::TestCase test 'index should return html' do get :index - assert_response(200) + assert_response(:success) assert_equal 'text/html', response.media_type end test 'image paths should return png' do - # Cannot test :random_question, as it requires initialization - # of hot posts, which isn't provided via get helper. - [:codidact, :community].each do |path| + [:codidact, :community, :random_question, :promoted_post].each do |path| get path - assert_response(200) + assert_response(:success) assert_equal 'image/png', response.media_type end end - test 'post image path should return png' do - get :specific_question, params: { id: posts(:question_one).id } - assert_response(200) + test 'specific_question for different post types' do + { 123_456_789 => :not_found, + posts(:question_one).id => :success, + posts(:article_one).id => :success, + posts(:answer_one).id => :not_found }.each do |id, status| + try_specific_question(id) + assert_response(status) + if status == :success + assert_equal 'image/png', response.media_type + end + end + end + + test 'specific_category' do + # :specific_category uses random post selection, so we can't easily test for different post types + get :specific_category, params: { id: categories(:main).id, score: -1, days: 3650 } + assert_response(:success) assert_equal 'image/png', response.media_type end + + private + + def try_specific_question(id) + get :specific_question, params: { id: id } + end end diff --git a/test/controllers/answers_controller_test.rb b/test/controllers/answers_controller_test.rb index b5a1ade00..5388aa150 100644 --- a/test/controllers/answers_controller_test.rb +++ b/test/controllers/answers_controller_test.rb @@ -5,14 +5,14 @@ class AnswersControllerTest < ActionController::TestCase test 'should convert to comment' do sign_in users(:moderator) + pre_count = posts(:question_one).comments.count post :convert_to_comment, params: { id: posts(:answer_one).id, post_id: posts(:question_one).id } post_count = posts(:question_one).comments.count - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:answer) - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal pre_count + 1, post_count assert_equal true, assigns(:answer).deleted end @@ -20,6 +20,7 @@ class AnswersControllerTest < ActionController::TestCase test 'should 404 convert comment for non-moderator' do sign_in users(:editor) post :convert_to_comment, params: { id: posts(:answer_one).id, post_id: posts(:question_one).id } - assert_response 404 + + assert_response(:not_found) end end diff --git a/test/controllers/categories_controller_test.rb b/test/controllers/categories_controller_test.rb index 3f2071974..82bdbfdc5 100644 --- a/test/controllers/categories_controller_test.rb +++ b/test/controllers/categories_controller_test.rb @@ -5,78 +5,111 @@ class CategoriesControllerTest < ActionController::TestCase test 'should get index' do get :index - assert_response 200 + assert_response(:success) assert_not_nil assigns(:categories) end - test 'should get show' do - get :show, params: { id: categories(:main).id } - assert_response 200 - assert_not_nil assigns(:category) - assert_not_nil assigns(:posts) + test 'should correctly show public categories' do + public_categories = categories.select(&:public?) + + assert_not public_categories.empty? + + public_categories.each do |category| + try_show_category(category) + + assert_response(:success) + assert_not_nil assigns(:category) + assert_not_nil assigns(:posts) + end end - test 'fake community should not get show' do + test 'fake community should never be shown' do RequestContext.community = communities(:fake) request.env['HTTP_HOST'] = 'fake.qpixel.com' - get :show, params: { id: categories(:main).id } - assert_response(404) - end + try_show_category(categories(:main)) - test 'should require authentication to get new' do - get :new - assert_response 302 - assert_redirected_to new_user_session_path + assert_response(:not_found) end - test 'should require admin to get new' do - sign_in users(:standard_user) - get :new - assert_response 404 + test 'categories should only be shown to those who can see them' do + users.each do |user| + sign_in user + + categories.each do |category| + try_show_category(category) + + if category.public? || user.can_see_category?(category) + assert_response(:success) + else + assert_response(:not_found) + end + + assert_not_nil assigns(:category) + end + end end - test 'should allow admins to get new' do - sign_in users(:admin) - get :new - assert_response 200 - assert_not_nil assigns(:category) + test ':new should require the user to be an admin' do + users.each do |user| + sign_in user + + get :new + + if user.admin? + assert_response(:success) + assert_not_nil assigns(:category) + elsif @controller.helpers.user_signed_in? + assert_response(:not_found) + else + assert_redirected_to_sign_in + end + end end test 'should require authentication to create category' do - post :create, params: { category: { name: 'test', short_wiki: 'test', display_post_types: [Question.post_type_id], - post_type_ids: [Question.post_type_id, Answer.post_type_id], - tag_set: tag_sets(:main).id, color_code: 'blue', - license_id: licenses(:cc_by_sa).id } } - assert_response 302 - assert_redirected_to new_user_session_path + try_create_category + assert_redirected_to_sign_in end test 'should require admin to create category' do sign_in users(:standard_user) - post :create, params: { category: { name: 'test', short_wiki: 'test', display_post_types: [Question.post_type_id], - post_type_ids: [Question.post_type_id, Answer.post_type_id], - tag_set: tag_sets(:main).id, color_code: 'blue', - license_id: licenses(:cc_by_sa).id } } - assert_response 404 + try_create_category + assert_response(:not_found) end test 'should allow admins to create category' do sign_in users(:admin) - post :create, params: { category: { name: 'test', short_wiki: 'test', display_post_types: [Question.post_type_id], - post_type_ids: [Question.post_type_id, Answer.post_type_id], - tag_set_id: tag_sets(:main).id, color_code: 'blue', - license_id: licenses(:cc_by_sa).id } } - assert_response 302 + try_create_category + + assert_response(:found) assert_not_nil assigns(:category) assert_not_nil assigns(:category).id assert_equal false, assigns(:category).errors.any? assert_redirected_to category_path(assigns(:category)) end - test 'should prevent users under min_view_trust_level viewing category that requires higher' do - get :show, params: { id: categories(:admin_only).id } - assert_response 404 - assert_not_nil assigns(:category) + private + + def try_create_category(**opts) + name = opts[:name] || 'test' + short_wiki = opts[:short_wiki] || 'test' + license = opts[:license] || licenses(:cc_by_sa) + color_code = opts[:color_code] || 'blue' + display_post_types = opts[:display_post_types] || [Question.post_type_id] + post_types = opts[:post_types] || [Question, Answer] + tag_set = opts[:tag_set] || tag_sets(:main) + + post :create, params: { category: { name: name, + short_wiki: short_wiki, + display_post_types: display_post_types, + post_type_ids: post_types.map(&:post_type_id), + tag_set_id: tag_set.id, + color_code: color_code, + license_id: license.id } } + end + + def try_show_category(category) + get :show, params: { id: category.id } end end diff --git a/test/controllers/close_reasons_controller_test.rb b/test/controllers/close_reasons_controller_test.rb index 41e6a0309..b01f12ced 100644 --- a/test/controllers/close_reasons_controller_test.rb +++ b/test/controllers/close_reasons_controller_test.rb @@ -8,7 +8,7 @@ class CloseReasonsControllerTest < ActionController::TestCase test 'should get index' do sign_in users(:admin) get :index - assert_response :success + assert_response(:success) assert_not_nil assigns(:close_reasons) end @@ -16,7 +16,7 @@ class CloseReasonsControllerTest < ActionController::TestCase sign_out :user PARAM_LESS_ACTIONS.each do |path| get path - assert_response(404) + assert_response(:not_found) end end @@ -24,14 +24,14 @@ class CloseReasonsControllerTest < ActionController::TestCase sign_in users(:standard_user) PARAM_LESS_ACTIONS.each do |path| get path - assert_response(404) + assert_response(:not_found) end end test 'should get new' do sign_in users(:global_admin) get :new - assert_response :success + assert_response(:success) assert_not_nil assigns(:close_reason) end @@ -39,7 +39,7 @@ class CloseReasonsControllerTest < ActionController::TestCase sign_in users(:global_admin) post :create, params: { close_reason: { name: 'test', description: 'test', requires_other_post: true, active: true } } - assert_response 302 + assert_response(:found) assert_redirected_to close_reasons_path assert_not_nil assigns(:close_reason) assert_not_nil assigns(:close_reason).id @@ -48,16 +48,22 @@ class CloseReasonsControllerTest < ActionController::TestCase test 'should get edit' do sign_in users(:global_admin) get :edit, params: { id: close_reasons(:duplicate).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:close_reason) end + test 'edit should fail for non-global admin on global reason' do + sign_in users(:admin) + get :edit, params: { id: close_reasons(:global).id } + assert_response(:not_found) + end + test 'should update close reason' do sign_in users(:global_admin) patch :update, params: { id: close_reasons(:duplicate).id, close_reason: { name: 'test', description: 'test', requires_other_post: true, active: false } } - assert_response 302 + assert_response(:found) assert_redirected_to close_reasons_path assert_not_nil assigns(:close_reason) assert_equal false, assigns(:close_reason).active diff --git a/test/controllers/comments/archive_test.rb b/test/controllers/comments/archive_test.rb new file mode 100644 index 000000000..796ae0e27 --- /dev/null +++ b/test/controllers/comments/archive_test.rb @@ -0,0 +1,24 @@ +require 'test_helper' +require 'comments_test_helpers' + +class CommentsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include CommentsControllerTestHelpers + + test 'should archive thread' do + sign_in users(:deleter) + try_archive_thread(comment_threads(:normal)) + + assert_response(:success) + assert_valid_json_response + assert_equal 'success', JSON.parse(response.body)['status'] + end + + test 'should require privilege to archive thread' do + sign_in users(:standard_user) + try_archive_thread(comment_threads(:normal)) + + assert_response(:not_found) + assert_not_nil assigns(:comment_thread) + end +end diff --git a/test/controllers/comments/create_test.rb b/test/controllers/comments/create_test.rb new file mode 100644 index 000000000..df763a1cb --- /dev/null +++ b/test/controllers/comments/create_test.rb @@ -0,0 +1,200 @@ +require 'test_helper' +require 'comments_test_helpers' + +class CommentsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include CommentsControllerTestHelpers + + test 'should correctly create threads' do + sign_in users(:editor) + + before_author_notifs = users(:standard_user).notifications.count + before_uninvolved_notifs = users(:moderator).notifications.count + + try_create_thread(posts(:question_one), mentions: [users(:deleter), users(:moderator)]) + + assert_response(:found) + assert_redirected_to post_path(assigns(:post)) + assert_not_nil assigns(:post) + assert_not_nil assigns(:comment)&.id + assert_not_nil assigns(:comment_thread)&.id + assert_nil flash[:danger] + assert_equal before_author_notifs + 1, users(:standard_user).notifications.count, + 'Author notification not created when it should have been' + assert_equal before_uninvolved_notifs, users(:moderator).notifications.count, + 'Uninvolved notification created when it should not have been' + assert assigns(:comment_thread).followed_by?(users(:editor)), 'Follower record not created for thread author' + end + + test 'should correctly default thread title if not provided' do + sign_in users(:editor) + + { + 'a' * 99 => ->(thread) { thread.comments.first.content }, + 'a' * 200 => ->(thread) { "#{thread.comments.first.content[0..100]}..." } + }.each do |content, matcher| + try_create_thread(posts(:question_one), content: content, mentions: [], title: '') + + assert_response(:found) + thread = assigns(:comment_thread) + assert_equal thread.title, matcher.call(thread) + end + end + + test 'should require auth to create thread' do + try_create_thread(posts(:question_one)) + assert_redirected_to_sign_in + end + + test 'should not create threads on posts of others without the unrestricted ability when rate-limited' do + sign_in users(:basic_user) + + SiteSetting['RL_NewUserComments'] = 0 + + post = posts(:question_one) + + try_create_thread(post) + + assert_not_nil flash[:danger] + assert_redirected_to @controller.helpers.generic_share_link(post) + end + + test 'should not create thread if the target post is inaccessible' do + sign_in users(:editor) + try_create_thread(posts(:high_trust)) + assert_response(:not_found) + end + + test 'should not create thread if the target post does not allow comments for known reasons' do + sign_in users(:editor) + + [:comments_disabled, :deleted, :locked].each do |name| + post = posts(name) + + assert !post.comments_allowed? + + try_create_thread(post, format: :json) + + assert_response(:forbidden) + assert_valid_json_response + assert_json_response_message(@controller.helpers.comments_post_error_msg(post)) + end + end + + test 'should return a catch-all response if the target post does not allow comments for an unknown reason' do + sign_in users(:editor) + + post = posts(:question_one) + + post.stub(:comments_allowed?, false) do + Post.stub(:find, post) do + try_create_thread(post, format: :json) + + assert_response(:forbidden) + assert_valid_json_response + assert_json_response_message(@controller.helpers.comments_post_error_msg(post)) + end + end + end + + test 'should correclty create comments in threads' do + sign_in users(:editor) + before_author_notifs = users(:standard_user).notifications.count + before_follow_notifs = users(:deleter).notifications.count + before_uninvolved_notifs = users(:moderator).notifications.count + + try_create_comment(comment_threads(:normal), mentions: [users(:deleter), users(:moderator)]) + + assert_response(:found) + assert_redirected_to comment_thread_path(assigns(:comment_thread)) + assert_not_nil assigns(:post) + assert_not_nil assigns(:comment_thread) + assert_not_nil assigns(:comment)&.id + assert_equal before_author_notifs + 1, users(:standard_user).notifications.count, + 'Post author notification not created when it should have been' + assert_equal before_follow_notifs + 1, users(:deleter).notifications.count, + 'Thread follower notification not created when it should have been' + assert_equal before_uninvolved_notifs, users(:moderator).notifications.count, + 'Uninvolved notification created when it should not have been' + assert assigns(:comment_thread).followed_by?(users(:editor)), 'Follower record not created for comment author' + end + + test 'should correctly redirect depending on the inline parameter' do + thread = comment_threads(:normal) + editor = users(:editor) + + sign_in editor + + [false, true].each do |inline| + try_create_comment(thread, inline: inline) + + assert_response(:found) + + if inline + comment = Comment.by(editor).where(comment_thread: thread).last + + assert_redirected_to @controller.helpers.generic_share_link(thread.post, + comment_id: comment.id, + thread_id: thread.id) + else + assert_redirected_to comment_thread_path(thread) + end + end + end + + test 'should require auth to create comments' do + try_create_comment(comment_threads(:normal)) + assert_redirected_to_sign_in + end + + test 'should not create comments on threads on posts of others without the unrestricted ability when rate-limited' do + sign_in users(:basic_user) + + SiteSetting['RL_NewUserComments'] = 0 + + thread = comment_threads(:normal) + + try_create_comment(thread) + + assert_not_nil flash[:danger] + assert_redirected_to @controller.helpers.generic_share_link(thread.post) + end + + test 'should not create comment if the target post is inaccessible' do + sign_in users(:editor) + try_create_comment(comment_threads(:high_trust)) + assert_response(:not_found) + end + + test 'should not create comment if the target thread is readonly for a known reason' do + sign_in users(:editor) + + [:locked, :deleted, :archived].each do |name| + thread = comment_threads(name) + + assert thread.read_only? + + try_create_comment(thread, format: :json) + + assert_response(:forbidden) + assert_valid_json_response + assert_json_response_message(@controller.helpers.comments_thread_error_msg(thread)) + end + end + + test 'should return a catch-all response if the target thread is readonly for an unknown reason' do + sign_in users(:editor) + + thread = comment_threads(:normal) + + thread.stub(:read_only?, true) do + CommentThread.stub(:find, thread) do + try_create_comment(thread, format: :json) + + assert_response(:forbidden) + assert_valid_json_response + assert_json_response_message(@controller.helpers.comments_thread_error_msg(thread)) + end + end + end +end diff --git a/test/controllers/comments/delete_test.rb b/test/controllers/comments/delete_test.rb new file mode 100644 index 000000000..2a04d0ce0 --- /dev/null +++ b/test/controllers/comments/delete_test.rb @@ -0,0 +1,64 @@ +require 'test_helper' +require 'comments_test_helpers' + +class CommentsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include CommentsControllerTestHelpers + + test 'should delete comment' do + sign_in users(:standard_user) + + try_delete_comment(comments(:one), format: :json) + + assert_response(:success) + assert_valid_json_response + assert_equal 'success', JSON.parse(response.body)['status'] + end + + test 'should require auth to delete comment' do + [:html, :json].each do |format| + try_delete_comment(comments(:one), format: format) + + if format == :html + assert_redirected_to_sign_in + else + assert_response(:unauthorized) + assert_valid_json_response + end + end + end + + test 'should allow moderators to delete comments' do + sign_in users(:moderator) + + try_delete_comment(comments(:one), format: :json) + + assert_response(:success) + assert_valid_json_response + assert_equal 'success', JSON.parse(response.body)['status'] + end + + test 'should not allow other users to delete comment' do + sign_in users(:editor) + try_delete_comment(comments(:one)) + + assert_response(:forbidden) + end + + test 'should correctly delete threads' do + sign_in users(:deleter) + try_delete_thread(comment_threads(:normal)) + + assert_response(:success) + assert_valid_json_response + assert_equal 'success', JSON.parse(response.body)['status'] + end + + test 'should require privilege to delete thread' do + sign_in users(:standard_user) + try_delete_thread(comment_threads(:normal)) + + assert_response(:not_found) + assert_not_nil assigns(:comment_thread) + end +end diff --git a/test/controllers/comments/follow_test.rb b/test/controllers/comments/follow_test.rb new file mode 100644 index 000000000..38735538e --- /dev/null +++ b/test/controllers/comments/follow_test.rb @@ -0,0 +1,23 @@ +require 'test_helper' +require 'comments_test_helpers' + +class CommentsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include CommentsControllerTestHelpers + + test 'any non-deleted user with a profile should be able to follow threads' do + thread = comment_threads(:normal) + + users.each do |user| + next unless user.profile_on?(RequestContext.community) + next if user.deleted? || user.community_user.deleted? + + sign_in user + try_follow_thread(thread) + + assert_response(:success, user.community_user.inspect) + assert_valid_json_response + assert_equal 'success', JSON.parse(response.body)['status'] + end + end +end diff --git a/test/controllers/comments/lock_test.rb b/test/controllers/comments/lock_test.rb new file mode 100644 index 000000000..b98d9b45a --- /dev/null +++ b/test/controllers/comments/lock_test.rb @@ -0,0 +1,41 @@ +require 'test_helper' +require 'comments_test_helpers' + +class CommentsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include CommentsControllerTestHelpers + + test 'should lock thread indefinitely by default' do + thread = comment_threads(:normal) + + sign_in users(:deleter) + + try_lock_thread(thread) + thread.reload + + assert_response(:success) + assert_valid_json_response + assert_equal 'success', JSON.parse(response.body)['status'] + assert thread.locked?, "Expected thread #{thread.title} to be locked" + end + + test 'should lock thread for a specific duration if provided' do + sign_in users(:deleter) + try_lock_thread(comment_threads(:normal), duration: 2) + + @thread = assigns(:comment_thread) + + assert_not_nil @thread + assert @thread.lock_active? + travel_to 3.days.from_now + assert_not @thread.lock_active? + end + + test 'should require privilege to lock thread' do + sign_in users(:standard_user) + try_lock_thread(comment_threads(:normal)) + + assert_response(:not_found) + assert_not_nil assigns(:comment_thread) + end +end diff --git a/test/controllers/comments/pingable_test.rb b/test/controllers/comments/pingable_test.rb new file mode 100644 index 000000000..8b6318046 --- /dev/null +++ b/test/controllers/comments/pingable_test.rb @@ -0,0 +1,23 @@ +require 'test_helper' +require 'comments_test_helpers' + +class CommentsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include CommentsControllerTestHelpers + + test 'should get pingable users on thread' do + sign_in users(:standard_user) + + try_pingable(posts(:question_one)) + + assert_response(:success) + assert_valid_json_response + end + + private + + # @param post [Post] + def try_pingable(post) + get :pingable, params: { id: -1, post: post.id } + end +end diff --git a/test/controllers/comments/post_test.rb b/test/controllers/comments/post_test.rb new file mode 100644 index 000000000..1f6424de5 --- /dev/null +++ b/test/controllers/comments/post_test.rb @@ -0,0 +1,46 @@ +require 'test_helper' +require 'comments_test_helpers' + +class CommentsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include CommentsControllerTestHelpers + + test 'non-moderator users without flag_curate ability should not see deleted threads' do + sign_in users(:editor) + get :post, params: { post_id: posts(:question_one).id }, format: :json + + assert_response(:success) + assert_valid_json_response + threads = JSON.parse(response.body) + assert_equal threads.any? { |t| t['deleted'] }, false + end + + test 'moderators and users with flag_curate ability should see deleted threads' do + sign_in users(:deleter) + get :post, params: { post_id: posts(:question_one).id }, format: :json + threads = JSON.parse(response.body) + assert_equal threads.any? { |t| t['deleted'] }, true + + sign_in users(:moderator) + get :post, params: { post_id: posts(:question_one).id }, format: :json + threads = JSON.parse(response.body) + assert_equal threads.any? { |t| t['deleted'] }, true + end + + test 'users should see deleted threads on their own posts even if those threads are deleted' do + sign_in users(:standard_user) + get :post, params: { post_id: posts(:question_one).id }, format: :json + + assert_response(:success) + assert_valid_json_response + threads = JSON.parse(response.body) + assert_equal threads.any? { |t| t['deleted'] }, true + end + + test 'should get comment threads on post' do + get :post, params: { post_id: posts(:question_one).id } + assert_response(:success) + assert_not_nil assigns(:post) + assert_not_nil assigns(:comment_threads) + end +end diff --git a/test/controllers/comments/rename_test.rb b/test/controllers/comments/rename_test.rb new file mode 100644 index 000000000..c534929f2 --- /dev/null +++ b/test/controllers/comments/rename_test.rb @@ -0,0 +1,35 @@ +require 'test_helper' +require 'comments_test_helpers' + +class CommentsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include CommentsControllerTestHelpers + + test 'should correctly rename thread' do + sign_in users(:deleter) + + try_rename_thread(comment_threads(:normal)) + + assert_response(:found) + assert_not_nil assigns(:comment_thread) + assert_redirected_to comment_thread_path(assigns(:comment_thread)) + assert_equal 'new thread title', assigns(:comment_thread).title + end + + test 'non-moderators should not be able to rename restricted threads' do + sign_in users(:deleter) + + [:locked, :archived, :deleted].each do |name| + before_title = comment_threads(name).title + + try_rename_thread(comment_threads(name)) + + @thread = assigns(:comment_thread) + + assert_response(:found) + assert_not_nil @thread + assert_redirected_to comment_thread_path(@thread) + assert_equal before_title, @thread.title + end + end +end diff --git a/test/controllers/comments/show_test.rb b/test/controllers/comments/show_test.rb new file mode 100644 index 000000000..92e81359b --- /dev/null +++ b/test/controllers/comments/show_test.rb @@ -0,0 +1,34 @@ +require 'test_helper' +require 'comments_test_helpers' + +class CommentsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include CommentsControllerTestHelpers + + test 'should correctly get one comment' do + [:html, :json].each do |format| + try_show_comment(comments(:one), format: format) + + assert_response(:success) + assert_not_nil assigns(:comment) + + if format == :json + assert_valid_json_response + end + end + end + + test 'should correctly get one thread' do + [:html, :json].each do |format| + try_show_thread(comment_threads(:normal), format: format) + + assert_response(:success) + assert_not_nil assigns(:comment_thread) + assert_not_nil assigns(:post) + + if format == :json + assert_valid_json_response + end + end + end +end diff --git a/test/controllers/comments/thread_followers_test.rb b/test/controllers/comments/thread_followers_test.rb new file mode 100644 index 000000000..730ca3377 --- /dev/null +++ b/test/controllers/comments/thread_followers_test.rb @@ -0,0 +1,27 @@ +require 'test_helper' +require 'comments_test_helpers' + +class CommentsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include CommentsControllerTestHelpers + + test 'should get thread followers' do + sign_in users(:admin) + get :thread_followers, params: { id: comment_threads(:normal).id } + assert_response(:success) + assert_not_nil assigns(:comment_thread) + assert_not_nil assigns(:followers) + end + + test 'should require auth to get thread followers' do + get :thread_followers, params: { id: comment_threads(:normal).id } + assert_redirected_to_sign_in + end + + test 'should require moderator to get thread followers' do + sign_in users(:standard_user) + get :thread_followers, params: { id: comment_threads(:normal).id } + assert_response(:not_found) + assert_not_nil assigns(:comment_thread) + end +end diff --git a/test/controllers/comments/thread_test.rb b/test/controllers/comments/thread_test.rb new file mode 100644 index 000000000..947c77d10 --- /dev/null +++ b/test/controllers/comments/thread_test.rb @@ -0,0 +1,33 @@ +require 'test_helper' +require 'comments_test_helpers' + +class CommentsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include CommentsControllerTestHelpers + + test 'should get thread' do + get :thread, params: { id: comment_threads(:normal).id } + assert_response(:success) + assert_not_nil assigns(:comment_thread) + end + + test 'should require auth to access high trust thread' do + get :thread, params: { id: comment_threads(:high_trust).id } + assert_response(:not_found) + assert_not_nil assigns(:comment_thread) + end + + test 'should require privileges to access high trust thread' do + sign_in users(:deleter) + get :thread, params: { id: comment_threads(:high_trust).id } + assert_response(:not_found) + assert_not_nil assigns(:comment_thread) + end + + test 'should access thread on own deleted post' do + sign_in users(:closer) + get :thread, params: { id: comment_threads(:on_deleted_post).id } + assert_response(:success) + assert_not_nil assigns(:comment_thread) + end +end diff --git a/test/controllers/comments/unarchive_test.rb b/test/controllers/comments/unarchive_test.rb new file mode 100644 index 000000000..12e82ae67 --- /dev/null +++ b/test/controllers/comments/unarchive_test.rb @@ -0,0 +1,31 @@ +require 'test_helper' +require 'comments_test_helpers' + +class CommentsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include CommentsControllerTestHelpers + + test 'should unarchive thread' do + sign_in users(:deleter) + try_unarchive_thread(comment_threads(:archived)) + + assert_response(:success) + assert_valid_json_response + assert_equal 'success', JSON.parse(response.body)['status'] + end + + test 'should require privilege to unarchive thread' do + sign_in users(:standard_user) + try_unarchive_thread(comment_threads(:archived)) + + assert_response(:not_found) + assert_not_nil assigns(:comment_thread) + end + + private + + # @param thread [CommentThread] + def try_unarchive_thread(thread) + post :thread_unrestrict, params: { id: thread.id, type: 'archive' } + end +end diff --git a/test/controllers/comments/undelete_test.rb b/test/controllers/comments/undelete_test.rb new file mode 100644 index 000000000..025cef807 --- /dev/null +++ b/test/controllers/comments/undelete_test.rb @@ -0,0 +1,97 @@ +require 'test_helper' +require 'comments_test_helpers' + +class CommentsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include CommentsControllerTestHelpers + + test 'should correctly undelete comments' do + sign_in users(:standard_user) + try_undelete_comment(comments(:deleted), format: :json) + + assert_response(:success) + assert_valid_json_response + assert_equal 'success', JSON.parse(response.body)['status'] + end + + test 'should require auth to undelete comments' do + try_undelete_comment(comments(:deleted)) + assert_redirected_to_sign_in + end + + test 'should allow moderators to undelete comments' do + sign_in users(:moderator) + try_undelete_comment(comments(:deleted), format: :json) + + assert_response(:success) + assert_valid_json_response + assert_equal 'success', JSON.parse(response.body)['status'] + end + + test 'should not allow non-moderator users to undelete comments' do + sign_in users(:editor) + try_undelete_comment(comments(:deleted)) + assert_response(:forbidden) + end + + test 'comment undeletion should correctly handle validation' do + sign_in users(:moderator) + + comment = comments(:deleted) + + # this is a bit cursed, but IIRC the easiest way to test this + comment.stub(:update, false) do + Comment.stub(:unscoped, Comment) do + Comment.stub(:find, comment) do + try_undelete_comment(comment, format: :json) + + assert_response(:internal_server_error) + end + end + end + end + + test 'only mods or admins should be able to undelete threads deleted by one of them' do + thread = comment_threads(:normal) + + sign_in users(:moderator) + try_delete_thread(thread) + + assert_response(:success) + + sign_in users(:deleter) + try_undelete_thread(thread) + + assert_response(:success) + assert_valid_json_response + response_body = JSON.parse(response.body) + assert_equal('error', response_body['status']) + assert_equal I18n.t('comments.errors.mod_only_undelete'), response_body['message'] + + sign_in users(:moderator) + try_undelete_thread(thread) + + assert_response(:success) + assert_valid_json_response + response_body = JSON.parse(response.body) + assert_equal('success', response_body['status']) + assert_nil response_body['message'] + end + + test 'should correctly undelete threads' do + sign_in users(:moderator) + try_undelete_thread(comment_threads(:deleted)) + + assert_response(:success) + assert_valid_json_response + assert_equal 'success', JSON.parse(response.body)['status'] + end + + test 'should require privilege to undelete thread' do + sign_in users(:standard_user) + try_undelete_thread(comment_threads(:deleted)) + + assert_response(:not_found) + assert_not_nil assigns(:comment_thread) + end +end diff --git a/test/controllers/comments/unfollow_test.rb b/test/controllers/comments/unfollow_test.rb new file mode 100644 index 000000000..d770bac3d --- /dev/null +++ b/test/controllers/comments/unfollow_test.rb @@ -0,0 +1,16 @@ +require 'test_helper' +require 'comments_test_helpers' + +class CommentsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include CommentsControllerTestHelpers + + test 'should correctly unfollow threads' do + sign_in users(:standard_user) + try_unfollow_thread(comment_threads(:normal)) + + assert_response(:success) + assert_valid_json_response + assert_equal 'success', JSON.parse(response.body)['status'] + end +end diff --git a/test/controllers/comments/unlock_test.rb b/test/controllers/comments/unlock_test.rb new file mode 100644 index 000000000..14269b243 --- /dev/null +++ b/test/controllers/comments/unlock_test.rb @@ -0,0 +1,24 @@ +require 'test_helper' +require 'comments_test_helpers' + +class CommentsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include CommentsControllerTestHelpers + + test 'should correctly unlock threads' do + sign_in users(:deleter) + try_unlock_thread(comment_threads(:locked)) + + assert_response(:success) + assert_valid_json_response + assert_equal 'success', JSON.parse(response.body)['status'] + end + + test 'should require privilege to unlock thread' do + sign_in users(:standard_user) + try_unlock_thread(comment_threads(:locked)) + + assert_response(:not_found) + assert_not_nil assigns(:comment_thread) + end +end diff --git a/test/controllers/comments/update_test.rb b/test/controllers/comments/update_test.rb new file mode 100644 index 000000000..cf4af790e --- /dev/null +++ b/test/controllers/comments/update_test.rb @@ -0,0 +1,38 @@ +require 'test_helper' +require 'comments_test_helpers' + +class CommentsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include CommentsControllerTestHelpers + + test 'should correctly update comments' do + sign_in users(:standard_user) + + try_update_comment(comments(:one)) + + assert_response(:success) + assert_valid_json_response + assert_equal 'success', JSON.parse(response.body)['status'] + end + + test 'should require auth to update comments' do + try_update_comment(comments(:one)) + assert_redirected_to_sign_in + end + + test 'should allow moderators to update comments' do + sign_in users(:moderator) + + try_update_comment(comments(:one)) + + assert_response(:success) + assert_valid_json_response + assert_equal 'success', JSON.parse(response.body)['status'] + end + + test 'non-moderator users should not be able to update comments' do + sign_in users(:editor) + try_update_comment(comments(:one)) + assert_response(:forbidden) + end +end diff --git a/test/controllers/comments_controller_test.rb b/test/controllers/comments_controller_test.rb deleted file mode 100644 index bb53a9c6e..000000000 --- a/test/controllers/comments_controller_test.rb +++ /dev/null @@ -1,394 +0,0 @@ -require 'test_helper' - -class CommentsControllerTest < ActionController::TestCase - include Devise::Test::ControllerHelpers - - test 'should create new thread' do - sign_in users(:editor) - before_author_notifs = users(:standard_user).notifications.count - before_uninvolved_notifs = users(:moderator).notifications.count - post :create_thread, params: { post_id: posts(:question_one).id, title: 'sample thread title', - body: "sample comment content @##{users(:deleter).id} @##{users(:moderator).id}" } - assert_response 302 - assert_redirected_to post_path(assigns(:post)) - assert_not_nil assigns(:post) - assert_not_nil assigns(:comment)&.id - assert_not_nil assigns(:comment_thread)&.id - assert_nil flash[:danger] - assert_equal before_author_notifs + 1, users(:standard_user).notifications.count, - 'Author notification not created when it should have been' - assert_equal before_uninvolved_notifs, users(:moderator).notifications.count, - 'Uninvolved notification created when it should not have been' - assert assigns(:comment_thread).followed_by?(users(:editor)), 'Follower record not created for thread author' - end - - test 'should require auth to create thread' do - post :create_thread, params: { post_id: posts(:question_one).id, title: 'sample thread title', - body: "sample comment content @##{users(:deleter).id} @##{users(:moderator).id}" } - assert_response 302 - assert_redirected_to new_user_session_path - end - - test 'should not create thread if comments disabled' do - sign_in users(:editor) - post :create_thread, params: { post_id: posts(:comments_disabled).id, title: 'sample thread title', - body: "sample comment content @##{users(:deleter).id} @##{users(:moderator).id}" } - assert_response 403 - assert_equal 'Comments have been disabled on this post.', JSON.parse(response.body)['message'] - end - - test 'should not create thread on inaccessible post' do - sign_in users(:editor) - post :create_thread, params: { post_id: posts(:high_trust).id, title: 'sample thread title', - body: "sample comment content @##{users(:deleter).id} @##{users(:moderator).id}" } - assert_response 404 - end - - test 'should add comment to existing thread' do - sign_in users(:editor) - before_author_notifs = users(:standard_user).notifications.count - before_follow_notifs = users(:deleter).notifications.count - before_uninvolved_notifs = users(:moderator).notifications.count - post :create, params: { id: comment_threads(:normal).id, post_id: posts(:question_one).id, - content: "comment content @##{users(:deleter).id} @##{users(:moderator).id}" } - assert_response 302 - assert_redirected_to comment_thread_path(assigns(:comment_thread)) - assert_not_nil assigns(:post) - assert_not_nil assigns(:comment_thread) - assert_not_nil assigns(:comment)&.id - assert_equal before_author_notifs + 1, users(:standard_user).notifications.count, - 'Post author notification not created when it should have been' - assert_equal before_follow_notifs + 1, users(:deleter).notifications.count, - 'Thread follower notification not created when it should have been' - assert_equal before_uninvolved_notifs, users(:moderator).notifications.count, - 'Uninvolved notification created when it should not have been' - assert assigns(:comment_thread).followed_by?(users(:editor)), 'Follower record not created for comment author' - end - - test 'should require auth to add comment' do - post :create, params: { id: comment_threads(:normal).id, post_id: posts(:question_one).id, - content: "comment content @##{users(:deleter).id} @##{users(:moderator).id}" } - assert_response 302 - assert_redirected_to new_user_session_path - end - - test 'should not add comment if comments disabled' do - sign_in users(:editor) - post :create, params: { id: comment_threads(:comments_disabled).id, post_id: posts(:comments_disabled).id, - content: "comment content @##{users(:deleter).id} @##{users(:moderator).id}" } - assert_response 403 - assert_equal 'Comments have been disabled on this post.', JSON.parse(response.body)['message'] - end - - test 'should not add comment on inaccessible post' do - sign_in users(:editor) - post :create, params: { id: comment_threads(:high_trust).id, post_id: posts(:high_trust).id, - content: "comment content @##{users(:deleter).id} @##{users(:moderator).id}" } - assert_response 404 - end - - test 'should edit comment' do - sign_in users(:standard_user) - post :update, params: { id: comments(:one).id, comment: { content: 'Edited comment content' } } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end - assert_equal 'success', JSON.parse(response.body)['status'] - end - - test 'should require auth to edit comment' do - post :update, params: { id: comments(:one).id, comment: { content: 'Edited comment content' } } - assert_response 302 - assert_redirected_to new_user_session_path - end - - test 'should allow moderator to edit comment' do - sign_in users(:moderator) - post :update, params: { id: comments(:one).id, comment: { content: 'Edited comment content' } } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end - assert_equal 'success', JSON.parse(response.body)['status'] - end - - test 'should not allow other users to edit comment' do - sign_in users(:editor) - post :update, params: { id: comments(:one).id, comment: { content: 'Edited comment content' } } - assert_response 403 - end - - test 'should delete comment' do - sign_in users(:standard_user) - delete :destroy, params: { id: comments(:one).id } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end - assert_equal 'success', JSON.parse(response.body)['status'] - end - - test 'should require auth to delete comment' do - delete :destroy, params: { id: comments(:one).id } - assert_response 302 - assert_redirected_to new_user_session_path - end - - test 'should allow moderator to delete comment' do - sign_in users(:moderator) - delete :destroy, params: { id: comments(:one).id } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end - assert_equal 'success', JSON.parse(response.body)['status'] - end - - test 'should not allow other users to delete comment' do - sign_in users(:editor) - delete :destroy, params: { id: comments(:one).id } - assert_response 403 - end - - test 'should restore comment' do - sign_in users(:standard_user) - patch :undelete, params: { id: comments(:one).id } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end - assert_equal 'success', JSON.parse(response.body)['status'] - end - - test 'should require auth to restore comment' do - patch :undelete, params: { id: comments(:one).id } - assert_response 302 - assert_redirected_to new_user_session_path - end - - test 'should allow moderator to restore comment' do - sign_in users(:moderator) - patch :undelete, params: { id: comments(:one).id } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end - assert_equal 'success', JSON.parse(response.body)['status'] - end - - test 'should not allow other users to restore comment' do - sign_in users(:editor) - patch :undelete, params: { id: comments(:one).id } - assert_response 403 - end - - test 'should get comment' do - get :show, params: { id: comments(:one).id } - assert_response 200 - assert_not_nil assigns(:comment) - end - - test 'should get thread' do - get :thread, params: { id: comment_threads(:normal).id } - assert_response 200 - assert_not_nil assigns(:comment_thread) - end - - test 'should require auth to access high trust thread' do - get :thread, params: { id: comment_threads(:high_trust).id } - assert_response 404 - assert_not_nil assigns(:comment_thread) - end - - test 'should require privileges to access high trust thread' do - sign_in users(:deleter) - get :thread, params: { id: comment_threads(:high_trust).id } - assert_response 404 - assert_not_nil assigns(:comment_thread) - end - - test 'should access thread on own deleted post' do - sign_in users(:closer) - get :thread, params: { id: comment_threads(:on_deleted_post).id } - assert_response 200 - assert_not_nil assigns(:comment_thread) - end - - test 'should get thread followers' do - sign_in users(:admin) - get :thread_followers, params: { id: comment_threads(:normal).id } - assert_response 200 - assert_not_nil assigns(:comment_thread) - assert_not_nil assigns(:followers) - end - - test 'should require auth to get thread followers' do - get :thread_followers, params: { id: comment_threads(:normal).id } - assert_response 302 - assert_redirected_to new_user_session_path - end - - test 'should require moderator to get thread followers' do - sign_in users(:standard_user) - get :thread_followers, params: { id: comment_threads(:normal).id } - assert_response 404 - assert_not_nil assigns(:comment_thread) - end - - test 'should rename thread' do - sign_in users(:deleter) - post :thread_rename, params: { id: comment_threads(:normal).id, title: 'new thread title' } - assert_response 302 - assert_not_nil assigns(:comment_thread) - assert_redirected_to comment_thread_path(assigns(:comment_thread)) - assert_equal 'new thread title', assigns(:comment_thread).title - end - - test 'should prevent non-moderator renaming locked thread' do - sign_in users(:deleter) - before_title = comment_threads(:locked).title - post :thread_rename, params: { id: comment_threads(:locked).id, title: 'new thread title' } - assert_response 302 - assert_not_nil assigns(:comment_thread) - assert_redirected_to comment_thread_path(assigns(:comment_thread)) - assert_equal before_title, assigns(:comment_thread).title - end - - test 'should lock thread' do - sign_in users(:deleter) - post :thread_restrict, params: { id: comment_threads(:normal).id, type: 'lock' } - assert_response 302 - assert_not_nil assigns(:comment_thread) - assert_redirected_to comment_thread_path(assigns(:comment_thread)) - assert_equal true, assigns(:comment_thread).locked - end - - test 'should require privilege to lock thread' do - sign_in users(:standard_user) - post :thread_restrict, params: { id: comment_threads(:normal).id, type: 'lock' } - assert_response 404 - assert_not_nil assigns(:comment_thread) - end - - test 'should delete thread' do - sign_in users(:deleter) - post :thread_restrict, params: { id: comment_threads(:normal).id, type: 'delete' } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end - assert_equal 'success', JSON.parse(response.body)['status'] - end - - test 'should require privilege to delete thread' do - sign_in users(:standard_user) - post :thread_restrict, params: { id: comment_threads(:normal).id, type: 'delete' } - assert_response 404 - assert_not_nil assigns(:comment_thread) - end - - test 'should archive thread' do - sign_in users(:deleter) - post :thread_restrict, params: { id: comment_threads(:normal).id, type: 'archive' } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end - assert_equal 'success', JSON.parse(response.body)['status'] - end - - test 'should require privilege to archive thread' do - sign_in users(:standard_user) - post :thread_restrict, params: { id: comment_threads(:normal).id, type: 'archive' } - assert_response 404 - assert_not_nil assigns(:comment_thread) - end - - test 'should follow thread' do - sign_in users(:standard_user) - post :thread_restrict, params: { id: comment_threads(:normal).id, type: 'follow' } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end - assert_equal 'success', JSON.parse(response.body)['status'] - end - - test 'should unlock thread' do - sign_in users(:deleter) - post :thread_unrestrict, params: { id: comment_threads(:locked).id, type: 'lock' } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end - assert_equal 'success', JSON.parse(response.body)['status'] - end - - test 'should require privilege to unlock thread' do - sign_in users(:standard_user) - post :thread_unrestrict, params: { id: comment_threads(:locked).id, type: 'lock' } - assert_response 404 - assert_not_nil assigns(:comment_thread) - end - - test 'should undelete thread' do - sign_in users(:moderator) - post :thread_unrestrict, params: { id: comment_threads(:deleted).id, type: 'delete' } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end - assert_equal 'success', JSON.parse(response.body)['status'] - end - - test 'should require privilege to undelete thread' do - sign_in users(:standard_user) - post :thread_unrestrict, params: { id: comment_threads(:deleted).id, type: 'delete' } - assert_response 404 - assert_not_nil assigns(:comment_thread) - end - - test 'should unarchive thread' do - sign_in users(:deleter) - post :thread_unrestrict, params: { id: comment_threads(:archived).id, type: 'archive' } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end - assert_equal 'success', JSON.parse(response.body)['status'] - end - - test 'should require privilege to unarchive thread' do - sign_in users(:standard_user) - post :thread_unrestrict, params: { id: comment_threads(:archived).id, type: 'archive' } - assert_response 404 - assert_not_nil assigns(:comment_thread) - end - - test 'should unfollow thread' do - sign_in users(:standard_user) - post :thread_unrestrict, params: { id: comment_threads(:normal).id, type: 'follow' } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end - assert_equal 'success', JSON.parse(response.body)['status'] - end - - test 'should get comment threads on post' do - get :post, params: { post_id: posts(:question_one).id } - assert_response 200 - assert_not_nil assigns(:post) - assert_not_nil assigns(:comment_threads) - end - - test 'should get pingable users on thread' do - sign_in users(:standard_user) - get :pingable, params: { id: -1, post: posts(:question_one).id } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end - end -end diff --git a/test/controllers/concerns/users/users_abilities_test.rb b/test/controllers/concerns/users/users_abilities_test.rb new file mode 100644 index 000000000..295e922e4 --- /dev/null +++ b/test/controllers/concerns/users/users_abilities_test.rb @@ -0,0 +1,54 @@ +module UsersAbilitiesTest + extend ActiveSupport::Concern + + included do + test 'mod_privilege_action: grant as new' do + sign_in users(:moderator) + post :mod_privilege_action, params: { ability: abilities(:flag_curate).internal_id, do: 'grant', + id: users(:standard_user).id } + assert_response(:success) + assert users(:standard_user).community_user.ability?(abilities(:flag_curate).internal_id), + "User was not granted expected ability #{abilities(:flag_curate).internal_id}" + end + + test 'mod_privilege_action: grant as unsuspend' do + sign_in users(:moderator) + post :mod_privilege_action, params: { ability: abilities(:edit_posts).internal_id, do: 'grant', + id: users(:enabled_2fa).id } + assert_response(:success) + assert users(:enabled_2fa).community_user.ability?(abilities(:edit_posts).internal_id), + "User was not granted expected ability #{abilities(:edit_posts).internal_id}" + end + + test 'mod_privilege_action: suspend' do + sign_in users(:moderator) + post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'suspend', + id: users(:standard_user).id, duration: -1 } + assert_response(:success) + assert_not users(:standard_user).community_user.ability?(abilities(:unrestricted).internal_id), + "User still has ability #{abilities(:unrestricted).internal_id} that should have been suspended" + end + + test 'mod_privilege_action: delete' do + sign_in users(:moderator) + post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'delete', + id: users(:standard_user).id } + assert_response(:success) + assert_not users(:standard_user).community_user.ability?(abilities(:unrestricted).internal_id), + "User still has ability #{abilities(:unrestricted).internal_id} that should have been deleted" + end + + test 'mod_privilege_action: unrecognized action' do + sign_in users(:moderator) + post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'unrecognized', + id: users(:standard_user).id } + assert_response(:not_found) + end + + test 'mod_privilege_action: require moderator' do + post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'unrecognized', + id: users(:standard_user).id } + assert_response(:not_found) + end + end +end diff --git a/test/controllers/donations_controller_test.rb b/test/controllers/donations_controller_test.rb index c2ed31624..21fa129a2 100644 --- a/test/controllers/donations_controller_test.rb +++ b/test/controllers/donations_controller_test.rb @@ -5,13 +5,14 @@ class DonationsControllerTest < ActionController::TestCase test 'should get index' do get :index - assert_response 200 + assert_response(:success) end test 'should create PaymentIntent' do skip unless Stripe.api_key post :intent, params: { currency: 'EUR', amount: '24.99', desc: 'Created from Rails test' } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:intent)&.id end end diff --git a/test/controllers/fake_community_controller_test.rb b/test/controllers/fake_community_controller_test.rb index dd5e089de..279f67237 100644 --- a/test/controllers/fake_community_controller_test.rb +++ b/test/controllers/fake_community_controller_test.rb @@ -8,7 +8,7 @@ class FakeCommunityControllerTest < ActionController::TestCase request.env['HTTP_HOST'] = 'sample.qpixel.com' get(:communities) - assert_response(404) + assert_response(:not_found) end test 'fake community should be able to access fake community controller' do @@ -16,7 +16,7 @@ class FakeCommunityControllerTest < ActionController::TestCase request.env['HTTP_HOST'] = 'fake.qpixel.com' get(:communities) - assert_response(200) + assert_response(:success) assert_not_nil assigns(:communities) end end diff --git a/test/controllers/flags_controller_test.rb b/test/controllers/flags_controller_test.rb index 58569e762..53b0aca80 100644 --- a/test/controllers/flags_controller_test.rb +++ b/test/controllers/flags_controller_test.rb @@ -6,83 +6,121 @@ class FlagsControllerTest < ActionController::TestCase test 'should create new post flag' do sign_in users(:standard_user) post :new, params: { reason: 'ABCDEF GHIJKL MNOPQR STUVWX YZ', post_id: posts(:answer_two).id, post_type: 'Post' } + assert_not_nil assigns(:flag) assert_not_nil assigns(:flag).post assert_equal 'success', JSON.parse(response.body)['status'] - assert_response(201) + assert_response(:created) end test 'should create new comment flag' do sign_in users(:standard_user) post :new, params: { reason: 'ABCDEF GHIJKL MNOPQR STUVWX YZ', post_id: comments(:one).id, post_type: 'Comment' } + assert_not_nil assigns(:flag) assert_not_nil assigns(:flag).post assert_equal 'success', JSON.parse(response.body)['status'] - assert_response(201) + assert_response(:created) end test 'should retrieve flag queue' do sign_in users(:moderator) get :queue assert_not_nil assigns(:flags) - assert_response(200) + assert_response(:success) end test 'should add status to flag' do sign_in users(:moderator) - post :resolve, params: { id: flags(:one).id, result: 'ABCDEF', message: 'ABCDEF GHIJKL MNOPQR STUVWX YZ' } + + try_resolve_flag(flags(:one), result: 'Helpful', message: 'Please send us more flags') + assert_not_nil assigns(:flag) assert_not_nil assigns(:flag).status assert_not_nil assigns(:flag).handled_by assert_equal 'success', JSON.parse(response.body)['status'] - assert_response(200) + assert_response(:success) end test 'should require authentication to create flag' do sign_out :user post :new - assert_response(302) + assert_response(:found) end test 'should require authentication to get queue' do sign_out :user get :queue - assert_response(302) + assert_response(:found) end test 'should require moderator status to get queue' do sign_in users(:standard_user) get :queue - assert_response(404) + assert_response(:not_found) end test 'should require authentication to resolve flag' do sign_out :user - post :resolve, params: { id: flags(:one).id } - assert_response(302) + try_resolve_flag(flags(:one)) + assert_response(:found) end test 'should require moderator status to resolve flag' do sign_in users(:standard_user) - post :resolve, params: { id: flags(:one).id } - assert_response(404) + try_resolve_flag(flags(:one)) + assert_response(:not_found) + end + + test 'should not allow non-moderator users to resolve flags on themselves' do + sign_in users(:deleter) + try_resolve_flag(flags(:on_deleter)) + assert_response(:not_found) + end + + test 'should not allow non-moderator users to resolve confidential flags' do + sign_in users(:deleter) + try_resolve_flag(flags(:confidential_on_deleter)) + assert_response(:not_found) end test 'should get handled flags list' do sign_in users(:moderator) get :handled - assert_response 200 + assert_response(:success) assert_not_nil assigns(:flags) end test 'should require authentication to get handled flags list' do get :handled - assert_response 302 + assert_response(:found) end test 'should require moderator status to get handled flags list' do sign_in users(:standard_user) get :handled - assert_response 404 + assert_response(:not_found) + end + + test 'non-moderator users should only see their flag history' do + mod_user = users(:moderator) + std_user = users(:standard_user) + + sign_in std_user + get :history, params: { id: mod_user.id } + assert_response(:not_found) + + get :history, params: { id: std_user.id } + assert_response(:success) + + sign_in mod_user + get :history, params: { id: std_user.id } + assert_response(:success) + end + + private + + def try_resolve_flag(flag, result: nil, message: nil) + post :resolve, params: { id: flag.id, result: result, message: message } end end diff --git a/test/controllers/licenses_controller_test.rb b/test/controllers/licenses_controller_test.rb index 18b20515a..830c9d663 100644 --- a/test/controllers/licenses_controller_test.rb +++ b/test/controllers/licenses_controller_test.rb @@ -6,70 +6,68 @@ class LicensesControllerTest < ActionController::TestCase test 'should require authentication to access license pages' do [:index, :new].each do |action| get action - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end get :edit, params: { id: licenses(:cc_by_sa).id } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'should require authentication to modify licenses' do post :create, params: { license: { name: 'Test', url: 'Test', default: false } } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in patch :update, params: { id: licenses(:cc_by_sa).id, license: { name: 'Test', url: 'Test', default: false } } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'should require admin to access license pages' do sign_in users(:standard_user) [:index, :new].each do |action| get action - assert_response 404 + assert_response(:not_found) end get :edit, params: { id: licenses(:cc_by_sa).id } - assert_response 404 + assert_response(:not_found) end test 'should require admin to modify licenses' do sign_in users(:standard_user) + post :create, params: { license: { name: 'Test', url: 'Test', default: false } } - assert_response 404 + assert_response(:not_found) patch :update, params: { id: licenses(:cc_by_sa).id, license: { name: 'Test', url: 'Test', default: false } } - assert_response 404 + assert_response(:not_found) end test 'should allow admins to access index' do sign_in users(:admin) get :index - assert_response 200 + assert_response(:success) assert_not_nil assigns(:licenses) end test 'should allow admins to access new' do sign_in users(:admin) get :new - assert_response 200 + assert_response(:success) assert_not_nil assigns(:license) end test 'should allow admins to access edit' do sign_in users(:admin) get :edit, params: { id: licenses(:cc_by_sa).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:license) end test 'should allow admins to create new licenses' do sign_in users(:admin) post :create, params: { license: { name: 'Test', url: 'Test', default: false } } - assert_response 302 + + assert_response(:found) assert_redirected_to licenses_path assert_not_nil assigns(:license) assert_not_nil assigns(:license).id @@ -79,7 +77,8 @@ class LicensesControllerTest < ActionController::TestCase test 'should allow admins to update existing license' do sign_in users(:admin) patch :update, params: { id: licenses(:cc_by_sa).id, license: { name: 'Test', url: 'Test', default: false } } - assert_response 302 + + assert_response(:found) assert_redirected_to licenses_path assert_not_nil assigns(:license) assert_equal 'Test', assigns(:license).name @@ -88,7 +87,8 @@ class LicensesControllerTest < ActionController::TestCase test 'should allow admins to disable not-in-use license' do sign_in users(:admin) post :toggle, params: { id: licenses(:not_in_use).id } - assert_response 302 + + assert_response(:found) assert_redirected_to licenses_path assert_nil flash[:danger] assert_not_nil assigns(:license) @@ -98,7 +98,8 @@ class LicensesControllerTest < ActionController::TestCase test 'should prevent admins disabling in-use license' do sign_in users(:admin) post :toggle, params: { id: licenses(:cc_by_sa).id } - assert_response 302 + + assert_response(:found) assert_redirected_to licenses_path assert_not_nil assigns(:license) assert_not_nil flash[:danger] @@ -108,7 +109,8 @@ class LicensesControllerTest < ActionController::TestCase test 'should only allow one default license' do sign_in users(:admin) post :update, params: { id: licenses(:cc_by_nc_sa).id, license: { name: 'Test', url: 'Test', default: true } } - assert_response 302 + + assert_response(:found) assert_redirected_to licenses_path assert_not_nil assigns(:license) assert_equal true, assigns(:license).default diff --git a/test/controllers/micro_auth/apps_controller_test.rb b/test/controllers/micro_auth/apps_controller_test.rb index 781c55df3..df7ea7e2b 100644 --- a/test/controllers/micro_auth/apps_controller_test.rb +++ b/test/controllers/micro_auth/apps_controller_test.rb @@ -1,7 +1,57 @@ require 'test_helper' class MicroAuth::AppsControllerTest < ActionDispatch::IntegrationTest - # test "the truth" do - # assert true - # end + include Devise::Test::IntegrationHelpers + + test 'should require authentication to create apps' do + post create_oauth_app_path + + assert_response(:found) + assert_redirected_to new_user_session_path + end + + test 'should correctly create apps' do + sign_in users(:standard_user) + + try_create_oauth_app + + assert_response(:found) + + @app = assigns(:app) + assert_not_nil @app + assert_redirected_to oauth_app_path(@app.app_id) + end + + test 'only owners or admins should be able to update apps' do + app = micro_auth_apps(:owned_by_standard) + + [:standard_user, :global_admin, :editor].each do |name| + updater = users(name) + + sign_in updater + + post update_oauth_app_path(app.app_id), params: { + micro_auth_app: { name: 'Updated name' } + } + + if app.user.same_as?(updater) || updater.admin? + assert_response(:found, "Expected #{updater.username} to be able to update the app") + assert_redirected_to oauth_app_path(app.app_id) + else + assert_response(:not_found) + end + end + end + + private + + def try_create_oauth_app(name: 'MyApp', description: 'test MicroAuth App', auth_domain: 'localhost') + post create_oauth_app_path, params: { + micro_auth_app: { + auth_domain: auth_domain, + name: name, + description: description + } + } + end end diff --git a/test/controllers/micro_auth/authentication_controller_test.rb b/test/controllers/micro_auth/authentication_controller_test.rb index 78ab8cf06..27283f869 100644 --- a/test/controllers/micro_auth/authentication_controller_test.rb +++ b/test/controllers/micro_auth/authentication_controller_test.rb @@ -1,7 +1,38 @@ require 'test_helper' class MicroAuth::AuthenticationControllerTest < ActionDispatch::IntegrationTest - # test "the truth" do - # assert true - # end + include Devise::Test::IntegrationHelpers + + test 'token should correctly handle missing apps' do + post oauth_token_path, params: { + app_id: 'i_do_not_exist', + secret: SecureRandom.base58(32), + format: :json + } + + assert_response(:bad_request) + assert_valid_json_response + res_body = JSON.parse(response.body) + error = res_body['error'] + assert_equal 'app_mismatch', error['type'] + assert_not_nil error['message'] + end + + test 'token should correctly handle missing tokens' do + app = micro_auth_apps(:owned_by_standard) + + post oauth_token_path, params: { + app_id: app.app_id, + secret: app.secret_key, + code: 'this_is_not_the_code_you_are_looking_for', + format: :json + } + + assert_response(:bad_request) + assert_valid_json_response + res_body = JSON.parse(response.body) + error = res_body['error'] + assert_equal 'token_mismatch', error['type'] + assert_not_nil error['message'] + end end diff --git a/test/controllers/mod_warning_controller_test.rb b/test/controllers/mod_warning_controller_test.rb index e2a0c2a10..edbcfda1d 100644 --- a/test/controllers/mod_warning_controller_test.rb +++ b/test/controllers/mod_warning_controller_test.rb @@ -7,7 +7,7 @@ class ModWarningControllerTest < ActionController::TestCase sign_out :user [:log, :new].each do |path| get path, params: { user_id: users(:standard_user).id } - assert_response(404) + assert_response(:not_found) end end @@ -15,7 +15,18 @@ class ModWarningControllerTest < ActionController::TestCase sign_in users(:standard_user) [:log, :new].each do |path| get path, params: { user_id: users(:standard_user).id } - assert_response(404) + assert_response(:not_found) + end + end + + test 'mods or admins should be able to access pages' do + [users(:moderator), users(:admin)].each do |user| + sign_in user + + [:log, :new].each do |path| + get path, params: { user_id: users(:standard_user).id } + assert_response(:success) + end end end @@ -58,4 +69,18 @@ class ModWarningControllerTest < ActionController::TestCase @warning.reload assert_not @warning.active end + + test 'lift should correctly deactivate user suspensions' do + sign_in users(:moderator) + + std = users(:standard_user) + warning = mod_warnings(:third_warning) + + warning.update(active: true) + post :lift, params: { user_id: std.id } + + assert_response(:found) + warning.reload + assert_not warning.active + end end diff --git a/test/controllers/moderator_controller_test.rb b/test/controllers/moderator_controller_test.rb index c425defa4..062626b51 100644 --- a/test/controllers/moderator_controller_test.rb +++ b/test/controllers/moderator_controller_test.rb @@ -6,14 +6,14 @@ class ModeratorControllerTest < ActionController::TestCase test 'should get index' do sign_in users(:moderator) get :index - assert_response(200) + assert_response(:success) end test 'should require authentication to access pages' do sign_out :user [:index, :recently_deleted_posts].each do |path| get path - assert_response(404) + assert_response(:not_found) end end @@ -21,119 +21,190 @@ class ModeratorControllerTest < ActionController::TestCase sign_in users(:standard_user) [:index, :recently_deleted_posts].each do |path| get path - assert_response(404) + assert_response(:not_found) end end + # TODO: more descriptive test case descriptions + test 'should get recently deleted posts page' do + sign_in users(:moderator) + get :recently_deleted_posts + + posts = assigns(:posts) + + assert_response(:success) + assert_not_nil posts + assert posts.all?(&:deleted?) + end + test 'should get recent comments page' do sign_in users(:moderator) get :recent_comments - assert_response 200 + assert_response(:success) assert_not_nil assigns(:comments) end test 'can nominate for promotion' do sign_in users(:deleter) post :nominate_promotion, params: { id: posts(:question_one).id } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:success) + assert_valid_json_response assert_equal 'success', JSON.parse(response.body)['status'] end test 'cannot nominate locked post' do sign_in users(:deleter) post :nominate_promotion, params: { id: posts(:locked).id, format: :json } - assert_response 403 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:forbidden) + assert_valid_json_response assert_equal 'failed', JSON.parse(response.body)['status'] end test 'nominate requires authentication' do post :nominate_promotion, params: { id: posts(:question_one).id } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'unprivileged user cannot nominate' do sign_in users(:standard_user) post :nominate_promotion, params: { id: posts(:question_one).id, format: :json } - assert_response 404 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:not_found) + assert_valid_json_response assert_equal ['no_privilege'], JSON.parse(response.body)['errors'] end test 'cannot nominate second-level post' do sign_in users(:deleter) post :nominate_promotion, params: { id: posts(:answer_one).id, format: :json } - assert_response 404 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:not_found) + assert_valid_json_response assert_equal ['unavailable_for_type'], JSON.parse(response.body)['errors'] end test 'can get promotions list' do sign_in users(:deleter) get :promotions - assert_response 200 + assert_response(:success) assert_not_nil assigns(:promotions) assert_not_nil assigns(:posts) end test 'promotions list requires auth' do get :promotions - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'promotions list requires privileges' do sign_in users(:standard_user) get :promotions - assert_response 404 + assert_response(:not_found) end test 'can remove a post from promotions' do - RequestContext.redis.set 'network/promoted_posts', - JSON.dump({ posts(:question_one).id.to_s => 28.days.from_now.to_i }) + RequestContext.redis.set('network/promoted_posts', + JSON.dump({ posts(:question_one).id.to_s => 28.days.from_now.to_i })) sign_in users(:deleter) delete :remove_promotion, params: { id: posts(:question_one).id } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:success) + assert_valid_json_response assert_equal 'success', JSON.parse(response.body)['status'] assert_equal '{}', RequestContext.redis.get('network/promoted_posts') end test 'remove promotion requires auth' do delete :remove_promotion, params: { id: posts(:question_one).id } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'remove promotion requires privileges' do sign_in users(:standard_user) + delete :remove_promotion, params: { id: posts(:question_one).id, format: :json } - assert_response 404 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:not_found) + assert_valid_json_response assert_equal ['no_privilege'], JSON.parse(response.body)['errors'] end test 'cannot remove unpromoted post' do sign_in users(:deleter) + delete :remove_promotion, params: { id: posts(:question_two).id, format: :json } - assert_response 404 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:not_found) + assert_valid_json_response assert_equal ['not_promoted'], JSON.parse(response.body)['errors'] end + + test 'user_vote_summary should provide correct user info' do + std = users(:standard_user) + + sign_in users(:moderator) + + get :user_vote_summary, params: { id: std.id } + + related_votes = votes.select { |v| v.user.same_as?(std) || v.recv_user.same_as?(std) } + user = assigns(:user) + users = assigns(:users) + + assert_response(:success) + assert user.same_as?(std) + + related_votes.each do |v| + assert(users.any? { |u| u.same_as?(v.user) || u.same_as?(v.recv_user) }) + end + end + + test 'user_vote_summary should provide correct stats for votes cast' do + std = users(:standard_user) + + sign_in users(:moderator) + + get :user_vote_summary, params: { id: std.id } + + vote_data = assigns(:vote_data) + votes_cast = votes.select { |v| v.user.same_as?(std) } + + assert_equal vote_data[:cast][:total], votes_cast.length + + vote_data[:cast][:breakdown].each do |data, count| + recv_user_id, type = data + votes = votes_cast.select { |v| v.vote_type == type && v.recv_user.id == recv_user_id } + assert_equal count, votes.length + end + + vote_data[:cast][:types].each do |type, count| + votes = votes_cast.select { |v| v.vote_type == type } + assert_equal count, votes.length + end + end + + test 'user_vote_summary should provide correct stats for votes received' do + std = users(:standard_user) + + sign_in users(:moderator) + + get :user_vote_summary, params: { id: std.id } + + vote_data = assigns(:vote_data) + votes_received = votes.select { |v| v.recv_user.same_as?(std) } + + assert_equal vote_data[:received][:total], votes_received.length + + vote_data[:received][:breakdown].each do |data, count| + user_id, type = data + votes = votes_received.select { |v| v.vote_type == type && v.user.id == user_id } + assert_equal count, votes.length + end + + vote_data[:received][:types].each do |type, count| + votes = votes_received.select { |v| v.vote_type == type } + assert_equal count, votes.length + end + end end diff --git a/test/controllers/notifications_controller_test.rb b/test/controllers/notifications_controller_test.rb index 6d4596080..1b7b570cb 100644 --- a/test/controllers/notifications_controller_test.rb +++ b/test/controllers/notifications_controller_test.rb @@ -7,7 +7,7 @@ class NotificationsControllerTest < ActionController::TestCase sign_in users(:standard_user) get :index, params: { format: :json } assert_not_nil assigns(:notifications) - assert_response(200) + assert_response(:success) end test 'should mark notification as read' do @@ -16,7 +16,7 @@ class NotificationsControllerTest < ActionController::TestCase assert_not_nil assigns(:notification) assert_equal true, assigns(:notification).is_read assert_equal 'success', JSON.parse(response.body)['status'] - assert_response(200) + assert_response(:success) end test 'should mark all notifications as read' do @@ -27,18 +27,18 @@ class NotificationsControllerTest < ActionController::TestCase assert_equal true, notification.is_read end assert_equal 'success', JSON.parse(response.body)['status'] - assert_response(200) + assert_response(:success) end test 'should prevent users marking others notifications read' do sign_in users(:editor) post :read, params: { id: notifications(:one).id, format: :json } - assert_response(403) + assert_response(:forbidden) end test 'should require authentication to get index' do sign_out :user get :index, params: { format: :json } - assert_response(401) # Devise seems to respond 401 for JSON requests. + assert_response(:unauthorized) # Devise seems to respond 401 for JSON requests. end end diff --git a/test/controllers/pinned_links_controller_test.rb b/test/controllers/pinned_links_controller_test.rb index b7cb3d3d1..2003eafbe 100644 --- a/test/controllers/pinned_links_controller_test.rb +++ b/test/controllers/pinned_links_controller_test.rb @@ -1,7 +1,33 @@ require 'test_helper' -class PinnedLinksControllerTest < ActionDispatch::IntegrationTest - # test "the truth" do - # assert true - # end +class PinnedLinksControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + + test 'edit should require moderator' do + sign_in users(:standard_user) + get :edit, params: { id: pinned_links(:active_with_label).id } + assert_response(:not_found) + end + + test 'edit should work for moderators' do + sign_in users(:moderator) + get :edit, params: { id: pinned_links(:active_with_label).id } + assert_response(:success) + assert_not_nil assigns(:link) + end + + test 'update should require moderator' do + sign_in users(:standard_user) + post :update, params: { id: pinned_links(:active_with_label).id, pinned_link: { label: 'updated label' } } + assert_response(:not_found) + end + + test 'update should work for moderators' do + sign_in users(:moderator) + post :update, params: { id: pinned_links(:active_with_label).id, pinned_link: { label: 'updated label' } } + assert_response(:found) + assert_redirected_to pinned_links_path + assert_not_nil assigns(:link) + assert_equal 'updated label', assigns(:link).label + end end diff --git a/test/controllers/post_history_controller_test.rb b/test/controllers/post_history_controller_test.rb index c86257132..636ac45a0 100644 --- a/test/controllers/post_history_controller_test.rb +++ b/test/controllers/post_history_controller_test.rb @@ -5,27 +5,27 @@ class PostHistoryControllerTest < ActionController::TestCase test 'should get post history page' do get :post, params: { id: posts(:question_one).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:history) assert_not_nil assigns(:post) end test 'anon user can access public post history' do get :post, params: { id: posts(:question_one).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:history) assert_not_nil assigns(:post) end test 'anon user cannot access deleted post history' do get :post, params: { id: posts(:deleted).id } - assert_response 404 + assert_response(:not_found) end test 'privileged user can access deleted post history' do sign_in users(:deleter) get :post, params: { id: posts(:deleted).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:history) assert_not_nil assigns(:post) end diff --git a/test/controllers/post_types_controller_test.rb b/test/controllers/post_types_controller_test.rb index af1c033d3..363c0b1a7 100644 --- a/test/controllers/post_types_controller_test.rb +++ b/test/controllers/post_types_controller_test.rb @@ -6,39 +6,37 @@ class PostTypesControllerTest < ActionController::TestCase test 'can get index' do sign_in users(:global_admin) get :index - assert_response 200 + assert_response(:success) assert_not_nil assigns(:types) end test 'index requires auth' do get :index - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'index requires global admin' do sign_in users(:admin) get :index - assert_response 404 + assert_response(:not_found) end test 'can get new' do sign_in users(:global_admin) get :new - assert_response 200 + assert_response(:success) assert_not_nil assigns(:type) end test 'new requires auth' do get :new - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'new requires global admin' do sign_in users(:admin) get :new - assert_response 404 + assert_response(:not_found) end test 'can create post type' do @@ -48,7 +46,7 @@ class PostTypesControllerTest < ActionController::TestCase answer_type_id: Answer.post_type_id, has_reactions: false, has_only_specific_reactions: true } post :create, params: { post_type: data } - assert_response 302 + assert_response(:found) assert_redirected_to post_types_path # Test, if the correct values are applied @@ -61,34 +59,32 @@ class PostTypesControllerTest < ActionController::TestCase test 'create requires auth' do post :create, params: { post_type: { name: 'Test Type', description: 'words', icon_name: 'heart', has_answers: 'true', has_license: 'true', has_category: 'true' } } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'create requires global admin' do sign_in users(:admin) post :create, params: { post_type: { name: 'Test Type', description: 'words', icon_name: 'heart', has_answers: 'true', has_license: 'true', has_category: 'true' } } - assert_response 404 + assert_response(:not_found) end test 'can get edit' do sign_in users(:global_admin) get :edit, params: { id: post_types(:question).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:type) end test 'edit requires auth' do get :edit, params: { id: post_types(:question).id } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'edit requires global admin' do sign_in users(:admin) get :edit, params: { id: post_types(:question).id } - assert_response 404 + assert_response(:not_found) end test 'can update post type' do @@ -99,7 +95,7 @@ class PostTypesControllerTest < ActionController::TestCase has_only_specific_reactions: true } patch :update, params: { post_type: data, id: post_types(:question).id } - assert_response 302 + assert_response(:found) assert_redirected_to post_types_path # Test, if the correct values are applied @@ -113,8 +109,7 @@ class PostTypesControllerTest < ActionController::TestCase patch :update, params: { post_type: { name: 'Test Type', description: 'words', icon_name: 'heart', has_answers: 'true', has_license: 'true', has_category: 'true' }, id: post_types(:question).id } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'update requires global admin' do @@ -122,6 +117,6 @@ class PostTypesControllerTest < ActionController::TestCase patch :update, params: { post_type: { name: 'Test Type', description: 'words', icon_name: 'heart', has_answers: 'true', has_license: 'true', has_category: 'true' }, id: post_types(:question).id } - assert_response 404 + assert_response(:not_found) end end diff --git a/test/controllers/posts/change_category_test.rb b/test/controllers/posts/change_category_test.rb index acb546eef..5b8022a8f 100644 --- a/test/controllers/posts/change_category_test.rb +++ b/test/controllers/posts/change_category_test.rb @@ -5,33 +5,33 @@ class PostsControllerTest < ActionController::TestCase test 'should change category' do sign_in users(:deleter) + post :change_category, params: { id: posts(:article_one).id, target_id: categories(:articles_only).id } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:post) assert_not_nil assigns(:target) - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal categories(:articles_only).id, assigns(:post).category_id end test 'should deny change category to unprivileged' do sign_in users(:standard_user) + post :change_category, params: { id: posts(:article_one).id, target_id: categories(:articles_only).id } - assert_response 403 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:forbidden) + assert_valid_json_response assert_equal ["You don't have permission to make that change.\n"], JSON.parse(response.body)['errors'] end test 'should refuse to change category of wrong post type' do sign_in users(:deleter) + post :change_category, params: { id: posts(:question_one).id, target_id: categories(:articles_only).id } - assert_response 409 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:conflict) + assert_valid_json_response assert_equal ["This post type is not allowed in the #{categories(:articles_only).name} category.\n"], JSON.parse(response.body)['errors'] end diff --git a/test/controllers/posts/close_test.rb b/test/controllers/posts/close_test.rb index 1c69c5a41..0b5545058 100644 --- a/test/controllers/posts/close_test.rb +++ b/test/controllers/posts/close_test.rb @@ -5,100 +5,101 @@ class PostsControllerTest < ActionController::TestCase test 'can close question' do sign_in users(:closer) + before_history = PostHistory.where(post: posts(:question_one)).count post :close, params: { id: posts(:question_one).id, reason_id: close_reasons(:not_good).id } after_history = PostHistory.where(post: posts(:question_one)).count - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:post) assert_equal before_history + 1, after_history, 'PostHistory event not created on closure' - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'success', JSON.parse(response.body)['status'] end test 'user can close own question' do sign_in users(:standard_user) + before_history = PostHistory.where(post: posts(:question_one)).count post :close, params: { id: posts(:question_one).id, reason_id: close_reasons(:not_good).id } after_history = PostHistory.where(post: posts(:question_one)).count - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:post) assert_equal before_history + 1, after_history, 'PostHistory event not created on closure' - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'success', JSON.parse(response.body)['status'] end test 'close requires authentication' do post :close, params: { id: posts(:question_one).id, reason_id: close_reasons(:not_good).id } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'unprivileged user cannot close' do sign_in users(:standard_user) + before_history = PostHistory.where(post: posts(:question_two)).count post :close, params: { id: posts(:question_two).id, reason_id: close_reasons(:not_good).id } after_history = PostHistory.where(post: posts(:question_two)).count - assert_response 403 + + assert_response(:forbidden) assert_not_nil assigns(:post) assert_equal before_history, after_history, 'PostHistory event incorrectly created on closure' - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'failed', JSON.parse(response.body)['status'] end test 'cannot close a closed post' do sign_in users(:closer) + before_history = PostHistory.where(post: posts(:closed)).count post :close, params: { id: posts(:closed).id, reason_id: close_reasons(:not_good).id } after_history = PostHistory.where(post: posts(:closed)).count - assert_response 400 + + assert_response(:bad_request) assert_not_nil assigns(:post) assert_equal before_history, after_history, 'PostHistory event incorrectly created on closure' - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'failed', JSON.parse(response.body)['status'] end test 'close rejects nonexistent close reason' do sign_in users(:closer) + before_history = PostHistory.where(post: posts(:question_one)).count post :close, params: { id: posts(:question_one).id, reason_id: -999 } after_history = PostHistory.where(post: posts(:question_one)).count - assert_response 404 + + assert_response(:not_found) assert_not_nil assigns(:post) assert_equal before_history, after_history, 'PostHistory event incorrectly created on closure' - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'failed', JSON.parse(response.body)['status'] end test 'close ensures other post exists if reason requires it' do sign_in users(:closer) + before_history = PostHistory.where(post: posts(:question_one)).count post :close, params: { id: posts(:question_one).id, reason_id: close_reasons(:duplicate) } after_history = PostHistory.where(post: posts(:question_one)).count - assert_response 400 + + assert_response(:bad_request) assert_not_nil assigns(:post) assert_equal before_history, after_history, 'PostHistory event incorrectly created on closure' - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'failed', JSON.parse(response.body)['status'] end test 'cannot close a locked post' do sign_in users(:closer) + before_history = PostHistory.where(post: posts(:locked)).count post :close, params: { id: posts(:locked).id, reason_id: close_reasons(:not_good).id } after_history = PostHistory.where(post: posts(:locked)).count - assert_response 403 + + assert_response(:forbidden) assert_equal before_history, after_history, 'PostHistory event incorrectly created on close' end end diff --git a/test/controllers/posts/create_test.rb b/test/controllers/posts/create_test.rb index e955cfa4c..255f50742 100644 --- a/test/controllers/posts/create_test.rb +++ b/test/controllers/posts/create_test.rb @@ -3,72 +3,72 @@ class PostsControllerTest < ActionController::TestCase include Devise::Test::ControllerHelpers - test 'can create help post' do - sign_in users(:moderator) - post :create, params: { post_type: post_types(:help_doc).id, - post: { post_type_id: post_types(:help_doc).id, title: sample.title, doc_slug: 'topic', - body_markdown: sample.body_markdown, help_category: 'A', help_ordering: '99' } } - assert_response 302 - assert_not_nil assigns(:post).id - assert_redirected_to help_path(assigns(:post).doc_slug) - end - test 'can create category post' do sign_in users(:standard_user) - post :create, params: { post_type: post_types(:question).id, category: categories(:main).id, - post: { post_type_id: post_types(:question).id, title: sample.title, - body_markdown: sample.body_markdown, category_id: categories(:main).id, - tags_cache: sample.tags_cache, license_id: licenses(:cc_by_sa).id } } - assert_response 302 + + try_create_post + + assert_response(:found) assert_not_nil assigns(:post).id assert_redirected_to post_path(assigns(:post)) end test 'can create answer' do sign_in users(:closer) + before_notifs = posts(:question_one).user.notifications.count - post :create, params: { post_type: post_types(:answer).id, parent: posts(:question_one).id, - post: { post_type_id: post_types(:answer).id, title: sample.title, - body_markdown: sample.body_markdown, parent_id: posts(:question_one).id, - license_id: licenses(:cc_by_sa).id } } + try_create_post(post_type: post_types(:answer), parent: posts(:question_one)) after_notifs = posts(:question_one).user.notifications.count - assert_response 302 + + assert_response(:found) assert_not_nil assigns(:post).id assert_equal before_notifs + 1, after_notifs, 'Notification not created on answer create' assert_redirected_to post_path(posts(:question_one).id, anchor: "answer-#{assigns(:post).id}") end test 'create requires authentication' do - post :create, params: { post_type: post_types(:question).id, category: categories(:main).id, - post: { post_type_id: post_types(:question).id, title: sample.title, - body_markdown: sample.body_markdown, category_id: categories(:main).id, - tags_cache: sample.tags_cache } } - assert_response 302 - assert_redirected_to new_user_session_path + try_create_post + assert_redirected_to_sign_in + end + + test 'can create help post' do + sign_in users(:moderator) + + post :create, params: { post_type: post_types(:help_doc).id, + post: { post_type_id: post_types(:help_doc).id, title: sample.title, doc_slug: 'topic', + body_markdown: sample.body_markdown, help_category: 'A', help_ordering: '99' } } + + assert_response(:found) + assert_not_nil assigns(:post).id + assert_redirected_to help_path(assigns(:post).doc_slug) end test 'standard users cannot create help posts' do sign_in users(:standard_user) + post :create, params: { post_type: post_types(:help_doc).id, post: { post_type_id: post_types(:help_doc).id, title: sample.title, doc_slug: 'topic', body_markdown: sample.body_markdown, help_category: 'A', help_ordering: '99' } } - assert_response 404 + + assert_response(:not_found) end test 'moderators cannot create policy posts' do sign_in users(:moderator) + post :create, params: { post_type: post_types(:policy_doc).id, post: { post_type_id: post_types(:policy_doc).id, title: sample.title, doc_slug: 'topic', body_markdown: sample.body_markdown, help_category: 'A', help_ordering: '99' } } - assert_response 404 + + assert_response(:not_found) end test 'category post type rejects without category' do sign_in users(:standard_user) - post :create, params: { post_type: post_types(:question).id, - post: { post_type_id: post_types(:question).id, title: sample.title, - body_markdown: sample.body_markdown, tags_cache: sample.tags_cache } } - assert_response 302 + + try_create_post(category: nil) + + assert_response(:found) assert_redirected_to root_path assert_not_nil flash[:danger] assert_nil assigns(:post).id @@ -76,21 +76,20 @@ class PostsControllerTest < ActionController::TestCase test 'category post type checks required trust level' do sign_in users(:standard_user) - post :create, params: { post_type: post_types(:question).id, category: categories(:high_trust).id, - post: { post_type_id: post_types(:question).id, title: sample.title, - body_markdown: sample.body_markdown, category_id: categories(:high_trust).id, - tags_cache: sample.tags_cache } } - assert_response 403 + + try_create_post(category: categories(:high_trust)) + + assert_response(:forbidden) assert_nil assigns(:post).id assert_not_empty assigns(:post).errors.full_messages end test 'parented post type rejects without parent' do sign_in users(:standard_user) - post :create, params: { post_type: post_types(:answer).id, - post: { post_type_id: post_types(:answer).id, title: sample.title, - body_markdown: sample.body_markdown } } - assert_response 302 + + try_create_post(post_type: post_types(:answer)) + + assert_response(:found) assert_redirected_to root_path assert_not_nil flash[:danger] assert_nil assigns(:post).id @@ -101,32 +100,44 @@ class PostsControllerTest < ActionController::TestCase before = CommunityUser.where(user: user, community: communities(:sample)).count sign_in user - post :create, params: { post_type: post_types(:question).id, category: categories(:main).id, - post: { post_type_id: post_types(:question).id, title: sample.title, - body_markdown: sample.body_markdown, category_id: categories(:main).id, - tags_cache: sample.tags_cache } } + try_create_post after = CommunityUser.where(user: user, community: communities(:sample)).count assert_equal before + 1, after, 'No CommunityUser record was created' end - test 'should prevent deleted account creating post' do + test 'should prevent deleted accounts from creating posts' do sign_in users(:deleted_account) - post :create, params: { post_type: post_types(:question).id, category: categories(:main).id, - post: { post_type_id: post_types(:question).id, title: sample.title, - body_markdown: sample.body_markdown, category_id: categories(:main).id, - tags_cache: sample.tags_cache } } - assert_response 302 - assert_redirected_to new_user_session_path + try_create_post + assert_redirected_to_sign_in end - test 'should prevent deleted profile creating post' do + test 'should prevent deleted profiles from creating posts' do sign_in users(:deleted_profile) - post :create, params: { post_type: post_types(:question).id, category: categories(:main).id, - post: { post_type_id: post_types(:question).id, title: sample.title, - body_markdown: sample.body_markdown, category_id: categories(:main).id, - tags_cache: sample.tags_cache } } - assert_response 302 - assert_redirected_to new_user_session_path + try_create_post + assert_redirected_to_sign_in + end + + private + + # Attempts to create a post + # @param post_type [PostType] + # @param category [Category, nil] + # @param parent [Post, nil] + # @param license [String] + def try_create_post(post_type: post_types(:question), + category: categories(:main), + parent: nil, + license: licenses(:cc_by_sa)) + post :create, params: { post_type: post_type.id, + parent: parent&.id, + category: category&.id, + post: { post_type_id: post_type.id, + title: sample.title, + body_markdown: sample.body_markdown, + category_id: category&.id, + parent_id: parent&.id, + tags_cache: sample.tags_cache, + license_id: license.id } } end end diff --git a/test/controllers/posts/delete_test.rb b/test/controllers/posts/delete_test.rb index 072289c50..9ec9144bc 100644 --- a/test/controllers/posts/delete_test.rb +++ b/test/controllers/posts/delete_test.rb @@ -5,10 +5,12 @@ class PostsControllerTest < ActionController::TestCase test 'can delete post' do sign_in users(:deleter) + before_history = PostHistory.where(post: posts(:question_two)).count post :delete, params: { id: posts(:question_two).id } after_history = PostHistory.where(post: posts(:question_two)).count - assert_response 302 + + assert_response(:found) assert_redirected_to post_path(assigns(:post)) assert_nil flash[:danger] assert_equal before_history + 1, after_history, 'PostHistory event not created on deletion' @@ -16,16 +18,17 @@ class PostsControllerTest < ActionController::TestCase test 'delete requires authentication' do post :delete, params: { id: posts(:question_one).id } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'unprivileged user cannot delete' do sign_in users(:closer) + before_history = PostHistory.where(post: posts(:question_one)).count post :delete, params: { id: posts(:question_one).id } after_history = PostHistory.where(post: posts(:question_one)).count - assert_response 302 + + assert_response(:found) assert_redirected_to post_path(assigns(:post)) assert_not_nil flash[:danger] assert_equal before_history, after_history, 'PostHistory event incorrectly created on deletion' @@ -33,10 +36,12 @@ class PostsControllerTest < ActionController::TestCase test 'cannot delete a post with responses' do sign_in users(:deleter) + before_history = PostHistory.where(post: posts(:question_one)).count post :delete, params: { id: posts(:question_one).id } after_history = PostHistory.where(post: posts(:question_one)).count - assert_response 302 + + assert_response(:found) assert_redirected_to post_path(assigns(:post)) assert_not_nil flash[:danger] assert_equal before_history, after_history, 'PostHistory event incorrectly created on deletion' @@ -44,10 +49,12 @@ class PostsControllerTest < ActionController::TestCase test 'cannot delete a deleted post' do sign_in users(:deleter) + before_history = PostHistory.where(post: posts(:deleted)).count post :delete, params: { id: posts(:deleted).id } after_history = PostHistory.where(post: posts(:deleted)).count - assert_response 302 + + assert_response(:found) assert_redirected_to post_path(assigns(:post)) assert_not_nil flash[:danger] assert_equal before_history, after_history, 'PostHistory event incorrectly created on deletion' @@ -55,23 +62,73 @@ class PostsControllerTest < ActionController::TestCase test 'cannot delete a locked post' do sign_in users(:deleter) + before_history = PostHistory.where(post: posts(:locked)).count post :delete, params: { id: posts(:locked).id } after_history = PostHistory.where(post: posts(:locked)).count - assert_response 403 + + assert_response(:forbidden) assert_equal before_history, after_history, 'PostHistory event incorrectly created on deletion' end test 'delete ensures all children are deleted' do - sign_in users(:deleter) - before_history = PostHistory.where(post_id: posts(:bad_answers).children.map(&:id)).count - post :delete, params: { id: posts(:bad_answers).id } - after_history = PostHistory.where(post_id: posts(:bad_answers).children.map(&:id)).count - assert_response 302 - assert_redirected_to post_path(assigns(:post)) + parent = posts(:question_one) + + sign_in users(:moderator) + + assert_not_equal(0, parent.children.undeleted.count, 'Expected post to have undeleted children') + + before_history = PostHistory.where(post_id: parent.children.map(&:id)).count + post :delete, params: { id: parent.id } + after_history = PostHistory.where(post_id: parent.children.map(&:id)).count + + @post = assigns(:post) + @post.reload + + assert_response(:found) + assert_redirected_to post_path(@post) assert_nil flash[:danger] - assert assigns(:post).children.all?(&:deleted), 'Answers not deleted with question' - assert_equal before_history + posts(:bad_answers).children.count, after_history, - 'Answer PostHistory events not created on question deletion' + assert @post.children.all?(&:deleted), 'Expected all post children to be deleted as well' + assert_equal before_history + parent.children.count, after_history, + 'Expected deletion history events to be created for every child' + end + + test 'delete should be an atomic operation' do + parent = posts(:question_one) + user = users(:moderator) + + sign_in user + + assert_not_equal(0, parent.children.undeleted.count, 'Expected post to have undeleted children') + + old_parent_events_count = PostHistory.where(post: parent).count + old_children_events_count = PostHistory.where(post: parent.children) + + assert_delete_atomic = lambda do |check_flash: true| + post :delete, params: { id: parent.id } + parent.reload + + if check_flash + assert_not_nil(flash[:danger]) + end + + assert_not_equal(0, parent.children.undeleted.count) + assert_equal old_parent_events_count, PostHistory.where(post: parent).count + assert_equal old_children_events_count, PostHistory.where(post: parent.children) + end + + @controller.stub(:do_delete, false) { assert_delete_atomic.call } + @controller.stub(:do_delete_children, false) { assert_delete_atomic.call } + + failed_history = PostHistory.new( + community: parent.community, + post: parent, + post_history_type: PostHistoryType.find_by(name: 'post_deleted'), + user: user + ) + + failed_history.errors.add(:test, 'this is only to make deletion fial') + + PostHistory.stub(:post_deleted, failed_history) { assert_delete_atomic.call(check_flash: false) } end end diff --git a/test/controllers/posts/drafts_test.rb b/test/controllers/posts/drafts_test.rb index bc7c9603b..76ff65161 100644 --- a/test/controllers/posts/drafts_test.rb +++ b/test/controllers/posts/drafts_test.rb @@ -15,10 +15,9 @@ class PostsControllerTest < ActionController::TestCase tags: ['tag1', 'tag2'], title: 'test_title' } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:success) + assert_valid_json_response base_key = JSON.parse(response.body)['key'] @@ -35,6 +34,6 @@ class PostsControllerTest < ActionController::TestCase test 'can delete draft' do sign_in users(:standard_user) post :delete_draft, params: { path: 'test' } - assert_response 200 + assert_response(:success) end end diff --git a/test/controllers/posts/edit_test.rb b/test/controllers/posts/edit_test.rb index bc36d2f55..058b4a7b9 100644 --- a/test/controllers/posts/edit_test.rb +++ b/test/controllers/posts/edit_test.rb @@ -6,26 +6,25 @@ class PostsControllerTest < ActionController::TestCase test 'can get edit' do sign_in users(:standard_user) get :edit, params: { id: posts(:question_one).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:post) end test 'edit requires authentication' do get :edit, params: { id: posts(:question_one).id } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'cannot edit locked post' do sign_in users(:standard_user) get :edit, params: { id: posts(:locked).id } - assert_response 403 + assert_response(:forbidden) end test 'cannot edit non-public post without permissions' do sign_in users(:standard_user) get :edit, params: { id: posts(:blog_post).id } - assert_response 302 + assert_response(:found) assert_redirected_to root_path assert_not_nil flash[:danger] end @@ -33,14 +32,14 @@ class PostsControllerTest < ActionController::TestCase test 'author can edit non-public post' do sign_in users(:closer) get :edit, params: { id: posts(:blog_post).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:post) end test 'moderator can edit non-public post' do sign_in users(:moderator) get :edit, params: { id: posts(:blog_post).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:post) end end diff --git a/test/controllers/posts/feature_test.rb b/test/controllers/posts/feature_test.rb index 6a649ebee..eeeaaed0a 100644 --- a/test/controllers/posts/feature_test.rb +++ b/test/controllers/posts/feature_test.rb @@ -7,7 +7,8 @@ class PostsControllerTest < ActionController::TestCase sign_in users(:moderator) before_audits = AuditLog.count post :feature, params: { id: posts(:question_one).id } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:post) assert_not_nil assigns(:link).id assert_equal before_audits + 1, AuditLog.count, 'AuditLog not created on post feature' @@ -15,17 +16,15 @@ class PostsControllerTest < ActionController::TestCase test 'feature requires authentication' do post :feature, params: { id: posts(:question_one).id } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'regular user cannot feature' do sign_in users(:deleter) post :feature, params: { id: posts(:question_one).id, format: :json } - assert_response 404 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:not_found) + assert_valid_json_response assert_equal ['no_privilege'], JSON.parse(response.body)['errors'] end end diff --git a/test/controllers/posts/help_test.rb b/test/controllers/posts/help_test.rb index db3e59f4d..151659826 100644 --- a/test/controllers/posts/help_test.rb +++ b/test/controllers/posts/help_test.rb @@ -5,40 +5,40 @@ class PostsControllerTest < ActionController::TestCase test 'can get help center' do get :help_center - assert_response 200 + assert_response(:success) assert_not_nil assigns(:posts) end test 'can get help article' do get :document, params: { slug: posts(:help_article).doc_slug } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:post) end test 'moderator can get mod help article' do sign_in users(:moderator) get :document, params: { slug: posts(:mod_help_article).doc_slug } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:post) end test 'moderator help requires authentication' do get :document, params: { slug: posts(:mod_help_article).doc_slug } - assert_response 404 + assert_response(:not_found) assert_nil assigns(:post) end test 'regular user cannot get mod help' do sign_in users(:standard_user) get :document, params: { slug: posts(:mod_help_article).doc_slug } - assert_response 404 + assert_response(:not_found) assert_nil assigns(:post) end test 'cannot get disabled help article' do sign_in users(:moderator) get :document, params: { slug: posts(:disabled_help_article).doc_slug } - assert_response 404 + assert_response(:not_found) assert_nil assigns(:post) end end diff --git a/test/controllers/posts/lock_test.rb b/test/controllers/posts/lock_test.rb index cfb7b2ddc..c8cdfabe7 100644 --- a/test/controllers/posts/lock_test.rb +++ b/test/controllers/posts/lock_test.rb @@ -6,88 +6,80 @@ class PostsControllerTest < ActionController::TestCase test 'can lock post' do sign_in users(:deleter) post :lock, params: { id: posts(:question_one).id, format: :json } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:post) assert assigns(:post).locked_until <= 7.days.from_now assert assigns(:post).locked_until >= 7.days.from_now - 1.minute - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'success', JSON.parse(response.body)['status'] end test 'lock requires authentication' do post :lock, params: { id: posts(:question_one).id } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'unprivileged user cannot lock' do sign_in users(:standard_user) post :lock, params: { id: posts(:question_one).id, format: :json } - assert_response 404 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:not_found) + assert_valid_json_response assert_equal 'failed', JSON.parse(response.body)['status'] end test 'cannot lock locked post' do sign_in users(:deleter) post :lock, params: { id: posts(:locked).id, format: :json } - assert_response 404 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:not_found) + assert_valid_json_response assert_equal 'failed', JSON.parse(response.body)['status'] end test 'cannot lock longer than 30 days' do sign_in users(:deleter) post :lock, params: { id: posts(:question_one).id, length: 60, format: :json } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:post) assert assigns(:post).locked_until <= 30.days.from_now assert assigns(:post).locked_until >= 30.days.from_now - 1.minute - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'success', JSON.parse(response.body)['status'] end test 'moderator can lock longer than 30 days' do sign_in users(:moderator) post :lock, params: { id: posts(:question_one).id, length: 60, format: :json } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:post) assert assigns(:post).locked_until <= 60.days.from_now assert assigns(:post).locked_until >= 60.days.from_now - 1.minute - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'success', JSON.parse(response.body)['status'] end test 'moderator can lock indefinitely' do sign_in users(:moderator) post :lock, params: { id: posts(:question_one).id, format: :json } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:post) assert_nil assigns(:post).locked_until - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'success', JSON.parse(response.body)['status'] end test 'Locks on posts expire' do sign_in users(:moderator) post :lock, params: { id: posts(:question_one).id, length: 1, format: :json } - assert_response 200 + assert_response(:success) # Change the locked_until to have already passed assigns(:post).update(locked_until: 1.second.ago) - assert_not assigns(:post).locked? end end diff --git a/test/controllers/posts/new_test.rb b/test/controllers/posts/new_test.rb index 4c02f2dce..24938df01 100644 --- a/test/controllers/posts/new_test.rb +++ b/test/controllers/posts/new_test.rb @@ -7,30 +7,32 @@ class PostsControllerTest < ActionController::TestCase sign_in users(:moderator) get :new, params: { post_type: post_types(:help_doc).id } assert_nil flash[:danger] - assert_response 200 + assert_response(:success) get :new, params: { post_type: post_types(:answer).id, parent: posts(:question_one).id } assert_nil flash[:danger] - assert_response 200 + assert_response(:success) get :new, params: { post_type: post_types(:question).id, category: categories(:main).id } assert_nil flash[:danger] - assert_response 200 + assert_response(:success) end test 'new requires authentication' do get :new, params: { post_type: post_types(:help_doc).id } - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in + get :new, params: { post_type: post_types(:answer).id, parent: posts(:question_one).id } - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in + get :new, params: { post_type: post_types(:question).id, category: categories(:main).id } - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'new rejects category post type without category' do sign_in users(:standard_user) get :new, params: { post_type: post_types(:question).id } - assert_response 302 + assert_response(:found) assert_redirected_to root_path assert_not_nil flash[:danger] end @@ -38,7 +40,7 @@ class PostsControllerTest < ActionController::TestCase test 'new rejects parented post type without parent' do sign_in users(:standard_user) get :new, params: { post_type: post_types(:answer).id } - assert_response 302 + assert_response(:found) assert_redirected_to root_path assert_not_nil flash[:danger] end diff --git a/test/controllers/posts/reopen_test.rb b/test/controllers/posts/reopen_test.rb index 9f574644b..d9985f6f2 100644 --- a/test/controllers/posts/reopen_test.rb +++ b/test/controllers/posts/reopen_test.rb @@ -8,7 +8,8 @@ class PostsControllerTest < ActionController::TestCase before_history = PostHistory.where(post: posts(:closed)).count post :reopen, params: { id: posts(:closed).id } after_history = PostHistory.where(post: posts(:closed)).count - assert_response 302 + + assert_response(:found) assert_redirected_to post_path(posts(:closed)) assert_nil flash[:danger] assert_equal before_history + 1, after_history, 'PostHistory event not created on reopen' @@ -16,8 +17,7 @@ class PostsControllerTest < ActionController::TestCase test 'reopen requires authentication' do post :reopen, params: { id: posts(:closed).id } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'unprivileged user cannot reopen' do @@ -25,7 +25,8 @@ class PostsControllerTest < ActionController::TestCase before_history = PostHistory.where(post: posts(:closed)).count post :reopen, params: { id: posts(:closed).id } after_history = PostHistory.where(post: posts(:closed)).count - assert_response 302 + + assert_response(:found) assert_redirected_to post_path(posts(:closed)) assert_not_nil flash[:danger] assert_equal before_history, after_history, 'PostHistory event incorrectly created on reopen' @@ -36,7 +37,8 @@ class PostsControllerTest < ActionController::TestCase before_history = PostHistory.where(post: posts(:question_one)).count post :reopen, params: { id: posts(:question_one).id } after_history = PostHistory.where(post: posts(:question_one)).count - assert_response 302 + + assert_response(:found) assert_redirected_to post_path(posts(:question_one)) assert_not_nil flash[:danger] assert_equal before_history, after_history, 'PostHistory event incorrectly created on reopen' @@ -47,7 +49,8 @@ class PostsControllerTest < ActionController::TestCase before_history = PostHistory.where(post: posts(:locked)).count post :reopen, params: { id: posts(:locked).id } after_history = PostHistory.where(post: posts(:locked)).count - assert_response 403 + + assert_response(:forbidden) assert_equal before_history, after_history, 'PostHistory event incorrectly created on reopen' end end diff --git a/test/controllers/posts/restore_test.rb b/test/controllers/posts/restore_test.rb index 4471e8d89..d77d7c3de 100644 --- a/test/controllers/posts/restore_test.rb +++ b/test/controllers/posts/restore_test.rb @@ -5,10 +5,12 @@ class PostsControllerTest < ActionController::TestCase test 'can restore post' do sign_in users(:deleter) + before_history = PostHistory.where(post: posts(:deleted)).count post :restore, params: { id: posts(:deleted).id } after_history = PostHistory.where(post: posts(:deleted)).count - assert_response 302 + + assert_response(:found) assert_redirected_to post_path(assigns(:post)) assert_nil flash[:danger] assert_equal before_history + 1, after_history, 'PostHistory event not created on deletion' @@ -16,16 +18,17 @@ class PostsControllerTest < ActionController::TestCase test 'restore requires authentication' do post :restore, params: { id: posts(:deleted).id } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'unprivileged user cannot restore' do sign_in users(:closer) + before_history = PostHistory.where(post: posts(:deleted)).count post :restore, params: { id: posts(:deleted).id } after_history = PostHistory.where(post: posts(:deleted)).count - assert_response 302 + + assert_response(:found) assert_redirected_to post_path(assigns(:post)) assert_not_nil flash[:danger] assert_equal before_history, after_history, 'PostHistory event incorrectly created on deletion' @@ -33,10 +36,12 @@ class PostsControllerTest < ActionController::TestCase test 'cannot restore a post deleted by a moderator' do sign_in users(:deleter) + before_history = PostHistory.where(post: posts(:deleted_mod)).count post :restore, params: { id: posts(:deleted_mod).id } after_history = PostHistory.where(post: posts(:deleted_mod)).count - assert_response 302 + + assert_response(:found) assert_redirected_to post_path(assigns(:post)) assert_not_nil flash[:danger] assert_equal before_history, after_history, 'PostHistory event incorrectly created on deletion' @@ -44,10 +49,12 @@ class PostsControllerTest < ActionController::TestCase test 'cannot restore a restored post' do sign_in users(:deleter) + before_history = PostHistory.where(post: posts(:question_one)).count post :restore, params: { id: posts(:question_one).id } after_history = PostHistory.where(post: posts(:question_one)).count - assert_response 302 + + assert_response(:found) assert_redirected_to post_path(assigns(:post)) assert_not_nil flash[:danger] assert_equal before_history, after_history, 'PostHistory event incorrectly created on deletion' @@ -55,22 +62,26 @@ class PostsControllerTest < ActionController::TestCase test 'cannot restore a locked post' do sign_in users(:deleter) + before_history = PostHistory.where(post: posts(:locked)).count post :restore, params: { id: posts(:locked).id } after_history = PostHistory.where(post: posts(:locked)).count - assert_response 403 + + assert_response(:forbidden) assert_equal before_history, after_history, 'PostHistory event incorrectly created on deletion' end test 'restore brings back all answers deleted after question' do sign_in users(:deleter) + deleted_at = posts(:deleted).deleted_at children = posts(:deleted).children.where('deleted_at >= ?', deleted_at) children_count = children.count before_history = PostHistory.where(post_id: children.where('deleted_at >= ?', deleted_at)).count post :restore, params: { id: posts(:deleted).id } after_history = PostHistory.where(post_id: children.where('deleted_at >= ?', deleted_at)).count - assert_response 302 + + assert_response(:found) assert_redirected_to post_path(assigns(:post)) assert_nil flash[:danger] assert_equal before_history + children_count, after_history, diff --git a/test/controllers/posts/show_test.rb b/test/controllers/posts/show_test.rb index b3f894b53..db18e7113 100644 --- a/test/controllers/posts/show_test.rb +++ b/test/controllers/posts/show_test.rb @@ -5,7 +5,7 @@ class PostsControllerTest < ActionController::TestCase test 'anonymous user can get show' do get :show, params: { id: posts(:question_one).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:post) assert_not_nil assigns(:children) assert_not assigns(:children).any?(&:deleted), 'Anonymous user can see deleted answers' @@ -14,7 +14,7 @@ class PostsControllerTest < ActionController::TestCase test 'standard user can get show' do sign_in users(:standard_user) get :show, params: { id: posts(:question_one).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:post) assert_not_nil assigns(:children) assert_not assigns(:children).any?(&:deleted), 'Anonymous user can see deleted answers' @@ -23,7 +23,7 @@ class PostsControllerTest < ActionController::TestCase test 'privileged user can see deleted post' do sign_in users(:deleter) get :show, params: { id: posts(:deleted).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:post) assert_not_nil assigns(:children) end @@ -31,7 +31,7 @@ class PostsControllerTest < ActionController::TestCase test 'privileged user can see deleted answers' do sign_in users(:deleter) get :show, params: { id: posts(:question_one).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:post) assert_not_nil assigns(:children) assert assigns(:children).any?(&:deleted), 'Privileged user cannot see deleted answers' @@ -39,7 +39,7 @@ class PostsControllerTest < ActionController::TestCase test 'show redirects parented to parent post' do get :show, params: { id: posts(:answer_one).id } - assert_response 302 + assert_response(:found) assert_redirected_to answer_post_path(posts(:answer_one).parent_id, answer: posts(:answer_one).id, anchor: "answer-#{posts(:answer_one).id}") end @@ -47,6 +47,6 @@ class PostsControllerTest < ActionController::TestCase test 'unprivileged user cannot see post in high trust level category' do sign_in users(:standard_user) get :show, params: { id: posts(:high_trust).id } - assert_response 404 + assert_response(:not_found) end end diff --git a/test/controllers/posts/toggle_comments_test.rb b/test/controllers/posts/toggle_comments_test.rb index 78601733b..ca4e3e911 100644 --- a/test/controllers/posts/toggle_comments_test.rb +++ b/test/controllers/posts/toggle_comments_test.rb @@ -6,25 +6,24 @@ class PostsControllerTest < ActionController::TestCase test 'can toggle comments' do sign_in users(:moderator) post :toggle_comments, params: { id: posts(:question_one).id } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:post) - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'success', JSON.parse(response.body)['status'] assert assigns(:post).comments_disabled end test 'toggle comments requires authentication' do post :toggle_comments, params: { id: posts(:question_one).id } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'regular users cannot toggle comments' do sign_in users(:standard_user) post :toggle_comments, params: { id: posts(:question_one).id } - assert_response 404 + + assert_response(:not_found) assert_not_nil assigns(:post) assert_not assigns(:post).comments_disabled end @@ -32,11 +31,10 @@ class PostsControllerTest < ActionController::TestCase test 'specifying delete all results in comments being deleted' do sign_in users(:moderator) post :toggle_comments, params: { id: posts(:question_one).id, delete_all_comments: true } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:post) - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'success', JSON.parse(response.body)['status'] assert assigns(:post).comments_disabled assert assigns(:post).comments.all?(&:deleted?) diff --git a/test/controllers/posts/unlock_test.rb b/test/controllers/posts/unlock_test.rb index 087700791..68139b483 100644 --- a/test/controllers/posts/unlock_test.rb +++ b/test/controllers/posts/unlock_test.rb @@ -7,37 +7,33 @@ class PostsControllerTest < ActionController::TestCase sign_in users(:deleter) posts(:locked).update(locked_until: 2.days.from_now) post :unlock, params: { id: posts(:locked).id, format: :json } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:post) - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'success', JSON.parse(response.body)['status'] end test 'unlock requires authentication' do post :unlock, params: { id: posts(:locked).id } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'unprivileged user cannot unlock' do sign_in users(:standard_user) post :unlock, params: { id: posts(:locked).id, format: :json } - assert_response 404 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:not_found) + assert_valid_json_response assert_equal 'failed', JSON.parse(response.body)['status'] end test 'cannot unlock unlocked post' do sign_in users(:deleter) post :unlock, params: { id: posts(:question_one).id, format: :json } - assert_response 404 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:not_found) + assert_valid_json_response assert_equal 'failed', JSON.parse(response.body)['status'] end @@ -45,10 +41,9 @@ class PostsControllerTest < ActionController::TestCase sign_in users(:deleter) posts(:locked_mod).update(locked_until: 2.days.from_now) post :unlock, params: { id: posts(:locked_mod).id, format: :json } - assert_response 404 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:not_found) + assert_valid_json_response assert_equal 'failed', JSON.parse(response.body)['status'] assert_equal ['locked_by_mod'], JSON.parse(response.body)['errors'] end diff --git a/test/controllers/posts/update_test.rb b/test/controllers/posts/update_test.rb index 247de58ce..669ff4e30 100644 --- a/test/controllers/posts/update_test.rb +++ b/test/controllers/posts/update_test.rb @@ -10,7 +10,7 @@ class PostsControllerTest < ActionController::TestCase post: { title: sample.edit.title, body_markdown: sample.edit.body_markdown, tags_cache: sample.edit.tags_cache } } after_history = PostHistory.where(post: posts(:question_one)).count - assert_response 302 + assert_response(:found) assert_redirected_to post_path(posts(:question_one)) assert_not_nil assigns(:post) assert_equal sample.edit.body_markdown, assigns(:post).body_markdown @@ -24,7 +24,7 @@ class PostsControllerTest < ActionController::TestCase post: { title: sample.edit.title, body_markdown: sample.edit.body_markdown, tags_cache: sample.edit.tags_cache } } after_history = PostHistory.where(post: posts(:question_one)).count - assert_response 302 + assert_response(:found) assert_redirected_to post_path(posts(:question_one)) assert_not_nil assigns(:post) assert_equal sample.edit.body_markdown, assigns(:post).body_markdown @@ -35,8 +35,8 @@ class PostsControllerTest < ActionController::TestCase patch :update, params: { id: posts(:question_one).id, post: { title: sample.edit.title, body_markdown: sample.edit.body_markdown, tags_cache: sample.edit.tags_cache } } - assert_response 302 - assert_redirected_to new_user_session_path + + assert_redirected_to_sign_in end test 'update by unprivileged user generates suggested edit' do @@ -49,7 +49,7 @@ class PostsControllerTest < ActionController::TestCase tags_cache: sample.edit.tags_cache } } after_history = PostHistory.where(post: posts(:question_one)).count after_edits = SuggestedEdit.where(post: posts(:question_one)).count - assert_response 302 + assert_response(:found) assert_redirected_to post_path(posts(:question_one)) assert_not_nil assigns(:post) assert_equal before_body, assigns(:post).body_markdown, 'Suggested edit incorrectly applied immediately' @@ -65,7 +65,7 @@ class PostsControllerTest < ActionController::TestCase post: { title: post.title, body_markdown: post.body_markdown, tags_cache: post.tags_cache } } after_history = PostHistory.where(post: posts(:question_one)).count - assert_response 302 + assert_response(:found) assert_redirected_to post_path(posts(:question_one)) assert_not_nil assigns(:post) assert_not_nil flash[:danger] @@ -79,7 +79,7 @@ class PostsControllerTest < ActionController::TestCase post: { title: sample.edit.title, body_markdown: sample.edit.body_markdown, tags_cache: sample.edit.tags_cache } } after_history = PostHistory.where(post: posts(:locked)).count - assert_response 403 + assert_response(:forbidden) assert_equal before_history, after_history, 'PostHistory event incorrectly created on update' end @@ -90,7 +90,7 @@ class PostsControllerTest < ActionController::TestCase post: { title: sample.edit.title, body_markdown: sample.edit.body_markdown, tags_cache: sample.edit.tags_cache } } after_history = PostHistory.where(post: posts(:free_edit)).count - assert_response 302 + assert_response(:found) assert_redirected_to post_path(posts(:free_edit)) assert_not_nil assigns(:post) assert_equal sample.edit.body_markdown, assigns(:post).body_markdown diff --git a/test/controllers/reactions_controller_test.rb b/test/controllers/reactions_controller_test.rb index beef081a4..2422f9d1e 100644 --- a/test/controllers/reactions_controller_test.rb +++ b/test/controllers/reactions_controller_test.rb @@ -6,8 +6,7 @@ class ReactionsControllerTest < ActionController::TestCase test 'add should require sign in' do post :add, params: { reaction_id: reaction_types(:wfm).id, comment: nil, post_id: posts(:answer_two) } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'add should fail if no post id given' do @@ -17,6 +16,12 @@ class ReactionsControllerTest < ActionController::TestCase end end + test 'add should fail if post is not accessible to user' do + sign_in users(:standard_user) + post :add, params: { reaction_id: reaction_types(:wfm).id, comment: nil, post_id: posts(:high_trust) } + assert_response(:not_found) + end + test 'add should fail if no reaction id given' do sign_in users(:standard_user) assert_raise ActiveRecord::RecordNotFound do @@ -29,7 +34,7 @@ class ReactionsControllerTest < ActionController::TestCase post :add, params: { reaction_id: reaction_types(:wfm).id, comment: nil, post_id: posts(:answer_two) } assert_not_nil assigns(:post) assert_equal 'success', JSON.parse(response.body)['status'] - assert_response 200 + assert_response(:success) end test 'add should fail if reaction type requires comment but none provided' do @@ -37,7 +42,7 @@ class ReactionsControllerTest < ActionController::TestCase post :add, params: { reaction_id: reaction_types(:bad).id, comment: nil, post_id: posts(:answer_two) } assert_not_nil assigns(:post) assert_equal 'failed', JSON.parse(response.body)['status'] - assert_response 403 + assert_response(:forbidden) end test 'add should pass if reaction type requires comment and one provided' do @@ -45,61 +50,63 @@ class ReactionsControllerTest < ActionController::TestCase post :add, params: { reaction_id: reaction_types(:bad).id, comment: 'A' * 50, post_id: posts(:answer_two) } assert_not_nil assigns(:post) assert_equal 'success', JSON.parse(response.body)['status'] - assert_response 200 + assert_response(:success) end test 'add should pass if reaction type requires no comment but one provided' do sign_in users(:standard_user) post :add, params: { reaction_id: reaction_types(:old).id, comment: 'A' * 50, post_id: posts(:answer_two) } + assert_not_nil assigns(:post) assert_equal 'success', JSON.parse(response.body)['status'] - assert_response 200 + assert_response(:success) end test 'add should allow adding second reaction of other type' do sign_in users(:standard_user) post :add, params: { reaction_id: reaction_types(:old).id, comment: nil, post_id: posts(:answer_one) } + assert_not_nil assigns(:post) assert_equal 'success', JSON.parse(response.body)['status'] - assert_response 200 + assert_response(:success) end test 'add should prevent adding second reaction of same type' do sign_in users(:standard_user) post :add, params: { reaction_id: reaction_types(:wfm).id, comment: nil, post_id: posts(:answer_one) } + assert_not_nil assigns(:post) assert_equal 'failed', JSON.parse(response.body)['status'] - assert_response 403 + assert_response(:forbidden) end test 'add should fail if unprivileged user attempts to comment' do sign_in users(:standard_user) - posts(:answer_two).update comments_disabled: true + posts(:answer_two).update(comments_disabled: true) post :add, params: { reaction_id: reaction_types(:wfm).id, comment: 'A' * 50, post_id: posts(:answer_two) } assert_not_nil assigns(:post) assert_equal 'failed', JSON.parse(response.body)['status'] - assert_response 403 + assert_response(:forbidden) - posts(:answer_two).update comments_disabled: false + posts(:answer_two).update(comments_disabled: false) end test 'add should pass if privileged user attempts to comment' do sign_in users(:admin) - posts(:answer_two).update comments_disabled: true + posts(:answer_two).update(comments_disabled: true) post :add, params: { reaction_id: reaction_types(:wfm).id, comment: 'A' * 50, post_id: posts(:answer_two) } assert_not_nil assigns(:post) assert_equal 'success', JSON.parse(response.body)['status'] - assert_response 200 + assert_response(:success) - posts(:answer_two).update comments_disabled: false + posts(:answer_two).update(comments_disabled: false) end test 'retract should require sign in' do post :retract, params: { reaction_id: reaction_types(:wfm).id, post_id: posts(:answer_two) } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'retract should fail if no post id given' do @@ -119,95 +126,93 @@ class ReactionsControllerTest < ActionController::TestCase test 'retract should fail if no active reaction' do sign_in users(:standard_user) post :retract, params: { reaction_id: reaction_types(:wfm).id, post_id: posts(:answer_two) } - assert_response 403 + assert_response(:forbidden) assert_equal 'failed', JSON.parse(response.body)['status'] end test 'retract should pass if active reaction' do sign_in users(:standard_user) post :retract, params: { reaction_id: reaction_types(:wfm).id, post_id: posts(:answer_one) } - assert_response 200 + assert_response(:success) assert_equal 'success', JSON.parse(response.body)['status'] end test 'retract should fail if no active reaction of same type' do sign_in users(:standard_user) post :retract, params: { reaction_id: reaction_types(:bad).id, post_id: posts(:answer_one) } - assert_response 403 + assert_response(:forbidden) assert_equal 'failed', JSON.parse(response.body)['status'] end test 'index should fail for signed-out' do get :index - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'index should fail for standard users' do sign_in users(:standard_user) get :index - assert_response 404 + assert_response(:not_found) end test 'index should fail for privileged standard users' do sign_in users(:closer) get :index - assert_response 404 + assert_response(:not_found) end test 'index should show for moderators' do sign_in users(:moderator) get :index - assert_response 200 + assert_response(:success) end test 'edit should fail for signed-out' do get :edit, params: { id: reaction_types(:wfm).id } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'edit should fail for standard users' do sign_in users(:standard_user) get :edit, params: { id: reaction_types(:wfm).id } - assert_response 404 + assert_response(:not_found) end test 'edit should fail for privileged standard users' do sign_in users(:closer) get :edit, params: { id: reaction_types(:wfm).id } - assert_response 404 + assert_response(:not_found) end test 'edit should show for moderators' do sign_in users(:moderator) get :edit, params: { id: reaction_types(:wfm).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:reaction_type) end test 'update should fail for signed-out' do patch :update, params: { id: reaction_types(:wfm).id, reaction_type: { name: 'WORKZ', active: false } } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'update should fail for standard users' do sign_in users(:standard_user) patch :update, params: { id: reaction_types(:wfm).id, reaction_type: { name: 'WORKZ', active: false } } - assert_response 404 + assert_response(:not_found) end test 'update should fail for privileged standard users' do sign_in users(:closer) patch :update, params: { id: reaction_types(:wfm).id, reaction_type: { name: 'WORKZ', active: false } } - assert_response 404 + assert_response(:not_found) end test 'update should pass for moderators' do sign_in users(:moderator) patch :update, params: { id: reaction_types(:wfm).id, reaction_type: { name: 'WORKZ', active: false } } - assert_response 302 + + assert_response(:found) assert_redirected_to reactions_path assert_not_nil assigns(:reaction_type) assert_equal false, assigns(:reaction_type).active @@ -216,26 +221,25 @@ class ReactionsControllerTest < ActionController::TestCase test 'new should fail for signed-out' do get :new - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'new should fail for standard users' do sign_in users(:standard_user) get :new - assert_response 404 + assert_response(:not_found) end test 'new should fail for privileged standard users' do sign_in users(:closer) get :new - assert_response 404 + assert_response(:not_found) end test 'new should show for moderators' do sign_in users(:moderator) get :new - assert_response 200 + assert_response(:success) assert_not_nil assigns(:reaction_type) end @@ -244,8 +248,7 @@ class ReactionsControllerTest < ActionController::TestCase icon: 'aaaaaah-icon', color: 'is-deeppurple is-orangegreen', requires_comment: false, position: 42 } post :create, params: { id: reaction_types(:wfm).id, reaction_type: data } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'create should fail for standard users' do @@ -254,7 +257,7 @@ class ReactionsControllerTest < ActionController::TestCase icon: 'aaaaaah-icon', color: 'is-deeppurple is-orangegreen', requires_comment: false, position: 42 } post :create, params: { id: reaction_types(:wfm).id, reaction_type: data } - assert_response 404 + assert_response(:not_found) end test 'create should fail for privileged standard users' do @@ -263,7 +266,7 @@ class ReactionsControllerTest < ActionController::TestCase icon: 'aaaaaah-icon', color: 'is-deeppurple is-orangegreen', requires_comment: false, position: 42 } post :create, params: { id: reaction_types(:wfm).id, reaction_type: data } - assert_response 404 + assert_response(:not_found) end test 'create should pass for moderators' do @@ -272,7 +275,7 @@ class ReactionsControllerTest < ActionController::TestCase icon: 'aaaaaah-icon', color: 'is-deeppurple is-orangegreen', requires_comment: false, position: 42 } post :create, params: { id: reaction_types(:wfm).id, reaction_type: data } - assert_response 302 + assert_response(:found) assert_redirected_to reactions_path end end diff --git a/test/controllers/reports_controller_test.rb b/test/controllers/reports_controller_test.rb index 73949150c..edea291a6 100644 --- a/test/controllers/reports_controller_test.rb +++ b/test/controllers/reports_controller_test.rb @@ -6,8 +6,7 @@ class ReportsControllerTest < ActionController::TestCase test 'should deny access to anonymous users' do [:users, :posts, :subscriptions].each do |route| get route - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end end @@ -15,7 +14,7 @@ class ReportsControllerTest < ActionController::TestCase sign_in users(:standard_user) [:users, :posts, :subscriptions].each do |route| get route - assert_response 404 + assert_response(:not_found) end end @@ -23,7 +22,15 @@ class ReportsControllerTest < ActionController::TestCase sign_in users(:moderator) [:users, :posts, :subscriptions].each do |route| get route - assert_response 200 + assert_response(:success) + end + end + + test 'every global route should work for global moderators & admins' do + sign_in users(:global_admin) + [:users_global, :subs_global, :posts_global].each do |route| + get route + assert_response(:success) end end end diff --git a/test/controllers/search_controller_test.rb b/test/controllers/search_controller_test.rb index 9beba194c..8eb453891 100644 --- a/test/controllers/search_controller_test.rb +++ b/test/controllers/search_controller_test.rb @@ -5,20 +5,20 @@ class SearchControllerTest < ActionController::TestCase test 'get without a search term should result in all posts' do get :search - assert_response 200 + assert_response(:success) assert_not_nil assigns(:posts) end test 'get with a search term should have results' do get :search, params: { search: 'ABCDEF' } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:posts) end test 'all search orders should work' do ['relevance', 'score', 'age'].each do |so| get :search, params: { search: 'ABCDEF', sort: so } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:posts) end end @@ -26,7 +26,7 @@ class SearchControllerTest < ActionController::TestCase test 'undefined search order should not error' do assert_nothing_raised do get :search, params: { search: 'ABCDEF', sort: 'abcdef' } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:posts) end end @@ -34,7 +34,7 @@ class SearchControllerTest < ActionController::TestCase test 'search with qualifiers should work' do assert_nothing_raised do get :search, params: { search: 'score:>=1 created:<1y abcdef' } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:posts) end end diff --git a/test/controllers/site_settings_controller_test.rb b/test/controllers/site_settings_controller_test.rb index cdb29e184..280158ca0 100644 --- a/test/controllers/site_settings_controller_test.rb +++ b/test/controllers/site_settings_controller_test.rb @@ -6,14 +6,16 @@ class SiteSettingsControllerTest < ActionController::TestCase test 'should get index page' do sign_in users(:admin) get :index + assert_not_nil assigns(:settings) - assert_response(200) + assert_response(:success) end test 'should update existing setting' do sign_in users(:admin) post :update, params: { community_id: RequestContext.community_id, name: site_settings(:one).name, site_setting: { value: 'ABCDEF' } } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:setting) assert_equal 'ABCDEF', JSON.parse(response.body)['setting']['value'] assert_equal 'OK', JSON.parse(response.body)['status'] @@ -22,42 +24,42 @@ class SiteSettingsControllerTest < ActionController::TestCase test 'should require authentication to access index' do sign_out :user get :index - assert_response(404) + assert_response(:not_found) end test 'should require admin status to access index' do sign_in users(:moderator) get :index - assert_response(404) + assert_response(:not_found) end test 'should require global admin to access global settings' do sign_in users(:global_admin) get :global - assert_response 200 + assert_response(:success) assert_not_nil assigns(:settings) end test 'should deny global access to local admins' do sign_in users(:admin) get :global - assert_response 404 + assert_response(:not_found) end test 'should allow global admin to update global setting' do sign_in users(:global_admin) post :update, params: { community_id: nil, name: site_settings(:one).name, site_setting: { value: 2 } } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:success) + assert_valid_json_response assert_equal 'OK', JSON.parse(response.body)['status'] end test 'should prevent local admin updating global setting' do sign_in users(:admin) post :update, params: { community_id: nil, name: site_settings(:one).name, site_setting: { value: 2 } } - assert_response 404 + + assert_response(:not_found) end test 'editing site setting should leave global alone' do @@ -65,10 +67,9 @@ class SiteSettingsControllerTest < ActionController::TestCase pre_value = site_settings(:one).value pre_count = SiteSetting.unscoped.count post :update, params: { community_id: RequestContext.community_id, name: site_settings(:one).name, site_setting: { value: 'ABC' } } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:success) + assert_valid_json_response assert_equal 'OK', JSON.parse(response.body)['status'] assert_equal pre_value, site_settings(:one).value assert_equal pre_count + 1, SiteSetting.unscoped.count diff --git a/test/controllers/subscriptions_controller_test.rb b/test/controllers/subscriptions_controller_test.rb index 898730b31..6d582119f 100644 --- a/test/controllers/subscriptions_controller_test.rb +++ b/test/controllers/subscriptions_controller_test.rb @@ -5,20 +5,18 @@ class SubscriptionsControllerTest < ActionController::TestCase test 'should require authentication to access new' do get :new, params: { type: 'all' } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'should require authentication to access index' do get :index - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'should get index when logged in' do sign_in users(:standard_user) get :index - assert_response 200 + assert_response(:success) assert_not_nil assigns(:subscriptions) assert_not assigns(:subscriptions).empty?, '@subscriptions instance variable expected size > 0, got <= 0' @@ -27,7 +25,7 @@ class SubscriptionsControllerTest < ActionController::TestCase test 'should get new when logged in' do sign_in users(:standard_user) get :new, params: { type: 'all' } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:phrasing) assert_not_nil assigns(:subscription) end @@ -36,7 +34,7 @@ class SubscriptionsControllerTest < ActionController::TestCase sign_in users(:standard_user) post :create, params: { return_to: user_path(users(:moderator)), subscription: { type: 'user', qualifier: users(:moderator).id, name: 'test', frequency: 7 } } - assert_response 302 + assert_response(:found) assert_not_nil assigns(:subscription) assert_not_nil flash[:success] assert_redirected_to user_path(users(:moderator)) @@ -45,7 +43,8 @@ class SubscriptionsControllerTest < ActionController::TestCase test 'should refuse to create tag subscription to nonexistent tag' do sign_in users(:standard_user) post :create, params: { subscription: { type: 'tag', qualifier: 'nope', name: 'test', frequency: 7 } } - assert_response 500 + + assert_response(:internal_server_error) assert_not_nil assigns(:subscription) assert assigns(:subscription).errors.any?, '@subscription instance variable has no errors attached but failed to save' @@ -54,33 +53,30 @@ class SubscriptionsControllerTest < ActionController::TestCase test 'should prevent users updating subscriptions belonging to others' do sign_in users(:editor) post :enable, params: { id: subscriptions(:all).id, enabled: true } - assert_response 403 + + assert_response(:forbidden) assert_not_nil assigns(:subscription) - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'failed', JSON.parse(response.body)['status'] end test 'should prevent users removing subscriptions belonging to others' do sign_in users(:editor) post :destroy, params: { id: subscriptions(:all).id } - assert_response 403 + + assert_response(:forbidden) assert_not_nil assigns(:subscription) - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'failed', JSON.parse(response.body)['status'] end test 'should allow users to update their own subscriptions' do sign_in users(:standard_user) post :enable, params: { id: subscriptions(:all).id } # no enabled param should default to false - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:subscription) - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'success', JSON.parse(response.body)['status'] assert_equal false, assigns(:subscription).enabled end @@ -88,22 +84,20 @@ class SubscriptionsControllerTest < ActionController::TestCase test 'should allow users to remove their own subscriptions' do sign_in users(:standard_user) post :destroy, params: { id: subscriptions(:all).id } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:subscription) - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'success', JSON.parse(response.body)['status'] end test 'should allow admins to update others subscriptions' do sign_in users(:admin) post :enable, params: { id: subscriptions(:all).id } # no enabled param should default to false - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:subscription) - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'success', JSON.parse(response.body)['status'] assert_equal false, assigns(:subscription).enabled end @@ -111,11 +105,10 @@ class SubscriptionsControllerTest < ActionController::TestCase test 'should allow admins to remove others subscriptions' do sign_in users(:admin) post :destroy, params: { id: subscriptions(:all).id } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:subscription) - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'success', JSON.parse(response.body)['status'] end end diff --git a/test/controllers/sudo_controller_test.rb b/test/controllers/sudo_controller_test.rb new file mode 100644 index 000000000..4ad9f95f3 --- /dev/null +++ b/test/controllers/sudo_controller_test.rb @@ -0,0 +1,53 @@ +require 'test_helper' + +class SudoControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + + test 'should require auth before sudo mode' do + get :sudo + assert_response(:found) + assert_redirected_to new_user_session_path + end + + test 'should show sudo mode page' do + sign_in users(:standard_user) + get :sudo + assert_response(:success) + end + + test 'should fail sudo mode with wrong password' do + sign_in users(:standard_user) + try_enter_sudo('wrong') + + assert_response(:success) + assert_equal 'The password you entered was incorrect.', flash[:danger] + end + + test 'should enter sudo mode' do + set_password(users(:standard_user), 'test1234') + sign_in users(:standard_user) + session[:sudo_return] = users_me_path + try_enter_sudo('test1234') + + assert_response(:found) + assert_redirected_to users_me_path + assert_not_nil session[:sudo] + assert_nothing_raised do + DateTime.iso8601(session[:sudo]) + end + end + + private + + # Attempts to enter sudo mode for the current user + # @param password [String] password of the user entering sudo mode + def try_enter_sudo(password) + post :enter_sudo, params: { password: password } + end + + def set_password(user, password) + user.password = password + user.skip_reconfirmation! + user.save! + end +end diff --git a/test/controllers/suggested_edit_controller_test.rb b/test/controllers/suggested_edit_controller_test.rb index c19a1b957..cec0b14df 100644 --- a/test/controllers/suggested_edit_controller_test.rb +++ b/test/controllers/suggested_edit_controller_test.rb @@ -5,14 +5,14 @@ class SuggestedEditControllerTest < ActionController::TestCase test 'should get page with all pending edits' do get :category_index, params: { category: categories(:main).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:category) assert_not_nil assigns(:edits) end test 'should get page with all decided edits' do get :category_index, params: { category: categories(:main).id, show_decided: 1 } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:category) assert_not_nil assigns(:edits) end @@ -21,7 +21,7 @@ class SuggestedEditControllerTest < ActionController::TestCase get :show, params: { id: suggested_edits(:pending_suggested_edit).id } assert_not_nil assigns(:edit) assert_equal assigns(:edit).active, true - assert_response(200) + assert_response(:success) end test 'should get approved suggested edit page' do @@ -29,7 +29,7 @@ class SuggestedEditControllerTest < ActionController::TestCase assert_not_nil assigns(:edit) assert_equal assigns(:edit).active, false assert_equal assigns(:edit).accepted, true - assert_response(200) + assert_response(:success) end test 'should get rejected suggested edit page' do @@ -37,7 +37,7 @@ class SuggestedEditControllerTest < ActionController::TestCase assert_not_nil assigns(:edit) assert_equal assigns(:edit).active, false assert_equal assigns(:edit).accepted, false - assert_response(200) + assert_response(:success) end test 'signed-out shouldn\'t be able to approve' do @@ -45,7 +45,7 @@ class SuggestedEditControllerTest < ActionController::TestCase suggested_edit.update(active: true, accepted: false) post :approve, params: { id: suggested_edit.id } - assert_response(400) + assert_response(:forbidden) end test 'signed-out shouldn\'t be able to reject' do @@ -53,7 +53,37 @@ class SuggestedEditControllerTest < ActionController::TestCase suggested_edit.update(active: true, accepted: false) post :reject, params: { id: suggested_edit.id, rejection_comment: 'WHY NOT?' } - assert_response(400) + assert_response(:forbidden) + end + + test 'users without the ability to edit posts shouldn\'t be able to approve' do + sign_in users(:moderator) + + edit = suggested_edits(:pending_high_trust) + + post :approve, params: { id: edit.id, format: 'json' } + + assert_response(:forbidden) + assert_valid_json_response + + res_body = JSON.parse(response.body) + assert_equal 'error', res_body['status'] + assert_not_empty res_body['message'] + end + + test 'users without the ability to edit posts shouldn\'t be able to reject' do + sign_in users(:moderator) + + edit = suggested_edits(:pending_high_trust) + + post :reject, params: { id: edit.id, format: 'json' } + + assert_response(:forbidden) + assert_valid_json_response + + res_body = JSON.parse(response.body) + assert_equal 'error', res_body['status'] + assert_not_empty res_body['message'] end test 'already decided edit shouldn\'t be able to be approved' do @@ -63,7 +93,7 @@ class SuggestedEditControllerTest < ActionController::TestCase suggested_edit.update(active: false, accepted: false) post :approve, params: { id: suggested_edit.id } - assert_response(409) + assert_response(:conflict) end test 'already decided edit shouldn\'t be able to be rejected' do @@ -73,7 +103,7 @@ class SuggestedEditControllerTest < ActionController::TestCase suggested_edit.update(active: false, accepted: true) post :reject, params: { id: suggested_edit.id, rejection_comment: 'WHY NOT?' } - assert_response(409) + assert_response(:conflict) end test 'approving edit should change status and apply it' do @@ -85,7 +115,7 @@ class SuggestedEditControllerTest < ActionController::TestCase post :approve, params: { id: suggested_edit.id } suggested_edit.reload - assert_response(200) + assert_response(:success) assert_not_nil assigns(:edit) assert_equal suggested_edit.active, false @@ -104,7 +134,7 @@ class SuggestedEditControllerTest < ActionController::TestCase post :reject, params: { id: suggested_edit.id, rejection_comment: 'WHY NOT?' } suggested_edit.reload - assert_response(200) + assert_response(:success) assert_not_nil assigns(:edit) assert_equal suggested_edit.active, false diff --git a/test/controllers/tag_sets_controller_test.rb b/test/controllers/tag_sets_controller_test.rb index 7cbd5d70a..92378d9e4 100644 --- a/test/controllers/tag_sets_controller_test.rb +++ b/test/controllers/tag_sets_controller_test.rb @@ -6,7 +6,7 @@ class TagSetsControllerTest < ActionController::TestCase test 'should deny access to non-admins' do [:index, :global].each do |route| get route - assert_response 404 + assert_response(:not_found) end rescue => e puts e.backtrace @@ -15,7 +15,7 @@ class TagSetsControllerTest < ActionController::TestCase test 'should allow admins to access index' do sign_in users(:admin) get :index - assert_response 200 + assert_response(:success) assert_not_nil assigns(:tag_sets) assert_not_nil assigns(:counts) end @@ -23,13 +23,13 @@ class TagSetsControllerTest < ActionController::TestCase test 'should deny admins access to global' do sign_in users(:admin) get :global - assert_response 404 + assert_response(:not_found) end test 'should allow global admins to access global' do sign_in users(:global_admin) get :global - assert_response 200 + assert_response(:success) assert_not_nil assigns(:tag_sets) assert_not_nil assigns(:counts) end @@ -37,22 +37,20 @@ class TagSetsControllerTest < ActionController::TestCase test 'should allow admins to access show' do sign_in users(:global_admin) get :show, params: { id: tag_sets(:main).id, format: 'json' } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:tag_set) - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response end test 'should update tag set' do sign_in users(:global_admin) post :update, params: { id: tag_sets(:main).id, name: 'Test' } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:tag_set) assert_equal 'Test', assigns(:tag_set).name - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assert_equal 'success', JSON.parse(response.body)['status'] end end diff --git a/test/controllers/tags_controller_test.rb b/test/controllers/tags_controller_test.rb index cdb3983d9..cb45dfa0c 100644 --- a/test/controllers/tags_controller_test.rb +++ b/test/controllers/tags_controller_test.rb @@ -5,20 +5,19 @@ class TagsControllerTest < ActionController::TestCase test 'index with json format should return JSON list of tags' do get :index, params: { format: 'json' } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:success) + assert_valid_json_response assert_not_nil assigns(:tags) end test 'index with search params should return tags including search term' do get :index, params: { format: 'json', term: 'dis' } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:success) + assert_valid_json_response assert_not_nil assigns(:tags) + JSON.parse(response.body).each do |tag| assert_equal true, tag['name'].include?('dis') || tag['tag_synonyms'].any? { |ts| ts['name'].include?('syn') } end @@ -26,11 +25,11 @@ class TagsControllerTest < ActionController::TestCase test 'index with search params should return tags whose synonyms include search term' do get :index, params: { format: 'json', term: 'syn' } - assert_response 200 - assert_nothing_raised do - JSON.parse(response.body) - end + + assert_response(:success) + assert_valid_json_response assert_not_nil assigns(:tags) + JSON.parse(response.body).each do |tag| assert_equal true, tag['name'].include?('syn') || tag['tag_synonyms'].any? { |ts| ts['name'].include?('syn') } end @@ -38,40 +37,40 @@ class TagsControllerTest < ActionController::TestCase test 'should get category tags list' do get :category, params: { id: categories(:main).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:tags) assert_not_nil assigns(:category) sign_in users(:standard_user) get :category, params: { id: categories(:main).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:tags) assert_not_nil assigns(:category) end test 'should get children list' do get :children, params: { id: categories(:main).id, tag_id: tags(:topic).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:tags) assert_not_nil assigns(:category) sign_in users(:standard_user) get :children, params: { id: categories(:main).id, tag_id: tags(:topic).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:tags) assert_not_nil assigns(:category) end test 'should get tag page and RSS' do get :show, params: { id: categories(:main).id, tag_id: tags(:topic).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:tag) assert_not_nil assigns(:category) assert_not_nil assigns(:posts) sign_in users(:standard_user) get :show, params: { id: categories(:main).id, tag_id: tags(:topic).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:tag) assert_not_nil assigns(:category) assert_not_nil assigns(:posts) @@ -79,14 +78,14 @@ class TagsControllerTest < ActionController::TestCase test 'should get tag RSS feed' do get :show, params: { id: categories(:main).id, tag_id: tags(:topic).id, format: :rss } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:tag) assert_not_nil assigns(:category) assert_not_nil assigns(:posts) sign_in users(:standard_user) get :show, params: { id: categories(:main).id, tag_id: tags(:topic).id, format: :rss } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:tag) assert_not_nil assigns(:category) assert_not_nil assigns(:posts) @@ -94,20 +93,19 @@ class TagsControllerTest < ActionController::TestCase test 'should deny edit to anonymous user' do get :edit, params: { id: categories(:main).id, tag_id: tags(:topic).id } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'should deny edit to unprivileged user' do sign_in users(:standard_user) get :edit, params: { id: categories(:main).id, tag_id: tags(:topic).id } - assert_response 403 + assert_response(:forbidden) end test 'should get edit' do sign_in users(:deleter) get :edit, params: { id: categories(:main).id, tag_id: tags(:topic).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:tag) assert_not_nil assigns(:category) end @@ -115,22 +113,21 @@ class TagsControllerTest < ActionController::TestCase test 'should deny update to anonymous user' do patch :update, params: { id: categories(:main).id, tag_id: tags(:topic).id, tag: { parent_id: tags(:discussion).id, excerpt: 'things' } } - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'should deny update to unprivileged user' do sign_in users(:standard_user) patch :update, params: { id: categories(:main).id, tag_id: tags(:topic).id, tag: { parent_id: tags(:discussion).id, excerpt: 'things' } } - assert_response 403 + assert_response(:forbidden) end test 'should update tag' do sign_in users(:deleter) patch :update, params: { id: categories(:main).id, tag_id: tags(:topic).id, tag: { parent_id: tags(:discussion).id, excerpt: 'things' } } - assert_response 302 + assert_response(:found) assert_redirected_to tag_path(id: categories(:main).id, tag_id: tags(:topic).id) assert_not_nil assigns(:tag) assert_equal tags(:discussion).id, assigns(:tag).parent_id @@ -141,7 +138,7 @@ class TagsControllerTest < ActionController::TestCase sign_in users(:deleter) patch :update, params: { id: categories(:main).id, tag_id: tags(:topic).id, tag: { tag_synonyms_attributes: { '1': { name: 'conversation' } } } } - assert_response 302 + assert_response(:found) assert_redirected_to tag_path(id: categories(:main).id, tag_id: tags(:topic).id) assert_not_nil assigns(:tag) assert_equal 'conversation', assigns(:tag).tag_synonyms.first&.name @@ -151,7 +148,7 @@ class TagsControllerTest < ActionController::TestCase sign_in users(:deleter) patch :update, params: { id: categories(:main).id, tag_id: tags(:base).id, tag: { tag_synonyms_attributes: { '1': { id: tag_synonyms(:base_synonym).id, _destroy: 'true' } } } } - assert_response 302 + assert_response(:found) assert_redirected_to tag_path(id: categories(:main).id, tag_id: tags(:base).id) assert_not_nil assigns(:tag) assert_equal true, (assigns(:tag).tag_synonyms.none? { |ts| ts.name == 'synonym' }) @@ -161,7 +158,7 @@ class TagsControllerTest < ActionController::TestCase sign_in users(:deleter) patch :update, params: { id: categories(:main).id, tag_id: tags(:topic).id, tag: { parent_id: tags(:topic).id, excerpt: 'things' } } - assert_response 400 + assert_response(:bad_request) assert_not_nil assigns(:tag) assert_equal ['A tag cannot be its own parent.'], assigns(:tag).errors.full_messages end @@ -170,8 +167,61 @@ class TagsControllerTest < ActionController::TestCase sign_in users(:deleter) patch :update, params: { id: categories(:main).id, tag_id: tags(:topic).id, tag: { parent_id: tags(:child).id, excerpt: 'things' } } - assert_response 400 + assert_response(:bad_request) assert_not_nil assigns(:tag) assert_equal ["The #{tags(:child).name} tag is already a child of this tag."], assigns(:tag).errors.full_messages end + + test 'should correctly rename a tag' do + sign_in users(:moderator) + + tag = tags(:base) + + new_tag_name = 'renamed' + + post :rename, params: { + format: :json, + id: categories(:main).id, + name: new_tag_name, + tag_id: tag.id, + tag: tag + } + + assert_response(:success) + assert_valid_json_response + + res_body = JSON.parse(response.body) + + assert_equal true, res_body['success'] + assert_equal new_tag_name, res_body['tag']['name'] + + log_entry = AuditLog.last + assert_equal 'tag_rename', log_entry['event_type'] + assert_equal tag.id, log_entry['related_id'] + end + + test 'should prevent renaming a tag to an invalid name' do + sign_in users(:moderator) + + tag = tags(:base) + + old_tag_name = tag.name + + post :rename, params: { + format: :json, + id: categories(:main).id, + name: '', + tag_id: tag.id, + tag: tag + } + + assert_response(:success) + assert_valid_json_response + + res_body = JSON.parse(response.body) + + assert_equal false, res_body['success'] + tag.reload + assert_equal tag.name, old_tag_name + end end diff --git a/test/controllers/users/registrations_controller_test.rb b/test/controllers/users/registrations_controller_test.rb new file mode 100644 index 000000000..bc90cc5d6 --- /dev/null +++ b/test/controllers/users/registrations_controller_test.rb @@ -0,0 +1,125 @@ +require 'test_helper' + +class Users::RegistrationsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include ApplicationHelper + + setup :devise_setup + + test 'should register user' do + try_register_user('test', 'test@example.com', 'testtest') + + assert_response(:found) + assert_not_nil assigns(:user).id + assert_redirected_to root_path + end + + test 'should prevent rapid registrations from same IP' do + User.create(username: 'test', email: 'test2@example.com', password: 'testtest', current_sign_in_ip: '0.0.0.0') + try_register_user('test', 'test@example.com', 'testtest') + + assert_response(:found) + assert_redirected_to users_path + assert_not_nil flash[:danger] + end + + test 'ensure Devise errors are handled properly' do + existing_user = users(:standard_user) + try_register_user(existing_user.username, existing_user.email, 'testtest') + + assert_response(:success) + assert_not_empty assigns(:user).errors + end + + test 'should show deletion information page' do + sign_in users(:standard_user) + session[:sudo] = DateTime.now.iso8601 + get :delete + assert_response(:success) + end + + test 'should require authentication for deletion information' do + get :delete + assert_response(:found) + assert_redirected_to new_user_session_path + end + + test 'should require sudo for deletion information' do + sign_in users(:standard_user) + get :delete + assert_response(:found) + assert_redirected_to user_sudo_path + end + + test 'should delete user account' do + sign_in users(:standard_user) + session[:sudo] = DateTime.now.iso8601 + try_do_delete_user(users(:standard_user)) + + assert_response(:found) + assert_redirected_to root_path + assert_equal 'Sorry to see you go!', flash[:info] + assert assigns(:user).deleted + end + + test 'should require authentication to delete user account' do + post :do_delete, params: { username: 'anything' } + + assert_response(:found) + assert_redirected_to new_user_session_path + end + + test 'should require sudo to delete user account' do + sign_in users(:standard_user) + post :do_delete, params: { username: 'anything' } + + assert_response(:found) + assert_redirected_to user_sudo_path + end + + test 'should prevent deletion if username is incorrect' do + sign_in users(:standard_user) + session[:sudo] = DateTime.now.iso8601 + post :do_delete, params: { username: 'wrong' } + + assert_response(:success) + assert_equal [I18n.t('users.errors.self_delete_wrong_username')], assigns(:user).errors.full_messages + assert_not assigns(:user).deleted + end + + test 'should prevent self-deletion if the user is at least a moderator' do + locale_string_map = { + moderator: 'users.errors.no_mod_self_delete', + admin: 'users.errors.no_admin_self_delete', + enabled_2fa: 'users.errors.no_2fa_self_delete' + } + + [:moderator, :admin, :enabled_2fa].each do |name| + sign_in users(name) + session[:sudo] = DateTime.now.iso8601 + + try_do_delete_user(users(name)) + + assert_response(:success) + assert_equal [I18n.t(locale_string_map[name])], assigns(:user).errors.full_messages + assert_not assigns(:user).deleted + end + end + + private + + # Attempts to sudo delete a given user + # @param user [User] user to delete + def try_do_delete_user(user) + post :do_delete, params: { username: user.username } + end + + def try_register_user(username, email, password) + post :create, params: { user: { username: username, email: email, password: password, + password_confirmation: password } } + end + + def devise_setup + @request.env['devise.mapping'] = Devise.mappings[:user] + end +end diff --git a/test/controllers/users/sessions_controller_test.rb b/test/controllers/users/sessions_controller_test.rb index b06e1b602..21cb88f44 100644 --- a/test/controllers/users/sessions_controller_test.rb +++ b/test/controllers/users/sessions_controller_test.rb @@ -8,7 +8,8 @@ class Users::SessionsControllerTest < ActionController::TestCase @request.env['devise.mapping'] = Devise.mappings[:user] Users::SessionsController.first_factor << users(:enabled_2fa).id post :verify_code, params: { uid: users(:enabled_2fa).id, code: 'M8lENyehyCvo9F9MbyTl1aOL' } - assert_response 302 + + assert_response(:found) assert_not_nil flash[:warning] assert_not_nil current_user assert_nil current_user.backup_2fa_code diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 0aa5d0863..5df2d395e 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -1,61 +1,63 @@ require 'test_helper' +require_relative 'concerns/users/users_abilities_test' class UsersControllerTest < ActionController::TestCase include Devise::Test::ControllerHelpers include ApplicationHelper + include UsersAbilitiesTest test 'should get index' do get :index assert_not_nil assigns(:users) - assert_response(200) + assert_response(:success) end test 'should not include users not in current community' do @other_user = create_other_user get :index assert_not_includes assigns(:users), @other_user - assert_response(200) + assert_response(:success) end test 'should get show user page' do sign_in users(:standard_user) get :show, params: { id: users(:standard_user).id } assert_not_nil assigns(:user) - assert_response(200) + assert_response(:success) end test 'should get show user unauthenticated' do get :show, params: { id: users(:standard_user).id } assert_not_nil assigns(:user) - assert_response 200 + assert_response(:success) end test 'should not show user page for non-community users' do @other_user = create_other_user sign_in users(:standard_user) get :show, params: { id: @other_user.id } - assert_response(404) + assert_response(:not_found) end test 'should get mod tools page' do sign_in users(:moderator) get :mod, params: { id: users(:standard_user).id } assert_not_nil assigns(:user) - assert_response(200) + assert_response(:success) end test 'should require authentication to access mod tools' do sign_out :user get :mod, params: { id: users(:standard_user).id } assert_nil assigns(:user) - assert_response(404) + assert_response(:not_found) end test 'should require moderator status to access mod tools' do sign_in users(:standard_user) get :mod, params: { id: users(:standard_user).id } assert_nil assigns(:user) - assert_response(404) + assert_response(:not_found) end test 'should destroy user' do @@ -63,27 +65,27 @@ class UsersControllerTest < ActionController::TestCase delete :destroy, params: { id: users(:standard_user).id } assert_not_nil assigns(:user) assert_equal 'success', JSON.parse(response.body)['status'] - assert_response(200) + assert_response(:success) end test 'should require authentication to destroy user' do sign_out :user delete :destroy, params: { id: users(:standard_user).id } assert_nil assigns(:user) - assert_response(404) + assert_response(:not_found) end test 'should require moderator status to destroy user' do sign_in users(:standard_user) delete :destroy, params: { id: users(:standard_user).id } assert_nil assigns(:user) - assert_response(404) + assert_response(:not_found) end test 'should soft-delete user' do sign_in users(:global_admin) delete :soft_delete, params: { id: users(:standard_user).id, type: 'user' } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:user) assert_equal true, assigns(:user).deleted end @@ -92,31 +94,32 @@ class UsersControllerTest < ActionController::TestCase sign_out :user delete :soft_delete, params: { id: users(:standard_user).id, transfer: users(:editor).id } assert_nil assigns(:user) - assert_response(404) + assert_response(:not_found) end test 'should require admin status to soft-delete user' do sign_in users(:standard_user) delete :soft_delete, params: { id: users(:standard_user).id, transfer: users(:editor).id } assert_nil assigns(:user) - assert_response(404) + assert_response(:not_found) end test 'should require authentication to get edit profile page' do get :edit_profile - assert_response 302 + assert_response(:found) end test 'should get edit profile page' do sign_in users(:standard_user) get :edit_profile - assert_response 200 + assert_response(:success) end test 'should redirect & show success notice on profile update' do sign_in users(:standard_user) patch :update_profile, params: { user: { username: 'std' } } - assert_response 302 + + assert_response(:found) assert_not_nil flash[:success] assert_not_nil assigns(:user) assert_equal users(:standard_user).id, assigns(:user).id @@ -152,29 +155,28 @@ class UsersControllerTest < ActionController::TestCase test 'should get full posts list for a user' do get :posts, params: { id: users(:standard_user).id } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:user) assert_not_nil assigns(:posts) end test 'should get full posts list in JSON format' do get :posts, params: { id: users(:standard_user).id, format: 'json' } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:user) assert_not_nil assigns(:posts) - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response end test 'should sort full posts lists correctly' do get :posts, params: { id: users(:standard_user).id, format: :json, sort: 'age' } - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:user) assert_not_nil assigns(:posts) - assert_nothing_raised do - JSON.parse(response.body) - end + assert_valid_json_response assigns(:posts).each_with_index do |post, idx| next if idx.zero? @@ -186,14 +188,14 @@ class UsersControllerTest < ActionController::TestCase test 'should require authentication to get mobile login' do get :qr_login_code - assert_response 302 - assert_redirected_to new_user_session_path + assert_redirected_to_sign_in end test 'should allow signed in users to get mobile login' do sign_in users(:standard_user) get :qr_login_code - assert_response 200 + + assert_response(:success) assert_not_nil assigns(:token) assert_not_nil assigns(:qr_code) assert_equal 1, User.where(login_token: assigns(:token)).count @@ -203,7 +205,8 @@ class UsersControllerTest < ActionController::TestCase test 'should sign in user in response to valid mobile login request' do get :do_qr_login, params: { token: 'abcdefghijklmnopqrstuvwxyz01' } - assert_response 302 + + assert_response(:found) assert_equal 'You are now signed in.', flash[:success] assert_equal users(:closer).id, current_user.id assert_nil current_user.login_token @@ -212,7 +215,8 @@ class UsersControllerTest < ActionController::TestCase test 'should refuse to sign in user using expired token' do get :do_qr_login, params: { token: 'abcdefghijklmnopqrstuvwxyz02' } - assert_response 404 + + assert_response(:not_found) assert_not_nil flash[:danger] assert_equal true, flash[:danger].start_with?("That login link isn't valid.") assert_nil current_user&.id @@ -220,51 +224,51 @@ class UsersControllerTest < ActionController::TestCase test 'should deny anonymous users access to annotations' do get :annotations, params: { id: users(:standard_user).id } - assert_response 404 + assert_response(:not_found) end test 'should deny non-mods access to annotations' do sign_in users(:standard_user) get :annotations, params: { id: users(:standard_user).id } - assert_response 404 + assert_response(:not_found) end test 'should get annotations' do sign_in users(:admin) get :annotations, params: { id: users(:standard_user).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:logs) end test 'should annotate user' do sign_in users(:admin) post :annotate, params: { id: users(:standard_user).id, comment: 'some words' } - assert_response 302 + assert_response(:found) assert_redirected_to user_annotations_path(users(:standard_user)) end test 'should deny access to deleted account' do get :show, params: { id: users(:deleted_account).id } - assert_response 404 + assert_response(:not_found) end test 'should deny access to deleted profile' do get :show, params: { id: users(:deleted_profile).id } - assert_response 404 + assert_response(:not_found) assert_not_nil assigns(:user) end test 'should allow moderator access to deleted account' do sign_in users(:moderator) get :show, params: { id: users(:deleted_account).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:user) end test 'should allow moderator access to deleted profile' do sign_in users(:moderator) get :show, params: { id: users(:deleted_profile).id } - assert_response 200 + assert_response(:success) assert_not_nil assigns(:user) end @@ -296,25 +300,231 @@ class UsersControllerTest < ActionController::TestCase test 'vote summary rendered for all users, signed in or out, own or others' do sign_out :user get :vote_summary, params: { id: users(:standard_user).id } - assert_response 200 + assert_response(:success) get :vote_summary, params: { id: users(:closer).id } - assert_response 200 + assert_response(:success) sign_in users(:editor) get :vote_summary, params: { id: users(:editor).id } - assert_response 200 + assert_response(:success) get :vote_summary, params: { id: users(:deleter).id } - assert_response 200 + assert_response(:success) + end + + test 'me should redirect to currently signed in user' do + std = users(:standard_user) + + sign_in std + get :me, format: 'html' + assert_redirected_to user_path(std) + end + + test "me should return currently signed in user's data for JSON format" do + mod = users(:moderator) + + sign_in mod + get :me, format: 'json' + assert_response(:success) + + data = JSON.parse(response.body) + + assert_equal data['id'], mod.id + assert_equal data['username'], mod.username + end + + test 'role toggle should correctly grant & revoke moderator role' do + sign_in users(:global_admin) + + mod = users(:moderator) + + post :role_toggle, params: { id: mod.id, role: 'mod' } + assert_response(:success) + + mod.reload + assert_equal mod.moderator?, false + + post :role_toggle, params: { id: mod.id, role: 'mod' } + assert_response(:success) + + mod.reload + assert_equal mod.moderator?, true + end + + test 'role toggle should correctly grant & revoke admin role' do + sign_in users(:global_admin) + + admin = users(:admin) + + post :role_toggle, params: { id: admin.id, role: 'admin' } + assert_response(:success) + + admin.reload + assert_equal admin.admin?, false + + post :role_toggle, params: { id: admin.id, role: 'admin' } + assert_response(:success) + + admin.reload + assert_equal admin.admin?, true + end + + test 'role toggle should correctly grant & revoke global moderator role' do + sign_in users(:global_admin) + + mod = users(:moderator) + + post :role_toggle, params: { id: mod.id, role: 'mod_global' } + assert_response(:success) + + mod.reload + assert_equal mod.global_moderator?, true + + post :role_toggle, params: { id: mod.id, role: 'mod_global' } + assert_response(:success) + + mod.reload + assert_equal mod.global_moderator?, false + end + + test 'role toggle should correctly grant & revoke global admin role' do + sign_in users(:global_admin) + + admin = users(:admin) + + post :role_toggle, params: { id: admin.id, role: 'admin_global' } + assert_response(:success) + + admin.reload + assert_equal admin.global_admin?, true + + post :role_toggle, params: { id: admin.id, role: 'admin_global' } + assert_response(:success) + + admin.reload + assert_equal admin.global_admin?, false + end + + test 'full_log should only be accessible to mods or admins' do + mod = users(:moderator) + std = users(:standard_user) + + sign_in mod + get :full_log, params: { id: std.id } + assert_response(:success) + + sign_in std + get :full_log, params: { id: std.id } + assert_response(:not_found) + end + + test 'activity should correctly apply single-type items filter' do + std = users(:standard_user) + + sign_in std + + model_map = { + 'posts' => Post, + 'comments' => Comment, + 'edits' => SuggestedEdit + } + + model_map.each do |filter, model| + get :activity, params: { id: std.id, filter: filter } + assert_response(:success) + items = assigns(:items) + + assert(items.all? { |x| x.instance_of?(model) }) + end + end + + test 'default activity filter should include items of all types' do + std = users(:standard_user) + + sign_in std + + get :activity, params: { id: std.id } + + assert_response(:success) + items = assigns(:items) + + edit_type = PostHistoryType.find_by(name: 'post_edited') + + [Post, Comment, SuggestedEdit, Flag, PostHistory, ModWarning].each do |model| + assert(items.any? do |item| + is_valid = if item.instance_of?(model) + case model + when Post + item.deleted == false + when Comment + item.comment_thread.deleted == false && + item.deleted == false && + item.post.deleted == false + when PostHistory + item.post_history_type == edit_type + when SuggestedEdit + item.post.deleted == false + end + else + true + end + + is_valid && item.user.same_as?(std) + end) + end + end + + test 'full_log should correctly apply single-type items filter' do + sign_in users(:moderator) + + model_map = { + 'posts' => Post, + 'comments' => Comment, + 'edits' => SuggestedEdit, + 'flags' => Flag, + 'warnings' => ModWarning + } + + model_map.each do |filter, model| + get :full_log, params: { id: users(:standard_user).id, filter: filter } + assert_response(:success) + items = assigns(:items) + + assert(items.all? { |x| x.instance_of?(model) }) + end + end + + test 'full_log\'s \'interesting\' filter should include deleted comments' do + sign_in users(:moderator) + + get :full_log, params: { id: users(:standard_user).id, filter: 'interesting' } + assert_response(:success) + items = assigns(:items) + + deleted_comment = comments(:deleted) + + assert(items.any? { |x| x.instance_of?(Comment) && x.id == deleted_comment.id }) + end + + test 'full_log\'s \'interesting\' filter should include declined flags' do + sign_in users(:moderator) + + get :full_log, params: { id: users(:standard_user).id, filter: 'interesting' } + assert_response(:success) + items = assigns(:items) + + declined_flag = flags(:declined) + + assert(items.any? { |x| x.instance_of?(Flag) && x.id == declined_flag.id }) end private def create_other_user other_community = Community.create(host: 'other.qpixel.com', name: 'Other') - RequestContext.redis.hset 'network/community_registrations', 'other@example.com', other_community.id + RequestContext.redis.hset('network/community_registrations', 'other@example.com', other_community.id) other_user = User.create!(email: 'other@example.com', password: 'abcdefghijklmnopqrstuvwxyz', username: 'other_user') other_user.community_users.create!(community: other_community) other_user diff --git a/test/controllers/votes_controller_test.rb b/test/controllers/votes_controller_test.rb index 81d1b5009..fe4c3cb38 100644 --- a/test/controllers/votes_controller_test.rb +++ b/test/controllers/votes_controller_test.rb @@ -5,84 +5,117 @@ class VotesControllerTest < ActionController::TestCase test 'should cast upvote' do sign_in users(:standard_user) - post :create, params: { post_id: posts(:question_two).id, vote_type: 1 } + + post :create, params: { post_id: posts(:question_without_votes).id, vote_type: 1 } + + assert_response(:success) + assert_valid_json_response assert_equal 'OK', JSON.parse(response.body)['status'] - assert_response(200) end test 'should cast downvote' do sign_in users(:standard_user) - post :create, params: { post_id: posts(:question_two).id, vote_type: -1 } + + post :create, params: { post_id: posts(:question_without_votes).id, vote_type: -1 } + + assert_response(:success) + assert_valid_json_response assert_equal 'OK', JSON.parse(response.body)['status'] - assert_response(200) end test 'should return correct modified status' do - sign_in users(:editor) - post :create, params: { post_id: posts(:question_one).id, vote_type: -1 } + post_id = posts(:question_without_votes).id + + sign_in users(:standard_user) + + post :create, params: { post_id: post_id, vote_type: 1 } + post :create, params: { post_id: post_id, vote_type: -1 } + + assert_response(:success) + assert_valid_json_response assert_equal 'modified', JSON.parse(response.body)['status'] - assert_response(200) end test 'should silently accept duplicate votes' do - sign_in users(:editor) - post :create, params: { post_id: posts(:question_one).id, vote_type: 1 } + post_id = posts(:question_without_votes).id + + sign_in users(:standard_user) + + post :create, params: { post_id: post_id, vote_type: 1 } + post :create, params: { post_id: post_id, vote_type: 1 } + + assert_response(:success) + assert_valid_json_response assert_equal 'modified', JSON.parse(response.body)['status'] - assert_response 200 end test 'should prevent self voting' do sign_in users(:editor) - post :create, params: { post_id: posts(:question_two).id, vote_type: 1 } - assert_equal 'You may not vote on your own posts.', JSON.parse(response.body)['message'] - assert_response(403) + + post :create, params: { post_id: posts(:question_without_votes).id, vote_type: 1 } + + assert_response(:forbidden) + assert_valid_json_response + assert_json_response_message('You may not vote on your own posts.') end test 'should remove existing vote' do sign_in users(:editor) + delete :destroy, params: { id: votes(:one).id } + + assert_response(:success) + assert_valid_json_response assert_equal 'OK', JSON.parse(response.body)['status'] - assert_response(200) end test 'should prevent users removing others votes' do sign_in users(:standard_user) + delete :destroy, params: { id: votes(:one).id } - assert_equal 'You are not authorized to remove this vote.', JSON.parse(response.body)['message'] - assert_response(403) + + assert_response(:forbidden) + assert_valid_json_response + assert_json_response_message('You are not authorized to remove this vote.') end test 'should require authentication to create a vote' do sign_out :user + post :create - assert_equal 'You must be logged in to vote.', JSON.parse(response.body)['message'] - assert_response(403) + + assert_response(:forbidden) + assert_valid_json_response + assert_json_response_message('You must be logged in to vote.') end test 'should require authentication to remove a vote' do sign_out :user + delete :destroy, params: { id: votes(:one).id } - assert_equal 'You must be logged in to vote.', JSON.parse(response.body)['message'] - assert_response(403) + + assert_response(:forbidden) + assert_valid_json_response + assert_json_response_message('You must be logged in to vote.') end test 'should prevent deleted account casting votes' do sign_in users(:deleted_account) - post :create, params: { post_id: posts(:question_two).id, vote_type: 1 } - assert_response 403 - assert_nothing_raised do - JSON.parse(response.body) - end - assert_equal 'You must be logged in to vote.', JSON.parse(response.body)['message'] + + post :create, params: { post_id: posts(:question_without_votes).id, vote_type: 1 } + + assert_response(:forbidden) + assert_valid_json_response + assert_json_response_message('You must be logged in to vote.') end test 'should prevent deleted profile casting votes' do sign_in users(:deleted_profile) - post :create, params: { post_id: posts(:question_two).id, vote_type: 1 } - assert_response 403 - assert_nothing_raised do - JSON.parse(response.body) - end - assert_equal 'You must be logged in to vote.', JSON.parse(response.body)['message'] + + post :create, params: { post_id: posts(:question_without_votes).id, vote_type: 1 } + + assert_response(:forbidden) + assert_valid_json_response + assert_json_response_message('You must be logged in to vote.') end end diff --git a/test/fixtures/blocked_items.yml b/test/fixtures/blocked_items.yml index 80aed36e3..6070be6b2 100644 --- a/test/fixtures/blocked_items.yml +++ b/test/fixtures/blocked_items.yml @@ -1,11 +1,13 @@ # Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html -# This model initially had no columns defined. If you add columns to the -# model remove the '{}' from the fixture names and add the columns immediately -# below each fixture, per the syntax in the comments below -# -one: {} -# column: value -# -two: {} -# column: value +email: + item_type: email + value: blocked@mail.com + automatic: true + reason: got fed up with them + +ip: + item_type: ip + value: 8.8.8.8 + automatic: false + reason: Why are we accessed by a DNS server? diff --git a/test/fixtures/categories.yml b/test/fixtures/categories.yml index b59b61265..f44432b70 100644 --- a/test/fixtures/categories.yml +++ b/test/fixtures/categories.yml @@ -29,13 +29,13 @@ high_trust: admin_only: community: sample - name: High Trust - short_wiki: High Trust + name: Admin Only + short_wiki: Admin Only display_post_types: - <%= Question.post_type_id %> tag_set: main - min_trust_level: 6 - min_view_trust_level: 6 + min_trust_level: 5 + min_view_trust_level: 5 license: cc_by_sa articles_only: @@ -46,3 +46,12 @@ articles_only: - <%= Article.post_type_id %> tag_set: main license: cc_by_sa + +moderator_tags: + community: sample + name: Moderator Tags + short_wiki: Category with a couple of moderator tags + tag_set: main + license: cc_by_sa + moderator_tags: + - feature-request diff --git a/test/fixtures/category_post_types.yml b/test/fixtures/category_post_types.yml index b2da00fdc..82d3d598c 100644 --- a/test/fixtures/category_post_types.yml +++ b/test/fixtures/category_post_types.yml @@ -63,3 +63,15 @@ articles_only_article: post_type: article upvote_rep: 10 downvote_rep: -2 + +moderator_tags_question: + category: moderator_tags + post_type: question + upvote_rep: 0 + downvote_rep: 0 + +moderator_tags_answer: + category: moderator_tags + post_type: answer + upvote_rep: 0 + downvote_rep: 0 diff --git a/test/fixtures/close_reasons.yml b/test/fixtures/close_reasons.yml index af52bcd22..30886897a 100644 --- a/test/fixtures/close_reasons.yml +++ b/test/fixtures/close_reasons.yml @@ -10,4 +10,11 @@ not_good: description: not good active: true requires_other_post: false - community: sample \ No newline at end of file + community: sample + +global: + name: global + description: global + active: true + requires_other_post: false + community: ~ diff --git a/test/fixtures/comment_threads.yml b/test/fixtures/comment_threads.yml index 49f8adbcb..0948a1c35 100644 --- a/test/fixtures/comment_threads.yml +++ b/test/fixtures/comment_threads.yml @@ -56,4 +56,10 @@ on_answer: title: on answer reply_count: 0 post: answer_one - community: sample \ No newline at end of file + community: sample + +new_user_on_own_post: + title: new user on own post + reply_count: 0 + post: new_user_question + community: sample diff --git a/test/fixtures/comments.yml b/test/fixtures/comments.yml index ace48142e..0de66ed0a 100644 --- a/test/fixtures/comments.yml +++ b/test/fixtures/comments.yml @@ -47,3 +47,10 @@ on_answer: content: ABCDEF GHIJKL MNOPQR community: sample comment_thread: on_answer + +new_user_on_own_post: + user: basic_user + post: new_user_question + content: ABCDEF GHIJKL MNOPQR + community: sample + comment_thread: new_user_on_own_post diff --git a/test/fixtures/community_users.yml b/test/fixtures/community_users.yml index 4d7ac6963..ea81fce1e 100644 --- a/test/fixtures/community_users.yml +++ b/test/fixtures/community_users.yml @@ -61,6 +61,13 @@ sample_global_admin: is_moderator: false reputation: 1 +sample_staff: + user: staff + community: sample + is_admin: false + is_moderator: false + reputation: 1 + sample_deleted_account: user: deleted_account community: sample @@ -77,3 +84,10 @@ sample_deleted_profile: deleted: true deleted_at: 2020-01-02T00:00:00.000000Z deleted_by: global_admin + +sample_enabled_2fa: + user: enabled_2fa + community: sample + is_admin: false + is_moderator: false + reputation: 1 diff --git a/test/fixtures/flags.yml b/test/fixtures/flags.yml index 64a57be06..83109e6fb 100644 --- a/test/fixtures/flags.yml +++ b/test/fixtures/flags.yml @@ -3,3 +3,34 @@ one: post: answer_two (Post) user: standard_user community: sample + +declined: + reason: Please decline it + post: question_one (Post) + user: standard_user + community: sample + status: declined + +on_deleter: + reason: For science! + post: deleted (Post) + post_flag_type: not_confidential + user: deleter + community: sample + +confidential_on_deleter: + reason: TOP SECRET DO NOT SHARE + post: deleted (Post) + post_flag_type: confidential + user: deleter + community: sample + +escalated: + reason: because I said so + post: question_one (Post) + user: standard_user + community: sample + escalated: true + escalation_comment: what do I do with this + escalated_at: 2025-01-01T00:00:00.000000Z + escalated_by: moderator diff --git a/test/fixtures/micro_auth/apps.yml b/test/fixtures/micro_auth/apps.yml index 9cdc9b49b..777583bc8 100644 --- a/test/fixtures/micro_auth/apps.yml +++ b/test/fixtures/micro_auth/apps.yml @@ -1,17 +1,19 @@ # Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html -one: - name: MyString - app_id: MyString - public_key: MyString - secret_key: MyString +owned_by_standard: + name: App owned by the standard user + app_id: StdApp + public_key: KF2zSr1dgedXNgUfwmWtjjrN7MNpSWAh + secret_key: FHWv7FJ9ZrJ2AJbriQELXFv8J6JXmK2K description: MyText auth_domain: MyString + user: standard_user -two: - name: MyString - app_id: MyString - public_key: MyString - secret_key: MyString +owned_by_admin: + name: App owned by a community admin + app_id: AdminApp + public_key: dQUptrgdqiLKZHSKm2ArZXJRwRh9yLXD + secret_key: PWT7h32gnQC6Sam32iTNoTHD5DnbTTRU description: MyText auth_domain: MyString + user: admin diff --git a/test/fixtures/micro_auth/tokens.yml b/test/fixtures/micro_auth/tokens.yml index 876354468..03103865d 100644 --- a/test/fixtures/micro_auth/tokens.yml +++ b/test/fixtures/micro_auth/tokens.yml @@ -1,13 +1,13 @@ # Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html one: - app: one - user: one - token: MyString + app: owned_by_standard + user: standard_user + token: q9sYGB637q expires_at: 2021-11-21 19:51:00 two: - app: two - user: two - token: MyString + app: owned_by_admin + user: admin + token: KXYnnCSgEM expires_at: 2021-11-21 19:51:00 diff --git a/test/fixtures/pinned_links.yml b/test/fixtures/pinned_links.yml index 80aed36e3..c13a0ac35 100644 --- a/test/fixtures/pinned_links.yml +++ b/test/fixtures/pinned_links.yml @@ -1,11 +1,16 @@ -# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html +active_with_label: + community: sample + label: test link + link: https://example.com + active: true -# This model initially had no columns defined. If you add columns to the -# model remove the '{}' from the fixture names and add the columns immediately -# below each fixture, per the syntax in the comments below -# -one: {} -# column: value -# -two: {} -# column: value +active_with_post: + community: sample + post: question_one + active: true + +inactive: + community: sample + label: test link + link: https://example.com + active: false diff --git a/test/fixtures/post_flag_types.yml b/test/fixtures/post_flag_types.yml index 80aed36e3..35522487a 100644 --- a/test/fixtures/post_flag_types.yml +++ b/test/fixtures/post_flag_types.yml @@ -9,3 +9,13 @@ one: {} # two: {} # column: value +# +confidential: + community: sample + confidential: true + name: For your eyes only + +not_confidential: + community: sample + confidential: false + name: Definitely not confidential diff --git a/test/fixtures/post_histories.yml b/test/fixtures/post_histories.yml index e69de29bb..cf6495c63 100644 --- a/test/fixtures/post_histories.yml +++ b/test/fixtures/post_histories.yml @@ -0,0 +1,16 @@ +question_one_revision: + post_history_type: post_edited + user: editor + post: question_one + before_title: Q1 - This is test question number one + after_title: Revised title for question one + comment: A simple revision made by an editor user + community: sample + +question_one_hidden_revision: + post_history_type: post_edited + user: admin + post: question_one + comment: 'A hidden revision made by an admin user' + community: sample + hidden: true diff --git a/test/fixtures/post_history_types.yml b/test/fixtures/post_history_types.yml index 330742f43..069e50608 100644 --- a/test/fixtures/post_history_types.yml +++ b/test/fixtures/post_history_types.yml @@ -1,3 +1,7 @@ initial_revision: name: initial_revision - description: initial revision \ No newline at end of file + description: initial revision + +post_edited: + name: post_edited + description: normal revision diff --git a/test/fixtures/posts.yml b/test/fixtures/posts.yml index 3af917cd4..e4cf4cace 100644 --- a/test/fixtures/posts.yml +++ b/test/fixtures/posts.yml @@ -327,6 +327,32 @@ deleted_answer: upvote_count: 0 downvote_count: 0 +good_answer: + post_type: answer + body: A3GA - This is the seventh answer to question number 1 (Q1). It has a good score. Posted by standard_user. + body_markdown: A7 - This is the seventh answer to question number 1 (Q1). It has a good score. Posted by standard_user. + score: 0.6 + parent: question_one + user: standard_user + community: sample + category: main + license: cc_by_sa + upvote_count: 1 + downvote_count: 0 + +divisive_answer: + post_type: answer + body: A3DA - This is the eighth answer to question number 1 (Q1). It has divisive votes. Posted by standard_user. + body_markdown: A8 - This is the eighth answer to question number 1 (Q1). It has divisive votes. Posted by standard_user. + score: 0.6 + parent: question_one + user: standard_user + community: sample + category: main + license: cc_by_sa + upvote_count: 4 + downvote_count: 2 + policy_doc: post_type: policy_doc body: PD - This is a policy document called "Terms of Service", or "tos" for short. @@ -440,3 +466,17 @@ blog_post: category: main license: cc_by_sa +question_without_votes: + post_type: question + title: This is a question for testing votes without affecting existing ones + body: This is the body of test question for votes. + body_markdown: This is the body of test question for votes. + tags_cache: + - support + tags: + - support + score: 0.5 + user: editor + community: sample + category: main + license: cc_by_sa diff --git a/test/fixtures/site_settings.yml b/test/fixtures/site_settings.yml index 73c3eb4cc..1d9b79515 100644 --- a/test/fixtures/site_settings.yml +++ b/test/fixtures/site_settings.yml @@ -7,3 +7,19 @@ sanitize: name: AskingGuidance value: "" value_type: string + +int: + name: SettingWithIntegerValue + value: 42 + value_type: integer + +text: + name: SettingWithTextValue + value: "

    a paragraph of text

    " + value_type: text + +rl_new_user_comments: + community: sample + name: RL_NewUserComments + value: 1 + value_type: integer diff --git a/test/fixtures/subscriptions.yml b/test/fixtures/subscriptions.yml index aa955833e..3c5949f60 100644 --- a/test/fixtures/subscriptions.yml +++ b/test/fixtures/subscriptions.yml @@ -35,3 +35,13 @@ interesting: last_sent_at: 2020-01-01T00:00:00 name: Interesting things community: sample + +category: + type: category + qualifier: <%= ActiveRecord::FixtureSet.identify :main %> + user: standard_user + enabled: true + frequency: 1 + last_sent_at: 2020-01-01T00:00:00 + name: Meta subscription + community: sample diff --git a/test/fixtures/suggested_edits.yml b/test/fixtures/suggested_edits.yml index 8e57bf630..5f79e9b6d 100644 --- a/test/fixtures/suggested_edits.yml +++ b/test/fixtures/suggested_edits.yml @@ -60,4 +60,15 @@ rejected_suggested_edit: accepted: false decided_at: 2000-01-01T00:00:00.000000Z decided_by: editor - rejected_comment: NO WAY \ No newline at end of file + rejected_comment: NO WAY + +pending_high_trust: + post: high_trust + user: moderator + community: sample + body: This suggested edit requires high trust to be approved or rejected + title: Very important suggestion + body_markdown:

    This suggested edit requires high trust to be approved or rejected

    + comment: High trust only + active: true + accepted: false diff --git a/test/fixtures/user_abilities.yml b/test/fixtures/user_abilities.yml index 697d53b1d..edc8024b2 100644 --- a/test/fixtures/user_abilities.yml +++ b/test/fixtures/user_abilities.yml @@ -56,4 +56,19 @@ d_et: b_eo: community_user: sample_basic_user - ability: everyone \ No newline at end of file + ability: everyone + +e2_eo: + community_user: sample_enabled_2fa + ability: everyone + +e2_ur: + community_user: sample_enabled_2fa + ability: unrestricted + +e2_ep_susp: + community_user: sample_enabled_2fa + ability: edit_posts + is_suspended: true + suspension_end: ~ + suspension_message: go away diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 82cd00ba7..ad5463bc6 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -93,6 +93,16 @@ global_admin: is_global_moderator: false confirmed_at: 2020-01-01T00:00:00.000000Z +staff: + email: staff@qpixel-test.net + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' + sign_in_count: 42 + username: staff + is_global_admin: true + is_global_moderator: true + staff: true + confirmed_at: 2020-01-01T00:00:00.000000Z + no_community_user: email: no_community_user@qpixel-test.net encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' diff --git a/test/fixtures/votes.yml b/test/fixtures/votes.yml index 31627e73b..531e483ec 100644 --- a/test/fixtures/votes.yml +++ b/test/fixtures/votes.yml @@ -60,3 +60,10 @@ old_answer_2: vote_type: 1 recv_user: standard_user community: sample + +on_editor_by_standard: + user: standard_user + post: question_two + vote_type: 1 + recv_user: editor + community: sample diff --git a/test/helpers/comments_helper_test.rb b/test/helpers/comments_helper_test.rb index 5d54e4fa1..dce3f4ec7 100644 --- a/test/helpers/comments_helper_test.rb +++ b/test/helpers/comments_helper_test.rb @@ -40,7 +40,7 @@ class CommentsHelperTest < ActionView::TestCase expected = { 'you can go to [category:main]' => "you can go to Main", 'or [category:Meta]' => "or Meta", - "maybe even to [category##{categories(:high_trust).id}]" => \ + "maybe even to [category##{categories(:high_trust).id}]" => "maybe even to High Trust", 'but not to [category:blah]' => 'but not to [category:blah]' } @@ -49,15 +49,63 @@ class CommentsHelperTest < ActionView::TestCase end end - test 'comment_rate_limited prevents new users commenting on others posts' do - rate_limited, limit_message = comment_rate_limited? users(:basic_user), posts(:question_one) - assert_equal true, rate_limited - assert_equal 'As a new user, you can only comment on your own posts and on answers to them.', limit_message + test 'comment_rate_limited? should prevent users that reached the daily limit from commenting' do + basic = users(:basic_user) + std = users(:standard_user) + own_post = posts(:question_one) + other_post = posts(:question_two) + + SiteSetting['RL_NewUserComments'] = 0 + SiteSetting['RL_NewUserCommentsOwnPosts'] = 0 + SiteSetting['RL_Comments'] = 0 + SiteSetting['RL_CommentsOwnPosts'] = 0 + + [basic, std].each do |user| + [own_post, other_post].each do |post| + rate_limited, limit_message = comment_rate_limited?(user, post) + assert_equal true, rate_limited + + if user.same_as?(basic) + assert_equal I18n.t('comments.errors.new_user_rate_limited'), limit_message + else + assert_equal rate_limited_error_msg(user, post), limit_message + end + + log = AuditLog.of_type('comment').where(related: post, user: user).order(created_at: :desc).first + assert log.present?, 'Expected audit log for attempting to comment on a post while rate-limited to be created' + end + end end - test 'comment_rate_limited allows new user to comment on own post' do - rate_limited, limit_message = comment_rate_limited? users(:basic_user), posts(:new_user_question) + test 'comment_rate_limited? should allow users to comment on their own posts' do + basic = users(:basic_user) + std = users(:standard_user) + + SiteSetting['RL_NewUserCommentsOwnPosts'] = 50 + + rate_limited, limit_message = comment_rate_limited?(basic, posts(:new_user_question)) assert_equal false, rate_limited - assert_equal nil, limit_message + assert_nil limit_message + + SiteSetting['RL_CommentsOwnPosts'] = 50 + + rate_limited, limit_message = comment_rate_limited?(std, posts(:question_one)) + assert_equal false, rate_limited + assert_nil limit_message + end + + test 'comment_rate_limited? should allow users to comment on posts of others' do + basic = users(:basic_user) + std = users(:standard_user) + other_post = posts(:question_two) + + SiteSetting['RL_NewUserComments'] = 50 + SiteSetting['RL_Comments'] = 50 + + [basic, std].each do |user| + rate_limited, limit_message = comment_rate_limited?(user, other_post) + assert_equal false, rate_limited + assert_nil limit_message + end end end diff --git a/test/helpers/search_helper_test.rb b/test/helpers/search_helper_test.rb index a82e92f6f..11f30c8f1 100644 --- a/test/helpers/search_helper_test.rb +++ b/test/helpers/search_helper_test.rb @@ -35,4 +35,168 @@ class SearchHelperTest < ActionView::TestCase assert_equal expect, date_value_sql(input) end end + + test 'qualifiers_to_sql should correctly narrow by :category qualifier' do + main = categories(:main) + admin_only = categories(:admin_only) + + std_user = users(:standard_user) + adm_user = users(:admin) + + posts_query_std = Post.accessible_to(std_user) + posts_query_adm = Post.accessible_to(adm_user) + + std_post = [{ param: :category, operator: '=', category_id: main.id }] + adm_post = [{ param: :category, operator: '=', category_id: admin_only.id }] + + std_posts_query_standard = qualifiers_to_sql(std_post, posts_query_std, std_user) + adm_posts_query_standard = qualifiers_to_sql(adm_post, posts_query_std, std_user) + adm_posts_query_admin = qualifiers_to_sql(adm_post, posts_query_adm, adm_user) + + assert_not_equal posts_query_std.size, std_posts_query_standard.size + assert_not_equal std_posts_query_standard.size, 0 + + assert_not_equal posts_query_adm.size, adm_posts_query_admin.size + assert_not_equal adm_posts_query_admin.size, 0 + + assert_equal adm_posts_query_standard.size, 0 + end + + test 'qualifiers_to_sql should correctly narrow by :user qualifier' do + std_user = users(:standard_user) + edt_user = users(:editor) + + posts_query = Post.accessible_to(std_user) + edt_post = [{ param: :user, operator: '=', user_id: edt_user.id }] + edt_query = qualifiers_to_sql(edt_post, posts_query, std_user) + + only_editor_posts = edt_query.to_a.all? { |p| p.user.id == edt_user.id } + + assert_not_equal posts_query.size, edt_query.size + assert_not_equal edt_query.size, 0 + assert only_editor_posts + end + + test 'qualifiers_to_sql should correctly narrow by :score qualifier' do + std_user = users(:standard_user) + + posts_query = Post.accessible_to(std_user) + bad_post = [{ param: :score, operator: '<', value: 0.5 }] + good_post = [{ param: :score, operator: '>', value: 0.5 }] + neut_post = [{ param: :score, operator: '=', value: 0.5 }] + + bad_posts_query = qualifiers_to_sql(bad_post, posts_query, std_user) + good_posts_query = qualifiers_to_sql(good_post, posts_query, std_user) + neut_posts_query = qualifiers_to_sql(neut_post, posts_query, std_user) + + only_bad_posts = bad_posts_query.to_a.all? { |p| p.score < 0.5 } + only_good_posts = good_posts_query.to_a.all? { |p| p.score > 0.5 } + only_neut_posts = neut_posts_query.to_a.all? { |p| p.score.to_d == 0.5.to_d } + + assert_not_equal posts_query.size, bad_posts_query.size + assert_not_equal bad_posts_query.size, 0 + assert only_bad_posts + + assert_not_equal posts_query.size, good_posts_query.size + assert_not_equal good_posts_query.size, 0 + assert only_good_posts + + assert_not_equal posts_query.size, neut_posts_query.size + assert_not_equal neut_posts_query.size, 0 + assert only_neut_posts + end + + test 'qualifiers_to_sql should correctly narrow by :status qualifier' do + std_user = users(:standard_user) + + posts_query = Post.accessible_to(std_user) + open_post = [{ param: :status, value: 'open' }] + closed_post = [{ param: :status, value: 'closed' }] + + open_query = qualifiers_to_sql(open_post, posts_query, std_user) + closed_query = qualifiers_to_sql(closed_post, posts_query, std_user) + + only_open_posts = open_query.to_a.none?(&:closed) + only_closed_posts = closed_query.to_a.all?(&:closed) + + assert_not_equal posts_query.size, open_query.size + assert_not_equal open_query.size, 0 + assert only_open_posts + + assert_not_equal posts_query.size, closed_query.size + assert_not_equal closed_query.size, 0 + assert only_closed_posts + end + + test 'qualifiers_to_sql should correctly narrow by :upvotes qualifier' do + std_user = users(:standard_user) + + posts_query = Post.accessible_to(std_user) + upvoted_post = [{ param: :upvotes, operator: '>', value: 0 }] + neutral_post = [{ param: :upvotes, operator: '=', value: 0 }] + + upvoted_query = qualifiers_to_sql(upvoted_post, posts_query, std_user) + neutral_query = qualifiers_to_sql(neutral_post, posts_query, std_user) + + only_upvoted_posts = upvoted_query.to_a.all? { |p| p[:upvote_count].positive? } + only_neutral_posts = neutral_query.to_a.all? { |p| p[:upvote_count].zero? } + + assert_not_equal posts_query.size, upvoted_query.size + assert_not_equal upvoted_query.size, 0 + assert only_upvoted_posts + + assert_not_equal posts_query.size, neutral_query.size + assert_not_equal neutral_query.size, 0 + assert only_neutral_posts + end + + test 'qualifiers_to_sql should correctly narrow by :downvotes qualifier' do + std_user = users(:standard_user) + + posts_query = Post.accessible_to(std_user) + downvoted_post = [{ param: :downvotes, operator: '>', value: 0 }] + neutral_post = [{ param: :downvotes, operator: '=', value: 0 }] + + downvoted_query = qualifiers_to_sql(downvoted_post, posts_query, std_user) + neutral_query = qualifiers_to_sql(neutral_post, posts_query, std_user) + + only_downvoted_posts = downvoted_query.to_a.all? { |p| p[:downvote_count].positive? } + only_neutral_posts = neutral_query.to_a.all? { |p| p[:downvote_count].zero? } + + assert_not_equal posts_query.size, downvoted_query.size + assert_not_equal downvoted_query.size, 0 + assert only_downvoted_posts + + assert_not_equal posts_query.size, neutral_query.size + assert_not_equal neutral_query.size, 0 + assert only_neutral_posts + end + + test 'qualifiers_to_sql should correctly narrow by :net_votes qualifier' do + std_user = users(:standard_user) + + posts_query = Post.accessible_to(std_user) + divisive_post = [{ param: :net_votes, operator: '=', value: 2 }] + + divisive_query = qualifiers_to_sql(divisive_post, posts_query, std_user) + + only_divisive_posts = divisive_query.to_a.all? do |p| + (p[:upvote_count] - p[:downvote_count]) == 2 + end + + assert_not_equal posts_query.size, divisive_query.size + assert_not_equal divisive_query.size, 0 + assert only_divisive_posts + end + + test 'search_posts should not show posts in categories that a user cannot view' do + std_user = users(:standard_user) + + params = ActionController::Parameters.new({ search: 'high trust' }) + posts, _qualifiers = search_posts(std_user, params) + + admin_category = categories(:admin_only) + + assert_not(posts.any? { |p| p.category.id == admin_category.id }) + end end diff --git a/test/integration/tasks_integration_test.rb b/test/integration/tasks_integration_test.rb index f4d9f72ed..3adb67256 100644 --- a/test/integration/tasks_integration_test.rb +++ b/test/integration/tasks_integration_test.rb @@ -5,18 +5,18 @@ class TasksIntegrationTest < ActionDispatch::IntegrationTest test 'should deny access to anonymous users' do get '/maintenance' - assert_response 403 + assert_response(:forbidden) end test 'should deny access to non-developers' do sign_in users(:admin) get '/maintenance' - assert_response 403 + assert_response(:forbidden) end test 'should grant access to developers' do sign_in users(:developer) get '/maintenance' - assert_response 200 + assert_response(:success) end end diff --git a/test/jobs/send_summary_emails_job_test.rb b/test/jobs/send_summary_emails_job_test.rb new file mode 100644 index 000000000..3207ff816 --- /dev/null +++ b/test/jobs/send_summary_emails_job_test.rb @@ -0,0 +1,21 @@ +require 'test_helper' + +class SendSummaryEmailsJobTest < ActiveJob::TestCase + include ActionMailer::TestCase::ClearTestDeliveries + + test 'should correctly send summary emails' do + perform_enqueued_jobs do + SendSummaryEmailsJob.perform_later + end + + assert_performed_jobs(2) + + delivered = SummaryMailer.deliveries.first + + to_email = users(:staff).email + + assert_equal 1, delivered.recipients.size + assert delivered.recipients.include?(to_email), + "Expected #{to_email} to be a recipient, actual: #{delivered.recipients.join(', ')}" + end +end diff --git a/test/mailers/admin_mailer_test.rb b/test/mailers/admin_mailer_test.rb index 4121d891f..e13b553d1 100644 --- a/test/mailers/admin_mailer_test.rb +++ b/test/mailers/admin_mailer_test.rb @@ -1,7 +1,21 @@ require 'test_helper' class AdminMailerTest < ActionMailer::TestCase - # test "the truth" do - # assert true - # end + test 'to_moderators' do + email = AdminMailer.with(body_markdown: 'test', subject: 'test', community: communities(:sample)).to_moderators + assert_emails 1 do + email.deliver_later + end + assert_operator email.from[0].length, :>, 3, 'Sender appears to be empty or default value' + end + + test 'to_all_users' do + email = AdminMailer.with(body_markdown: 'test', subject: 'test', users: ['test1@example.com'], + community: communities(:sample)) + .to_all_users + assert_emails 1 do + email.deliver_later + end + assert_operator email.from[0].length, :>, 3, 'Sender appears to be empty or default value' + end end diff --git a/test/mailers/donation_mailer_test.rb b/test/mailers/donation_mailer_test.rb index d87953c8c..baf6b0003 100644 --- a/test/mailers/donation_mailer_test.rb +++ b/test/mailers/donation_mailer_test.rb @@ -1,7 +1,20 @@ require 'test_helper' class DonationMailerTest < ActionMailer::TestCase - # test "the truth" do - # assert true - # end + test 'donation_successful should correctly send donation emails' do + sender_email = SiteSetting['DonationSenderEmail'] + pi = Stripe::PaymentIntent.new.to_json + user = users(:standard_user) + + job = DonationMailer.with(currency: '£', amount: 1000, email: user.email, name: user.username, intent: pi) + .donation_uncaptured + .deliver_later + + job.perform_now + + delivered = DonationMailer.deliveries.first + + assert_equal 1, delivered.from.length + assert delivered.from.include?(sender_email) + end end diff --git a/test/mailers/flag_mailer_test.rb b/test/mailers/flag_mailer_test.rb index ea9a8de37..4115e3c95 100644 --- a/test/mailers/flag_mailer_test.rb +++ b/test/mailers/flag_mailer_test.rb @@ -1,7 +1,22 @@ require 'test_helper' class FlagMailerTest < ActionMailer::TestCase - # test "the truth" do - # assert true - # end + test 'flag_escalated should correctly send flag escalation emails' do + assert_emails 1 do + flag = flags(:escalated) + + FlagMailer.with(flag: flag).flag_escalated.deliver_now + end + end + + test 'flag_escalated should not fail if the flagged post has been updated before escalation' do + assert_emails 1 do + flag = flags(:escalated) + + # forces 'post modified after flag' to be rendered + flag.post.update(updated_at: DateTime.now) + + FlagMailer.with(flag: flag).flag_escalated.deliver_now + end + end end diff --git a/test/mailers/previews/devise_mailer_preview.rb b/test/mailers/previews/devise_mailer_preview.rb index bb090c90a..f4b468fd6 100644 --- a/test/mailers/previews/devise_mailer_preview.rb +++ b/test/mailers/previews/devise_mailer_preview.rb @@ -2,4 +2,8 @@ class DeviseMailerPreview < ActionMailer::Preview def confirmation_instructions Devise::Mailer.confirmation_instructions(User.first, 'faketoken') end + + def password_change + Devise::Mailer.password_change(User.first) + end end diff --git a/test/mailers/previews/flag_mailer_preview.rb b/test/mailers/previews/flag_mailer_preview.rb index ac93c4c7e..ee7fe75f8 100644 --- a/test/mailers/previews/flag_mailer_preview.rb +++ b/test/mailers/previews/flag_mailer_preview.rb @@ -1,3 +1,6 @@ # Preview all emails at http://localhost:3000/rails/mailers/flag_mailer class FlagMailerPreview < ActionMailer::Preview + def flag_escalated + FlagMailer.with(flag: Flag.escalated.last || Flag.last).flag_escalated + end end diff --git a/test/mailers/previews/summary_mailer_preview.rb b/test/mailers/previews/summary_mailer_preview.rb new file mode 100644 index 000000000..6bf5e4afd --- /dev/null +++ b/test/mailers/previews/summary_mailer_preview.rb @@ -0,0 +1,17 @@ +# Preview all emails at http://localhost:3000/rails/mailers/summary_mailer +class SummaryMailerPreview < ActionMailer::Preview + def content_summary + test_timeframe = 1.year + staff = User.where(staff: true) + posts = Post.unscoped.qa_only.where(created_at: test_timeframe.ago..DateTime.now) + .includes(:community, :user) + flags = Flag.unscoped.where(created_at: test_timeframe.ago..DateTime.now) + .includes(:post, :community, :user) + comments = Comment.unscoped.where(created_at: test_timeframe.ago..DateTime.now) + .includes(:user, :post, :comment_thread, post: :community) + users = User.where(created_at: test_timeframe.ago..DateTime.now).includes(:community_users) + + SummaryMailer.with(to: staff.first.email, posts: posts.to_a, flags: flags.to_a, comments: comments.to_a, users: users.to_a) + .content_summary + end +end diff --git a/test/mailers/previews/user_mailer_preview.rb b/test/mailers/previews/user_mailer_preview.rb new file mode 100644 index 000000000..140260392 --- /dev/null +++ b/test/mailers/previews/user_mailer_preview.rb @@ -0,0 +1,7 @@ +# Preview all emails at http://localhost:3000/rails/mailers/user_mailer +class UserMailerPreview < ActionMailer::Preview + def deletion_confirmation + @user = User.last + UserMailer.with(user: @user).deletion_confirmation + end +end diff --git a/test/mailers/summary_mailer_test.rb b/test/mailers/summary_mailer_test.rb new file mode 100644 index 000000000..c52ffc1a6 --- /dev/null +++ b/test/mailers/summary_mailer_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class SummaryMailerTest < ActionMailer::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/mailers/user_mailer_test.rb b/test/mailers/user_mailer_test.rb new file mode 100644 index 000000000..3fed9bb33 --- /dev/null +++ b/test/mailers/user_mailer_test.rb @@ -0,0 +1,12 @@ +require 'test_helper' + +class UserMailerTest < ActionMailer::TestCase + test 'deletion_confirmation' do + email = UserMailer.with(user: users(:standard_user), host: communities(:sample).host) + .deletion_confirmation + assert_emails 1 do + email.deliver_later + end + assert_operator email.from[0].length, :>, 3, 'Sender for deletion confirmation appears empty or default value' + end +end diff --git a/test/models/community_user_test.rb b/test/models/community_user_test.rb new file mode 100644 index 000000000..b775496f9 --- /dev/null +++ b/test/models/community_user_test.rb @@ -0,0 +1,23 @@ +require 'test_helper' + +class CommunityUserTest < ActiveSupport::TestCase + test 'score getters should correctly calculate scores' do + std = community_users(:sample_standard_user) + + [:edit_score, :flag_score, :post_score].each do |name| + next unless std.respond_to?(name) + + score = std.send(name) + assert score.positive? && score < 1 + end + end + + test 'latest_warning should return the timestamp of the latest warning, if any' do + std = community_users(:sample_standard_user) + + latest = mod_warnings.select { |mw| mw.community_user == std } + .min { |a, b| a.created_at > b.created_at ? 1 : -1 } + + assert_equal std.latest_warning, latest&.created_at + end +end diff --git a/test/models/concerns/identity_test.rb b/test/models/concerns/identity_test.rb new file mode 100644 index 000000000..60f5788ef --- /dev/null +++ b/test/models/concerns/identity_test.rb @@ -0,0 +1,49 @@ +require 'test_helper' + +class IdentityTest < ActiveSupport::TestCase + def setup + @klass1 = Class.new do + include Identity + def initialize(id) + super() + @id = id + end + attr_accessor :id + end + + @klass2 = Class.new do + include Identity + def initialize(id) + super() + @id = id + end + attr_accessor :id + end + end + + test 'same_as? should correctly determine identity' do + first = @klass1.new(42) + second = @klass1.new(42) + third = @klass1.new(777) + + assert first.same_as?(second) + assert_not first.same_as?(third) + end + + test 'same_as? should ensure compared models are the same' do + first = @klass1.new(42) + second = @klass2.new(42) + + assert_not first.same_as?(second) + end + + test 'same_as? should not fail if the compared model is nil' do + first = @klass1.new(42) + + assert_nothing_raised do + first.same_as?(nil) + end + + assert_not first.same_as?(nil) + end +end diff --git a/test/models/flag_test.rb b/test/models/flag_test.rb index 34aac66b3..09d62f85c 100644 --- a/test/models/flag_test.rb +++ b/test/models/flag_test.rb @@ -6,4 +6,12 @@ class FlagTest < ActiveSupport::TestCase test 'is post related' do assert_post_related(Flag) end + + test 'confidential?' do + normal = flags(:one) + secret = flags(:confidential_on_deleter) + + assert_equal normal.confidential?, false + assert_equal secret.confidential?, true + end end diff --git a/test/models/post_history_test.rb b/test/models/post_history_test.rb index 156f729d2..2a6b504a1 100644 --- a/test/models/post_history_test.rb +++ b/test/models/post_history_test.rb @@ -36,4 +36,19 @@ class PostHistoryTest < ActiveSupport::TestCase assert_equal post.body_markdown, event.after_state assert_nil event.before_state end + + test 'allowed_to_see_details? should correctly check if a given user can acces a revision' do + editor = users(:editor) + mod = users(:moderator) + admin = users(:admin) + + hidden = post_histories(:question_one_hidden_revision) + + assert_equal hidden.allowed_to_see_details?(editor), false + assert_equal hidden.allowed_to_see_details?(mod), false + assert_equal hidden.allowed_to_see_details?(admin), true + + # post author should always see history items + assert_equal hidden.allowed_to_see_details?(hidden.post.user), true + end end diff --git a/test/models/post_test.rb b/test/models/post_test.rb index e4b0105bc..befb2b3a9 100644 --- a/test/models/post_test.rb +++ b/test/models/post_test.rb @@ -47,6 +47,24 @@ class PostTest < ActiveSupport::TestCase end end + test 'accessible_to should correctly check user access' do + adm_user = users(:admin) + mod_user = users(:moderator) + std_user = users(:standard_user) + + adm_posts = Post.accessible_to(adm_user) + mod_posts = Post.accessible_to(mod_user) + std_posts = Post.accessible_to(std_user) + + can_admin_get_deleted_posts = adm_posts.any?(&:deleted) + can_mod_get_deleted_posts = mod_posts.any?(&:deleted) + can_user_get_deleted_posts = std_posts.any?(&:deleted) + + assert can_admin_get_deleted_posts + assert can_mod_get_deleted_posts + assert_not can_user_get_deleted_posts + end + test 'should allow specified post types in a category' do category = categories(:main) post_type = post_types(:question) @@ -81,7 +99,27 @@ class PostTest < ActiveSupport::TestCase post_with_reactions = posts(:answer_one) reaction_list = post_with_reactions.reaction_list refute reaction_list.empty? - assert reaction_list.key? reaction_types(:wfm) + assert reaction_list.key?(reaction_types(:wfm)) assert_equal 1, reaction_list[reaction_types(:wfm)].count end + + test 'moderator_tags should correctly validate if the user can use moderator tags on posts' do + category = categories(:moderator_tags) + post_type = post_types(:question) + + users.each do |user| + # moderator_tags validation requires request context user to be set + RequestContext.user = user + + post = Post.new(body_markdown: 'm' * category.min_body_length, body: "

    #{'b' * category.min_body_length}

    ", + title: 't' * category.min_title_length, tags_cache: ['feature-request'], license: licenses(:cc_by_sa), + score: 0, user: user, post_type: post_type, category: category) + + if post.valid? + assert_equal user.at_least_moderator?, true + else + assert_not_empty post.errors[:mod_tags] + end + end + end end diff --git a/test/models/request_context_test.rb b/test/models/request_context_test.rb index 6a666dfc3..cf04401d9 100644 --- a/test/models/request_context_test.rb +++ b/test/models/request_context_test.rb @@ -36,7 +36,7 @@ class RequestContextTest < ActiveSupport::TestCase worker1 = Thread.new do RequestContext.community = @community2 - sleep 0.5 + sleep(0.5) # this check runs second assert_equal RequestContext.community, @community2 diff --git a/test/models/site_setting_test.rb b/test/models/site_setting_test.rb index 053c54009..5a72d80d2 100644 --- a/test/models/site_setting_test.rb +++ b/test/models/site_setting_test.rb @@ -53,4 +53,12 @@ class SiteSettingTest < ActiveSupport::TestCase SiteSetting.create(community_id: nil, name: 'test', value: 'bar', value_type: 'string') assert_equal SiteSetting.where(name: 'test').first, setting1 end + + test 'text? should correctly check if the setting accepts long text values' do + text_setting = site_settings(:text) + int_setting = site_settings(:int) + + assert_equal text_setting.text?, true + assert_equal int_setting.text?, false + end end diff --git a/test/models/subscription_test.rb b/test/models/subscription_test.rb index da38c7544..899318c56 100644 --- a/test/models/subscription_test.rb +++ b/test/models/subscription_test.rb @@ -9,16 +9,14 @@ class SubscriptionTest < ActiveSupport::TestCase test 'subscription to all should return some questions' do questions = subscriptions(:all).questions - assert_not_nil questions - assert_not questions.empty?, 'No questions returned' - assert questions.size <= 100, 'Too many questions returned' + + assert_questions_valid(questions) end test 'tag subscription should return only tag questions' do questions = subscriptions(:tag).questions - assert_not_nil questions - assert_not questions.empty?, 'No questions returned' - assert questions.size <= 100, 'Too many questions returned' + + assert_questions_valid(questions) questions.each do |question| assert question.tags.map(&:name).include?(subscriptions(:tag).qualifier), "Tag subscription returned question #{question.id} without specified tag" @@ -27,12 +25,45 @@ class SubscriptionTest < ActiveSupport::TestCase test 'user subscription should return only user questions' do questions = subscriptions(:user).questions - assert_not_nil questions - assert_not questions.empty?, 'No questions returned' - assert questions.size <= 100, 'Too many questions returned' + + assert_questions_valid(questions) questions.each do |question| assert question.user_id == subscriptions(:user).qualifier.to_i, "User subscription returned question #{question.id} not from specified user" end end + + test 'interesting subscription should return only questions with score higher than the threshold' do + threshold = 0.5 + + SiteSetting['InterestingSubscriptionScoreThreshold'] = threshold + + questions = subscriptions(:interesting).questions + + assert_questions_valid(questions) + questions.each do |question| + assert question.score >= threshold, + "Expected question #{question.id} with a score of #{question.score} to be excluded" + end + end + + test 'category subscription should return only questions from a specific category' do + category = categories(:main) + questions = subscriptions(:category).questions + + assert_questions_valid(questions) + questions.each do |question| + assert question.category == category, + "Expected quesiton #{question.id} to be from the #{category.name} category," \ + "actual: #{question.category.name || 'no category'}" + end + end + + private + + def assert_questions_valid(questions) + assert_not_nil(questions) + assert_not(questions.empty?, 'No questions returned') + assert(questions.size <= 100, 'Too many questions returned') + end end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 9d8cfd64b..290f267b7 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -1,6 +1,22 @@ require 'test_helper' class UserTest < ActiveSupport::TestCase + test 'search should correctly narrow down users by username' do + users = User.search('deleted') + + users.each do |u| + assert_equal true, u.username.include?('deleted') + end + end + + test 'search should match any substring in usernames' do + users = User.search('oderat') + + users.each do |u| + assert_equal true, u.username.include?('oderat') + end + end + test 'users should be destructible in a single call' do assert_nothing_raised do users(:standard_user).destroy! @@ -18,14 +34,14 @@ class UserTest < ActiveSupport::TestCase end test 'has_post_privilege should grant all to OP' do - assert_equal true, users(:standard_user).has_post_privilege?('flag_curate', posts(:question_one)) + assert_equal true, users(:standard_user).post_privilege?('flag_curate', posts(:question_one)) end test 'website_domain should strip out everything but domain' do assert_equal 'example.com', users(:closer).website_domain end - test 'can_update should determine if the user can update a given post' do + test 'can_update? should determine if the user can update a given post' do basic_user = users(:basic_user) post_owner = users(:standard_user) category = categories(:main) @@ -41,21 +57,21 @@ class UserTest < ActiveSupport::TestCase post_type: post_type, category: category) - assert_equal true, post_owner.can_update(post, post_type) - assert_equal false, basic_user.can_update(post, post_type) - assert_equal true, users(:moderator).can_update(post, post_type) - assert_equal true, users(:editor).can_update(post, post_type) + assert_equal true, post_owner.can_update?(post, post_type) + assert_equal false, basic_user.can_update?(post, post_type) + assert_equal true, users(:moderator).can_update?(post, post_type) + assert_equal true, users(:editor).can_update?(post, post_type) basic_user.community_user.grant_privilege!('unrestricted') - assert_equal false, basic_user.can_update(post, post_type) - assert_equal true, basic_user.can_update(post, post_types(:free_edit)) + assert_equal false, basic_user.can_update?(post, post_type) + assert_equal true, basic_user.can_update?(post, post_types(:free_edit)) end - test 'can_push_to_network should determine if the user can push updates to network' do + test 'can_push_to_network? should determine if the user can push updates to network' do post_type = post_types(:help_doc) - assert_equal false, users(:standard_user).can_push_to_network(post_type) - assert_equal true, users(:global_moderator).can_push_to_network(post_type) - assert_equal true, users(:global_admin).can_push_to_network(post_type) + assert_equal false, users(:standard_user).can_push_to_network?(post_type) + assert_equal true, users(:global_moderator).can_push_to_network?(post_type) + assert_equal true, users(:global_admin).can_push_to_network?(post_type) end test 'community_user is based on context' do @@ -67,30 +83,42 @@ class UserTest < ActiveSupport::TestCase assert_equal user.community_user.reload, cu1 end - test 'is_moderator for community moderator' do - assert_equal users(:moderator).is_moderator, true + test 'at_least_moderator? for community moderator' do + assert_equal users(:moderator).at_least_moderator?, true end - test 'is_moderator for community moderator in another context' do + test 'at_least_moderator? for community moderator in another context' do RequestContext.community = Community.create(host: 'other', name: 'Other') - assert_equal users(:moderator).is_moderator, false + assert_equal users(:moderator).at_least_moderator?, false + end + + test 'at_least_moderator? for global moderator' do + assert_equal users(:global_moderator).at_least_moderator?, true end - test 'is_moderator for global moderator' do - assert_equal users(:global_moderator).is_moderator, true + test 'at_least_global_moderator?' do + admin = users(:admin) + mod = users(:moderator) + global_admin = users(:global_admin) + global_mod = users(:global_moderator) + + assert_equal admin.at_least_global_moderator?, false + assert_equal mod.at_least_global_moderator?, false + assert_equal global_mod.at_least_global_moderator?, true + assert_equal global_admin.at_least_global_moderator?, true end - test 'is_admin for community admin' do - assert_equal users(:admin).is_admin, true + test 'admin? for community admin' do + assert_equal users(:admin).admin?, true end - test 'is_admin for community admin in another context' do + test 'admin? for community admin in another context' do RequestContext.community = Community.create(host: 'other', name: 'Other') - assert_equal users(:admin).is_admin, false + assert_equal users(:admin).admin?, false end - test 'is_admin for global admin' do - assert_equal users(:global_admin).is_admin, true + test 'admin? for global admin' do + assert_equal users(:global_admin).admin?, true end test 'ensure_community_user! does not alter existing community_user' do @@ -121,4 +149,155 @@ class UserTest < ActiveSupport::TestCase assert_equal cu, current_cu assert_equal user.community_users.count, original_count + 1 end + + test 'moderator_on? should only be true for users that are moderators or admins on a community' do + community = communities(:sample) + basic = users(:basic_user) + std = users(:standard_user) + mod = users(:moderator) + admin = users(:admin) + + assert_equal basic.moderator_on?(community.id), false + assert_equal std.moderator_on?(community.id), false + assert_equal mod.moderator_on?(community.id), true + assert_equal admin.moderator_on?(community.id), true + end + + test 'moderator_on? should always be true for global moderators and admins with profile on a community' do + global_mod = users(:global_moderator) + global_admin = users(:global_admin) + + communities.each do |c| + assert_equal global_mod.moderator_on?(c.id), global_mod.profile_on?(c.id) + assert_equal global_admin.moderator_on?(c.id), global_admin.profile_on?(c.id) + end + end + + test 'ability_on? should be false for users that do not have a profile on a community' do + fake = communities(:fake) + basic = users(:basic_user) + std = users(:standard_user) + mod = users(:moderator) + admin = users(:admin) + + abilities.each do |ability| + assert_equal basic.ability_on?(fake.id, ability.internal_id), false + assert_equal std.ability_on?(fake.id, ability.internal_id), false + assert_equal mod.ability_on?(fake.id, ability.internal_id), false + assert_equal admin.ability_on?(fake.id, ability.internal_id), false + end + end + + test 'ability_on? should always be true for moderators and admins with profile on a community' do + community = communities(:sample) + mod = users(:moderator) + admin = users(:admin) + + abilities.each do |ability| + assert_equal mod.ability_on?(community.id, ability.internal_id), true + assert_equal admin.ability_on?(community.id, ability.internal_id), true + end + end + + test 'ability_on? should return true for every undeleted user with profile on a community' do + everyone = abilities(:everyone) + + communities.each do |community| + CommunityUser.unscoped.undeleted.where(community_id: community.id).each do |cu| + unless cu.user.deleted + assert_equal cu.user.ability_on?(community.id, everyone.internal_id), true + end + end + end + end + + test 'ability_on? should correctly check for unrestricted ability' do + community = communities(:sample) + basic = users(:basic_user) + system = users(:system) + + unrestricted = abilities(:unrestricted) + + [basic, system].each do |user| + assert_equal user.ability_on?(community.id, unrestricted.internal_id), false + end + + CommunityUser.unscoped.undeleted.where(community_id: community.id).where.not(user_id: [basic.id, system.id]).each do |cu| + assert_equal cu.user.ability_on?(community.id, unrestricted.internal_id), !cu.user.deleted + end + end + + test 'metric should correctly return user stats' do + std = users(:editor) + + ['p', '1', '2', 's', 'v', 'V', 'E'].each do |name| + count = std.metric(name) + assert count.positive?, "Expected metric #{name} to be positive, actual: #{count}" + end + end + + test 'no_blank_unicode_in_username validation should fail if the username contains blank Unicode chars' do + user = User.new(id: 42, username: "\u200BWhy\u200Bso\u200Bmuch\u200Bspace?") + + assert_equal false, user.valid? + assert(user.errors[:username]&.any? { |m| m.include?('blank unicode') }) + end + + test 'no_links_in_username validation should fail if the username contains URLs' do + user = User.new(id: 42, username: 'Visit our https://example.com site!') + + assert_equal false, user.valid? + assert(user.errors[:username]&.any? { |m| m.include?('links') }) + end + + test 'username_not_fake_admin validation should fail if the username contains a resticted badge' do + admin_badge = SiteSetting['AdminBadgeCharacter'] + mod_badge = SiteSetting['ModBadgeCharacter'] + + [admin_badge, mod_badge].each do |badge| + user = User.new(id: 42, username: "I am totally a #{badge}") + + assert_equal false, user.valid? + assert(user.errors[:username]&.any? { |m| m.include?(badge) }) + end + end + + test 'inspect should work with the model' do + std = users(:standard_user) + + assert_nothing_raised do + std.inspect + end + end + + test 'moderator_communities should correctly list mod communities' do + Community.create(name: 'Test', host: 'test.host') + + global_result = users(:global_moderator).moderator_communities + assert_equal Community.all.size, global_result.size + + local_result = users(:moderator).moderator_communities + assert_equal 1, local_result.size + end + + test 'admin_communities should correctly list admin communities' do + Community.create(name: 'Test', host: 'test.host') + + global_result = users(:global_admin).admin_communities + assert_equal Community.all.size, global_result.size + + local_result = users(:admin).admin_communities + assert_equal 1, local_result.size + end + + test 'not_blocklisted? should correctly determine if the user is blocklisted' do + std = users(:standard_user) + + std.skip_reconfirmation! + std.update(email: blocked_items(:email).value) + std.valid? + + assert_equal false, std.changed? + assert std.errors[:base].intersect?(ApplicationRecord.useful_err_msg) + end end diff --git a/test/models/vote_test.rb b/test/models/vote_test.rb index 69cffe196..40e13e9ee 100644 --- a/test/models/vote_test.rb +++ b/test/models/vote_test.rb @@ -26,21 +26,28 @@ class VoteTest < ActiveSupport::TestCase rep_change_down = cpt.downvote_rep expected_rep_change = (3 * rep_change_up) + (2 * rep_change_down) - post.votes.create([ - { user: users(:standard_user), recv_user: author, vote_type: 1 }, - { user: users(:closer), recv_user: author, vote_type: 1 }, - { user: users(:deleter), recv_user: author, vote_type: 1 }, - { user: users(:moderator), recv_user: author, vote_type: -1 }, - { user: users(:admin), recv_user: author, vote_type: -1 } - ]) - - assert_equal post.votes.count, 5 - assert_equal post.upvote_count, 3 - assert_equal post.downvote_count, 2 + new_votes = [ + { user: users(:standard_user), recv_user: author, vote_type: 1 }, + { user: users(:closer), recv_user: author, vote_type: 1 }, + { user: users(:deleter), recv_user: author, vote_type: 1 }, + { user: users(:moderator), recv_user: author, vote_type: -1 }, + { user: users(:admin), recv_user: author, vote_type: -1 } + ] + + post.votes.create(new_votes) + + num_votes = new_votes.length + num_upvotes = new_votes.inject(0) { |a, c| a + (c[:vote_type] == 1 ? 1 : 0) } + num_downvotes = new_votes.inject(0) { |a, c| a + (c[:vote_type] == -1 ? 1 : 0) } + + # NB: fixtures can & should be able to add more votes than created here + assert post.votes.count >= num_votes, "Expected more than #{num_votes} votes, actual: #{post.votes.count}" + assert post.upvote_count >= num_upvotes, "Expected more than #{num_upvotes} upvotes, actual: #{post.upvote_count}" + assert post.downvote_count >= num_downvotes, "Expected more than #{num_downvotes} downvotes, actual: #{post.downvote_count}" assert_equal author.reputation, previous_rep + expected_rep_change end - test 'Vote.total_rep_change should result in correct rep change for given votes' do + test 'total_rep_change should result in correct rep change for given votes' do post = posts(:answer_one) cpt = CategoryPostType.find_by(category: posts(:answer_one).category, post_type: posts(:answer_one).post_type) rep_change_up = cpt.upvote_rep diff --git a/test/support/application_test_helper.rb b/test/support/application_test_helper.rb index 5050aa883..a1c512180 100644 --- a/test/support/application_test_helper.rb +++ b/test/support/application_test_helper.rb @@ -1,8 +1,8 @@ module ApplicationTestHelper def assert_array_equal(expected, object) [:include?, :each, :size].each do |method| - assert expected.respond_to? method, "Expected `expected' to be array-like, got #{expected.class}" - assert object.respond_to? method, "Expected `object' to be array-like, got #{object.class}" + assert expected.respond_to?(method, "Expected `expected' to be array-like, got #{expected.class}") + assert object.respond_to?(method, "Expected `object' to be array-like, got #{object.class}") end assert expected.size == object.size, "Array sizes are unequal\n+++#{object}\n---#{expected}" diff --git a/test/system/post_test.rb b/test/system/post_system_test.rb similarity index 94% rename from test/system/post_test.rb rename to test/system/post_system_test.rb index d99cd266c..ddc6f31ed 100644 --- a/test/system/post_test.rb +++ b/test/system/post_system_test.rb @@ -1,6 +1,8 @@ require 'application_system_test_case' -class PostTest < ApplicationSystemTestCase +# Renamed from PostTest to PostSystemTest to avoid clash with test/models/post_test.rb +# when running rails test:all +class PostSystemTest < ApplicationSystemTestCase # ------------------------------------------------------- # Create # ------------------------------------------------------- @@ -126,7 +128,7 @@ class PostTest < ApplicationSystemTestCase # Check that answers are displayed somewhere on the page assert post.children.any?, 'The post for this system test should have answers' - post.children.where(deleted: false).each do |child| + post.children.undeleted.each do |child| assert_text child.body end end @@ -137,6 +139,8 @@ class PostTest < ApplicationSystemTestCase click_on 'Active' + assert post.children.count > 1, 'Answer buttons are only shown for posts with more than one answer' + assert_current_path post_url(post, sort: 'active') end diff --git a/test/test_helper.rb b/test/test_helper.rb index db21f1264..63ef1fa97 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,13 +1,14 @@ require 'simplecov' require 'simplecov_json_formatter' SimpleCov.formatter = SimpleCov::Formatter::JSONFormatter -SimpleCov.start 'rails' +SimpleCov.start('rails') ENV['RAILS_ENV'] ||= 'test' require File.expand_path('../config/environment', __dir__) require 'rails/test_help' require 'minitest/ci' +require 'minitest/mock' Minitest::Ci.report_dir = Rails.root.join('test/reports/minitest').to_s # cleanup seeds after all tests are run (can't use teardown callbacks as they run after each test) @@ -49,6 +50,8 @@ EmailLog, ErrorLog, Subscription, + MicroAuth::Token, + MicroAuth::App, User, Notification, SiteSetting, @@ -71,15 +74,20 @@ end end -Dir.glob(Rails.root.join('test/support/**/*.rb')).sort.each { |f| require f } +Dir.glob(Rails.root.join('test/support/**/*.rb')).each { |f| require f } class ActiveSupport::TestCase + include ActiveJob::TestHelper + # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all setup :set_request_context - teardown :clear_cache + teardown do + clear_enqueued_jobs + clear_cache + end protected @@ -87,7 +95,7 @@ class ActiveSupport::TestCase # This means that we can leverage it's smart transaction behavior to significantly speed up our tests (by a factor of 6). def load_fixtures(config) # Loading a fixture deletes all data in the same tables, so it has to happen before we load our normal seeds. - fixture_data = super(config) + fixture_data = super load_tags_paths load_seeds @@ -107,7 +115,8 @@ def load_seeds end def load_tags_paths - ActiveRecord::Base.connection.execute File.read(Rails.root.join('db/scripts/create_tags_path_view.sql')) + sql = File.read(Rails.root.join('db/scripts/create_tags_path_view.sql')) + ActiveRecord::Base.connection.execute(sql) end def clear_cache @@ -120,6 +129,22 @@ def copy_abilities(community_id) end end + def assert_valid_json_response + assert_nothing_raised do + parsed = JSON.parse(response.body) + assert_not_nil(parsed) + end + end + + def assert_json_response_message(expected) + assert_equal expected, JSON.parse(response.body)['message'] + end + + def assert_redirected_to_sign_in + assert_response(:found) + assert_redirected_to(new_user_session_path) + end + PostMock = Struct.new(:title, :body_markdown, :body, :tags_cache, :edit, keyword_init: true) def sample diff --git a/tsconfig.json b/tsconfig.json index a1b8adeed..5556dbb7b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "module": "none", "noEmit": true, "target": "ES2021", - "types": ["./global.d.ts", "jquery", "select2"] + "types": ["./global.d.ts", "@types/jquery", "@types/select2"] }, "include": ["./app/assets/javascripts"], "exclude": [