diff --git a/.circleci/config.yml b/.circleci/config.yml index d83e7122e..3df3f5de9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,13 +1,12 @@ version: 2.1 jobs: - test: + test-ruby27: docker: - image: cimg/ruby:2.7-node - image: cimg/mysql:8.0 command: [--default-authentication-plugin=mysql_native_password] environment: MYSQL_ROOT_HOST: '%' - MYSQL_USER: 'root' MYSQL_ROOT_PASSWORD: 'root' MYSQL_DATABASE: 'qpixel_test' - image: cimg/redis:7.0 @@ -23,21 +22,157 @@ jobs: - checkout - restore_cache: keys: - - qpixel-{{ checksum "Gemfile" }} - - qpixel- + - qpixel-ruby27-{{ checksum "Gemfile.lock" }} + - qpixel-ruby27- - run: name: Install Bundler & gems command: | gem install bundler bundle install --path=~/gems + - run: + name: Clean unnecessary gems + command: | + bundle clean --force + - save_cache: + key: qpixel-ruby27-{{ checksum "Gemfile.lock" }} + paths: + - ~/gems + - run: + name: Copy key + command: | + if [ -z "$MASTER_KEY" ]; then rm config/credentials.yml.enc; else echo "$MASTER_KEY" > config/master.key; fi + - run: + name: Prepare config & database + environment: + RAILS_ENV: test + command: | + cp config/database.sample.yml config/database.yml + cp config/storage.sample.yml config/storage.yml + bundle exec rails db:create + bundle exec rails db:schema:load + bundle exec rails db:migrate + bundle exec rails test:prepare + - run: + name: Current revision + command: | + git rev-parse $(git rev-parse --abbrev-ref HEAD) + - run: + name: Coveralls token + command: | + if [ -z "$COVERALLS_REPO_TOKEN" ]; then echo "Skipping coveralls"; else echo "repo_token: $COVERALLS_REPO_TOKEN" > .coveralls.yml; fi + - run: + name: Test + command: | + bundle exec rails test + - store_test_results: + path: "~/qpixel/test/reports" + system-test-ruby27: + docker: + - image: cimg/ruby:2.7-browsers + - image: cimg/mysql:8.0 + command: [--default-authentication-plugin=mysql_native_password] + environment: + MYSQL_ROOT_HOST: '%' + MYSQL_ROOT_PASSWORD: 'root' + MYSQL_DATABASE: 'qpixel_test' + - image: cimg/redis:7.0 + + working_directory: ~/qpixel + + steps: + - run: + name: Install packages + command: | + sudo apt-get --allow-releaseinfo-change -qq update + sudo apt-get -y install git libmariadb-dev libmagickwand-dev + - checkout + - restore_cache: + keys: + - qpixel-ruby27-{{ checksum "Gemfile.lock" }} + - qpixel-ruby27- + - run: + name: Install Bundler & gems + command: | + gem install bundler + bundle install --path=~/gems + - run: + name: Clean unnecessary gems + command: | + bundle clean --force + - save_cache: + key: qpixel-ruby27-{{ checksum "Gemfile.lock" }} + paths: + - ~/gems + - run: + name: Copy key + command: | + if [ -z "$MASTER_KEY" ]; then rm config/credentials.yml.enc; else echo "$MASTER_KEY" > config/master.key; fi + - run: + name: Prepare config & database + environment: + RAILS_ENV: test + command: | + cp config/database.sample.yml config/database.yml + cp config/storage.sample.yml config/storage.yml + bundle exec rails db:create + bundle exec rails db:schema:load + bundle exec rails db:migrate + bundle exec rails test:prepare + - run: + name: Current revision + command: | + git rev-parse $(git rev-parse --abbrev-ref HEAD) + - run: + name: Test + command: | + bundle exec rails test:system + - store_test_results: + path: "~/qpixel/test/reports" + - store_artifacts: + path: "~/qpixel/tmp/screenshots" + when: on_fail + + test-ruby31: + docker: + - image: cimg/ruby:3.1-node + - image: cimg/mysql:8.0 + command: [ --default-authentication-plugin=mysql_native_password ] + environment: + MYSQL_ROOT_HOST: '%' + MYSQL_ROOT_PASSWORD: 'root' + MYSQL_DATABASE: 'qpixel_test' + - image: cimg/redis:7.0 + + working_directory: ~/qpixel + + steps: + - run: + name: Install packages + command: | + sudo apt-get --allow-releaseinfo-change -qq update + sudo apt-get -y install git libmariadb-dev libmagickwand-dev + - checkout + - restore_cache: + keys: + - qpixel-ruby31-{{ checksum "Gemfile.lock" }} + - qpixel-ruby31- + - run: + name: Install Bundler & gems + command: | + gem install bundler + bundle install --path=~/gems + - run: + name: Clean unnecessary gems + command: | + bundle clean --force - save_cache: - key: qpixel-{{ checksum "Gemfile" }} + key: qpixel-ruby31-{{ checksum "Gemfile.lock" }} paths: - ~/gems - run: name: Copy key command: | - echo "$MASTER_KEY" > config/master.key + if [ -z "$MASTER_KEY" ]; then rm config/credentials.yml.enc; else echo "$MASTER_KEY" > config/master.key; fi - run: name: Prepare config & database environment: @@ -56,17 +191,82 @@ jobs: - run: name: Coveralls token command: | - echo "repo_token: $COVERALLS_REPO_TOKEN" > .coveralls.yml + if [ -z "$COVERALLS_REPO_TOKEN" ]; then echo "Skipping coveralls"; else echo "repo_token: $COVERALLS_REPO_TOKEN" > .coveralls.yml; fi - run: name: Test command: | bundle exec rails test - store_test_results: path: "~/qpixel/test/reports" + system-test-ruby31: + docker: + - image: cimg/ruby:3.1-browsers + - image: cimg/mysql:8.0 + command: [ --default-authentication-plugin=mysql_native_password ] + environment: + MYSQL_ROOT_HOST: '%' + MYSQL_ROOT_PASSWORD: 'root' + MYSQL_DATABASE: 'qpixel_test' + - image: cimg/redis:7.0 + + working_directory: ~/qpixel + + steps: + - run: + name: Install packages + command: | + sudo apt-get --allow-releaseinfo-change -qq update + sudo apt-get -y install git libmariadb-dev libmagickwand-dev + - checkout + - restore_cache: + keys: + - qpixel-ruby31-{{ checksum "Gemfile.lock" }} + - qpixel-ruby31- + - run: + name: Install Bundler & gems + command: | + gem install bundler + bundle install --path=~/gems + - run: + name: Clean unnecessary gems + command: | + bundle clean --force + - save_cache: + key: qpixel-ruby31-{{ checksum "Gemfile.lock" }} + paths: + - ~/gems + - run: + name: Copy key + command: | + if [ -z "$MASTER_KEY" ]; then rm config/credentials.yml.enc; else echo "$MASTER_KEY" > config/master.key; fi + - run: + name: Prepare config & database + environment: + RAILS_ENV: test + command: | + cp config/database.sample.yml config/database.yml + cp config/storage.sample.yml config/storage.yml + bundle exec rails db:create + bundle exec rails db:schema:load + bundle exec rails db:migrate + bundle exec rails test:prepare + - run: + name: Current revision + command: | + git rev-parse $(git rev-parse --abbrev-ref HEAD) + - run: + name: Test + command: | + bundle exec rails test:system + - store_test_results: + path: "~/qpixel/test/reports" + - store_artifacts: + path: "~/qpixel/tmp/screenshots" + when: on_fail rubocop: docker: - - image: cimg/ruby:2.7-node + - image: cimg/ruby:3.1-node working_directory: ~/qpixel @@ -79,15 +279,19 @@ jobs: - checkout - restore_cache: keys: - - qpixel-{{ checksum "Gemfile" }} - - qpixel- + - qpixel-ruby31-{{ checksum "Gemfile.lock" }} + - qpixel-ruby31- - run: name: Install Bundler & gems command: | gem install bundler bundle install --path=~/gems + - run: + name: Clean unnecessary gems + command: | + bundle clean --force - save_cache: - key: qpixel-{{ checksum "Gemfile" }} + key: qpixel-ruby31-{{ checksum "Gemfile.lock" }} paths: - ~/gems - run: @@ -97,7 +301,7 @@ jobs: deploy: docker: - - image: cimg/ruby:2.7-node + - image: cimg/ruby:3.1-node working_directory: ~/qpixel @@ -115,11 +319,17 @@ jobs: workflows: test_lint: jobs: - - test + - test-ruby27 + - test-ruby31 + - system-test-ruby27 + - system-test-ruby31 - rubocop - deploy: requires: - - test + - test-ruby27 + - test-ruby31 + - system-test-ruby27 + - system-test-ruby31 - rubocop filters: branches: diff --git a/.github/ISSUE_TEMPLATE/bug-feature-via-meta.md b/.github/ISSUE_TEMPLATE/bug-feature-via-meta.md new file mode 100644 index 000000000..9141bee85 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-feature-via-meta.md @@ -0,0 +1,16 @@ +--- +name: Bug/Feature via Meta +about: Use when you're copying a bug/feature request here from Meta. +title: '' +labels: '' +assignees: '' + +--- + + +meta:123 + + + + + diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..3d933dccf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. Include device/browser/OS information if it's relevant. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..bbcbbe7d6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.gitignore b/.gitignore index 004c052c2..d665bfb07 100644 --- a/.gitignore +++ b/.gitignore @@ -9,13 +9,16 @@ # Docker environment (production) docker/env +# mount mysql volume so that its easy to interact with the database outside of the container. +# This also allows persistent database storage +docker/mysql +# allow custom docker-compose files as users might have different needs +docker-compose*.yml +!docker-compose.yml # Don't track changes to the docker-compose .env file only in project root /.env -# mount mysql volume so that its easy to interact with the database outside of the container. This also allows persistent database storage -docker/mysql - # Ignore the default SQLite database. /db/*.sqlite3 /db/*.sqlite3-journal @@ -29,6 +32,7 @@ coverage/ coverage/* .idea +.vscode test/reports @@ -51,4 +55,11 @@ qpixel-import.tar.gz # Ignore Vim stuff. *.swp +# Ignore emacs stuff. +*~ + dump.rdb + +# Ignore IRB files +.irbrc +.irb_history diff --git a/.rubocop.yml b/.rubocop.yml index 5e6705c20..edf12b0c0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -4,7 +4,7 @@ require: - ./lib/rubocop/path_in_helpers.rb AllCops: - TargetRubyVersion: 2.6 + TargetRubyVersion: 2.7 Exclude: - 'config/**/*' - 'db/**/*' @@ -43,13 +43,13 @@ Metrics/BlockLength: Metrics/BlockNesting: Max: 5 Metrics/ClassLength: - Max: 300 + Max: 350 Metrics/CyclomaticComplexity: Max: 30 Metrics/MethodLength: Max: 60 Metrics/ModuleLength: - Max: 200 + Max: 250 Metrics/PerceivedComplexity: Enabled: false diff --git a/.sample.irbrc b/.sample.irbrc new file mode 100644 index 000000000..998084f49 --- /dev/null +++ b/.sample.irbrc @@ -0,0 +1 @@ +Qpixel.irb! if defined?(Qpixel) diff --git a/CODE-STANDARDS.md b/CODE-STANDARDS.md index ad30362a0..35e2888a9 100644 --- a/CODE-STANDARDS.md +++ b/CODE-STANDARDS.md @@ -121,7 +121,7 @@ Pseudo-classes and pseudo-element selectors should appear *after* the main selec `@media` and other nested [*at-rules*](https://developer.mozilla.org/en-US/docs/Web/CSS/At-rule) should be added to the end of the document, preceded by an extra blank line. -See [*landing-page/primary.css @1ca2f671*](https://github.com/codidact/landing-page/blob/1ca2f671/dist/assets/css/primary.css) +See [*landing-page/dist/assets/css/primary.css @1ca2f671*](https://github.com/codidact/landing-page/blob/1ca2f671/dist/assets/css/primary.css) for an example of all of the above. ### Spacing @@ -130,9 +130,9 @@ for an example of all of the above. - Do not write more than one statement per line. ### Line breaks -Rules should be separated by a blank line, except for the two special cases provided in item -[**#3**](#Order-of-selectors) - namely, an extra blank line is expected between universal selectors and other -selectors, as well as before nested _at-rules_. As such, these rule groups should be separated by *two* spaces. +Rules should be separated by a blank line, except for the two special cases provided in +[Order of selectors](#order-of-selectors) - namely, an extra blank line is expected between universal selectors and +other selectors, as well as before nested _at-rules_. As such, these rule groups should be separated by *two* spaces. All properties are written on their own line and end with a semicolon. The closing bracket must appear in its own line. @@ -259,7 +259,7 @@ When referencing external resources (including those local to the domain), do no HTTPS access to resources if possible. Prefer retrieving resources by canonical URIs when possible, i.e. those that do not redirect upon request. Check -with a command-line tool or a service such as [apitester.com](https://apitester.com/) to be sure. +with a command-line tool or a service such as [apitester.org](https://apitester.org/app) to be sure. ```html @@ -379,20 +379,23 @@ When adding an ID or class to reference an element from JavaScript, prefix the v #### `` - If using `target="_blank"` to open links in a new tab, also include `rel="noopener noreferrer"`. -- If a JS-enabled link is necessary (it normally shouldn't - see note below), prefer `href="#"` over `href="javascript:void(0)"` (and its equivalent `href="javascript:;"`). Please do combine this with `event.preventDefault()` in order to prevent unwanted scrolling and adding of pointless entries to the user's browsing history. +- If a JS-enabled link is necessary (it normally shouldn't be - see note below), prefer `href="#"` over +`href="javascript:void(0)"` (and its equivalent `href="javascript:;"`). Please do combine this with +`event.preventDefault()` in order to prevent unwanted scrolling and adding of pointless entries to the user's browsing +history. **Note:** Since the above directive still requires JavaScript to be enabled, the RECOMMENDED first-line approach is to either link to an actual page/resource that performs the same expected action, or use a `') + .click(function () { + navigator.clipboard.writeText(content); + $(this).text('Copied!'); + setTimeout(() => { $(this).text('Copy'); }, 2000); + })) + }); +}); \ No newline at end of file diff --git a/app/assets/javascripts/comments.js b/app/assets/javascripts/comments.js index 508de72d6..b2af26bf7 100644 --- a/app/assets/javascripts/comments.js +++ b/app/assets/javascripts/comments.js @@ -77,7 +77,10 @@ $(() => { const $tgt = $(evt.target); const $comment = $tgt.parents('.comment'); const $commentBody = $comment.find('.comment--body'); + const $thread = $comment.parents('.thread'); const commentId = $comment.attr('data-id'); + const postId = $thread.attr('data-post'); + const threadId = $thread.attr('data-thread'); const originalComment = $commentBody.clone(); const resp = await fetch(`/comments/${commentId}`, { @@ -89,7 +92,7 @@ $(() => { const formTemplate = `
- + { $commentBody.html(formTemplate); + $commentBody.find(`#comment-content`).on('keyup', pingable_popup); + $(`.js-discard-edit[data-comment-id="${commentId}"]`).click(() => { $commentBody.html(originalComment.html()); }); @@ -195,7 +200,9 @@ $(() => { }); const pingable = {}; - $(document).on('keyup', '.js-comment-field', async ev => { + $(document).on('keyup', '.js-comment-field', pingable_popup); + + async function pingable_popup(ev) { if (QPixel.Popup.isSpecialKey(ev.keyCode)) { return; } @@ -242,7 +249,7 @@ $(() => { else { QPixel.Popup.destroyAll(); } - }); + } $('.js-new-thread-link').on('click', async ev => { ev.preventDefault(); diff --git a/app/assets/javascripts/embed.js b/app/assets/javascripts/embed.js index f8a6547e1..5797df56b 100644 --- a/app/assets/javascripts/embed.js +++ b/app/assets/javascripts/embed.js @@ -10,9 +10,11 @@ $(() => { // Only embed raw YT links, i.e. not [text](link), only [link](link) if ((href.startsWith('https://youtube.com') || href.startsWith('https://www.youtube.com')) && $tgt.text() === href) { const videoId = /v=([^$&]+)/.exec(href); - $tgt.after(``); - $tgt.remove(); + if (videoId) { + $tgt.after(``); + $tgt.remove(); + } } // Likewise, only raw Spotify links diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js new file mode 100644 index 000000000..b8b8d63e1 --- /dev/null +++ b/app/assets/javascripts/filters.js @@ -0,0 +1,148 @@ +$(() => { + $('.js-filter-select').each(async (_, el) => { + const $select = $(el); + const $form = $select.closest('form'); + const $formFilters = $form.find('.form--filter'); + const $saveButton = $form.find('.filter-save'); + const $isDefaultCheckbox = $form.find('.filter-is-default'); + const categoryId = $isDefaultCheckbox.val(); + let defaultFilter = await QPixel.defaultFilter(categoryId); + const $deleteButton = $form.find('.filter-delete'); + + // Enables/Disables Save & Delete buttons programatically + async function computeEnables() { + const filters = await QPixel.filters(); + const filterName = $select.val(); + + // Nothing set + if (!filterName) { + $saveButton.prop('disabled', true); + $deleteButton.prop('disabled', true); + return; + } + + const filter = filters[filterName] + + // New filter + if (!filter) { + $saveButton.prop('disabled', false); + $deleteButton.prop('disabled', true); + return; + } + + // Not a new filter + $deleteButton.prop('disabled', filter.system); + + const hasChanges = [...$formFilters].some(el => { + const filterValue = filter[el.dataset.name]; + let elValue = $(el).val(); + if (filterValue?.constructor == Array) { + elValue = elValue ?? []; + return filterValue.length != elValue.length || filterValue.some((v, i) => v[1] != elValue[i]); + } + else { + return filterValue ? filterValue != elValue : elValue; + } + }); + const defaultStatusChanged = $isDefaultCheckbox.prop('checked') != (defaultFilter === $select.val()); + $saveButton.prop('disabled', !defaultStatusChanged && (filter.system || !hasChanges)); + } + + async function initializeSelect() { + defaultFilter = await QPixel.defaultFilter(categoryId); + $isDefaultCheckbox.prop('checked', defaultFilter === $select.val()); + const filters = await QPixel.filters(); + + function template(option) { + if (option.id == '') { return 'Default'; } + + const filter = filters[option.id]; + const name = `${option.text}`; + const systemIndicator = filter?.system + ? ' (System)' + : ''; + const newIndicator = !filter + ? ' (New)' + : ''; + return $(name + systemIndicator + newIndicator); + } + + // Clear out any old options + $select.children().filter((_, option) => option.value && !filters[option.value]).detach(); + $select.select2({ + data: Object.keys(filters), + tags: true, + + templateResult: template, + templateSelection: template + }).on('select2:select', async evt => { + const filterName = evt.params.data.id; + const preset = filters[filterName]; + + $isDefaultCheckbox.prop('checked', defaultFilter === $select.val()); + computeEnables(); + + // Name is not one of the presets, i.e user is creating a new preset + if (!preset) { + return; + } + + for (const [name, value] of Object.entries(preset)) { + const $el = $form.find(`.form--filter[data-name=${name}]`); + if (value?.constructor == Array) { + $el.val(null); + for (const val of value) { + $el.append(new Option(val[0], val[1], false, true)); + } + $el.trigger('change'); + } else { + $el.val(value).trigger('change'); + } + } + }); + computeEnables(); + } + + initializeSelect(); + + // Enable saving when the filter is changed + $formFilters.on('change', computeEnables); + $isDefaultCheckbox.on('change', computeEnables); + + async function saveFilter() { + if (!$form[0].reportValidity()) { return; } + + const filter = {}; + + for (const el of $formFilters) { + filter[el.dataset.name] = $(el).val(); + } + + await QPixel.setFilter($select.val(), filter, categoryId, $isDefaultCheckbox.prop('checked')); + defaultFilter = await QPixel.defaultFilter(categoryId); + + // Reinitialize to get new options + await initializeSelect(); + } + + $saveButton.on('click', saveFilter); + + function clear() { + $select.val(null).trigger('change'); + $form.find('.form--filter').val(null).trigger('change'); + $isDefaultCheckbox.prop('checked', false); + computeEnables(); + } + + $deleteButton?.on('click', async evt => { + if (confirm(`Are you sure you want to delete ${$select.val()}?`)) { + await QPixel.deleteFilter($select.val()); + // Reinitialize to get new options + await initializeSelect(); + clear(); + } + }); + + $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 68ff6e14e..dba93f7ec 100644 --- a/app/assets/javascripts/flags.js +++ b/app/assets/javascripts/flags.js @@ -28,9 +28,9 @@ $(() => { 'reason': $(`#flag-post-${postId}`).val() }; - if (requiresDetails && data['reason'].length < 15) { + if (requiresDetails && data['reason'].length < 1) { QPixel.createNotification('danger', - 'Details are required for this flag type - please enter at least 15 characters.'); + 'Details are required for this flag type - please enter a message.'); return; } @@ -50,7 +50,7 @@ $(() => { const messages = { comment: `Thanks! Your flag has been added as a comment for the author to review.` }; - const defaultMessage = `Thanks! A moderator will review your flag.`; + const defaultMessage = `Thanks! We will review your flag.`; QPixel.createNotification('success', messages[responseType] || defaultMessage); $(`#flag-post-${postId}`).val(''); } diff --git a/app/assets/javascripts/keyboard_tools.js b/app/assets/javascripts/keyboard_tools.js index 3ae4034ce..5e7d08e99 100644 --- a/app/assets/javascripts/keyboard_tools.js +++ b/app/assets/javascripts/keyboard_tools.js @@ -77,8 +77,27 @@ $(() => { } }); + /** + * Checks common modifier states on a given keyboard event + * @param {KeyboardEvent} e + * @returns {boolean} + */ + const getModifierState = (e) => { + return !!e.altKey || !!e.ctrlKey || !!e.metaKey || !!e.shiftKey; + }; + + /** + * Handles the "home" keyboard state + * @param {KeyboardEvent} e + */ function homeMenu(e) { - if (e.key === "?") { + const isHelp = e.key === "?"; + + if (!isHelp && getModifierState(e)) { + return; + } + + if (isHelp) { _CodidactKeyboard.dialog( 'Codidact Keyboard Shortcuts\n' + '===========================\n' + @@ -156,7 +175,15 @@ $(() => { } } + /** + * Handles "goto" keyboard state + * @param {KeyboardEvent} e + */ function gotoMenu(e) { + if (getModifierState(e)) { + return; + } + if (e.key === 'm') { window.location.href = '/'; } else if (e.key === 'u') { @@ -208,7 +235,15 @@ $(() => { } } + /** + * Handles the "goto/category" keyboard state + * @param {KeyboardEvent} e + */ function categoryMenu(e) { + if (getModifierState(e)) { + return; + } + const number = parseInt(e.key); if (!isNaN(number)) { const data = _CodidactKeyboard.categories(); @@ -219,7 +254,15 @@ $(() => { } } + /** + * Handles the "goto/category-tags" keyboard state + * @param {KeyboardEvent} e + */ function categoryTagsMenu(e) { + if (getModifierState(e)) { + return; + } + const number = parseInt(e.key); if (!isNaN(number)) { const data = Object.entries(_CodidactKeyboard.categories()); @@ -229,7 +272,15 @@ $(() => { } } + /** + * Handles the "goto/category-edits" keyboard state + * @param {KeyboardEvent} e + */ function categorySuggestedEditsMenu(e) { + if (getModifierState(e)) { + return; + } + const number = parseInt(e.key); if (!isNaN(number)) { const data = Object.entries(_CodidactKeyboard.categories()); @@ -239,7 +290,15 @@ $(() => { } } + /** + * Handles the "tools" keyboard state + * @param {KeyboardEvent} e + */ function toolsMenu(e) { + if (getModifierState(e)) { + return; + } + if (e.key === 'e') { window.location.href = $(_CodidactKeyboard.selectedItem).find('.tools--item i.fa.fa-pencil-alt').parent().attr("href"); } else if (e.key === 'h') { @@ -276,7 +335,15 @@ $(() => { } + /** + * Handles the "tools/vote" keyboard state + * @param {KeyboardEvent} e + */ function voteMenu(e) { + if (getModifierState(e)) { + return; + } + if (e.key === 'u') { const cl = $(_CodidactKeyboard.selectedItem).find('.vote-button[data-vote-type="1"]'); cl.click(); diff --git a/app/assets/javascripts/markdown.js b/app/assets/javascripts/markdown.js index 1166a6976..c3173ec90 100644 --- a/app/assets/javascripts/markdown.js +++ b/app/assets/javascripts/markdown.js @@ -30,7 +30,8 @@ $(() => { numbered: ['\n 1. ', null], heading: ['\n# ', null], hr: ['\n\n-----\n\n', null], - table: ['\n\n| Title1 | Title2 |\n|- | - |\n| row1_1 | row1_2 |\n\n', null] + table: ['\n\n| Title1 | Title2 |\n|- | - |\n| row1_1 | row1_2 |\n\n', null], + mathjax: ['$', '$'] }; if (Object.keys(actions).indexOf(action) !== -1) { @@ -117,7 +118,7 @@ $(() => { QPixel.addPrePostValidation(text => { // This regex catches Markdown images with no or default alt text. - const altRegex = /!\[(?:Image alt text)?\](?:\(.+(?!\\\))\)|\[.+(?!\\\])\])/gi; + const altRegex = /!\[(?:Image_alt_text)?\](?:\(.+(?!\\\))\)|\[.+(?!\\\])\])/gi; if (text.match(altRegex)) { const message = `It looks like you're posting an image with no alt text. Alt text is important for ` + `accessibility. Consider adding alt text to the images in your post - ` + diff --git a/app/assets/javascripts/notifications.js b/app/assets/javascripts/notifications.js index 269a3ff59..db8c6dec2 100644 --- a/app/assets/javascripts/notifications.js +++ b/app/assets/javascripts/notifications.js @@ -8,7 +8,7 @@ $(() => { ${notification.created_at}

${notification.content}

+ class="h-fw-bold is-not-underlined ${notification.is_read ? 'read' : ''} notification-link">${notification.content}

mark ${notification.is_read ? 'unread' : 'read'} @@ -67,8 +67,6 @@ $(() => { const item = $(makeNotification(notification)); $inboxContainer.append(item); }); - - $inboxContainer.append(`See all your notifications »`); } }); @@ -122,4 +120,8 @@ $(() => { const change = data.notification.is_read ? -1 : +1; changeInboxCount(change); }); + + $(document).on('click', '.notification-link', async ev => { + $(ev.target).parents('.inbox').removeClass('is-active'); + }); }); \ No newline at end of file diff --git a/app/assets/javascripts/post_histories.js b/app/assets/javascripts/post_histories.js new file mode 100644 index 000000000..4d8c7d985 --- /dev/null +++ b/app/assets/javascripts/post_histories.js @@ -0,0 +1,9 @@ +$(() => { + const openRelevantEditOnly = () => { + $("details.history-event").prop('open', false); + $(location.hash).prop('open', true); + } + + window.addEventListener("hashchange", openRelevantEditOnly); + openRelevantEditOnly(); +}); diff --git a/app/assets/javascripts/posts.js b/app/assets/javascripts/posts.js index 2bdc896ad..2b0210eed 100644 --- a/app/assets/javascripts/posts.js +++ b/app/assets/javascripts/posts.js @@ -4,15 +4,30 @@ const ALLOWED_TAGS = ['a', 'p', 'span', 'b', 'i', 'em', 'strong', 'hr', 'h1', 'h 'summary', 'ins', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 's']; const ALLOWED_ATTR = ['id', 'class', 'href', 'title', 'src', 'height', 'width', 'alt', 'rowspan', 'colspan', 'lang', 'start', 'dir']; +// this is a list of constructors to ignore even if they are removed by sanitizer (mostly comments & body) +const IGNORE_UNSUPPORTED = [Comment, HTMLBodyElement]; $(() => { + DOMPurify.addHook("uponSanitizeAttribute", (node, event) => { + const rowspan = node.getAttribute("rowspan"); + const colspan = node.getAttribute("colspan"); + + if (rowspan && Number.isNaN(+rowspan)) { + event.keepAttr = false; + } + + if (colspan && Number.isNaN(+colspan)) { + event.keepAttr = false; + } + }); + const $uploadForm = $('.js-upload-form'); const stringInsert = (str, idx, insert) => str.slice(0, idx) + insert + str.slice(idx); const placeholder = "![Uploading, please wait...]()"; - $uploadForm.find('input[type="file"]').on('change', async evt => { + $uploadForm.find('input[type="file"]').on('change', async (evt) => { const $postField = $('.js-post-field'); const postText = $postField.val(); const cursorPos = $postField[0].selectionStart; @@ -24,7 +39,7 @@ $(() => { $form.submit(); }); - $uploadForm.on('submit', async evt => { + $uploadForm.on('submit', async (evt) => { evt.preventDefault(); const $tgt = $(evt.target); @@ -63,7 +78,7 @@ $(() => { const $postField = $('.js-post-field'); const postText = $postField.val(); - $postField.val(postText.replace(placeholder, `![Image alt text](${data.link})`)); + $postField.val(postText.replace(placeholder, `![Image_alt_text](${data.link})`)); $tgt.parents('.modal').removeClass('is-active'); }); @@ -80,7 +95,24 @@ $(() => { tags: true }); - const saveDraft = async (postText, $field, manual = false) => { + /** + * @typedef {{ + * body: string + * comment?: string + * excerpt?: string + * license?: string + * tag_name?: string + * tags?: string[] + * title?: string + * }} PostDraft + * + * Attempts to save a post draft + * @param {PostDraft} draft post draft + * @param {JQuery} $field body input element + * @param {boolean} [manual] whether manual draft saving is enabled + * @returns {Promise} + */ + const saveDraft = async (draft, $field, manual = false) => { const autosavePref = await QPixel.preference('autosave', true); if (autosavePref !== 'on' && !manual) { return; @@ -93,23 +125,63 @@ $(() => { 'X-CSRF-Token': QPixel.csrfToken(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ - post: postText, - path: location.pathname - }) + body: JSON.stringify({ ...draft, path: location.pathname }) }); + if (resp.status === 200) { - const $el = $(`· draft saved`); - $field.parents('.widget').find('.js-post-field-footer').append($el); - $el.fadeOut(1500, function () { $(this).remove() }); + const $statusEl = $field.parents('.widget').find('.js-post-draft-status'); + + $statusEl.removeClass('transparent'); + + setTimeout(() => { + $statusEl.addClass('transparent'); + }, 1500); } }; - $('.js-save-draft').on('click', async ev => { - const $tgt = $(ev.target); - const $field = $tgt.parents('.widget').find('.js-post-field'); - const postText = $field.val(); - await saveDraft(postText, $field, true); + /** + * Extracts draft info from a given target + * @param {EventTarget} target post input field or "save draft" button + * @returns {{ draft: PostDraft, field: any }} + */ + const parseDraft = (target) => { + const $tgt = $(target); + const $form = $tgt.parents('form'); + + const $bodyField = $form.find('.js-post-field'); + const $licenseField = $form.find('.js-license-select'); + const $excerptField = $form.find('.js-tag-excerpt'); + + const $tagsField = $form.find('#post_tags_cache'); + const $titleField = $form.find('#post_title'); + const $commentField = $form.find('#edit_comment'); + const $tagNameField = $form.find('#tag_name'); + + const bodyText = $bodyField.val(); + const commentText = $commentField.val(); + const excerptText = $excerptField.val(); + const license = $licenseField.val(); + const tags = $tagsField.val(); + const titleText = $titleField.val(); + const tagName = $tagNameField.val(); + + /** @type {PostDraft} */ + const draft = { + body: bodyText, + comment: commentText, + excerpt: excerptText, + license: license, + tags: tags, + tag_name: tagName, + title: titleText, + }; + + return { draft, field: $bodyField }; + }; + + $('.js-save-draft').on('click', async (ev) => { + const { draft, field } = parseDraft(ev.target); + await saveDraft(draft, field, true); }); let featureTimeout = null; @@ -117,7 +189,27 @@ $(() => { const postFields = $('.post-field'); - postFields.on('paste', async evt => { + const draftFieldsSelectors = [ + '.js-post-field', + '.js-license-select', + '.js-tag-excerpt', + '#edit_comment', + '#post_tags_cache', + '#post_title', + '#tag_parent_id', + '#tag_name', + ]; + + // TODO: consider merging with post fields + $(draftFieldsSelectors.join(', ')).on('keyup change', (ev) => { + clearTimeout(draftTimeout); + draftTimeout = setTimeout(() => { + const { draft, field } = parseDraft(ev.target); + saveDraft(draft, field); + }, 1000); + }); + + postFields.on('paste', async (evt) => { if (evt.originalEvent.clipboardData.files.length > 0) { const $fileInput = $uploadForm.find('input[type="file"]'); $fileInput[0].files = evt.originalEvent.clipboardData.files; @@ -125,52 +217,71 @@ $(() => { } }); - postFields.on('focus keyup paste change markdown', evt => { - const $tgt = $(evt.target); - - if (!window.converter) { - window.converter = window.markdownit({ - html: true, - breaks: false, - linkify: true - }); - window.converter.use(window.markdownitFootnote); - window.converter.use(window.latexEscape); - } - window.setTimeout(() => { - const converter = window.converter; + postFields.on('focus keyup paste change markdown', (() => { + let previous = null; + return evt => { + const $tgt = $(evt.target); const text = $(evt.target).val(); - const unsafe_html = converter.render(text); - const html = DOMPurify.sanitize(unsafe_html, { - USE_PROFILES: { html: true }, - ALLOWED_TAGS, - ALLOWED_ATTR - }); - $tgt.parents('.form-group').siblings('.post-preview').html(html); - $tgt.parents('form').find('.js-post-html[name="__html"]').val(html + ''); - }, 0); - - if (featureTimeout) { - clearTimeout(featureTimeout); - } - - featureTimeout = setTimeout(() => { - if (window['MathJax']) { - MathJax.typeset(); + // Don't bother re-rendering if nothing's changed + if (text === previous) { return; } + previous = text; + if (!window.converter) { + window.converter = window.markdownit({ + html: true, + breaks: false, + linkify: true + }); + window.converter.use(window.markdownitFootnote); + window.converter.use(window.latexEscape); } - if (window['hljs']) { - hljs.highlightAll(); + window.setTimeout(() => { + const converter = window.converter; + const unsafe_html = converter.render(text); + const html = DOMPurify.sanitize(unsafe_html, { + ALLOWED_TAGS, + ALLOWED_ATTR + }); + + const removedElements = [...new Set(DOMPurify.removed + .filter(entry => entry.element && !IGNORE_UNSUPPORTED.some((ctor) => entry.element instanceof ctor)) + .map(entry => entry.element.localName))]; + + const removedAttributes = [...new Set(DOMPurify.removed + .filter(entry => entry.attribute) + .map(entry => [ + entry.attribute.name + (entry.attribute.value ? `='${entry.attribute.value}'` : ''), + entry.from.localName + ]))] + + $tgt.parents('form') + .find('.rejected-elements') + .toggleClass('hide', removedElements.length === 0 && removedAttributes.length === 0) + .find('ul') + .empty() + .append( + removedElements.map(name => $(`

  • <${name}>
  • `)), + removedAttributes.map(([attr, elName]) => $(`
  • ${attr} (in <${elName}>)
  • `))); + + $tgt.parents('.form-group').siblings('.post-preview').html(html); + $tgt.parents('form').find('.js-post-html[name="__html"]').val(html + ''); + }, 0); + + if (featureTimeout) { + clearTimeout(featureTimeout); } - }, 1000); - }).on('keyup', ev => { - clearTimeout(draftTimeout); - const text = $(ev.target).val(); - draftTimeout = setTimeout(() => { - saveDraft(text, $(ev.target)); - }, 3000); - }).trigger('markdown'); - postFields.parents('form').on('submit', async ev => { + featureTimeout = setTimeout(() => { + if (window['MathJax']) { + MathJax.typeset(); + } + if (window['hljs']) { + hljs.highlightAll(); + } + }, 1000); + }; + })()).trigger('markdown'); + + postFields.parents('form').on('submit', async (ev) => { const $tgt = $(ev.target); const field = $tgt.find('.post-field'); @@ -252,24 +363,64 @@ $(() => { $('.js-draft-loaded').each((i, e) => { $(e).parents('.widget').after(`
    Draft loaded. - You've edited this post before but didn't save it. We loaded your edits here for you. + You had edited this before but haven't saved it. We loaded the edits for you.
    `); }); - $('.js-permalink > .js-text').text('Copy Link'); - $('.js-permalink').on('click', ev => { - ev.preventDefault(); + const setCopyButtonState = ($button, state) => { + const isSuccess = state === "success"; + const buttonClass = isSuccess ? "is-green" : "is-danger"; + const iconClass = isSuccess ? "fa-check" : "fa-times"; + + const $icon = $button.find(".fa"); + + $icon.removeClass("fa-copy"); + $icon.addClass(iconClass); + $button.addClass(buttonClass); - const $tgt = $(ev.target).is('a') ? $(ev.target) : $(ev.target).parents('a'); - const link = $tgt.attr('href'); - navigator.clipboard.writeText(link); - $tgt.find('.js-text').text('Copied!'); setTimeout(() => { - $tgt.find('.js-text').text('Copy Link'); - }, 1000); + $icon.removeClass(iconClass); + $button.removeClass(buttonClass); + $icon.addClass("fa-copy"); + }, 1e3); + }; + + $(".js-permalink-trigger").removeAttr("hidden"); + + $(".js-permalink-copy").on("click", async (ev) => { + ev.preventDefault(); + + const $tgt = $(ev.target); + + const $button = $tgt.hasClass("js-permalink-copy") + ? $tgt + : $tgt.parents(".js-permalink-copy"); + + const postId = $button.data("post-id"); + const linkType = $button.data("link-type"); + + if (!postId || !linkType) { + return; + } + + const $input = $(`#permalink-${postId}-${linkType}`); + + const url = $input.val(); + + if (!url) { + return; + } + + try { + await navigator.clipboard.writeText(url); + setCopyButtonState($button, "success"); + } + catch (_e) { + setCopyButtonState($button, "error"); + } }); - $('.js-nominate-promotion').on('click', async ev => { + $('.js-nominate-promotion').on('click', async (ev) => { ev.preventDefault(); const $tgt = $(ev.target); @@ -291,7 +442,7 @@ $(() => { $('.js-mod-tools').removeClass('is-active'); }); - $('.js-cancel-edit').on('click', async ev => { + $('.js-cancel-edit').on('click', async (ev) => { ev.preventDefault(); let $btn = $(ev.target); diff --git a/app/assets/javascripts/privileges.js b/app/assets/javascripts/privileges.js index b61d17a0a..f63feeb69 100644 --- a/app/assets/javascripts/privileges.js +++ b/app/assets/javascripts/privileges.js @@ -27,7 +27,11 @@ $(() => { const $input = $td.find('.js-privilege-edit'); const name = $input.data('name'); const type = $input.data('type'); - const value = parseFloat($input.val() || '') || null; + + // incorrect input values will cause rawValue to be NaN + const rawValue = parseFloat($input.val()) + + const value = Number.isNaN(rawValue) ? null : rawValue; const resp = await fetch(`/admin/privileges/${name}`, { method: 'POST', diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index df4236002..d63f6bdf4 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -3,6 +3,30 @@ const validators = []; /** Counts notifications popped up at any time. */ let popped_modals_ct = 0; +/** + * @typedef {{ + * min_score: number | null, + * max_score: number | null, + * min_answers: number | null, + * max_answers: number | null, + * include_tags: [string, number][], + * exclude_tags: [string, number][], + * status: 'any' | 'closed' | 'open', + * system: boolean, + * }} Filter + * + * @typedef {{ + * id: number, + * username: string, + * is_moderator: boolean, + * is_admin: boolean, + * is_global_moderator: boolean, + * is_global_admin: boolean, + * trust_level: number, + * se_acct_id: string | null, + * }} User + */ + window.QPixel = { /** * Get the current CSRF anti-forgery token. Should be passed as the X-CSRF-Token header when @@ -20,7 +44,7 @@ window.QPixel = { * @param type the type to apply to the popup - warning, danger, etc. * @param message the message to show */ - createNotification: function(type, message) { + createNotification: function (type, message) { // Some messages include a date stamp, `append_date` governs that. let append_date = false; let message_with_date = message; @@ -42,26 +66,26 @@ window.QPixel = { } const span = ''; const button = (''); - $("
    ") - .addClass("notice has-shadow-3 is-" + type) - .html(button + '

    ' + message_with_date + '

    ') - .css({ - 'position': 'fixed', - 'top': '50px', - 'left': '50%', - 'transform': 'translateX(-50%)', - 'width': '100%', - 'max-width': '800px', - 'cursor': 'pointer' - }) - .on('click', function(ev) { - $(this).fadeOut(200, function() { - $(this).remove(); - popped_modals_ct = popped_modals_ct > 0 ? (popped_modals_ct - 1) : 0; - }); - }) - .appendTo(document.body); + span + ''); + $('
    ') + .addClass('notice has-shadow-3 is-' + type) + .html(button + '

    ' + message_with_date + '

    ') + .css({ + 'position': 'fixed', + 'top': '50px', + 'left': '50%', + 'transform': 'translateX(-50%)', + 'width': '100%', + 'max-width': '800px', + 'cursor': 'pointer' + }) + .on('click', function (ev) { + $(this).fadeOut(200, function () { + $(this).remove(); + popped_modals_ct = popped_modals_ct > 0 ? (popped_modals_ct - 1) : 0; + }); + }) + .appendTo(document.body); popped_modals_ct += 1; }, @@ -70,7 +94,7 @@ window.QPixel = { * @param el the element for which to find the offset. * @returns {{top: integer, left: integer, bottom: integer, right: integer}} */ - offset: function(el) { + offset: function (el) { const topLeft = $(el).offset(); return { top: topLeft.top, @@ -151,21 +175,59 @@ window.QPixel = { $field.val(prev.substring(0, $field[0].selectionStart) + text + prev.substring($field[0].selectionEnd)); }, + /** + * Used to prevent launching multiple requests to /users/me + * @type {Promise|null} + */ + _pendingUserResponse: null, + + /** + * @type {User|null} + */ _user: null, /** - * Get the user object for the current user. - * @returns {Promise} a JSON object containing user details + * FIFO-style fetch wrapper for /users/me requests + * @returns {Promise} */ - user: async () => { - if (QPixel._user) return QPixel._user; - const resp = await fetch('/users/me', { + _fetchUser () { + if (QPixel._pendingUserResponse) { + return QPixel._pendingUserResponse; + } + + const myselfPromise = fetch('/users/me', { credentials: 'include', headers: { 'Accept': 'application/json' } }); - QPixel._user = await resp.json(); + + QPixel._pendingUserResponse = myselfPromise; + + return myselfPromise; + }, + + /** + * Get the user object for the current user. + * @returns {Promise} a JSON object containing user details + */ + user: async () => { + if (QPixel._user != null || document.body.dataset.userId === 'none') { + return QPixel._user; + } + + try { + const resp = await QPixel._fetchUser(); + + if (!resp.bodyUsed) { + QPixel._user = await resp.json(); + } + } + finally { + // ensures pending user is cleared regardless of network errors + QPixel._pendingUserResponse = null; + } + return QPixel._user; }, @@ -176,30 +238,24 @@ window.QPixel = { * localStorage, or Redis via AJAX. * @returns {Promise} a JSON object containing user preferences */ - preferences: async () => { - if (this._preferences == null && !!localStorage['qpixel.user_preferences']) { - this._preferences = JSON.parse(localStorage['qpixel.user_preferences']); - - // If we don't have the global key, we're probably using an old preferences schema. - if (!this._preferences.global) { - delete localStorage['qpixel.user_preferences']; - this._preferences = null; - } + _getPreferences: async () => { + // Early return for the most frequent case (local variable already contains the preferences) + if (QPixel._preferences != null) { + return QPixel._preferences; } - else if (this._preferences == null) { - // If they're still null (or undefined) after loading from localStorage, we're probably on a site we haven't - // loaded them for yet. Load from Redis via AJAX. - const resp = await fetch('/users/me/preferences', { - credentials: 'include', - headers: { - 'Accept': 'application/json' - } - }); - const data = await resp.json(); - localStorage['qpixel.user_preferences'] = JSON.stringify(data); - this._preferences = data; + // Early return the preferences from localStorage unless null or undefined + const key = QPixel._preferencesLocalStorageKey(); + const localStoragePreferences = (key in localStorage) + ? JSON.parse(localStorage[key]) + : null; + if (localStoragePreferences != null) { + QPixel._preferences = localStoragePreferences; + return QPixel._preferences; } - return this._preferences; + // Preferences are still null (or undefined) after loading from localStorage, so we're probably on a site we + // haven't loaded them for yet. Load from Redis via AJAX. + await QPixel._cachedFetchPreferences(); + return QPixel._preferences; }, /** @@ -209,29 +265,25 @@ window.QPixel = { * @returns {Promise<*>} the value of the requested preference */ preference: async (name, community = false) => { - let prefs = await QPixel.preferences(); - let value = community ? prefs.community[name] : prefs.global[name]; - - // Deliberate === here: null is a valid value for a preference, but undefined means we haven't fetched it. - // If we haven't fetched a preference, that probably means it's new - run a full re-fetch. - if (value === undefined) { - const resp = await fetch('/users/me/preferences', { - credentials: 'include', - headers: { - 'Accept': 'application/json' - } - }); - const data = await resp.json(); - localStorage['qpixel.user_preferences'] = JSON.stringify(data); - this._preferences = data; + const user = await QPixel.user(); - prefs = await QPixel.preferences(); - value = community ? prefs.community[name] : prefs.global[name]; - return value; + if (!user) { + return null; } - else { + + let prefs = await QPixel._getPreferences(); + let value = community ? prefs.community[name] : prefs.global[name]; + + // Note that null is a valid value for a preference, but undefined means we haven't fetched it. + if (typeof (value) !== 'undefined') { return value; } + // If we haven't fetched a preference, that probably means it's new - run a full re-fetch. + await QPixel._cachedFetchPreferences(); + + prefs = await QPixel._getPreferences(); + value = community ? prefs.community[name] : prefs.global[name]; + return value; }, /** @@ -250,7 +302,7 @@ window.QPixel = { 'Accept': 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, value, community }) + body: JSON.stringify({name, value, community}) }); const data = await resp.json(); if (data.status !== 'success') { @@ -258,11 +310,163 @@ window.QPixel = { console.error(resp); } else { - this._preferences = data.preferences; - localStorage['qpixel.user_preferences'] = JSON.stringify(this._preferences); + QPixel._updatePreferencesLocally(data.preferences); + } + }, + + /** + * @returns {Promise>} + */ + filters: async () => { + if (this._filters == null) { + // If they're still null (or undefined) after loading from localStorage, we're probably on a site we haven't + // loaded them for yet. Load via AJAX. + const resp = await fetch('/users/me/filters', { + credentials: 'include', + headers: { + 'Accept': 'application/json' + } + }); + const data = await resp.json(); + localStorage['qpixel.user_filters'] = JSON.stringify(data); + this._filters = data; + } + + return this._filters; + }, + + /** + * Fetches default user filter for a given category + * @param categoryId id of the category to fetch + * @returns {Promise} + */ + defaultFilter: async (categoryId) => { + const user = await QPixel.user(); + + if (!user) { + return ''; + } + + const resp = await fetch(`/users/me/filters/default?category=${categoryId}`, { + credentials: 'include', + headers: { + 'Accept': 'application/json' + } + }); + + const data = await resp.json(); + return data.name; + }, + + setFilterAsDefault: async (categoryId, name) => { + const resp = await fetch(`/categories/${categoryId}/filters/default`, { + method: 'POST', + credentials: 'include', + headers: { + 'X-CSRF-Token': QPixel.csrfToken(), + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({name}) + }); + }, + + setFilter: async (name, filter, category, isDefault) => { + const resp = await fetch('/users/me/filters', { + method: 'POST', + credentials: 'include', + headers: { + 'X-CSRF-Token': QPixel.csrfToken(), + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(Object.assign(filter, {name, category, is_default: isDefault})) + }); + const data = await resp.json(); + if (data.status !== 'success') { + console.error(`Filter persist failed (${name})`); + console.error(resp); + } + else { + this._filters = data.filters; + localStorage['qpixel.user_filters'] = JSON.stringify(this._filters); + } + }, + + deleteFilter: async (name, system = false) => { + const resp = await fetch('/users/me/filters', { + method: 'DELETE', + credentials: 'include', + headers: { + 'X-CSRF-Token': QPixel.csrfToken(), + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({name, system}) + }); + const data = await resp.json(); + if (data.status !== 'success') { + console.error(`Filter deletion failed (${name})`); + console.error(resp); + } + else { + this._filters = data.filters; + localStorage['qpixel.user_filters'] = JSON.stringify(this._filters); } }, + /** + * Get the key to use for storing user preferences in localStorage, to avoid conflating users + * @returns string the localStorage key + */ + _preferencesLocalStorageKey: () => { + const id = document.body.dataset.userId; + const key = `qpixel.user_${id}_preferences`; + QPixel._preferencesLocalStorageKey = () => key; + return key; + }, + + /** + * Call _fetchPreferences but only the first time to prevent redundant HTTP requests + * @returns {Promise} + */ + _cachedFetchPreferences: async () => { + // No 'await' because we want the promise not its value + const cachedPromise = QPixel._fetchPreferences(); + // Redefine this function to await this same initial promise on every subsequent call + // This prevents multiple calls from triggering multiple redundant '_fetchPreferences' calls + QPixel._cachedFetchPreferences = async () => { + await cachedPromise; + }; + // Remember to await the promise so the very first call does not return before '_fetchPreferences' returns + await cachedPromise; + }, + + /** + * Update local variable _preferences and localStorage with an AJAX call for the user preferences + * @returns {Promise} + */ + _fetchPreferences: async () => { + const resp = await fetch('/users/me/preferences', { + credentials: 'include', + headers: { + 'Accept': 'application/json' + } + }); + const data = await resp.json(); + QPixel._updatePreferencesLocally(data); + }, + + /** + * Set local variable _preferences and localStorage to new preferences data + * @param data an object, containing the new preferences data + */ + _updatePreferencesLocally: data => { + QPixel._preferences = data; + const key = QPixel._preferencesLocalStorageKey(); + localStorage[key] = JSON.stringify(QPixel._preferences); + }, + /** * Get the word in a string that the given position is in, and the position within that word. * @param splat an array, containing the string already split by however you define a "word" diff --git a/app/assets/javascripts/suggested_edit.js b/app/assets/javascripts/suggested_edit.js index b4aae89a4..4763022eb 100644 --- a/app/assets/javascripts/suggested_edit.js +++ b/app/assets/javascripts/suggested_edit.js @@ -3,6 +3,7 @@ $(document).on('ready', function () { ev.preventDefault(); const self = $(ev.target); const editId = self.attr('data-suggested-edit-approve'); + const comment = $('#summary').val(); const resp = await fetch(`/posts/suggested-edit/${editId}/approve`, { method: 'POST', @@ -10,7 +11,8 @@ $(document).on('ready', function () { headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': QPixel.csrfToken() - } + }, + body: JSON.stringify({ comment }) }); const data = await resp.json(); diff --git a/app/assets/javascripts/tags.js b/app/assets/javascripts/tags.js index a2d71685f..460c215b5 100644 --- a/app/assets/javascripts/tags.js +++ b/app/assets/javascripts/tags.js @@ -14,7 +14,8 @@ $(() => { }; const template = (tag) => { - const tagSpan = `${tag.text}`; + const tagSynonyms = !!tag.synonyms ? ` (${tag.synonyms})` : ''; + const tagSpan = `${tag.text}${tagSynonyms}`; let desc = !!tag.desc ? splitWordsMaxLength(tag.desc, 120) : ''; const descSpan = !!tag.desc ? `
    ${desc[0]}${desc.length > 1 ? '...' : ''}` : @@ -22,7 +23,7 @@ $(() => { return $(tagSpan + descSpan); } - $('.js-tag-select').each((i, el) => { + $('.js-tag-select').each((_i, el) => { const $tgt = $(el); let $this; const useIds = $tgt.attr('data-use-ids') === 'true'; @@ -38,16 +39,16 @@ $(() => { data: function (params) { $this = $(this); // (for the tour) - if ($this.data('tag-set') === '-1') { - return Object.assign(params, { tag_set: "1" }); + if (Number($this.data('tag-set')) === -1) { + return Object.assign(params, { tag_set: '1' }); } return Object.assign(params, { tag_set: $this.data('tag-set') }); }, headers: { 'Accept': 'application/json' }, delay: 100, - processResults: data => { + processResults: (data) => { // (for the tour) - if ($this.data('tag-set') === '-1') { + if (Number($this.data('tag-set')) === -1) { return { results: [ { id: 1, text: 'hot-red-firebreather', desc: 'Very cute dragon' }, @@ -61,17 +62,66 @@ $(() => { results: data.map(t => ({ id: useIds ? t.id : t.name, text: t.name.replace(//g, '>'), + synonyms: processSynonyms($this, t.tag_synonyms), desc: t.excerpt })) }; }, }, + placeholder: '', templateResult: template, allowClear: true }); }); - $('.js-add-required-tag').on('click', ev => { + function processSynonyms($search, synonyms) { + if (!synonyms) return synonyms; + + let displayedSynonyms; + if (synonyms.length > 3) { + const searchValue = $search.data('select2').selection.$search.val().toLowerCase(); + displayedSynonyms = synonyms.filter(ts => ts.name.includes(searchValue)).slice(0, 3); + } else { + displayedSynonyms = synonyms; + } + let synonymsString = displayedSynonyms.map((ts) => `${ts.name.replace(//g, '>')}`).join(', '); + if (synonyms.length > displayedSynonyms.length) { + synonymsString += `, ${synonyms.length - displayedSynonyms.length} more synonyms`; + } + return synonymsString; + } + + $('#add-tag-synonym').on('click', (ev) => { + const $wrapper = $('#tag-synonyms-wrapper'); + const lastId = $wrapper.children('.tag-synonym').last().attr('data-id'); + const newId = parseInt(lastId, 10) + 1; + + //Duplicate the first element at the end of the wrapper + const newField = $wrapper.find('.tag-synonym[data-id="0"]')[0] + .outerHTML + .replace(/data-id="0"/g, 'data-id="' + newId + '"') + .replace(/(?attributes(\]\[)|(_))0/g, '$' + newId) + $wrapper.append(newField); + + //Alter the newly added tag synonym + const $newTagSynonym = $wrapper.children().last(); + $newTagSynonym.find('.tag-synonym-name').removeAttr('value').removeAttr('readonly').removeAttr('disabled'); + $newTagSynonym.find('.destroy-tag-synonym').attr('value', 'false'); + $newTagSynonym.show(); + + //Add handler for removing an element + $newTagSynonym.find(`.remove-tag-synonym`).click(removeTagSynonym); + }); + + $('.remove-tag-synonym').click(removeTagSynonym); + + function removeTagSynonym() { + const synonym = $(this).closest('.tag-synonym'); + synonym.find('.destroy-tag-synonym').attr('value', 'true'); + synonym.hide(); + } + + $('.js-add-required-tag').on('click', (ev) => { const $tgt = $(ev.target); const useIds = $tgt.attr('data-use-ids') === 'true'; const tagId = $tgt.attr('data-tag-id'); @@ -87,7 +137,7 @@ $(() => { } }); - $('.js-rename-tag').on('click', async ev => { + $('.js-rename-tag').on('click', async (ev) => { const $tgt = $(ev.target).is('a') ? $(ev.target) : $(ev.target).parents('a'); const categoryId = $tgt.attr('data-category'); const tagId = $tgt.attr('data-tag'); diff --git a/app/assets/javascripts/two_factor.js b/app/assets/javascripts/two_factor.js new file mode 100644 index 000000000..406dadc05 --- /dev/null +++ b/app/assets/javascripts/two_factor.js @@ -0,0 +1,33 @@ +$(() => { + $('.js-backup-code-form').on('submit', async ev => { + ev.preventDefault(); + const $tgt = $(ev.target); + const $input = $tgt.find('input[name="code"]'); + const code = $input.val(); + const req = await fetch('/users/two-factor/backup', { + method: 'POST', + headers: { + 'X-CSRF-Token': QPixel.csrfToken(), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ code }) + }); + const res = await req.json(); + + if (res.status === 'error') { + const $label = $tgt.find('label[for="code"]'); + $label.text(res.message); + $input.addClass('is-danger'); + $tgt.find('input[type="submit"]').removeAttr('disabled'); + } + else if (res.status === 'success') { + const codeForm = $(`
    + Show code + + +
    `); + $tgt.after(codeForm); + $tgt.remove(); + } + }); +}); diff --git a/app/assets/javascripts/votes.js b/app/assets/javascripts/votes.js index 611a78ffe..6dae33b0a 100644 --- a/app/assets/javascripts/votes.js +++ b/app/assets/javascripts/votes.js @@ -2,22 +2,29 @@ $(() => { $(document).on('click', '.vote-button', async evt => { const $tgt = $(evt.target).is('button') ? $(evt.target) : $(evt.target).parents('button'); const $post = $tgt.parents('.post'); - const $up = $post.find('.post--votes').find('.js-upvote-count'); - const $down = $post.find('.post--votes').find('.js-downvote-count'); + + const $container = $post.find(".post--votes"); + + const $up = $container.find('.js-upvote-count'); + const $down = $container.find('.js-downvote-count'); const voteType = $tgt.data('vote-type'); const voted = $tgt.hasClass('is-active'); if (voted) { const voteId = $tgt.attr('data-vote-id'); + const resp = await fetch(`/votes/${voteId}`, { method: 'DELETE', credentials: 'include', headers: { 'X-CSRF-Token': QPixel.csrfToken() } }); + const data = await resp.json(); + if (data.status === 'OK') { $up.text(`+${data.upvotes}`); $down.html(`−${data.downvotes}`); + $container.attr("title", `Score: ${data.score}`); $tgt.removeClass('is-active') .removeAttr('data-vote-id'); } @@ -34,10 +41,13 @@ $(() => { headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': QPixel.csrfToken() }, body: JSON.stringify({post_id: $post.data('post-id'), vote_type: voteType}) }); + const data = await resp.json(); + if (data.status === 'modified' || data.status === 'OK') { $up.text(`+${data.upvotes}`); $down.html(`−${data.downvotes}`); + $container.attr("title", `Score: ${data.score}`); $tgt.addClass('is-active') .attr('data-vote-id', data.vote_id); diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 2b105d33f..44a2e1d21 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -145,22 +145,6 @@ hr { } } -.footnote-ref a::before { - content: '['; -} - -div.post-preview .footnote-ref a::before { - content: ''; -} - -.footnote-ref a::after { - content: ']'; -} - -div.post-preview .footnote-ref a::after { - content: ''; -} - .footnotes-sep + .footnotes { border-top: 0; } @@ -209,6 +193,10 @@ img { border-top: 1px solid #9daeb7; } +.widget .widget--body .widget--body-extra { + margin-left: 0.75em; +} + pre { background: #f0f0f0; border: 0; diff --git a/app/assets/stylesheets/categories.scss b/app/assets/stylesheets/categories.scss index 93b1c4f89..6989a8781 100644 --- a/app/assets/stylesheets/categories.scss +++ b/app/assets/stylesheets/categories.scss @@ -16,6 +16,12 @@ align-items: center; justify-content: space-between; } + + & .category-meta--start { + display: flex; + align-items: center; + gap: 0.5em; + } } .category-header--nav { diff --git a/app/assets/stylesheets/comments.scss b/app/assets/stylesheets/comments.scss index df4686166..f80352758 100644 --- a/app/assets/stylesheets/comments.scss +++ b/app/assets/stylesheets/comments.scss @@ -65,6 +65,17 @@ font-style: italic; } +.post--comments-header { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.post--comments-container { + margin-bottom: 1rem; +} + .post--comments-thread.is-inline { padding: 0.5rem 0.25rem; display: flex; @@ -139,7 +150,7 @@ .new-thread-modal { box-shadow: 0 3px 5px -2px #eee; border: 1px solid #d0d9dd; - margin-top: 10px; + margin-top: 1rem; padding: 0.7em; display: none; } diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index baa824c4d..284c3418e 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -17,14 +17,6 @@ select.form-element { @media screen and (min-width: $screen-md) { flex-direction: row; - - & > :first-child { - margin: 0 0.5em 0 0 !important; - } - - & > :last-child { - margin: 0 0 0 0.5em !important; - } } & > .form-group { diff --git a/app/assets/stylesheets/post_history.scss b/app/assets/stylesheets/post_history.scss index 55d9411b2..12cfdcaf9 100644 --- a/app/assets/stylesheets/post_history.scss +++ b/app/assets/stylesheets/post_history.scss @@ -13,6 +13,10 @@ display: unset; font-weight: unset; color: unset; + + & .droppanel { + position: fixed; + } } &:last-of-type { diff --git a/app/assets/stylesheets/posts.scss b/app/assets/stylesheets/posts.scss index a1a548af6..67e911056 100644 --- a/app/assets/stylesheets/posts.scss +++ b/app/assets/stylesheets/posts.scss @@ -89,7 +89,25 @@ h1 .badge.is-tag.is-master-tag { width: calc(100% + 2px); + .widget--footer { - margin-bottom: 0; + border-top: none; + align-items: center; + margin: 0; + + &.mdhint { + display: flex; + flex-wrap: wrap; + gap: 1em 0; + justify-content: space-between; + + & > * { + padding: 0; + } + } + + & > .draft-status { + text-align: center; + transition: opacity 0.5s ease-in-out; + } } } diff --git a/app/assets/stylesheets/site_settings.scss b/app/assets/stylesheets/site_settings.scss index f9a52d4d0..9cdec8029 100644 --- a/app/assets/stylesheets/site_settings.scss +++ b/app/assets/stylesheets/site_settings.scss @@ -1,4 +1,5 @@ .site-setting--value { min-height: 1em; min-width: 2em; + overflow-wrap: anywhere; } \ No newline at end of file diff --git a/app/assets/stylesheets/users.scss b/app/assets/stylesheets/users.scss index a79c0662a..867e962e3 100644 --- a/app/assets/stylesheets/users.scss +++ b/app/assets/stylesheets/users.scss @@ -43,6 +43,7 @@ } } + .profile-text { padding: 0.125em; line-height: 1.5; @@ -50,17 +51,35 @@ overflow: auto; } -.user-profile-heading { - padding: 0.5em; +.user-profile-heading-container { + align-items: center; border-bottom: 1px solid #ddd; - margin-bottom: 0; -} + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.5em; + + & > .user-profile-heading { + flex-grow: 1; + margin-bottom: 0; + margin-top: 0; + padding: 0.5em; + &:not(:last-child) { + padding-right: 0; + } + } + + & > .button:last-child { + margin-right: 0; + } +} .user-profile--image { text-align: center; img { width: 100%; + object-fit: contain; } } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index be684f5f9..2ed9f314b 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -95,6 +95,16 @@ pre.pre-wrap { white-space: pre-wrap !important; } +.copy-button { + display: none; + position: absolute; + right: 0; +} + +div:hover > .copy-button { + display: block; +} + .stat-panel { flex: 1; border: 1px solid $muted-graphic; @@ -271,3 +281,11 @@ span.spoiler { color: $key; } } + +.clearfix { + overflow: hidden; +} + +.transparent { + opacity: 0; +} diff --git a/app/controllers/active_storage/base_controller.rb b/app/controllers/active_storage/base_controller.rb new file mode 100644 index 000000000..63cf54708 --- /dev/null +++ b/app/controllers/active_storage/base_controller.rb @@ -0,0 +1,17 @@ +class ActiveStorage::BaseController < ActionController::Base + before_action :enforce_signed_in + include ActiveStorage::SetCurrent + protect_from_forgery with: :exception + + self.etag_with_template_digest = false + + protected + + def enforce_signed_in + # If not restricted, the user is signed in or the environment is test, allow all content. + return true if !SiteSetting['RestrictedAccess'] || user_signed_in? || Rails.env.test? + + redirect_to '/', status: :forbidden + false + end +end diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index b7453e9ce..5d13f6d3b 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -3,7 +3,7 @@ class AdminController < ApplicationController before_action :verify_admin, except: [:change_back, :verify_elevation] before_action :verify_global_admin, only: [:admin_email, :send_admin_email, :new_site, :create_site, :setup, :setup_save, :hellban] - before_action :verify_developer, only: [:change_users, :impersonate] + before_action :verify_developer, only: [:change_users, :impersonate, :all_email, :send_all_email] def index; end @@ -52,6 +52,18 @@ def send_admin_email redirect_to admin_path end + def all_email; end + + def send_all_email + Thread.new do + AdminMailer.with(body_markdown: params[:body_markdown], subject: params[:subject]).to_all_users.deliver_now + end + AuditLog.admin_audit(event_type: 'send_all_email', user: current_user, + comment: "Subject: #{params[:subject]}") + flash[:success] = t 'admin.email_being_sent' + redirect_to admin_path + end + def audit_log @logs = if current_user.is_global_admin AuditLog.unscoped.where.not(log_type: ['user_annotation', 'user_history']) @@ -170,7 +182,12 @@ def verify_elevation return not_found unless session[:impersonator_id].present? @impersonator = User.find session[:impersonator_id] - if @impersonator&.valid_password? params[:password] + if @impersonator&.sso_profile.present? + session.delete :impersonator_id + AuditLog.admin_audit(event_type: 'impersonation_end', related: current_user, user: @impersonator) + sign_out @impersonator + redirect_to new_saml_user_session_path + elsif @impersonator&.valid_password? params[:password] session.delete :impersonator_id AuditLog.admin_audit(event_type: 'impersonation_end', related: current_user, user: @impersonator) sign_in @impersonator diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ce8c81040..efa5963ca 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -7,9 +7,15 @@ class ApplicationController < ActionController::Base protect_from_forgery with: :exception before_action :configure_permitted_parameters, if: :devise_controller? before_action :set_globals + before_action :enforce_signed_in, unless: :devise_controller? before_action :check_if_warning_or_suspension_pending before_action :distinguish_fake_community before_action :stop_the_awful_troll + + # Before checking 2fa enforcing or access, store the location that the user is trying to access. + # In case re-authentication is necessary / the user signs in, we can direct back to this location directly. + before_action :store_user_location!, if: :storable_location? + before_action :enforce_2fa before_action :block_write_request, if: :read_only_mode? @@ -17,15 +23,19 @@ class ApplicationController < ActionController::Base def upload if ActiveStorage::Blob.service.class.name.end_with?('S3Service') - redirect_to helpers.upload_remote_url(params[:key]), status: 301 + redirect_to helpers.upload_remote_url(params[:key]), status: 301, allow_other_host: true else blob = params[:key] - redirect_to url_for(ActiveStorage::Blob.find_by(key: blob.is_a?(String) ? blob : blob.key)) + redirect_to url_for(ActiveStorage::Blob.find_by(key: blob.is_a?(String) ? blob : blob.key)), + allow_other_host: true end end def dashboard @communities = Community.all + @edits = Post.unscoped do + SuggestedEdit.unscoped.joins(:post).where(active: true).group(Arel.sql('posts.category_id')).count + end render layout: 'without_sidebar' end @@ -309,7 +319,8 @@ def enforce_2fa # Enable users to log out even if 2fa is enforced !request.fullpath.end_with?('/users/sign_out') && (current_user.is_global_admin || - current_user.is_global_moderator) + current_user.is_global_moderator) && + (current_user.sso_profile.blank? || SiteSetting['Enable2FAForSsoUsers']) redirect_path = '/users/two-factor' unless request.fullpath.end_with?(redirect_path) flash[:notice] = 'All global admins and global moderators must enable two-factor authentication to continue' \ @@ -319,6 +330,39 @@ def enforce_2fa end end + # Ensure that the user is signed in before showing any content. If the user is not signed in, display the main page. + # + # Exceptions: + # - 4** and 500 error pages + # - stylesheets and javascript + # - assets + # - /help, /policy, /help/* and /policy/* + def enforce_signed_in + # If not restricted, the user is signed in or the environment is test, allow all content. + return true if !SiteSetting['RestrictedAccess'] || user_signed_in? || Rails.env.test? + + # Allow error pages and assets + path = request.fullpath + return true if path.start_with?('/4') || path == '/500' || + path.start_with?('/assets/') || + path.end_with?('.css') || path.end_with?('.js') + + # Make available to controller that the we should not leak posts in the sidebar + @prevent_sidebar = true + + # Allow /help (help center), /help/* and /policy/* depending on settings + help = SiteSetting['RestrictedAccessHelpPagesPublic'] + policy = SiteSetting['RestrictedAccessPolicyPagesPublic'] + return true if (help && path.start_with?('/help/')) || + (policy && path.start_with?('/policy/')) || + (path == '/help' && (help || policy)) + + store_location_for(:user, request.fullpath) if storable_location? + + render 'errors/restricted_content', layout: 'without_sidebar', status: :forbidden + false + end + def block_write_request(**add) respond_to do |format| format.html do @@ -342,12 +386,24 @@ def user_signed_in? helpers.user_signed_in? end + def sso_sign_in_enabled? + helpers.sso_sign_in_enabled? + end + + def devise_sign_in_enabled? + helpers.devise_sign_in_enabled? + end + def authenticate_user!(_fav = nil, **_opts) unless user_signed_in? respond_to do |format| format.html do flash[:error] = 'You need to sign in or sign up to continue.' - redirect_to new_user_session_path + if devise_sign_in_enabled? + redirect_to new_user_session_path + else + redirect_to new_saml_user_session_path + end end format.json do render json: { error: 'You need to sign in or sign up to continue.' }, status: 401 @@ -355,4 +411,27 @@ def authenticate_user!(_fav = nil, **_opts) end end end + + # Checks if the requested location should be stored. + # + # Its important that the location is NOT stored if: + # - The request method is not GET (non idempotent) + # - The request is handled by a Devise controller such as Devise::SessionsController as that could cause an + # infinite redirect loop. + # - The request is an Ajax request as this can lead to very unexpected behaviour. + # - The request is to a location we dont want to store, such as: + # - Anything trying to fetch for the current user (filters, preferences, etc) as it is not the actual page + # - The mobile login, as it would redirect to the code url after the sign in + # - Uploaded files, as these appear in posts and are not the main route we would want to store + def storable_location? + request.get? && is_navigational_format? && !devise_controller? && !request.xhr? && + !request.path.start_with?('/users/me') && + !request.path.start_with?('/users/mobile-login') && + !request.path.start_with?('/uploads/') + end + + # Stores the location in the system for the current session, such that after login we send them back to the same page. + def store_user_location! + store_location_for(:user, request.fullpath) + end end diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index b8076e366..c319160f3 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -144,6 +144,7 @@ def category_params :color_code, :min_view_trust_level, :license_id, :sequence, :asking_guidance_override, :answering_guidance_override, :use_for_hot_posts, :use_for_advertisement, + :min_title_length, :min_body_length, :default_filter_id, display_post_types: [], post_type_ids: [], required_tag_ids: [], topic_tag_ids: [], moderator_tag_ids: []) end @@ -161,8 +162,41 @@ def set_list_posts native: Arel.sql('att_source IS NULL DESC, last_activity DESC') } sort_param = sort_params[params[:sort]&.to_sym] || { last_activity: :desc } @posts = @category.posts.undeleted.where(post_type_id: @category.display_post_types) - .includes(:post_type, :tags).list_includes.paginate(page: params[:page], per_page: 50) - .order(sort_param) + .includes(:post_type, :tags).list_includes + filter_qualifiers = helpers.params_to_qualifiers + @active_filter = helpers.active_filter + + if filter_qualifiers.blank? && @active_filter[:name].blank? + if user_signed_in? + default_filter_id = helpers.default_filter(current_user.id, @category.id) + default_filter = Filter.find_by(id: default_filter_id) + default = :user if default_filter.present? + end + + if default_filter.nil? + default_filter = @category.default_filter + default = :category if default_filter.present? + end + + unless default_filter.nil? + filter_qualifiers = helpers.filter_to_qualifiers default_filter + @active_filter = { + default: default, + name: default_filter.name, + min_score: default_filter.min_score, + max_score: default_filter.max_score, + min_answers: default_filter.min_answers, + max_answers: default_filter.max_answers, + include_tags: default_filter.include_tags, + exclude_tags: default_filter.exclude_tags, + status: default_filter.status + } + end + end + + @posts = helpers.qualifiers_to_sql(filter_qualifiers, @posts) + @filtered = filter_qualifiers.any? + @posts = @posts.paginate(page: params[:page], per_page: 50).order(sort_param) end def update_last_visit(category) diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb index 57894f297..607d391a8 100644 --- a/app/controllers/comments_controller.rb +++ b/app/controllers/comments_controller.rb @@ -101,12 +101,19 @@ def create end def update + @post = @comment.post + @comment_thread = @comment.comment_thread before = @comment.content + before_pings = check_for_pings @comment_thread, before if @comment.update comment_params unless current_user.id == @comment.user_id AuditLog.moderator_audit(event_type: 'comment_update', related: @comment, user: current_user, comment: "from <<#{before}>>\nto <<#{@comment.content}>>") end + + after_pings = check_for_pings @comment_thread, @comment.content + apply_pings(after_pings - before_pings - @comment_thread.thread_follower.to_a) + render json: { status: 'success', comment: render_to_string(partial: 'comments/comment', locals: { comment: @comment }) } else diff --git a/app/controllers/email_logs_controller.rb b/app/controllers/email_logs_controller.rb new file mode 100644 index 000000000..40cded41f --- /dev/null +++ b/app/controllers/email_logs_controller.rb @@ -0,0 +1,28 @@ +class EmailLogsController < ApplicationController + skip_forgery_protection only: [:log] + skip_before_action :set_globals, only: [:log] + skip_before_action :distinguish_fake_community, only: [:log] + skip_before_action :enforce_signed_in, only: [:log] + + def log + message_type = request.headers['X-Amz-SNS-Message-Type'] + if ['SubscriptionConfirmation', 'Notification'].include? message_type + verifier = Aws::SNS::MessageVerifier.new + body = request.body.read + if verifier.authentic? body + aws_data = JSON.parse body + if message_type == 'SubscriptionConfirmation' + EmailLog.create(log_type: 'SubscriptionConfirmation', data: aws_data) + else + message_data = JSON.parse aws_data['Message'] + log_type = message_data['notificationType'] + destination = message_data['mail']['destination'].join(', ') + EmailLog.create(log_type: log_type, destination: destination, data: aws_data['Message']) + end + render plain: 'OK' + else + render plain: "You're not AWS. Go away.", status: 401 + end + end + end +end diff --git a/app/controllers/flags_controller.rb b/app/controllers/flags_controller.rb index a83947d37..f505f08fc 100644 --- a/app/controllers/flags_controller.rb +++ b/app/controllers/flags_controller.rb @@ -41,7 +41,7 @@ def new end def history - @user = User.find(params[:id]) + @user = helpers.user_with_me params[:id] unless @user == current_user || (current_user.is_admin || current_user.is_moderator) not_found return diff --git a/app/controllers/moderator_controller.rb b/app/controllers/moderator_controller.rb index ae482cd4f..04d2a94b5 100644 --- a/app/controllers/moderator_controller.rb +++ b/app/controllers/moderator_controller.rb @@ -48,8 +48,8 @@ def remove_promotion render json: { status: 'success', success: true } end - VoteData = Struct.new(:cast, :received) - VoteSummary = Struct.new(:breakdown, :types, :total) + VoteData = Struct.new(:cast, :received, keyword_init: true) + VoteSummary = Struct.new(:breakdown, :types, :total, keyword_init: true) def user_vote_summary @user = User.find params[:id] diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb index 02477f56a..b47d7fcfc 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -6,7 +6,7 @@ def index @notifications = Notification.unscoped.where(user: current_user).paginate(page: params[:page], per_page: 100) .order(Arel.sql('is_read ASC, created_at DESC')) respond_to do |format| - format.html { render :index } + format.html { render :index, layout: 'without_sidebar' } format.json { render json: @notifications, methods: :community_name } end end diff --git a/app/controllers/post_history_controller.rb b/app/controllers/post_history_controller.rb index 050c4ba38..543771e02 100644 --- a/app/controllers/post_history_controller.rb +++ b/app/controllers/post_history_controller.rb @@ -6,8 +6,30 @@ def post return not_found end - @history = PostHistory.where(post_id: params[:id]).includes(:post_history_type, :user, post_history_tags: [:tag]) - .order(created_at: :desc).paginate(per_page: 20, page: params[:page]) - render layout: 'without_sidebar' + @history = PostHistory.where(post_id: params[:id]) + .includes(:post_history_type, :user, post_history_tags: [:tag]) + .order(created_at: :desc, id: :desc) + .paginate(per_page: 20, page: params[:page]) + + if @post&.help_category.nil? + render layout: 'without_sidebar' + else + render 'post_history/post', layout: 'without_sidebar', locals: { show_content: false } + end + end + + def slug_post + @post = Post.by_slug(params[:slug], current_user) + + if @post.nil? + return not_found + end + + @history = PostHistory.where(post_id: @post.id) + .includes(:post_history_type, :user) + .order(created_at: :desc, id: :desc) + .paginate(per_page: 20, page: params[:page]) + + render 'post_history/post', layout: 'without_sidebar', locals: { show_content: false } end end diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index d3295ea77..5e8b293a8 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -26,7 +26,7 @@ def new return end - if ['HelpDoc', 'PolicyDoc'].include?(@post_type.name) + if @post_type.system? check_permissions # return # uncomment if you add more code after this end @@ -73,7 +73,7 @@ def create return end - if ['HelpDoc', 'PolicyDoc'].include?(@post_type.name) && !check_permissions + if @post_type.system? && !check_permissions return end @@ -144,12 +144,13 @@ def show return not_found end + # @post = @post.includes(:flags, flags: :post_flag_type) @children = if current_user&.privilege?('flag_curate') Post.where(parent_id: @post.id) else Post.where(parent_id: @post.id).undeleted .or(Post.where(parent_id: @post.id, user_id: current_user&.id).where.not(user_id: nil)) - end.includes(:votes, :user, :comments, :license, :post_type) + end.includes(:votes, :user, :comments, :license, :post_type, :flags, flags: :post_flag_type) .order(Post.arel_table[:id].not_eq(params[:answer])) .user_sort({ term: params[:sort], default: Arel.sql('deleted ASC, score DESC, RAND()') }, score: Arel.sql('deleted ASC, score DESC, RAND()'), active: :last_activity, @@ -159,6 +160,49 @@ def show def edit; end + # Attempts to update a given post + # @param post [Post] post the user is attempting to update + # @param user [User] user attempting to update the post + # @param body_rendered [String] new post body + # @param edit_post_params [ActionController::Parameters] edit parameters + # @return [Boolean] status of the operation + def do_update(post, user, body_rendered, **edit_post_params) + post.update(edit_post_params.merge(body: body_rendered, + last_edited_at: DateTime.now, + last_edited_by: user, + last_activity: DateTime.now, + last_activity_by: user)) + end + + # Attempts to update a given post network-wide. The update is manual to avoid + # skipping validations and fail early if at least one validation fails. + # @param post [Post] post from which the network push is initiated + # @param posts [ActiveRecord::Result] network posts to be updated + # @param user [User] user attempting to push updates to network + # @param body_rendered [String] new post body + # @param edit_post_params [ActionController::Parameters] edit parameters + # @return [Boolean] status of the operation + def do_update_network(post, posts, user, body_rendered, **edit_post_params) + update_status = true + + posts.each do |network_post| + network_post.update(edit_post_params.merge(body: body_rendered, + last_edited_at: DateTime.now, + last_edited_by_id: user.id, + last_activity: DateTime.now, + last_activity_by_id: user.id)) + + if network_post.errors.any? + post.errors.merge!(network_post.errors) + update_status = false + end + + next if update_status == true + end + + update_status + end + def update before = { body: @post.body_markdown, title: @post.title, tags: @post.tags.to_a } body_rendered = helpers.post_markdown(:post, :body_markdown) @@ -169,39 +213,67 @@ def update return redirect_to post_path(@post) end - if current_user.privilege?('edit_posts') || current_user.is_moderator || current_user == @post.user || \ - (@post_type.is_freely_editable && current_user.privilege?('unrestricted')) - if ['HelpDoc', 'PolicyDoc'].include?(@post_type.name) && (current_user.is_global_moderator || \ - current_user.is_global_admin) && params[:network_push] == 'true' - posts = Post.unscoped.where(post_type_id: [PolicyDoc.post_type_id, HelpDoc.post_type_id], - doc_slug: @post.doc_slug, body: @post.body) - update_params = edit_post_params.to_h.merge(body: body_rendered, last_edited_at: DateTime.now, - last_edited_by_id: current_user.id, last_activity: DateTime.now, - last_activity_by_id: current_user.id) - posts.update_all(**update_params.symbolize_keys) - posts.each do |post| - PostHistory.post_edited(post, current_user, before: before[:body], - after: @post.body_markdown, comment: params[:edit_comment], - before_title: before[:title], after_title: @post.title, - before_tags: before[:tags], after_tags: @post.tags) + if current_user.can_update(@post, @post_type) + if current_user.can_push_to_network(@post_type) && params[:network_push] == 'true' + # post network push & post histories creation must be atomic to prevent sync issues on error + @post.transaction do + posts = Post.unscoped.where(post_type_id: [PolicyDoc.post_type_id, HelpDoc.post_type_id], + doc_slug: @post.doc_slug, + body: @post.body) + + update_status = do_update_network(@post, posts, current_user, body_rendered, **edit_post_params) + + if update_status + posts.each do |post| + history_entry = PostHistory.post_edited(post, current_user, before: before[:body], + after: @post.body_markdown, comment: params[:edit_comment], + before_title: before[:title], after_title: @post.title, + before_tags: before[:tags], after_tags: @post.tags) + + if history_entry&.errors&.any? + @post.errors.merge!(history_entry.errors) + raise ActiveRecord::Rollback + end + end + + flash[:success] = "#{helpers.pluralize(posts.to_a.size, 'post')} updated." + redirect_to help_path(slug: @post.doc_slug) + end + + next end - flash[:success] = "#{helpers.pluralize(posts.to_a.size, 'post')} updated." - redirect_to help_path(slug: @post.doc_slug) else - if @post.update(edit_post_params.merge(body: body_rendered, - last_edited_at: DateTime.now, last_edited_by: current_user, - last_activity: DateTime.now, last_activity_by: current_user)) - PostHistory.post_edited(@post, current_user, before: before[:body], - after: @post.body_markdown, comment: params[:edit_comment], - before_title: before[:title], after_title: @post.title, - before_tags: before[:tags], after_tags: @post.tags) - Rails.cache.delete "community_user/#{current_user.community_user.id}/metric/E" - do_draft_delete(URI(request.referer || '').path) - redirect_to post_path(@post) - else - render :edit, status: :bad_request + # post update & post history creation must be atomic to prevent sync issues on error + @post.transaction do + update_status = do_update(@post, current_user, body_rendered, **edit_post_params) + + if update_status + history_entry = PostHistory.post_edited(@post, current_user, before: before[:body], + after: @post.body_markdown, comment: params[:edit_comment], + before_title: before[:title], after_title: @post.title, + before_tags: before[:tags], after_tags: @post.tags) + + if history_entry&.errors&.any? + @post.errors.merge!(history_entry.errors) + raise ActiveRecord::Rollback + end + + if params[:redact] + PostHistory.redact(@post, current_user) + end + Rails.cache.delete "community_user/#{current_user.community_user.id}/metric/E" + do_draft_delete(URI(request.referer || '').path) + redirect_to post_path(@post) + end + + next end end + + # this is only reached if we rollback the transaction or fail validations + if @post.errors.any? + render :edit, status: :bad_request + end else new_user = !current_user.privilege?('unrestricted') rate_limit = SiteSetting["RL_#{new_user ? 'NewUser' : ''}SuggestedEdits"] @@ -232,7 +304,7 @@ def update do_draft_delete(URI(request.referer || '').path) redirect_to post_path(@post) else - @post.errors = edit.errors + @post.errors.copy!(edit.errors) render :edit, status: :bad_request end end @@ -315,6 +387,12 @@ def delete return end + if @post.post_type.is_freely_editable && !current_user&.is_moderator + flash[:danger] = helpers.i18ns('posts.cant_delete_community') + redirect_to post_path(@post) + return + end + if @post.children.any? { |a| !a.deleted? && a.score >= 0.5 } && !current_user&.is_moderator flash[:danger] = helpers.i18ns('posts.cant_delete_responded') redirect_to post_path(@post) @@ -386,15 +464,14 @@ def restore end def document - @post = Post.unscoped.where(doc_slug: params[:slug], community_id: [RequestContext.community_id, nil]).first - not_found && return if @post.nil? + @post = Post.by_slug(params[:slug], current_user) - if @post&.help_category == '$Disabled' - not_found - end - if @post&.help_category == '$Moderator' && !current_user&.is_moderator + if @post.nil? not_found end + + # Make sure we don't leak featured posts in the sidebar + render layout: 'without_sidebar' if @prevent_sidebar end def upload @@ -417,6 +494,9 @@ def help_center .order(:help_ordering, :title) .group_by(&:post_type_id) .transform_values { |posts| posts.group_by { |p| p.help_category.presence } } + + # Make sure we don't leak featured posts in the sidebar + render layout: 'without_sidebar' if @prevent_sidebar end def change_category @@ -506,14 +586,37 @@ def feature render json: { status: 'success', success: true } end + # saving by-field is kept for backwards compatibility with old drafts def save_draft - key = "saved_post.#{current_user.id}.#{params[:path]}" - saved_at = "saved_post_at.#{current_user.id}.#{params[:path]}" - RequestContext.redis.set key, params[:post] - RequestContext.redis.set saved_at, DateTime.now.iso8601 - RequestContext.redis.expire key, 86_400 * 7 - RequestContext.redis.expire saved_at, 86_400 * 7 - render json: { status: 'success', success: true, key: key } + expiration_time = 86_400 * 7 + + base_key = "saved_post.#{current_user.id}.#{params[:path]}" + + [:body, :comment, :excerpt, :license, :tag_name, :tags, :title].each do |key| + next unless params.key?(key) + + key_name = [:body, :saved_at].include?(key) ? base_key : "#{base_key}.#{key}" + + if key == :tags + valid_tags = params[key]&.select(&:present?) + + RequestContext.redis.del(key_name) + + if valid_tags.present? + RequestContext.redis.sadd(key_name, valid_tags) + end + else + RequestContext.redis.set(key_name, params[key]) + end + + RequestContext.redis.expire(key_name, expiration_time) + end + + saved_at_key = "saved_post_at.#{current_user.id}.#{params[:path]}" + RequestContext.redis.set(saved_at_key, DateTime.now.iso8601) + RequestContext.redis.expire(saved_at_key, expiration_time) + + render json: { status: 'success', success: true, key: base_key } end def delete_draft @@ -580,9 +683,13 @@ def unless_locked end def do_draft_delete(path) - key = "saved_post.#{current_user.id}.#{path}" - saved_at = "saved_post_at.#{current_user.id}.#{path}" - RequestContext.redis.del key, saved_at + keys = [:body, :comment, :excerpt, :license, :saved_at, :tags, :tag_name, :title].map do |key| + pfx = key == :saved_at ? 'saved_post_at' : 'saved_post' + base = "#{pfx}.#{current_user.id}.#{path}" + [:body, :saved_at].include?(key) ? base : "#{base}.#{key}" + end + + RequestContext.redis.del(*keys) end end # rubocop:enable Metrics/MethodLength diff --git a/app/controllers/reactions_controller.rb b/app/controllers/reactions_controller.rb index 8899d8fa4..23acd2311 100644 --- a/app/controllers/reactions_controller.rb +++ b/app/controllers/reactions_controller.rb @@ -33,13 +33,20 @@ def add reaction = Reaction.new(user: current_user, post: @post, reaction_type: reaction_type, comment: comment) - ActiveRecord::Base.transaction do - thread&.save! - comment&.save! - reaction.save! - end + begin + ActiveRecord::Base.transaction do + thread&.save! + comment&.save! + reaction.save! + end - render json: { status: 'success' } + render json: { status: 'success' } + rescue + render json: { status: 'failed', + message: "Could not create comment thread: #{(thread&.errors&.full_messages.to_a \ + + comment&.errors&.full_messages.to_a \ + + reaction&.errors&.full_messages.to_a).join(', ')}" } + end end def retract diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index f2e6673ef..202a3636b 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -1,20 +1,11 @@ class SearchController < ApplicationController def search - @posts = if params[:search].present? - search_data = helpers.parse_search(params[:search]) - posts = (current_user&.is_moderator || current_user&.is_admin ? Post : Post.undeleted) - .qa_only.list_includes - posts = helpers.qualifiers_to_sql(search_data[:qualifiers], posts) - posts = posts.paginate(page: params[:page], per_page: 25) + @posts, @qualifiers = helpers.search_posts + + @signed_out_me = @qualifiers.any? { |q| q[:param] == :user && q[:user_id].nil? } + + @active_filter = helpers.active_filter - if search_data[:search].present? - posts.search(search_data[:search]).user_sort({ term: params[:sort], default: :search_score }, - relevance: :search_score, score: :score, age: :created_at) - else - posts.user_sort({ term: params[:sort], default: :score }, - score: :score, age: :created_at) - end - end @count = begin @posts&.count rescue diff --git a/app/controllers/site_settings_controller.rb b/app/controllers/site_settings_controller.rb index 9134f8da7..256b54ecc 100644 --- a/app/controllers/site_settings_controller.rb +++ b/app/controllers/site_settings_controller.rb @@ -4,6 +4,14 @@ class SiteSettingsController < ApplicationController before_action :verify_admin before_action :verify_global_admin, only: [:global] + # Checks if a given user has access to site settings on a given community + # @param [User] user user to check access for + # @param [String, nil] community_id id of the community to check access on + # @return [Boolean] + def access?(user, community_id) + community_id.present? || user.is_global_admin + end + def index # The weird argument to sort_by here sorts without throwing errors on nil values - # see https://stackoverflow.com/a/35539062/3160466. 0:1,c sorts nil last, to switch @@ -26,8 +34,41 @@ def show render json: @setting&.as_json&.merge(typed: @setting.typed) end + # Adds an audit log for a given site setting update event + # @param [User] user initiating user + # @param [SiteSetting] before current site setting + # @param [SiteSetting] after updated site setting + # @return [void] + def audit_update(user, before, after) + AuditLog.admin_audit(event_type: 'setting_update', + related: after, + user: user, + comment: "from <>\nto <>") + end + + # Deletes cache for a given site setting for a given community + # @param [SiteSetting] setting site setting to clear cache for + # @param [String, nil] community_id community id to clear cache for + # @return [Boolean] + def clear_cache(setting, community_id) + Rails.cache.delete("SiteSettings/#{community_id}/#{setting.name}", include_community: false) + end + + # Actually creates a given site setting + # @param [SiteSetting] setting site setting to create + # @param [String, nil] community_id community id to create a setting for + # @return [SiteSetting] + def do_create(setting, community_id) + SiteSetting.create(name: setting.name, + community_id: community_id, + value: '', + value_type: setting.value_type, + category: setting.category, + description: setting.description) + end + def update - if params[:community_id].blank? && !current_user.is_global_admin + unless access?(current_user, params[:community_id]) not_found return end @@ -36,20 +77,28 @@ def update matches = SiteSetting.unscoped.where(community_id: RequestContext.community_id, name: params[:name]) if matches.count.zero? global = SiteSetting.unscoped.where(community_id: nil, name: params[:name]).first - SiteSetting.create(name: global.name, community_id: RequestContext.community_id, value: '', - value_type: global.value_type, category: global.category, - description: global.description) + do_create(global, RequestContext.community_id) else matches.first end else SiteSetting.unscoped.where(community_id: nil, name: params[:name]).first end + before = @setting.attributes_print + @setting.update(setting_params) - AuditLog.admin_audit(event_type: 'setting_update', related: @setting, user: current_user, - comment: "from <>\nto <>") - Rails.cache.delete "SiteSettings/#{RequestContext.community_id}/#{@setting.name}" + + audit_update(current_user, before, @setting) + + if @setting.global? + Community.all.each do |c| + clear_cache(@setting, c.id) + end + else + clear_cache(@setting, RequestContext.community_id) + end + render json: { status: 'OK', setting: @setting&.as_json&.merge(typed: @setting.typed) } end diff --git a/app/controllers/suggested_edit_controller.rb b/app/controllers/suggested_edit_controller.rb index 34e0bbc7b..c724df4a5 100644 --- a/app/controllers/suggested_edit_controller.rb +++ b/app/controllers/suggested_edit_controller.rb @@ -30,21 +30,56 @@ def approve return end - opts = { before: @post.body_markdown, after: @edit.body_markdown, comment: @edit.comment, - before_title: @post.title, after_title: @edit.title, before_tags: @post.tags, after_tags: @edit.tags } - - before = { before_body: @post.body, before_body_markdown: @post.body_markdown, before_tags_cache: @post.tags_cache, - before_tags: @post.tags.to_a, before_title: @post.title } + comment = params[:comment].present? && !params[:comment].empty? ? params[:comment] : @edit.comment + + # The to_a / dup methods called on the tags for `opts` and `before` are necessary. + # We need to work on a copy of them, because we update the post before the edit, which will change their values. + # (We would otherwise be pointing to the same instance, and only see the updated version). + opts = { before: @post.body_markdown, + after: @edit.body_markdown, + comment: comment, + before_title: @post.title, + after_title: @edit.title, + before_tags: @post.tags.to_a, + after_tags: @edit.tags } + + before = { before_body: @post.body, + before_body_markdown: @post.body_markdown, + before_tags_cache: @post.tags_cache.dup, + before_tags: @post.tags.to_a, + before_title: @post.title } + + @post.transaction do + post_update_status = @post.update(applied_details) + + if post_update_status + edit_update_status = @edit.update(before.merge(active: false, + accepted: true, + comment: comment, + rejected_comment: '', + decided_at: DateTime.now, + decided_by: current_user, + updated_at: DateTime.now)) + + if @edit.errors.any? + @post.errors.merge!(@edit.errors) + raise ActiveRecord::Rollback + end + + if edit_update_status + PostHistory.post_edited(@post, @edit.user, **opts) + AbilityQueue.add(@edit.user, "Suggested Edit Approved ##{@edit.id}") + end + end + + next + end - if @post.update(applied_details) - @edit.update(before.merge(active: false, accepted: true, rejected_comment: '', decided_at: DateTime.now, - decided_by: current_user, updated_at: DateTime.now)) - PostHistory.post_edited(@post, @edit.user, **opts) + if @post.errors.any? + render json: { status: 'error', message: @post.errors.full_messages.join(', ') }, status: :bad_request + else flash[:success] = 'Edit approved successfully.' - AbilityQueue.add(@edit.user, "Suggested Edit Approved ##{@edit.id}") render json: { status: 'success', redirect_url: post_path(@post) } - else - render json: { status: 'error', message: @post.errors.full_messages.join(', ') }, status: :bad_request end end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index f1e6dac20..98f133329 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -2,7 +2,8 @@ class TagsController < ApplicationController before_action :authenticate_user!, only: [:new, :create, :edit, :update, :rename, :merge, :select_merge] before_action :set_category, except: [:index] before_action :set_tag, only: [:show, :edit, :update, :children, :rename, :merge, :select_merge, :nuke, :nuke_warning] - before_action :verify_moderator, only: [:new, :create, :rename, :merge, :select_merge] + before_action :verify_tag_editor, only: [:new, :create] + before_action :verify_moderator, only: [:rename, :merge, :select_merge] before_action :verify_admin, only: [:nuke, :nuke_warning] def index @@ -13,10 +14,10 @@ def index (@tag_set&.tags || Tag).search(params[:term]) else (@tag_set&.tags || Tag.all).order(:name) - end.paginate(page: params[:page], per_page: 50) + end.includes(:tag_synonyms).paginate(page: params[:page], per_page: 50) respond_to do |format| format.json do - render json: @tags + render json: @tags.to_json(include: { tag_synonyms: { only: :name } }) end end end @@ -26,18 +27,27 @@ def category @tags = if params[:q].present? @tag_set.tags.search(params[:q]) elsif params[:hierarchical].present? - @tag_set.tags_with_paths.order(:path) + @tag_set.with_paths(params[:no_excerpt]) elsif params[:no_excerpt].present? - @tag_set.tags.where(excerpt: '').or(@tag_set.tags.where(excerpt: nil)) - .order(Arel.sql('COUNT(posts.id) DESC')) + @tag_set.tags.where(excerpt: ['', nil]) else - @tag_set.tags.order(Arel.sql('COUNT(posts.id) DESC')) + @tag_set&.tags end - @count = @tags.count + table = params[:hierarchical].present? ? 'tags_paths' : 'tags' - @tags = @tags.left_joins(:posts).group(Arel.sql("#{table}.id")) - .select(Arel.sql("#{table}.*, COUNT(posts.id) AS post_count")) - .paginate(per_page: 96, page: params[:page]) + + @tags = @tags&.left_joins(:posts) + &.group(Arel.sql("#{table}.id")) + &.select(Arel.sql("#{table}.*, COUNT(DISTINCT IF(posts.deleted = 0, posts.id, NULL)) AS post_count")) + &.paginate(per_page: 96, page: params[:page]) + + @tags = if params[:hierarchical].present? + @tags&.order(:path) + else + @tags&.order(Arel.sql('COUNT(posts.id) DESC')) + end + + @count = @tags&.length || 0 end def show @@ -49,8 +59,9 @@ def show else @tag.all_children + [@tag.id] end - post_ids = helpers.post_ids_for_tags(tag_ids) - @posts = Post.where(id: post_ids).undeleted.where(post_type_id: @category.display_post_types) + displayed_post_types = @tag.tag_set.categories.map(&:display_post_types).flatten + @posts = Post.joins(:tags).where(id: PostsTag.select(:post_id).distinct.where(tag_id: tag_ids)) + .undeleted.where(post_type_id: displayed_post_types) .includes(:post_type, :tags).list_includes.paginate(page: params[:page], per_page: 50) .order(sort_param) respond_to do |format| @@ -61,6 +72,7 @@ def show def new @tag = Tag.new + @tag.tag_synonyms.build end def create @@ -76,6 +88,7 @@ def create def edit check_your_privilege('edit_tags', nil, true) + @tag.tag_synonyms.build end def update @@ -123,57 +136,68 @@ def merge @subordinate = Tag.find params[:merge_with_id] - AuditLog.moderator_audit event_type: 'tag_merge', related: @primary, user: current_user, - comment: "#{@subordinate.name} (#{@subordinate.id}) into #{@primary.name} (#{@primary.id})" - - # Take the tag off posts - posts_sql = 'UPDATE posts INNER JOIN posts_tags ON posts.id = posts_tags.post_id ' \ - 'SET posts.tags_cache = REPLACE(posts.tags_cache, ?, ?) ' \ - 'WHERE posts_tags.tag_id = ?' - exec([posts_sql, "\n- #{@subordinate.name}", "\n- #{@primary.name}", @subordinate.id]) - - # Break hierarchies - tags_sql = 'UPDATE tags SET parent_id = NULL WHERE parent_id = ?' - exec([tags_sql, @subordinate.id]) - - # Remove references to the tag - sql = 'UPDATE IGNORE $TABLENAME SET tag_id = ? WHERE tag_id = ?' - exec([sql.gsub('$TABLENAME', 'posts_tags'), @primary.id, @subordinate.id]) - exec([sql.gsub('$TABLENAME', 'categories_moderator_tags'), @primary.id, @subordinate.id]) - exec([sql.gsub('$TABLENAME', 'categories_required_tags'), @primary.id, @subordinate.id]) - exec([sql.gsub('$TABLENAME', 'categories_topic_tags'), @primary.id, @subordinate.id]) - exec([sql.gsub('$TABLENAME', 'post_history_tags'), @primary.id, @subordinate.id]) - exec([sql.gsub('$TABLENAME', 'suggested_edits_tags'), @primary.id, @subordinate.id]) - exec([sql.gsub('$TABLENAME', 'suggested_edits_before_tags'), @primary.id, @subordinate.id]) - - # Nuke it from orbit - @subordinate.destroy + Post.transaction do + AuditLog.moderator_audit event_type: 'tag_merge', related: @primary, user: current_user, comment: + "#{@subordinate.name} (#{@subordinate.id}) into #{@primary.name} (#{@primary.id})" + + # Replace subordinate with primary, except when a post already has primary (to avoid giving them a duplicate tag) + posts_sql = 'UPDATE posts INNER JOIN posts_tags ON posts.id = posts_tags.post_id ' \ + 'SET posts.tags_cache = REPLACE(posts.tags_cache, ?, ?) ' \ + 'WHERE posts_tags.tag_id = ? ' \ + 'AND posts_tags.post_id NOT IN (SELECT post_id FROM posts_tags WHERE tag_id = ?)' + exec_sql([posts_sql, "\n- #{@subordinate.name}\n", "\n- #{@primary.name}\n", @subordinate.id, @primary.id]) + + # Remove the subordinate tag from posts that still have it (the ones that were excluded from our previous query) + posts2_sql = 'UPDATE posts INNER JOIN posts_tags ON posts.id = posts_tags.post_id ' \ + 'SET posts.tags_cache = REPLACE(posts.tags_cache, ?, ?) ' \ + 'WHERE posts_tags.tag_id = ?' + exec_sql([posts2_sql, "\n- #{@subordinate.name}\n", "\n", @subordinate.id]) + + # Break hierarchies + tags_sql = 'UPDATE tags SET parent_id = NULL WHERE parent_id = ?' + exec_sql([tags_sql, @subordinate.id]) + + # Remove references to the tag + sql = 'UPDATE IGNORE $TABLENAME SET tag_id = ? WHERE tag_id = ?' + exec_sql([sql.gsub('$TABLENAME', 'posts_tags'), @primary.id, @subordinate.id]) + exec_sql([sql.gsub('$TABLENAME', 'categories_moderator_tags'), @primary.id, @subordinate.id]) + exec_sql([sql.gsub('$TABLENAME', 'categories_required_tags'), @primary.id, @subordinate.id]) + exec_sql([sql.gsub('$TABLENAME', 'categories_topic_tags'), @primary.id, @subordinate.id]) + exec_sql([sql.gsub('$TABLENAME', 'post_history_tags'), @primary.id, @subordinate.id]) + exec_sql([sql.gsub('$TABLENAME', 'suggested_edits_tags'), @primary.id, @subordinate.id]) + exec_sql([sql.gsub('$TABLENAME', 'suggested_edits_before_tags'), @primary.id, @subordinate.id]) + + # Nuke it from orbit + @subordinate.destroy + end flash[:success] = "Merged #{@subordinate.name} into #{@primary.name}." redirect_to tag_path(id: @category.id, tag_id: @primary.id) end def nuke - AuditLog.admin_audit event_type: 'tag_nuke', related: @tag, user: current_user, - comment: "#{@tag.name} (#{@tag.id})" - - tables = ['posts_tags', 'categories_moderator_tags', 'categories_required_tags', 'categories_topic_tags', - 'post_history_tags', 'suggested_edits_tags', 'suggested_edits_before_tags'] - - # Remove tag from caches - caches_sql = 'UPDATE posts INNER JOIN posts_tags ON posts.id = posts_tags.post_id ' \ - 'SET posts.tags_cache = REPLACE(posts.tags_cache, ?, ?) ' \ - 'WHERE posts_tags.tag_id = ?' - exec([caches_sql, "\n- #{@tag.name}", '', @tag.id]) - - # Delete all references to the tag - tables.each do |tbl| - sql = "DELETE FROM #{tbl} WHERE tag_id = ?" - exec([sql, @tag.id]) - end + Post.transaction do + AuditLog.admin_audit event_type: 'tag_nuke', related: @tag, user: current_user, + comment: "#{@tag.name} (#{@tag.id})" + + tables = ['posts_tags', 'categories_moderator_tags', 'categories_required_tags', 'categories_topic_tags', + 'post_history_tags', 'suggested_edits_tags', 'suggested_edits_before_tags'] + + # Remove tag from caches + caches_sql = 'UPDATE posts INNER JOIN posts_tags ON posts.id = posts_tags.post_id ' \ + 'SET posts.tags_cache = REPLACE(posts.tags_cache, ?, ?) ' \ + 'WHERE posts_tags.tag_id = ?' + exec_sql([caches_sql, "\n- #{@tag.name}\n", "\n", @tag.id]) + + # Delete all references to the tag + tables.each do |tbl| + sql = "DELETE FROM #{tbl} WHERE tag_id = ?" + exec_sql([sql, @tag.id]) + end - # Nuke it - @tag.destroy + # Nuke it + @tag.destroy + end flash[:success] = "Deleted #{@tag.name}" redirect_to category_tags_path(@category) @@ -192,10 +216,29 @@ def set_category end def tag_params - params.require(:tag).permit(:excerpt, :wiki_markdown, :parent_id, :name) + params.require(:tag).permit(:excerpt, :wiki_markdown, :parent_id, :name, + tag_synonyms_attributes: [:id, :name, :_destroy]) end - def exec(sql_array) + def exec_sql(sql_array) ApplicationRecord.connection.execute(ActiveRecord::Base.sanitize_sql_array(sql_array)) end + + def verify_tag_editor + unless user_signed_in? && (current_user.privilege?(:edit_tags) || + current_user.is_moderator || + current_user.is_admin) + respond_to do |format| + format.html do + render 'errors/not_found', layout: 'without_sidebar', status: :not_found + end + format.json do + render json: { status: 'failed', success: false, errors: ['not_found'] }, status: :not_found + end + end + + return false + end + true + end end diff --git a/app/controllers/tour_controller.rb b/app/controllers/tour_controller.rb index b7101473d..13ea7f27e 100644 --- a/app/controllers/tour_controller.rb +++ b/app/controllers/tour_controller.rb @@ -5,7 +5,9 @@ def index; end def question1; end - def question2; end + def question2 + @tagset_id = TagSet.find_by(name: 'Tour')&.id || -1 + end def question3; end diff --git a/app/controllers/two_factor_controller.rb b/app/controllers/two_factor_controller.rb index 4d85ecba2..fea0dc9c6 100644 --- a/app/controllers/two_factor_controller.rb +++ b/app/controllers/two_factor_controller.rb @@ -5,11 +5,18 @@ class TwoFactorController < ApplicationController def tf_status; end def enable_2fa + if current_user.sso_profile.present? && !SiteSetting['Enable2FAForSsoUsers'] + flash[:danger] = 'You cannot enable 2FA because you sign in through SSO.' + redirect_to two_factor_status_path + return + end + case params[:method] when 'app' - secret = ROTP::Base32.random - current_user.update(two_factor_token: secret, two_factor_method: 'app') - totp = ROTP::TOTP.new(secret, issuer: 'codidact.com') + @secret = ROTP::Base32.random + current_user.update(two_factor_token: @secret, two_factor_method: 'app', + backup_2fa_code: SecureRandom.alphanumeric(24)) + totp = ROTP::TOTP.new(@secret, issuer: 'codidact.com') uri = totp.provisioning_uri("#{current_user.id}@users-2fa.codidact.com") qr_svg = RQRCode::QRCode.new(uri).as_svg @qr_uri = "data:image/svg+xml;base64,#{Base64.encode64(qr_svg)}" @@ -52,7 +59,7 @@ def confirm_disable_code totp = ROTP::TOTP.new(current_user.two_factor_token) if totp.verify(params[:code], drift_behind: 15, drift_ahead: 15) - current_user.update(two_factor_token: nil, enabled_2fa: false) + current_user.update(two_factor_token: nil, enabled_2fa: false, backup_2fa_code: nil) AuditLog.user_history(event_type: 'two_factor_disabled', related: current_user) flash[:success] = 'Success! 2FA has been disabled on your account.' redirect_to two_factor_status_path @@ -81,4 +88,13 @@ def confirm_disable_link flash[:success] = 'Success! 2FA has been disabled on your account.' redirect_to two_factor_status_path end + + def show_backup_code + totp = ROTP::TOTP.new(current_user.two_factor_token) + if totp.verify(params[:code], drift_behind: 15, drift_ahead: 15) + render json: { status: 'success', code: current_user.backup_2fa_code } + else + render json: { status: 'error', message: 'Wrong code - please try again.' }, status: 401 + end + end end diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index 72e61c7b5..00f0722b8 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -1,7 +1,18 @@ class Users::RegistrationsController < Devise::RegistrationsController protected + layout 'without_sidebar', only: :edit + + before_action :check_sso, only: :update + def after_update_path_for(resource) edit_user_registration_path(resource) end + + def check_sso + if current_user && current_user.sso_profile.present? + flash['danger'] = 'You sign in with SSO, so updating your email/password is not possible.' + redirect_to edit_user_registration_path + end + end end diff --git a/app/controllers/users/saml_sessions_controller.rb b/app/controllers/users/saml_sessions_controller.rb new file mode 100644 index 000000000..ff0ca3e58 --- /dev/null +++ b/app/controllers/users/saml_sessions_controller.rb @@ -0,0 +1,200 @@ +class Users::SamlSessionsController < Devise::SamlSessionsController + # Called when someone is redirected to sign into the application using SSO/SAML. + def new + # If this is not the base community, then redirect them there for the sign in + base = base_community + if base.id != RequestContext.community_id + redirect_to "//#{base.host}#{sign_in_request_from_other_path(RequestContext.community_id)}", + allow_other_host: true + return + end + + # If we are the base community, use normal behavior + super + end + + # This method is almost the same code as the Users::SessionsController#create, and any changes + # made here should probably also be applied over there. + def create + super do |user| + return unless post_sign_in(user, false) + + # SSO Only - Redirect to filler endpoint to actually get the clients cookie values (not sent to us here). + # We need to check cookies because we may be signing in for another community. + redirect_to after_sign_in_check_path + return + end + end + + # On the initial return from the SSO the client does not send along its cookies (CORS/CSRF/XSS protections). + # Instead, we redirect the user after the sign-in to this endpoint, such that we get their cookies. + # Then we can check whether we were supposed to sign them in for a different community. + def after_sign_in_check + if cookies.encrypted[:signing_in_for].present? && + cookies.encrypted[:signing_in_for] != RequestContext.community_id + handle_sign_in_for_other_community(current_user) + return + end + + return unless post_sign_in(current_user, true) + + redirect_to after_sign_in_path_for(current_user) + end + + # Another community requests to sign in via this community. + def sign_in_request_from_other + # Check whether the requested community actually exists + unless Community.exists?(params[:id]) + raise ArgumentError, 'User is trying to sign in to non-existing community' + end + + # Store in a cookie which community we are signing in for such that we can redirect back after the sign in. + cookies.encrypted[:signing_in_for] = { + value: params[:id], + httponly: true, + expires: 15.minutes.from_now + } + + # If already signed in, sign them in in the other community as well. Otherwise redirect to SAML sign in. + if user_signed_in? + handle_sign_in_for_other_community(current_user) + else + redirect_to new_saml_user_session_path + end + end + + # User was signed in at the base community, now sign in here. + def sign_in_return_from_base + # Figure out which user was signed in. + # If we get a blank result then the message is either too old or the user messed with it. + user_info = decrypt_user_info(params[:message]) + if user_info.blank? + flash[:notice] = nil + flash[:danger] = 'Something went wrong signing in, please try again.' + redirect_to root_path + end + + # Determine the user we are trying to sign in as and report error if we can't + user = User.find(user_info) + if user.nil? + flash[:notice] = nil + flash[:danger] = 'Something went wrong signing in, please contact support.' + redirect_to root_path + end + + # Actually sign in the user and handle the post-sign-in behavior + sign_in(user) + return unless post_sign_in(user, true) + + # Finish with default devise behavior for sign ins + redirect_to after_sign_in_path_for(user) + end + + private + + # After a sign in, this method is called to check whether special conditions apply to the user. + # The user may be signed out by this method. + # + # In general, this method should have similar behavior to the Users::SessionsController#post_sign_in method. + # If you make changes here, you may also have to update that method. + # + # @param user [User] + # @param final_destination [Boolean] whether the current community is the one the user is trying to sign into + # @return [Boolean] false if the user was redirected by this + def post_sign_in(user, final_destination = false) + # If the user was banished, let them know non-specifically. + if user.deleted? || user.community_user&.deleted? + # The IDP already confirmed the sign in, so we can't fool the user any more that their credentials were incorrect. + sign_out user + flash[:notice] = nil + flash[:danger] = 'We could not sign you in because of an issue with your account.' + redirect_to root_path + return false + end + + # Enforce 2fa if enabled for SSO users + if SiteSetting['Enable2FAForSsoUsers'] && user.enabled_2fa + if final_destination + handle_2fa_login(user) + return false + else + # User needs to do 2FA, but we are (potentially) signing in for a different community. + # Sign them out and continue the sign in process, when they reach the final destination we will do 2FA there. + sign_out user + return true + end + end + + true + end + + def handle_2fa_login(user, host = nil) + host ||= request.hostname + sign_out user + case user.two_factor_method + when 'app' + id = user.id + Users::SessionsController.first_factor << id + redirect_to login_verify_2fa_path(uid: id) + when 'email' + TwoFactorMailer.with(user: user, host: host).login_email.deliver_now + flash[:notice] = nil + flash[:info] = 'Please check your email inbox for a link to sign in.' + redirect_to after_sign_in_path_for(user) + end + end + + # Handles a successful sign in at the base community when it was requested to do from another community. + # @param user [User] + def handle_sign_in_for_other_community(user) + # Determine which community we are signing in for, log out if not found (user messed with encrypted cookie/expired) + community = Community.find(cookies.encrypted[:signing_in_for]) + if community.nil? + sign_out(user) + flash[:notice] = nil + flash[:danger] = 'Something went wrong trying to sign you in.' + redirect_to root_path + return + end + + # Clear the cookie to prevent future issues + cookies.delete(:signing_in_for) + + # We signed in for a different community, we need to send them back to the original community with encrypted + # info about who signed in. + encrypted_user_info = encrypt_user_info(user) + redirect_to "//#{community.host}#{sign_in_return_from_base_path}?message=#{CGI.escape(encrypted_user_info)}", + allow_other_host: true + end + + # Encrypts user information for sending them to a different community. + # @param user [User] + def encrypt_user_info(user) + len = ActiveSupport::MessageEncryptor.key_len - 1 + key = Rails.application.secrets.secret_key_base || Rails.application.credentials.secret_key_base + crypt = ActiveSupport::MessageEncryptor.new(key[0..len]) + crypt.encrypt_and_sign(user.id, expires_in: 1.minute) + end + + # Decrypts the user information when received at a different community. + # @param data + def decrypt_user_info(data) + len = ActiveSupport::MessageEncryptor.key_len - 1 + key = Rails.application.secrets.secret_key_base || Rails.application.credentials.secret_key_base + crypt = ActiveSupport::MessageEncryptor.new(key[0..len]) + crypt.decrypt_and_verify(data) + end + + # @return [Community] the community to which the SSO is connected, and which must be used to sign in via. + def base_community + uri = URI.parse(Devise.saml_config.assertion_consumer_service_url) + host = if uri.port != 80 && uri.port != 443 && !uri.port.nil? + "#{uri.hostname}:#{uri.port}" + else + uri.hostname + end + Community.find_by(host: host) || Community.first + rescue + Community.first + end +end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index da34a1d18..9719fd745 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -1,42 +1,12 @@ class Users::SessionsController < Devise::SessionsController protect_from_forgery except: [:create] - @@first_factor = [] + mattr_accessor :first_factor, default: [], instance_writer: false, instance_reader: false + # Any changes made here may also require changes to Users::SamlSessionsController#create. def create super do |user| - if user.deleted? - sign_out user - flash[:notice] = nil - flash[:danger] = 'Invalid Email or password.' - render :new - return - end - - if user.community_user&.deleted? - sign_out user - flash[:notice] = nil - flash[:danger] = 'Your profile on this community has been deleted.' - render :new - return - end - - if user.present? && user.enabled_2fa - sign_out user - case user.two_factor_method - when 'app' - id = user.id - @@first_factor << id - redirect_to login_verify_2fa_path(uid: id) - return - when 'email' - TwoFactorMailer.with(user: user, host: request.hostname).login_email.deliver_now - flash[:notice] = nil - flash[:info] = 'Please check your email inbox for a link to sign in.' - redirect_to root_path - return - end - end + return unless post_sign_in(user) end end @@ -51,16 +21,26 @@ def verify_code end totp = ROTP::TOTP.new(target_user.two_factor_token) - if totp.verify(params[:code], drift_ahead: 15, drift_behind: 15) + if totp.verify(params[:code], drift_ahead: 15, drift_behind: 15) || params[:code] == target_user.backup_2fa_code if @@first_factor.include? params[:uid].to_i + if params[:code] == target_user.backup_2fa_code + target_user.update(enabled_2fa: false, two_factor_token: nil, backup_2fa_code: nil) + flash[:warning] = 'Two-factor authentication has been disabled for your account because you signed in with ' \ + 'a backup code. Please re-configure two-factor authentication via your profile.' + end + AuditLog.user_history(event_type: 'two_factor_success', related: target_user) @@first_factor.delete params[:uid].to_i flash[:info] = 'Signed in successfully.' - sign_in_and_redirect User.find(params[:uid]) + sign_in_and_redirect target_user else AuditLog.user_history(event_type: 'two_factor_fail', related: target_user, comment: 'first factor not present') flash[:danger] = "You haven't entered your password yet." - redirect_to new_session_path(target_user) + if devise_sign_in_enabled? + redirect_to new_session_path(target_user) + else + redirect_to new_saml_user_session_path(target_user) + end end else AuditLog.user_history(event_type: 'two_factor_fail', related: target_user, comment: 'wrong code') @@ -68,4 +48,65 @@ def verify_code redirect_to login_verify_2fa_path(uid: params[:uid]) end end + + private + + # After a sign in, this method is called to check whether special conditions apply to the user. + # The user may be signed out by this method. + # + # In general, this method should have similar behavior to the Users::SamlSessionsController#post_sign_in method. + # If you make changes here, you may also have to update that method. + # @param user [User] + # @return [Boolean] false if the handling by the calling method should be stopped + def post_sign_in(user) + # For a deleted user (banished), tell them non-specifically that there was a mistake with their credentials. + if user.deleted? + sign_out user + flash[:notice] = nil + flash[:danger] = 'Invalid Email or password.' + render :new + return false + end + + # If profile is deleted, the user was banished. Inform them and send them back to the sign in page. + if user.community_user&.deleted? + sign_out user + flash[:notice] = nil + flash[:danger] = 'Your profile on this community has been deleted.' + render :new + return false + end + + # For users who are linked to an SSO Profile, disallow normal login and let them sign in through SSO instead. + if user.sso_profile.present? + sign_out user + flash[:notice] = nil + flash[:danger] = 'Please sign in using the Single Sign-On service of your institution.' + redirect_to new_saml_user_session_path + return false + end + + # Enforce 2FA + if user.enabled_2fa + handle_2fa_login(user) + return false + end + + true + end + + def handle_2fa_login(user) + sign_out user + case user.two_factor_method + when 'app' + id = user.id + @@first_factor << id + redirect_to login_verify_2fa_path(uid: id) + when 'email' + TwoFactorMailer.with(user: user, host: request.hostname).login_email.deliver_now + flash[:notice] = nil + flash[:info] = 'Please check your email inbox for a link to sign in.' + redirect_to after_sign_in_path_for(user) + end + end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 11e95df0d..1b4aec6f0 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -5,12 +5,13 @@ class UsersController < ApplicationController include Devise::Controllers::Rememberable before_action :authenticate_user!, only: [:edit_profile, :update_profile, :stack_redirect, :transfer_se_content, - :qr_login_code, :me, :preferences, :set_preference, :my_vote_summary] + :qr_login_code, :me, :preferences, :set_preference, :my_vote_summary, + :disconnect_sso, :confirm_disconnect_sso, :filters] before_action :verify_moderator, only: [:mod, :destroy, :soft_delete, :role_toggle, :full_log, :annotate, :annotations, :mod_privileges, :mod_privilege_action] before_action :set_user, only: [:show, :mod, :destroy, :soft_delete, :posts, :role_toggle, :full_log, :activity, :annotate, :annotations, :mod_privileges, :mod_privilege_action, - :vote_summary, :avatar] + :vote_summary, :network, :avatar] before_action :check_deleted, only: [:show, :posts, :activity] def index @@ -26,13 +27,20 @@ def index def show @abilities = Ability.on_user(@user) - @posts = if current_user&.privilege?('flag_curate') - @user.posts - else - @user.posts.undeleted - end.list_includes.joins(:category) - .where('IFNULL(categories.min_view_trust_level, 0) <= ?', current_user&.trust_level || 0) - .order(score: :desc).first(15) + + all_posts = if current_user&.privilege?('flag_curate') || @user == current_user + @user.posts + else + @user.posts.undeleted + end + .list_includes + .joins(:category) + .where('IFNULL(categories.min_view_trust_level, 0) <= ?', current_user&.trust_level || 0) + .user_sort({ term: params[:sort], default: :score }, + age: :created_at, score: :score) + + @posts = all_posts.first(15) + @total_post_count = all_posts.count render layout: 'without_sidebar' end @@ -57,6 +65,7 @@ def preferences prefs = current_user.preferences @preferences = prefs[:global] @community_prefs = prefs[:community] + render layout: 'without_sidebar' end format.json do render json: current_user.preferences @@ -64,6 +73,99 @@ def preferences end end + # Helper method to convert it to the form expected by the client + def filter_json(filter) + { + min_score: filter.min_score, + max_score: filter.max_score, + min_answers: filter.min_answers, + max_answers: filter.max_answers, + include_tags: Tag.where(id: filter.include_tags).map { |tag| [tag.name, tag.id] }, + exclude_tags: Tag.where(id: filter.exclude_tags).map { |tag| [tag.name, tag.id] }, + status: filter.status, + system: filter.user_id == -1 + } + end + + def filters_json + system_filters = Rails.cache.fetch 'default_system_filters', expires_in: 1.day do + User.find(-1).filters.to_h { |filter| [filter.name, filter_json(filter)] } + end + + if user_signed_in? + current_user.filters.to_h { |filter| [filter.name, filter_json(filter)] } + .merge(system_filters) + else + system_filters + end + end + + def filters + respond_to do |format| + format.html do + render layout: 'without_sidebar' + end + format.json do + render json: filters_json + end + end + end + + def set_filter + if user_signed_in? && params[:name] + filter = Filter.find_or_create_by(user: current_user, name: params[:name]) + + filter.update(filter_params) + + unless params[:category].nil? || params[:is_default].nil? + helpers.set_filter_default(current_user.id, filter.id, params[:category].to_i, params[:is_default]) + end + + render json: { status: 'success', success: true, filters: filters_json }, + status: 200 + else + render json: { status: 'failed', success: false, errors: ['Filter name is required'] }, + status: 400 + end + end + + def delete_filter + unless params[:name] + return render json: { status: 'failed', success: false, errors: ['Filter name is required'] }, + status: 400 + end + + as_user = current_user + + if params[:system] == true + if current_user&.is_global_admin + as_user = User.find(-1) + else + return render json: { status: 'failed', success: false, errors: ['You do not have permission to delete'] }, + status: 400 + end + end + + filter = Filter.find_by(user: as_user, name: params[:name]) + if filter.destroy + render json: { status: 'success', success: true, filters: filters_json } + else + render json: { status: 'failed', success: false, errors: ['Failed to delete'] }, + status: 400 + end + end + + def default_filter + if user_signed_in? && params[:category] + default_filter = helpers.default_filter(current_user.id, params[:category].to_i) + render json: { status: 'success', success: true, name: default_filter&.name }, + status: 200 + else + render json: { status: 'failed', success: false }, + status: 400 + end + end + def set_preference if !params[:name].nil? && !params[:value].nil? global_key = "prefs.#{current_user.id}" @@ -80,7 +182,7 @@ def set_preference end def posts - @posts = if current_user&.privilege?('flag_curate') + @posts = if current_user&.privilege?('flag_curate') || @user == current_user Post.all else Post.undeleted @@ -99,12 +201,22 @@ def posts end end + def my_network + redirect_to network_path(current_user) + end + + def network + @communities = Community.all + render layout: 'without_sidebar' + end + def activity @posts = Post.undeleted.where(user: @user).count @comments = Comment.joins(:comment_thread, :post).undeleted.where(user: @user, comment_threads: { deleted: false }, posts: { deleted: false }).count @suggested_edits = SuggestedEdit.where(user: @user).count - @edits = PostHistory.joins(:post).where(user: @user, posts: { deleted: false }).count + @edits = PostHistory.joins(:post, :post_history_type).where(user: @user, posts: { deleted: false }, + post_history_types: { name: 'post_edited' }).count @all_edits = @suggested_edits + @edits @@ -116,16 +228,17 @@ def activity posts: { deleted: false }) when 'edits' SuggestedEdit.where(user: @user) + \ - PostHistory.joins(:post).where(user: @user, posts: { deleted: false }) + PostHistory.joins(:post, :post_history_type).where(user: @user, posts: { deleted: false }, + post_history_types: { name: 'post_edited' }) else Post.undeleted.where(user: @user) + \ Comment.joins(:comment_thread, :post).undeleted.where(user: @user, comment_threads: { deleted: false }, posts: { deleted: false }) + \ SuggestedEdit.where(user: @user).all + \ - PostHistory.joins(:post).where(user: @user, posts: { deleted: false }) + PostHistory.joins(:post).where(user: @user, posts: { deleted: false }).all end - @items = items.sort_by(&:created_at).reverse + @items = items.sort_by(&:created_at).reverse.paginate(page: params[:page], per_page: 50) render layout: 'without_sidebar' end @@ -168,7 +281,7 @@ def full_log Post.where(user: @user).all + Comment.where(user: @user).all + Flag.where(user: @user).all + \ SuggestedEdit.where(user: @user).all + PostHistory.where(user: @user).all + \ ModWarning.where(community_user: @user.community_user).all - end).sort_by(&:created_at).reverse + end).sort_by(&:created_at).reverse.paginate(page: params[:page], per_page: 50) render layout: 'without_sidebar' end @@ -229,13 +342,7 @@ def soft_delete return end - AuditLog.moderator_audit(event_type: 'user_delete', related: @user, user: current_user, - comment: @user.attributes_print(join: "\n")) - @user.assign_attributes(deleted: true, deleted_by_id: current_user.id, deleted_at: DateTime.now, - username: "user#{@user.id}", email: "#{@user.id}@deleted.localhost", - password: SecureRandom.hex(32)) - @user.skip_reconfirmation! - @user.save + @user.do_soft_delete(current_user) else render json: { status: 'failed', message: 'Unrecognised deletion type.' }, status: 400 return @@ -248,14 +355,24 @@ def edit_profile render layout: 'without_sidebar' end - def update_profile - profile_params = params.require(:user).permit(:username, :profile_markdown, :website, :twitter, :discord) - profile_params[:twitter] = profile_params[:twitter].delete('@') + def cleaned_profile_websites(profile_params) + sites = profile_params[:user_websites_attributes] - if profile_params[:website].present? && URI.parse(profile_params[:website]).instance_of?(URI::Generic) - # URI::Generic indicates the user didn't include a protocol, so we'll add one now so that it can be - # parsed correctly in the view later on. - profile_params[:website] = "https://#{profile_params[:website]}" + sites.transform_values do |w| + w.merge({ label: w[:label].presence, url: w[:url].presence }) + end + end + + def update_profile + profile_params = params.require(:user).permit(:username, + :profile_markdown, + :website, + :discord, + :twitter, + user_websites_attributes: [:id, :label, :url]) + + if profile_params[:user_websites_attributes].present? + profile_params[:user_websites_attributes] = cleaned_profile_websites(profile_params) end @user = current_user @@ -271,8 +388,14 @@ def update_profile end end - profile_rendered = helpers.post_markdown(:user, :profile_markdown) - if @user.update(profile_params.merge(profile: profile_rendered)) + if params[:user][:profile_markdown].present? + profile_rendered = helpers.post_markdown(:user, :profile_markdown) + profile_params = profile_params.merge(profile: profile_rendered) + end + + status = @user.update(profile_params) + + if status flash[:success] = 'Your profile details were updated.' redirect_to user_path(current_user) else @@ -301,7 +424,7 @@ def role_toggle # Set/update ability if new_value - @user.community_user.grant_privilege 'mod' + @user.community_user.grant_privilege! 'mod' else @user.community_user.privilege('mod').destroy end @@ -330,7 +453,7 @@ def mod_privilege_action case params[:do] when 'grant' if ua.nil? - @user.community_user.grant_privilege(ability.internal_id) + @user.community_user.grant_privilege!(ability.internal_id) AuditLog.admin_audit(event_type: 'ability_grant', related: @user, user: current_user, comment: ability.internal_id.to_s) elsif ua.is_suspended @@ -426,7 +549,7 @@ def do_qr_login sign_in user remember_me user AuditLog.user_history(event_type: 'mobile_login', related: user) - redirect_to root_path + redirect_to after_sign_in_path_for(user) else flash[:danger] = "That login link isn't valid. Codes expire after 5 minutes - if it's been longer than that, " \ 'get a new code and try again.' @@ -456,21 +579,27 @@ def my_vote_summary end def vote_summary - @votes = Vote.where(recv_user: @user) \ - .includes(:post).group(:date_of, :post_id, :vote_type) - @votes = @votes.select(:post_id, :vote_type) \ - .select('count(*) as vote_count') \ - .select('date(created_at) as date_of') + @votes = Vote.where(recv_user: @user) + .includes(:post) + .group(:date_of, :post_id, :vote_type) + + @votes = @votes.select(:post_id, :vote_type) + .select('count(*) as vote_count') + .select('date(votes.created_at) as date_of') + @votes = @votes.order(date_of: :desc, post_id: :desc).all \ - .group_by(&:date_of).map { |k, vl| [k, vl.group_by(&:post) ] } \ + .group_by(&:date_of).map do |k, vl| + [k, vl.group_by(&:post), vl.sum { |v| v.vote_type * v.vote_count }] + end \ .paginate(page: params[:page], per_page: 15) - @votes + + render layout: 'without_sidebar' end def avatar respond_to do |format| format.png do - size = params[:size]&.to_i&.positive? ? params[:size]&.to_i : 64 + size = params[:size]&.to_i&.positive? ? [params[:size]&.to_i, 256].min : 64 send_data helpers.user_auto_avatar(size, user: @user).to_blob, type: 'image/png', disposition: 'inline' end end @@ -486,10 +615,41 @@ def specific_avatar end end + def disconnect_sso + render layout: 'without_sidebar' + end + + def confirm_disconnect_sso + if current_user.sso_profile.blank? || !helpers.devise_sign_in_enabled? || !SiteSetting['AllowSsoDisconnect'] + flash[:danger] = 'You cannot disable Single Sign-On.' + redirect_to edit_user_registration_path + return + end + + if current_user.sso_profile.destroy + current_user.send_reset_password_instructions + flash[:success] = 'Successfully disconnected from Single Sign-On. Please see your email to set your password.' + redirect_to edit_user_registration_path + else + flash[:danger] = 'Failed to disconnect from Single Sign-On.' + redirect_to user_disconnect_sso_path + end + end + private + def filter_params + params.permit(:min_score, :max_score, :min_answers, :max_answers, :status, :include_tags, :exclude_tags, + include_tags: [], exclude_tags: []) + end + def set_user - @user = user_scope.find_by(id: params[:id]) + user_id = if params[:id] == 'me' && user_signed_in? + current_user.id + else + params[:id] + end + @user = user_scope.find_by(id: user_id) not_found if @user.nil? end diff --git a/app/controllers/votes_controller.rb b/app/controllers/votes_controller.rb index 84b57ce79..f39a98b44 100644 --- a/app/controllers/votes_controller.rb +++ b/app/controllers/votes_controller.rb @@ -42,8 +42,11 @@ def create AbilityQueue.add(post.user, "Vote Change on ##{post.id}") modified = !destroyed.empty? - state = { status: (modified ? 'modified' : 'OK'), vote_id: vote.id, upvotes: post.upvote_count, - downvotes: post.downvote_count } + state = { status: (modified ? 'modified' : 'OK'), + vote_id: vote.id, + upvotes: post.upvote_count, + downvotes: post.downvote_count, + score: post.score } render json: state end @@ -59,7 +62,10 @@ def destroy if vote.destroy AbilityQueue.add(post.user, "Vote Change on ##{post.id}") - render json: { status: 'OK', upvotes: post.upvote_count, downvotes: post.downvote_count } + render json: { status: 'OK', + upvotes: post.upvote_count, + downvotes: post.downvote_count, + score: post.score } else render json: { status: 'failed', message: vote.errors.full_messages.join('. ') }, status: :forbidden end diff --git a/app/helpers/advertisements/article_helper.rb b/app/helpers/advertisements/article_helper.rb index 80a425c75..fc1dc9d61 100644 --- a/app/helpers/advertisements/article_helper.rb +++ b/app/helpers/advertisements/article_helper.rb @@ -21,8 +21,8 @@ def article_ad(article) answer.font = './app/assets/imgfonts/Roboto-Bold.ttf' answer.pointsize = 40 answer.gravity = CenterGravity - answer.annotate ad, 600, 120, 0, 10, 'Check out this article' do - self.fill = 'white' + answer.annotate ad, 600, 120, 0, 10, 'Check out this article' do |s| + s.fill = 'white' end icon_path = SiteSetting.find_by(name: 'SiteLogoPath', community: article.community).typed @@ -37,8 +37,8 @@ def article_ad(article) community_name.font = './app/assets/imgfonts/Roboto-Bold.ttf' community_name.pointsize = 25 community_name.gravity = SouthWestGravity - community_name.annotate ad, 0, 0, 20, 20, article.community.name do - self.fill = '#4B68FF' + community_name.annotate ad, 0, 0, 20, 20, article.community.name do |s| + s.fill = '#4B68FF' end end @@ -48,8 +48,8 @@ def article_ad(article) community_url.font = './app/assets/imgfonts/Roboto-Bold.ttf' community_url.pointsize = 20 community_url.gravity = SouthEastGravity - community_url.annotate ad, 0, 0, 20, 20, article.community.host do - self.fill = '#666666' + community_url.annotate ad, 0, 0, 20, 20, article.community.host do |s| + s.fill = '#666666' end title = Draw.new @@ -62,15 +62,15 @@ def article_ad(article) if article.title.length > 60 title.pointsize = 35 wrap_text(do_rtl_witchcraft(article.title), 500, 35).split("\n").each do |line| - title.annotate ad, 500, 100, 50, 135 + (position * 55), line do - self.fill = '#333333' + title.annotate ad, 500, 100, 50, 135 + (position * 55), line do |s| + s.fill = '#333333' end position += 1 end else wrap_text(do_rtl_witchcraft(article.title), 500, 55).split("\n").each do |line| - title.annotate ad, 500, 100, 50, 160 + (position * 70), line do - self.fill = '#333333' + title.annotate ad, 500, 100, 50, 160 + (position * 70), line do |s| + s.fill = '#333333' end position += 1 end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 175469b04..ac3009673 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -34,6 +34,10 @@ def query_url(base_url = nil, **params) uri.to_s end + def sign_in_link(title) + link_to title, new_user_session_url + end + def license_link link_to SiteSetting['ContentLicenseName'], SiteSetting['ContentLicenseLink'] end @@ -91,6 +95,19 @@ def generic_share_link(post) end end + def generic_share_link_md(post) + "[#{post.title}](#{generic_share_link(post)})" + end + + def post_history_share_link(post, history, index) + post_history_url(post, anchor: history.size - index) + end + + def post_history_share_link_md(post, history, index) + rev_num = history.size - index + "[Revision #{rev_num} — #{post.title}](#{post_history_share_link(post, history, index)})" + end + def generic_edit_link(post) edit_post_url(post) end @@ -177,4 +194,19 @@ def direct_request? false end end + + def current_commit + commit_info = Rails.cache.persistent('current_commit') + shasum, timestamp = commit_info + + begin + date = DateTime.iso8601(timestamp) + rescue + date = DateTime.parse(timestamp) + end + + [shasum, date] + rescue + [nil, nil] + end end diff --git a/app/helpers/categories_helper.rb b/app/helpers/categories_helper.rb index 68c77d120..7efbc1617 100644 --- a/app/helpers/categories_helper.rb +++ b/app/helpers/categories_helper.rb @@ -23,4 +23,10 @@ def current_category @article.category end end + + def pending_suggestions? + Rails.cache.fetch "pending_suggestions/#{current_category.id}" do + SuggestedEdit.where(post: Post.undeleted.where(category: current_category), active: true).any? + end + end end diff --git a/app/helpers/comments_helper.rb b/app/helpers/comments_helper.rb index 636d0a95d..548a269c9 100644 --- a/app/helpers/comments_helper.rb +++ b/app/helpers/comments_helper.rb @@ -81,8 +81,8 @@ def get_pingable(thread) class CommentScrubber < Rails::Html::PermitScrubber def initialize super - self.tags = %w[a b i em strong s strike del pre code p blockquote span sup sub br] - self.attributes = %w[href title lang dir id class] + self.tags = %w[a b i em strong s strike del pre code p blockquote span sup sub br ul ol li] + self.attributes = %w[href title lang dir id class start] end def skip_node?(node) diff --git a/app/helpers/edit_helper.rb b/app/helpers/edit_helper.rb new file mode 100644 index 000000000..db4b05ad4 --- /dev/null +++ b/app/helpers/edit_helper.rb @@ -0,0 +1,5 @@ +module EditHelper + def max_edit_comment_length + [SiteSetting['MaxEditCommentLength'] || 255, 255].min + end +end diff --git a/app/helpers/email_logs_helper.rb b/app/helpers/email_logs_helper.rb new file mode 100644 index 000000000..9b08eeb7a --- /dev/null +++ b/app/helpers/email_logs_helper.rb @@ -0,0 +1,2 @@ +module EmailLogsHelper +end diff --git a/app/helpers/markdown_tools_helper.rb b/app/helpers/markdown_tools_helper.rb index 0c4f44c75..9dbcaa85d 100644 --- a/app/helpers/markdown_tools_helper.rb +++ b/app/helpers/markdown_tools_helper.rb @@ -4,7 +4,8 @@ def md_button(name = nil, action: nil, label: nil, **attribs, &block) class: "#{attribs[:class] || ''} button is-muted is-outlined js-markdown-tool", data_action: action, aria_label: label, - title: label + title: label, + role: 'button' attribs.transform_keys! { |k| k.to_s.tr('_', '-') }.symbolize_keys! if name.nil? && block_given? tag.a(**attribs, &block) @@ -18,7 +19,8 @@ def md_list_item(name = nil, action: nil, label: nil, **attribs, &block) class: "#{attribs[:class] || ''}js-markdown-tool", data_action: action, aria_label: label, - title: label + title: label, + role: 'button' attribs.transform_keys! { |k| k.to_s.tr('_', '-') }.symbolize_keys! if name.nil? && block_given? tag.a(**attribs, &block) diff --git a/app/helpers/posts_helper.rb b/app/helpers/posts_helper.rb index 2c9e19ab6..9fb810fd5 100644 --- a/app/helpers/posts_helper.rb +++ b/app/helpers/posts_helper.rb @@ -15,6 +15,35 @@ def cancel_redirect_path(post) end end + # @param category [Category, Nil] + # @return [Integer] the minimum length for post bodies + def min_body_length(category) + category&.min_body_length || 30 + end + + # @param _category [Category, Nil] + # @return [Integer] the maximum length for post bodies + def max_body_length(_category) + 30_000 + end + + # @param category [Category, Nil] post category, if any + # @param post_type [PostType] type of the post (system limits are relaxed) + # @return [Integer] the minimum length for post titles + def min_title_length(category, post_type) + if post_type.system? + 1 + else + category&.min_title_length || 15 + end + end + + # @param _category [Category, Nil] + # @return [Integer] the maximum length for post titles + def max_title_length(_category) + [SiteSetting['MaxTitleLength'] || 255, 255].min + end + class PostScrubber < Rails::Html::PermitScrubber def initialize super diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index f5786839a..fe21b7d35 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -1,4 +1,104 @@ module SearchHelper + def check_posts_permissions + (current_user&.is_moderator || current_user&.is_admin ? Post : Post.undeleted) + .qa_only.list_includes + end + + def search_posts + posts = check_posts_permissions + + qualifiers = params_to_qualifiers + search_string = params[:search] + + # Filter based on search string qualifiers + if search_string.present? + search_data = parse_search(search_string) + qualifiers += parse_qualifier_strings search_data[:qualifiers] + search_string = search_data[:search] + end + + posts = qualifiers_to_sql(qualifiers, posts) + posts = posts.paginate(page: params[:page], per_page: 25) + + posts = if search_string.present? + posts.search(search_data[:search]).user_sort({ term: params[:sort], default: :search_score }, + relevance: :search_score, score: :score, age: :created_at) + else + posts.user_sort({ term: params[:sort], default: :score }, + score: :score, age: :created_at) + end + + [posts, qualifiers] + end + + # Convert a Filter record into a form parseable by the search function + def filter_to_qualifiers(filter) + qualifiers = [] + qualifiers.append({ param: :score, operator: '>=', value: filter.min_score }) unless filter.min_score.nil? + qualifiers.append({ param: :score, operator: '<=', value: filter.max_score }) unless filter.max_score.nil? + qualifiers.append({ param: :answers, operator: '>=', value: filter.min_answers }) unless filter.min_answers.nil? + qualifiers.append({ param: :answers, operator: '<=', value: filter.max_answers }) unless filter.max_answers.nil? + qualifiers.append({ param: :include_tags, tag_ids: filter.include_tags }) unless filter.include_tags.nil? + qualifiers.append({ param: :exclude_tags, tag_ids: filter.exclude_tags }) unless filter.exclude_tags.nil? + qualifiers.append({ param: :status, value: filter.status }) unless filter.status.nil? + qualifiers + end + + def active_filter + { + default: nil, + name: params[:predefined_filter], + min_score: params[:min_score], + max_score: params[:max_score], + min_answers: params[:min_answers], + max_answers: params[:max_answers], + include_tags: params[:include_tags], + exclude_tags: params[:exclude_tags], + status: params[:status] + } + end + + def params_to_qualifiers + valid_value = { + date: /^[\d.]+(?:s|m|h|d|w|mo|y)?$/, + status: /any|open|closed/, + numeric: /^[\d.]+$/, + integer: /^\d+$/ + } + + filter_qualifiers = [] + + if params[:min_score]&.match?(valid_value[:numeric]) + filter_qualifiers.append({ param: :score, operator: '>=', value: params[:min_score].to_f }) + end + + if params[:max_score]&.match?(valid_value[:numeric]) + filter_qualifiers.append({ param: :score, operator: '<=', value: params[:max_score].to_f }) + end + + if params[:min_answers]&.match?(valid_value[:numeric]) + filter_qualifiers.append({ param: :answers, operator: '>=', value: params[:min_answers].to_i }) + end + + if params[:max_answers]&.match?(valid_value[:numeric]) + filter_qualifiers.append({ param: :answers, operator: '<=', value: params[:max_answers].to_i }) + end + + if params[:status]&.match?(valid_value[:status]) + filter_qualifiers.append({ param: :status, value: params[:status] }) + end + + if params[:include_tags]&.all? { |id| id.match? valid_value[:integer] } + filter_qualifiers.append({ param: :include_tags, tag_ids: params[:include_tags] }) + end + + if params[:exclude_tags]&.all? { |id| id.match? valid_value[:integer] } + filter_qualifiers.append({ param: :exclude_tags, tag_ids: params[:exclude_tags] }) + end + + filter_qualifiers + end + def parse_search(raw_search) qualifiers_regex = /([\w\-_]+(?=]{0,2}[\d.]+(?:s|m|h|d|w|mo|y)?$/, + status: /any|open|closed/, numeric: /^[<>=]{0,2}[\d.]+$/ } - qualifiers.each do |qualifier| # rubocop:disable Metrics/BlockLength + qualifiers.map do |qualifier| # rubocop:disable Metrics/BlockLength splat = qualifier.split ':' parameter = splat[0] value = splat[1] @@ -27,59 +128,111 @@ def qualifiers_to_sql(qualifiers, query) next unless value.match?(valid_value[:numeric]) operator, val = numeric_value_sql value - query = query.where("score #{operator.presence || '='} ?", val.to_f) + { param: :score, operator: operator.presence || '=', value: val.to_f } when 'created' next unless value.match?(valid_value[:date]) operator, val, timeframe = date_value_sql value - query = query.where("created_at #{operator.presence || '='} DATE_SUB(CURRENT_TIMESTAMP, " \ - "INTERVAL ? #{timeframe})", - val.to_i) + { param: :created, operator: operator.presence || '=', timeframe: timeframe, value: val.to_i } when 'user' - next unless value.match?(valid_value[:numeric]) + operator, val = if value.match?(valid_value[:numeric]) + numeric_value_sql value + elsif value == 'me' + ['=', current_user&.id&.to_i] + else + next + end - operator, val = numeric_value_sql value - query = query.where("user_id #{operator.presence || '='} ?", val.to_i) + { param: :user, operator: operator.presence || '=', user_id: val } when 'upvotes' next unless value.match?(valid_value[:numeric]) operator, val = numeric_value_sql value - query = query.where("upvotes #{operator.presence || '='} ?", val.to_i) + { param: :upvotes, operator: operator.presence || '=', value: val.to_i } when 'downvotes' next unless value.match?(valid_value[:numeric]) operator, val = numeric_value_sql value - query = query.where("downvotes #{operator.presence || '='} ?", val.to_i) + { param: :downvotes, operator: operator.presence || '=', value: val.to_i } when 'votes' next unless value.match?(valid_value[:numeric]) operator, val = numeric_value_sql value - query = query.where("(upvotes - downvotes) #{operator.presence || '='}", val.to_i) + { param: :net_votes, operator: operator.presence || '=', value: val.to_i } when 'tag' - query = query.where(posts: { id: PostsTag.where(tag_id: Tag.where(name: value).select(:id)).select(:post_id) }) + { param: :include_tag, tag_id: Tag.where(name: value).select(:id) } when '-tag' - query = query.where.not(posts: { id: PostsTag.where(tag_id: Tag.where(name: value).select(:id)) - .select(:post_id) }) + { param: :exclude_tag, tag_id: Tag.where(name: value).select(:id) } when 'category' next unless value.match?(valid_value[:numeric]) operator, val = numeric_value_sql value - trust_level = current_user&.trust_level || 0 - allowed_categories = Category.where('IFNULL(min_view_trust_level, -1) <= ?', trust_level) - query = query.where("category_id #{operator.presence || '='} ?", val.to_i) - .where(category_id: allowed_categories) + { param: :category, operator: operator.presence || '=', category_id: val.to_i } when 'post_type' next unless value.match?(valid_value[:numeric]) operator, val = numeric_value_sql value - query = query.where("post_type_id #{operator.presence || '='} ?", val.to_i) + { param: :post_type, operator: operator.presence || '=', post_type_id: val.to_i } when 'answers' next unless value.match?(valid_value[:numeric]) operator, val = numeric_value_sql value + { param: :answers, operator: operator.presence || '=', value: val.to_i } + when 'status' + next unless value.match?(valid_value[:status]) + + { param: :status, value: value } + end + end.compact + # Consider partitioning and telling the user which filters were invalid + end + + def qualifiers_to_sql(qualifiers, query) + trust_level = current_user&.trust_level || 0 + allowed_categories = Category.where('IFNULL(min_view_trust_level, -1) <= ?', trust_level) + query = query.where(category_id: allowed_categories) + + qualifiers.each do |qualifier| # rubocop:disable Metrics/BlockLength + case qualifier[:param] + when :score + query = query.where("score #{qualifier[:operator]} ?", qualifier[:value]) + when :created + query = query.where("created_at #{qualifier[:operator]} DATE_SUB(CURRENT_TIMESTAMP, " \ + "INTERVAL ? #{qualifier[:timeframe]})", + qualifier[:value]) + when :user + query = query.where("user_id #{qualifier[:operator]} ?", qualifier[:user_id]) + when :upvotes + query = query.where("upvote_count #{qualifier[:operator]} ?", qualifier[:value]) + when :downvotes + query = query.where("downvote_count #{qualifier[:operator]} ?", qualifier[:value]) + when :net_votes + query = query.where("(upvote_count - downvote_count) #{qualifier[:operator]} ?", qualifier[:value]) + when :include_tag + query = query.where(posts: { id: PostsTag.where(tag_id: qualifier[:tag_id]).select(:post_id) }) + when :include_tags + qualifier[:tag_ids].each do |id| + query = query.where(id: PostsTag.where(tag_id: id).select(:post_id)) + end + when :exclude_tag + query = query.where.not(posts: { id: PostsTag.where(tag_id: qualifier[:tag_id]).select(:post_id) }) + when :exclude_tags + query = query.where.not(id: PostsTag.where(tag_id: qualifier[:tag_ids]).select(:post_id)) + when :category + query = query.where("category_id #{qualifier[:operator]} ?", qualifier[:category_id]) + when :post_type + query = query.where("post_type_id #{qualifier[:operator]} ?", qualifier[:post_type_id]) + when :answers post_types_with_answers = PostType.where(has_answers: true) - query = query.where("answer_count #{operator.presence || '='} ?", val.to_i) + query = query.where("answer_count #{qualifier[:operator]} ?", qualifier[:value]) .where(post_type_id: post_types_with_answers) + when :status + case qualifier[:value] + when 'open' + query = query.where(closed: false) + when 'closed' + query = query.where(closed: true) + end end end diff --git a/app/helpers/users/avatar_helper.rb b/app/helpers/users/avatar_helper.rb index af86fc689..6a6686f27 100644 --- a/app/helpers/users/avatar_helper.rb +++ b/app/helpers/users/avatar_helper.rb @@ -35,8 +35,8 @@ def user_auto_avatar(size, user: nil, letter: nil, color: nil) let.font = './app/assets/imgfonts/Roboto.ttf' let.pointsize = size * 0.75 let.gravity = CenterGravity - let.annotate ava, size, size * 1.16, 0, 0, letter.upcase do - self.fill = text_color + let.annotate ava, size, size * 1.16, 0, 0, letter.upcase do |s| + s.fill = text_color end ava.format = 'PNG' diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 2ebc8c27c..a689060d0 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -30,6 +30,21 @@ def preference_choice(pref_config) end end + def default_filter(user_id, category_id) + CategoryFilterDefault.find_by(user_id: user_id, category_id: category_id)&.filter + end + + def set_filter_default(user_id, filter_id, category_id, keep) + if keep + CategoryFilterDefault.create_with(filter_id: filter_id) + .find_or_create_by(user_id: user_id, category_id: category_id) + .update(filter_id: filter_id) + else + CategoryFilterDefault.where(user_id: user_id, category_id: category_id) + .destroy_all + end + end + def user_preference(name, community: false) return nil if current_user.nil? @@ -44,11 +59,37 @@ def rtl_safe_username(user) deleted_user?(user) ? 'deleted user' : user.rtl_safe_username end - def user_link(user, **link_opts) - if deleted_user?(user) - link_to 'deleted user', '#', { dir: 'ltr' }.merge(link_opts) + def user_link(user, url_opts = nil, **link_opts) + url_opts ||= {} + anchortext = link_opts[:anchortext] + link_opts_reduced = { dir: 'ltr' }.merge(link_opts).except(:anchortext) + if !anchortext.nil? + link_to anchortext, user_url(user, **url_opts), { dir: 'ltr' }.merge(link_opts) + elsif deleted_user?(user) + link_to 'deleted user', '#', link_opts_reduced + else + link_to user.rtl_safe_username, user_url(user, **url_opts), link_opts_reduced + end + end + + def sso_sign_in_enabled? + SiteSetting['SsoSignIn'] + end + + def devise_sign_in_enabled? + SiteSetting['MixedSignIn'] || !sso_sign_in_enabled? + end + + ## + # Returns a user corresponding to the ID provided, with the caveat that if +user_id+ is 'me' and there is a user + # signed in, the signed in user will be returned. Use for /users/me links. + # @param [String] user_id The user ID to find, from +params+ + # @return [User] The User object + def user_with_me(user_id) + if user_id == 'me' && user_signed_in? + current_user else - link_to user.rtl_safe_username, user_url(user), { dir: 'ltr' }.merge(link_opts) + User.find(user_id) end end end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 000000000..d394c3d10 --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/app/jobs/clean_up_spammy_users_job.rb b/app/jobs/clean_up_spammy_users_job.rb new file mode 100644 index 000000000..001afda5f --- /dev/null +++ b/app/jobs/clean_up_spammy_users_job.rb @@ -0,0 +1,22 @@ +class CleanUpSpammyUsersJob < ApplicationJob + queue_as :default + + def perform(created_after: 1.month.ago) + # Select potential spammers: users created within timeframe, who are not deleted, who have posted but all posts have + # since been deleted (no live posts). + possible_spammers = User.joins('inner join posts on users.id = posts.user_id') + .where('users.created_at >= ?', created_after) + .where(users: { deleted: false }).group('users.id').having('count(posts.id) > 0') + .having('count(distinct if(posts.deleted = true, null, posts.id)) = 0') + possible_spammers.each do |spammer| + all_posts_spam = spammer.posts.all? do |post| + # A post is considered spam if there are any helpful spam flags on it. + post.flags.any? { |flag| flag.post_flag_type.name == "it's spam" && flag.status == 'helpful' } + end + if all_posts_spam + spammer.block('automatic block from spam cleanup job', length: 2.years) + spammer.do_soft_delete(User.find(-1)) + end + end + end +end diff --git a/app/jobs/cleanup_votes_job.rb b/app/jobs/cleanup_votes_job.rb new file mode 100644 index 000000000..b54b26be7 --- /dev/null +++ b/app/jobs/cleanup_votes_job.rb @@ -0,0 +1,32 @@ +class CleanupVotesJob < ApplicationJob + queue_as :default + + def perform + Community.all.each do |c| + RequestContext.community = c + orphan_votes = Vote.all.reject { |v| v.post.present? } + + puts "[#{c.name}] destroying #{orphan_votes.length} #{'orphan vote'.pluralize(orphan_votes.length)}" + + system_user = User.find(-1) + + orphan_votes.each do |v| + result = v.destroy + + if result + AuditLog.admin_audit( + comment: "Deleted orphaned vote for user ##{v.recv_user_id} " \ + "on post ##{v.post_id} " \ + "in community ##{c.id} (#{c.name})", + event_type: 'vote_delete', + related: v, + user: system_user + ) + else + puts "[#{c.name}] failed to destroy vote \"#{v.id}\"" + v.errors.each { |e| puts e.full_message } + end + end + end + end +end diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index a68f9c55c..f4f0d2606 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -1,5 +1,8 @@ class AdminMailer < ApplicationMailer - default from: 'Codidact Admins ' + default from: lambda { + "#{SiteSetting['ModeratorDistributionListSenderName']} " \ + "<#{SiteSetting['ModeratorDistributionListSenderEmail']}>" + } def to_moderators @subject = params[:subject] @@ -8,6 +11,19 @@ def to_moderators "INNER JOIN community_users cu ON cu.user_id = u.id WHERE s.type = 'moderators' AND " \ '(u.is_global_admin = 1 OR u.is_global_moderator = 1 OR cu.is_admin = 1 OR cu.is_moderator = 1)' emails = ActiveRecord::Base.connection.execute(query).to_a.flatten - mail subject: "Codidact Moderators: #{@subject}", to: 'moderators-noreply@codidact.org', bcc: emails + from = "#{SiteSetting['ModeratorDistributionListSenderName']} " \ + "<#{SiteSetting['ModeratorDistributionListSenderEmail']}>" + to = SiteSetting['ModeratorDistributionListSenderEmail'] + mail subject: "Codidact Moderators: #{@subject}", to: to, from: from, bcc: emails + end + + def to_all_users + @subject = params[:subject] + @body_markdown = params[:body_markdown] + @users = User.where('email NOT LIKE ?', '%localhost').select(:email).map(&:email) + to = SiteSetting['AllUsersSenderEmail'] + from = "#{SiteSetting['AllUsersSenderName']} <#{SiteSetting['AllUsersSenderEmail']}>" + reply_to = SiteSetting['AllUsersReplyToEmail'] + mail subject: @subject, to: to, from: from, reply_to: reply_to, bcc: @users end end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 59f7ed59f..45f5f0f87 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,4 @@ class ApplicationMailer < ActionMailer::Base - default from: 'Codidact ' + default from: -> { "#{SiteSetting['NoReplySenderName']} <#{SiteSetting['NoReplySenderEmail']}>" } layout 'mailer' end diff --git a/app/mailers/donation_mailer.rb b/app/mailers/donation_mailer.rb index ee6b79854..587ae6009 100644 --- a/app/mailers/donation_mailer.rb +++ b/app/mailers/donation_mailer.rb @@ -4,9 +4,9 @@ def donation_successful @amount = params[:amount] @email = params[:email] @name = params[:name] - mail from: 'Codidact Donations ', - reply_to: 'Codidact Support ', - to: @email, subject: 'Thanks for your donation!' + from = "#{SiteSetting['DonationSenderName']} <#{SiteSetting['DonationSenderEmail']}>" + reply_to = "#{SiteSetting['DonationSupportReceiverName']} <#{SiteSetting['DonationSupportReceiverEmail']}>" + mail from: from, reply_to: reply_to, to: @email, subject: 'Thanks for your donation!' end def donation_uncaptured @@ -15,8 +15,8 @@ def donation_uncaptured @email = params[:email] @name = params[:name] @intent = params[:intent] - mail from: 'Codidact Donations ', - reply_to: 'Codidact Support ', - to: @email, subject: 'Your donation is unfinished - was everything okay?' + from = "#{SiteSetting['DonationSenderName']} <#{SiteSetting['DonationSenderEmail']}>" + reply_to = "#{SiteSetting['DonationSupportReceiverName']} <#{SiteSetting['DonationSupportReceiverEmail']}>" + mail from: from, reply_to: reply_to, to: @email, subject: 'Your donation is unfinished - was everything okay?' end end diff --git a/app/mailers/flag_mailer.rb b/app/mailers/flag_mailer.rb index 9eb0f7168..b43d60310 100644 --- a/app/mailers/flag_mailer.rb +++ b/app/mailers/flag_mailer.rb @@ -7,7 +7,8 @@ def flag_escalated .or(User.joins(:community_user) .where(community_users: { is_admin: true, community_id: @flag.community_id })) .select(:email).map(&:email) - mail from: 'Codidact ', to: 'noreply@codidact.com', bcc: emails, - subject: "New flag escalation on #{@flag.community.name}" + from = "#{SiteSetting['NoReplySenderName']} <#{SiteSetting['NoReplySenderEmail']}>" + to = SiteSetting['NoReplySenderEmail'] + mail from: from, to: to, bcc: emails, subject: "New flag escalation on #{@flag.community.name}" end end diff --git a/app/mailers/subscription_mailer.rb b/app/mailers/subscription_mailer.rb index 2c6687890..6261e13bf 100644 --- a/app/mailers/subscription_mailer.rb +++ b/app/mailers/subscription_mailer.rb @@ -1,4 +1,6 @@ class SubscriptionMailer < ApplicationMailer + helper UsersHelper + def subscription @subscription = params[:subscription] @questions = @subscription.questions&.includes(:user) || [] @@ -9,14 +11,20 @@ def subscription return end + # Load request community to ensure we can access the settings/posts of the correct community + RequestContext.community = @subscription.community + name = @subscription.name site_name = @subscription.community.name - subject = if @subscription.name.present? - "Latest questions from your '#{@subscription.name}' subscription on #{site_name}" + subject = if name.present? + "Latest questions from your '#{name}' subscription on #{site_name}" else "Latest questions from your subscription on #{site_name}" end @subscription.update(last_sent_at: DateTime.now) - mail from: 'Codidact Subscriptions ', to: @subscription.user.email, subject: subject + from = "#{SiteSetting['SubscriptionSenderName']} <#{SiteSetting['SubscriptionSenderEmail']}>" + to = @subscription.user.email + mail from: from, to: to, subject: subject + Rails.logger.info "Sent subscription mail (sub ID ##{@subscription.id}, to: '#{to}', name: '#{name}'" end end diff --git a/app/mailers/two_factor_mailer.rb b/app/mailers/two_factor_mailer.rb index de656dd7c..2784ab9be 100644 --- a/app/mailers/two_factor_mailer.rb +++ b/app/mailers/two_factor_mailer.rb @@ -1,5 +1,5 @@ class TwoFactorMailer < ApplicationMailer - default from: 'Codidact ' + default from: -> { "#{SiteSetting['NoReplySenderName']} <#{SiteSetting['NoReplySenderEmail']}>" } def disable_email user = params[:user] @@ -16,4 +16,10 @@ def login_email user.update(login_token: @token, login_token_expires_at: 5.minutes.from_now) mail to: user.email, subject: 'Your sign in link for Codidact' end + + def backup_code + @user = params[:user] + @host = params[:host] + mail to: @user.email, subject: 'Your 2FA backup code for Codidact' + end end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 0e552701f..2f0d184b0 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -107,11 +107,11 @@ def user_sort(term_opts, **field_mappings) end end -klasses = [::ActiveRecord::Relation] -klasses << if defined? ::ActiveRecord::Associations::CollectionProxy - ::ActiveRecord::Associations::CollectionProxy +klasses = [ActiveRecord::Relation] +klasses << if defined? ActiveRecord::Associations::CollectionProxy + ActiveRecord::Associations::CollectionProxy else - ::ActiveRecord::Associations::AssociationCollection + ActiveRecord::Associations::AssociationCollection end ActiveRecord::Base.extend UserSortable diff --git a/app/models/category.rb b/app/models/category.rb index 9cdaea765..f5951519c 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -9,6 +9,7 @@ class Category < ApplicationRecord has_many :posts belongs_to :tag_set belongs_to :license + belongs_to :default_filter, class_name: 'Filter', optional: true serialize :display_post_types, Array diff --git a/app/models/category_filter_default.rb b/app/models/category_filter_default.rb new file mode 100644 index 000000000..540f1cf78 --- /dev/null +++ b/app/models/category_filter_default.rb @@ -0,0 +1,5 @@ +class CategoryFilterDefault < ApplicationRecord + belongs_to :user + belongs_to :filter + belongs_to :category +end diff --git a/app/models/community_user.rb b/app/models/community_user.rb index a62926dcc..f0a80e60f 100644 --- a/app/models/community_user.rb +++ b/app/models/community_user.rb @@ -14,6 +14,12 @@ class CommunityUser < ApplicationRecord after_create :prevent_ulysses_case + delegate :url_helpers, to: 'Rails.application.routes' + + def system? + user_id == -1 + end + def suspended? return true if is_suspended && !suspension_end.past? @@ -24,6 +30,10 @@ def suspended? false end + def latest_warning + mod_warnings&.order(created_at: 'desc')&.first&.created_at + end + # Calculation functions for privilege scores # These are quite expensive, so we'll cache them for a while def post_score @@ -54,8 +64,6 @@ def flag_score end end - ## Privilege functions - def privilege?(internal_id, ignore_suspension: false, ignore_mod: false) if internal_id != 'mod' && !ignore_mod && user.is_moderator return true # includes: privilege? 'mod' @@ -73,11 +81,26 @@ def privilege(internal_id) UserAbility.joins(:ability).where(community_user_id: id, abilities: { internal_id: internal_id }).first end - def grant_privilege(internal_id) + ## + # Grant a specified ability to this CommunityUser. + # @param internal_id [String] The +internal_id+ of the ability to grant. + # @param notify [Boolean] Whether to send a notification to the user. + def grant_privilege!(internal_id, notify: true) priv = Ability.where(internal_id: internal_id).first UserAbility.create community_user_id: id, ability: priv + if notify + community_host = priv.community.host + user.create_notification("You've earned the #{priv.name} ability! Learn more.", + url_helpers.ability_url(priv.internal_id, host: community_host)) + end end + ## + # Recalculate a specified ability for this CommunityUser. Will not revoke abilities that have already been granted. + # @param internal_id [String] The +internal_id+ of the ability to be recalculated. + # @param sandbox [Boolean] Whether to run in sandbox mode - if sandboxed, the ability will not be granted but the + # return value indicates whether it would have been. + # @return [Boolean] Whether or not the ability was granted. def recalc_privilege(internal_id, sandbox: false) # Do not recalculate privileges already granted return true if privilege?(internal_id, ignore_suspension: true, ignore_mod: false) @@ -96,17 +119,27 @@ def recalc_privilege(internal_id, sandbox: false) end # If not sandbox mode, create new privilege entry - grant_privilege(internal_id) unless sandbox + grant_privilege!(internal_id) unless sandbox recalc_trust_level unless sandbox true end + ## + # Recalculate a list of standard abilities for this CommunityUser. + # @param sandbox [Boolean] Whether to run in sandbox mode - see {#recalc_privilege}. + # @return [Array] def recalc_privileges(sandbox: false) [:everyone, :unrestricted, :edit_posts, :edit_tags, :flag_close, :flag_curate].map do |ability| recalc_privilege(ability, sandbox: sandbox) end end + alias ability? privilege? + alias ability privilege + alias grant_ability! grant_privilege! + alias recalc_ability recalc_privilege + alias recalc_abilities recalc_privileges + # This check makes sure that every user gets the # 'everyone' permission upon creation. We do not want # to create a no permissions user by accident. diff --git a/app/models/concerns/edits_validations.rb b/app/models/concerns/edits_validations.rb new file mode 100644 index 000000000..11b4b98fd --- /dev/null +++ b/app/models/concerns/edits_validations.rb @@ -0,0 +1,21 @@ +# Common validations for unilateral & suggested edits +module EditsValidations + extend ActiveSupport::Concern + + included do + validate :max_edit_comment_length + end + + def max_edit_comment_length + if comment.nil? + return + end + + max_edit_comment_length = SiteSetting['MaxEditCommentLength'] + max_length = [(max_edit_comment_length || 255), 255].min + if comment.length > max_length + msg = I18n.t('edits.max_edit_comment_length', { count: max_length }).gsub(':length', max_length.to_s) + errors.add(:base, msg) + end + end +end diff --git a/app/models/concerns/post_validations.rb b/app/models/concerns/post_validations.rb new file mode 100644 index 000000000..a0986b345 --- /dev/null +++ b/app/models/concerns/post_validations.rb @@ -0,0 +1,84 @@ +# Validations for posts which are shared between posts and suggested edits. +module PostValidations + extend ActiveSupport::Concern + + included do + validate :tags_in_tag_set, if: -> { post_type.has_tags } + validate :maximum_tags, if: -> { post_type.has_tags } + validate :maximum_tag_length, if: -> { post_type.has_tags } + validate :no_spaces_in_tags, if: -> { post_type.has_tags } + validate :stripped_minimum_body, if: -> { !body_markdown.nil? } + validate :stripped_minimum_title, if: -> { !title.nil? } + validate :maximum_title_length, if: -> { !title.nil? } + validate :required_tags?, if: -> { post_type.has_tags && post_type.has_category } + end + + def maximum_tags + if tags_cache.length > 5 + errors.add(:base, "Post can't have more than 5 tags.") + elsif tags_cache.empty? + errors.add(:base, 'Post must have at least one tag.') + end + end + + def maximum_tag_length + tags_cache.each do |tag| + max_len = SiteSetting['MaxTagLength'] + if tag.length > max_len + errors.add(:tags, "can't be more than #{max_len} characters long each") + end + end + end + + def no_spaces_in_tags + tags_cache.each do |tag| + if tag.include?(' ') || tag.include?('_') + errors.add(:tags, 'may not include spaces or underscores - use hyphens for multiple-word tags') + end + end + end + + def stripped_minimum_body + min_body = category.nil? ? 30 : category.min_body_length + if (body_markdown&.gsub(/(?:^[\s\t\u2000-\u200F]+|[\s\t\u2000-\u200F]+$)/, '')&.length || 0) < min_body + errors.add(:body, "must be more than #{min_body} non-whitespace characters long") + end + end + + def stripped_minimum_title + min_title = if ['HelpDoc', 'PolicyDoc'].include?(post_type.name) + 1 + elsif category.nil? + 15 + else + category.min_title_length + end + + if (title&.gsub(/(?:^[\s\t\u2000-\u200F]+|[\s\t\u2000-\u200F]+$)/, '')&.length || 0) < min_title + errors.add(:title, "must be more than #{min_title} non-whitespace characters long") + end + end + + def maximum_title_length + max_title_len = SiteSetting['MaxTitleLength'] + if title.length > [(max_title_len || 255), 255].min + errors.add(:title, "can't be more than #{max_title_len} characters") + end + end + + def tags_in_tag_set + tag_set = category.tag_set + unless tags.all? { |t| t.tag_set_id == tag_set.id } + errors.add(:base, "Not all of this question's tags are in the correct tag set.") + end + end + + def required_tags? + required = category&.required_tag_ids + return unless required.present? && !required.empty? + + unless tag_ids.any? { |t| required.include? t } + errors.add(:tags, "must contain at least one required tag (#{category.required_tags.pluck(:name).join(', ')})") + end + end +end diff --git a/app/models/concerns/saml_init.rb b/app/models/concerns/saml_init.rb new file mode 100644 index 000000000..3709b87ec --- /dev/null +++ b/app/models/concerns/saml_init.rb @@ -0,0 +1,128 @@ +# Module for saml based initalization. +# +# The saml_init_email method is used to initialize the email address after a successful SSO sign in. +# The saml_init_identifier method is used to +module SamlInit + extend ActiveSupport::Concern + + included do + has_one :sso_profile, required: false, autosave: true, dependent: :destroy + + before_validation :prepare_from_saml, if: -> { saml_identifier.present? } + end + + # ----------------------------------------------------------------------------------------------- + # Identifier + # ----------------------------------------------------------------------------------------------- + + # @return [String, Nil] the saml_identifier of this user, or nil if the user is not from SSO + def saml_identifier + sso_profile&.saml_identifier + end + + # @param saml_identifier [String, Nil] sets (or clears) the saml_identifier of this user + def saml_identifier=(saml_identifier) + if saml_identifier.nil? + sso_profile&.destroy + else + build_sso_profile if sso_profile.nil? + sso_profile.saml_identifier = saml_identifier + end + end + + # This method is added as a fallback to support the Single Logout Service. + # + # @return [String, Nil] the saml_identifier of this user, or nil if the user is not from SSO + # @see #saml_identifier + def saml_init_identifier + saml_identifier + end + + # Sets the saml_identifier to the given saml_identifier upon initialization. In contrast to + # #saml_identifier=, this method does not delete the SSO profile in case the saml_identifier is + # not present (safety in case of SSO issues). + # + # @param saml_identifier [String, Nil] the saml_identifier + # @return [String, Nil] the saml_identifier of this user, should never be nil + def saml_init_identifier=(saml_identifier) + build_sso_profile if sso_profile.nil? + + # Only update if non-empty + sso_profile.saml_identifier = saml_identifier if saml_identifier.present? + end + + # ----------------------------------------------------------------------------------------------- + # Email + # ----------------------------------------------------------------------------------------------- + + # This method is added as a fallback to support the Single Logout Service. + # @return [String, Nil] the email address of this user, or nil if the user is not from SSO + def saml_init_email + return nil if sso_profile.nil? + + email + end + + # Initializes email address, and prevents (re)confirmation in case it is changed. + # + # @param email [String] the email address + def saml_init_email=(email) + self.email = email + skip_confirmation! + skip_reconfirmation! + end + + # ----------------------------------------------------------------------------------------------- + # Email is identifier + # ----------------------------------------------------------------------------------------------- + + # Used in the case that email is the unique identifier from saml. + # @return [String, Nil] the email address of the user, or nil in the case the user is not from SSO + def saml_init_email_and_identifier + return nil if sso_profile.nil? + + email + end + + # Used in the case that email is the unique identifier from saml. + # + # @param email [String] the email address (and saml identifier) + def saml_init_email_and_identifier=(email) + self.saml_init_email = email + self.saml_init_identifier = email + end + + # ----------------------------------------------------------------------------------------------- + # Username + # ----------------------------------------------------------------------------------------------- + + # This method is added as fallback to support the Single Logout Service. + # @return [String] the username + def saml_init_username_no_update + username + end + + # Sets the username from SAML in case it was not already set. + # This prevents overriding the user set username with the one from SAML all the time, while + # allowing for email updates to be applied. + # + # @param username [String] the username to set + def saml_init_username_no_update=(username) + self.username = username unless self.username.present? + end + + # ----------------------------------------------------------------------------------------------- + # Creation + # ----------------------------------------------------------------------------------------------- + + protected + + # Prepare a (potentially) new user from saml for creation. If the user is actually new, a random + # password is created for them and email confirmation is skipped. + def prepare_from_saml + return unless new_record? + + self.password = SecureRandom.hex + skip_confirmation! + end +end diff --git a/app/models/email_log.rb b/app/models/email_log.rb new file mode 100644 index 000000000..9b171ddbc --- /dev/null +++ b/app/models/email_log.rb @@ -0,0 +1,2 @@ +class EmailLog < ApplicationRecord +end diff --git a/app/models/filter.rb b/app/models/filter.rb new file mode 100644 index 000000000..fb11e1d47 --- /dev/null +++ b/app/models/filter.rb @@ -0,0 +1,7 @@ +class Filter < ApplicationRecord + belongs_to :user + has_many :category_filter_defaults, dependent: :destroy + validates :name, uniqueness: { scope: :user } + serialize :include_tags, Array + serialize :exclude_tags, Array +end diff --git a/app/models/post.rb b/app/models/post.rb index 458b91df7..4a1d1ba99 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -1,5 +1,6 @@ class Post < ApplicationRecord include CommunityRelated + include PostValidations belongs_to :user, optional: true belongs_to :post_type @@ -24,22 +25,20 @@ class Post < ApplicationRecord has_many :reactions counter_culture :parent, column_name: proc { |model| model.deleted? ? nil : 'answer_count' } + counter_culture [:user, :community_user], column_name: proc { |model| model.deleted? ? nil : 'post_count' } serialize :tags_cache, Array - validates :body, presence: true, length: { minimum: 30, maximum: 30_000 } + validates :body, presence: true, length: { maximum: 30_000 } validates :doc_slug, uniqueness: { scope: [:community_id], case_sensitive: false }, if: -> { doc_slug.present? } - validates :title, :body, :tags_cache, presence: true, if: -> { post_type.has_tags } - validate :tags_in_tag_set, if: -> { post_type.has_tags } - validate :maximum_tags, if: -> { post_type.has_tags } - validate :maximum_tag_length, if: -> { post_type.has_tags } - validate :no_spaces_in_tags, if: -> { post_type.has_tags } - validate :stripped_minimum, if: -> { post_type.has_tags } - validate :maximum_title_length, if: -> { post_type.has_tags } + validates :title, presence: true, if: -> { post_type.is_top_level? } + validates :tags_cache, presence: true, if: -> { post_type.has_tags } + validate :category_allows_post_type, if: -> { category_id.present? } validate :license_valid, if: -> { post_type.has_license } - validate :required_tags?, if: -> { post_type.has_tags && post_type.has_category } - validate :moderator_tags, if: -> { post_type.has_tags && post_type.has_category } + validate :moderator_tags, if: -> { post_type.has_tags && post_type.has_category && tags_cache_changed? } + + # Other validations (shared with suggested edits) are in concerns/PostValidations scope :undeleted, -> { where(deleted: false) } scope :deleted, -> { where(deleted: true) } @@ -55,13 +54,32 @@ class Post < ApplicationRecord after_save :modify_author_reputation after_save :copy_last_activity_to_parent after_save :break_description_cache - after_save :update_category_activity, if: -> { post_type.has_category } + after_save :update_category_activity, if: -> { post_type.has_category && !destroyed? } after_save :recalc_score + # @param term [String] the search term + # @return [ActiveRecord::Relation] def self.search(term) match_search term, posts: :body_markdown end + def self.by_slug(slug, user) + post = Post.unscoped.where( + doc_slug: slug, + community_id: [RequestContext.community_id, nil] + ).first + + if post&.help_category == '$Disabled' + return nil + end + + if post&.help_category == '$Moderator' && !user&.is_moderator + return nil + end + + post + end + # Double-define: initial definitions are less efficient, so if we have a record of the post type we'll # override them later with more efficient methods. ['Question', 'Answer', 'PolicyDoc', 'HelpDoc', 'Article'].each do |pt| @@ -76,14 +94,18 @@ def self.search(term) end end + # @return [TagSet] def tag_set parent.nil? ? category.tag_set : parent.category.tag_set end + # @return [Boolean] def meta? false end + # Used in the transfer of content from SE to reassign the owner of a post to the given user. + # @param new_user [User] def reassign_user(new_user) new_user.ensure_community_user! @@ -94,10 +116,13 @@ def reassign_user(new_user) update!(deleted: false, deleted_at: nil, deleted_by: nil) end + # Removes the attribution notice from this post + # @return [Boolean] whether the action was successful def remove_attribution_notice! update(att_source: nil, att_license_link: nil, att_license_name: nil) end + # @return [String] the type of the last activity on this post def last_activity_type case last_activity when closed_at @@ -121,37 +146,51 @@ def last_activity_type end end + # @return [String] the body with all markdown stripped def body_plain ApplicationController.helpers.strip_markdown(body_markdown) end + # @return [Boolean] whether this post is a question def question? post_type_id == Question.post_type_id end + # @return [Boolean] whether this post is an answer def answer? post_type_id == Answer.post_type_id end + # @return [Boolean] whether this post is an article def article? post_type_id == Article.post_type_id end + # @return [Boolean] whether there is a suggested edit pending for this post def pending_suggested_edit? SuggestedEdit.where(post_id: id, active: true).any? end + # @return [SuggestedEdit, Nil] the suggested edit pending for this post (if any) def pending_suggested_edit SuggestedEdit.where(post_id: id, active: true).last end + # Recalculates the score of this post based on its up and downvotes def recalc_score variable = SiteSetting['ScoringVariable'] || 2 sql = 'UPDATE posts SET score = (upvote_count + ?) / (upvote_count + downvote_count + (2 * ?)) WHERE id = ?' sanitized = ActiveRecord::Base.sanitize_sql_array([sql, variable, variable, id]) ActiveRecord::Base.connection.execute sanitized + + # ensures the updated score is immediately available + self.score = (upvote_count + variable).to_f / (upvote_count + downvote_count + (2 * variable)) + # prevents AR from accidentally saving the dirty state + clear_attribute_changes([:score]) end + # This method will update the locked status of this post if locked_until is in the past. + # @return [Boolean] whether this post is locked def locked? return true if locked && locked_until.nil? # permanent lock return true if locked && !locked_until.past? @@ -159,14 +198,27 @@ def locked? if locked update(locked: false, locked_by: nil, locked_at: nil, locked_until: nil) end + + false + end + + # The test here is for flags that are pending (no status). A spam flag + # could be marked helpful but the post wouldn't be deleted, and + # we don't necessarily want the post to be treated like it's a spam risk + # if that happens. + def spam_flag_pending? + flags.any? { |flag| flag.post_flag_type&.name == "it's spam" && !flag.status } end + # @param user [User, Nil] + # @return [Boolean] whether the given user can view this post def can_access?(user) (!deleted? || user&.has_post_privilege?('flag_curate', self)) && (!category.present? || !category.min_view_trust_level.present? || category.min_view_trust_level <= (user&.trust_level || 0)) end + # @return [Hash] a hash with as key the reaction type and value the amount of reactions for that type def reaction_list reactions.includes(:reaction_type).group_by(&:reaction_type_id) .to_h { |_k, v| [v.first.reaction_type, v] } @@ -174,12 +226,21 @@ def reaction_list private + ## + # Before-validation callback. Update the tags association from the tags_cache. def update_tag_associations tags_cache.each do |tag_name| - tag = Tag.find_or_create_by name: tag_name, tag_set: category.tag_set + tag, name_used = Tag.find_or_create_synonymized name: tag_name, tag_set: category.tag_set unless tags.include? tag tags << tag end + + # If the tags_cache doesn't include name_used then tag_name was a synonym - remove the synonym from tags_cache + # and add the primary for it instead. + unless tags_cache.include? name_used + tags_cache.delete tag_name + tags_cache << name_used + end end tags.each do |tag| unless tags_cache.include? tag.name @@ -188,11 +249,20 @@ def update_tag_associations end end + ## + # Helper method for #check_attribution_notice validator. Produces a text-only attribution notice either based on + # values given or the current state of the post for use in post histories. + # @param source [String, Nil] where the post originally came from + # @param name [String, Nil] the name of the license + # @param url [String, Nil] the url of the license + # @return [String] an attribution notice corresponding to this post def attribution_text(source = nil, name = nil, url = nil) "Source: #{source || att_source}\nLicense name: #{name || att_license_name}\n" \ "License URL: #{url || att_license_link}" end + # Intended to be called as callback after a save. + # If changes were made to the licensing of this post, this will insert the correct history items. def check_attribution_notice sc = saved_changes attributes = ['att_source', 'att_license_name', 'att_license_link'] @@ -210,6 +280,8 @@ def check_attribution_notice end end + # Intended to be called as callback after a save. + # If the last activity of this post was changed and it has a parent, also updates the parent activity def copy_last_activity_to_parent sc = saved_changes if parent.present? && (sc.include?('last_activity') || sc.include?('last_activity_by_id')) \ @@ -218,22 +290,28 @@ def copy_last_activity_to_parent end end + # Intended to be called as callback after a save. + # If this deletion status of this post was changed, then remove or re-add the reputation. def modify_author_reputation sc = saved_changes if sc.include?('deleted') && sc['deleted'][0] != sc['deleted'][1] && created_at >= 60.days.ago deleted = !!saved_changes['deleted']&.last if deleted - user.update(reputation: user.reputation - Vote.total_rep_change(votes)) + user&.update(reputation: (user&.reputation || 1) - Vote.total_rep_change(votes)) else - user.update(reputation: user.reputation + Vote.total_rep_change(votes)) + user&.update(reputation: (user&.reputation || 1) + Vote.total_rep_change(votes)) end end end + # Intended to be called as callback after a save. + # @return [PostHistory] creates an initial revision for this post def create_initial_revision PostHistory.initial_revision(self, user, after: body_markdown, after_title: title, after_tags: tags) end + # Intended to be used as validation. + # Will add an error if this post's post type is not allowed in the associated category. def category_allows_post_type return if category.nil? @@ -242,6 +320,8 @@ def category_allows_post_type end end + # Intended to be called as callback after a save. + # Deletes this posts description from the cache such that it will be regenerated next time it is needed. def break_description_cache Rails.cache.delete "posts/#{id}/description" if parent_id.present? @@ -249,6 +329,8 @@ def break_description_cache end end + # Intended to be used as a validation. + # Checks whether the associated license is present and enabled. def license_valid # Don't validate license on edits return unless id.nil? @@ -263,63 +345,9 @@ def license_valid end end - def maximum_tags - if tags_cache.length > 5 - errors.add(:base, "Post can't have more than 5 tags.") - elsif tags_cache.empty? - errors.add(:base, 'Post must have at least one tag.') - end - end - - def maximum_tag_length - tags_cache.each do |tag| - max_len = SiteSetting['MaxTagLength'] - if tag.length > max_len - errors.add(:tags, "can't be more than #{max_len} characters long each") - end - end - end - - def no_spaces_in_tags - tags_cache.each do |tag| - if tag.include?(' ') || tag.include?('_') - errors.add(:tags, 'may not include spaces or underscores - use hyphens for multiple-word tags') - end - end - end - - def stripped_minimum - if (body&.gsub(/(?:^[\s\t\u2000-\u200F]+|[\s\t\u2000-\u200F]+$)/, '')&.length || 0) < 30 - errors.add(:body, 'must be more than 30 non-whitespace characters long') - end - if (title&.gsub(/(?:^[\s\t\u2000-\u200F]+|[\s\t\u2000-\u200F]+$)/, '')&.length || 0) < 15 - errors.add(:title, 'must be more than 15 non-whitespace characters long') - end - end - - def maximum_title_length - max_title_len = SiteSetting['MaxTitleLength'] - if title.length > [(max_title_len || 255), 255].min - errors.add(:title, "can't be more than #{max_title_len} characters") - end - end - - def tags_in_tag_set - tag_set = category.tag_set - unless tags.all? { |t| t.tag_set_id == tag_set.id } - errors.add(:base, "Not all of this question's tags are in the correct tag set.") - end - end - - def required_tags? - required = category&.required_tag_ids - return unless required.present? && !required.empty? - - unless tag_ids.any? { |t| required.include? t } - errors.add(:tags, "must contain at least one required tag (#{category.required_tags.pluck(:name).join(', ')})") - end - end - + # Intended to be used as validation. + # Checks whether there are any moderator tags present added, and if so whether the current user is allowed to add + # those. def moderator_tags mod_tags = category&.moderator_tags&.map(&:name) return unless mod_tags.present? && !mod_tags.empty? @@ -333,6 +361,8 @@ def moderator_tags end end + # Intended to be used as callback after a save. + # Updates the category activity indicator if the last activity of this post changed. def update_category_activity if saved_changes.include? 'last_activity' category.update_activity(last_activity) diff --git a/app/models/post_history.rb b/app/models/post_history.rb index 2a742a99f..990518da2 100644 --- a/app/models/post_history.rb +++ b/app/models/post_history.rb @@ -1,5 +1,7 @@ class PostHistory < ApplicationRecord include PostRelated + include EditsValidations + belongs_to :post_history_type belongs_to :user has_many :post_history_tags @@ -13,13 +15,30 @@ def after_tags tags.where(post_history_tags: { relationship: 'after' }) end + # @param user [User] + # @return [Boolean] whether the given user is allowed to see the details of this history item + def allowed_to_see_details?(user) + !hidden || user&.is_admin || user_id == user&.id || post.user_id == user&.id + end + + # Hides all previous history + # @param post [Post] + # @param user [User] + def self.redact(post, user) + where(post: post).update_all(hidden: true) + history_hidden(post, user, after: post.body_markdown, + after_title: post.title, + after_tags: post.tags, + comment: 'Detailed history before this event is hidden because of a redaction.') + end + def self.method_missing(name, *args, **opts) unless args.length >= 2 raise NoMethodError end object, user = args - fields = [:before, :after, :comment, :before_title, :after_title, :before_tags, :after_tags] + fields = [:before, :after, :comment, :before_title, :after_title, :before_tags, :after_tags, :hidden] values = fields.to_h { |f| [f, nil] }.merge(opts) history_type_name = name.to_s @@ -31,7 +50,7 @@ def self.method_missing(name, *args, **opts) params = { post_history_type: history_type, user: user, post: object, community_id: object.community_id } { before: :before_state, after: :after_state, comment: :comment, before_title: :before_title, - after_title: :after_title }.each do |arg, attr| + after_title: :after_title, hidden: :hidden }.each do |arg, attr| next if values[arg].nil? params = params.merge(attr => values[arg]) @@ -47,7 +66,10 @@ def self.method_missing(name, *args, **opts) end end.values.compact.flatten - history.post_history_tags = PostHistoryTag.create(post_history_tags) + # do not create post history tags if post history validations failed + unless history.errors.any? + history.post_history_tags = PostHistoryTag.create(post_history_tags) + end history end diff --git a/app/models/post_type.rb b/app/models/post_type.rb index 7f3e7570b..2cf37fdfd 100644 --- a/app/models/post_type.rb +++ b/app/models/post_type.rb @@ -19,6 +19,11 @@ def reactions end end + # @return [Boolean] whether the post type is a system type + def system? + ['HelpDoc', 'PolicyDoc'].include?(name) + end + def self.mapping Rails.cache.fetch 'network/post_types/post_type_ids', include_community: false do PostType.all.to_h { |pt| [pt.name, pt.id] } diff --git a/app/models/site_setting.rb b/app/models/site_setting.rb index ab5c9504b..160bbd05b 100644 --- a/app/models/site_setting.rb +++ b/app/models/site_setting.rb @@ -11,14 +11,14 @@ class SiteSetting < ApplicationRecord def self.[](name) key = "SiteSettings/#{RequestContext.community_id}/#{name}" - cached = Rails.cache.fetch key do + cached = Rails.cache.fetch key, include_community: false do SiteSetting.applied_setting(name)&.typed end if cached.nil? - Rails.cache.delete key + Rails.cache.delete key, include_community: false value = SiteSetting.applied_setting(name)&.typed - Rails.cache.write key, value + Rails.cache.write key, value, include_community: false value else cached @@ -26,10 +26,16 @@ def self.[](name) end def self.exist?(name) - Rails.cache.exist?("SiteSettings/#{RequestContext.community_id}/#{name}") || + Rails.cache.exist?("SiteSettings/#{RequestContext.community_id}/#{name}", include_community: false) || SiteSetting.where(name: name).count.positive? end + # Checks whether the setting is a global site setting + # @return [Boolean] + def global? + community_id.nil? + end + def typed SettingConverter.new(value).send("as_#{value_type.downcase}") end @@ -45,14 +51,14 @@ def self.applied_setting(name) def self.all_communities(name) communities = Community.all keys = (communities.map { |c| [c.id, "SiteSetting/#{c.id}/#{name}"] } + [[nil, "SiteSetting//#{name}"]]).to_h - cached = Rails.cache.read_multi(*keys.values) + cached = Rails.cache.read_multi(*keys.values, include_community: false) missing = keys.reject { |_k, v| cached.include?(v) }.map { |k, _v| k } settings = if missing.empty? {} else SiteSetting.where(name: name, community_id: missing).to_h { |s| [s.community_id, s] } end - Rails.cache.write_multi(missing.to_h { |cid| [keys[cid], settings[cid]&.typed] }) + Rails.cache.write_multi(missing.to_h { |cid| [keys[cid], settings[cid]&.typed] }, include_community: false) communities.to_h do |c| [ c.id, diff --git a/app/models/sso_profile.rb b/app/models/sso_profile.rb new file mode 100644 index 000000000..0559a0f0f --- /dev/null +++ b/app/models/sso_profile.rb @@ -0,0 +1,5 @@ +class SsoProfile < ApplicationRecord + belongs_to :user, inverse_of: :sso_profile + + validates :saml_identifier, uniqueness: true, presence: true +end diff --git a/app/models/suggested_edit.rb b/app/models/suggested_edit.rb index 7730bbc61..0afbe4d90 100644 --- a/app/models/suggested_edit.rb +++ b/app/models/suggested_edit.rb @@ -1,5 +1,7 @@ class SuggestedEdit < ApplicationRecord include PostRelated + include PostValidations + include EditsValidations belongs_to :user @@ -10,6 +12,15 @@ class SuggestedEdit < ApplicationRecord has_and_belongs_to_many :tags has_and_belongs_to_many :before_tags, class_name: 'Tag', join_table: 'suggested_edits_before_tags' + has_one :post_type, through: :post + has_one :category, through: :post + + after_save :clear_pending_cache, if: proc { saved_change_to_attribute?(:active) } + + def clear_pending_cache + Rails.cache.delete "pending_suggestions/#{post.category_id}" + end + def pending? active end diff --git a/app/models/tag.rb b/app/models/tag.rb index 1b78a95fe..873a0ea95 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -5,6 +5,8 @@ class Tag < ApplicationRecord has_many :children, class_name: 'Tag', foreign_key: :parent_id has_many :children_with_paths, class_name: 'TagWithPath', foreign_key: :parent_id has_many :post_history_tags + has_many :tag_synonyms, dependent: :destroy + accepts_nested_attributes_for :tag_synonyms, allow_destroy: true belongs_to :tag_set belongs_to :parent, class_name: 'Tag', optional: true @@ -13,12 +15,57 @@ class Tag < ApplicationRecord validates :name, presence: true, format: { with: /[^ \t]+/, message: 'Tag names may not include spaces' } validate :parent_not_self validate :parent_not_own_child + validate :synonym_unique validates :name, uniqueness: { scope: [:tag_set_id], case_sensitive: false } def self.search(term) - where('name LIKE ?', "%#{sanitize_sql_like(term)}%") - .or(where('excerpt LIKE ?', "%#{sanitize_sql_like(term)}%")) - .order(Arel.sql(sanitize_sql_array(['name LIKE ? DESC, name', "#{sanitize_sql_like(term)}%"]))) + stripped = term.strip + # Query to search on tags, the name is used for sorting. + q1 = where('tags.name LIKE ?', "%#{sanitize_sql_like(stripped)}%") + .or(where('tags.excerpt LIKE ?', "%#{sanitize_sql_like(stripped)}%")) + .select(Arel.sql('name AS sortname, tags.*')) + + # Query to search on synonyms, the synonym name is used for sorting. + # The order clause here actually applies to the union of q1 and q2 (so not just q2). + q2 = joins(:tag_synonyms) + .where('tag_synonyms.name LIKE ?', "%#{sanitize_sql_like(stripped)}%") + .select(Arel.sql('tag_synonyms.name AS sortname, tags.*')) + .order(Arel.sql(sanitize_sql_array(['sortname LIKE ? DESC, sortname', "#{sanitize_sql_like(stripped)}%"]))) + + # Select from the union of the above queries, select only the tag columns such that we can distinct them + from(Arel.sql("(#{q1.to_sql} UNION #{q2.to_sql}) tags")) + .select(Tag.column_names.map { |c| "tags.#{c}" }) + .distinct + end + + ## + # Find or create a tag within a given tag set, considering synonyms. If a synonym is given as +name+ then the primary + # tag for it is returned instead. + # @param name [String] A tag name to find or create. + # @param tag_set [TagSet] The tag set within which to search for or create the tag. + # @return [Array(Tag, String)] The found or created tag, and the final name used. If a synonymized name was given as + # +name+ then this will be the primary tag name. + # + # @example +name+ does not yet exist: a new Tag is created + # Tag.find_or_create_synonymized name: 'new-tag', tag_set: ... + # # => [Tag, 'new-tag'] + # + # @example +name+ already exists: the existing Tag is returned + # Tag.find_or_create_synonymized name: 'existing-tag', tag_set: ... + # # => [Tag, 'existing-tag'] + # + # @example +name+ is a synonym of 'other-tag': the Tag for 'other-tag' is returned + # Tag.find_or_create_synonymized name: 'synonym', tag_set: ... + # # => [Tag, 'other-tag'] + def self.find_or_create_synonymized(name:, tag_set:) + existing = Tag.find_by(name: name, tag_set: tag_set) + if existing.nil? + synonyms = TagSynonym.joins(:tag).where(name: name, tags: { tag_set: tag_set }) + synonymized_name = synonyms.exists? ? synonyms.first.tag.name : name + [Tag.find_or_create_by(name: synonymized_name, tag_set: tag_set), synonymized_name] + else + [existing, name] + end end def all_children @@ -54,4 +101,10 @@ def parent_not_own_child errors.add(:base, "The #{parent.name} tag is already a child of this tag.") end end + + def synonym_unique + if TagSynonym.joins(:tag).where(tags: { community_id: community_id }).exists?(name: name) + errors.add(:base, "A tag synonym with the name #{name} already exists.") + end + end end diff --git a/app/models/tag_set.rb b/app/models/tag_set.rb index e71e33c89..150dab46a 100644 --- a/app/models/tag_set.rb +++ b/app/models/tag_set.rb @@ -13,4 +13,12 @@ def self.meta def self.main where(name: 'Main').first end + + def with_paths(no_excerpt = false) + if no_excerpt + tags_with_paths.where(excerpt: ['', nil]) + else + tags_with_paths + end + end end diff --git a/app/models/tag_synonym.rb b/app/models/tag_synonym.rb new file mode 100644 index 000000000..da0aec259 --- /dev/null +++ b/app/models/tag_synonym.rb @@ -0,0 +1,18 @@ +class TagSynonym < ApplicationRecord + belongs_to :tag + has_one :community, through: :tag + + validates :name, presence: true, format: { with: /[^ \t]+/, message: 'Tag names may not include spaces' } + validate :name_unique + + private + + # Checks whether the name of this synonym is not already taken by a tag or synonym in the same community. + def name_unique + if TagSynonym.joins(:tag).where(tags: { community_id: tag.community_id }).exists?(name: name) + errors.add(:base, "A tag synonym with the name #{name} already exists.") + elsif Tag.unscoped.where(community_id: tag.community_id).exists?(name: name) + errors.add(:base, "A tag with the name #{name} already exists.") + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 84d79db01..04e1219f7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,10 +2,11 @@ # application code (i.e. excluding Devise) is concerned, has many questions, answers, and votes. class User < ApplicationRecord include ::UserMerge + include ::SamlInit devise :database_authenticatable, :registerable, :confirmable, :recoverable, :rememberable, :trackable, :validatable, - :lockable, :omniauthable + :lockable, :omniauthable, :saml_authenticatable has_many :posts, dependent: :nullify has_many :votes, dependent: :destroy @@ -24,6 +25,10 @@ class User < ApplicationRecord has_many :comments, dependent: :nullify has_many :comment_threads_deleted, class_name: 'CommentThread', foreign_key: :deleted_by_id, dependent: :nullify has_many :comment_threads_locked, class_name: 'CommentThread', foreign_key: :locked_by_id, dependent: :nullify + has_many :category_filter_defaults, dependent: :destroy + has_many :filters, dependent: :destroy + has_many :user_websites, dependent: :destroy + accepts_nested_attributes_for :user_websites belongs_to :deleted_by, required: false, class_name: 'User' validates :username, presence: true, length: { minimum: 3, maximum: 50 } @@ -40,7 +45,7 @@ class User < ApplicationRecord scope :active, -> { where(deleted: false) } scope :deleted, -> { where(deleted: true) } - after_create :send_welcome_tour_message + after_create :send_welcome_tour_message, :ensure_websites def self.list_includes includes(:posts, :avatar_attachment) @@ -58,6 +63,12 @@ def trust_level community_user.trust_level end + # Checks whether this user is the same as a given user + # @param [User] user user to compare with + def same_as?(user) + id == user.id + end + # This class makes heavy use of predicate names, and their use is prevalent throughout the codebase # because of the importance of these methods. # rubocop:disable Naming/PredicateName @@ -69,6 +80,22 @@ def has_post_privilege?(name, post) end end + # Checks if the user can push a given post type to network + # @param post_type [PostType] type of the post to be pushed + # @return [Boolean] + def can_push_to_network(post_type) + post_type.system? && (is_global_moderator || is_global_admin) + end + + # Checks if the user can directly update a given post + # @param post [Post] updated post (owners can unilaterally update) + # @param post_type [PostType] type of the post (some are freely editable) + # @return [Boolean] + def can_update(post, post_type) + privilege?('edit_posts') || is_moderator || self == post.user || \ + (post_type.is_freely_editable && privilege?('unrestricted')) + end + def metric(key) Rails.cache.fetch("community_user/#{community_user.id}/metric/#{key}", expires_in: 24.hours) do case key @@ -111,6 +138,18 @@ def website_domain website.nil? ? website : URI.parse(website).hostname end + def valid_websites_for + user_websites.where.not(url: [nil, '']).order(position: :asc) + end + + def ensure_websites + pos = user_websites.size + while pos < UserWebsite::MAX_ROWS + pos += 1 + UserWebsite.create(user_id: id, position: pos) + end + end + def is_moderator is_global_moderator || community_user&.is_moderator || is_admin || community_user&.privilege?('mod') || false end @@ -119,6 +158,42 @@ def is_admin is_global_admin || community_user&.is_admin || false end + # Used by network profile: does this user have a profile on that other comm? + def has_profile_on(community_id) + cu = community_users.where(community_id: community_id).first + !cu&.user_id.nil? || false + end + + def reputation_on(community_id) + cu = community_users.where(community_id: community_id).first + cu&.reputation || 1 + end + + def post_count_on(community_id) + cu = community_users.where(community_id: community_id).first + cu&.post_count || 0 + end + + def is_moderator_on(community_id) + cu = community_users.where(community_id: community_id).first + # is_moderator is a DB check, not a call to is_moderator() + is_global_moderator || is_admin || cu&.is_moderator || cu&.privilege?('mod') || false + end + + def has_ability_on(community_id, ability_internal_id) + cu = community_users.where(community_id: community_id).first + if cu&.is_moderator || cu&.is_admin || is_global_moderator || is_global_admin || cu&.privilege?('mod') + true + elsif cu.nil? + false + else + Ability.unscoped do + UserAbility.joins(:ability).where(community_user_id: cu&.id, is_suspended: false, + ability: { internal_id: ability_internal_id }).exists? + end + end + end + def rtl_safe_username "#{username}\u202D" end @@ -177,7 +252,7 @@ def is_not_blocklisted def email_not_bad_pattern return unless File.exist?(Rails.root.join('../.qpixel-email-patterns.txt')) - return unless saved_changes.include? 'email' + return unless changes.include? 'email' patterns = File.read(Rails.root.join('../.qpixel-email-patterns.txt')).split("\n") matched = patterns.select { |p| email.match? Regexp.new(p) } @@ -214,7 +289,7 @@ def send_welcome_tour_message 'how this site works.', '/tour') end - def block(reason) + def block(reason, length: 180.days) user_email = email user_ip = [last_sign_in_ip] @@ -222,10 +297,10 @@ def block(reason) user_ip << current_sign_in_ip end - BlockedItem.create(item_type: 'email', value: user_email, expires: DateTime.now + 180.days, + BlockedItem.create(item_type: 'email', value: user_email, expires: length.from_now, automatic: true, reason: "#{reason}: #" + id.to_s) user_ip.compact.uniq.each do |ip| - BlockedItem.create(item_type: 'ip', value: ip, expires: 180.days.from_now, + BlockedItem.create(item_type: 'ip', value: ip, expires: length.from_now, automatic: true, reason: "#{reason}: #" + id.to_s) end end @@ -234,13 +309,19 @@ def preferences global_key = "prefs.#{id}" community_key = "prefs.#{id}.community.#{RequestContext.community_id}" { - global: AppConfig.preferences.reject { |_, v| v['community'] }.transform_values { |v| v['default'] } + global: AppConfig.preferences.select { |_, v| v['global'] }.transform_values { |v| v['default'] } .merge(RequestContext.redis.hgetall(global_key)), community: AppConfig.preferences.select { |_, v| v['community'] }.transform_values { |v| v['default'] } .merge(RequestContext.redis.hgetall(community_key)) } end + def category_preference(category_id) + category_key = "prefs.#{id}.category.#{RequestContext.community_id}.category.#{category_id}" + AppConfig.preferences.select { |_, v| v['category'] }.transform_values { |v| v['default'] } + .merge(RequestContext.redis.hgetall(category_key)) + end + def validate_prefs! global_key = "prefs.#{id}" community_key = "prefs.#{id}.community.#{RequestContext.community_id}" @@ -260,5 +341,24 @@ def validate_prefs! def preference(name, community: false) preferences[community ? :community : :global][name] end + + def has_active_flags?(post) + !post.flags.where(user: self, status: nil).empty? + end + + def active_flags(post) + post.flags.where(user: self, status: nil) + end + + def do_soft_delete(attribute_to) + AuditLog.moderator_audit(event_type: 'user_delete', related: self, user: attribute_to, + comment: attributes_print(join: "\n")) + assign_attributes(deleted: true, deleted_by_id: attribute_to.id, deleted_at: DateTime.now, + username: "user#{id}", email: "#{id}@deleted.localhost", + password: SecureRandom.hex(32)) + skip_reconfirmation! + save + end + # rubocop:enable Naming/PredicateName end diff --git a/app/models/user_website.rb b/app/models/user_website.rb new file mode 100644 index 000000000..6b50d909f --- /dev/null +++ b/app/models/user_website.rb @@ -0,0 +1,6 @@ +class UserWebsite < ApplicationRecord + belongs_to :user + default_scope { order(:position) } + + MAX_ROWS = 3 +end diff --git a/app/models/vote.rb b/app/models/vote.rb index a7a3505ab..a7c42dcb3 100644 --- a/app/models/vote.rb +++ b/app/models/vote.rb @@ -34,37 +34,45 @@ def reverse_rep_change end def rep_change(direction) + return unless post.present? + change = CategoryPostType.rep_changes[[post.category_id, post.post_type_id]][vote_type] || 0 recv_user.update!(reputation: recv_user.reputation + (direction * change)) end def post_not_deleted - if post.deleted? + if post&.deleted? errors.add(:base, 'Votes are locked on deleted posts') end end def check_valid - throw :abort unless valid? + throw :abort unless valid? || post.blank? end def add_counter + return unless post.present? + case vote_type when 1 post.update(upvote_count: post.upvote_count + 1) when -1 post.update(downvote_count: post.downvote_count + 1) end + post.recalc_score end def remove_counter + return unless post.present? + case vote_type when 1 post.update(upvote_count: [post.upvote_count - 1, 0].max) when -1 post.update(downvote_count: [post.downvote_count - 1, 0].max) end + post.recalc_score end end diff --git a/app/tasks/maintenance/initialize_user_websites_task.rb b/app/tasks/maintenance/initialize_user_websites_task.rb new file mode 100644 index 000000000..80a91b948 --- /dev/null +++ b/app/tasks/maintenance/initialize_user_websites_task.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Maintenance + class InitializeUserWebsitesTask < MaintenanceTasks::Task + def collection + User.all + end + + def process(user) + unless user.user_websites.exists?(position: 1) + if user.website.present? + UserWebsite.create!(user_id: user.id, position: 1, label: 'website', url: user.website) + else + UserWebsite.create!(user_id: user.id, position: 1) + end + end + + unless user.user_websites.exists?(position: 2) + if user.twitter.present? + UserWebsite.create!(user_id: user.id, position: 2, label: 'Twitter', + url: "https://twitter.com/#{user.twitter}") + else + UserWebsite.create!(user_id: user.id, position: 2) + end + end + + # This check *should* be superfluous, but just in case... + unless user.user_websites.exists?(position: 3) + UserWebsite.create!(user_id: user.id, position: 3) + end + end + end +end diff --git a/app/views/abilities/show.html.erb b/app/views/abilities/show.html.erb index c06d2131e..a34e29ca2 100644 --- a/app/views/abilities/show.html.erb +++ b/app/views/abilities/show.html.erb @@ -49,20 +49,23 @@ <% unless @ability.manual? %> <% unless params[:thresholds].nil? %>
    -

    You need to reach these thresholds to earn the ability:

    - + <% if @user.id == current_user&.id %> +

    You need to reach these thresholds to earn the ability:

    + <% else %> +

    <%= @user.username %> needs to reach these thresholds to earn the ability:

    + <% end %> <% unless @ability.post_score_threshold.nil? %> <% post_score_percent = (linearize_progress(@user.community_user.post_score) / linearize_progress(@ability.post_score_threshold) * 100).to_i %>

    Post score threshold

    <% if post_score_percent < 100 %> -

    You need to have more well-received posts.

    +

    Need to have more well-received posts.

    <%= post_score_percent %>%
    <% else %> -

    You need to have many well-received posts.

    +

    Need to have many well-received posts.

    100%
    @@ -75,13 +78,13 @@

    Edit score threshold

    <% if edit_score_percent < 100 %> -

    You need to have more accepted suggested edits.

    +

    Need to have more accepted suggested edits.

    <%= edit_score_percent %>%
    <% else %> -

    You need to have many accepted suggested edits.

    +

    Need to have many accepted suggested edits.

    100%
    @@ -94,13 +97,13 @@

    Flag score threshold

    <% if flag_score_percent < 100 %> -

    You need to have more helpful flags.

    +

    Need to have more helpful flags.

    <%= flag_score_percent %>%
    <% else %> -

    You need to have many helpful flags.

    +

    Need to have many helpful flags.

    100%
    diff --git a/app/views/admin/all_email.html.erb b/app/views/admin/all_email.html.erb new file mode 100644 index 000000000..ade37ea36 --- /dev/null +++ b/app/views/admin/all_email.html.erb @@ -0,0 +1,24 @@ +<%= render 'posts/markdown_script' %> + +
    +

    + Please be careful, as this tool sends a lot of emails. +

    +
    + +

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

    +

    <%= t 'admin.email_all_blurb' %>

    + +<%= form_with url: send_all_email_path do |f| %> +
    + <%= f.label :subject, t('g.subject').capitalize, class: 'form-element' %> + <%= f.text_field :subject, class: 'form-element', required: true %> +
    + + <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: t('g.body').capitalize, post: nil %> + +
    + + <%= f.submit t('g.send').capitalize, class: 'button is-filled', + onclick: "return confirm('Are you sure you want to send this email to all users?')" %> +<% end %> diff --git a/app/views/admin/audit_log.html.erb b/app/views/admin/audit_log.html.erb index cb6003e1b..ea2b467af 100644 --- a/app/views/admin/audit_log.html.erb +++ b/app/views/admin/audit_log.html.erb @@ -4,15 +4,15 @@

    <%= pluralize(@logs.count, t('g.log')) %>

    <% classes = 'button is-outlined is-muted' %> - <%= link_to t('g.age'), query_url(sort: 'age'), + <%= link_to t('g.age'), request.params.merge(sort: 'age'), class: "#{classes} #{params[:sort] == 'age' || params[:sort].nil? ? 'is-active' : ''}" %> - <%= link_to t('g.type'), query_url(sort: 'type'), + <%= link_to t('g.type'), request.params.merge(sort: 'type'), class: "#{classes} #{params[:sort] == 'type' ? 'is-active' : ''}" %> - <%= link_to t('g.event'), query_url(sort: 'event'), + <%= link_to t('g.event'), request.params.merge(sort: 'event'), class: "#{classes} #{params[:sort] == 'event' ? 'is-active' : ''}" %> - <%= link_to t('g.related'), query_url(sort: 'related'), + <%= link_to t('g.related'), request.params.merge(sort: 'related'), class: "#{classes} #{params[:sort] == 'related' ? 'is-active' : ''}" %> - <%= link_to t('g.user'), query_url(sort: 'user'), + <%= link_to t('g.user'), request.params.merge(sort: 'user'), class: "#{classes} #{params[:sort] == 'user' ? 'is-active' : ''}" %>
    diff --git a/app/views/admin/change_back.html.erb b/app/views/admin/change_back.html.erb index 26e9e77c0..33cf3c7ea 100644 --- a/app/views/admin/change_back.html.erb +++ b/app/views/admin/change_back.html.erb @@ -1,14 +1,20 @@

    Stop impersonating

    - You (<%= @impersonator.username %>) are currently impersonating <%= current_user.username %>. To stop - impersonating them, verify your password below + You (<%= @impersonator.username %>) are currently impersonating <%= current_user.username %>. + <% if @impersonator.sso_profile.present? %> + You can stop impersonating them with the button below, after which you will have to sign in again. + <% else %> + To stop impersonating them, verify your password below. + <% end %>

    <%= form_tag stop_impersonating_path, class: 'form-horizontal' do %> -
    - <%= label_tag :password, 'Password', class: 'form-element' %> - <%= password_field_tag :password, '', class: 'form-element' %> -
    + <% unless @impersonator.sso_profile.present? %> +
    + <%= label_tag :password, 'Password', class: 'form-element' %> + <%= password_field_tag :password, '', class: 'form-element' %> +
    + <% end %>
    <%= submit_tag 'Verify & Stop Impersonating', class: 'button is-primary is-filled' %> diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb index 89f299e13..2f4c5266d 100644 --- a/app/views/admin/index.html.erb +++ b/app/views/admin/index.html.erb @@ -30,17 +30,28 @@
    - - <%= link_to t('admin.tools.email_moderators'), moderator_email_path, 'data-ckb-item-link' => '' %> + + <%= link_to t('admin.tools.post_types'), post_types_path, 'data-ckb-item-link' => '' %>
    + <% if current_user.developer? %> +
    +
    +
    + + <%= link_to 'Email All Users', email_all_path, 'data-ckb-item-link' => '' %> +
    +
    +
    + <% end %> +
    - - <%= link_to t('admin.tools.post_types'), post_types_path, 'data-ckb-item-link' => '' %> + + <%= link_to t('admin.tools.email_moderators'), moderator_email_path, 'data-ckb-item-link' => '' %>
    @@ -108,6 +119,7 @@
    +
    @@ -116,6 +128,7 @@
    + <% if current_user.is_global_admin %>
    diff --git a/app/views/admin_mailer/to_all_users.html.erb b/app/views/admin_mailer/to_all_users.html.erb new file mode 100644 index 000000000..790db2689 --- /dev/null +++ b/app/views/admin_mailer/to_all_users.html.erb @@ -0,0 +1,2 @@ +<%= raw(sanitize(ApplicationController.helpers.render_markdown(@body_markdown), + scrubber: ApplicationController.helpers.scrubber)) %> diff --git a/app/views/admin_mailer/to_all_users.text.erb b/app/views/admin_mailer/to_all_users.text.erb new file mode 100644 index 000000000..636df4adf --- /dev/null +++ b/app/views/admin_mailer/to_all_users.text.erb @@ -0,0 +1 @@ +<%= ApplicationController.helpers.strip_markdown @body_markdown %> diff --git a/app/views/application/dashboard.html.erb b/app/views/application/dashboard.html.erb index f818dfaba..397e020fc 100644 --- a/app/views/application/dashboard.html.erb +++ b/app/views/application/dashboard.html.erb @@ -1,6 +1,7 @@ <%= content_for :title, 'Dashboard' %>

    Dashboard

    +

    All communities, with their categories. You can also see the communities from the dropdown arrow at the top right of each page.

    <% @communities.each do |c| %> <% categories = Category.unscoped.where(community: c).order(sequence: :asc, id: :asc) %> @@ -19,7 +20,6 @@ <% end %>
    -
    Posts
    <% categories.each do |cat| %> <% next if (cat.min_view_trust_level || -1) > (current_user&.trust_level || 0) %>
    @@ -29,6 +29,15 @@ <% end %> <% end %> + + <% if current_user&.has_ability_on(c.id, 'edit_posts') %> + <% sug_edits = @edits[cat.id] || 0 %> + <% if sug_edits > 0 %> + <%= link_to suggested_edits_queue_url(cat, host: c.host), class: 'widget--body-extra' do %> + (<%= sug_edits %> pending <%= "edit".pluralize(sug_edits) %>) + <% end %> + <% end %> + <% end %>
    <% end %> <% if current_user&.is_global_moderator || current_user&.is_global_admin %> diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index 7b172c7eb..55e04b314 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -1,4 +1,14 @@ <%= form_for @category, url: submit_path, method: :post do |f| %> + <% if @category.errors.any? %> +
    +
      + <% @category.errors.full_messages.each do |m| %> +
    • <%= m %>
    • + <% end %> +
    +
    + <% end %> +
    Basic Information @@ -51,6 +61,18 @@ <%= f.select :tag_set_id, options_for_select(TagSet.all.map { |ts| [ts.name, ts.id] }, selected: @category.tag_set_id), { include_blank: true }, class: 'form-element js-category-tag-set-select' %>
    + +
    + <%= f.label :min_title_length, 'Minimum title length', class: 'form-element' %> + Titles of posts in this category must be at least this many characters long. + <%= f.number_field :min_title_length, class: 'form-element' %> +
    + +
    + <%= f.label :min_body_length, 'Minimum body length', class: 'form-element' %> + Posts in this category must be at least this many characters long. + <%= f.number_field :min_body_length, class: 'form-element' %> +
    @@ -123,6 +145,13 @@ <%= f.number_field :sequence, class: 'form-element' %>
    + +
    + <%= f.label :default_filter_id, class: 'form-element' %> + The default filter for this category, used for anonymous users. + <% system_filters = User.find(-1).filters.to_h { |filter| [filter.name, filter.id] } %> + <%= f.select :default_filter_id, options_for_select(system_filters, selected: @category.default_filter_id), { include_blank: "No default" } %> +
    @@ -192,7 +221,7 @@ <% disabled = @category.tag_set.nil? %> <%= f.select :required_tag_ids, options_for_select(@category.required_tags.map { |t| [t.name, t.id] }, selected: @category.required_tag_ids), - { include_blank: true }, multiple: true, class: 'form-element js-tag-select js-required-tags', + {}, multiple: true, class: 'form-element js-tag-select js-required-tags', data: { tag_set: @category.tag_set&.id, create: 'false', use_ids: 'true' }, disabled: disabled %> @@ -210,7 +239,7 @@ <%= f.select :topic_tag_ids, options_for_select(@category.topic_tags.map { |t| [t.name, t.id] }, selected: @category.topic_tag_ids), - { include_blank: true }, multiple: true, class: 'form-element js-tag-select js-topic-tags', + {}, multiple: true, class: 'form-element js-tag-select js-topic-tags', data: { tag_set: @category.tag_set&.id, create: 'false', use_ids: 'true' }, disabled: disabled %> @@ -227,7 +256,7 @@ <%= f.select :moderator_tag_ids, options_for_select(@category.moderator_tags.map { |t| [t.name, t.id] }, selected: @category.moderator_tag_ids), - { include_blank: true }, multiple: true, class: 'form-element js-tag-select js-moderator-tags', + {}, multiple: true, class: 'form-element js-tag-select js-moderator-tags', data: { tag_set: @category.tag_set&.id, create: 'false', use_ids: 'true' }, disabled: disabled %>
    diff --git a/app/views/categories/show.html.erb b/app/views/categories/show.html.erb index c5bfcce11..62a18a316 100644 --- a/app/views/categories/show.html.erb +++ b/app/views/categories/show.html.erb @@ -11,45 +11,62 @@ <% end %> <% post_count = @posts.count %> -
    -
    - <%= short_number_to_human post_count, precision: 1, significant: false %> - <%= 'post'.pluralize(post_count) %> · - - <% if current_user&.is_admin %> - <%= link_to 'Edit Category', edit_category_path(@category) %> - · - <% end %> - - <%= link_to 'Subscribe', - new_subscription_path(type: 'category', qualifier: @category.id, return_to: request.path), - class: 'button is-outlined' %> -
    +
    + + + <%= short_number_to_human post_count, precision: 1, significant: false %> + <%= 'post'.pluralize(post_count) %> + + <%= render 'shared/rss_link', url: category_feed_path(@category, format: 'rss') %> + + <% if current_user&.is_admin %> + <%= link_to 'Edit Category', edit_category_path(@category) %> + <% end %> + +
    - <%= link_to 'Activity', query_url(sort: 'activity'), + <%= link_to 'Activity', request.params.merge(sort: 'activity'), class: "button is-muted is-outlined #{(params[:sort].nil?) && !current_page?(questions_lottery_path) || params[:sort] == 'activity' ? 'is-active' : ''}", title: 'most recent changes: new posts, edits, close/open, delete/undelete' %> - <%= link_to 'Age', query_url(sort: 'age'), + <%= link_to 'Age', request.params.merge(sort: 'age'), class: "button is-muted is-outlined #{params[:sort] == 'age' ? 'is-active' : ''}", title: 'newest posts (ignores other activity)' %> - <%= link_to 'Score', query_url(sort: 'score'), + <%= link_to 'Score', request.params.merge(sort: 'score'), class: "button is-muted is-outlined #{params[:sort] == 'score' ? 'is-active' : ''}", title: 'highest score first (not the same as net votes)' %> <% if SiteSetting['AllowContentTransfer'] %> - <%= link_to 'Native', query_url(sort: 'native'), + <%= link_to 'Native', request.params.merge(sort: 'native'), class: "button is-muted is-outlined #{params[:sort] == 'native' ? 'is-active' : ''}", title: 'exclude imported posts' %> <% end %> - <% if @category.name == 'Q&A' %> - <%= link_to 'Random', query_url(sort: 'lottery'), - class: "button is-muted is-outlined #{params[:sort] == 'lottery' ? 'is-active' : ''}", - title: 'random set of questions, usually older ones' %> - <% end %> + <%= link_to 'Random', request.params.merge(sort: 'lottery'), + class: "button is-muted is-outlined #{params[:sort] == 'lottery' ? 'is-active' : ''}", + title: 'random set of questions, usually older ones' %>
    +
    + Filters (<%= @filtered ? @active_filter[:name].empty? ? 'Custom' : @active_filter[:name] : 'None' %>) + <% if @active_filter[:default] == :user %> +
    + You are currently filtering by <%= @active_filter[:name] %> because it is set as your default for this category +
    + <% elsif @active_filter[:default] == :category and not user_signed_in? %> +
    + You are currently filtering by <%= @active_filter[:name] %> because it is the default for this category +
    + <% elsif @active_filter[:default] == :category and user_signed_in? %> +
    + You are currently filtering by <%= @active_filter[:name] %> because it is the default for this category and you do not have a personal default set +
    + <% end %> + <%= form_tag request.original_url, method: :get do %> + <%= render 'search/filters' %> + <% end %> +
    +
    <% @posts.each do |post| %> <%= render 'posts/type_agnostic', post: post %> @@ -61,7 +78,5 @@
    - <%= link_to category_feed_path(@category, format: 'rss') do %> - Category RSS feed - <% end %> -
    \ No newline at end of file + <%= render 'shared/rss_link', url: category_feed_path(@category, format: 'rss'), text: 'Category RSS feed' %> +
    diff --git a/app/views/close_reasons/_form.html.erb b/app/views/close_reasons/_form.html.erb index e691772a6..03522077d 100644 --- a/app/views/close_reasons/_form.html.erb +++ b/app/views/close_reasons/_form.html.erb @@ -42,5 +42,5 @@ <%= f.submit 'Save', class: 'button is-filled' %> - <%= link_to 'Cancel', close_reasons_path(global: @close_reason.community.nil? ? 1 : 0), class: 'button' %> + <%= link_to 'Cancel', close_reasons_path(global: @close_reason.community.nil? ? 1 : 0), class: 'button', role: 'button' %> <% end %> diff --git a/app/views/close_reasons/index.html.erb b/app/views/close_reasons/index.html.erb index 324b7ecbc..1297a0279 100644 --- a/app/views/close_reasons/index.html.erb +++ b/app/views/close_reasons/index.html.erb @@ -33,7 +33,7 @@ <%= reason.requires_other_post ? "yes" : "no" %> <%= reason.active ? "yes" : "no" %> - <%= link_to "edit", close_reason_path(id: reason.id), class: "button is-outlined" %> + <%= link_to "edit", close_reason_path(id: reason.id), class: "button is-outlined", 'aria-label': "Edit close reason #{reason.name}" %> <% end %> @@ -46,5 +46,5 @@ Do not add reasons where you're not sure that your community needs or wants them.

    It's not possible to remove reasons, once created. They can only be deactivated.

    - <%= link_to "add reason", new_close_reason_path(global: params[:global]), class: "button is-outlined" %> + <%= link_to "add reason", new_close_reason_path(global: params[:global]), class: "button is-outlined", 'aria-label': 'Add new close reason' %> diff --git a/app/views/comments/_comment.html.erb b/app/views/comments/_comment.html.erb index 7dc7caad9..4d8e4f2c2 100644 --- a/app/views/comments/_comment.html.erb +++ b/app/views/comments/_comment.html.erb @@ -45,27 +45,27 @@ <% end %> diff --git a/app/views/comments/_new_thread_modal.html.erb b/app/views/comments/_new_thread_modal.html.erb index 5ba122f28..8ea24575f 100644 --- a/app/views/comments/_new_thread_modal.html.erb +++ b/app/views/comments/_new_thread_modal.html.erb @@ -20,11 +20,7 @@
    Start the thread with a comment.
    <%= text_area_tag :body, '', class: 'form-element js-comment-field', required: true, data: { post: post.id, thread: '-1', character_count: ".js-character-count-#{post.id}" } %> - - - 0 / 1000 - + <%= render 'shared/char_count', type: post.id, min: 15, max: 1000 %> <%= label_tag :title, 'Comment thread title (optional)', class: 'form-element' %> @@ -32,12 +28,7 @@ be shown. <%= text_field_tag :title, '', class: 'form-element', data: { character_count: ".js-character-count-thread-title" } %> - - - - 0 / 255 - + <%= render 'shared/char_count', type: 'thread-title' %> <%= submit_tag 'Create thread', class: 'button is-filled', id: "create_thread_button_#{post.id}", disabled: true %> <% end %> diff --git a/app/views/comments/_post.html.erb b/app/views/comments/_post.html.erb index 4949f5012..4d7c00fcf 100644 --- a/app/views/comments/_post.html.erb +++ b/app/views/comments/_post.html.erb @@ -3,14 +3,14 @@ %> <% comment_threads.each do |thread| %> -
    +
    <% if thread.deleted %> <% elsif thread.archived %> <% end %> - <%= link_to thread.title, comment_thread_path(thread), class: 'js--comment-link', data: { thread: thread.id } %> + <%= link_to 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/comments/thread.html.erb b/app/views/comments/thread.html.erb index dda35581c..98aa32ebd 100644 --- a/app/views/comments/thread.html.erb +++ b/app/views/comments/thread.html.erb @@ -24,9 +24,9 @@
    -
    + data-comments="<%= @comment_thread.reply_count %>" data-post="<%= @post.id %>">
    <% if params[:inline] == 'true' %> @@ -37,18 +37,23 @@ <% else %> <% if current_user&.privilege? 'flag_curate' %> - tools + + tools + <% end %> <% unless current_user.nil? %> <% if @comment_thread.followed_by? current_user %> - unfollow + data-thread="<%= @comment_thread.id %>" title="You are following this thread and will be notified of every response. You can unfollow at any time." + role="button" aria-label="Unfollow this thread"> + unfollow <% else %> - follow + data-thread="<%= @comment_thread.id %>" title="Follow this thread to be notified of every response." + role="button" aria-label="Follow this thread"> + follow <% end %> <% end %> @@ -71,26 +76,28 @@ <% 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 %> -
    +
    + <% 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 %> -
    - <%= 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 %> -
    \ No newline at end of file +
    diff --git a/app/views/flags/_handled.html.erb b/app/views/flags/_handled.html.erb index 892213226..e9e65c75c 100644 --- a/app/views/flags/_handled.html.erb +++ b/app/views/flags/_handled.html.erb @@ -9,7 +9,7 @@

    <%= flag.post_flag_type&.name || 'Flag reason' %>: <%= flag.reason %> — - <%= user_link flag.user %> + <%= user_link flag.user %> at <%= flag.created_at.iso8601 %> (<%= link_to 'history', flag_history_path(flag.user) %>)

    @@ -20,12 +20,13 @@ <% if flag.message.present? %> response: <%= flag.message %> <% end %> - <% if flag.handled_by_id.present? %> + <% if flag.handled_by_id.present? && current_user.is_moderator %> — <%= link_to user_path(flag.handled_by) do %> <%= rtl_safe_username(flag.handled_by) %> - <% end %> + <% end %> + handled at <%= flag.handled_at.iso8601 %> <% end %>

    -
    \ No newline at end of file +
    diff --git a/app/views/flags/handled.html.erb b/app/views/flags/handled.html.erb index 320ac30dd..9e0c3d0ed 100644 --- a/app/views/flags/handled.html.erb +++ b/app/views/flags/handled.html.erb @@ -13,4 +13,6 @@ <% @flags.each do |flag| %> <%= render 'handled', flag: flag %> -<% end %> \ No newline at end of file +<% end %> + +<%= will_paginate @flags, renderer: BootstrapPagination::Rails %> diff --git a/app/views/flags/queue.html.erb b/app/views/flags/queue.html.erb index 9939b4d7d..3cee19591 100644 --- a/app/views/flags/queue.html.erb +++ b/app/views/flags/queue.html.erb @@ -6,6 +6,7 @@

    Moderator Flag Queue

    Below is a list of posts that users have flagged. Users are asked to provide a reason when flagging posts for moderator attention; use that to help you determine what needs to be done.

    +

    You can mark a flag helpful even if you take no action. If a post was edited after the flag was raised, for example, the problem might already be fixed.

    <%= link_to 'Active', flag_queue_path, diff --git a/app/views/layouts/_footer.html.erb b/app/views/layouts/_footer.html.erb index b00c2c03f..909be8b49 100644 --- a/app/views/layouts/_footer.html.erb +++ b/app/views/layouts/_footer.html.erb @@ -2,28 +2,23 @@ diff --git a/app/views/layouts/_head.html.erb b/app/views/layouts/_head.html.erb index c9bf20abd..8a88d427a 100644 --- a/app/views/layouts/_head.html.erb +++ b/app/views/layouts/_head.html.erb @@ -19,7 +19,7 @@ <%= stylesheet_link_tag "https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.11.2/css/all.min.css" %> -<%= stylesheet_link_tag "https://cdn.jsdelivr.net/npm/@codidact/co-design@0.12.5/dist/codidact.css" %> +<%= stylesheet_link_tag "https://cdn.jsdelivr.net/npm/@codidact/co-design@latest/dist/codidact.css" %> <%= stylesheet_link_tag "https://cdn.jsdelivr.net/npm/select2@4.0.12/dist/css/select2.min.css" %> <%= stylesheet_link_tag "/assets/community/#{@community.host.split('.')[0]}.css" %> <%= stylesheet_link_tag 'application', media: 'all' %> @@ -27,13 +27,13 @@ <%= javascript_include_tag "https://cdn.jsdelivr.net/npm/jquery@2.2.2/dist/jquery.min.js" %> <%= javascript_include_tag "https://cdn.jsdelivr.net/npm/moment@2.13.0/min/moment.min.js" %> <%= javascript_include_tag "https://cdn.jsdelivr.net/npm/select2@4.0.12/dist/js/select2.min.js" %> -<% if SiteSetting['DonationsEnabled'] %> +<% if SiteSetting['DonationsEnabled'] && (SiteSetting['LoadStripeEverywhere'] || controller_name == 'donations') %> <%= javascript_include_tag "https://js.stripe.com/v3/" %> <% end %> <%= javascript_include_tag "https://cdn.jsdelivr.net/npm/dompurify@2.2.9/dist/purify.min.js" %> <%= javascript_include_tag "/assets/community/#{@community.host.split('.')[0]}.js" %> <%= javascript_include_tag 'application' %> - + <% if SiteSetting['SyntaxHighlightingEnabled'] %> @@ -49,7 +49,7 @@ } }; - + <% end %> diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 0de78a20d..bf3f6c612 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -2,6 +2,23 @@ <% mobile_logo_path = SiteSetting['MobileLogoPath'] %> <% sticky_header = user_preference('sticky_header', community: false) == 'true' %> + +
    <%= f.submit 'Save', class: 'button is-filled' %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/licenses/_license.html.erb b/app/views/licenses/_license.html.erb index 616015f80..57544538f 100644 --- a/app/views/licenses/_license.html.erb +++ b/app/views/licenses/_license.html.erb @@ -15,7 +15,7 @@

    <%= link_to 'Edit', edit_license_path(license), class: 'button is-outlined' %> <%= link_to license.enabled? ? 'Disable' : 'Enable', toggle_license_path(license), class: 'button is-outlined', - method: :post %> + method: :post, role: 'button' %>

    \ No newline at end of file diff --git a/app/views/micro_auth/apps/index.html.erb b/app/views/micro_auth/apps/index.html.erb index 8f9984adc..5e19d56f3 100644 --- a/app/views/micro_auth/apps/index.html.erb +++ b/app/views/micro_auth/apps/index.html.erb @@ -3,7 +3,7 @@

    Your OAuth Apps

    - <%= link_to new_oauth_app_path, class: 'button is-filled' do %> + <%= link_to new_oauth_app_path, class: 'button is-filled', 'aria-label': 'Create new OAuth App' do %> Create <% end %> @@ -11,9 +11,9 @@
    <% classes = 'button is-outlined is-muted' %> <%= link_to 'yours', oauth_apps_path(request.query_parameters.except(:admin)), - class: "#{classes} #{params[:admin].nil? ? 'is-active' : ''}" %> + class: "#{classes} #{params[:admin].nil? ? 'is-active' : ''}", 'aria-label': 'View your OAuth Apps' %> <%= link_to 'admin', oauth_apps_path(request.query_parameters.merge(admin: true)), - class: "#{classes} #{params[:admin].present? ? 'is-active' : ''}" %> + class: "#{classes} #{params[:admin].present? ? 'is-active' : ''}", 'aria-label': 'View Admin OAuth Apps' %>
    <% end %>
    @@ -29,7 +29,9 @@ <%= text_field_tag :search, params[:search], class: 'form-element' %>
    - <%= submit_tag 'Search', class: 'button' %> + <%= button_tag type: :submit, class: 'button is-medium is-filled is-outlined', name: nil do %> + + <% end %>
    <% end %> diff --git a/app/views/micro_auth/authentication/initiate.html.erb b/app/views/micro_auth/authentication/initiate.html.erb index ff4f9222c..bc1e9bebb 100644 --- a/app/views/micro_auth/authentication/initiate.html.erb +++ b/app/views/micro_auth/authentication/initiate.html.erb @@ -24,5 +24,5 @@

    You can choose to allow or deny this request.

    -<%= link_to 'Allow', approve_oauth_path(request.query_parameters), method: :post, class: 'button is-green is-filled' %> -<%= link_to 'Deny', reject_oauth_path, class: 'button is-danger is-outlined' %> +<%= link_to 'Allow', approve_oauth_path(request.query_parameters), method: :post, class: 'button is-green is-filled', role: 'button' %> +<%= link_to 'Deny', reject_oauth_path, class: 'button is-danger is-outlined', role: 'button' %> diff --git a/app/views/mod_warning/current.html.erb b/app/views/mod_warning/current.html.erb index c98f4bf93..f5f7eb3e9 100644 --- a/app/views/mod_warning/current.html.erb +++ b/app/views/mod_warning/current.html.erb @@ -10,7 +10,11 @@ <%= raw(sanitize(@warning.body_as_html, scrubber: scrubber)) %>

    Your account has been temporarily suspended (ends in <%= time_ago_in_words(current_user.community_user.suspension_end) %>). We look forward to your return and continued contributions to the site after this period. In the event of continued rule violations after this period, your account may be suspended for longer periods. If you have any questions about this suspension or would like to dispute it, contact us.

    - <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> + <% if devise_sign_in_enabled? %> + <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined', role: 'button' %> + <% else %> + <%= link_to 'Sign Out', destroy_saml_user_session_path, method: :delete, class: 'button is-danger is-outlined', role: 'button' %> + <% end %> <% else %>
    @@ -27,7 +31,11 @@ <%= submit_tag 'Continue', class: 'button is-filled' %> - <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> + <% if devise_sign_in_enabled? %> + <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined', role: 'button' %> + <% else %> + <%= link_to 'Sign Out', destroy_saml_user_session_path, method: :delete, class: 'button is-danger is-outlined', role: 'button' %> + <% end %> <% end %>
    <% end %> @@ -46,7 +54,11 @@ <%= submit_tag 'Continue', class: 'button is-filled' %> - <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> + <% if devise_sign_in_enabled? %> + <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined', role: 'button' %> + <% else %> + <%= link_to 'Sign Out', destroy_saml_user_session_path, method: :delete, class: 'button is-danger is-outlined', role: 'button' %> + <% end %> <% end %> <% end %> \ No newline at end of file diff --git a/app/views/notifications/index.html.erb b/app/views/notifications/index.html.erb index 0672c4558..33c2be2fb 100644 --- a/app/views/notifications/index.html.erb +++ b/app/views/notifications/index.html.erb @@ -1,3 +1,5 @@ +<%= render 'users/tabs', user: current_user %> +

    Your Inbox

    You'll find all your inbox messages here, as far back as your account goes.

    diff --git a/app/views/pinned_links/_form.html.erb b/app/views/pinned_links/_form.html.erb index a69f010bb..80a3191e6 100644 --- a/app/views/pinned_links/_form.html.erb +++ b/app/views/pinned_links/_form.html.erb @@ -48,5 +48,5 @@ <%= f.submit "Update", class: "button is-filled" %> - <%= link_to "Cancel", pinned_links_path(global: params[:global]), class: "button" %> + <%= link_to "Cancel", pinned_links_path(global: params[:global]), class: "button", role: 'button' %> <% end %> \ No newline at end of file diff --git a/app/views/post_history/post.html.erb b/app/views/post_history/post.html.erb index 5e2b284d6..c31b586ea 100644 --- a/app/views/post_history/post.html.erb +++ b/app/views/post_history/post.html.erb @@ -1,11 +1,15 @@ +<% @show_content = !!defined?(show_content) ? show_content : true %> +

    Post History

    +<% if @show_content %>
    <%= render 'posts/type_agnostic', post: @post, show_category_tag: true, show_type_tag: true, last_activity: false %>
    +<% end %> <% @history.each.with_index do |event, index| %> -
    +
    #<%= @history.size - index %>: <%= event.post_history_type.name.humanize %> @@ -24,15 +28,45 @@
    <%= event.comment %> <% end %>
    + <%= render 'shared/copy_link', classes: ["button", "is-small", "is-muted", "is-outlined"], + desc: "Copy a permanent link to revision #{@history.size - index}", + id: "#{@post.id}-#{@history.size - index}", + md: post_history_share_link_md(@post, @history, index), + raw: post_history_share_link(@post, @history, index) + %>
    - <% if (event.before_title.present? || event.after_title.present?) && event.before_title != event.after_title %> - <%= render 'diff', before: event.before_title, after: event.after_title, post: @post %> - <% end %> - <% if (event.before_state.present? || event.after_state.present?) && event.before_state != event.after_state %> - <%= render 'diff', before: event.before_state, after: event.after_state, post: @post %> - <% end %> - <% if (event.before_tags.present? || event.after_tags.present?) && event.before_tags != event.after_tags %> - <%= render 'diff', before: event.before_tags, after: event.after_tags, post: @post %> + <% if event.allowed_to_see_details?(current_user) %> + <% if event.hidden? %> +
    +

    Hidden revision

    +

    + This revision is hidden because of a redaction. You have access to the details because + <% if current_user == event.user %> + you performed the redaction, + <% elsif current_user == @post.user %> + you are the post author, + <% elsif current_user&.is_admin %> + you are an administrator, + <% end %> + but you should not share this revision with others. +

    +
    + <% end %> + <% if (event.before_title.present? || event.after_title.present?) && event.before_title != event.after_title %> + <%= render 'diff', before: event.before_title, after: event.after_title, post: @post %> + <% end %> + <% if (event.before_state.present? || event.after_state.present?) && event.before_state != event.after_state %> + <%= render 'diff', before: event.before_state, after: event.after_state, post: @post %> + <% end %> + <% if (event.before_tags.present? || event.after_tags.present?) && event.before_tags != event.after_tags %> + <%= render 'diff', before: event.before_tags, after: event.after_tags, post: @post %> + <% end %> + <% elsif [event.before_title, event.after_title, + event.before_state, event.after_state, + event.before_tags, event.after_tags].any?(&:present?) %> +

    + The detailed changes of this event are hidden because of a redaction. +

    <% end %>
    <% end %> diff --git a/app/views/posts/_article_list.html.erb b/app/views/posts/_article_list.html.erb index d8deeaf56..fb48d014d 100644 --- a/app/views/posts/_article_list.html.erb +++ b/app/views/posts/_article_list.html.erb @@ -3,7 +3,7 @@ <% @show_category_tag = !!defined?(show_category_tag) ? show_category_tag : false %> <% @last_activity = !!defined?(last_activity) ? last_activity : true %>
    -
    +
    <%= (post.score * 100).to_i %>%
    @@ -17,7 +17,7 @@
    <% if @show_category_tag %> - <%= defined?(@category) ? @category.name : post.category.name %> + <%= post.category.name %> <% end %> <%= link_to post.title, generic_share_link(post), 'data-ckb-item-link' => '' %>
    diff --git a/app/views/posts/_edit_comment.html.erb b/app/views/posts/_edit_comment.html.erb new file mode 100644 index 000000000..80a952ccd --- /dev/null +++ b/app/views/posts/_edit_comment.html.erb @@ -0,0 +1,39 @@ +<%# + Edit comment reusable partial. + Variables: + comment : [String, Nil] optional, initial value of the field (default '') + cur_length : [Integer, Nil] optional, current character length (default 0) + min_length : [Integer, Nil] optional, the minimum allowed length (default 0) + max_length : [Integer, Nil] optional, the maximum allowed length (default 255) +%> + +<% + # Defaults + comment = (defined?(comment) ? comment : nil) || '' + cur_length = (defined?(cur_length) ? cur_length : nil) || 0 + min_length = (defined?(min_length) ? min_length : nil) || 0 + max_length = (defined?(max_length) ? max_length : nil) || 255 +%> + +
    + <%= label_tag :edit_comment, t('posts.edit_comment_label'), class: 'form-element' %> + <%= text_field_tag :edit_comment, + nil, + class: 'form-element', + value: comment, + data: { character_count: ".js-character-count-edit-comment" } %> +
    + <%= render 'shared/char_count', type: 'edit-comment', cur: cur_length, min: min_length, max: max_length %> +
    +
    +
    +
    +
    + <%= label_tag :redact, t('posts.redact_label'), class: 'form-element' %> + <%= t('posts.redact_explanation') %> +
    +
    + <%= check_box_tag :redact, true, false, class: 'form-checkbox-element' %> +
    +
    +
    \ No newline at end of file diff --git a/app/views/posts/_expanded.html.erb b/app/views/posts/_expanded.html.erb index 82733701f..5f28e9d23 100644 --- a/app/views/posts/_expanded.html.erb +++ b/app/views/posts/_expanded.html.erb @@ -18,7 +18,7 @@ <% title = post.title + (post.closed && !post.duplicate_post ? " [closed]" : "") + (post.duplicate_post ? " [duplicate]" : "") %> - <%= title %> + <%= title %> <% if category.display_post_types.reject { |e| e.to_s.empty? }.size > 1 %> <%= post_type_badge(post_type) %> <% end %> @@ -36,33 +36,35 @@ <% end %>
    - <% if post_type.has_votes %> + <% if post_type.has_votes || (user_signed_in? && post.post_type.has_reactions && post.post_type.reactions.any?) %>
    -
    - <% existing_vote = my_vote(post) %> - <% unless post.locked? %> - - <% end %> -
    - +<%= post.upvote_count %> -
    -
    - −<%= post.downvote_count %> + <% if post_type.has_votes %> +
    + <% existing_vote = my_vote(post) %> + <% unless post.locked? %> + + <% end %> +
    + +<%= post.upvote_count %> +
    +
    + −<%= post.downvote_count %> +
    + <% unless post.locked? %> + + <% end %>
    - <% unless post.locked? %> - - <% end %> -
    + <% end %> <% if user_signed_in? && post.post_type.has_reactions && post.post_type.reactions.any? %>
    <% unless post.locked? %> @@ -83,6 +85,15 @@ <%= render('reactions/list', post: post) if post.reactions %> <% if post_type.is_closeable && post.closed %> + <% if current_user == post.user %> +
    +

    + + <%= t 'posts.post_closed_guidance' %> +

    +
    + <% end %> +

    Closed @@ -123,7 +134,7 @@

    <% end %> -

    Users with the Curate privilege may vote to undelete this post if it has been deleted incorrectly.

    +

    Users with the Curate privilege may vote to restore this post if it has been deleted incorrectly.

    <% end %> @@ -134,22 +145,42 @@ on <%= post.locked_at.strftime('%b %e, %Y at %H:%M') %>.

    - <% end %> + <% end %> + <% if post.spam_flag_pending? && user_signed_in? %> +
    + <% if post.user == current_user %> +

    Your post has been flagged by members of our community. Please review our guidelines for promotional content.

    + <% else %> +

    Possible spam: this post has pending flags for spam. Be careful when following links.

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

    There is a pending suggested edit on this post. Review changes

    +

    + + There is a pending suggested edit on this post. + Review changes

    - <% elsif post.pending_suggested_edit.user.id == current_user&.id %> + <% elsif post.pending_suggested_edit.user&.id == current_user&.id %>
    -

    Your suggested edit on this post is <%= link_to 'pending review', suggested_edit_url(post.pending_suggested_edit.id) %>.

    +

    + + Your suggested edit on this post is + <%= link_to 'pending review', suggested_edit_url(post.pending_suggested_edit.id) %>.

    <% end %> - <% end %> + <% end %> -
    - <%= raw(sanitize(post.body, scrubber: scrubber)) %> +
    + <% effective_post = raw(sanitize(post.body, scrubber: scrubber)) %> + <% if post.spam_flag_pending? && !user_signed_in? %> + <%= sanitize(effective_post, attributes: %w()) %> + <% else %> + <%= effective_post %> + <% end %> <% been_edited = post.last_edited_by_id != nil %> <% if been_edited then last_edited_by_self = post.user_id == post.last_edited_by_id end %> @@ -200,11 +231,12 @@
    - <%= link_to generic_share_link(post), class: 'tools--item js-permalink' do %> - - Permalink - <% end %> - <%= link_to post_history_path(post), class: 'tools--item' do %> + <%= render "shared/copy_link", classes: ["tools--item"], + desc: "Copy a permanent link to this post", + id: post.id, + md: generic_share_link_md(post), + raw: generic_share_link(post) %> + <%= link_to post_history_path(post), class: 'tools--item', 'aria-label': 'View history of this post' do %> History <% end %> @@ -216,7 +248,7 @@ Review suggested edit <% end %> <% else %> - <%= link_to edit_post_path(post), class: 'tools--item' do %> + <%= link_to edit_post_path(post), class: 'tools--item', 'aria-label': 'Edit this post' do %> Edit <% end %> @@ -224,8 +256,13 @@ <% elsif !current_user.nil? %> <% if post.pending_suggested_edit? %> suggested edit pending... + <% elsif post_type.is_freely_editable %> + <%= link_to edit_post_path(post), class: 'tools--item', 'aria-label': 'Edit this post' do %> + + Edit + <% end %> <% else %> - <%= link_to edit_post_path(post), class: 'tools--item' do %> + <%= link_to edit_post_path(post), class: 'tools--item', 'aria-label': 'Suggest edit to this post' do %> Suggest edit <% end %> @@ -233,7 +270,7 @@ <% end %> <% end %> <% if user_signed_in? %> - + Flag @@ -241,12 +278,12 @@ <% unless post.locked? || !post_type.is_closeable %> <% if check_your_privilege('flag_close') || (post.user_id === current_user&.id)%> <% if !post.closed %> - + Close <% elsif post.closed %> - <%= link_to reopen_post_path(post), method: :post, class: 'reopen-question tools--item' do %> + <%= link_to reopen_post_path(post), method: :post, class: 'reopen-question tools--item', role: 'button', 'aria-label': 'Reopen this post' do %> Reopen <% end %> @@ -257,13 +294,15 @@ <% unless post.locked? %> <% if !post.deleted %> <%= link_to delete_post_path(post), method: :post, - data: { confirm: 'Are you sure you want to delete this post?' }, class: "tools--item is-danger" do %> + data: { confirm: 'Are you sure you want to delete this post?' }, class: "tools--item is-danger", + role: 'button', 'aria-label': 'Delete this post' do %> Delete <% end %> <% else %> <%= link_to restore_post_path(post), method: :post, - data: { confirm: 'Restore this post, making it visible to regular users?' }, class: "tools--item is-danger is-filled" do %> + data: { confirm: 'Restore this post, making it visible to regular users?' }, class: "tools--item is-danger is-filled", + role: 'button', 'aria-label': 'Restore this post' do %> Restore <% end %> @@ -271,7 +310,7 @@ <% end %> <% end %> <% if check_your_privilege('flag_curate') %> - + Tools @@ -288,7 +327,7 @@ end %> <% if flags_count > 0 %> - + Show <%= pluralize(flags_count - own_flags_count, 'flag') %> @@ -299,9 +338,26 @@
    -
    Why does this post require moderator attention?
    +
    Why does this post require attention from curators or moderators?
    + <% if current_user&.has_active_flags?(post) %> +
    +
    + You already have active flags on this post: +
      + <% current_user.active_flags(post).each do |flag| %> +
    • + <%= flag.post_flag_type&.name || 'other' %> + <% unless flag.reason.nil? %> + <%= flag.reason %> + <% end %> +
    • + <% end %> +
    +
    +
    + <% end %> <% unless post.locked? %> - <% PostFlagType.where(post_type_id: post.post_type.id).or(PostFlagType.where(post_type_id: nil)).each do |reason| %> + <% PostFlagType.where(post_type_id: [post.post_type.id, nil]).where(active: 1).each do |reason| %>
    @@ -330,22 +386,38 @@
    <% end %> <% end %> -
    -
    -
    - -
    -
    - -
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    @@ -424,7 +496,7 @@
    <% end %> - <% if is_top_level && post.children.length >= SiteSetting["TableOfContentsThreshold"] && SiteSetting["TableOfContentsThreshold"] != -1 %> + <% if is_top_level && post.children.undeleted.length >= SiteSetting["TableOfContentsThreshold"] && SiteSetting["TableOfContentsThreshold"] != -1 %>
    <% sorted_answers = post.children.sort_by { |answer| answer.score }.reverse! %> @@ -452,30 +524,36 @@ <% public_count = comment_threads.count %> <% available_count = current_user&.has_post_privilege?('flag_curate', post) ? post.comment_threads.count : post.comment_threads.publicly_available.count %> +

    <%= pluralize(public_count, 'comment thread') %>

    <% if user_signed_in? %> -

    - <% if CommentThread.post_followed?(post, current_user) %> - <%= link_to follow_post_comments_path(post_id: post.id), method: :post, - title: 'Don\'t follow new comment threads on this post' do %> - unfollow new - <% end %> - <% else %> - <%= link_to follow_post_comments_path(post_id: post.id), method: :post, - title: 'Follow all new comment threads on this post' do %> - follow new - <% end %> + <% if CommentThread.post_followed?(post, current_user) %> + <%= link_to follow_post_comments_path(post_id: post.id), method: :post, + class: "button is-muted is-outlined is-small", + title: 'Don\'t follow new comment threads on this post', + role: 'button', + 'aria-label': 'Unfollow new comment threads on this post' do %> + Unfollow new <% end %> -

    + <% else %> + <%= link_to follow_post_comments_path(post_id: post.id), method: :post, + class: "button is-muted is-outlined is-small", + title: 'Follow all new comment threads on this post', + role: 'button', + 'aria-label': 'Follow all new comment threads on this post' do %> + Follow new + <% end %> + <% end %> <% end %> -
    +
    +
    <%= render 'comments/post', comment_threads: comment_threads.first(5) %>
    <% if available_count > [comment_threads.count, 5].min %> - + Show more <% end %> @@ -492,8 +570,10 @@ <% elsif post.comments_disabled %>

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

    <% end %> - Start new comment thread <% end %> + + Start new comment thread + <%= render 'comments/new_thread_modal', post: post %> <% end %>
    diff --git a/app/views/posts/_form.html.erb b/app/views/posts/_form.html.erb index 97c7a2d9d..6002284bd 100644 --- a/app/views/posts/_form.html.erb +++ b/app/views/posts/_form.html.erb @@ -54,21 +54,46 @@

    <% end %> - <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: t('posts.body_label'), post: post %> + <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: t('posts.body_label'), post: post, + cur_length: post.body_markdown&.length, min_length: min_body_length(category), max_length: max_body_length(category) %> +
    +

    Unsupported HTML detected

    +

    The following HTML tags and attributes are unsupported and will be removed from the final post:

    +
      +
    +

    For a list of allowed HTML, see this help article. + If you meant to display the tags as code in the post, please enclose them in a code block.

    +
    <% unless post_type.has_parent? %> + <% value = {} %> + <% key = "saved_post.#{current_user&.id}.#{request.path}.title" %> + <% saved_at_key = "saved_post_at.#{current_user&.id}.#{request.path}" %> + <% saved_at = DateTime.parse(RequestContext.redis.get(saved_at_key) || '') rescue Date.new(2000, 1, 1) %> + <% + # Find the most recent between post-create, post-update, and draft-saved, and use the value corresponding to that. + value = [ + [post.created_at || Date.new(2000, 1, 1), {}], + [post.updated_at || Date.new(2000, 1, 1), {}], + [saved_at || Date.new(2001, 1, 1), { value: RequestContext.redis.get(key) }] + ].max_by do |x| + x[0] + end[1] + %>
    <%= f.label :title, t('posts.post_title_label'), class: 'form-element' %> - <%= f.text_field :title, class: 'form-element post_title', data: { character_count: ".js-character-count-post-title" } %> -
    -
    - - - 0 / 255 - + <%= f.text_field :title, + **({ class: 'form-element post_title' }).merge(value), + data: { character_count: ".js-character-count-post-title" } + %> +
    + <%= render 'shared/char_count', type: 'post-title', + cur: value[:value]&.length || post.title&.length, + max: max_title_length(category), + min: min_title_length(category, post_type) %> +
    <% end %> @@ -87,8 +112,26 @@ <% end %> <% end %> - <%= f.select :tags_cache, options_for_select(post.tags_cache.map { |t| [t, t] }, selected: post.tags_cache), - { include_blank: true }, multiple: true, class: "form-element js-tag-select", + <% tags = post.tags_cache %> + <% key = "saved_post.#{current_user&.id}.#{request.path}.tags" %> + <% saved_at_key = "saved_post_at.#{current_user&.id}.#{request.path}" %> + <% saved_at = DateTime.parse(RequestContext.redis.get(saved_at_key) || '') rescue Date.new(2000, 1, 1) %> + <% + # Find the most recent between post-create, post-update, and draft-saved, and use the value corresponding to that. + tags = [ + [post.created_at || Date.new(2000, 1, 1), post.tags_cache], + [post.updated_at || Date.new(2000, 1, 1), post.tags_cache], + [saved_at || Date.new(2001, 1, 1), RequestContext.redis.smembers(key)] + ].max_by do |x| + x[0] + end[1] + %> + <%= f.select :tags_cache, options_for_select( + (tags || post.tags_cache).map { |t| [t, t] }, + selected: tags || post.tags_cache), + {}, + multiple: true, + class: "form-element js-tag-select", data: { tag_set: category.tag_set_id } %>
    <% end %> @@ -101,7 +144,7 @@ <% category_default = category.license %> <% user_default = user_preference('default_license', community: true) %> <% if site_default.present? %> - site default: + community default: <%= site_default.name %> <% end %> @@ -122,9 +165,23 @@ <% end %> + <% license = post.license_id %> + <% key = "saved_post.#{current_user&.id}.#{request.path}.license" %> + <% saved_at_key = "saved_post_at.#{current_user&.id}.#{request.path}" %> + <% saved_at = DateTime.parse(RequestContext.redis.get(saved_at_key) || '') rescue Date.new(2000, 1, 1) %> + <% + # Find the most recent between post-create, post-update, and draft-saved, and use the value corresponding to that. + license = [ + [post.created_at || Date.new(2000, 1, 1), post.license_id], + [post.updated_at || Date.new(2000, 1, 1), post.license_id], + [saved_at || Date.new(2001, 1, 1), RequestContext.redis.get(key)] + ].max_by do |x| + x[0] + end[1] + %> <%= f.select :license_id, options_for_select(License.enabled.default_order(category, user_default) .map { |l| [l.name, l.id, { 'data-title': l.description }] }, - selected: post.license_id), + selected: license || post.license_id), { include_blank: user_default == 'No default (make me choose)' }, class: 'form-element js-license-select' %>
    @@ -171,11 +228,25 @@ <% end %> <% if edit_comment %> + <% comment = '' %> + <% key = "saved_post.#{current_user&.id}.#{request.path}.comment" %> + <% saved_at_key = "saved_post_at.#{current_user&.id}.#{request.path}" %> + <% saved_at = DateTime.parse(RequestContext.redis.get(saved_at_key) || '') rescue Date.new(2000, 1, 1) %> + <% + # Find the most recent between post-create, post-update, and draft-saved, and use the value corresponding to that. + comment = [ + [post.created_at || Date.new(2000, 1, 1), ''], + [post.updated_at || Date.new(2000, 1, 1), ''], + [saved_at || Date.new(2001, 1, 1), RequestContext.redis.get(key)] + ].max_by do |x| + x[0] + end[1] + %>
    -
    - <%= label_tag :edit_comment, t('posts.edit_comment_label'), class: 'form-element' %> - <%= text_field_tag :edit_comment, nil, class: 'form-element' %> -
    + <%= render 'edit_comment', comment: comment, + cur_length: comment&.length || 0, + max_length: max_edit_comment_length + %> <% end %>
    diff --git a/app/views/posts/_list.html.erb b/app/views/posts/_list.html.erb index 9caf81526..456e75360 100644 --- a/app/views/posts/_list.html.erb +++ b/app/views/posts/_list.html.erb @@ -8,7 +8,7 @@
    <% if post.post_type.has_votes %> -
    +
    <%= (post.score * 100).to_i %>%
    @@ -28,7 +28,7 @@
    <%= link_to generic_share_link(post), 'data-ckb-item-link' => '' do %> <% if @show_category_tag && post.post_type.has_category %> - <%= defined?(@category) ? @category.name : post.category.name %> + <%= post.category.name %> <% end %> <%= post.post_type.is_top_level ? post.title : post.parent.title %> <%= post.post_type.is_closeable && post.closed && !post.duplicate_post ? "[closed]" : "" %> @@ -37,7 +37,7 @@
    <% if (SiteSetting['PostBodyListTruncateLength'] || 0) > 0 %>

    - <%= strip_tags(post.body).truncate(SiteSetting['PostBodyListTruncateLength'] || 200) %> + <%= sanitize(strip_tags(post.body).truncate(SiteSetting['PostBodyListTruncateLength'] || 200), scrubber: scrubber) %>

    <% end %>

    diff --git a/app/views/posts/_mdhint.html.erb b/app/views/posts/_mdhint.html.erb index 5f9536ca7..27c264ddd 100644 --- a/app/views/posts/_mdhint.html.erb +++ b/app/views/posts/_mdhint.html.erb @@ -1,9 +1,25 @@ -

    - We support Markdown for posts: - **bold**, *italics*, `code`, two newlines for paragraphs - - - 0 / 30000 - +<%# + Widget displaying that we support markdown, linking to the help article. This also applies the min/max length for the + post body. + + Variables: + cur_length : [Integer, Nil] optional, current character length (default 0) + min_length : [Integer, Nil] optional, the minimum allowed length (default 30) + max_length : [Integer, Nil] optional, the maximum allowed length (default 30_000) +%> + +<% + # Defaults + cur_length = (defined?(cur_length) ? cur_length : nil) || 0 + min_length = (defined?(min_length) ? min_length : nil) || 30 + max_length = (defined?(max_length) ? max_length : nil) || 30_000 +%> + +
    + Markdown support for posts: + **bold**, *italics*, `code`, 2 newlines for paragraphs + draft saved +
    + <%= render 'shared/char_count', type: 'post-body', cur: cur_length, min: min_length, max: max_length %> +
    diff --git a/app/views/posts/document.html.erb b/app/views/posts/document.html.erb index a5cb84a16..e08d748ab 100644 --- a/app/views/posts/document.html.erb +++ b/app/views/posts/document.html.erb @@ -2,9 +2,15 @@ « Back to help center <% end %> <% unless @post.nil? %> - <% if (moderator? && @post.post_type_id == HelpDoc.post_type_id) || (admin? && @post.post_type_id == PolicyDoc.post_type_id) %> + <% + is_hc = @post.post_type_id == HelpDoc.post_type_id + 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) %> <%= 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" %> <% end %> <% if @post.help_category == '$Moderator' %> diff --git a/app/views/posts/help_center.html.erb b/app/views/posts/help_center.html.erb index 49fa62614..35dce5d6c 100644 --- a/app/views/posts/help_center.html.erb +++ b/app/views/posts/help_center.html.erb @@ -1,7 +1,7 @@ <% content_for :title, 'Help Center' %>

    Help Center

    - +

    Community moderators can edit, reorganize, and create topics in the Help section. Topics in the Policy section are managed by the network administrators.

    Help

    diff --git a/app/views/posts/new.html.erb b/app/views/posts/new.html.erb index 94baae739..b81569e90 100644 --- a/app/views/posts/new.html.erb +++ b/app/views/posts/new.html.erb @@ -34,4 +34,6 @@
    You don't have a high enough trust level to post in the <%= @category.name %> category.
    +

    Not where you meant to post? See <%= link_to 'Categories', categories_path %>

    + <% end %> diff --git a/app/views/posts/show.html.erb b/app/views/posts/show.html.erb index c25a17c03..5846533fd 100644 --- a/app/views/posts/show.html.erb +++ b/app/views/posts/show.html.erb @@ -1,4 +1,4 @@ -<% content_for :title, @post.title.truncate(50) %> +<% content_for :title, @post.title.truncate(max_title_length(@post.category)) %> <% content_for :description do %> <% Rails.cache.fetch "posts/#{@post.id}/description" do %> <%= @post.body_plain[0..74].strip %>... @@ -23,8 +23,15 @@

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

    - Score - Active + <%= 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)' %>
    diff --git a/app/views/questions/_list.html.erb b/app/views/questions/_list.html.erb index 7e387ee6d..046ce39bc 100644 --- a/app/views/questions/_list.html.erb +++ b/app/views/questions/_list.html.erb @@ -1,10 +1,14 @@
    <%= link_to 'Activity', query_url(root_url, sort: 'activity'), class: "button is-muted is-outlined #{(params[:sort].nil?) && !current_page?(questions_lottery_path) || params[:sort] == 'activity' ? - 'is-active' : ''}" %> - <%= link_to 'Age', query_url(root_url, sort: 'age'), class: "button is-muted is-outlined #{params[:sort] == 'age' ? 'is-active' : ''}" %> - <%= link_to 'Score', query_url(root_url, sort: 'score'), class: "button is-muted is-outlined #{params[:sort] == 'score' ? 'is-active' : ''}" %> - <%= link_to 'Random', questions_lottery_path, class: "button is-muted is-outlined #{current_page?(questions_lottery_path) ? 'is-active' : ''}" %> + 'is-active' : ''}", + role: 'button', 'aria-label': 'Sort by activity' %> + <%= link_to 'Age', query_url(root_url, sort: 'age'), class: "button is-muted is-outlined #{params[:sort] == 'age' ? 'is-active' : ''}", + role: 'button', 'aria-label': 'Sort by age' %> + <%= link_to 'Score', query_url(root_url, sort: 'score'), class: "button is-muted is-outlined #{params[:sort] == 'score' ? 'is-active' : ''}", + role: 'button', 'aria-label': 'Sort by score' %> + <%= link_to 'Random', questions_lottery_path, class: "button is-muted is-outlined #{current_page?(questions_lottery_path) ? 'is-active' : ''}", + role: 'button', 'aria-label': 'Order randomly' %>
     
    diff --git a/app/views/reactions/_dialog.html.erb b/app/views/reactions/_dialog.html.erb index d6e75d533..64bedbde1 100644 --- a/app/views/reactions/_dialog.html.erb +++ b/app/views/reactions/_dialog.html.erb @@ -37,6 +37,7 @@ <% end %>
    <%= f.submit 'Save', class: 'button is-filled' %> - <%= link_to 'Cancel', reactions_path, class: 'button' %> + <%= link_to 'Cancel', reactions_path, class: 'button', role: 'button' %> <% end %> \ No newline at end of file diff --git a/app/views/reactions/index.html.erb b/app/views/reactions/index.html.erb index b0fd6a4ee..bef08dc66 100644 --- a/app/views/reactions/index.html.erb +++ b/app/views/reactions/index.html.erb @@ -37,7 +37,7 @@
    - <%= link_to "edit", edit_reaction_path(rt.id), class: "button is-outlined" %> + <%= link_to "edit", edit_reaction_path(rt.id), class: "button is-outlined", 'aria-label': "Edit #{rt.name}" %>
    diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb new file mode 100644 index 000000000..a282f1303 --- /dev/null +++ b/app/views/search/_filters.html.erb @@ -0,0 +1,78 @@ +<% allow_delete ||= false %> +<% allow_apply = true if allow_apply.nil? %> +<% @active_filter ||= {} %> + +
    +
    +
    + <%= label_tag :predefined_filter, 'Predefined Filters', class: "form-element" %> + <%= select_tag :predefined_filter, options_for_select([@active_filter[:name]], + selected: @active_filter[:name]), + include_blank: true, class: "form-element js-filter-select", id: nil, + data: { placeholder: "" } %> +
    + + <% if allow_apply %> + <%= submit_tag 'Apply', class: 'button is-medium is-outlined', name: nil %> + <% end %> + <% if user_signed_in? %> + + <% end %> + <% if allow_delete %> + + <% end %> +
    + <% if user_signed_in? && defined? @category %> + <%= label_tag :save_as_default, 'Is default for this category?' %> + <%= check_box_tag :save_as_default, @category.id, false, { class: 'filter-is-default form-checkbox-element' } %> + <% end %> +
    +
    + <%= label_tag :min_score, 'Min Score (0-1)', class: "form-element" %> + <%= number_field_tag :min_score, @active_filter[:min_score], + min: 0, max: 1, step: 0.01, class: 'form-element form--filter', + data: { name: 'min_score' } %> +
    +
    + <%= label_tag :max_score, 'Max Score (0-1)', class: "form-element" %> + <%= number_field_tag :max_score, @active_filter[:max_score], + min: 0, max: 1, step: 0.01, class: 'form-element form--filter', + data: { name: 'max_score' } %> +
    +
    + <%= label_tag :min_answers, 'Min Answers', class: "form-element" %> + <%= number_field_tag :min_answers, @active_filter[:min_answers], + min: 0, step: 1, class: 'form-element form--filter', + data: { name: 'min_answers' } %> +
    +
    + <%= label_tag :max_answers, 'Max Answers', class: "form-element" %> + <%= number_field_tag :max_answers, @active_filter[:max_answers], + min: 0, step: 1, class: 'form-element form--filter', + data: { name: 'max_answers' } %> +
    +
    + <%= label_tag :status, 'Status', class: "form-element" %> + <%= select_tag :status, options_for_select(['any', 'open', 'closed'], selected: @active_filter[:status] || 'any'), + min: 0, step: 1, class: 'form-element form--filter', + data: { name: 'status' } %> +
    +
    + +
    +
    + <%= label_tag :include_tags, 'Include Tags', class: "form-element" %> + <%= select_tag :include_tags, options_for_select(Tag.where(id: @active_filter[:include_tags]).map { |tag| [tag.name, tag.id] }, + selected: @active_filter[:include_tags] || []), multiple: true, class: 'form-element form--filter js-tag-select', + data: { name: 'include_tags', tag_set: @category&.tag_set_id, create: 'false', use_ids: 'true', placeholder: '' } %> +
    +
    + <%= label_tag :exclude_tags, 'Exclude Tags', class: "form-element" %> + <%= select_tag :exclude_tags, options_for_select(Tag.where(id: @active_filter[:exclude_tags]).map { |tag| [tag.name, tag.id] }, + selected: @active_filter[:exclude_tags]|| []), multiple: true, class: 'form-element form--filter js-tag-select', + data: { name: 'exclude_tags', tag_set: @category&.tag_set_id, create: 'false', use_ids: 'true', placeholder: '' } %> +
    +
    +
    diff --git a/app/views/search/_widget.html.erb b/app/views/search/_widget.html.erb new file mode 100644 index 000000000..0c7231a7c --- /dev/null +++ b/app/views/search/_widget.html.erb @@ -0,0 +1,39 @@ +<%= form_tag search_path, method: :get, role: 'search' do %> +
    +
    + <%= label_tag :search, 'Search term', class: "form-element" %> + <%= search_field_tag :search, params[:search], class: 'form-element' %> +
    +
    + <%= submit_tag 'Search', class: 'button is-medium is-outlined', name: nil %> +
    +
    + + <% if @signed_out_me %> +
    +
    +

    + Search results for user:me can only be displayed when signed in. + Please <%= sign_in_link 'sign in' %> and try again. +

    +
    +
    + <% end %> + +
    +
    + Advanced Search Options +
    +
    + <%= raw(sanitize(render_markdown(SiteSetting['JITAdvancedSearchHelp']), scrubber: scrubber)) %> +

    Further help with searching is available <%= link_to 'in the help center', help_path('search') %>.

    +

    Quick hints: tag:tagname, user:xxx, "exact phrase", post_type:xxx, created:<N{d,w,mo,y}, score:>=0.5

    +
    +
    + +
    + Filters + <%= render 'filters' %> +
    + +<% end %> diff --git a/app/views/search/search.html.erb b/app/views/search/search.html.erb index 3e91baa9c..5e4794cdb 100644 --- a/app/views/search/search.html.erb +++ b/app/views/search/search.html.erb @@ -2,35 +2,25 @@

    Search

    -<%= form_tag search_path, method: :get do %> -
    -
    - <%= label_tag :search, 'Search term', class: "form-element" %> - <%= text_field_tag :search, params[:search], class: 'form-element' %> -
    -
    - <%= submit_tag 'Search', class: 'button is-medium is-outlined', name: nil %> -
    -
    -<% end %> - -
    -
    - Advanced Search Options -
    -
    - <%= raw(sanitize(render_markdown(SiteSetting['JITAdvancedSearchHelp']), scrubber: scrubber)) %> -

    Further help with searching is available <%= link_to 'in the help center', help_path('search') %>.

    -
    -
    +<%= render 'widget' %> <% unless @posts.nil? %> -
    - <%= link_to 'Relevance', query_url(sort: 'relevance'), - class: "button is-outlined is-muted #{params[:sort] == 'relevance' || params[:sort].nil? ? 'is-active' : ''}" %> - <%= link_to 'Score', query_url(sort: 'score'), class: "button is-outlined is-muted #{params[:sort] == 'score' ? 'is-active' : ''}" %> - <%= link_to 'Age', query_url(sort: 'age'), class: "button is-outlined is-muted #{params[:sort] == 'age' ? 'is-active' : ''}" %> + <% post_count = @posts.count %> +
    + + <%= short_number_to_human post_count, precision: 1, significant: false %> + <%= 'post'.pluralize(post_count) %> + +
    + <%= link_to 'Relevance', request.params.merge(sort: 'relevance'), class: "button is-outlined is-muted #{params[:sort] == 'relevance' || params[:sort].nil? ? 'is-active' : ''}", + role: 'button', 'aria-label': 'Sort by relevance' %> + <%= link_to 'Score', request.params.merge(sort: 'score'), class: "button is-outlined is-muted #{params[:sort] == 'score' ? 'is-active' : ''}", + role: 'button', 'aria-label': 'Sort by score' %> + <%= link_to 'Age', request.params.merge(sort: 'age'), class: "button is-outlined is-muted #{params[:sort] == 'age' ? 'is-active' : ''}", + role: 'button', 'aria-label': 'Sort by age' %> +
    +
     
    <% if @posts.size == 0 %> diff --git a/app/views/shared/_body_field.html.erb b/app/views/shared/_body_field.html.erb index 8812a6804..2552d739d 100644 --- a/app/views/shared/_body_field.html.erb +++ b/app/views/shared/_body_field.html.erb @@ -1,3 +1,20 @@ +<%# + Adds a markdown body form textarea. + Variables: + post : [ApplicationRecord, Nil] the entity to which this body field belongs (a post, a tag, a user, ...) + field_name : [Symbol] the name of the body field (for the given entity) + field_label : [String] the label for the field + min_length : [Integer, Nil] optional, the minimum allowed length + max_length : [Integer, Nil] optional, the maximum allowed length +%> + +<% + # Defaults + cur_length = defined?(cur_length) ? cur_length : nil + min_length = defined?(min_length) ? min_length : nil + max_length = defined?(max_length) ? max_length : nil +%> +
    <%= f.label field_name, field_label, class: "form-element" %> <% if block_given? %> @@ -22,7 +39,9 @@ <%= render 'shared/markdown_tools' %> <%= f.text_area field_name, **({ class: classes, rows: 15, placeholder: 'Start typing your post...' }).merge(value), data: { character_count: ".js-character-count-post-body" } %> - <%= render 'posts/mdhint' %> + <%= render 'posts/mdhint', cur_length: value[:value]&.length || cur_length, + min_length: min_length, + max_length: max_length %>
    <%= hidden_field_tag "__html", nil, class: 'js-post-html' %>
    diff --git a/app/views/shared/_char_count.html.erb b/app/views/shared/_char_count.html.erb new file mode 100644 index 000000000..985dec3f8 --- /dev/null +++ b/app/views/shared/_char_count.html.erb @@ -0,0 +1,28 @@ +<%# + Reusable helper view for character count requirements. + + Variables: + cur : current number of characters (default 0) + max : maximum number of characters allowed (default 255) + min : minimum number of characters allowed (default 0) + threshold : fraction of max to show the count at (default 0.75) + type : character count type (e.g.: post-title) +%> + +<% + # defaults & normalization + cur ||= defined?(cur) && !cur.nil? ? cur.to_i : 0 + max ||= defined?(max) && !max.nil? ? max.to_i : 255 + min ||= defined?(min) && !min.nil? ? min.to_i : 0 + threshold ||= defined?(threshold) && !threshold.nil? ? threshold.to_f : 0.75 +%> + + + + + <%= cur %> / <%= cur < min ? min : max %> + + \ No newline at end of file diff --git a/app/views/shared/_copy_link.html.erb b/app/views/shared/_copy_link.html.erb new file mode 100644 index 000000000..6932d72ab --- /dev/null +++ b/app/views/shared/_copy_link.html.erb @@ -0,0 +1,66 @@ +<%# + Adds a copy link button and drop panel. + Variables: + classes : a CSS string representing class names for the trigger button + desc : short description of what clicking on the button does + id : id of the entity to link to + md : Markdown-formatted link to copy + raw : raw URL of the link to copy +%> + +<% + # Defaults + classes = defined?(classes) ? classes : [] +%> + + +<%= tag.button 'aria-label': desc, + class: classes + ["js-permalink-trigger"], + data: { + drop: "#permalink-#{id}", + drop_force_dir: "down", + drop_self_class_toggle: "is-active" + }, + hidden: true, + type: "button" do %> + + Copy Link +<% end %> + + \ 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 1bc846dca..daa3ea3d3 100644 --- a/app/views/shared/_markdown_tools.html.erb +++ b/app/views/shared/_markdown_tools.html.erb @@ -32,6 +32,11 @@ <%= md_button action: 'code', label: 'Monospace font', class: 'is-icon-only' do %> <% end %> + <% if SiteSetting['MathJaxEnabled'] %> + <%= md_button action: 'mathjax', label: 'MathJax', class: 'is-icon-only' do %> + $ + <% end %> + <% end %>
    @@ -48,7 +53,7 @@
    + data-drop-self-class-toggle="is-active" aria-label="Tools" title="Tools" role="button"> @@ -75,7 +80,7 @@ diff --git a/app/views/shared/_rss_link.html.erb b/app/views/shared/_rss_link.html.erb new file mode 100644 index 000000000..c3f144f9b --- /dev/null +++ b/app/views/shared/_rss_link.html.erb @@ -0,0 +1,20 @@ +<%# + 'Adds an RSS link. + Variables: + url : [String] URL to the feed + text : [String, Nil] text to visibly show + tooltip : [String, Nil] text to show in the tooltip + ' + %> + +<% + # Defaults + text = defined?(text) ? text : 'RSS' + tooltip = defined?(tooltip) ? tooltip : 'RSS Feed' +%> + + + <%= link_to url, class: 'has-display-inline-block' do %> + RSS + <% end %> + \ No newline at end of file diff --git a/app/views/site_settings/index.html.erb b/app/views/site_settings/index.html.erb index 2d5117582..9562d314c 100644 --- a/app/views/site_settings/index.html.erb +++ b/app/views/site_settings/index.html.erb @@ -17,15 +17,13 @@ <% settings.each do |setting| %> -

    - <%= setting.name %> - <% if setting.community_id.nil? %> - global - <% else %> - site - <% end %> - <%= setting.value_type %> -

    + <% if setting.global? %> + global + <% else %> + site + <% end %> + <%= setting.value_type %> +

    <%= setting.name %>

    <%= setting.description %>
    150 ? '...' : '' %>

    - — <%= link_to question.user.rtl_safe_username, user_url(question.user, host: @subscription.community.host), dir: 'ltr'%> + — <%= user_link question.user, { host: @subscription.community.host } %> <%= time_ago_in_words(question.created_at) %> ago

    <% end %> @@ -29,4 +29,4 @@
    <%= link_to 'Change your email settings or unsubscribe here', - subscriptions_url(host: @subscription.community.host) %> \ No newline at end of file + subscriptions_url(host: @subscription.community.host) %> diff --git a/app/views/subscriptions/index.html.erb b/app/views/subscriptions/index.html.erb index 774017ba6..8c8561f2a 100644 --- a/app/views/subscriptions/index.html.erb +++ b/app/views/subscriptions/index.html.erb @@ -3,10 +3,19 @@

    Your Subscriptions

    These are all the email subscriptions you're currently signed up for. You can turn them off or remove them entirely from here.

    -<% @subscriptions.each do |sub| %> -
    - <%= sub.name %> -

    Subscription to <%= phrase_for sub.type, sub.qualifier %>, emailed every <%= pluralize(sub.frequency, 'day') %>.

    +<% @subscriptions.group_by(&:type).flat_map do |type, subs| + subs.each_with_index.map do |sub, index| + [ + sub.created_at, + [sub.name.present? ? sub.name : "#{type.capitalize} subscription #{index + 1}",sub] + ] + 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') + %>.

    <%= check_box_tag :enabled, 1, sub.enabled, class: 'js-enable-subscription' %> <%= label_tag :enabled, 'Enabled?' %> · Remove diff --git a/app/views/suggested_edit/category_index.html.erb b/app/views/suggested_edit/category_index.html.erb index 462cae1f1..f9c6b126c 100644 --- a/app/views/suggested_edit/category_index.html.erb +++ b/app/views/suggested_edit/category_index.html.erb @@ -1,5 +1,8 @@

    Suggested Edits

    -

    This is a list of suggested edits on posts in this category.

    +

    Suggested edits for review.

    + +<% categories = Category.unscoped.where(community: @community).order(sequence: :asc, id: :asc) %> +<% current_cat = current_category %>
    <%= link_to 'Pending', query_url(show_decided: 0), @@ -10,11 +13,46 @@ <% if params[:show_decided] != '1' && !current_user&.privilege?('edit_posts') %>
    - You can't approve or reject suggested edits because you haven't - yet earned the <%= link_to 'Edit Posts', ability_path('edit_posts') %> ability. + You can't approve or reject suggested edits (except on your own posts) + because you haven't yet earned the <%= link_to 'Edit Posts', ability_path('edit_posts') %> ability.
    <% end %> +<% if params[:show_decided] != '1' %> +

    All categories

    + + + + + + <% 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 %> + + + + + <% end %> +
    categorypending edits
    + <% if cat == current_cat %> + <%= cat.name %> + <% else %> + <%= link_to suggested_edits_queue_url(cat) do %> + <%= cat.name %> + <% end %> + <% end %> + + <% if cat == current_cat %> + <%= sug_edits %> + <% else %> + <%= link_to suggested_edits_queue_url(cat) do %> + <%= sug_edits %> + <% end %> + <% end %> +
    +<% end %> + + <% if @edits.any? %>
    <% @edits.each do |edit| %> @@ -35,4 +73,4 @@
    <% else %>

    There are no suggested edits in this category.

    -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/suggested_edit/show.html.erb b/app/views/suggested_edit/show.html.erb index 8a35f3c5a..535a2daa6 100644 --- a/app/views/suggested_edit/show.html.erb +++ b/app/views/suggested_edit/show.html.erb @@ -1,10 +1,17 @@ <% content_for :title, "Suggested Edit #" + @edit.id.to_s %> <% post = @edit.post %> <% is_question_or_article = @edit.on_question? || @edit.on_article? %> -<% may_decide = check_your_post_privilege(post, 'edit_posts') && @edit.user.id != current_user.id %> +<% may_decide = check_your_post_privilege(post, 'edit_posts') %>

    Review Suggested Edit

    +<% if params[:show_decided] != '1' && !may_decide %> +
    + You can't approve or reject suggested edits because you haven't + yet earned the <%= link_to 'Edit Posts', ability_path('edit_posts') %> ability. +
    +<% end %> + <% if @edit.active? && may_decide %>

    This edit was suggested by another user. Good edits:

    @@ -16,10 +23,10 @@
    <% end %> -
    +
    <% if @edit.pending? %>

    Pending.
    This suggested edit is pending review.

    @@ -42,7 +49,11 @@
    - + <%= text_field_tag :summary, @edit.comment, class: 'form-element', + data: { character_count: ".js-character-count-#{@edit.id}" } %> +
    + <%= render 'shared/char_count', type: @edit.id, cur: @edit.comment&.length, max: max_edit_comment_length %> +
    @@ -63,21 +74,22 @@
    -

    Suggested <%= time_ago_in_words(@edit.created_at) %> ago by - <%= user_link @edit.user %>

    +

    Suggested + <%= time_ago_in_words(@edit.created_at) %> ago + by <%= user_link @edit.user %>

    <% if @edit.active? && may_decide %> <% end %> -
    + diff --git a/app/views/tags/_form.html.erb b/app/views/tags/_form.html.erb index f88e1d6d4..43202c83c 100644 --- a/app/views/tags/_form.html.erb +++ b/app/views/tags/_form.html.erb @@ -14,15 +14,51 @@ <%= form_for @tag, url: submit_path do |f| %> <% if submit_path == create_tag_path %> + <% tag_name = @tag.name %> + <% key = "saved_post.#{current_user&.id}.#{request.path}.tag_name" %> + <% saved_at_key = "saved_post_at.#{current_user&.id}.#{request.path}" %> + <% saved_at = DateTime.parse(RequestContext.redis.get(saved_at_key) || '') rescue Date.new(2000, 1, 1) %> + <% + # Find the most recent between post-create, post-update, and draft-saved, and use the value corresponding to that. + tag_name = [ + [@tag.created_at || Date.new(2000, 1, 1), @tag.name], + [@tag.updated_at || Date.new(2000, 1, 1), @tag.name], + [saved_at || Date.new(2001, 1, 1), RequestContext.redis.get(key)] + ].max_by do |x| + x[0] + end[1] + %>
    <%= f.label :name, 'Name', class: 'form-element' %> Name of the tag - <%= f.text_field :name, class: 'form-element' %> + <%= f.text_field :name, value: tag_name, class: 'form-element' %>
    <% end %> +
    + <%= f.label :tag_synonyms, 'Tag Synonyms', class: 'form-element' %> + + Alternative names for this tag + +
    + <% i = -1 %> + <%= f.fields_for :tag_synonyms do |tsf| %> +
    +
    + <%= tsf.text_field :name, class: 'form-element tag-synonym-name', readonly: tsf.object&.name.present?, disabled: tsf.object&.name.blank? %> +
    +
    + +
    + <%= tsf.hidden_field :_destroy, value: tsf.object&.name.blank?, class: 'destroy-tag-synonym' %> +
    + <% end %> +
    +
    + +
    <%= f.label :parent_id, 'Parent tag', class: 'form-element' %> @@ -31,21 +67,39 @@ <%= f.select :parent_id, options_for_select(@tag.parent.present? ? [[@tag.parent.name, @tag.parent_id]] : [], selected: @tag.parent.present? ? @tag.parent_id : nil), { include_blank: true }, class: "form-element js-tag-select", - data: { tag_set: @category.tag_set_id, use_ids: true, placeholder: "None" } %> + data: { tag_set: @category.tag_set_id, create: false, use_ids: true, placeholder: "None" } %>
    + <% excerpt = @tag.excerpt %> + <% key = "saved_post.#{current_user&.id}.#{request.path}.excerpt" %> + <% saved_at_key = "saved_post_at.#{current_user&.id}.#{request.path}" %> + <% saved_at = DateTime.parse(RequestContext.redis.get(saved_at_key) || '') rescue Date.new(2000, 1, 1) %> + <% + # Find the most recent between post-create, post-update, and draft-saved, and use the value corresponding to that. + excerpt = [ + [@tag.created_at || Date.new(2000, 1, 1), @tag.excerpt], + [@tag.updated_at || Date.new(2000, 1, 1), @tag.excerpt], + [saved_at || Date.new(2001, 1, 1), RequestContext.redis.get(key)] + ].max_by do |x| + x[0] + end[1] + %> <%= f.label :excerpt, 'Usage guidance', class: 'form-element' %> Short usage guidance for this tag. Will be cut off at 120 characters in the tags list, but displayed in full on the tag page. - <%= f.text_area :excerpt, class: 'form-element js-tag-excerpt', rows: 3 %> - 0 / 600 + <%= f.text_area :excerpt, class: 'form-element js-tag-excerpt', + value: excerpt, + rows: 3, + data: { character_count: '.js-character-count-tag-excerpt' } + %> + <%= render 'shared/char_count', type: 'tag-excerpt', cur: excerpt&.length || @tag.excerpt&.length, min: 0, max: 600 %>
    - <%= render 'shared/body_field', f: f, field_name: :wiki_markdown, field_label: 'Wiki', post: @tag do %> + <%= render 'shared/body_field', f: f, min_length: 0, max_length: 30_000, cur_length: @tag.wiki_markdown&.length, + field_name: :wiki_markdown, field_label: 'Wiki', post: @tag do %> Full usage guidance and any other information you want people to know about this tag. <% end %>
    diff --git a/app/views/tags/_list.html.erb b/app/views/tags/_list.html.erb index cc7e4ea91..fb46962ba 100644 --- a/app/views/tags/_list.html.erb +++ b/app/views/tags/_list.html.erb @@ -4,7 +4,7 @@ <% topic_ids = @category&.topic_tag_ids %> <% ApplicationRecord.with_lax_group_rules do %> - <% @tags.each do |tag| %> + <% @tags&.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' : '' %> @@ -12,4 +12,4 @@ <%= render 'tag', category: @category, tag: tag, classes: classes %> <% end %> <% end %> -
    \ No newline at end of file +
    diff --git a/app/views/tags/_tag.html.erb b/app/views/tags/_tag.html.erb index ccb2d5644..c0beafbb0 100644 --- a/app/views/tags/_tag.html.erb +++ b/app/views/tags/_tag.html.erb @@ -8,6 +8,13 @@ <% end %> <%= link_to tag.name, tag_path(id: category.id, tag_id: tag.id), class: classes, 'data-ckb-item-link' => '' %> × <%= tag.post_count %> + <% if tag.tag_synonyms.present? %> +

    + <% tag.tag_synonyms.each do |synonym| %> + <%= synonym.name %> + <% end %> +

    + <% end %> <% if tag.excerpt.present? %>

    <% splat = split_words_max_length(tag.excerpt, 120) %> diff --git a/app/views/tags/category.html.erb b/app/views/tags/category.html.erb index a3119f65f..7f1c55307 100644 --- a/app/views/tags/category.html.erb +++ b/app/views/tags/category.html.erb @@ -2,8 +2,16 @@

    Tags used in <%= @category.name %>

    -<% if current_user&.is_moderator %> - <%= link_to 'New', new_tag_path(id: @category.id), class: 'button is-muted is-outlined' %> +<% ApplicationRecord.with_lax_group_rules do %> + +<% if @tags == nil %> +

    This category is missing its tag set. Set this in the Category settings (admin).

    +<% end %> + +<% unless @tags == nil %> + +<% if current_user&.is_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 %> <%= form_tag category_tags_path(@category), method: :get, class: 'form-inline' do %> @@ -23,15 +31,33 @@

    <%= pluralize(@count, 'tag') %>

    <%= link_to 'Usage', category_tags_path(@category), - class: "button is-muted is-outlined #{request.query_parameters.size == 0 ? 'is-active' : ''}" %> + class: "button is-muted is-outlined #{request.query_parameters.size == 0 ? 'is-active' : ''}", + 'aria-label': 'Show tag usage for this category' %> <%= link_to 'Hierarchy', query_url(hierarchical: '1'), - class: "button is-muted is-outlined #{params[:hierarchical].present? ? 'is-active' : ''}" %> + class: "button is-muted is-outlined #{params[:hierarchical].present? ? 'is-active' : ''}", + 'aria-label': 'Show tag hierarchies' %> <%= link_to 'Missing Excerpt', query_url(no_excerpt: '1'), - class: "button is-muted is-outlined #{params[:no_excerpt].present? ? 'is-active' : ''}" %> + class: "button is-muted is-outlined #{params[:no_excerpt].present? ? 'is-active' : ''}", + 'aria-label': 'Show tags without excerpts' %>
    <% end %> <%= render 'list' %> +<% if @tags&.size == 0 %> + <% if params[:q].present? %> +

    No results for <%= params[:q] %>

    + <% else %> +

    There are no tags in <%= @category.name %>

    + <% end %> +<% end %> + +<% if @tags&.size > 0 %> <%= will_paginate @tags, renderer: BootstrapPagination::Rails %> +<% end %> + +<% end %> <%# unless @tags == nil %> + +<% end %> <%# ApplicationRecord.with_lax_group_rules %> + diff --git a/app/views/tags/nuke_warning.html.erb b/app/views/tags/nuke_warning.html.erb index 677c2d10c..5813fc820 100644 --- a/app/views/tags/nuke_warning.html.erb +++ b/app/views/tags/nuke_warning.html.erb @@ -23,6 +23,7 @@

    This action will be logged.

    -<%= link_to nuke_tag_path(id: @category.id, tag_id: @tag.id), class: 'button is-danger is-filled', method: :delete do %> +<%= link_to nuke_tag_path(id: @category.id, tag_id: @tag.id), class: 'button is-danger is-filled', method: :delete, + role: 'button' do %> I understand, delete <%= @tag.name %> <% end %> diff --git a/app/views/tags/show.html.erb b/app/views/tags/show.html.erb index b2350de22..341c53292 100644 --- a/app/views/tags/show.html.erb +++ b/app/views/tags/show.html.erb @@ -4,22 +4,35 @@ Posts tagged <%= @tag.name %> <% if moderator? %> + data-category="<%= @category.id %>" data-tag="<%= @tag.id %>" data-name="<%= @tag.name %>" + aria-label="Rename this tag"> - <%= link_to select_tag_merge_path(id: @category.id, tag_id: @tag.id), class: 'has-font-size-caption h-m-r-2' do %> + <%= link_to select_tag_merge_path(id: @category.id, tag_id: @tag.id), class: 'has-font-size-caption h-m-r-2', + 'aria-label': 'Merge this tag with other tags' do %> <% end %> <% end %> <% if admin? %> - <%= link_to nuke_tag_warning_path(id: @category.id, tag_id: @tag.id), class: 'has-font-size-caption h-m-r-2 is-red' do %> + <%= link_to nuke_tag_warning_path(id: @category.id, tag_id: @tag.id), class: 'has-font-size-caption h-m-r-2 is-red', + 'aria-label': 'Nuke this tag' do %> <% end %> <% end %> + +<% if @tag.tag_synonyms.any? %> +

    + Synonyms: + <% @tag.tag_synonyms.each do |synonym| %> + <%= synonym.name %> + <% end %> +

    +<% end %> +

    <% if @tag.parent_id.present? %> - Subtag of <%= link_to @tag.parent.name, tag_path(id: @category.id, tag_id: @tag.parent_id), + Subtag of <%= link_to @tag.parent.name, tag_path(id: @category.id, tag_id: @tag.parent_id), class: tag_classes(@tag.parent, @category) %> <% end %> <% child_count = @tag.children.count %> @@ -39,7 +52,8 @@ This tag doesn't have any usage information yet. <% if current_user&.privilege?('edit_tags') %> - <%= link_to 'Help us create it', edit_tag_path(id: @category.id, tag_id: @tag.id) %>. + <%= link_to 'Help us create it', edit_tag_path(id: @category.id, tag_id: @tag.id), + 'aria-label': 'Help us create usage information for this tag' %>. <% end %>

    @@ -48,12 +62,12 @@
    <% if @tag.wiki.present? %> <% if @tag.wiki.length < 600 %> - <%= raw(sanitize(@tag.wiki, scrubber: scrubber)) %> - <% else %> -
    - Tag Wiki <%= raw(sanitize(@tag.wiki, scrubber: scrubber)) %> -
    + <% else %> +
    + Tag Wiki + <%= raw(sanitize(@tag.wiki, scrubber: scrubber)) %> +
    <% end %> <% end %> <% unless @tag.wiki.present? %> @@ -61,7 +75,8 @@ This tag doesn't have a detailed wiki yet. <% if current_user&.privilege?('edit_tags') %> - <%= link_to 'Help us create it', edit_tag_path(id: @category.id, tag_id: @tag.id) %>. + <%= link_to 'Help us create it', edit_tag_path(id: @category.id, tag_id: @tag.id), + 'aria-label': 'Help us create a detailed wiki for this tag' %>. <% end %>

    @@ -70,24 +85,39 @@
    <% if current_user&.privilege?('edit_tags') %> - <%= link_to 'Edit', edit_tag_path(id: @category.id, tag_id: @tag.id), class: 'button is-muted is-outlined' %> + <%= link_to 'Edit', edit_tag_path(id: @category.id, tag_id: @tag.id), class: 'button is-muted is-outlined', + 'aria-label': "Edit tag #{@tag.name}" %> <% end %> <% post_count = @posts.count %>
    -
    - <%= short_number_to_human post_count, precision: 1, significant: false %> - <%= 'post'.pluralize(post_count) %> - <%= link_to 'Subscribe', - new_subscription_path(type: 'tag', qualifier: @tag.name, return_to: request.path), - class: 'button is-outlined' %> +
    + + <%= short_number_to_human post_count, precision: 1, significant: false %> + <%= 'post'.pluralize(post_count) %> + + <%= render 'shared/rss_link', url: tag_path(id: @category.id, tag_id: @tag.id, format: 'rss'), tooltip: 'RSS feed for this tag' %> + <% if user_signed_in? && current_user&.subscriptions.where(type: 'tag', qualifier: @tag.name).exists? %> + + <%= link_to 'Subscribed', subscriptions_path(current_user), + class: 'button is-outlined', 'aria-label': "Subscribed to tag #{@tag.name}" %> + + <% else %> + + <%= link_to 'Subscribe', + new_subscription_path(type: 'tag', qualifier: @tag.name, return_to: request.path), + class: 'button is-outlined', 'aria-label': "Subscribe to tag #{@tag.name}" %> + + <% end %>
    <%= link_to 'Tag Only', query_url(self: 1), - class: "button is-muted is-outlined #{params[:self].present? ? 'is-active' : ''}" %> + class: "button is-muted is-outlined #{params[:self].present? ? 'is-active' : ''}", + role: 'button', 'aria-label': 'View this tag only, without showing its children' %> <%= link_to 'Tag + Children', tag_path(id: @category.id, tag_id: @tag.id), - class: "button is-muted is-outlined #{params[:self].nil? ? 'is-active' : ''}" %> + class: "button is-muted is-outlined #{params[:self].nil? ? 'is-active' : ''}", + role: 'button', 'aria-label': 'View this tag and its children'%>
    @@ -102,7 +132,5 @@
    - <%= link_to tag_path(id: @category.id, tag_id: @tag.id, format: 'rss') do %> - Tag RSS feed - <% end %> + <%= render 'shared/rss_link', url: tag_path(id: @category.id, tag_id: @tag.id, format: 'rss'), text: 'Tag RSS feed' %>
    diff --git a/app/views/tour/question2.html.erb b/app/views/tour/question2.html.erb index 008550ba3..b2da72932 100644 --- a/app/views/tour/question2.html.erb +++ b/app/views/tour/question2.html.erb @@ -43,7 +43,13 @@
    - + +
    diff --git a/app/views/tour/question3.html.erb b/app/views/tour/question3.html.erb index eeb62f939..0c783c16a 100644 --- a/app/views/tour/question3.html.erb +++ b/app/views/tour/question3.html.erb @@ -339,7 +339,7 @@

    Voting also helps good content be more visible. Good answers are promoted to the top, while bad ones sink to the bottom. Please help curate the community by voting responsibly.

    Do so by clicking the - icon next to the post.

    + icon next to the post. After you vote, the tour will continue.

    diff --git a/app/views/users/activity.html.erb b/app/views/users/activity.html.erb index d9afc9b57..e86b14425 100644 --- a/app/views/users/activity.html.erb +++ b/app/views/users/activity.html.erb @@ -7,104 +7,24 @@

    Activity for <%= user_link @user %>

    - - - - - - - - - <% @items.each do |i| %> - - <% if i.class == Post %> - - - - - <% elsif i.class == Comment %> - - - - - <% elsif i.class == PostHistory %> - - - - - <% elsif i.class == SuggestedEdit %> - - - - - <% else %> - - - - - <% end %> - - - <% end %> -
    TypeOn...ExcerptStatusDate
    - - <%= (i.question? ? "Question" : (i.article? ? "Article" : "Answer")) %> - - - <% if !i.answer? %> - <%= i.title %>
    - <% else %> - A: <%= i.parent.title %>
    - <% end %> - <%= i.body_plain[0..300] + ((i.body_plain.length > 300) ? "..." : "") %>
    - <%= link_to '(more)', generic_share_link(i) %> -
    - Comment - - <%= link_to "Post #" + i.post.id.to_s, generic_share_link(i.post) %> - - <%= i.content[0..300] + ((i.content.length > 300) ? "..." : "") %>
    - <%= link_to '(more)', comment_link(i) %> -
    - Edit - - <%= link_to "Post #" + i.post.id.to_s, generic_share_link(i.post) %> - - <% if i.comment %> - <%= i.post_history_type.name.gsub("_", " ").capitalize %>:
    <%= i.comment %> - <% else %> - <%= i.post_history_type.name.gsub("_", " ").capitalize %> - <% end %> -
    - Suggested Edit - - <%= link_to "Post #" + i.post.id.to_s, generic_share_link(i.post) %> - - Suggested edit:
    <%= i.comment %>
    <%= link_to '(more)', suggested_edit_url(i.id) %> -
    - <%= (i.pending? ? "pending" : (i.approved? ? "helpful" : "declined")) %> - - Unknown - <%= i.class %> - <%= time_ago_in_words(i.created_at) %> ago -
    \ No newline at end of file +<%= render 'activity_items', mod: false %> diff --git a/app/views/users/disconnect_sso.html.erb b/app/views/users/disconnect_sso.html.erb new file mode 100644 index 000000000..b55f669fb --- /dev/null +++ b/app/views/users/disconnect_sso.html.erb @@ -0,0 +1,12 @@ +

    Disconnect from Single Sign-On

    +

    + You currently sign in using your institutions Single Sign-On service. + It is possible to switch to signing in with a normal email address and password by disconnecting your account from + the Single Sign-On service. + After disconnecting your account, you will be sent an email to set the password for your account. +

    + +<%= link_to 'Disconnect my account from SSO', + user_confirm_disconnect_sso_path, + method: :post, + class: 'button is-filled is-danger' %> \ No newline at end of file diff --git a/app/views/users/edit_profile.html.erb b/app/views/users/edit_profile.html.erb index 330bdbbb9..654a2ef29 100644 --- a/app/views/users/edit_profile.html.erb +++ b/app/views/users/edit_profile.html.erb @@ -34,33 +34,41 @@
    <%= f.label :username, class: "form-element" %>
    What other people call you.
    - <%= f.text_field :username, class: 'form-element', autocomplete: 'off' %> + <%= f.text_field :username, class: 'form-element', autocomplete: 'off', data: { character_count: '.js-character-count-user-name' } %> + <%= render 'shared/char_count', type: 'user-name', cur: current_user.username&.length, min: 3, max: 50 %>
    - <%= render 'shared/body_field', f: f, field_name: :profile_markdown, field_label: 'Profile', post: current_user %> + <%= render 'shared/body_field', f: f, field_name: :profile_markdown, field_label: 'Profile', post: current_user, + cur_length: current_user.profile_markdown&.length, min_length: 0 %> + <% unless current_user.community_user.privilege?('unrestricted') %> +

    Note: Links are not shown publicly until you have earned the Participate Everywhere ability.

    + <% end %>
    -
    -
    - <%= f.label :website, class: "form-element" %> - A link to anywhere on the internet for your stuff. - <%= f.text_field :website, class: 'form-element', autocomplete: 'off', placeholder: 'https://...' %> -
    - -
    - <%= f.label :twitter, class: "form-element" %> - Your Twitter username, if you've got one you want to share. - <%= f.text_field :twitter, class: 'form-element', autocomplete: 'off', placeholder: '@username' %> -
    - -
    - <%= f.label :discord, class: 'form-element' %> - Your Discord user tag, in the format username#1234. - <%= f.text_field :discord, class: 'form-element', autocomplete: 'off', placeholder: 'username#1234' %> +
    +

    Extra fields -- your web site, GitHub profile, social-media usernames, whatever you want. Only values that begin with "http" are rendered as links.

    +
    + <%= f.fields_for :user_websites do |w| %> +
    +
    +
    <%= w.text_field :label, class: 'form-element', autocomplete: 'off', placeholder: 'label' %>
    +
    +
    +
    <%= w.text_field :url, class: 'form-element', autocomplete: 'off', placeholder: 'https://...' %>
    +
    +
    + <% end %>
    +
    + <%= f.label :discord, class: 'form-element' %> + Your Discord user tag, username or username#1234. + <%= f.text_field :discord, class: 'form-element', autocomplete: 'off', placeholder: 'username#1234' %> +
    + + <%= f.submit 'Save', class: 'button is-filled' %> <% end %> @@ -72,7 +80,7 @@ you'll be able to request that any content you created on Stack Exchange that has been copied across here is transferred to you.

    - <%= link_to stack_oauth_url, class: 'button is-outlined' do %> + <%= link_to stack_oauth_url, class: 'button is-outlined', 'aria-label': 'Authenticate Stack Exchange account' do %> Authenticate <% end %>
    @@ -101,4 +109,4 @@ <%= submit_tag 'Claim Content', name: nil, class: 'button is-outlined' %> <% end %>
    -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/users/filters.html.erb b/app/views/users/filters.html.erb new file mode 100644 index 000000000..95a83c71f --- /dev/null +++ b/app/views/users/filters.html.erb @@ -0,0 +1,11 @@ +<%= render 'tabs', user: current_user %> + +

    Filters

    +

    + Manage your filters here +

    + +<%# Just a nominal form so that stuff like validation works %> +
    + <%= render 'search/filters', allow_apply: false, allow_delete: true %> + \ No newline at end of file diff --git a/app/views/users/full_log.html.erb b/app/views/users/full_log.html.erb index 3bb5c826a..bbd01e835 100644 --- a/app/views/users/full_log.html.erb +++ b/app/views/users/full_log.html.erb @@ -1,4 +1,4 @@ -<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> +<% content_for :title, "Full Activity Log: #{rtl_safe_username(@user)}" %>

    Full activity log for <%= user_link @user %>

    @@ -9,137 +9,36 @@ <% end %> - - - - - - - - - <% @items.each do |i| %> - - <% if i.class == Post %> - - - - - <% elsif i.class == Comment %> - - - - - <% elsif i.class == PostHistory %> - - - - - <% elsif i.class == SuggestedEdit %> - - - - - <% elsif i.class == ModWarning %> - - - - - <% elsif i.class == Flag %> - - - - - <% else %> - - - - - <% end %> - - - <% end %> -
    TypeOn...ExcerptStatusDate
    - - <%= (i.question? ? "Question" : (i.article? ? "Article" : "Answer")) %> - - - <% if !i.answer? %> - <%= i.title %>
    - <% else %> - A: <%= i.parent.title %>
    - <% end %> - <%= i.body_plain[0..300] + ((i.body_plain.length > 300) ? "..." : "") %>
    - <%= link_to '(more)', generic_share_link(i)%> -
    - Comment - - <%= link_to "Post #" + i.post.id.to_s, generic_share_link(i.post)%> - - <%= i.content[0..300] + ((i.content.length > 300) ? "..." : "") %>
    - <%= link_to '(more)', generic_share_link(i.post) + "#comment-" + i.id.to_s %> -
    - Edit - - <%= link_to "Post #" + i.post.id.to_s, generic_share_link(i.post)%> - - <% if i.comment %> - <%= i.post_history_type.name.gsub("_", " ").capitalize %>:
    <%= i.comment %> - <% else %> - <%= i.post_history_type.name.gsub("_", " ").capitalize %> - <% end %> -
    - Suggested Edit - - <%= link_to "Post #" + i.post.id.to_s, generic_share_link(i.post)%> - - Suggested edit:
    <%= i.comment %>
    <%= link_to '(more)', suggested_edit_url(i.id) %> -
    - <%= (i.pending? ? "pending" : (i.approved? ? "helpful" : "declined")) %> - - Warning - - <%= i.body[0..300] + ((i.body.length > 300) ? "..." : "") %> - - Flag - - <%= link_to "Post #" + i.post.id.to_s, generic_share_link(i.post)%> - - <%= i.reason[0..300] + ((i.reason.length > 300) ? "..." : "") %>
    -
    - <%= i.status || "pending" %> - - Unknown - <%= i.class %> - <%= time_ago_in_words(i.created_at) %> ago -
    +<%= render 'activity_items', mod: true %> + diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index aeea59d5c..f0ada48e5 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -15,15 +15,21 @@ <% end %>
    - <%= link_to 'Reputation', query_url(sort: 'reputation'), - class: "button is-muted is-outlined #{params[:sort] == 'reputation' || (params[:sort].nil? && params[:search].nil?) ? 'is-active' : ''}" %> - <%= link_to 'Age', query_url(sort: 'age'), class: "button is-muted is-outlined #{params[:sort] == 'age' ? 'is-active' : ''}" %> + <%= link_to 'Reputation', request.params.merge(sort: 'reputation'), + class: "button is-muted is-outlined #{params[:sort] == 'reputation' || (params[:sort].nil? && params[:search].nil?) ? 'is-active' : ''}", + role: 'button', 'aria-label': 'Sort by reputation' %> + <%= link_to 'Age', request.params.merge(sort: 'age'), class: "button is-muted is-outlined #{params[:sort] == 'age' ? 'is-active' : ''}", + role: 'button', 'aria-label': 'Sort by age' %>
    -
    - <% @users.each do |user| %> - <%= render 'user', user: user %> - <% end %> -
    +<% if @users.count == 0 %> +

    <%= I18n.t('search.no_users') %>

    +<% else %> +
    + <% @users.each do |user| %> + <%= render 'user', user: user %> + <% end %> +
    +<% end %> <%= will_paginate @users, renderer: BootstrapPagination::Rails %> diff --git a/app/views/users/mod.html.erb b/app/views/users/mod.html.erb index 54761db71..4f4cd8050 100644 --- a/app/views/users/mod.html.erb +++ b/app/views/users/mod.html.erb @@ -26,15 +26,15 @@ after initiating an action.

    <%= link_to 'Destroy user', destroy_user_path(@user.id), remote: true, - method: :delete, class: 'js-destroy-user button is-danger is-filled' %> + method: :delete, class: 'js-destroy-user button is-danger is-filled', role: 'button' %> <%= link_to 'Delete community profile', soft_delete_user_path(@user.id, type: 'profile'), remote: true, - method: :delete, class: 'js-soft-delete button is-danger is-filled' %> + method: :delete, class: 'js-soft-delete button is-danger is-filled', role: 'button' %> <% if current_user.is_global_moderator || current_user.is_global_admin %> <%= link_to 'Delete user network-wide', soft_delete_user_path(@user.id, type: 'user'), remote: true, - method: :delete, class: 'js-soft-delete button is-danger is-filled' %> + method: :delete, class: 'js-soft-delete button is-danger is-filled', role: 'button' %> <% end %> <% if current_user.is_global_admin %> - <%= link_to 'Feed to STAT (180 days)', hellban_user_path(@user), method: :post, class: 'button is-danger is-filled' %> + <%= link_to 'Feed to STAT (180 days)', hellban_user_path(@user), method: :post, class: 'button is-danger is-filled', role: 'button' %> <% end %>
    diff --git a/app/views/users/network.html.erb b/app/views/users/network.html.erb new file mode 100644 index 000000000..cb520a24e --- /dev/null +++ b/app/views/users/network.html.erb @@ -0,0 +1,10 @@ +<%= render 'tabs', user: @user %> + +

    Profiles for <%= user_link @user %>

    + +

    + Links to profiles on other communities on this network. +

    + +<%= render 'network' %> + diff --git a/app/views/users/posts.html.erb b/app/views/users/posts.html.erb index ee63d1bc6..83f02b24e 100644 --- a/app/views/users/posts.html.erb +++ b/app/views/users/posts.html.erb @@ -6,9 +6,19 @@

    Posts by <%= user_link @user %>

    -
    - <%= link_to 'Score', query_url(sort: 'score'), class: 'button is-muted is-outlined ' + (active_search?('score') ? 'is-active' : '') %> - <%= link_to 'Age', query_url(sort: 'age'), class: 'button is-muted is-outlined ' + (active_search?('created_at') ? 'is-active' : '') %> +<% post_count = @posts.count %> +
    + + <%= short_number_to_human post_count, precision: 1, significant: false %> + <%= 'post'.pluralize(post_count) %> + + +
    + <%= 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' %> +
    @@ -17,4 +27,4 @@ <% end %>
    -<%= will_paginate @posts, renderer: BootstrapPagination::Rails %> \ No newline at end of file +<%= will_paginate @posts, renderer: BootstrapPagination::Rails %> diff --git a/app/views/users/preferences.html.erb b/app/views/users/preferences.html.erb index 6d9825d41..e02038ffd 100644 --- a/app/views/users/preferences.html.erb +++ b/app/views/users/preferences.html.erb @@ -2,7 +2,7 @@

    Preferences

    - Manage your user preferences here. Changes are automatically saved once you move away from the input field. + Manage your user preferences here. Changes are automatically saved.

    <%= link_to 'Manage Email Subscriptions', subscriptions_path %>

    diff --git a/app/views/users/qr_login_code.html.erb b/app/views/users/qr_login_code.html.erb index 9a02d5a89..3ad98fd75 100644 --- a/app/views/users/qr_login_code.html.erb +++ b/app/views/users/qr_login_code.html.erb @@ -4,8 +4,20 @@ follow the URL to log in there.

    -
    - - <%= raw(@qr_code.as_svg(standalone: false, module_size: 4)) %> - -
    \ No newline at end of file +
    +

    Caution

    +

    + The QR code below, when scanned, provides immediate access to your <%= t 'platform.network_name' %> 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. +

    +
    + +
    + Show QR code +
    + + <%= raw(@qr_code.as_svg(standalone: false, module_size: 4)) %> + +
    +
    diff --git a/app/views/users/sessions/verify_2fa.html.erb b/app/views/users/sessions/verify_2fa.html.erb index 999ec617a..88deb61f2 100644 --- a/app/views/users/sessions/verify_2fa.html.erb +++ b/app/views/users/sessions/verify_2fa.html.erb @@ -1,4 +1,5 @@

    Two-factor authentication

    +

    Your account has two-factor authentication enabled. Enter a code from your authenticator app here.

    @@ -8,7 +9,7 @@
    <%= label_tag 'code', 'Code', class: 'form-element' %> - <%= number_field_tag 'code', '', class: 'form-element', autocomplete: 'one-time-code' %> + <%= text_field_tag 'code', '', class: 'form-element', autocomplete: 'one-time-code', autofocus: 'true' %>
    diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 9545ef8c1..1ce3f9975 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -1,3 +1,5 @@ +<%= render 'tabs', user: @user %> + <% content_for :title, "User #{rtl_safe_username(@user)}" %> <% if moderator? && deleted_user?(@user) %> @@ -18,23 +20,44 @@
    <% end %> -<%= render 'tabs', user: @user %> -
    -
    +
    -

    - <% if @user.website.present? %> - - <%= link_to @user.website_domain, @user.website, rel: 'nofollow' %> - - <% end %> - <% if @user.twitter.present? %> - - <%= link_to @user.twitter, "https://twitter.com/#{@user.twitter}" %> - + + <% effective_profile = raw(sanitize(@user.profile&.strip || '', scrubber: scrubber)) %> + <% if effective_profile.blank? %> +

    A quiet enigma. We don't know anything about <%= rtl_safe_username(@user) %> yet.

    + <% elsif !user_signed_in? && !@user.community_user.privilege?('unrestricted') %> + <%= sanitize(effective_profile, attributes: %w()) %> + <% else %> + <%= effective_profile %> + <% end %> +
    + + <% unless !user_signed_in? && !@user.community_user.privilege?('unrestricted') %> + <% if @user.valid_websites_for.size.positive? %> +
    +

    Extra fields

    + + <% @user.valid_websites_for.each do |w| %> + + + + + <% end %> +
    <%= w.label %> + <% if w.url[0,4] == 'http' %> + <%= link_to w.url, w.url, rel: 'nofollow' %> + <% else %> + <%= w.url %> + <% end %> +
    +
    <% end %> + <% end %> + +

    <% if @user.discord.present? %> <%= @user.discord %> @@ -42,13 +65,6 @@ <% end %>

    - <% if @user.profile.nil? || @user.profile.blank? %> -

    A quiet enigma. We don't know anything about <%= rtl_safe_username(@user) %> yet.

    - <% else %> - <%= raw(sanitize(@user.profile, scrubber: scrubber)) %> - <% end %> -
    -
    <% if user_signed_in? %> <%= link_to new_subscription_path(type: 'user', qualifier: @user.id, return_to: request.path), class: "button is-outlined is-small" do %> @@ -56,14 +72,18 @@ <% end %> <% end %> <% if current_user&.is_moderator %> - Moderator Tools + Moderator Tools <% if @user.community_user.mod_warnings&.size.positive? %> (<%= pluralize(@user.community_user.mod_warnings.count, 'message') %>) <% end %> <% end %> - <% if current_user&.id == @user.id %> + <% if current_user&.same_as?(@user) %> <%= link_to qr_login_code_path, class: 'button is-outlined is-small' do %> Mobile Sign In <% end %> <% end %>
    - + <% if @posts.size == 0 %>

    <%= rtl_safe_username(@user) %> hasn't posted anything yet.

    <% else %> @@ -87,8 +114,8 @@ <%= render 'posts/type_agnostic', post: a, show_type_tag: true, show_category_tag: true %> <% end %>
    - <%= link_to user_posts_path(@user), class: "button is-muted" do %> - See all » + <%= link_to user_posts_path(@user), class: "button is-muted", 'aria-label': "View all posts by #{rtl_safe_username(@user)}" do %> + See all <%= @total_post_count %> » <% end %> <% end %>
    @@ -137,12 +164,21 @@ <%= @user.metric 'E' %> + + <% if current_user&.id == @user.id || current_user&.is_moderator %> + + + + +
    User since <%= @user.created_at %>
    + <% end %> <% unless @abilities.empty? %>

    Earned Abilities

    <% @abilities.each do |a| %> + <% if @user.privilege?(a.internal_id) %> <% end %> + <% end %> diff --git a/config/application.rb b/config/application.rb index e5c0a4004..39ebcb680 100644 --- a/config/application.rb +++ b/config/application.rb @@ -39,5 +39,10 @@ class Application < Rails::Application Devise::Mailer.helper :users Devise::Mailer.layout 'devise_mailer' end + + console do + require 'console_extension' + include ConsoleExtension + end end end diff --git a/config/attribute-map.yml b/config/attribute-map.yml new file mode 100644 index 000000000..1f3bc4eea --- /dev/null +++ b/config/attribute-map.yml @@ -0,0 +1,18 @@ +# Add your SAML attribute mapping here. +# +# Required: +# '': 'saml_init_email' +# '': 'saml_init_identifier' +# '': 'username' (user cannot change it) OR 'saml_init_username_no_update' (only set on first login, user can change) +# +# If email is the unique identifier, map the email attribute to 'saml_init_email_and_identifier' instead of the above. +# In that case you also need to update devise.rb and set `config.saml_default_user_key = :'saml_init_email_and_identifier'`. +# +'uid': 'saml_init_identifier' +'urn:mace:dir:attribute-def:uid': 'saml_init_identifier' +'mail': 'saml_init_email' +'urn:mace:dir:attribute-def:mail': 'saml_init_email' +'email': 'saml_init_email' +'urn:mace:dir:attribute-def:email': 'saml_init_email' +'displayName': 'saml_init_username_no_update' +'urn:mace:dir:attribute-def:displayName': 'saml_init_username_no_update' diff --git a/config/config/preferences.yml b/config/config/preferences.yml index 36be0760a..5d72b1204 100644 --- a/config/config/preferences.yml +++ b/config/config/preferences.yml @@ -10,13 +10,17 @@ # - if # - type is choice # default: string default value | ~ -# community: true | false # optional, default false (global) +## Either community, global or category should be set to true, all default to false +# community: true | false +# global: true | false +# category: true | false keyboard_tools: type: boolean description: > Enable keyboard shortcuts. Press ? for a list of shortcuts. default: 'true' + global: true autosave: type: choice @@ -67,9 +71,16 @@ auto_follow_comment_threads: description: > Automatically follow any comment thread you participate in. default: 'true' + global: true sticky_header: type: boolean description: > Make the top navigation bar sticky. - default: false \ No newline at end of file + default: 'false' + global: true + +default_filter_name: + type: ~ + default: none + category: true \ No newline at end of file diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 8fe95183b..fc62796fc 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -7h9/PUnrx1KYHkSB0ye+1GWX4RHzJoc7XbutJDscZv6hISSAO/wjQBTvTGXEJ2yoJ3nWeJw2ePHmEy6Rexw2ycLvglexAJ5PAdO3jdh2d43k1+zUd50Mntz+vuUxCiRJ1lYogKZ0/iyn7i2cf1Xy1ZW05lNlgluzVo8dHvd7gH9A2Xsdd2kouKp2MvU7Kxxd4vnNor7PF53MhfxDOsEgQfmrcBU4fRbQQCTDx7SOXIiTLyvA3EgC7i+ECagetwSKyrvvUuYXyFfsgtXZLzn3w5VtzWBGULVVKy0uMvh3RlPkdzgrDW+sGQtHCi7YwM5CJPz6wah9ypjDnhlQXbcMg9qjcnatMF6YYDg8Jf4NkO0gSHSpzBZweTydb1kWWql5m40bqtrOM6yxkyBeGA2jQRfQ+zSsltOxwGa6ZeDfKD76z+5fFGtl3apIPG91KkFpVZrm8LOJdsKV9P/JFFzXTAMyIRkBHAiacwY/qVFPxzI4exEJUI0R3H2RFgD6tADJOSHAcVDUbkxt56ARUk0DpKq/7/azLdM+1NOwmITO/Nn6d1pjB7/rKjHEjSCu7hAtvXL+Ou9adKDRdxAnM+tOCwc5olQcPzepeiVNuO6lBqhSVE/huu8hQVemBlNIjWhqC1TjSN/dvgUdve6rap/d3Y66T5MEmKFsPzV/iLVkEKZQ7/lHRgZMidOHoemg0FWbShhdDjZWFR8Q2K9d7vhPuZj7DSW0inTgCMayHf/ULR8QGRxCySusCK9rU3BsxKmWHiX+6F0EN0CFQRGEXzIKIi6U7RNh86b8apskPFFgtPUWsCufP1Z1NWnxJU/8hb9+ZhEYbs3z54+ssR9jw+yHvEFg63R6THGwcF/kXhXomRAjNYt/bJV2aAuC4w39YMQakctrgWYcA8PuBXc8tDpBZjA8+yHcxDTOUb0veaZnLNfj0+D54Q6zwO6iV+DdTeOL8JWKh52erhPyh8u6mYRS88CP9R1Ep/LqbtG1om4nPmAsVfuMBbyih9XJxvGwknZEmgUo8w+pGhff2JXCsACnKq66cy6VKWQqiUDTZKo+hSXEc/dRqSTOSTq26ZN5ctbbXE57WNydvQ==--PEsrFqN16qSIVkzT--nvIHvNPjy9ZEHA3g3EuNjg== \ No newline at end of file +SA7h7Xm7xF3AEvje/xZiPMzem0wh2CysuvvB+iM6R0Epkg+z+vZG2oxI/9tVzDGELWtwoNDPeqNa0bUbGjo6gt7xyEpmQbivEVKnwcbVK+1bxPHKke+hveVEUhmY/axFIIpty+VTnZVnrJkZ35/rW9egcRr5O2HikuAuBz8KrYLvB2w3h8EdmcIbbfjMvKWlUVvc8D9t3v+k56B1CbTghTmGeG0NLijWAA+U+pDmErPOR9JUAVY/nbF2/T8aQ5Szameuoj88Pk5P6alFDHwlWUSHhuEY5Z7A5LKM1NtzlTGc+CAcTVzKvx6DCg8WUJugy3uS7Bpp6uNj9TvJoZzrb0i5G1nDmIl7tK2GCNclp3JFEklrUJIrF4asRuDQUzdvuPeTEXNyi2cc5d6hSBG+MwEvG90P6n0td5+gv+rr1FCKuzP5ftyxqPsnvZuBKRxNSw0+VTTXhTO73jcwttHYrD9Jwc9tVbCVqegwsEO0dB0sNL6M89rudDMbpPnz7rJXr6lXD4Az3qJ/0N3ubYDwiuGOk7UHDntm4Id4BFDm5qeLeOUg1n2PDhsQUWh2Lfnk7ADvjQMinfXhdAQkYVim9sDtNO/Grn9xSXfCn3vs1xOdTtKLb23+EIxO2STtkHTsJG5XvxC2jmzL5/zQx/vO/zepHom7POdso4Ygoqw9oEttm/KPG85lcX0edXv/l1i5JBMMbzaQgd1PL4uWwrv+3LoSq+vQORijR50OiLdlMZ5IFn+QagJEdeVtUiCbYA3AhUK/FcH3L5QtSGNObEUrUJ4fVuWP6X8E34vd+DoY++D6PtG89SFIGBRmQ23cT111uIw3p2A9HP4N9B/uNKwxAnr4S1he6EkoA5CtDE0NZufy25s0IVf//FDWXS8FDg3ZhtOUeMWB0dew9VeCCWwr8RrLu4jP46IvVqfFeiVWf7gJcenEJ0QuqPlBeukSpgNxckDjwijRz5obzeS9ZAWYgCZg2kg/6CmuIM2VjkWMJ13uKYkFgpheoNsk1jZwH4+0fdm+Zc+fE4KQFcSkkazea/qEanN2/tDo6pRowSlRpi/nWJqHxcu87QMm7cBq5/E9DNjwyKOPmgr7c2NzDrlPYMRgzdcauPz6Y4qfcg9C6lKwJIo18VQhrqwh7wW+nNTov3Shw/yclkVrxnHTQbyH3mOstJsVPpYeYewxOQj/gikvvkx/lUeUnyo2/Ebff2nJCbnLmJ8/7Vwz9c/yvg==--d1HPd+AnhFU1woqD--z2ikkRr6d5WTzC7X5xOVPA== \ No newline at end of file diff --git a/config/environments/development.rb b/config/environments/development.rb index 3586b8d51..f6fa52367 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -34,6 +34,9 @@ # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = :local + # Allow ngrok connections to dev server + config.hosts << /[a-z0-9\-.]+\.ngrok-free\.app/ + # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false config.action_mailer.delivery_method = :ses @@ -77,12 +80,15 @@ config.i18n.raise_on_missing_translations = true config.action_mailer.delivery_method = :letter_opener_web - config.action_mailer.default_url_options = { host: 'meta.codidact.com', protocol: 'https' } + + config.action_mailer.default_url_options = { + host: 'meta.codidact.com', protocol: ENV['MAILER_PROTOCOL'] || 'https' + } # 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 - config.web_console.allowed_ips << host_ip + config.web_console.permissions = host_ip # ==> Configuration for :confirmable # A period that the user is allowed to access the website even without diff --git a/config/environments/production.rb b/config/environments/production.rb index 7a7b6d2d4..8884d9834 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -93,9 +93,14 @@ # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') config.action_mailer.delivery_method = :ses - config.action_mailer.default_url_options = { host: 'meta.codidact.com', protocol: 'https' } + config.action_mailer.default_url_options = { + host: 'meta.codidact.com', + protocol: ENV['MAILER_PROTOCOL'] || 'https' + } config.action_mailer.asset_host = 'https://meta.codidact.com' # Do not dump schema after migrations. config.active_record.dump_schema_after_migration = false + + config.active_job.queue_adapter = :async end diff --git a/config/environments/test.rb b/config/environments/test.rb index 8bd2cfad8..8212caac0 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -56,7 +56,10 @@ # ActionMailer::Base.deliveries array. config.action_mailer.delivery_method = :test - config.action_mailer.default_url_options = { host: 'test.host' } + config.action_mailer.default_url_options = { + host: 'test.host', + protocol: ENV['MAILER_PROTOCOL'] || 'https' + } # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 2b2af4c05..9eadc984e 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -270,4 +270,78 @@ # When using OmniAuth, Devise cannot automatically set OmniAuth path, # so you need to do it manually. For the users scope, it would be: # config.omniauth_path_prefix = '/my_engine/users/auth' + + # Reference https://github.com/apokalipto/devise_saml_authenticatable + # ==> Configuration for :saml_authenticatable + + # Add prefix to saml routes + config.saml_route_helper_prefix = 'saml' + + # Create user if the user does not exist. + config.saml_create_user = true + + # Update the attributes of the user after a successful login. Set this to + # false if you don't want to update the users email address from saml when it + # is changed. + config.saml_update_user = true + + # Set the default user key. The user will be looked up by this key. Make + # sure that the (mapped) Authentication Response includes the attribute. + # + # In the attribute map we register this as saml_init_identifier + # (the method to call), so this is also how we have to refer to it here. + config.saml_default_user_key = :'saml_init_identifier' + + # Load by sso_profile instead of by email address. + config.saml_resource_locator = Proc.new do |model, saml_response, auth_value| + # You can use saml_response here to access other attributes than the saml_default_user_key indicated above if need be. + SsoProfile.includes(:user).find_by(saml_identifier: auth_value)&.user + end + + # Optional. This stores the session index defined by the IDP during login. If provided it will be used as a salt + # for the user's session to facilitate an IDP initiated logout request. + config.saml_session_index_key = :session_index + + # You can set this value to use Subject or SAML assertation as info to which email will be compared. + # If you don't set it then email will be extracted from SAML assertation attributes. + config.saml_use_subject = false + + # You can support multiple IdPs by setting this value to a class that implements a #settings method which takes + # an IdP entity id as an argument and returns a hash of idp settings for the corresponding IdP. + config.idp_settings_adapter = nil + + # You provide you own method to find the idp_entity_id in a SAML message in the case of multiple IdPs + # by setting this to a custom reader class, or use the default. + # config.idp_entity_id_reader = DeviseSamlAuthenticatable::DefaultIdpEntityIdReader + + # You can set a handler object that takes the response for a failed SAML request and the strategy, + # and implements a #handle method. This method can then redirect the user, return error messages, etc. + # config.saml_failed_callback = nil + + # Add your SAML configuration from your IDP here. + # + # Attributes must be mapped in the attribute-map.yml + # + # For certificates and keys, you can use + # File.read('path/to/certificate') + # instead of providing the certificate/key in a string. + # + # config.saml_configure do |settings| + # settings.assertion_consumer_service_url = '/users/saml/auth' + # settings.assertion_consumer_service_binding = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' + # settings.name_identifier_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' + # settings.security[:want_assertions_signed] = true + # settings.security[:metadata_signed] = true + # settings.security[:authn_requests_signed] = true + # settings.force_authn = !Rails.env.production? + # settings.protocol_binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + # settings.passive = false + # settings.issuer = '/users/saml/metadata' + # settings.idp_slo_target_url = '' + # settings.idp_sso_target_url = '' + # settings.idp_entity_id = '' + # settings.idp_cert = '' + # settings.certificate = '' + # settings.private_key = '' + # end end diff --git a/config/initializers/devise_example.rb b/config/initializers/devise_example.rb index ef47ca5e1..f8863061b 100644 --- a/config/initializers/devise_example.rb +++ b/config/initializers/devise_example.rb @@ -265,4 +265,75 @@ # When using OmniAuth, Devise cannot automatically set OmniAuth path, # so you need to do it manually. For the users scope, it would be: # config.omniauth_path_prefix = '/my_engine/users/auth' + + # Reference https://github.com/apokalipto/devise_saml_authenticatable + # ==> Configuration for :saml_authenticatable + + # Add prefix to saml routes + config.saml_route_helper_prefix = 'saml' + + # Create user if the user does not exist. + config.saml_create_user = true + + # Update the attributes of the user after a successful login. + config.saml_update_user = true + + # Set the default user key. The user will be looked up by this key. Make + # sure that the Authentication Response includes the attribute. + # + # In the attribute map we register this as saml_init_identifier (the method to call), so this is also how we have to refer to it here. + config.saml_default_user_key = :'saml_init_identifier' + + # Load by sso_profile instead of by email address. + config.saml_resource_locator = Proc.new do |model, saml_response, auth_value| + # You can use saml_response here to access other attributes than the saml_default_user_key indicated above if need be. + SsoProfile.includes(:user).find_by(saml_identifier: auth_value)&.user + end + + # Optional. This stores the session index defined by the IDP during login. If provided it will be used as a salt + # for the user's session to facilitate an IDP initiated logout request. + config.saml_session_index_key = :session_index + + # You can set this value to use Subject or SAML assertation as info to which email will be compared. + # If you don't set it then email will be extracted from SAML assertation attributes. + config.saml_use_subject = false + + # You can support multiple IdPs by setting this value to a class that implements a #settings method which takes + # an IdP entity id as an argument and returns a hash of idp settings for the corresponding IdP. + config.idp_settings_adapter = nil + + # You provide you own method to find the idp_entity_id in a SAML message in the case of multiple IdPs + # by setting this to a custom reader class, or use the default. + # config.idp_entity_id_reader = DeviseSamlAuthenticatable::DefaultIdpEntityIdReader + + # You can set a handler object that takes the response for a failed SAML request and the strategy, + # and implements a #handle method. This method can then redirect the user, return error messages, etc. + # config.saml_failed_callback = nil + + # Add your SAML configuration from your IDP here. + # + # Attributes must be mapped in the attribute-map.yml + # + # For certificates and keys, you can use + # File.read('path/to/certificate') + # instead of providing the certificate/key in a string. + # + # config.saml_configure do |settings| + # settings.assertion_consumer_service_url = '/users/saml/auth' + # settings.assertion_consumer_service_binding = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' + # settings.name_identifier_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' + # settings.security[:want_assertions_signed] = true + # settings.security[:metadata_signed] = true + # settings.security[:authn_requests_signed] = true + # settings.force_authn = !Rails.env.production? + # settings.protocol_binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + # settings.passive = false + # settings.issuer = '/users/saml/metadata' + # settings.idp_slo_target_url = '' + # settings.idp_sso_target_url = '' + # settings.idp_entity_id = '' + # settings.idp_cert = '' + # settings.certificate = '' + # settings.private_key = '' + # end end diff --git a/config/initializers/zz_cache_setup.rb b/config/initializers/zz_cache_setup.rb index d7a9fe5f0..ae1e14a40 100644 --- a/config/initializers/zz_cache_setup.rb +++ b/config/initializers/zz_cache_setup.rb @@ -1,3 +1,5 @@ Rails.cache.persistent 'current_commit', clear: true do - [`git rev-parse HEAD`.strip, `git log -1 --date=iso --pretty=format:%cd`.strip] + commit_sha = `git rev-parse HEAD`.strip + commit_date = `git log -1 --date=iso-strict --pretty=format:%cd`.strip + [commit_sha, commit_date] end diff --git a/config/initializers/zz_codidact_sites.rb b/config/initializers/zz_codidact_sites.rb index b745b63de..ac4511296 100644 --- a/config/initializers/zz_codidact_sites.rb +++ b/config/initializers/zz_codidact_sites.rb @@ -4,12 +4,18 @@ if Rails.env.development? || Rails.env.test? [] else - response = Net::HTTP.get_response(URI('https://codidact.com/communities.json')) + uri = URI('https://codidact.com/communities.json') + req = Net::HTTP::Get.new(uri) + req['Authorization'] = "Bearer #{Rails.application.credentials.cf_bot_key}" + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| + http.request(req) + end + if response.code == '200' JSON.parse(response.body) else - Rails.logger.fatal "Couldn't fetch Codidact sites: response code #{response.code}" - exit 255 + Rails.logger.error "Couldn't fetch Codidact sites: response code #{response.code}" + [] end end end diff --git a/config/locales/strings/en.admin.yml b/config/locales/strings/en.admin.yml index 08115e4dd..ac389d08a 100644 --- a/config/locales/strings/en.admin.yml +++ b/config/locales/strings/en.admin.yml @@ -11,6 +11,10 @@ en: This tool allows you to send to the moderators' email distribution list, which will reach any current moderator who is subscribed to it. Use sparingly and non-spammily. Your email will be sent in both HTML (formatted) and plain text (unformatted) forms, so format in a way that will be legible when Markdown is removed. + email_all_blurb: > + This tool sends email to ALL registered users with a valid email address. Use very very rarely, usually only for + organisation-wide changes like privacy policy or TOS changes. Format in a way that will be legible when Markdown + is removed. 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. @@ -18,6 +22,7 @@ en: g_site_settings: 'Global Site Settings' g_tag_sets: 'Global Tag Sets' email_moderators: 'Email Moderators' + email_all: 'Email All Users' error_reports: 'Error Reports' site_settings: 'Site Settings' tag_sets: 'Tag Sets' diff --git a/config/locales/strings/en.edits.yml b/config/locales/strings/en.edits.yml new file mode 100644 index 000000000..a34799dd6 --- /dev/null +++ b/config/locales/strings/en.edits.yml @@ -0,0 +1,6 @@ +en: + edits: + # validation error messages + max_edit_comment_length: + one: Edit comment can't be more than 1 character long. + other: Edit comment can't be more than :length characters long. \ No newline at end of file diff --git a/config/locales/strings/en.g.yml b/config/locales/strings/en.g.yml index 53590fa2f..1fdf073cc 100644 --- a/config/locales/strings/en.g.yml +++ b/config/locales/strings/en.g.yml @@ -22,4 +22,7 @@ en: subject: 'subject' threshold: 'threshold' type: 'type' - user: 'user' \ No newline at end of file + user: 'user' + + platform: + network_name: 'Codidact network' diff --git a/config/locales/strings/en.posts.yml b/config/locales/strings/en.posts.yml index 93fba33ff..e866d0e8e 100644 --- a/config/locales/strings/en.posts.yml +++ b/config/locales/strings/en.posts.yml @@ -25,10 +25,12 @@ en: Can't delete a deleted post. cant_delete_responded: > This post cannot be deleted because it has responses. + cant_delete_community: > + This post cannot be deleted because it is owned by the community (freely editable). cant_restore_post: > Can't restore this post right now. Try again later. cant_restore_undeleted: > - Can't restore an undeleted post.. + Can't restore a post that isn't deleted. cant_restore_deleted_by_moderator: > You cannot restore this post deleted by a moderator. cant_change_category: > @@ -46,6 +48,10 @@ en: Requires at least one of edit_comment_label: > Edit Comment + redact_label: > + Redact + redact_explanation: > + Redact original content by hiding the previous versions from history? Use only for private information such as passwords or personally identifiable information. licence_label: > License unsaved_changes_confirmation: > @@ -66,3 +72,8 @@ en: Responding to: no_block_mathjax_title: > Title cannot contain block-level MathJax. + + post_closed_guidance: > + Your post has been closed and is not accepting new answers. You should read the reason for closure listed below and check for any comments left by others about + why your post has been closed and what it needs, then edit your post to improve it to a state where it can be reopened. If you need help, reply to comments to + clarify any requests, or join us in chat for more guidance. diff --git a/config/locales/strings/en.rate_limit.yml b/config/locales/strings/en.rate_limit.yml index 8510b148e..e073f705a 100644 --- a/config/locales/strings/en.rate_limit.yml +++ b/config/locales/strings/en.rate_limit.yml @@ -1,10 +1,11 @@ -rate_limit: - new_user_posts: > - You may only post :count :level posts per day. Once you have some well-received posts, your limit will increase. - posts: > - You may only post :count :level posts per day. - new_user_suggested_edits: > - You may only suggest :count edits per day. Once you have some well-received contributions, your limit will increase. - suggested_edits: > - You may only suggest :count edits per day. Once you have enough well-received suggestions, you will earn the ability - to edit without review. +en: + rate_limit: + new_user_posts: > + You may only post :count :level posts per day. Once you have some well-received posts, your limit will increase. + posts: > + You may only post :count :level posts per day. + new_user_suggested_edits: > + You may only suggest :count edits per day. Once you have some well-received contributions, your limit will increase. + suggested_edits: > + You may only suggest :count edits per day. Once you have enough well-received suggestions, you will earn the ability + to edit without review. diff --git a/config/locales/strings/en.search.yml b/config/locales/strings/en.search.yml new file mode 100644 index 000000000..9358256b2 --- /dev/null +++ b/config/locales/strings/en.search.yml @@ -0,0 +1,5 @@ +en: + search: + # used on the users page (not the main search) + no_users: + No users found. diff --git a/config/locales/strings/en.votes.yml b/config/locales/strings/en.votes.yml new file mode 100644 index 000000000..485ef2f6f --- /dev/null +++ b/config/locales/strings/en.votes.yml @@ -0,0 +1,4 @@ +en: + votes: + summary: + post_missing: 'Post not found' \ No newline at end of file diff --git a/config/locales/strings/es.posts.yml b/config/locales/strings/es.posts.yml index ccd7f2420..d59bb2d56 100644 --- a/config/locales/strings/es.posts.yml +++ b/config/locales/strings/es.posts.yml @@ -46,6 +46,10 @@ es: Requiere al menos un/a de edit_comment_label: > Editar comentario + redact_label: > + Redactar + redact_explanation: > + Redactar el contenido original ocultando las versiones anteriores del historial licence_label: > Licencia unsaved_changes_confirmation: > diff --git a/config/locales/strings/es.rate_limit.yml b/config/locales/strings/es.rate_limit.yml index a79963a9e..430931763 100644 --- a/config/locales/strings/es.rate_limit.yml +++ b/config/locales/strings/es.rate_limit.yml @@ -1,10 +1,11 @@ -rate_limit: - new_user_posts: > - Usted sólo debería publicar :count :level publicaciones por día. Cuando usted haya publicado varias publicaciones bien recibidas, su límite será incrementado. - posts: > - Usted sólo debería publicar :count :level publicaciones por día. - new_user_suggested_edits: > - Usted sólo debería sugerir :count ediciones por día. Cuando usted haya propuesto varias contribuciones bien recibidas, su límite será incrementado. - suggested_edits: > - Usted sólo debería sugerir :count ediciones por día. Cuando usted haya propuesto las suficientes sugerencias bien recibidas, usted conseguirá la habilidad - de editar sin ser revisado. +es: + rate_limit: + new_user_posts: > + Usted sólo debería publicar :count :level publicaciones por día. Cuando usted haya publicado varias publicaciones bien recibidas, su límite será incrementado. + posts: > + Usted sólo debería publicar :count :level publicaciones por día. + new_user_suggested_edits: > + Usted sólo debería sugerir :count ediciones por día. Cuando usted haya propuesto varias contribuciones bien recibidas, su límite será incrementado. + suggested_edits: > + Usted sólo debería sugerir :count ediciones por día. Cuando usted haya propuesto las suficientes sugerencias bien recibidas, usted conseguirá la habilidad + de editar sin ser revisado. diff --git a/config/routes.rb b/config/routes.rb index 1619878ee..8b8b6b37b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,8 +1,20 @@ Rails.application.routes.draw do - devise_for :users, controllers: { sessions: 'users/sessions', registrations: 'users/registrations' } + # Add normal sign in/sign up, confirmations, registrations, unlocking and password editing routes only if no SSO or mixed sign in. + devise_for :users, only: %i[sessions registrations confirmations unlock passwords], + controllers: { sessions: 'users/sessions', registrations: 'users/registrations' }, + constraints: { url: lambda { |_url| SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] } } + # Add SAML routes only when SAML enabled + devise_for :users, only: :saml_authenticatable, + controllers: { saml_sessions: 'users/saml_sessions' }, + constraints: { url: lambda { |_url| SiteSetting['SsoSignIn'] } } + # Add any other devise routes that may exist that we did not add yet + devise_for :users, skip: %i[sessions registrations confirmations unlock passwords saml_authenticatable] devise_scope :user do get 'users/2fa/login', to: 'users/sessions#verify_2fa', as: :login_verify_2fa post 'users/2fa/login', to: 'users/sessions#verify_code', as: :login_verify_code + 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 end root to: 'categories#homepage' @@ -26,6 +38,8 @@ get 'mod-email', to: 'admin#admin_email', as: :moderator_email post 'mod-email', to: 'admin#send_admin_email', as: :send_moderator_email + get 'email-all', to: 'admin#all_email', as: :email_all + post 'email-all', to: 'admin#send_all_email', as: :send_all_email get 'audits', to: 'admin#audit_log', as: :audit_log @@ -144,8 +158,10 @@ get ':id/:answer', to: 'posts#show', as: :answer_post end - get 'policy/:slug', to: 'posts#document', as: :policy - get 'help/:slug', to: 'posts#document', as: :help + get 'policy/:slug/history', to: 'post_history#slug_post', as: :policy_post_history, constraints: { slug: /.*/ } + get 'policy/:slug', to: 'posts#document', as: :policy, constraints: { slug: /.*/ } + get 'help/:slug/history', to: 'post_history#slug_post', as: :help_post_history, constraints: { slug: /.*/ } + get 'help/:slug', to: 'posts#document', as: :help, constraints: { slug: /.*/ } get 'tags', to: 'tags#index', as: :tags @@ -159,6 +175,7 @@ post 'disable/link-email', to: 'two_factor#send_disable_email', as: :two_factor_send_disable_email get 'disable/link/:token', to: 'two_factor#disable_link', as: :two_factor_disable_link post 'disable/link', to: 'two_factor#confirm_disable_link', as: :two_factor_confirm_disable_link + post 'backup', to: 'two_factor#show_backup_code', as: :two_factor_backup_code end scope 'users' do @@ -170,17 +187,25 @@ get '/me', to: 'users#me', as: :users_me get '/me/preferences', to: 'users#preferences', as: :user_preferences post '/me/preferences', to: 'users#set_preference', as: :set_user_preference + get '/me/filters', to: 'users#filters', as: :user_filters + get '/me/filters/default/', to: 'users#default_filter', as: :default_filter + post '/me/filters', to: 'users#set_filter', as: :set_user_filter + delete '/me/filters', to: 'users#delete_filter', as: :delete_user_filter get '/me/notifications', to: 'notifications#index', as: :notifications get '/edit/profile', to: 'users#edit_profile', as: :edit_user_profile patch '/edit/profile', to: 'users#update_profile', as: :update_user_profile get '/me/vote-summary', to: 'users#my_vote_summary', as: :my_vote_summary + get '/me/network', to: 'users#my_network', as: :my_network 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 '/:id', to: 'users#show', as: :user get '/:id/flags', to: 'flags#history', as: :flag_history get '/:id/activity', to: 'users#activity', as: :user_activity get '/:id/mod', to: 'users#mod', as: :mod_user get '/:id/posts', to: 'users#posts', as: :user_posts get '/:id/vote-summary', to: 'users#vote_summary', as: :vote_summary + get '/:id/network', to: 'users#network', as: :network get '/:id/mod/privileges', to: 'users#mod_privileges', as: :user_privileges post '/:id/mod/privileges', to: 'users#mod_privilege_action', as: :user_privilege_action post '/:id/mod/toggle-role', to: 'users#role_toggle', as: :toggle_user_role @@ -237,6 +262,7 @@ root to: 'categories#index', as: :categories get 'new', to: 'categories#new', as: :new_category post 'new', to: 'categories#create', as: :create_category + post ':id/filters/default', to: 'categories#default_filter', as: :set_default_filter get ':id', to: 'categories#show', as: :category get ':id/edit', to: 'categories#edit', as: :edit_category post ':id/edit', to: 'categories#update', as: :update_category @@ -326,6 +352,10 @@ end end + scope 'emails' do + post 'log', to: 'email_logs#log', as: :create_email_log + end + get '403', to: 'errors#forbidden' get '404', to: 'errors#not_found' get '409', to: 'errors#conflict' @@ -339,4 +369,8 @@ scope 'network' do root to: 'fake_community#communities', as: :fc_communities end + + # Communities can have custom js or css defined (placed in public/assets/community). + # If these are not defined for a community, respond with 204 (ok but empty) + get '/assets/community/*path', to: ->(env) { [204, {}, ['']] } end diff --git a/config/schedule.rb b/config/schedule.rb index a9dd5e5aa..1b1c9c343 100644 --- a/config/schedule.rb +++ b/config/schedule.rb @@ -6,6 +6,22 @@ runner 'scripts/mail_uncaptured_donations.rb' end +every 1.day, at: '02:10' do + runner 'scripts/prune_email_logs.rb' +end + +every 1.day, at: '02:15' do + runner 'scripts/run_spam_cleanup.rb' +end + +every 1.day, at: '02:20' do + runner 'scripts/cleanup_drafts.rb' +end + +every 1.day, at: '02:25' do + runner 'scripts/cleanup_votes.rb' +end + every 6.hours do runner 'scripts/recalc_abilities.rb' end diff --git a/config/storage.docker.yml b/config/storage.docker.yml new file mode 100644 index 000000000..88649a3fb --- /dev/null +++ b/config/storage.docker.yml @@ -0,0 +1,14 @@ +test: + service: Disk + root: <%= Rails.root.join('tmp/storage') %> + +local: + service: Disk + root: <%= Rails.root.join('storage') %> + +s3: + service: S3 + access_key_id: "" + secret_access_key: "" + region: us-east-1 + bucket: "" diff --git a/config/storage.sample.yml b/config/storage.sample.yml index 88649a3fb..8d0ce7438 100644 --- a/config/storage.sample.yml +++ b/config/storage.sample.yml @@ -1,3 +1,8 @@ +# To use external files (such as image upload), you need a storage.yml file. +# If you are using only the local disk (such as in a development environment), +# you can just copy this file. If you are using an S3 bucket, copy this +# file and then edit the settings. + test: service: Disk root: <%= Rails.root.join('tmp/storage') %> diff --git a/db/migrate/20200813132829_enforce_concurrence_of_is_moderator_and_mod_ability.rb b/db/migrate/20200813132829_enforce_concurrence_of_is_moderator_and_mod_ability.rb index d84fdcc33..606f724a9 100644 --- a/db/migrate/20200813132829_enforce_concurrence_of_is_moderator_and_mod_ability.rb +++ b/db/migrate/20200813132829_enforce_concurrence_of_is_moderator_and_mod_ability.rb @@ -2,7 +2,7 @@ class EnforceConcurrenceOfIsModeratorAndModAbility < ActiveRecord::Migration[5.2 def up CommunityUser.unscoped.where(is_moderator: true).all.map do |cu| RequestContext.community = cu.community - cu.grant_privilege 'mod' + cu.grant_privilege! 'mod' end end diff --git a/db/migrate/20220811131155_create_sso_profile.rb b/db/migrate/20220811131155_create_sso_profile.rb new file mode 100644 index 000000000..1ef086211 --- /dev/null +++ b/db/migrate/20220811131155_create_sso_profile.rb @@ -0,0 +1,8 @@ +class CreateSsoProfile < ActiveRecord::Migration[5.2] + def change + create_table :sso_profiles do |t| + t.string :saml_identifier, null: false + t.references :user, null: false, foreign_key: true + end + end +end \ No newline at end of file diff --git a/db/migrate/20220915181608_create_tag_synonyms.rb b/db/migrate/20220915181608_create_tag_synonyms.rb new file mode 100644 index 000000000..2bf629601 --- /dev/null +++ b/db/migrate/20220915181608_create_tag_synonyms.rb @@ -0,0 +1,9 @@ +class CreateTagSynonyms < ActiveRecord::Migration[7.0] + def change + create_table :tag_synonyms do |t| + t.belongs_to :tag, foreign_key: true + t.string :name, null: false + t.timestamps + end + end +end diff --git a/db/migrate/20220916075849_create_filters.rb b/db/migrate/20220916075849_create_filters.rb new file mode 100644 index 000000000..ffb41db6a --- /dev/null +++ b/db/migrate/20220916075849_create_filters.rb @@ -0,0 +1,17 @@ +class CreateFilters < ActiveRecord::Migration[7.0] + def change + create_table :filters do |t| + t.references :user, null: false, foreign_key: true + t.string :name, null: false + t.float :min_score + t.float :max_score + t.integer :min_answers + t.integer :max_answers + t.string :status + t.string :include_tags + t.string :exclude_tags + + t.timestamps + end + end +end diff --git a/db/migrate/20221002043021_create_category_filter_defaults.rb b/db/migrate/20221002043021_create_category_filter_defaults.rb new file mode 100644 index 000000000..81e5dfa17 --- /dev/null +++ b/db/migrate/20221002043021_create_category_filter_defaults.rb @@ -0,0 +1,9 @@ +class CreateCategoryFilterDefaults < ActiveRecord::Migration[7.0] + def change + create_table :category_filter_defaults do |t| + t.references :user, null: false, foreign_key: true + t.references :filter, null: false, foreign_key: true + t.references :category, null: false, foreign_key: true + end + end +end diff --git a/db/migrate/20230613205236_add_min_title_length_to_categories.rb b/db/migrate/20230613205236_add_min_title_length_to_categories.rb new file mode 100644 index 000000000..9c5b752fe --- /dev/null +++ b/db/migrate/20230613205236_add_min_title_length_to_categories.rb @@ -0,0 +1,6 @@ +class AddMinTitleLengthToCategories < ActiveRecord::Migration[7.0] + def change + add_column :categories, :min_title_length, :integer, null: false, default: 15 + add_column :categories, :min_body_length, :integer, null: false, default: 30 + end +end diff --git a/db/migrate/20230627035349_add_default_filter_to_category.rb b/db/migrate/20230627035349_add_default_filter_to_category.rb new file mode 100644 index 000000000..b6f49a4b4 --- /dev/null +++ b/db/migrate/20230627035349_add_default_filter_to_category.rb @@ -0,0 +1,5 @@ +class AddDefaultFilterToCategory < ActiveRecord::Migration[7.0] + def change + add_reference :categories, :default_filter, foreign_key: { to_table: :filters }, null: true + end +end diff --git a/db/migrate/20230726143348_add_hidden_to_post_history.rb b/db/migrate/20230726143348_add_hidden_to_post_history.rb new file mode 100644 index 000000000..c1ba98202 --- /dev/null +++ b/db/migrate/20230726143348_add_hidden_to_post_history.rb @@ -0,0 +1,5 @@ +class AddHiddenToPostHistory < ActiveRecord::Migration[7.0] + def change + add_column :post_histories, :hidden, :boolean, null: false, default: false + end +end diff --git a/db/migrate/20230803191600_add_backup_2fa_code_to_users.rb b/db/migrate/20230803191600_add_backup_2fa_code_to_users.rb new file mode 100644 index 000000000..781ae8712 --- /dev/null +++ b/db/migrate/20230803191600_add_backup_2fa_code_to_users.rb @@ -0,0 +1,5 @@ +class AddBackup2faCodeToUsers < ActiveRecord::Migration[7.0] + def change + add_column :users, :backup_2fa_code, :string + end +end diff --git a/db/migrate/20230817213150_rename_site_setting_category_email_subscriptions.rb b/db/migrate/20230817213150_rename_site_setting_category_email_subscriptions.rb new file mode 100644 index 000000000..60382394c --- /dev/null +++ b/db/migrate/20230817213150_rename_site_setting_category_email_subscriptions.rb @@ -0,0 +1,5 @@ +class RenameSiteSettingCategoryEmailSubscriptions < ActiveRecord::Migration[7.0] + def change + SiteSetting.where(category: 'EmailSubscriptions').update_all(category: 'Email') + end +end diff --git a/db/migrate/202309141441_disable_needs_author_attention_flag.rb b/db/migrate/202309141441_disable_needs_author_attention_flag.rb new file mode 100644 index 000000000..917c84dca --- /dev/null +++ b/db/migrate/202309141441_disable_needs_author_attention_flag.rb @@ -0,0 +1,9 @@ +class DisableNeedsAuthorAttentionFlag < ActiveRecord::Migration[7.0] + def up + PostFlagType.unscoped.where(name: "needs author's attention").update_all(active: false) + end + + def down + PostFlagType.unscoped.where(name: "needs author's attention").update_all(active: true) + end +end diff --git a/db/migrate/20240405113618_create_email_logs.rb b/db/migrate/20240405113618_create_email_logs.rb new file mode 100644 index 000000000..ecea1a21d --- /dev/null +++ b/db/migrate/20240405113618_create_email_logs.rb @@ -0,0 +1,11 @@ +class CreateEmailLogs < ActiveRecord::Migration[7.0] + def change + create_table :email_logs do |t| + t.string :log_type + t.string :destination + t.text :data + + t.timestamps + end + end +end diff --git a/db/migrate/20241020193053_add_post_count_to_community_users.rb b/db/migrate/20241020193053_add_post_count_to_community_users.rb new file mode 100644 index 000000000..a6c54aa17 --- /dev/null +++ b/db/migrate/20241020193053_add_post_count_to_community_users.rb @@ -0,0 +1,5 @@ +class AddPostCountToCommunityUsers < ActiveRecord::Migration[7.0] + def change + add_column :community_users, :post_count, :integer, default: 0, null: false + end +end diff --git a/db/migrate/20250123141400_create_user_websites.rb b/db/migrate/20250123141400_create_user_websites.rb new file mode 100644 index 000000000..ec77b2d17 --- /dev/null +++ b/db/migrate/20250123141400_create_user_websites.rb @@ -0,0 +1,11 @@ +class CreateUserWebsites < ActiveRecord::Migration[7.0] + def change + create_table :user_websites do |t| + t.column :label, :string, limit:80 + t.string :url + t.integer :position + end + add_reference :user_websites, :user, null: false, foreign_key: true + add_index(:user_websites, [:user_id, :url], unique: true) + end +end diff --git a/db/migrate/20250128030354_create_maintenance_tasks_runs.maintenance_tasks.rb b/db/migrate/20250128030354_create_maintenance_tasks_runs.maintenance_tasks.rb new file mode 100644 index 000000000..c9b215d68 --- /dev/null +++ b/db/migrate/20250128030354_create_maintenance_tasks_runs.maintenance_tasks.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# This migration comes from maintenance_tasks (originally 20201211151756) +class CreateMaintenanceTasksRuns < ActiveRecord::Migration[6.0] + def change + create_table(:maintenance_tasks_runs) do |t| + t.string(:task_name, null: false) + t.datetime(:started_at) + t.datetime(:ended_at) + t.float(:time_running, default: 0.0, null: false) + t.integer(:tick_count, default: 0, null: false) + t.integer(:tick_total) + t.string(:job_id) + t.bigint(:cursor) + t.string(:status, default: :enqueued, null: false) + t.string(:error_class) + t.string(:error_message) + t.text(:backtrace) + t.timestamps + t.index(:task_name) + t.index([:task_name, :created_at], order: { created_at: :desc }) + end + end +end diff --git a/db/migrate/20250128030355_change_cursor_to_string.maintenance_tasks.rb b/db/migrate/20250128030355_change_cursor_to_string.maintenance_tasks.rb new file mode 100644 index 000000000..4e414cad3 --- /dev/null +++ b/db/migrate/20250128030355_change_cursor_to_string.maintenance_tasks.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# This migration comes from maintenance_tasks (originally 20210219212931) +class ChangeCursorToString < ActiveRecord::Migration[6.0] + # This migration will clear all existing data in the cursor column with MySQL. + # Ensure no Tasks are paused when this migration is deployed, or they will be resumed from the start. + # Running tasks are able to gracefully handle this change, even if interrupted. + def up + change_table(:maintenance_tasks_runs) do |t| + t.change(:cursor, :string) + end + end + + def down + change_table(:maintenance_tasks_runs) do |t| + t.change(:cursor, :bigint) + end + end +end diff --git a/db/migrate/20250128030356_remove_index_on_task_name.maintenance_tasks.rb b/db/migrate/20250128030356_remove_index_on_task_name.maintenance_tasks.rb new file mode 100644 index 000000000..1b5cadf8d --- /dev/null +++ b/db/migrate/20250128030356_remove_index_on_task_name.maintenance_tasks.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# This migration comes from maintenance_tasks (originally 20210225152418) +class RemoveIndexOnTaskName < ActiveRecord::Migration[6.0] + def up + change_table(:maintenance_tasks_runs) do |t| + t.remove_index(:task_name) + end + end + + def down + change_table(:maintenance_tasks_runs) do |t| + t.index(:task_name) + end + end +end diff --git a/db/migrate/20250128030357_add_arguments_to_maintenance_tasks_runs.maintenance_tasks.rb b/db/migrate/20250128030357_add_arguments_to_maintenance_tasks_runs.maintenance_tasks.rb new file mode 100644 index 000000000..8478268eb --- /dev/null +++ b/db/migrate/20250128030357_add_arguments_to_maintenance_tasks_runs.maintenance_tasks.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# This migration comes from maintenance_tasks (originally 20210517131953) +class AddArgumentsToMaintenanceTasksRuns < ActiveRecord::Migration[6.0] + def change + add_column(:maintenance_tasks_runs, :arguments, :text) + end +end diff --git a/db/migrate/20250128030358_add_lock_version_to_maintenance_tasks_runs.maintenance_tasks.rb b/db/migrate/20250128030358_add_lock_version_to_maintenance_tasks_runs.maintenance_tasks.rb new file mode 100644 index 000000000..b302565af --- /dev/null +++ b/db/migrate/20250128030358_add_lock_version_to_maintenance_tasks_runs.maintenance_tasks.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# This migration comes from maintenance_tasks (originally 20211210152329) +class AddLockVersionToMaintenanceTasksRuns < ActiveRecord::Migration[6.0] + def change + add_column( + :maintenance_tasks_runs, + :lock_version, + :integer, + default: 0, + null: false, + ) + end +end diff --git a/db/migrate/20250128030359_change_runs_tick_columns_to_bigints.maintenance_tasks.rb b/db/migrate/20250128030359_change_runs_tick_columns_to_bigints.maintenance_tasks.rb new file mode 100644 index 000000000..064b07705 --- /dev/null +++ b/db/migrate/20250128030359_change_runs_tick_columns_to_bigints.maintenance_tasks.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# This migration comes from maintenance_tasks (originally 20220706101937) +class ChangeRunsTickColumnsToBigints < ActiveRecord::Migration[6.0] + def up + change_table(:maintenance_tasks_runs, bulk: true) do |t| + t.change(:tick_count, :bigint) + t.change(:tick_total, :bigint) + end + end + + def down + change_table(:maintenance_tasks_runs, bulk: true) do |t| + t.change(:tick_count, :integer) + t.change(:tick_total, :integer) + end + end +end diff --git a/db/migrate/20250128030360_add_index_on_task_name_and_status_to_runs.maintenance_tasks.rb b/db/migrate/20250128030360_add_index_on_task_name_and_status_to_runs.maintenance_tasks.rb new file mode 100644 index 000000000..74d77e6be --- /dev/null +++ b/db/migrate/20250128030360_add_index_on_task_name_and_status_to_runs.maintenance_tasks.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# This migration comes from maintenance_tasks (originally 20220713131925) +class AddIndexOnTaskNameAndStatusToRuns < ActiveRecord::Migration[6.0] + def change + remove_index( + :maintenance_tasks_runs, + column: [:task_name, :created_at], + order: { created_at: :desc }, + name: :index_maintenance_tasks_runs_on_task_name_and_created_at, + ) + + add_index( + :maintenance_tasks_runs, + [:task_name, :status, :created_at], + name: :index_maintenance_tasks_runs, + order: { created_at: :desc }, + ) + end +end diff --git a/db/migrate/20250128030361_add_metadata_to_runs.maintenance_tasks.rb b/db/migrate/20250128030361_add_metadata_to_runs.maintenance_tasks.rb new file mode 100644 index 000000000..054ef0e2a --- /dev/null +++ b/db/migrate/20250128030361_add_metadata_to_runs.maintenance_tasks.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# This migration comes from maintenance_tasks (originally 20230622035229) +class AddMetadataToRuns < ActiveRecord::Migration[6.0] + def change + add_column(:maintenance_tasks_runs, :metadata, :text) + end +end diff --git a/db/schema.rb b/db/schema.rb index 9cc021c6d..97e097dba 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2022_09_13_183826) do +ActiveRecord::Schema[7.0].define(version: 2025_01_28_030361) do create_table "abilities", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "community_id" t.string "name" @@ -109,7 +109,11 @@ t.integer "sequence" t.boolean "use_for_hot_posts", default: true t.boolean "use_for_advertisement", default: true + t.integer "min_title_length", default: 15, null: false + t.integer "min_body_length", default: 30, null: false + t.bigint "default_filter_id" t.index ["community_id"], name: "index_categories_on_community_id" + t.index ["default_filter_id"], name: "index_categories_on_default_filter_id" t.index ["license_id"], name: "index_categories_on_license_id" t.index ["sequence"], name: "index_categories_on_sequence" t.index ["tag_set_id"], name: "index_categories_on_tag_set_id" @@ -138,6 +142,15 @@ t.bigint "tag_id" end + create_table "category_filter_defaults", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "filter_id", null: false + t.bigint "category_id", null: false + t.index ["category_id"], name: "index_category_filter_defaults_on_category_id" + t.index ["filter_id"], name: "index_category_filter_defaults_on_filter_id" + t.index ["user_id"], name: "index_category_filter_defaults_on_user_id" + end + create_table "close_reasons", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "name" t.text "description", size: :medium @@ -213,11 +226,20 @@ t.boolean "deleted", default: false, null: false t.datetime "deleted_at", precision: nil t.bigint "deleted_by_id" + t.integer "post_count", default: 0, null: false t.index ["community_id"], name: "index_community_users_on_community_id" t.index ["deleted_by_id"], name: "index_community_users_on_deleted_by_id" t.index ["user_id"], name: "index_community_users_on_user_id" end + create_table "email_logs", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.string "log_type" + t.string "destination" + t.text "data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "error_logs", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "community_id" t.bigint "user_id" @@ -234,6 +256,21 @@ t.index ["user_id"], name: "index_error_logs_on_user_id" end + create_table "filters", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "name", null: false + t.float "min_score" + t.float "max_score" + t.integer "min_answers" + t.integer "max_answers" + t.string "status" + t.string "include_tags" + t.string "exclude_tags" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_filters_on_user_id" + end + create_table "flags", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.text "reason" t.datetime "created_at", precision: nil, null: false @@ -272,6 +309,27 @@ t.index ["name"], name: "index_licenses_on_name" end + create_table "maintenance_tasks_runs", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.string "task_name", null: false + t.datetime "started_at", precision: nil + t.datetime "ended_at", precision: nil + t.float "time_running", default: 0.0, null: false + t.bigint "tick_count", default: 0, null: false + t.bigint "tick_total" + t.string "job_id" + t.string "cursor" + t.string "status", default: "enqueued", null: false + t.string "error_class" + t.string "error_message" + t.text "backtrace" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "arguments" + t.integer "lock_version", default: 0, null: false + t.text "metadata" + t.index ["task_name", "status", "created_at"], name: "index_maintenance_tasks_runs", order: { created_at: :desc } + end + create_table "micro_auth_apps", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "name" t.string "app_id" @@ -360,6 +418,7 @@ t.bigint "community_id" t.string "before_title" t.string "after_title" + t.boolean "hidden", default: false, null: false t.index ["community_id"], name: "index_post_histories_on_community_id" t.index ["post_history_type_id"], name: "index_post_histories_on_post_history_type_id" t.index ["post_id"], name: "index_post_histories_on_post_type_and_post_id" @@ -398,8 +457,8 @@ t.boolean "is_top_level", default: false, null: false t.boolean "is_freely_editable", default: false, null: false t.string "icon_name" - t.bigint "answer_type_id" t.boolean "has_reactions" + t.bigint "answer_type_id" t.boolean "has_only_specific_reactions" t.index ["answer_type_id"], name: "index_post_types_on_answer_type_id" t.index ["name"], name: "index_post_types_on_name" @@ -535,6 +594,12 @@ t.index ["name"], name: "index_site_settings_on_name" end + create_table "sso_profiles", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.string "saml_identifier", null: false + t.bigint "user_id", null: false + t.index ["user_id"], name: "index_sso_profiles_on_user_id" + end + create_table "subscriptions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "type", null: false t.string "qualifier" @@ -594,6 +659,14 @@ t.index ["community_id"], name: "index_tag_sets_on_community_id" end + create_table "tag_synonyms", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.bigint "tag_id" + t.string "name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["tag_id"], name: "index_tag_synonyms_on_tag_id" + end + create_table "tags", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "name" t.datetime "created_at", precision: nil, null: false @@ -632,6 +705,15 @@ t.index ["community_user_id"], name: "index_user_abilities_on_community_user_id" end + create_table "user_websites", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.string "label", limit: 80 + t.string "url" + t.integer "position" + t.bigint "user_id", null: false + t.index ["user_id", "url"], name: "index_user_websites_on_user_id_and_url", unique: true + t.index ["user_id"], name: "index_user_websites_on_user_id" + end + create_table "users", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "email" t.string "encrypted_password" @@ -674,6 +756,7 @@ t.boolean "deleted", default: false, null: false t.datetime "deleted_at", precision: nil t.bigint "deleted_by_id" + t.string "backup_2fa_code" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["deleted_by_id"], name: "index_users_on_deleted_by_id" t.index ["email"], name: "index_users_on_email", unique: true @@ -714,8 +797,8 @@ t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.boolean "read", default: false - t.index ["author_id"], name: "index_mod_messages_on_author_id" - t.index ["community_user_id"], name: "index_mod_messages_on_community_user_id" + t.index ["author_id"], name: "index_warnings_on_author_id" + t.index ["community_user_id"], name: "index_warnings_on_community_user_id" end add_foreign_key "abilities", "communities" @@ -723,8 +806,12 @@ add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "audit_logs", "communities" add_foreign_key "audit_logs", "users" + add_foreign_key "categories", "filters", column: "default_filter_id" add_foreign_key "categories", "licenses" add_foreign_key "categories", "tag_sets" + add_foreign_key "category_filter_defaults", "categories" + add_foreign_key "category_filter_defaults", "filters" + add_foreign_key "category_filter_defaults", "users" add_foreign_key "comment_threads", "users", column: "archived_by_id" add_foreign_key "comment_threads", "users", column: "deleted_by_id" add_foreign_key "comment_threads", "users", column: "locked_by_id" @@ -735,6 +822,7 @@ add_foreign_key "community_users", "users", column: "deleted_by_id" add_foreign_key "error_logs", "communities" add_foreign_key "error_logs", "users" + add_foreign_key "filters", "users" add_foreign_key "flags", "communities" add_foreign_key "flags", "users", column: "escalated_by_id" add_foreign_key "micro_auth_apps", "users" @@ -755,17 +843,20 @@ add_foreign_key "posts", "users", column: "locked_by_id" add_foreign_key "privileges", "communities" add_foreign_key "site_settings", "communities" + add_foreign_key "sso_profiles", "users" add_foreign_key "subscriptions", "communities" add_foreign_key "subscriptions", "users" add_foreign_key "suggested_edits", "communities" add_foreign_key "suggested_edits", "posts" add_foreign_key "suggested_edits", "users" add_foreign_key "suggested_edits", "users", column: "decided_by_id" + add_foreign_key "tag_synonyms", "tags" add_foreign_key "tags", "communities" add_foreign_key "tags", "tags", column: "parent_id" add_foreign_key "thread_followers", "posts" add_foreign_key "user_abilities", "abilities" add_foreign_key "user_abilities", "community_users" + add_foreign_key "user_websites", "users" add_foreign_key "users", "users", column: "deleted_by_id" add_foreign_key "votes", "communities" add_foreign_key "warning_templates", "communities" diff --git a/db/scripts/create_tags_path_view.sql b/db/scripts/create_tags_path_view.sql index 92708f38b..6752f6734 100644 --- a/db/scripts/create_tags_path_view.sql +++ b/db/scripts/create_tags_path_view.sql @@ -1,4 +1,4 @@ -create view tags_paths as +CREATE OR REPLACE VIEW tags_paths AS WITH RECURSIVE tag_path (id, created_at, updated_at, community_id, tag_set_id, wiki_markdown, wiki, excerpt, parent_id, name, path) AS ( diff --git a/db/seeds.rb b/db/seeds.rb index c116c94c3..11cb350a0 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -16,16 +16,81 @@ end # Prioritize the following models (in this order) such that models depending on them get created after -priority = [PostType, CloseReason, License, TagSet, PostHistoryType] +priority = [PostType, CloseReason, License, TagSet, PostHistoryType, User, Ability, CommunityUser, Filter] sorted = files.zip(types).to_h.sort do |a, b| (priority.index(a.second) || 999) <=> (priority.index(b.second) || 999) end.to_h +def expand_communities(type, seed) + if type.column_names.include?('community_id') && !seed.include?('community_id') + # if model includes a community_id, create the seed for every community + Community.all.map { |c| seed.deep_symbolize_keys.merge(community_id: c.id) } + else + # otherwise, no need to worry, just create it + [seed] + end +end + +def expand_ids(type, seeds) + # Transform all _id relations into the actual rails objects to pass validations + seeds.map do |seed| + columns = type.column_names.select { |name| name.match(/^.*_id$/) } + new_seed = seed.deep_symbolize_keys + columns.each do |column| + begin + column_type_name = column.chomp('_id') + column_type = column_type_name.classify.constantize + new_seed = new_seed.except(column.to_sym) + .merge(column_type_name.to_sym => column_type.unscoped.find(seed[column.to_sym])) + rescue StandardError + # Either the type does not exist or the value specified as the id is not valid, ignore. + next + end + end + new_seed + end +end + +def create_objects(type, seed) + seeds = expand_communities(type, seed) + seeds = expand_ids(type, seeds) + + # Actually create the objects and count successes + objs = type.create seeds + + skipped = objs.select { |o| o.errors.any? }.size + created = objs.select { |o| !o.errors.any? }.size + + [created, skipped] +end + +def ensure_system_user_abilities + system_users = CommunityUser.unscoped.where(user_id: -1) + + system_users.each do |su| + abilities = Ability.unscoped + .where(internal_id: ['everyone', 'mod', 'unrestricted']) + .where(community_id: su.community_id) + + user_abilities = UserAbility.unscoped.where(community_user_id: su.id) + + abilities.each do |ab| + unless user_abilities.any? { |ua| ua.ability_id == ab.id } + UserAbility.create community_user_id: su.id, ability: ab + end + rescue => e + puts "#{type}: failed to add \"#{ab.name}\" to system user \"#{su.id}\" on \"#{su.community.name}\"" + puts e + end + end +end + sorted.each do |f, type| begin processed = ERB.new(File.read(f)).result(binding) data = YAML.load(processed) created = 0 + errored = 0 skipped = 0 updated = 0 data.each do |seed| @@ -39,57 +104,60 @@ puts "Running full Posts update..." seed['body'] = ApplicationController.helpers.render_markdown(seed['body_markdown']) + + system_usr = User.find(-1) + Community.all.each do |c| RequestContext.community = c post = Post.find_by doc_slug: seed['doc_slug'] - if post.present? && PostHistory.where(post: post).count <= 1 + if post.present? && PostHistory.where(post: post) + .where.not(post_history_type: + PostHistoryType.find_by(name: 'initial_revision')) + .count.zero? + # post exists, still original version: update post post.update(seed.merge('community_id' => c.id)) + + no_initial = PostHistory.where(post: post) + .where(post_history_type: PostHistoryType.find_by(name: 'initial_revision')) + .count.zero? + + if no_initial + puts "[#{c.name}:#{seed['doc_slug']}] missing initial revision, creating..." + PostHistory.initial_revision(post, system_usr) + end + updated += 1 elsif post.nil? # post doesn't exist: create post - Post.create seed.merge('community_id' => c.id) - created += 1 + status = Post.create seed.merge('community_id' => c.id, 'user' => system_usr) + + if status.errors.size + status.errors.full_messages.each do |msg| + puts "[#{c.name}:#{seed['doc_slug']}] invalid: #{msg}" + end + + errored += 1 + else + created += 1 + end else # post exists, versions diverged: skip skipped += 1 end end else - seeds = if type.column_names.include?('community_id') && !seed.include?('community_id') - # if model includes a community_id, create the seed for every community - Community.all.map { |c| seed.deep_symbolize_keys.merge(community_id: c.id) } - else - # otherwise, no need to worry, just create it - [seed] - end - - # Transform all _id relations into the actual rails objects to pass validations - seeds = seeds.map do |seed| - columns = type.column_names.select { |name| name.match(/^.*_id$/) } - new_seed = seed.deep_symbolize_keys - columns.each do |column| - begin - column_type_name = column.chomp('_id') - column_type = column_type_name.classify.constantize - new_seed = new_seed.except(column.to_sym) - .merge(column_type_name.to_sym => column_type.unscoped.find(seed[column.to_sym])) - rescue StandardError - # Either the type does not exist or the value specified as the id is not valid, ignore. - next - end - end - new_seed - end + new_created, new_skipped = create_objects(type, seed) + created += new_created + skipped += new_skipped - # Actually create the objects and count successes - objs = type.create seeds - skipped += objs.select { |o| o.errors.any? }.size - created += objs.select { |o| !o.errors.any? }.size + if type == CommunityUser + ensure_system_user_abilities + end end end unless Rails.env.test? - puts "#{type}: Created #{created}, #{updated > 0 ? "updated #{updated}, " : ''}skipped #{skipped}" + puts "#{type}: errored #{errored}, created #{created}, #{updated > 0 ? "updated #{updated}, " : ''}skipped #{skipped}" end rescue StandardError => e puts "Got error #{e}. Continuing..." diff --git a/db/seeds/abilities/edit_posts.html b/db/seeds/abilities/edit_posts.html index ecb1741a4..ff0fdff83 100644 --- a/db/seeds/abilities/edit_posts.html +++ b/db/seeds/abilities/edit_posts.html @@ -1,9 +1,9 @@

    What does this ability allow me to do?

    This ability allows you to unilaterally edit posts, as well as review suggested edits from other users.

    -

    Edits should be used to improve posts, including edits improving spelling, grammar, and formatting, as well as fixes for missing alt text, broken images and links, and other improvements. Edits should, however, respect the goal of the original poster.

    +

    Edits should be used to improve posts, including edits improving spelling, grammar, and formatting, as well as fixes for missing alt text, broken images and links, and other improvements. Edits should, however, preserve the meaning of the post.

    Edit post button

    How do I earn this ability?

    -

    To earn this ability, you need to have at least a 95% approval rate for your suggested edits, with a hard minimum of 30 approved suggested edits (these numbers may vary from site to site).

    \ No newline at end of file +

    To earn this ability, you need to have at least a 95% approval rate for your suggested edits, with a hard minimum of 30 approved suggested edits (these numbers may vary from community to community).

    diff --git a/db/seeds/abilities/everyone.html b/db/seeds/abilities/everyone.html index be547ccca..8f112ef81 100644 --- a/db/seeds/abilities/everyone.html +++ b/db/seeds/abilities/everyone.html @@ -1,9 +1,9 @@

    What does this ability allow me to do?

    -

    This ability allows you to posts 3 top-level posts (questions and articles) a day, and to post 20 answers a day.

    +

    This ability allows you to post 3 top-level posts (questions and articles) per day, and to post 20 answers per day.

    -

    This ability also allows you to raise 15 flags on posts a day.

    +

    This ability also allows you to raise 15 flags on posts per day.

    How do I earn this ability?

    -

    This ability is automatically granted to you when you create an account.

    \ No newline at end of file +

    This ability is automatically granted to you when you create an account.

    diff --git a/db/seeds/abilities/unrestricted.html b/db/seeds/abilities/unrestricted.html index cce29e0e1..d10050344 100644 --- a/db/seeds/abilities/unrestricted.html +++ b/db/seeds/abilities/unrestricted.html @@ -2,4 +2,6 @@

    What does this ability allow me to do?

    This ability allows you to post questions, articles, and answers without limit, as well as being able to vote and leave comments on any post. You can see guidance on asking questions and how to vote in the Help Center.

    How do I earn this ability?

    -

    To earn this ability, you need to have roughly 75% of your posts be positively received, with a minimum of 5 positively-recieved posts (these numbers may vary from site to site).

    +

    To earn this ability, you need to have roughly 75% of your posts be positively received, with a minimum of 5 positively-received posts (these numbers may vary from site to site).

    +

    If a site is in "new site" mode, all users start with this ability.

    + diff --git a/db/seeds/community_users.yml b/db/seeds/community_users.yml new file mode 100644 index 000000000..4935d657d --- /dev/null +++ b/db/seeds/community_users.yml @@ -0,0 +1,7 @@ +- deleted: false + is_admin: true + is_moderator: true + is_suspended: false + post_count: 0 + reputation: 1 + user_id: -1 \ No newline at end of file diff --git a/db/seeds/filters.yml b/db/seeds/filters.yml new file mode 100644 index 000000000..d23f91b9e --- /dev/null +++ b/db/seeds/filters.yml @@ -0,0 +1,14 @@ +- name: None + user_id: -1 + +- name: Positive + user_id: -1 + min_score: 0.5 + max_score: 1 + status: 'open' + +- name: Unanswered + user_id: -1 + min_answers: 0 + max_answers: 0 + status: 'open' \ No newline at end of file diff --git a/db/seeds/post_flag_types.yml b/db/seeds/post_flag_types.yml index 618f76e4e..5c3204aca 100644 --- a/db/seeds/post_flag_types.yml +++ b/db/seeds/post_flag_types.yml @@ -16,7 +16,7 @@ description: > This question is off-topic or cannot be reasonably answered in its current form and needs revision by its author. confidential: false - active: true + active: false post_type_id: <%= Question.post_type_id %> - name: is a duplicate @@ -24,4 +24,11 @@ This question has been asked before and has already been answered. It should be marked as a duplicate. confidential: false active: true - post_type_id: <%= Question.post_type_id %> \ No newline at end of file + post_type_id: <%= Question.post_type_id %> + +- name: generated by AI + description: > + This post appears to contain AI-generated content (such as ChatGPT) without [attribution](/help/referencing). + confidential: false + active: true + post_type_id: null diff --git a/db/seeds/post_history_types.yml b/db/seeds/post_history_types.yml index 9ed649d14..d12b6e28c 100644 --- a/db/seeds/post_history_types.yml +++ b/db/seeds/post_history_types.yml @@ -9,4 +9,5 @@ - name: attribution_notice_changed - name: imported_from_external_source - name: nominated_for_promotion -- name: promotion_removed \ No newline at end of file +- name: promotion_removed +- name: history_hidden \ No newline at end of file diff --git a/db/seeds/posts.yml b/db/seeds/posts.yml index 893015027..f34506126 100644 --- a/db/seeds/posts.yml +++ b/db/seeds/posts.yml @@ -38,6 +38,14 @@ help_category: About the Network community_id: ~ +- post_type_id: <%= PolicyDoc.post_type_id %> + title: Accessibility Statement + body_markdown: $FILE posts/accessibility-policy.html + body: $FILE posts/accessibility-policy.html + doc_slug: accessibility-policy + help_category: About the Network + community_id: ~ + - post_type_id: <%= HelpDoc.post_type_id %> title: FAQ body_markdown: $FILE posts/local_faq.html @@ -101,6 +109,13 @@ doc_slug: licenses help_category: Guidance +- post_type_id: <%= HelpDoc.post_type_id %> + title: Accessibility + body_markdown: $FILE posts/accessibility.html + body: $FILE posts/accessibility.html + doc_slug: accessibility + help_category: Guidance + - post_type_id: <%= HelpDoc.post_type_id %> title: Search options body_markdown: $FILE posts/search.html @@ -142,3 +157,11 @@ body: $FILE posts/deleted.html doc_slug: deleted help_category: Site Information + +- post_type_id: <%= HelpDoc.post_type_id %> + title: Advanced formatting help + body_markdown: $FILE posts/advanced-formatting.html + body: $FILE posts/advanced-formatting.html + doc_slug: advanced-formatting + help_category: Site Information + diff --git a/db/seeds/posts/accessibility-policy.html b/db/seeds/posts/accessibility-policy.html new file mode 100644 index 000000000..b3f9eebf3 --- /dev/null +++ b/db/seeds/posts/accessibility-policy.html @@ -0,0 +1,41 @@ +| URL slug | Category | +|:--------:|:--------:| +| accessibility-policy | About the Network | + +

    $EDIT-Organization-Name Accessibility Statement

    + + + +Sharing information and being part of a community are the two fundamental goals of the Codidact Project. Everyone deserves access to information and to be a part of a community, and with that in mind, the Codidact Foundation is committed to accessibility in all areas of the Codidact Project. + +

    Who is responsible for accessibility?

    + +Accessibility is the responsibility of everyone, from the people writing posts to the people working on the website. However, the chief person responsible for accessibility in the $EDIT-Organization-Name is $Edit-Accessibility-Officer. Questions about $EDIT-Organization-Name's accessibility can be directed to $EDIT-email. + +

    I'm encountering an accessibility problem with the QPixel software, what do I do?

    + +Please report any accessibility problems you have on Codidact Meta, using the [bug] and [accessibility] tags. That makes sure that the Codidact developers are aware of the problem, and they'll do their best to fix it as soon as reasonably possible with their available resources. + +

    Technical details: QPixel Accessibility Policy

    + +This Accessibility Policy outlines the technical details of the commitment to accessibility for sites running the QPixel software and sets expectations for what is in scope of that commitment. + +

    The $EDIT-Organization-Name Network

    + + + +The platform that the Network runs on, known as QPixel, should meet both the Web Content Accessibility Guidelines (WCAG) 2.2 and the Authoring Tool Accessibility Guidelines (ATAG) 2.0 standards at the AA conformance level. + +Since the platform is open-source and volunteer operated, setting strict timetables isn't realistic. However, new changes to the platform should be made with these standards in mind, including using Accessible Rich Internet Applications (WAI-ARIA) features. + +

    User-contributed content

    + +The $EDIT-Organization-Name encourages all people contributing posts, such as questions, answers, and articles, to make their posts accessible. This is done via system reminders, such as a warning when posting images without alt text, and via other users editing those posts to be more accessible. + +An in-depth guide to creating accessible posts is available in the help center. + +

    $EDIT other sites

    + + + +This document was last updated on [date]. diff --git a/db/seeds/posts/accessibility.html b/db/seeds/posts/accessibility.html new file mode 100644 index 000000000..d8d1b556e --- /dev/null +++ b/db/seeds/posts/accessibility.html @@ -0,0 +1,255 @@ +

    This short guide goes over several aspects of accessibility to keep in mind when writing or editing posts on the Codidact network. There are many different disabilities out there, and these tips cover accessibility for many of them, such as people using screen readers, keyboard users, colorblind users, and people with cognitive disabilities.

    +
    +

    Table of Contents

    + +
    +

    Formatting

    +

    Headings

    +

    Headings are used to separate sections of information. Each heading should give you a decent indication of what you can expect in that section. (As an example, the heading "headings" before this section is a good hint that this section is about headings.)

    +
      +
    • +

      Follow an intuitive header order.

      +

      Don't jump from an <h2> to an <h5>; follow a consistent, intuitive order for headings, where top-level sections have a higher-level header and sub-sections have a lower-level header. You shouldn't skip levels of headings (for instance, moving from an <h2> to an <h4> without an <h3> in between)
      +However, when you are ending a sub-section and moving back to a higher-level section, you can move from a low-level header (such as <h4>) back up to a higher-level header (such as <h2>) without including a mid-level header.

      +
    • +
    • +

      There should generally only be one top-level header (which can be formatted using <h1> or #) per-page.

      +

      Posts on the Codidact network have a top-level heading as the question or article title.
      +Whether or not using another top-level header is appropriate depends on the type of post you're writing, as well as on how the question and answers are structured. Posts covering a large number of topics or that are split up into multiple, entirely distinct sections sometimes might need to include more than one top-level heading, but consider carefully if it's actually necessary.

      +
    • +
    • +

      Only use headings for actual headings, and don't use headings for non-header content.

      +

      If something acts as a heading, it should be properly marked up as a heading, using either the appropriate Markdown or HTML tags. The opposite holds true as well; if something is not acting as a heading, it should not be formatted as a heading. If you want to emphasize something, don't use headings to do that.

      +
    • +
    +

    Screen readers and similar can jump from heading to heading (including describing what level of heading), so keep that in mind when choosing headings for your posts.

    +
    +

    Examples

    +

    A good heading order follows the logical layout of the page, without skipping over levels, consistently uses the same level headings for sections at the same level, and only uses headings for actual heading content.

    +
    // Example of a good heading order
    +<h1>Question title (automatically provided)<h1>
    +<h2>Answer section 1</h2>
    +<h3>Answer sub-section 1a</h3>
    +<h3>Answer sub-section 1b</h3>
    +<h2>Answer section 2</h2>
    +<h3>Answer sub-section 2a</h3>
    +<h3>Answer sub-section 2b</h3>
    +<h4>Answer sub-sub-section 2b.1</h4>
    +<h4>Answer sub-sub-section 2b.2</h4>
    +<h2>Answer section 3</h2>
    +
    +

    The heading order above shows an answer that doesn't use a top-level heading (<h1>), since there's already one on the page - the question title. It uses <h2> to differentiate its high-level sections. It then uses <h3> for the sub-sections, and when it's necessary to have a sub-sub-section, it uses <h4>.

    +

    A bad heading order would skip levels, use headings for non-heading content, or unnecessarily over-use top-level headings.

    +
    // Example of a bad heading order
    +<h1>Question title (automatically provided)</h1>
    +<h1>Answer summary (actual content)</h1>
    +<h3>Answer section 1</h3>
    +<h5>Answer sub-section 1a</h5>
    +<h4>Answer sub-section 1b</h4>
    +<h2>Answer section 2</h2>
    +<h4>Notes</h4>
    +<h1>Thank you for reading!</h1>
    +
    +

    This bad example uses <h1> twice in the answer, despite there already being a top-level heading as the question title. It uses an <h1> heading for the answer summary, using a heading for non-heading content. It then uses wildly inconsistent heading levels for its different sections and sub-sections, and skips levels of headings (such as moving directly from <h3> to <h5>).

    +
    +

    Emphasized text

    +

    Emphasized text, such as bold or italics, is good for calling attention to key words or sentences. However, if it's overused, it ends up defeating its own purpose and making a post more difficult to read instead of clearer.

    +
      +
    • +

      Bold text should be used sparingly.

      +

      Only use it to highlight words or sentences that actually need special attention called to them.

      +
    • +
    • +

      Italics should only be used when it is appropriate to use italics.

      +

      Italics are used for emphasizing stress on a certain word, italicizing the names of works, indicating words in foreign languages, and other standard uses of italics. Don't over-use italics on text that doesn't need to be italicized; remember that italics can make it harder to read the text for certain people, including some people with dyslexia.

      +
    • +
    • +

      Avoid bolding or italicizing entire paragraphs.

      +
    • +
    +

    Code markup

    +
      +
    • +

      Code markup should be used for code, including variable names and other code elements that may be found in non-code lines.

      +

      This allows for code highlighting to work, and makes it clear when a code element is being referred to.

      +
    • +
    • +

      Code markup should not be used for any non-code elements.

      +

      This includes using it for emphasis, for tables, or other non-code usage. Instead, use the dedicated formatting for those elements. Misusing code markup can cause issues for assistive technology such as screen readers.

      +
    • +
    +

    Text size

    +
      +
    • +

      Don't stack subscripts or superscripts to make your text tiny.

      +

      Using superscript or subscript once is enough, and only use it when necessary. Screen readers may not differentiate between sub- or superscript and regular text, so keep that in mind.

      +
    • +
    +

    Codidact has integrated footnotes available, so you should avoid using sub- and superscript for footnotes; use the dedicated Markdown instead.

    +

    Tables

    +
      +
    • +

      If possible, avoid putting ambiguous data into tables - i.e., having data that you can't tell which column of the table it would be associated with without checking, such as having two columns containing plain numbers.

      +
    • +
    • +

      Avoid blank header rows in tables, and don't use table formatting for data that doesn't actually belong in a table.

      +
    • +
    +

    Images

    +

    Alternative (alt) text

    +
      +
    • +

      Whenever you include an image in a post, you should include alternative text (commonly called "alt text") that serves the same purpose as the image.

      +

      This replaces the default text of "Image_alt_text". This is used by screen readers, search engines, and when images can't be displayed (such as images being blocked in certain countries or by school/business networks).

      +
    • +
    • +

      The alt text should be short, succinct, and serve the exact same purpose as the image - it shouldn't contain more or less information than the image itself.

      +
    • +
    +

    As a general way of making sure your alt text is appropriate, consider if the information present in the post would change at all if the image was replaced entirely with the alt text. If the information would stay the same, you're good to go.

    +

    Decorative images

    +
      +
    • A decorative image, which serves no purpose other than visual, should have its alt text be entirely blank.
    • +
    +

    Note that this is blank, not missing. From a coding perspective, this means setting its alt attribute to ="", not leaving out the alt attribute.

    +

    In general, you should avoid including images that don't serve any specific purpose or that are just decorative in your post.
    +If you find yourself including a decorative image, make sure that it's not formatted as a link, leaving only the embedded image, and to set the alt text to be blank.

    +

    Stable resource (local images)

    +

    In order to avoid the possibility of dead images, and ensure that that post remains stable for as long as possible, you should avoid using an external image hosting service. Instead, use the built-in image uploader for the Codidact Network. This means that the image is stored on our own servers instead of depending on someone else.

    +
    +

    Examples

    +

    Let's take the following snippet of a post for our example:

    +
    +

    When you go to edit a post, you now have the option to check the "redact" button:

    +

    Checkbox to select "redact": Redact original content by hiding the previous versions from history?
    +(Source: "What should I do when I come across PII in a post?" by Mithical on Codidact Meta, licensed under CC BY-SA-NC 4.0)

    +
    +

    In this example, the image is being used to illustrate the new button and what it does. That information needs to be presented in the alt text as well, which the current alt text does:

    +
    ![Checkbox to select "redact": Redact original content by hiding the previous versions from history?](https://meta.codidact.com/uploads/qwvy25mvolpdjabhknuujzjncbpm)
    +
    +

    This is short and to the point. It tells anybody who can't see the image what information is shown with the checkbox, which is why the screenshot was included.

    +

    A bad example would be leaving out the alt text, having overly long alt text, or relying on an external image hosting service:

    +
    ![](https://i.imgur.com/XtjJxjF.jpg) 
    +
    +![A screenshot of two different sections that you see when editing a post. One says "Edit Comment" with a blank text box, where you can input an edit comment that will show up in the revision history for that post. The other says "Redact", and it has text with an unchecked checkbox. It has the text "Redact original content by hiding the previous revisions from history?" Below those are two buttons. One is blue and says "Save Post in Q&A". The other is gray and says "Cancel".](https://meta.codidact.com/uploads/qwvy25mvolpdjabhknuujzjncbpm)
    +
    +

    In the first bad example, there is both no alt text and it relies on an external image service. There is no information presented to anyone who can't see the image, and there's the risk that the image will go dead even for people who can see it.
    +In the second bad example, the alt text is too long. It has information that's not present in the image itself, such as information about the edit comment appearing in the revision history, and describes information that's not relevant to the purpose of the screenshot - which is simply to show what you're presented with when you go to redact a post.

    +
    +

    Don't rely solely on color

    +

    When your image uses colors to indicate a difference between things - such as on a chart or graph - you should also use a different method of differentiating, such as an icon or different shape. Also avoid using colors that are known to be a problem for colorblind users (such as red/green).

    +

    Contrast

    +

    Avoid colors that are too close to each other, especially for text on a background color. As a simple way of testing, take a glance at the image in sunlight - can you still make it out?

    +

    Animations

    +

    We don't currently support any way to disable or pause animations in posts, so avoid using animations where possible. In particular, make sure to avoid flashing content (especially anything flashing more than three times a second - don't do that!). Flashing content can cause seizures, and looping animations can be distracting for everyone, but especially for people with some cognitive disabilities.

    +

    Images of text

    +
      +
    • +

      Avoid images of text.

      +

      Images of text can't have the text selected, be read by screen readers, indexed by search engines, have the text adjust in a responsive design, or have the font changed. This includes images of code; instead, put the actual code in your post and format it using the dedicated code formatting Markdown.

      +
    • +
    +

    Quoting

    +
      +
    • +

      If you are quoting from somewhere, don't provide an image of the text; use text, formatted as a blockquote (which can be done by putting a > at the beginning of a paragraph), and cite your source.

      +

      This applies to both online and offline resources, such as Wikipedia or a physical book.

      +
    • +
    • +

      Do not use code formatting for quotes.

      +
    • +
    + + +
      +
    • +

      Avoid link text such as "Here" or "Read more".

      +

      The link should explain its purpose through the text itself. Remember that screen readers and similar tools can jump to specific links, but if they're named something like "this", navigating to the correct link is much harder.
      +Don't go too far in the other direction, though; there's no need to make an entire sentence a link as long as the link text is descriptive and distinct.

      +
    • +
    • +

      In general, link text should be unique - don't use the same link text twice in one post if those links go to different places.

      +
    • +
    +

    Stable resource (Web Archive)

    +

    While not required by any standard that I'm aware of, I'd encourage you to take steps to make sure that any resource you link to remains stable by archiving it in the Web Archive when you link to it. (This is similar to what Wikipedia does; sources used in articles are almost always archived so that a backup exists.)

    +

    Making your posts understandable

    +

    Vocabulary

    +
      +
    • +

      In general, try to keep your vocabulary simple.

      +

      This doesn't mean avoiding all technical terms, or not using the correct terms for things, but don't use jargon or fancy words when it's not necessary. This makes it easier for people who don't speak English as their first language, or people with cognitive disabilities, to understand your post.

      +
    • +
    +

    This should not come at the expense of precision or accuracy, though; keep your audience in mind. If you are writing for a highly specialized or technical audience, you shouldn't necessary shy away from using the relevant terminology; but if you're writing for everyone, using relatively simple terms is often a good idea.

    +

    Define acronyms and specialized terms

    +
      +
    • +

      The first time you use an acronym, you should fully spell out what you're referring to.

      +

      A common example found on Codidact (CD) is just that - the acronym CD. However, that acronym can also refer to the terminal command cd, or the physical medium of Compact Discs, as well as other meanings. Once you've defined what CD stands for in your specific post, you can continue to use the acronym.

      +
    • +
    +

    This applies to specialized terms, jargon, and words in other languages as well. The first time you use a specialized term, define what it means or translate it.

    +

    Paragraph and sentence breaks

    +
      +
    • +

      Avoid walls of text.

      +

      Make sure to break up your posts into sections, paragraphs, and sentences. When something runs on for too long, or is too dense, it can be very hard for people to get through, especially people who don't speak the language well or people with certain cognitive disabilities.

      +
    • +
    +
    +

    And those are the top tips for making your post accessible! Remember that accessibility is an ongoing process, so don't feel too bad if not all of your posts meet these guidelines. They can always be edited later, and the important thing is to keep accessibility in mind as you go forwards writing and editing posts.

    + diff --git a/db/seeds/posts/advanced-formatting.html b/db/seeds/posts/advanced-formatting.html new file mode 100644 index 000000000..6373aa426 --- /dev/null +++ b/db/seeds/posts/advanced-formatting.html @@ -0,0 +1,86 @@ +

    Posts on Codidact, including questions, answers, articles, tag wikis, help pages, and user + profiles, support the use of the following HTML tags:

    +
      +
    • a
    • +
    • p
    • +
    • span
    • +
    • b
    • +
    • i
    • +
    • em
    • +
    • s
    • +
    • strong
    • +
    • hr
    • +
    • h1, h2, h3, h4, h5, + h6
    • +
    • blockquote
    • +
    • img
    • +
    • strike
    • +
    • del, ins
    • +
    • code
    • +
    • pre
    • +
    • br
    • +
    • ul, ol, li
    • +
    • sup, sub
    • +
    • section
    • +
    • details, summary
    • +
    • table, thead, tbody, tr, + th, td
    • +
    +

    These tags may have the following attributes:

    +
      +
    • id
    • +
    • class
    • +
    • href
    • +
    • title
    • +
    • src
    • +
    • height
    • +
    • width
    • +
    • alt
    • +
    • dir
    • +
    • lang
    • +
    • start
    • +
    • rowspan
    • +
    • colspan
    • +
    +

    Shorter text forms, such as comments, support the following HTML tags:

    +
      +
    • a
    • +
    • b
    • +
    • i
    • +
    • em
    • +
    • strong
    • +
    • strike
    • +
    • del
    • +
    • code
    • +
    +

    These tags may have the following attributes:

    +
      +
    • href
    • +
    • title
    • +
    +
    +

    HTML tags that do not appear on this list will be stripped out and not displayed if they are + included in posts or comments. The source code for supported tags in posts can be found in + posts_helper.rb, and for comments in + comments_helper.rb.

    +
    +

    Much of the content of this page came from + this answer by luap42.

    diff --git a/db/seeds/posts/category-permissions.html b/db/seeds/posts/category-permissions.html index 922970152..8bc18a9b4 100644 --- a/db/seeds/posts/category-permissions.html +++ b/db/seeds/posts/category-permissions.html @@ -6,7 +6,7 @@

    Please note that trust levels are hierarchical, so that anyone who has one trust level has also all lower trust levels by default.

    1. Everyone

    -

    As the name says, **absolutely everyone** has this trust level. This includes anonymous (unregistered) and new users.

    +

    As the name says, absolutely everyone has this trust level. This includes anonymous (unregistered) and new users.

    Most write actions will require a registered user account, though, even if the trust level "everyone" has been selected.

    2. Anyone with a user account

    @@ -19,7 +19,7 @@

    4. Veteran users

    For the sake of this trust level, every user with either the Edit Posts or the Vote on Hold is considered "veteran".

    5. Moderators only

    -

    Moderators, Adminis and the global variants thereof are the users that have this trust level.

    +

    Moderators, Admins, and the global variants thereof are the users that have this trust level.

    6. Staff only

    -

    This trust level is only reached by users with the staff mark.

    \ No newline at end of file +

    This trust level is only reached by users with the staff mark.

    diff --git a/db/seeds/posts/formatting.html b/db/seeds/posts/formatting.html index e9ad8a39d..4e4e966b4 100644 --- a/db/seeds/posts/formatting.html +++ b/db/seeds/posts/formatting.html @@ -64,3 +64,26 @@
    Heading 6

    Footnotes

    To include a footnote in your post, you can use the syntax [^1]. In your main text, include Text[^1] and more text, and at the bottom (where you want to include your footnote), place a line resembling [^1]: footnote text.

    + +

    Hidden Sections

    +

    CommonMark does not support collapsible sections (sometimes called "spoiler blocks"), but you can use the HTML details and summary tags, like this:

    +
    <details>
    +    <summary>Spoiler! Click here to reveal</summary>
    +    Secret details
    +</details>
    +

    Which renders like this:

    +
    +Spoiler! Click here to reveal +Secret details +
    +

    If the details text uses any Markdown, you must add a blank line between the summary and the text:

    +
    <details>
    +    <summary>Spoiler! Click here to reveal</summary>
    +
    +    *Secret* details 
    +</details>
    +

    Renders:

    +
    +Spoiler! Click here to reveal +Secret details +
    diff --git a/db/seeds/posts/global_faq.html b/db/seeds/posts/global_faq.html index 3830f2a4f..6fb50d068 100644 --- a/db/seeds/posts/global_faq.html +++ b/db/seeds/posts/global_faq.html @@ -1,4 +1,5 @@ - + +

    How do I ask a question?

    You will need to register an account. Make sure you are logged into the appropriate topic-related site for your question and click the "Ask Question" button at the top of the page. A template should appear with helpful guidelines on how to ask a quality question. Check out the local FAQ page for important information about each community.

    @@ -7,7 +8,9 @@

    How do I ask a question?

    What license do my posts fall under?

    Most posts fall under CC BY-SA 4.0. Some communities or contributors may use a license that more closely aligns with the content posted or their ideological position. Check a community's local FAQ page and Terms of Service for site-specific details.

    + +

    What is Codidact?

    -

    This community is built using the open-source QPixel software provided by The Codidact Foundation. The software is free, open-source, and customizable.

    +

    This community is built with the QPixel engine provided by The Codidact Foundation. The software is free, open-source, and highly customizable.

    diff --git a/db/seeds/posts/scoring.html b/db/seeds/posts/scoring.html index 20aeb6e4c..ed1795d5c 100644 --- a/db/seeds/posts/scoring.html +++ b/db/seeds/posts/scoring.html @@ -4,7 +4,7 @@

    Calculations Used

    The actual score of a particular post is calculated as

    (upvote_count + z^2/2)/(upvote_count + downvote_count + z^2)

    This uses a modified form of a Wilson Score, assuming a binomial distribution. By default the weighting constant z is two, but each community can change this to another value as they see fit. Several charts and graphs showing the default weighting distribution are shown below.

    -table of scores with the values for all upvote and downvote combinations from one to ten +table of scores with the values for all upvote and downvote combinations from one to ten lines in a cartesian coordinate plane sloping from upper left to lower right and converging as z increases

    Diagram showing the distribution in scores where upvotes less downvotes = 3 for different values of z

    a 3D diagram showing the score distribution for upvotes and downvotes diff --git a/db/seeds/posts/search.html b/db/seeds/posts/search.html index 8215a42c6..e21de8a20 100644 --- a/db/seeds/posts/search.html +++ b/db/seeds/posts/search.html @@ -17,20 +17,39 @@

    Filtering by score and age

    It's possible to filter your search to only include results that have been posted within a certain timeframe, or match certain score requirements.

    +

    You can use >, >=, <, and <= with these options to search for ranges. By default, a value without an operator looks for an exact match. For example, upvotes:4 searches for exactly 4; upvotes:>=4 searches for at least 4.

    • filtering by post score

      -

      Codidact uses Wilson scoring to help in sorting posts. (To learn more about how this works, see /help/scoring for a detailed explanation.) Every post has a score between 0.0 and 1.0. To use this in search, you can use score:0.5 to filter your search to only include posts with a score of at least 0.5.

      +

      Codidact uses Wilson scoring to help in sorting posts. (To learn more about how this works, see /help/scoring for a detailed explanation.) Every post has a score between 0.0 and 1.0. To use this in search, you can use score:>=0.5 to filter your search to only include posts with a score of at least 0.5.

    • filtering by votes

      -

      If you want to filter by the raw votes that a post has, you can use votes:5 to find posts where the net votes (upvotes minus downvotes) of a post equals 5 or higher.

      +

      If you want to filter by the raw votes that a post has, you can use votes:5 to find posts where the net votes (upvotes minus downvotes) of a post equals 5 or votes:>=5 for votes of 5 or more.

    • filtering by upvotes and downvotes

      -

      If you search for upvotes:4, Codidact will find posts that have received at least 4 upvotes, irrespective of how many downvotes the post has. Likewise, if you search for downvotes:4, Codidact will find posts that have received at least 4 downvotes without taking upvotes into consideration. You can also use a less than (<) symbol to filter for posts that have received no more than a certain number of votes (for instance, downvotes:<4 will find posts that have received less than four downvotes total).

      +

      If you search for upvotes:4, Codidact will find posts that have received exactly 4 upvotes, irrespective of how many downvotes the post has. Likewise, if you search for downvotes:4, Codidact will find posts that have received exactly 4 downvotes without taking upvotes into consideration. downvotes:<4 will find posts that have received fewer than four downvotes total).

      +
    • +
    • filtering by number of answers

      +

      If you want to find posts with n answers, use answers:n. This is particularly helpful to find unanswered questions: answers:0. answers:<5 shows posts with fewer than five answers.

    • filtering by creation date

      If you want to only find posts that have been written within a certain timeframe, you can use the created: search operator. created:<1w will find all posts created less than a week ago, where created:>1w will find only posts older than a week. You can use m for minute, h for hour, d for day, w for week, mo for month, and y for year.

    +

    Filtering by tag, user, category, or post type

    +
      +
    • filtering by tag

      +

      To filter for all posts with the tag snake, use the tag:snake operator. To exclude all posts with the tag oil, use -tag:oil.

      +
    • +
    • filtering by user

      +

      If you want to search for posts written by a particular user you will need to know their unique user number for the community. This can be found by looking at their profile URL. You can then use user:xxxx where xxxx is the unique user number you are interested in.

      +
    • +
    • filtering by category

      +

      To filter by category, you will need to know the unique numeric ID for that category. This can be found by looking at the URL shown when you click to view all posts in a particular category. Use the formatting category:xxxx to apply this filter.

      +
    • +
    • filtering by post type

      +

      If you want to restrict your search to a particular post type, type post_type: into the search bar, and a dropdown menu should show the available post types to search. Note that not all communities may use all post types.

      +
    • +

    Advanced

    • wildcard

      diff --git a/db/seeds/site_settings.yml b/db/seeds/site_settings.yml index cb91ffc3b..48a1ca475 100644 --- a/db/seeds/site_settings.yml +++ b/db/seeds/site_settings.yml @@ -150,14 +150,21 @@ description: > The maximum characters a single tag name may contain. Default is 35 for compatibility with Stack Exchange; going lower may introduce validation issues with content imported from SE. - + - name: MaxTitleLength value: 150 value_type: integer category: SiteDetails description: > The maximum characters a post title may contain. Default is 150 for compatibility with Stack Exchange; going lower - may introduce validation issues with content imported from SE. + may introduce validation issues with content imported from SE. + +- name: MaxEditCommentLength + value: 255 + value_type: integer + category: SiteDetails + description: > + The maximum number of characters an edit comment may contain. Defaults to 255. - name: MaxUploadSize value: 2MB @@ -178,10 +185,122 @@ - name: InterestingSubscriptionScoreThreshold value: 1 value_type: integer - category: EmailSubscriptions + category: Email description: > The minimum score a question must have to qualify for selection for the Interesting email subscription. +- name: SubscriptionSenderName + value: Codidact Subscriptions + value_type: string + category: Email + description: > + The name of the sender of subscription emails. + +- name: SubscriptionSenderEmail + value: subscriptions@codidact.com + value_type: string + category: Email + description: > + The address to send subscription emails from (can be a fake address). + Make sure your server is allowed to send email from this address, or your mails will not be received. + +- name: ModeratorDistributionListSenderName + value: Codidact Admins + value_type: string + category: Email + description: > + The name of the sender of the moderator distribution list. + +- name: ModeratorDistributionListSenderEmail + value: moderators-noreply@codidact.com + value_type: string + category: Email + description: > + The address to send moderator distribution list emails from (can be a fake address). + Make sure your server is allowed to send email from this address, or your mails will not be received. + +- name: NoReplySenderName + value: Codidact + value_type: string + category: Email + description: > + The name of the sender of no-reply emails. + +- name: NoReplySenderEmail + value: noreply@codidact.com + value_type: string + category: Email + 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. + Make sure your server is allowed to send email from this address, or your mails will not be received. + +- name: DonationSupportReceiverName + value: Codidact Support + value_type: string + category: Email + description: > + The name of the donation support email address. + +- name: DonationSupportReceiverEmail + value: support@codidact.com + value_type: string + category: Email + description: > + The (real) address to receive donation support emails on. Used for users who want to reply to donation emails. + +- name: DonationSenderName + value: Codidact Donations + value_type: string + category: Email + description: > + The name of the sender of donation related emails. + +- name: DonationSenderEmail + value: donations-support@codidact.com + value_type: string + category: Email + description: > + The address to send donation related emails from (can be a fake address). + Make sure your server is allowed to send email from this address, or your mails will not be received. + +- name: SupportReceiverName + value: Codidact Support + value_type: string + category: Email + description: > + The name of the donation support email address. + +- name: SupportReceiverEmail + value: info@codidact.org + value_type: string + category: Email + description: > + The (real) address to receive support emails on. + +- name: AllUsersSenderName + value: Codidact Team + value_type: string + category: Email + description: > + The name of the sender of emails sent to all users of the network via the admin tools. + +- name: AllUsersSenderEmail + value: allusers-noreply@codidact.org + value_type: string + category: Email + description: > + The address to use as sender for emails sent to all users of the network via the admin tools (can be a fake address). + Make sure your server is allowed to send email from this address, or your mails will not be received. + +- name: AllUsersReplyToEmail + value: info@codidact.org + value_type: string + category: Email + description: > + The (real) address to use as reply-to for emails sent to all users of the network via the admin tools. + + - name: LotteryAgeDeprecationSpeed value: 0.002 value_type: float @@ -515,3 +634,86 @@ description: > The content of a post is shown in short in lists (e.g. category post overview or in search). This setting controls how many characters of a post are shown. + +- name: NetworkPitch + value: $FILE site_settings/widgets_network_pitch.html + value_type: text + community_id: ~ + category: Widgets + description: > + Sidebar text to promote the rest of the network. + +- name: SsoSignIn + value: false + value_type: boolean + community_id: ~ + category: SignInAndSignUp + description: > + Whether to enable SSO Sign in. If enabled, this replaces normal Sign In and Sign Up unless if Mixed Sign In is enabled. + NOTE: This requires a SAML provider to be configured and set up. + +- name: MixedSignIn + value: false + value_type: boolean + community_id: ~ + category: SignInAndSignUp + description: > + This setting only has an effect when SSO Sign In is enabled. Enables mixed sign in: both signing in and signing up as well as SSO sign in are enabled. + If disabled, only one sign in method is enabled. + +- name: Enable2FAForSsoUsers + value: false + value_type: boolean + community_id: ~ + category: SignInAndSignUp + description: > + This setting only has an effect when SSO Sign In is enabled. Enables 2FA options (and enforces 2FA for global mods and admins if configured) also for SSO users. + When the authentication is outsourced to a Single Sign-On provider (which may already require 2FA), it often does not make sense to have an additional 2FA check on top of that. + +- name: AllowSsoDisconnect + value: false + value_type: boolean + community_id: ~ + category: SignInAndSignUp + description: > + This setting only has an effect when SSO Sign In and Mixed Sign In are enabled. Allows users to disconnect their account from SSO and switch over to normal login. + +- name: LoadStripeEverywhere + value: false + value_type: boolean + category: Integrations + description: > + Load Stripe JS API on all pages instead of just donation pages. May improve security and fraud detection. + +- name: RestrictedAccess + value: false + value_type: boolean + category: SiteDetails + description: > + Whether the content of this community should be visible only to users who are signed in. + +- name: RestrictedAccessFrontPageText + value: > +

      Welcome to our community!

      +

      Please sign in to continue

      + value_type: text + category: SiteDetails + description: > + This setting only has an effect when RestrictedAccess is enabled. + This is the text that will be displayed on the front page for users who are not signed in. Markdown allowed. + +- name: RestrictedAccessHelpPagesPublic + value: true + value_type: boolean + category: SiteDetails + description: > + This setting only has an effect when RestrictedAccess is enabled. + Whether the help pages are publicly accessible. + +- name: RestrictedAccessPolicyPagesPublic + value: true + value_type: boolean + category: SiteDetails + description: > + This setting only has an effect when RestrictedAccess is enabled. + Whether the policy pages are publicly accessible (Terms of Service, Privacy Policy, etc.). diff --git a/db/seeds/site_settings/widgets_network_pitch.html b/db/seeds/site_settings/widgets_network_pitch.html new file mode 100644 index 000000000..4038bfe1f --- /dev/null +++ b/db/seeds/site_settings/widgets_network_pitch.html @@ -0,0 +1 @@ +

      This community is part of a network of communities — take a look!

      diff --git a/db/seeds/tag_sets.yml b/db/seeds/tag_sets.yml index d675cb51a..8320d8bf5 100644 --- a/db/seeds/tag_sets.yml +++ b/db/seeds/tag_sets.yml @@ -1,5 +1,2 @@ - name: Main - name: Meta -- name: Tour - id: -1 - community_id: 1 \ No newline at end of file diff --git a/db/seeds/warning_templates/off-topic.md b/db/seeds/warning_templates/off-topic.md index f682b8589..f4f205c5f 100644 --- a/db/seeds/warning_templates/off-topic.md +++ b/db/seeds/warning_templates/off-topic.md @@ -3,7 +3,9 @@ We've noticed that you've written a number of posts about topics that are beyond You can find out about what's on-topic and what's off-topic on $SiteName in the [help center](/help/faq). This is just a gentle reminder that we expect posts on this site to stay focused on the topic on-hand. -We have a [Network of communities](https://codidact.com/) that you are free to use; you may find one of our other communities more suitable to some of your posts. If you would like to talk about the possibility of creating a site for a subject not currently covered, please write a post on [meta](https://meta.codidact.com/categories/10) with your request. +We have a [Network of communities](/dashboard) that you are free to use; you may find one of our other communities more suitable to some of your posts. +If you would like to explore creating a site for a subject not currently covered, see [the Proposals process on codidact.com](https://proposals.codidact.com/help/proposals). + Additionally, we have a $ChatLink available for more free-form discussion. While we appreciate your continued contributions within the scope of this site, we do ask that you make sure that the topic of your posts remain in scope. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 201795be7..556c4b818 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ -version: "3.8" services: db: + restart: on-failure:3 build: context: "." dockerfile: docker/Dockerfile.db @@ -8,20 +8,31 @@ services: - ./docker/mysql:/var/lib/mysql env_file: - ${ENV_FILE_LOCATION} - command: mysqld --default-authentication-plugin=mysql_native_password --skip-mysqlx + command: mysqld --mysql-native-password=on --skip-mysqlx cap_add: - SYS_NICE + healthcheck: + test: mysqladmin ping -h 127.0.0.1 -u $$MYSQL_USER --password=$$MYSQL_PASSWORD + start_period: 5s + interval: 5s + timeout: 5s + retries: 12 + uwsgi: - restart: always + restart: on-failure:3 build: context: "." - dockerfile: docker/Dockerfile + dockerfile: ${CLIENT_DOCKERFILE} depends_on: - - db + db: + condition: service_healthy + redis: + condition: service_healthy environment: - COMMUNITY_NAME=${COMMUNITY_NAME} - RAILS_ENV=${RAILS_ENV} + - MAILER_PROTOCOL=${MAILER_PROTOCOL} - CONFIRMABLE_ALLOWED_ACCESS_DAYS=${CONFIRMABLE_ALLOWED_ACCESS_DAYS} - LOCAL_DEV_PORT=${LOCAL_DEV_PORT} env_file: @@ -37,7 +48,7 @@ services: - db redis: - restart: always + restart: on-failure:3 image: redis:latest - depends_on: - - db + healthcheck: + test: ["CMD", "redis-cli","ping"] diff --git a/docker/Dockerfile b/docker/Dockerfile index 06fe360ac..8944ffb63 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,41 +1,53 @@ -FROM ruby:2.7.6 +FROM ruby:3.1.2-bullseye AS ruby +FROM node:12.18.3-slim AS node -# docker build -f docker/Dockerfile -t qpixel_uwsgi . +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 gcc && \ - apt-get install -y make && \ - apt-get install -y \ - default-libmysqlclient-dev \ - autoconf \ - bison \ - build-essential \ - libssl-dev \ - libyaml-dev \ - libreadline-dev \ - zlib1g-dev \ - libncurses5-dev \ - libffi-dev \ - libgdbm-dev && \ - apt-get install -y default-mysql-server - -# Install nodejs and imagemagick -WORKDIR /opt -RUN wget https://nodejs.org/dist/v12.18.3/node-v12.18.3-linux-x64.tar.xz && \ - tar xf node-v12.18.3-linux-x64.tar.xz && \ - wget https://imagemagick.org/archive/binaries/magick && \ - chmod +x magick && \ - mv magick /usr/local/bin/magick + 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=/opt/node-v12.18.3-linux-x64/bin ENV PATH=$NODEJS_HOME:$PATH -# Add core code to container WORKDIR /code -COPY . /code -RUN gem install bundler && \ - bundle install EXPOSE 80 443 3000 ENTRYPOINT ["/bin/bash"] diff --git a/docker/Dockerfile.arm b/docker/Dockerfile.arm new file mode 100644 index 000000000..b6790b416 --- /dev/null +++ b/docker/Dockerfile.arm @@ -0,0 +1,55 @@ +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.db b/docker/Dockerfile.db index bf43b19df..41875ec29 100644 --- a/docker/Dockerfile.db +++ b/docker/Dockerfile.db @@ -1,4 +1,4 @@ -FROM mysql +FROM mysql:8.4.2 # docker build -t qpixel_db -f docker/Dockerfile.db . diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev new file mode 100644 index 000000000..994452878 --- /dev/null +++ b/docker/Dockerfile.dev @@ -0,0 +1,60 @@ +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 + +RUN ls -1 / +RUN [ ! -f "/db-created" ] && echo 1 || echo 2 + +RUN /code/docker/entrypoint.sh dev + +# ensures continued running of the container +CMD sleep 7d diff --git a/docker/README.md b/docker/README.md index f7e1a33ba..18e1af18c 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,13 +1,22 @@ # Docker Installation -A [docker-compose.yml](../docker-compose.yml) file is provided for deployment with Docker compose, if you choose. +A [docker-compose.yml](../docker-compose.yml) file is provided for deployment with Docker Compose. If you want to use a custom one, simply override the `COMPOSE_FILE` predefined environment variable in the `.env` file in project root ([local-setup.sh](./local-setup.sh) script creates one automatically from [compose-env](./compose-env). By default, `COMPOSE_FILE` is set to [docker-compose.yml](../docker-compose.yml)). -To use docker compose, you need to install the docker-compose-plugin. For a system like debian or ubuntu, you can use the following command. +To use Docker Compose, you need to install the docker-compose-plugin. +You can check if it's already installed via the following command: + +```bash +sudo docker compose version +``` + +If the version is 2.x or higher, you are all set. If not, you should install the plugin. On a Debian-based Linux distro, you can run the following: ```bash sudo apt-get install docker-compose-plugin ``` +For Mac OS, you can install Docker Desktop by downloading it from the Docker website. After starting the application, the docker compose command becomes available in your terminal. + Depending on your setup, you may need to prefix every docker command with sudo. ## 1. Setup and Secrets @@ -24,7 +33,14 @@ chmod +x docker/local-setup.sh docker/local-setup.sh ``` -Editing the `./.env` file will modify the corresponding variables used in the docker-compose.yml file but **NOT** the environment variables in the container. Editing the `./docker/env` file will change environment variables only in the running container. +Editing the `./.env` file will modify the corresponding variables used in the docker-compose.yml file but **NOT** the environment variables in the container. +Editing the `./docker/env` file will change environment variables only in the running container. + +### Custom build config + +Our Docker setup supports custom build configurations for the uwsgi contianer via the `CLIENT_DOCKERFILE` environment variable (see [compose-env](/docker/compose-env)). The default is `docker/Dockerfile`, which points to a preconfigured [production-like setup](/docker/Dockerfile). For developers who need more control over their setup, we also provide a [configuration](/docker/Dockerfile.dev) that is tailored for local development. + +To use a custom build config, change the `CLIENT_DOCKERFILE` variable in the .env file that is automatically created by [local-setup.sh](/docker/local-setup.sh) in the project root. ## 2. Database File Ensure `config/database.yml` has the username and password as defined in [docker/env](docker/env) file. The `config/database.yml` should already be gitignored. @@ -49,30 +65,30 @@ docker compose build db docker compose build redis ``` +NOTE: If you get an error like "Cannot connect to the Docker daemon at ...", you need to ensure you start docker. Depending on your system, this can be done with `sudo service docker start` (Ubuntu) or by opening the Docker Desktop application and waiting for it to start (Mac OS). + ## 4. Start Containers -Then start your containers! +Then start your containers: ```bash -docker compose up # append -d if you want to detach the processes, although it can be useful to see output into the terminal -Creating qpixel_redis_1 ... done -Creating qpixel_db_1 ... done -Creating qpixel_uwsgi_1 ... done +docker compose up # append -d (--detach) if you don't want to see output in the terminal ``` -The uwsgi container has a sleep command for 15 seconds to give the database a chance to start, -so don't expect to see output right away. After about 20 seconds, check to make sure the server is running (and verify port 3000, note that you can change this mapping in the `.env` file) +After about 20 seconds, check to make sure the server is running (and verify port 3000, note that you can change this mapping in the `.env` file) ``` -uwsgi_1 | => Booting Puma -uwsgi_1 | => Rails 5.2.4.3 application starting in development -uwsgi_1 | => Run `rails server -h` for more startup options -uwsgi_1 | Puma starting in single mode... -uwsgi_1 | * Version 3.12.6 (ruby 2.6.5-p114), codename: Llamas in Pajamas -uwsgi_1 | * Min threads: 0, max threads: 16 -uwsgi_1 | * Environment: development -uwsgi_1 | * Listening on tcp://localhost:3000 -uwsgi_1 | Use Ctrl-C to stop +qpixel_uwsgi_1 | => Booting Puma +qpixel_uwsgi_1 | => Rails 7.0.4 application starting in development +qpixel_uwsgi_1 | => Run `rails server -h` for more startup options +qpixel_uwsgi_1 | Puma starting in single mode... +qpixel_uwsgi_1 | * Puma version: 5.6.5 (ruby 2.7.6-p219) ("Birdie's Version") +qpixel_uwsgi_1 | * Min threads: 5 +qpixel_uwsgi_1 | * Max threads: 5 +qpixel_uwsgi_1 | * Environment: development +qpixel_uwsgi_1 | * PID: 49 +qpixel_uwsgi_1 | * Listening on http://0.0.0.0:3000 +qpixel_uwsgi_1 | Use Ctrl-C to stop ``` You should then be able to open your browser to [http://localhost:3000](http://localhost:3000) @@ -82,6 +98,22 @@ and see the interface. You can then click "Sign in" to login with what you defined for `$COMMUNITY_ADMIN_EMAIL` and `$COMMUNITY_ADMIN_PASSWORD`. Importantly, your password must be 6 characters or longer, otherwise the user won't be created. +### Custom build configs + +If you are using a custom build config that doesn't automatically start Rails or our [config for local development](/docker/Dockerfile.dev), you will also have to manually start the server. + +After the containers have started, connect to the uwsgi container (if you are using Docker Desktop, you can connect directly from the application): + +```bash +docker exec -it qpixel-uwsgi-1 bash +``` + +And run the following command to start Rails (starting the server is intentionally disabled to allow for live debugging): + +```bash +rails server -b 0.0.0.0 +``` + ## 5. Login Once you are logged in, you should see your icon in the top right: diff --git a/docker/compose-env b/docker/compose-env index 162e3169b..5374e1ad4 100644 --- a/docker/compose-env +++ b/docker/compose-env @@ -1,5 +1,8 @@ +COMPOSE_FILE=./docker-compose.yml LOCAL_DEV_PORT=3000 COMMUNITY_NAME=Dev Community +MAILER_PROTOCOL=https RAILS_ENV=development CONFIRMABLE_ALLOWED_ACCESS_DAYS=2 ENV_FILE_LOCATION=./docker/env +CLIENT_DOCKERFILE=docker/Dockerfile diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh old mode 100644 new mode 100755 index 145b478c6..b6fbc730e --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,8 +1,5 @@ #!/bin/bash -# Give database chance to finish creation -sleep 15 - # If not created yet if [ ! -f "/db-created" ]; then rails db:create @@ -18,5 +15,7 @@ fi # If this isn't done again, there is a 500 error on the first page about posts rails db:seed -# defaults to port 3000 -rails server -b 0.0.0.0 +# we don't start the server immediately in dev mode +if [[ "$1" != 'dev' ]]; then + rails server -b 0.0.0.0 +fi diff --git a/docker/local-setup.sh b/docker/local-setup.sh index 0b2674eeb..9426b7cbc 100755 --- a/docker/local-setup.sh +++ b/docker/local-setup.sh @@ -3,3 +3,5 @@ cp ./docker/dummy.env ./docker/env cp ./docker/compose-env .env cp config/database.docker.yml config/database.yml +cp config/storage.docker.yml config/storage.yml +cp ./.sample.irbrc ./.irbrc diff --git a/docker/mysql-init.sql b/docker/mysql-init.sql index 2996693ec..510059bb9 100644 --- a/docker/mysql-init.sql +++ b/docker/mysql-init.sql @@ -1,8 +1,6 @@ /* database qpixel and user are already created * if you change your environment file, you need to update database names here */ -CREATE DATABASE qpixel_dev; -CREATE DATABASE qpixel_test; GRANT ALL ON qpixel_dev.* TO qpixel; GRANT ALL ON qpixel_test.* TO qpixel; GRANT ALL ON qpixel.* TO qpixel; diff --git a/img/edit-category.png b/img/edit-category.png new file mode 100644 index 000000000..fefd5c760 Binary files /dev/null and b/img/edit-category.png differ diff --git a/img/meta-tags.png b/img/meta-tags.png new file mode 100644 index 000000000..0de519f72 Binary files /dev/null and b/img/meta-tags.png differ diff --git a/img/qpixel-dev.png b/img/qpixel-dev.png new file mode 100644 index 000000000..242818e36 Binary files /dev/null and b/img/qpixel-dev.png differ diff --git a/img/required-tags.png b/img/required-tags.png new file mode 100644 index 000000000..4e8fa4bd7 Binary files /dev/null and b/img/required-tags.png differ diff --git a/lib/console_extension.rb b/lib/console_extension.rb new file mode 100644 index 000000000..ce7cd5c0b --- /dev/null +++ b/lib/console_extension.rb @@ -0,0 +1,106 @@ +module ConsoleExtension + extend ActiveSupport::Concern + + included do + console do + Rails.logger.silence do + console_init_community + end + end + end + + def console_init_community + community_count = Community.count + if community_count.zero? + puts "\e[31m(!) You have not yet created any communities.\e[0m" + puts 'Create a community by entering:' + puts '' + puts " Community.create(name: 'my community name', host: 'my.site.com')" + puts ' Rails.cache.clear' + puts '' + + if Rails.env.development? + begin + port = Rails::Server::Options.new.parse!(ARGV)[:Port] || 3000 + rescue + port = 3000 + end + puts "Since you are running in development mode, you probably want to set host to localhost:#{port}" + puts '' + elsif Rails.env.production? + puts 'Since you are running in production mode, set host to your fully qualified domain name without http(s).' + puts 'For example, if you host your site at https://meta.codidact.org, set host to meta.codidact.org' + puts '' + end + puts 'For more information, see the set up instructions.' + elsif community_count == 1 + community = Community.first + RequestContext.community = community + puts "\e[32m(!) Found one community, set current community to #{community.name} @ #{community.host}\e[0m" + else + community = Community.find_by(host: 'localhost:3000') if Rails.env.development? + community ||= Community.first + RequestContext.community = community + puts "\e[32m(!) Found multiple communities, set current community to #{community.name} @ #{community.host}\e[0m" + puts '' + puts 'You can change your current community by entering:' + puts '' + puts ' RequestContext.community = Community.find_by(...)' + puts '' + puts "You can use `host: 'my.host'` or `name: 'community name'` in place of the dots" + end + puts '' + rescue + puts "\e[31m(!) Unable to load communities. Is your database configuration correct?\e[0m" + end +end + +# Create module that can be included in the .irbrc: +# +# Qpixel.irb! if defined?(Qpixel) +module Qpixel + def self.irb! + IRB::Irb.class_eval do + private + + def self.rails_environment + case Rails.env + when 'development' + "\e[32mdev\e[0m" + when 'production' + "\e[31mprod\e[0m" + when 'test' + "\e[32mtest\e[0m" + when 'staging' + "\e[32mstag\e[0m" + else + "\e[31m#{Rails.env}\e[0m" + end + end + + def self.qpixel_prompt + c = RequestContext.community + "[#{rails_environment}] [\e[34m#{c&.name || '-'} @ #{c&.host || '-'}\e[0m]" + end + end + + IRB::Irb.class_eval do + # Define an alternative string dup method which will redetermine the prompt part if community changes + qpixel_block = proc do |s| + def s.dup + IRB::Irb.qpixel_prompt + self + end + end + + IRB.conf[:PROMPT][:QPIXEL] = { + PROMPT_I: ':%03n> '.tap(&qpixel_block), + PROMPT_N: ':%03n> '.tap(&qpixel_block), + PROMPT_S: ':%03n%l '.tap(&qpixel_block), + PROMPT_C: ':%03n* '.tap(&qpixel_block), + RETURN: IRB.conf[:PROMPT][:DEFAULT][:RETURN] + } + + IRB.conf[:PROMPT_MODE] = :QPIXEL + end + end +end diff --git a/lib/namespaced_env_cache.rb b/lib/namespaced_env_cache.rb index af5130e70..093c9a90a 100644 --- a/lib/namespaced_env_cache.rb +++ b/lib/namespaced_env_cache.rb @@ -5,10 +5,16 @@ def initialize(underlying) @getters = {} end + def include_community(opts) + include = opts.delete(:include_community) + include.nil? ? true : include + end + # These methods need the cache key name updating before we pass it to the underlying cache. [:decrement, :delete, :exist?, :fetch, :increment, :read, :write, :delete_matched].each do |method| define_method method do |name, *args, **opts, &block| - @underlying.send(method, construct_ns_key(name, include_community: opts.delete(:include_community) || true), + include_community = include_community(opts) + @underlying.send(method, construct_ns_key(name, include_community: include_community), *args, **opts, &block) end end @@ -16,7 +22,8 @@ def initialize(underlying) # These methods need a hash of cache keys updating before we pass it to the underlying cache. [:write_multi].each do |method| define_method method do |hash, *args, **opts, &block| - hash = hash.map { |k, v| [construct_ns_key(k), v] }.to_h + include_community = include_community(opts) + hash = hash.map { |k, v| [construct_ns_key(k, include_community: include_community), v] }.to_h @underlying.send(method, hash, *args, **opts, &block) end end @@ -29,14 +36,16 @@ def initialize(underlying) end def read_multi(*keys, **opts) - keys = keys.map { |k| [construct_ns_key(k), k] }.to_h - results = @underlying.read_multi *keys.keys, **opts + include_community = include_community(opts) + keys = keys.map { |k| [construct_ns_key(k, include_community: include_community), k] }.to_h + results = @underlying.read_multi(*keys.keys, **opts) results.map { |k, v| [keys[k], v] }.to_h end def fetch_multi(*keys, **opts, &block) - keys = keys.map { |k| construct_ns_key(k) } - @underlying.fetch_multi *keys, **opts, &block + include_community = include_community(opts) + keys = keys.map { |k| construct_ns_key(k, include_community: include_community) } + @underlying.fetch_multi(*keys, **opts, &block) end def persistent(name, **opts, &block) diff --git a/public/assets/scoring_table.png b/public/assets/scoring_table.png index 6f818f6ce..42c001f31 100644 Binary files a/public/assets/scoring_table.png and b/public/assets/scoring_table.png differ diff --git a/public/assets/scoring_table.svg b/public/assets/scoring_table.svg new file mode 100644 index 000000000..48835ef0f --- /dev/null +++ b/public/assets/scoring_table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/cleanup_drafts.rb b/scripts/cleanup_drafts.rb new file mode 100644 index 000000000..27ef4eb63 --- /dev/null +++ b/scripts/cleanup_drafts.rb @@ -0,0 +1,5 @@ +redis = RequestContext.redis + +redis.scan_each(:match => "saved_post.*.*.tags") do |key| + redis.srem?(key, '') +end \ No newline at end of file diff --git a/scripts/cleanup_votes.rb b/scripts/cleanup_votes.rb new file mode 100644 index 000000000..67707824e --- /dev/null +++ b/scripts/cleanup_votes.rb @@ -0,0 +1 @@ +CleanupVotesJob.perform_later \ No newline at end of file diff --git a/scripts/create_backup_2fa_codes.rb b/scripts/create_backup_2fa_codes.rb new file mode 100644 index 000000000..ddeac6793 --- /dev/null +++ b/scripts/create_backup_2fa_codes.rb @@ -0,0 +1,4 @@ +User.where(enabled_2fa: true).each do |user| + user.update(backup_2fa_code: SecureRandom.alphanumeric(24)) + TwoFactorMailer.with(user: user, host: 'meta.codidact.com').backup_code.deliver_now +end diff --git a/scripts/prune_email_logs.rb b/scripts/prune_email_logs.rb new file mode 100644 index 000000000..0336c9e0c --- /dev/null +++ b/scripts/prune_email_logs.rb @@ -0,0 +1 @@ +EmailLog.where('created_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 MONTH)').destroy_all diff --git a/scripts/recalc_abilities.rb b/scripts/recalc_abilities.rb index eeda78688..17c99d4fd 100644 --- a/scripts/recalc_abilities.rb +++ b/scripts/recalc_abilities.rb @@ -36,11 +36,11 @@ puts "Scope: CommunityUser : #{cu.id}" end - cu.recalc_privileges + 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.privilege?('mod') - cu.grant_privilege('mod') + if (cu.is_moderator || cu.is_admin || u.is_global_moderator || u.is_global_admin) && !cu.ability?('mod') + cu.grant_ability!('mod') end resolved << q.id diff --git a/scripts/recalc_abilities_upon_first_migration.rb b/scripts/recalc_abilities_upon_first_migration.rb index 520144e65..32b3c48e4 100644 --- a/scripts/recalc_abilities_upon_first_migration.rb +++ b/scripts/recalc_abilities_upon_first_migration.rb @@ -9,7 +9,7 @@ cu.recalc_privileges if (cu.is_moderator || cu.is_admin || u.is_global_moderator || u.is_global_admin) && !cu.privilege?('mod') - cu.grant_privilege('mod') + cu.grant_privilege!('mod') end rescue puts " !!! Error recalcing for CommunityUser.Id=#{cu.id}" diff --git a/scripts/run_spam_cleanup.rb b/scripts/run_spam_cleanup.rb new file mode 100644 index 000000000..2b2a6853d --- /dev/null +++ b/scripts/run_spam_cleanup.rb @@ -0,0 +1 @@ +CleanUpSpammyUsersJob.perform_later diff --git a/scripts/wipe_everything.rb b/scripts/wipe_everything.rb index 98da394d9..b5311f9a1 100644 --- a/scripts/wipe_everything.rb +++ b/scripts/wipe_everything.rb @@ -3,17 +3,21 @@ exit 255 end -def exec(sql) +def exec_sql(sql) ActiveRecord::Base.connection.execute sql end conn = ActiveRecord::Base.connection leave_tables = ['ar_internal_metadata', 'schema_migrations'] + +exec_sql 'SET FOREIGN_KEY_CHECKS = 0' (conn.tables - leave_tables).each do |t| - exec "DELETE FROM `#{t}`" - exec "ALTER TABLE `#{t}` AUTO_INCREMENT=1" + exec_sql "DELETE FROM `#{t}`" + exec_sql "ALTER TABLE `#{t}` AUTO_INCREMENT=1" end +exec_sql 'SET FOREIGN_KEY_CHECKS = 1' +Community.create(name: 'Dev Community', host: 'localhost:3000') Rails.cache.clear -`bundle exec rails db:seed` \ No newline at end of file +`rails db:seed` diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb new file mode 100644 index 000000000..da6f04b00 --- /dev/null +++ b/test/application_system_test_case.rb @@ -0,0 +1,103 @@ +require 'test_helper' + +# This class serves as the base for all system test cases. +# +# The DRIVER environment variable is used to determine the browser that is used. Possible options are: +# - headless_chrome +# - chrome +# - headless_firefox (default) +# - firefox +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + DRIVER = if ENV['DRIVER'] + ENV['DRIVER'].to_sym + else + :headless_firefox + end + + driven_by :selenium, using: DRIVER, screen_size: [1920, 1080] + + setup do + Community.first.update(host: root_url.gsub(/https?:\/\//, '').gsub('/', '')) + end + + # Logs in as the specified user + # + # @param user_or_fixture [User, Symbol] either a user or a symbol referring to a user from the fixtures + # @param password [String] the password to sign in with + def log_in(user_or_fixture, password = 'test123') + @user = user(user_or_fixture) + visit new_user_session_url + fill_in 'Email', with: @user.email + fill_in 'Password', with: password + + click_button 'Sign in' + end + + # Attempts to log out using the buttons in the top menu bar. + def log_out + within :css, '.header' do + find(:css, 'i.far.fa-caret-square-down').find(:xpath, '..').click + end + + find_link('Sign Out').click + end + + # Pretends the user has clicked the confirmation link in the email they received. + # + # @param user_or_fixture [User, Symbol] the user or a symbol referring to the user fixture to use + def confirm_email(user_or_fixture) + u = user(user_or_fixture) + visit user_confirmation_url( + params: { confirmation_token: u.confirmation_token } + ) + end + + # Translates the given parameter to a proper user. + # + # @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 + user_or_fixture + else + users(user_or_fixture) + end + end + + # In the post form, this method will select the given tag. + # + # @param tag_name [String] the name of the tag + # @param create_new [Boolean] whether creating a new tag is allowed (default false) + def post_form_select_tag(tag_name, create_new = false) + # First enter the tag name into the select2 search field for the tag + within find_field('Tags (at least one):').find(:xpath, '..') do + 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…' } + + 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 + 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 + 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 + end +end diff --git a/test/controllers/donations_controller_test.rb b/test/controllers/donations_controller_test.rb index 81105d8ed..c2ed31624 100644 --- a/test/controllers/donations_controller_test.rb +++ b/test/controllers/donations_controller_test.rb @@ -9,6 +9,7 @@ class DonationsControllerTest < ActionController::TestCase 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_not_nil assigns(:intent)&.id diff --git a/test/controllers/email_logs_controller_test.rb b/test/controllers/email_logs_controller_test.rb new file mode 100644 index 000000000..9addb0be1 --- /dev/null +++ b/test/controllers/email_logs_controller_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class EmailLogsControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/posts/drafts_test.rb b/test/controllers/posts/drafts_test.rb index 21f629cb6..bc7c9603b 100644 --- a/test/controllers/posts/drafts_test.rb +++ b/test/controllers/posts/drafts_test.rb @@ -5,13 +5,31 @@ class PostsControllerTest < ActionController::TestCase test 'can save draft' do sign_in users(:standard_user) - post :save_draft, params: { path: 'test', post: 'test' } + post :save_draft, params: { + body: 'test_body', + comment: 'test_comment', + excerpt: 'test_excerpt', + license: '4', + path: 'test_path', + tag_name: 'test_tag_name', + tags: ['tag1', 'tag2'], + title: 'test_title' + } assert_response 200 assert_nothing_raised do JSON.parse(response.body) end - assert_equal "saved_post.#{users(:standard_user).id}.test", JSON.parse(response.body)['key'] - assert_equal 'test', RequestContext.redis.get(JSON.parse(response.body)['key']) + + base_key = JSON.parse(response.body)['key'] + + assert_equal "saved_post.#{users(:standard_user).id}.test_path", base_key + assert_equal 'test_body', RequestContext.redis.get(base_key) + assert_equal 'test_comment', RequestContext.redis.get("#{base_key}.comment") + assert_equal 'test_excerpt', RequestContext.redis.get("#{base_key}.excerpt") + assert_equal 'test_tag_name', RequestContext.redis.get("#{base_key}.tag_name") + assert_equal '4', RequestContext.redis.get("#{base_key}.license") + assert_empty ['tag1', 'tag2'].difference(RequestContext.redis.smembers("#{base_key}.tags")) + assert_equal 'test_title', RequestContext.redis.get("#{base_key}.title") end test 'can delete draft' do diff --git a/test/controllers/posts/help_test.rb b/test/controllers/posts/help_test.rb index 48ba059ab..db3e59f4d 100644 --- a/test/controllers/posts/help_test.rb +++ b/test/controllers/posts/help_test.rb @@ -25,20 +25,20 @@ class PostsControllerTest < ActionController::TestCase test 'moderator help requires authentication' do get :document, params: { slug: posts(:mod_help_article).doc_slug } assert_response 404 - assert_not_nil assigns(:post) + 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_not_nil assigns(:post) + 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_not_nil assigns(:post) + assert_nil assigns(:post) end end diff --git a/test/controllers/posts/lock_test.rb b/test/controllers/posts/lock_test.rb index 95a30470b..cfb7b2ddc 100644 --- a/test/controllers/posts/lock_test.rb +++ b/test/controllers/posts/lock_test.rb @@ -79,4 +79,15 @@ class PostsControllerTest < ActionController::TestCase end 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 + + # 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/search_controller_test.rb b/test/controllers/search_controller_test.rb index 00792a96e..9beba194c 100644 --- a/test/controllers/search_controller_test.rb +++ b/test/controllers/search_controller_test.rb @@ -3,10 +3,10 @@ class SearchControllerTest < ActionController::TestCase include Devise::Test::ControllerHelpers - test 'get without a search term should result in nil' do + test 'get without a search term should result in all posts' do get :search assert_response 200 - assert_nil assigns(:posts) + assert_not_nil assigns(:posts) end test 'get with a search term should have results' do diff --git a/test/controllers/tags_controller_test.rb b/test/controllers/tags_controller_test.rb index 176beff95..cdb3983d9 100644 --- a/test/controllers/tags_controller_test.rb +++ b/test/controllers/tags_controller_test.rb @@ -12,7 +12,7 @@ class TagsControllerTest < ActionController::TestCase assert_not_nil assigns(:tags) end - test 'index with search params should return tags starting with search' do + 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 @@ -20,7 +20,19 @@ class TagsControllerTest < ActionController::TestCase end assert_not_nil assigns(:tags) JSON.parse(response.body).each do |tag| - assert_equal true, tag['name'].start_with?('dis') + assert_equal true, tag['name'].include?('dis') || tag['tag_synonyms'].any? { |ts| ts['name'].include?('syn') } + end + end + + 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_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 end @@ -125,6 +137,26 @@ class TagsControllerTest < ActionController::TestCase assert_equal 'things', assigns(:tag).excerpt end + test 'should update tag with synonym addition' do + 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_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 + end + + test 'should update tag with synonym removal' do + 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_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' }) + end + test 'should prevent a tag being its own parent' do sign_in users(:deleter) patch :update, params: { id: categories(:main).id, tag_id: tags(:topic).id, diff --git a/test/controllers/users/sessions_controller_test.rb b/test/controllers/users/sessions_controller_test.rb new file mode 100644 index 000000000..b06e1b602 --- /dev/null +++ b/test/controllers/users/sessions_controller_test.rb @@ -0,0 +1,17 @@ +require 'test_helper' + +class Users::SessionsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include ApplicationHelper + + test 'should sign in with 2fa backup code' do + @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_not_nil flash[:warning] + assert_not_nil current_user + assert_nil current_user.backup_2fa_code + assert_not current_user.enabled_2fa + end +end diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 17a18c257..0aa5d0863 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -113,16 +113,41 @@ class UsersControllerTest < ActionController::TestCase assert_response 200 end - test 'should update profile text' do + test 'should redirect & show success notice on profile update' do sign_in users(:standard_user) - patch :update_profile, params: { user: { profile_markdown: 'ABCDEF GHIJKL', website: 'https://example.com/user', - twitter: '@standard_user' } } + patch :update_profile, params: { user: { username: 'std' } } assert_response 302 assert_not_nil flash[:success] assert_not_nil assigns(:user) assert_equal users(:standard_user).id, assigns(:user).id - assert_not_nil assigns(:user).profile - assert_equal 'standard_user', assigns(:user).twitter + end + + test 'should update profile text' do + sign_in users(:standard_user) + patch :update_profile, params: { + user: { profile_markdown: 'ABCDEF GHIJKL' } + } + assert_equal assigns(:user).profile.strip, '

      ABCDEF GHIJKL

      ' + end + + test 'should update websites' do + sign_in users(:standard_user) + patch :update_profile, params: { + user: { user_websites_attributes: { + '0': { label: 'web', url: 'example.com' } + } } + } + assert_not_nil assigns(:user).user_websites + assert_equal 'web', assigns(:user).user_websites.first.label + assert_equal 'example.com', assigns(:user).user_websites.first.url + end + + test 'should update user discord link' do + sign_in users(:standard_user) + patch :update_profile, params: { + user: { discord: 'example_user#1234' } + } + assert_equal 'example_user#1234', assigns(:user).discord end test 'should get full posts list for a user' do diff --git a/test/fixtures/category_filter_defaults.yml b/test/fixtures/category_filter_defaults.yml new file mode 100644 index 000000000..697b14ee7 --- /dev/null +++ b/test/fixtures/category_filter_defaults.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +main_default_filter: + user: standard_user + filter: one + category: main + +meta_default_filter: + user: standard_user + filter: one + category: meta diff --git a/test/fixtures/community_users.yml b/test/fixtures/community_users.yml index 31d28a04f..4d7ac6963 100644 --- a/test/fixtures/community_users.yml +++ b/test/fixtures/community_users.yml @@ -1,3 +1,10 @@ +sample_basic_user: + user: basic_user + community: sample + is_admin: false + is_moderator: false + reputation: 1 + sample_standard_user: user: standard_user community: sample diff --git a/test/fixtures/email_logs.yml b/test/fixtures/email_logs.yml new file mode 100644 index 000000000..7ca782a8c --- /dev/null +++ b/test/fixtures/email_logs.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + log_type: MyString + destination: MyString + data: MyText + +two: + log_type: MyString + destination: MyString + data: MyText diff --git a/test/fixtures/filters.yml b/test/fixtures/filters.yml new file mode 100644 index 000000000..f47aa1e31 --- /dev/null +++ b/test/fixtures/filters.yml @@ -0,0 +1,19 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyFilterOne + user: standard_user + min_score: 1.5 + max_score: 1.5 + min_answers: 1 + max_answers: 1 + status: MyString + +two: + name: MyFilterTwo + user: standard_user + min_score: 1.5 + max_score: 1.5 + min_answers: 1 + max_answers: 1 + status: MyString diff --git a/test/fixtures/posts.yml b/test/fixtures/posts.yml index bd4c76383..c3597fb96 100644 --- a/test/fixtures/posts.yml +++ b/test/fixtures/posts.yml @@ -1,8 +1,8 @@ question_one: post_type: question - title: Q1 ABCDEF GHIJKL MNOPQR STUVWX YZ - body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ + title: Q1 - This is test question number one + body: This is the body of test question one. Note that we did not include any markdown or HTML in here. + body_markdown: This is the body of test question one. Note that we did not include any markdown or HTML in here. tags_cache: - discussion - support @@ -21,9 +21,9 @@ question_one: question_two: post_type: question - title: Q2 ABCDEF GHIJKL MNOPQR STUVWX YZ - body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA + title: Q2 - This is test question number two + body: This is the body of test question two. Note that we did not include any markdown or HTML in here. + body_markdown: This is the body of test question two. Note that we did not include any markdown or HTML in here. tags_cache: - discussion - support @@ -42,9 +42,9 @@ question_two: bad_answers: post_type: question - title: Q1 ABCDEF GHIJKL MNOPQR STUVWX YZ - body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ + title: Q3B - This question has bad answers + body: Q3B - This question is in the category main, posted by standard_user. It has bad answers. + body_markdown: Q3B - This question is in the category main, posted by standard_user. It has bad answers. tags_cache: - discussion - support @@ -63,9 +63,9 @@ bad_answers: deleted: post_type: question - title: Q3D ZY XWVUTS RQPONM LKJIHG FEDCBA - body: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA - body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA + title: Q3D - This question is deleted + body: Q3D - This question is in the category main, posted by standard_user. It has been deleted by deleter. + body_markdown: Q3D - This question is in the category main, posted by standard_user. It has been deleted by deleter. tags_cache: - discussion - support @@ -87,9 +87,9 @@ deleted: deleted_mod: post_type: question - title: Q3D ZY XWVUTS RQPONM LKJIHG FEDCBA - body: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA - body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA + title: Q3DM - This question is deleted by a moderator + body: Q3DM - This question is in category main, posted by standard_user. It has been deleted by moderator. + body_markdown: Q3DM - This question is in category main, posted by standard_user. It has been deleted by moderator. tags_cache: - discussion - support @@ -111,9 +111,9 @@ deleted_mod: closed: post_type: question - title: Q4C ABCDEF GHIJKL MNOPQR STUVWX YZ - body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA + title: Q4C - This question is closed + body: Q4C - This question is in the category main, posted by standard_user. It has been closed by closer. + body_markdown: Q4C - This question is in the category main, posted by standard_user. It has been closed by closer. tags_cache: - discussion - support @@ -135,9 +135,9 @@ closed: locked: post_type: question - title: Q4C ABCDEF GHIJKL MNOPQR STUVWX YZ - body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA + title: Q4L - This question is locked + body: Q4L - This question is in the category main, posted by standard_user. It has been locked by deleter. + body_markdown: Q4L - This question is in the category main, posted by standard_user. It has been locked by deleter. tags_cache: - discussion - support @@ -150,7 +150,6 @@ locked: locked: true locked_by: deleter locked_at: 2019-01-01T00:00:00.000000Z - locked_until: 2020-01-01T00:00:00.000000Z user: standard_user community: sample category: main @@ -160,9 +159,9 @@ locked: locked_mod: post_type: question - title: LM ABCDEF GHIJKL MNOPQR STUVWX YZ - body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA + title: Q4LM - This question is locked by a moderator + body: Q4LM - This question is in the category main, posted by standard_user. It has been locked by moderator. + body_markdown: Q4LM - This question is in the category main, posted by standard_user. It has been locked by moderator. tags_cache: - discussion - support @@ -175,7 +174,6 @@ locked_mod: locked: true locked_by: moderator locked_at: 2019-01-01T00:00:00.000000Z - locked_until: 2020-01-01T00:00:00.000000Z user: standard_user community: sample category: main @@ -185,9 +183,9 @@ locked_mod: free_edit: post_type: free_edit - title: FE ABCDEF GHIJKL MNOPQR STUVWX YZ - body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ + title: FE - This is a post of the type free_edit + body: FE - This is a free edit post, in the category main, posted by moderator. + body_markdown: FE - This is a free edit post, in the category main, posted by moderator. tags_cache: - discussion - support @@ -206,9 +204,9 @@ free_edit: high_trust: post_type: question - title: Q1 ABCDEF GHIJKL MNOPQR STUVWX YZ - body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ + title: Q5 - This is a question with high trust + body: Q5 - By not providing any information, you know you can trust everything said in this post. + body_markdown: Q5 - By not providing any information, you know you can trust everything said in this post. tags_cache: - discussion - support @@ -227,8 +225,8 @@ high_trust: answer_one: post_type: answer - body: A1 ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA + body: A1 - This is the first answer to question number 1 (Q1). It was posted by standard user. + body_markdown: A1 - This is the first answer to question number 1 (Q1). It was posted by standard user. score: 0.5 parent: question_one user: standard_user @@ -240,8 +238,8 @@ answer_one: answer_two: post_type: answer - body: A2 ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA + body: A2 - This is the second answer to question number 1 (Q1). It was posted by editor. + body_markdown: A2 - This is the second answer to question number 1 (Q1). It was posted by editor. score: 0.5 parent: question_one user: editor @@ -253,8 +251,8 @@ answer_two: comments_disabled: post_type: answer - body: A2 ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA + body: A3CD - This is the third answer to question number 1 (Q1). It has comments disabled. Posted by closer. + body_markdown: A3 - This is the third answer to question number 1 (Q1). It has comments disabled. Posted by closer. score: 0.5 parent: question_one user: closer @@ -267,8 +265,8 @@ comments_disabled: bad_answer: post_type: answer - body: A2 ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA + body: A3BA - This is the fourth answer to question number 1 (Q1). It has a bad score. Posted by editor. + body_markdown: A4 - This is the fourth answer to question number 1 (Q1). It has a bad score. Posted by editor. score: 0.4 parent: question_one user: editor @@ -280,8 +278,8 @@ bad_answer: really_old_answer: post_type: answer - body: A3RO ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA + body: A3RO - This is the fifth answer to question number 1 (Q1). It is very old. Posted by standard_user. + body_markdown: A3RO - This is the fifth answer to question number 1 (Q1). It is very old. Posted by standard_user. score: 2 parent: question_one user: standard_user @@ -294,8 +292,8 @@ really_old_answer: deleted_answer: post_type: answer - body: A4D ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA + body: A4D - This is the sixth answer to question number 1 (Q1). It is deleted. Posted by closer. + body_markdown: A4D - This is the sixth answer to question number 1 (Q1). It is deleted. Posted by closer. score: 0.5 parent: question_one user: closer @@ -310,8 +308,8 @@ deleted_answer: policy_doc: post_type: policy_doc - body: PD ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA + body: PD - This is a policy document called "Terms of Service", or "tos" for short. + body_markdown: PD - This is a policy document called "Terms of Service", or "tos" for short. title: Terms of Service doc_slug: tos user: admin @@ -330,9 +328,9 @@ help_doc: article_one: post_type: article - title: Q1 ABCDEF GHIJKL MNOPQR STUVWX YZ - body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ + title: Art1 - This is the first article + body: Testing is an important practice, which should probably be discussed in this test article. + body_markdown: Testing is an important practice, which should probably be discussed in this test article. tags_cache: - discussion - support @@ -351,9 +349,9 @@ article_one: deleted_article: post_type: article - title: Q1 ABCDEF GHIJKL MNOPQR STUVWX YZ - body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ + title: Art2D - This is a deleted article + body: Articles are most likely deleted because they have no inherent value, just like this sentence. + body_markdown: Articles are most likely deleted because they have no inherent value, just like this sentence. tags_cache: - discussion - support @@ -375,8 +373,8 @@ deleted_article: help_article: post_type: help_doc - body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ + body: HA1 - This help article is in the category Site information, with "sample" as slug. + body_markdown: HA1 - This help article is in the category Site information, with "sample" as slug. user: system community: sample help_category: Site Information @@ -385,8 +383,8 @@ help_article: mod_help_article: post_type: help_doc - body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ + body: HA2 - This help article is in the category for moderators only, with "sample-mod" as slug. + body_markdown: HA2 - This help article is in the category for moderators only, with "sample-mod" as slug. user: system community: sample help_category: $Moderator @@ -395,8 +393,8 @@ mod_help_article: disabled_help_article: post_type: help_doc - body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ + body: HA3 - This help article is disabled. It is in the category disabled, with "sample-disable" as slug. + body_markdown: HA3 - This help article is disabled. It is in the category disabled, with "sample-disable" as slug. user: system community: sample help_category: $Disabled @@ -405,9 +403,9 @@ disabled_help_article: blog_post: post_type: blog_post - title: B1 ABCDEF GHIJKL MNOPQR STUVWX YZ - body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ - body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ + title: B1 - This is a blog post in the main category + body: B1 - This blog post is in the main category, posted by closer. + body_markdown: B1 - This blog post is in the main category, posted by closer. tags_cache: - discussion - support diff --git a/test/fixtures/tag_sets.yml b/test/fixtures/tag_sets.yml index df6517d05..57b91b8a2 100644 --- a/test/fixtures/tag_sets.yml +++ b/test/fixtures/tag_sets.yml @@ -5,3 +5,7 @@ main: meta: name: Meta community: sample + +empty: + name: 'Empty' + community: sample diff --git a/test/fixtures/tag_synonyms.yml b/test/fixtures/tag_synonyms.yml new file mode 100644 index 000000000..48965bc78 --- /dev/null +++ b/test/fixtures/tag_synonyms.yml @@ -0,0 +1,5 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +base_synonym: + name: synonym + tag: base diff --git a/test/fixtures/tags.yml b/test/fixtures/tags.yml index de28397ed..422794b4e 100644 --- a/test/fixtures/tags.yml +++ b/test/fixtures/tags.yml @@ -10,6 +10,7 @@ support: bug: name: bug + excerpt: use for bug reports community: sample tag_set: main @@ -38,3 +39,8 @@ child: community: sample tag_set: main parent: topic + +base: + name: base + community: sample + tag_set: main diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index e60c01e6c..9cc566f84 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,6 +1,15 @@ +basic_user: + email: basic@qpixel-test.net + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' + sign_in_count: 42 + username: basic_user + is_global_admin: false + is_global_moderator: false + confirmed_at: 2020-01-01T00:00:00.000000Z + standard_user: email: standard@qpixel-test.net - encrypted_password: abcdefghijklmnopqrstuvwxyz + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' sign_in_count: 1337 username: standard_user is_global_admin: false @@ -9,7 +18,7 @@ standard_user: closer: email: closer@qpixel-test.net - encrypted_password: abcdefghijklmnopqrstuvwxyz + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' sign_in_count: 1337 username: closer website: https://example.com/closer @@ -22,7 +31,7 @@ closer: editor: email: editor@qpixel-test.net - encrypted_password: abcdefghijklmnopqrstuvwxyz + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' sign_in_count: 1337 username: editor is_global_admin: false @@ -33,7 +42,7 @@ editor: deleter: email: delete@qpixel-test.net - encrypted_password: abcdefghijklmnopqrstuvwxyz + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' sign_in_count: 1337 username: deleter is_global_admin: false @@ -42,7 +51,7 @@ deleter: moderator: email: moderator@qpixel-test.net - encrypted_password: abcdefghijklmnopqrstuvwxyz + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' sign_in_count: 1337 username: moderator is_global_admin: false @@ -51,7 +60,7 @@ moderator: admin: email: admin@qpixel-test.net - encrypted_password: abcdefghijklmnopqrstuvwxyz + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' sign_in_count: 1337 username: admin is_global_admin: false @@ -60,7 +69,7 @@ admin: global_moderator: email: global-moderator@qpixel-test.net - encrypted_password: abcdefghijklmnopqrstuvwxyz + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' sign_in_count: 1337 username: global-moderator is_global_admin: false @@ -69,7 +78,7 @@ global_moderator: global_admin: email: global-admin@qpixel-test.net - encrypted_password: abcdefghijklmnopqrstuvwxyz + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' sign_in_count: 1337 username: global-admin is_global_admin: true @@ -78,7 +87,7 @@ global_admin: no_community_user: email: no_community_user@qpixel-test.net - encrypted_password: abcdefghijklmnopqrstuvwxyz + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' sign_in_count: 1337 username: no_community_user is_global_admin: false @@ -88,7 +97,7 @@ no_community_user: system: id: -99 email: system@qpixel-test.net - encrypted_password: abcdefghijklmnopqrstuvwxyz + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' sign_in_count: 1337 username: system is_global_admin: true @@ -97,7 +106,7 @@ system: deleted_account: email: deleted@qpixel-test.net - encrypted_password: abcdefghijklmnopqrstuvwxyz + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' sign_in_count: 1337 username: deleted_account confirmed_at: 2020-01-01T00:00:00.000000Z @@ -107,7 +116,17 @@ deleted_account: deleted_profile: email: deleted_profile@qpixel-test.net - encrypted_password: abcdefghijklmnopqrstuvwxyz + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' sign_in_count: 1337 username: deleted_profile confirmed_at: 2020-01-01T00:00:00.000000Z + +enabled_2fa: + email: 2fa@qpixel-test.net + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' + sign_in_count: 1337 + username: enabled_2fa + confirmed_at: 2020-01-01T00:00:00.000000Z + enabled_2fa: true + two_factor_token: WT65ANYXBB2SBR7III7IVWNJDS4PQF2T + backup_2fa_code: M8lENyehyCvo9F9MbyTl1aOL diff --git a/test/helpers/comments_helper_test.rb b/test/helpers/comments_helper_test.rb index fca0964bd..42c6bde74 100644 --- a/test/helpers/comments_helper_test.rb +++ b/test/helpers/comments_helper_test.rb @@ -28,7 +28,7 @@ class CommentsHelperTest < ActionView::TestCase test '[flags?] substitution' do expected = { '[flag] me if you can' => "flag me if you can", - '\'cause it\'s our [flags]hip product' => "\'cause it\'s our flagship product", + '\'cause it\'s our [flags]hip product' => "'cause it's our flagship product", 'yeah bad pun - [flagged] and downvoted' => 'yeah bad pun - [flagged] and downvoted' } expected.each do |input, expect| diff --git a/test/jobs/clean_up_spammy_users_job_test.rb b/test/jobs/clean_up_spammy_users_job_test.rb new file mode 100644 index 000000000..8d84e8a91 --- /dev/null +++ b/test/jobs/clean_up_spammy_users_job_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class CleanUpSpammyUsersJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/mailers/previews/two_factor_mailer_preview.rb b/test/mailers/previews/two_factor_mailer_preview.rb index 804f87012..2857de292 100644 --- a/test/mailers/previews/two_factor_mailer_preview.rb +++ b/test/mailers/previews/two_factor_mailer_preview.rb @@ -7,4 +7,8 @@ def disable_email_preview def login_email_preview TwoFactorMailer.with(user: User.last, host: 'testhost.codidact.com').login_email end + + def backup_code_preview + TwoFactorMailer.with(user: User.last, host: 'testhost.codidact.com').backup_code + end end diff --git a/test/models/category_filter_default_test.rb b/test/models/category_filter_default_test.rb new file mode 100644 index 000000000..72358b7c0 --- /dev/null +++ b/test/models/category_filter_default_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class CategoryFilterDefaultTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/email_log_test.rb b/test/models/email_log_test.rb new file mode 100644 index 000000000..12c90407f --- /dev/null +++ b/test/models/email_log_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class EmailLogTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/filter_test.rb b/test/models/filter_test.rb new file mode 100644 index 000000000..418de9e94 --- /dev/null +++ b/test/models/filter_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class FilterTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/tag_set_test.rb b/test/models/tag_set_test.rb index c5ea5ed11..39e8b086b 100644 --- a/test/models/tag_set_test.rb +++ b/test/models/tag_set_test.rb @@ -1,7 +1,18 @@ require 'test_helper' class TagSetTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + include CommunityRelatedHelper + + test 'is community related' do + assert_community_related(TagSet) + end + + test 'with_paths method should respect no_excerpt' do + main = TagSet.main + + all = main.with_paths.size + excerptless = main.with_paths(true).size + + assert_not_equal(all, excerptless) + end end diff --git a/test/models/tag_synonym_test.rb b/test/models/tag_synonym_test.rb new file mode 100644 index 000000000..a5ea423be --- /dev/null +++ b/test/models/tag_synonym_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class TagSynonymTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 233537068..9d8cfd64b 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -25,6 +25,39 @@ class UserTest < ActiveSupport::TestCase assert_equal 'example.com', users(:closer).website_domain end + 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) + license = licenses(:cc_by_sa) + post_type = post_types(:question) + post = Post.create(body_markdown: 'rev 1', + body: '

      rev 1

      ', + title: 'test post', + tags_cache: ['test'], + license: license, + score: 0, + user: post_owner, + 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) + + 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)) + end + + 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) + end + test 'community_user is based on context' do user = users(:standard_user) community = Community.create(host: 'other', name: 'Other') diff --git a/test/system/login_test.rb b/test/system/login_test.rb new file mode 100644 index 000000000..5f799dbc5 --- /dev/null +++ b/test/system/login_test.rb @@ -0,0 +1,57 @@ +require 'application_system_test_case' + +class LoginTest < ApplicationSystemTestCase + test 'User can register a new account and sign-in to it after confirming their email' do + email = 'test@test.com' + username = 'Test User' + password = 'login_test_1' + + # Sign up for an account + visit root_url + click_on 'Sign Up' + fill_in 'Email', with: email + fill_in 'Username', with: username + fill_in 'Password', with: password + fill_in 'Password confirmation', with: password + + # Check that the user is created in the DB + assert_difference 'User.count' do + click_on 'Sign up' + end + + user = User.last + + # Try logging in directly, this should fail because not confirmed yet + log_in user, password + assert_selector '.notice', text: 'You have to confirm your email address before continuing.' + + # Confirm email and sign in again, should succeed this time + confirm_email user + log_in user, password + assert_selector '.notice', text: 'Signed in successfully.' + end + + test 'User can sign in and is redirected back to the page they were on' do + # Start on the users page + visit users_url + + # Click the sign in button (top right) + # Don't go through log_in helper, since we want to test the sign-in fully here + click_on 'Sign In' + fill_in 'Email', with: users(:standard_user).email + fill_in 'Password', with: 'test123' + click_button 'Sign in' + + # We should see a message that we have signed in, and we should be on the users page again. + assert_selector '.notice', text: 'Signed in successfully.' + assert_current_path users_url + end + + test 'User can sign out' do + log_in :standard_user + assert_selector '.notice', text: 'Signed in successfully.' + + log_out + assert_selector '.notice', text: 'Signed out successfully.' + end +end diff --git a/test/system/post_test.rb b/test/system/post_test.rb new file mode 100644 index 000000000..d99cd266c --- /dev/null +++ b/test/system/post_test.rb @@ -0,0 +1,166 @@ +require 'application_system_test_case' + +class PostTest < ApplicationSystemTestCase + # ------------------------------------------------------- + # Create + # ------------------------------------------------------- + + test 'Not-signed in user cannot create a post' do + visit root_url + click_on 'Create Post' + + assert_current_path new_user_session_url + end + + test 'Signed in user can create a question' do + category = categories(:meta) + log_in :standard_user + visit category_path(category) + click_on 'Create Post' + + body_text = 'When running QPixel, users are generally supposed to be able to create posts. ' \ + 'Does that actually work?' + title_text = 'Can a signed-in user create a post?' + + fill_in 'Body', with: body_text + fill_in 'Summarize your post with a title:', with: title_text + post_form_select_tag tags(:faq).name + + # Check that the post is actually created + assert_difference 'Post.count' do + click_on "Save Post in #{category.name}" + end + + # Verify that the post is correctly created + new_post = Post.last + assert_equal body_text, new_post.body_markdown + assert_equal title_text, new_post.title + assert_equal [tags(:faq)], new_post.tags + end + + test 'Creating a question is blocked when body is too short' do + category = categories(:meta) + log_in :standard_user + visit category_path(category) + click_on 'Create Post' + + fill_in 'Summarize your post with a title:', with: 'Initial title is of sufficient length' + post_form_select_tag tags(:faq).name + fill_in 'Body', with: 'Short' + + # Check that the button is disabled + find_button "Save Post in #{category.name}", disabled: true + + # After filling out body correctly, verify that the button becomes enabled + fill_in 'Body', with: 'This body should pass the minimum length requirements for questions in the meta category.' + find_button "Save Post in #{category.name}", disabled: false + end + + test 'Creating a question is blocked when title is too short' do + category = categories(:meta) + log_in :standard_user + visit category_path(category) + click_on 'Create Post' + + fill_in 'Body', with: 'This body should pass the minimum length requirements for questions in the meta category.' + post_form_select_tag tags(:faq).name + fill_in 'Summarize your post with a title:', with: 'Too short' + + # Check that the button is disabled + find_button "Save Post in #{category.name}", disabled: true + + # After filling out the title, verify that the button becomes enabled + fill_in 'Summarize your post with a title:', with: 'Updated title is of sufficient length' + find_button "Save Post in #{category.name}", disabled: false + end + + test 'Signed in user gets to pick post type for post creation in categories with multiple types' do + category = categories(:main) + log_in :standard_user + visit category_path(category) + click_on 'Create Post' + + # All the top level post types set should be present + category.post_types.where(is_top_level: true).each do |pt| + assert_link pt.name.underscore.humanize + end + + # Pick a non-question post type + post_type = category.post_types.where(is_top_level: true).where.not(name: 'Question').first + + # After clicking on a post type, we should be on the creation page of the correct category and post type. + click_on post_type.name.underscore.humanize + assert_current_path new_category_post_url(post_type.id, category.id) + end + + test 'Signed in user can answer question' do + log_in :standard_user + post = posts(:question_two) + visit post_path(post) + + # Answer the question + answer_text = 'You can do this by running the rails system tests, rails test:system.' + fill_in 'Body', with: answer_text + assert_difference 'Post.count' do + click_on "Save Post in #{post.category.name}" + end + + # We should now be looking at our answer, look for the text on the page + assert_text answer_text + + # The original post should also still be on the page + assert_text post.body + end + + # ------------------------------------------------------- + # Show + # ------------------------------------------------------- + + test 'Anyone can view question' do + post = posts(:question_one) + visit post_url(post) + + # Check that the post is displayed somewhere on the page + assert_text post.title + assert_text post.body + + # 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| + assert_text child.body + end + end + + test 'Anyone can sort answers' do + post = posts(:question_one) + visit post_url(post) + + click_on 'Active' + + assert_current_path post_url(post, sort: 'active') + end + + # ------------------------------------------------------- + # Edit + # ------------------------------------------------------- + + test 'User with edit permissions can directly edit question' do + log_in :editor + post = posts(:question_two) + visit post_url(post) + + within ".post[data-post-id=\"#{post.id}\"]" do + click_on 'Edit' + end + + updated_text = 'This is the updated body text, which should be quite different from the original text!' + fill_in 'Body', with: updated_text + fill_in 'Edit Comment', with: 'Major Rewrite for Tests' + + click_on "Save Post in #{post.category.name}" + assert_current_path post_url(post) + + # Check that the page shows the updated text + assert_text updated_text + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index c41d9b27c..638cf4085 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -14,18 +14,39 @@ class ActiveSupport::TestCase # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all - setup :load_seeds + setup :set_request_context teardown :clear_cache protected - def load_seeds + # Overrides minitest' load_fixtures method to also load our seeds when fixtures are loaded. + # 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) + load_tags_paths + load_seeds + + # We do need to return the same thing as the original method to not break fixtures + fixture_data + end + + # Ensures that a community is set for all requests that will be made (on this thread) + def set_request_context comm = Community.first || Community.create(name: 'Test', host: 'test.host') RequestContext.community = comm + end + + def load_seeds + set_request_context Rails.application.load_seed end + def load_tags_paths + ActiveRecord::Base.connection.execute File.read(Rails.root.join('db/scripts/create_tags_path_view.sql')) + end + def clear_cache Rails.cache.clear end