Merge branch 'master' into theme-select-mobile

This commit is contained in:
Joffrey JAFFEUX 2018-09-20 10:31:41 +02:00 committed by GitHub
commit 87a97cbf4b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
98 changed files with 969 additions and 563 deletions

View File

@ -34,7 +34,7 @@ gem 'redis-namespace'
gem 'active_model_serializers', '~> 0.8.3'
gem 'onebox', '1.8.60'
gem 'onebox', '1.8.61'
gem 'http_accept_language', '~>2.0.5', require: false

View File

@ -44,20 +44,20 @@ GEM
arel (9.0.0)
ast (2.4.0)
aws-eventstream (1.0.1)
aws-partitions (1.92.0)
aws-sdk-core (3.21.2)
aws-partitions (1.104.0)
aws-sdk-core (3.27.0)
aws-eventstream (~> 1.0)
aws-partitions (~> 1.0)
aws-sigv4 (~> 1.0)
jmespath (~> 1.0)
aws-sdk-kms (1.5.0)
aws-sdk-core (~> 3)
aws-sdk-kms (1.9.0)
aws-sdk-core (~> 3, >= 3.26.0)
aws-sigv4 (~> 1.0)
aws-sdk-s3 (1.14.0)
aws-sdk-core (~> 3, >= 3.21.2)
aws-sdk-s3 (1.19.0)
aws-sdk-core (~> 3, >= 3.26.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.0)
aws-sigv4 (1.0.2)
aws-sigv4 (1.0.3)
barber (0.12.0)
ember-source (>= 1.0, < 3.1)
execjs (>= 1.2, < 3)
@ -257,7 +257,7 @@ GEM
omniauth-twitter (1.4.0)
omniauth-oauth (~> 1.1)
rack
onebox (1.8.60)
onebox (1.8.61)
htmlentities (~> 4.3)
moneta (~> 1.0)
multi_json (~> 1.11)
@ -510,7 +510,7 @@ DEPENDENCIES
omniauth-oauth2
omniauth-openid
omniauth-twitter
onebox (= 1.8.60)
onebox (= 1.8.61)
openid-redis-store
pg
pry-nav

View File

@ -2,6 +2,7 @@ import debounce from "discourse/lib/debounce";
import { renderSpinner } from "discourse/helpers/loading-spinner";
import { escapeExpression } from "discourse/lib/utilities";
import { bufferedRender } from "discourse-common/lib/buffered-render";
import { observes, on } from "ember-addons/ember-computed-decorators";
export default Ember.Component.extend(
bufferedRender({
@ -21,30 +22,38 @@ export default Ember.Component.extend(
$div.scrollTop = $div.scrollHeight;
},
_updateFormattedLogs: debounce(function() {
const logs = this.get("logs");
if (logs.length === 0) {
@on("init")
@observes("logs.[]")
_resetFormattedLogs() {
if (this.get("logs").length === 0) {
this._reset(); // reset the cached logs whenever the model is reset
} else {
// do the log formatting only once for HELLish performance
let formattedLogs = this.get("formattedLogs");
for (let i = this.get("index"), length = logs.length; i < length; i++) {
const date = logs[i].get("timestamp"),
message = escapeExpression(logs[i].get("message"));
formattedLogs += "[" + date + "] " + message + "\n";
}
// update the formatted logs & cache index
this.setProperties({
formattedLogs: formattedLogs,
index: logs.length
});
// force rerender
this.rerenderBuffer();
}
},
@on("init")
@observes("logs.[]")
_updateFormattedLogs: debounce(function() {
const logs = this.get("logs");
if (logs.length === 0) return;
// do the log formatting only once for HELLish performance
let formattedLogs = this.get("formattedLogs");
for (let i = this.get("index"), length = logs.length; i < length; i++) {
const date = logs[i].get("timestamp"),
message = escapeExpression(logs[i].get("message"));
formattedLogs += "[" + date + "] " + message + "\n";
}
// update the formatted logs & cache index
this.setProperties({
formattedLogs: formattedLogs,
index: logs.length
});
// force rerender
this.rerenderBuffer();
Ember.run.scheduleOnce("afterRender", this, this._scrollDown);
}, 150)
.observes("logs.[]")
.on("init"),
}, 150),
buildBuffer(buffer) {
const formattedLogs = this.get("formattedLogs");

View File

@ -1,24 +1,17 @@
import ModalFunctionality from "discourse/mixins/modal-functionality";
import Backup from "admin/models/backup";
export default Ember.Controller.extend(ModalFunctionality, {
adminBackupsLogs: Ember.inject.controller(),
_startBackup(withUploads) {
this.currentUser.set("hideReadOnlyAlert", true);
Backup.start(withUploads).then(() => {
this.get("adminBackupsLogs.logs").clear();
this.send("backupStarted");
});
},
actions: {
startBackup() {
this._startBackup();
startBackupWithUploads() {
this.send("closeModal");
this.send("startBackup", true);
},
startBackupWithoutUpload() {
this._startBackup(false);
startBackupWithoutUploads() {
this.send("closeModal");
this.send("startBackup", false);
},
cancel() {

View File

@ -10,6 +10,7 @@ export default Discourse.Route.extend({
activate() {
this.messageBus.subscribe(LOG_CHANNEL, log => {
if (log.message === "[STARTED]") {
Discourse.User.currentProp("hideReadOnlyAlert", true);
this.controllerFor("adminBackups").set(
"model.isOperationRunning",
true
@ -62,15 +63,14 @@ export default Discourse.Route.extend({
},
actions: {
startBackup() {
showStartBackupModal() {
showModal("admin-start-backup", { admin: true });
this.controllerFor("modal").set("modalClass", "start-backup-modal");
},
backupStarted() {
this.controllerFor("adminBackups").set("isOperationRunning", true);
startBackup(withUploads) {
this.transitionTo("admin.backups.logs");
this.send("closeModal");
Backup.start(withUploads);
},
destroyBackup(backup) {
@ -100,17 +100,8 @@ export default Discourse.Route.extend({
I18n.t("yes_value"),
function(confirmed) {
if (confirmed) {
Discourse.User.currentProp("hideReadOnlyAlert", true);
backup.restore().then(function() {
self
.controllerFor("adminBackupsLogs")
.get("logs")
.clear();
self
.controllerFor("adminBackups")
.set("model.isOperationRunning", true);
self.transitionTo("admin.backups.logs");
});
self.transitionTo("admin.backups.logs");
backup.restore();
}
}
);

View File

@ -21,7 +21,7 @@
label="admin.backups.operations.cancel.label"
icon="times"}}
{{else}}
{{d-button action="startBackup"
{{d-button action="showStartBackupModal"
class="btn-primary"
title="admin.backups.operations.backup.title"
label="admin.backups.operations.backup.label"

View File

@ -1,8 +1,6 @@
{{#unless editingTheme}}
<div class='customize-themes-header'>
<div class="title">
<h3>{{i18n 'admin.customize.theme.long_title'}}</h3>
</div>
<div class="create-actions">
{{d-button label="admin.customize.new" icon="plus" action="showCreateModal" class="btn-primary"}}

View File

@ -1,5 +1,5 @@
{{#d-modal-body title="admin.backups.operations.backup.confirm"}}
<button {{action "startBackup"}} class="btn btn-primary">{{i18n 'yes_value'}}</button>
<button {{action "startBackupWithoutUpload"}} class="btn">{{i18n 'admin.backups.operations.backup.without_uploads'}}</button>
<button {{action "startBackupWithUploads"}} class="btn btn-primary">{{i18n 'yes_value'}}</button>
<button {{action "startBackupWithoutUploads"}} class="btn">{{i18n 'admin.backups.operations.backup.without_uploads'}}</button>
<button {{action "cancel"}} class="btn">{{i18n 'no_value'}}</button>
{{/d-modal-body}}

View File

@ -15,7 +15,7 @@
{{#each group.buttons as |b|}}
{{#if b.popupMenu}}
{{toolbar-popup-menu-options
onPopupMenuAction=onPopupMenuAction
onSelect=onPopupMenuAction
onExpand=(action b.action b)
title=b.title
headerIcon=b.icon

View File

@ -1,11 +1,8 @@
{{#d-modal-body class='change-ownership'}}
{{{i18n 'topic.change_owner.instructions' count=selectedPostsCount old_user=selectedPostsUsername}}}
<p>
{{{i18n 'topic.change_owner.instructions_warn'}}}
</p>
<form>
<label>{{i18n 'topic.change_owner.label'}}</label>
<label></label>
{{user-selector single="true"
usernames=new_user
placeholderKey="topic.change_owner.placeholder"

View File

@ -113,7 +113,7 @@
<td>
{{#if authProvider.method.can_revoke}}
{{#conditional-loading-spinner condition=revoking size='small'}}
{{d-button action="revokeAccount" actionParam=authProvider.account title="user.associated_accounts.revoke" icon="trash" }}
{{d-button action="revokeAccount" actionParam=authProvider.account title="user.associated_accounts.revoke" class="btn-danger no-text" icon="trash" }}
{{/conditional-loading-spinner}}
{{/if}}
</td>

View File

@ -8,7 +8,7 @@
{{tag-chooser
tags=model.watched_tags
blacklist=selectedTags
filterPlaceholder=null
filterPlaceholder="select_kit.filter_placeholder"
allowCreate=false
everyTag=true
unlimitedTagCount=true}}
@ -20,7 +20,7 @@
{{tag-chooser
tags=model.tracked_tags
blacklist=selectedTags
filterPlaceholder=null
filterPlaceholder="select_kit.filter_placeholder"
allowCreate=false
everyTag=true
unlimitedTagCount=true}}
@ -32,7 +32,7 @@
{{tag-chooser
tags=model.watching_first_post_tags
blacklist=selectedTags
filterPlaceholder=null
filterPlaceholder="select_kit.filter_placeholder"
allowCreate=false
everyTag=true
unlimitedTagCount=true}}
@ -44,7 +44,7 @@
{{tag-chooser
tags=model.muted_tags
blacklist=selectedTags
filterPlaceholder=null
filterPlaceholder="select_kit.filter_placeholder"
allowCreate=false
everyTag=true
unlimitedTagCount=true}}

View File

@ -0,0 +1,12 @@
(function() {
var preloadedDataElement = document.getElementById("data-preloaded");
if (preloadedDataElement) {
var ps = require("preload-store").default;
var preloaded = JSON.parse(preloadedDataElement.dataset.preloaded);
Object.keys(preloaded).forEach(function(key) {
ps.store(key, JSON.parse(preloaded[key]));
});
}
})();

View File

@ -42,16 +42,13 @@ export default ComboBoxSelectBoxHeaderComponent.extend({
if (categoryBackgroundColor || categoryTextColor) {
let style = "";
if (categoryBackgroundColor) {
if (categoryStyle === "bar") {
style += `border-color: #${categoryBackgroundColor};`;
} else if (categoryStyle === "box") {
style += `background-color: #${categoryBackgroundColor};`;
if (categoryStyle === "box") {
style += `border-color: #${categoryBackgroundColor}; background-color: #${categoryBackgroundColor};`;
if (categoryTextColor) {
style += `color: #${categoryTextColor};`;
}
}
}
return style.htmlSafe();
}
}

View File

@ -210,6 +210,10 @@ export default SelectKitComponent.extend({
},
select(computedContentItem) {
if (this.get("hasSelection")) {
this.deselect(this.get("selection.value"));
}
if (
!computedContentItem ||
computedContentItem.__sk_row_type === "noneRow"

View File

@ -12,10 +12,7 @@ export default DropdownSelectBoxComponent.extend({
return `<h3>${title}</h3>`;
},
mutateValue(value) {
this.sendAction("onPopupMenuAction", value);
this.setProperties({ value: null, highlighted: null });
},
autoHighlight() {},
computeContent(content) {
return content

View File

@ -550,8 +550,12 @@ $mobile-breakpoint: 700px;
@include breakpoint(mobile) {
margin: 0 -10px;
}
label {
margin-bottom: 0;
}
input {
margin: 0;
margin-right: 5px;
margin-bottom: 0;
@include breakpoint(tablet) {
max-width: 150px;
}
@ -614,8 +618,6 @@ $mobile-breakpoint: 700px;
}
}
.toggle {
margin-top: 8px;
float: right;
span {
font-weight: bold;
}
@ -624,14 +626,6 @@ $mobile-breakpoint: 700px;
display: inline-block;
margin-right: 5px;
}
#last-seen input[type="text"] {
float: none;
}
.ac-wrap {
display: inline-block;
vertical-align: middle;
padding: 0;
}
.pull-right {
padding-right: 10px;
}

View File

@ -61,13 +61,8 @@ $rollback-darker: darken($rollback, 20%) !default;
}
.admin-backups-logs {
max-height: 500px;
max-height: 65vh;
overflow: auto;
pre {
white-space: pre-wrap;
word-wrap: break-word;
max-height: 65vh;
}
}
button.ru {

View File

@ -71,6 +71,10 @@
margin-bottom: 0;
}
}
.create-actions {
margin-left: auto;
}
}
.admin-container {
padding: 0;
@ -570,8 +574,16 @@
}
}
.permalink-form .select-kit {
width: 150px;
.permalink-form {
display: flex;
align-items: center;
.select-kit {
width: 150px;
}
input {
margin: 0 5px;
}
}
.permalink-title {

View File

@ -1,13 +1,5 @@
// Styles for admin/emails
.email-preview {
.ac-wrap {
.item {
margin: 0.2em 0 0 0.4em;
}
}
}
// Emails
.email-list {
.filters input {

View File

@ -39,8 +39,6 @@
}
.input-setting-string,
.input-setting-textarea {
box-sizing: border-box;
height: 30px;
width: 100%;
@media (max-width: $mobile-breakpoint) {
width: 100%;

View File

@ -246,7 +246,7 @@
margin: 0 -0.25em 1em;
display: flex;
flex-wrap: wrap;
align-items: baseline;
align-items: center;
}
@media screen and (min-width: 800px) {
.screened-ip-address-form {

View File

@ -78,14 +78,19 @@
margin-left: 0;
}
.btn {
margin: 2px 5px 2px 0;
margin-right: 5px;
}
}
}
.admin-users .users-list {
.username .fa {
color: dark-light-choose($primary-medium, $secondary-medium);
.admin-users {
input {
margin-bottom: 0;
}
.users-list {
.username .fa {
color: dark-light-choose($primary-medium, $secondary-medium);
}
}
}

View File

@ -15,9 +15,9 @@
clear: both;
margin-bottom: 5px;
.combo-box .combo-box-header {
background: $primary-low;
background: $secondary;
color: $primary;
border: 1px solid transparent;
border: 1px solid $primary-medium;
padding: 5px 6px 5px 10px;
font-size: $font-0;
transition: none;

View File

@ -222,7 +222,8 @@
}
.add-warning {
margin-left: 1em;
margin-left: 0.75em;
margin-bottom: 0;
display: flex;
input {
margin-right: 5px;
@ -399,8 +400,11 @@ div.ac-wrap {
max-height: 150px;
display: flex;
flex-wrap: wrap;
align-items: center;
background-color: $secondary;
border: 1px solid $primary-medium;
min-height: 30px;
box-sizing: border-box;
div.item {
float: left;
padding: 4px 10px;
@ -426,6 +430,7 @@ div.ac-wrap {
border: 0;
margin: 0;
background: transparent;
min-height: unset;
}
}

View File

@ -111,15 +111,17 @@ span.relative-date {
}
label {
display: block;
display: flex;
margin-bottom: 5px;
align-items: flex-start;
}
input {
&[type="radio"],
&[type="checkbox"] {
margin: 3px 0;
line-height: $line-height-medium;
margin-top: 3px;
margin-right: 3px;
line-height: $line-height-small;
cursor: pointer;
}
&[type="submit"],
@ -192,11 +194,13 @@ input {
padding: $input-padding;
margin-bottom: 9px;
font-size: $font-0;
line-height: $line-height-large;
line-height: $line-height-small;
color: $primary;
background-color: $secondary;
border: 1px solid $primary-medium;
border-radius: 0;
box-sizing: border-box;
min-height: 30px;
&:focus {
border-color: $tertiary;
box-shadow: shadow("focus");

View File

@ -370,7 +370,8 @@
.edit-category-modal {
input[type="number"] {
width: 50px;
min-width: 8em;
margin-bottom: 0;
}
.subcategory-list-style-field {
@ -468,7 +469,7 @@
.change-timestamp,
.poll-ui-builder {
.date-picker {
width: 9em;
min-width: 8em;
}
#date-container {

View File

@ -161,6 +161,7 @@
.post-infos {
display: flex;
flex: 0 0 auto;
align-items: baseline;
}
}

View File

@ -11,10 +11,12 @@
padding: 6px 12px;
font-weight: 500;
font-size: $font-0;
line-height: $line-height-medium;
line-height: $line-height-small;
text-align: center;
cursor: pointer;
transition: all 0.25s;
box-sizing: border-box;
min-height: 30px;
&:active,
&.btn-active {
@ -50,6 +52,7 @@
&[href] {
color: $primary;
min-height: unset; // ovverides button defaults
}
&:hover,
&.btn-hover {

View File

@ -14,7 +14,7 @@
.date-picker {
text-align: center;
width: 80px;
width: 8em;
margin: 0;
}

View File

@ -30,7 +30,11 @@
padding: 6px 12px;
color: $primary;
font-size: $font-up-1;
line-height: $line-height-medium;
line-height: $line-height-small;
box-sizing: border-box;
min-height: 30px;
display: flex;
align-items: center;
transition: background 0.15s;
.d-icon {

View File

@ -15,7 +15,7 @@
}
&.bar.has-selection .category-drop-header {
border: none;
padding: 4px 5px 4px 10px;
}
&.box.has-selection .category-drop-header {

View File

@ -18,11 +18,14 @@
}
.select-kit-filter {
line-height: $line-height-medium;
padding: $input-padding;
border-top: 1px solid dark-light-diff($primary, $secondary, 90%, -60%);
border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%);
.spinner {
flex: 0 0 auto;
}
.filter-input,
.filter-input:focus,
.filter-input:active {
@ -41,8 +44,6 @@
padding: $input-padding;
font-weight: 500;
font-size: $font-0;
line-height: $line-height-large;
min-height: 2em; // when no content is available
&.is-focused {
border: 1px solid $tertiary;

View File

@ -18,7 +18,6 @@
.select-kit-body {
max-width: 32em;
width: 32em;
}
.select-kit-header {

View File

@ -18,6 +18,7 @@
.select-kit-filter {
border: 0;
flex: 1;
margin: 1px;
}
.multi-select-header {
@ -64,7 +65,7 @@
}
.choices {
margin: 1px;
margin: 0 2px;
box-sizing: border-box;
display: flex;
justify-content: flex-start;
@ -73,13 +74,11 @@
.choice {
display: inline-flex;
box-sizing: border-box;
padding: 0 5px;
border: 1px solid transparent;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
flex-direction: row;
margin: 1px;
margin: 1px 0px 2px 2px;
}
.filter {
@ -105,7 +104,7 @@
border: 0;
box-shadow: none;
border-radius: 0;
height: 21px;
min-height: unset; // overrides input defaults
}
}
@ -118,7 +117,7 @@
.color-preview {
height: 5px;
margin: 0 2px 2px 2px;
margin: 0 2px 2px 0px;
display: flex;
width: 100%;
}
@ -148,36 +147,18 @@
}
.selected-name {
color: $primary;
background-clip: padding-box;
-webkit-touch-callout: none;
user-select: none;
background-color: $primary-low;
cursor: pointer;
outline: none;
line-height: $line-height-medium;
overflow: hidden;
flex: 0 1 auto;
flex-wrap: nowrap;
padding: 0;
display: flex;
flex-direction: column;
flex: unset;
.footer {
display: flex;
width: 100%;
}
.body {
display: flex;
align-items: center;
flex: 1;
background: $primary-low;
padding: 4px;
}
.name {
padding: 2px 4px;
line-height: $line-height-medium;
&:after {
content: "\f00d";
color: $primary-low-mid;

View File

@ -66,10 +66,13 @@
align-items: center;
justify-content: space-between;
flex-direction: row;
min-height: 30px;
line-height: $line-height-small;
.selected-name {
text-align: left;
flex: 1 1 auto;
padding: 1px 0;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;

View File

@ -9,17 +9,3 @@ thead {
padding-left: 2px;
}
}
$filter-line-height: 1.5;
.groups-header-filters {
.groups-header-filters-type {
.select-kit-header {
line-height: $filter-line-height;
}
}
input {
line-height: $filter-line-height;
}
}

View File

@ -265,28 +265,34 @@
padding-top: 10px;
padding-left: 30px;
.form-vertical {
width: 400px;
max-width: 100%;
}
h3 {
color: $primary;
margin: 20px 0 10px 0;
}
textarea {
width: 530px;
}
.category-selector,
.tag-chooser {
width: 530px;
.tag-chooser,
textarea {
width: 100%;
}
input {
&.user-selector {
width: 530px;
width: 100%;
}
}
&[type="text"] {
@include breakpoint(medium) {
width: 450px;
.tag-controls,
.category-controls {
label {
align-items: center;
.d-icon {
margin-right: 3px;
}
}
}
@ -314,4 +320,12 @@
.user-main & .user-field.text {
padding-top: 0;
}
.image-upload-controls {
display: flex;
align-items: center;
.btn {
margin-right: 5px;
}
}
}

View File

@ -19,6 +19,7 @@
box-sizing: border-box;
display: flex;
align-self: stretch;
align-items: center;
margin: 0 3px 10px 3px;
order: 10; // always last for consistent placement
}

View File

@ -1,42 +0,0 @@
require_dependency 'memory_diagnostics'
class Admin::DiagnosticsController < Admin::AdminController
layout false
skip_before_action :check_xhr
def memory_stats
text = nil
if params.key?(:diff)
if !MemoryDiagnostics.snapshot_exists?
text = "No initial snapshot exists"
else
text = MemoryDiagnostics.compare
end
elsif params.key?(:snapshot)
MemoryDiagnostics.snapshot_current_process
text = "Writing snapshot to: #{MemoryDiagnostics.snapshot_filename}\n\nTo get a diff use ?diff=1"
else
text = MemoryDiagnostics.memory_report(class_report: params.key?(:full))
end
render plain: text
end
def dump_heap
begin
# ruby 2.1
GC.start(full_mark: true)
require 'objspace'
io = File.open("discourse-heap-#{SecureRandom.hex(3)}.json", 'w')
ObjectSpace.dump_all(output: io)
io.close
render plain: "HEAP DUMP:\n#{io.path}"
rescue
render plain: "HEAP DUMP:\nnot supported"
end
end
end

View File

@ -5,6 +5,7 @@ class ExportCsvController < ApplicationController
def export_entity
guardian.ensure_can_export_entity!(export_params[:entity])
Jobs.enqueue(:export_csv_file, entity: export_params[:entity], user_id: current_user.id, args: export_params[:args])
StaffActionLogger.new(current_user).log_entity_export(export_params[:entity])
render json: success_json
end

View File

@ -1,5 +1,7 @@
class TagGroupsController < ApplicationController
requires_login except: [:index, :show]
requires_login
before_action :ensure_staff
skip_before_action :check_xhr, only: [:index, :show]
before_action :fetch_tag_group, only: [:show, :update, :destroy]

View File

@ -865,16 +865,19 @@ class UsersController < ApplicationController
end
end
user.uploaded_avatar_id = upload_id
upload = Upload.find_by(id: upload_id)
# old safeguard
user.create_user_avatar unless user.user_avatar
guardian.ensure_can_pick_avatar!(user.user_avatar, upload)
if AVATAR_TYPES_WITH_UPLOAD.include?(type)
# make sure the upload exists
unless Upload.where(id: upload_id).exists?
if !upload
return render_json_error I18n.t("avatar.missing")
end
user.create_user_avatar unless user.user_avatar
if type == "gravatar"
user.user_avatar.gravatar_upload_id = upload_id
else
@ -882,6 +885,7 @@ class UsersController < ApplicationController
end
end
user.uploaded_avatar_id = upload_id
user.save!
user.user_avatar.save!

View File

@ -129,7 +129,7 @@ module ApplicationHelper
javascript = javascript.scrub
javascript.gsub!(/\342\200\250/u, '&#x2028;')
javascript.gsub!(/(<\/)/u, '\u003C/')
javascript.html_safe
javascript
else
''
end
@ -401,4 +401,9 @@ module ApplicationHelper
Stylesheet::Manager.stylesheet_link_tag(name, 'all', ids)
end
def preloaded_json
return '{}' if @preloaded.blank?
@preloaded.transform_values { |value| escape_unicode(value) }.to_json
end
end

View File

@ -19,7 +19,7 @@ class Jobs::Onceoff < Jobs::Base
begin
return if OnceoffLog.where(job_name: job_name).exists? && !args[:force]
execute_onceoff(args)
OnceoffLog.create(job_name: job_name)
OnceoffLog.create!(job_name: job_name)
ensure
$redis.del(running_key_name) if has_lock
end

View File

@ -1,7 +1,7 @@
require_dependency "upload_recovery"
module Jobs
class RecoverPostUploads < Jobs::Onceoff
class PostUploadsRecovery < Jobs::Onceoff
MIN_PERIOD = 30
MAX_PERIOD = 120

View File

@ -25,7 +25,6 @@ class Group < ActiveRecord::Base
before_save :cook_bio
after_save :destroy_deletions
after_save :automatic_group_membership
after_save :update_primary_group
after_save :update_title
@ -35,6 +34,7 @@ class Group < ActiveRecord::Base
after_save :expire_cache
after_destroy :expire_cache
after_commit :automatic_group_membership, on: [:create, :update]
after_commit :trigger_group_created_event, on: :create
after_commit :trigger_group_updated_event, on: :update
after_commit :trigger_group_destroyed_event, on: :destroy

View File

@ -38,18 +38,23 @@ class OptimizedImage < ActiveRecord::Base
end
end
# prefer to look up the thumbnail without grabbing any locks
thumbnail = find_by(upload_id: upload.id, width: width, height: height)
# correct bad thumbnail if needed
if thumbnail && thumbnail.url.blank?
thumbnail.destroy!
thumbnail = nil
end
return thumbnail if thumbnail
lock(upload.id, width, height) do
# do we already have that thumbnail?
# may have been generated since we got the lock
thumbnail = find_by(upload_id: upload.id, width: width, height: height)
# make sure we have an url
if thumbnail && thumbnail.url.blank?
thumbnail.destroy
thumbnail = nil
end
# return the previous thumbnail if any
return thumbnail unless thumbnail.nil?
return thumbnail if thumbnail
# create the thumbnail otherwise
original_path = Discourse.store.path_for(upload)

View File

@ -221,8 +221,9 @@ class Topic < ActiveRecord::Base
unless skip_callbacks
ensure_topic_has_a_category
end
if title_changed?
write_attribute :fancy_title, Topic.fancy_title(title)
write_attribute(:fancy_title, Topic.fancy_title(title))
end
if category_id_changed? || new_record?
@ -346,10 +347,9 @@ class Topic < ActiveRecord::Base
end
def self.fancy_title(title)
escaped = ERB::Util.html_escape(title)
return unless escaped
return unless escaped = ERB::Util.html_escape(title)
fancy_title = Emoji.unicode_unescape(HtmlPrettify.render(escaped))
fancy_title.length > Topic.max_fancy_title_length ? title : fancy_title
fancy_title.length > Topic.max_fancy_title_length ? escaped : fancy_title
end
def fancy_title

View File

@ -15,6 +15,7 @@ class Upload < ActiveRecord::Base
has_many :posts, through: :post_uploads
has_many :optimized_images, dependent: :destroy
has_many :user_uploads, dependent: :destroy
attr_accessor :for_group_message
attr_accessor :for_theme
@ -168,6 +169,10 @@ class Upload < ActiveRecord::Base
Digest::SHA1.file(path).hexdigest
end
def self.extract_upload_url(url)
url.match(/(\/original\/\dX[\/\.\w]*\/([a-zA-Z0-9]+)[\.\w]*)/)
end
def self.get_from_url(url)
return if url.blank?
@ -177,7 +182,7 @@ class Upload < ActiveRecord::Base
end
return if uri&.path.blank?
data = uri.path.match(/(\/original\/\dX[\/\.\w]*\/([a-zA-Z0-9]+)[\.\w]*)/)
data = extract_upload_url(uri.path)
return if data.blank?
sha1 = data[2]
upload = nil

View File

@ -53,6 +53,7 @@ class User < ActiveRecord::Base
has_many :groups, through: :group_users
has_many :secure_categories, through: :groups, source: :categories
has_many :user_uploads, dependent: :destroy
has_many :user_emails, dependent: :destroy
has_one :primary_email, -> { where(primary: true) }, class_name: 'UserEmail', dependent: :destroy

View File

@ -81,7 +81,8 @@ class UserHistory < ActiveRecord::Base
removed_unsilence_user: 62,
removed_unsuspend_user: 63,
post_rejected: 64,
merge_user: 65
merge_user: 65,
entity_export: 66
)
end
@ -141,7 +142,8 @@ class UserHistory < ActiveRecord::Base
:change_badge,
:delete_badge,
:post_rejected,
:merge_user
:merge_user,
:entity_export
]
end

View File

@ -0,0 +1,4 @@
class UserUpload < ActiveRecord::Base
belongs_to :upload
belongs_to :user
end

View File

@ -11,8 +11,8 @@ class SearchIndexer
@disabled = false
end
def self.scrub_html_for_search(html)
HtmlScrubber.scrub(html)
def self.scrub_html_for_search(html, strip_diacritics: SiteSetting.search_ignore_accents)
HtmlScrubber.scrub(html, strip_diacritics: strip_diacritics)
end
def self.inject_extra_terms(raw)
@ -169,18 +169,10 @@ class SearchIndexer
DIACRITICS ||= /([\u0300-\u036f]|[\u1AB0-\u1AFF]|[\u1DC0-\u1DFF]|[\u20D0-\u20FF])/
def self.strip_diacritics(str)
s = str.unicode_normalize(:nfkd)
s.gsub!(DIACRITICS, "")
s.strip!
s
end
attr_reader :scrubbed
def initialize(strip_diacritics: false)
@scrubbed = +""
# for now we are disabling this per: https://meta.discourse.org/t/discourse-should-ignore-if-a-character-is-accented-when-doing-a-search/90198/16?u=sam
@strip_diacritics = strip_diacritics
end
@ -189,7 +181,7 @@ class SearchIndexer
me = new(strip_diacritics: strip_diacritics)
Nokogiri::HTML::SAX::Parser.new(me).parse("<div>#{html}</div>")
me.scrubbed
me.scrubbed.squish
end
ATTRIBUTES ||= %w{alt title href data-youtube-title}
@ -204,8 +196,15 @@ class SearchIndexer
end
end
def strip_diacritics(str)
s = str.unicode_normalize(:nfkd)
s.gsub!(DIACRITICS, "")
s.strip!
s
end
def characters(str)
str = HtmlScrubber.strip_diacritics(str) if @strip_diacritics
str = strip_diacritics(str) if @strip_diacritics
scrubbed << " #{str} "
end
end

View File

@ -459,6 +459,14 @@ class StaffActionLogger
))
end
def log_entity_export(entity, opts = {})
UserHistory.create!(params(opts).merge(
action: UserHistory.actions[:entity_export],
ip_address: @admin.ip_address.to_s,
subject: entity
))
end
def log_backup_download(backup, opts = {})
raise Discourse::InvalidParameters.new(:backup) unless backup
UserHistory.create!(params(opts).merge(

View File

@ -55,6 +55,9 @@
<%= render partial: "common/discourse_stylesheet" %>
<%= render partial: "common/special_font_face" %>
<meta id="data-preloaded" data-preloaded="<%= preloaded_json %>">
<%= preload_script "preload-application-data" %>
<%= yield :head %>
<%= build_plugin_html 'server:before-head-close' %>
@ -104,17 +107,6 @@
</form>
<% end %>
<%- if @preloaded.present? %>
<script>
(function() {
var ps = require('preload-store').default;
<%- @preloaded.each do |key, json| %>
ps.store("<%= key %>", <%= escape_unicode(json) %>);
<% end %>
})();
</script>
<%- end %>
<%= yield :data %>
<%= render :partial => "common/discourse_javascript" %>

View File

@ -119,6 +119,7 @@ module Discourse
service-worker.js
google-tag-manager.js
google-universal-analytics.js
preload-application-data.js
}
# Precompile all available locales

View File

@ -1806,7 +1806,7 @@ bs_BA:
action: "spoji izabrane postove"
error: "Desila se greška prilikom spajanja označenih objava."
change_owner:
title: "Change Owner of Posts"
title: "Change Owner"
action: "change ownership"
error: "There was an error changing the ownership of the posts."
label: "New Owner of Posts"

View File

@ -2000,15 +2000,13 @@ en:
error: "There was an error merging the selected posts."
change_owner:
title: "Change Owner of Posts"
title: "Change Owner"
action: "change ownership"
error: "There was an error changing the ownership of the posts."
label: "New Owner of Posts"
placeholder: "username of new owner"
instructions:
one: "Please choose the new owner of the post by <b>{{old_user}}</b>."
other: "Please choose the new owner of the {{count}} posts by <b>{{old_user}}</b>."
instructions_warn: "Note that any notifications about this post will not be transferred to the new user retroactively."
one: "Please choose a new owner for the post by <b>@{{old_user}}</b>"
other: "Please choose a new owner for the {{count}} posts by <b>@{{old_user}}</b>"
change_timestamp:
title: "Change Timestamp..."
@ -3522,6 +3520,7 @@ en:
change_badge: "change badge"
delete_badge: "delete badge"
merge_user: "merge user"
entity_export: "export entity"
screened_emails:
title: "Screened Emails"
description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed."

View File

@ -1144,6 +1144,7 @@ en:
log_search_queries: "Log search queries performed by users"
search_query_log_max_size: "Maximum amount of search queries to keep"
search_query_log_max_retention_days: "Maximum amount of time to keep search queries, in days."
search_ignore_accents: "Ignore accents when searching for text."
allow_uncategorized_topics: "Allow topics to be created without a category. WARNING: If there are any uncategorized topics, you must recategorize them before turning this off."
allow_duplicate_topic_titles: "Allow topics with identical, duplicate titles."
unique_posts_mins: "How many minutes before a user can make a post with the same content again"

View File

@ -290,9 +290,6 @@ Discourse::Application.routes.draw do
post "preview" => "badges#preview"
end
end
get "memory_stats" => "diagnostics#memory_stats", constraints: AdminConstraint.new
get "dump_heap" => "diagnostics#dump_heap", constraints: AdminConstraint.new
end # admin namespace
get "email_preferences" => "email#preferences_redirect", :as => "email_preferences_redirect"
@ -793,7 +790,7 @@ Discourse::Application.routes.draw do
end
end
resources :tag_groups, except: [:new, :edit] do
resources :tag_groups, constraints: StaffConstraint.new, except: [:new, :edit] do
collection do
get '/filter/search' => 'tag_groups#search'
end

View File

@ -934,6 +934,7 @@ files:
clean_orphan_uploads_grace_period_hours: 48
purge_deleted_uploads_grace_period_days:
default: 30
shadowed_by_global: true
prevent_anons_from_downloading_files:
default: false
client: true
@ -1431,7 +1432,6 @@ search:
zh_TW: 2
ko: 2
ja: 2
search_tokenize_chinese_japanese_korean: false
search_prefer_recent_posts: false
search_recent_posts_size:
@ -1446,6 +1446,22 @@ search:
search_query_log_max_retention_days:
default: 365 # 1 year
max: 1825 # 5 years
search_ignore_accents:
default: false
locale_default:
ar: true
ca: true
cs: true
el: true
es: true
fa_IR: true
fr: true
hu: true
pt: true
pt_BR: true
ro: true
sk: true
tr_TR: true
uncategorized:
version_checks:

View File

@ -0,0 +1,22 @@
class CreateUserUploads < ActiveRecord::Migration[5.2]
def up
create_table :user_uploads do |t|
t.integer :upload_id, null: false
t.integer :user_id, null: false
t.datetime :created_at, null: false
end
add_index :user_uploads, [:upload_id, :user_id], unique: true
execute <<~SQL
INSERT INTO user_uploads(upload_id, user_id, created_at)
SELECT id, user_id, COALESCE(created_at, current_timestamp)
FROM uploads
WHERE user_id IS NOT NULL
SQL
end
def down
drop_table :user_uploads
end
end

View File

@ -54,13 +54,10 @@ module BackupRestore
@success = true
File.join(@archive_directory, @backup_filename)
ensure
begin
notify_user
remove_old
clean_up
rescue => ex
Rails.logger.error("#{ex}\n" + ex.backtrace.join("\n"))
end
remove_old
clean_up
notify_user
log "Finished!"
@success ? log("[SUCCESS]") : log("[FAILED]")
end
@ -255,6 +252,8 @@ module BackupRestore
def remove_old
log "Removing old backups..."
Backup.remove_old
rescue => ex
log "Something went wrong while removing old backups.", ex
end
def notify_user
@ -270,6 +269,8 @@ module BackupRestore
end
post
rescue => ex
log "Something went wrong while notifying user.", ex
end
def clean_up
@ -279,42 +280,49 @@ module BackupRestore
disable_readonly_mode if Discourse.readonly_mode?
mark_backup_as_not_running
refresh_disk_space
log "Finished!"
end
def refresh_disk_space
log "Refreshing disk cache..."
log "Refreshing disk stats..."
DiskSpace.reset_cached_stats
rescue => ex
log "Something went wrong while refreshing disk stats.", ex
end
def remove_tar_leftovers
log "Removing '.tar' leftovers..."
Dir["#{@archive_directory}/*.tar"].each { |filename| File.delete(filename) }
rescue => ex
log "Something went wrong while removing '.tar' leftovers.", ex
end
def remove_tmp_directory
log "Removing tmp '#{@tmp_directory}' directory..."
FileUtils.rm_rf(@tmp_directory) if Dir[@tmp_directory].present?
rescue
log "Something went wrong while removing the following tmp directory: #{@tmp_directory}"
rescue => ex
log "Something went wrong while removing the following tmp directory: #{@tmp_directory}", ex
end
def unpause_sidekiq
log "Unpausing sidekiq..."
Sidekiq.unpause!
rescue
log "Something went wrong while unpausing Sidekiq."
rescue => ex
log "Something went wrong while unpausing Sidekiq.", ex
end
def disable_readonly_mode
return if @readonly_mode_was_enabled
log "Disabling readonly mode..."
Discourse.disable_readonly_mode
rescue => ex
log "Something went wrong while disabling readonly mode.", ex
end
def mark_backup_as_not_running
log "Marking backup as finished..."
BackupRestore.mark_as_not_running!
rescue => ex
log "Something went wrong while marking backup as finished.", ex
end
def ensure_directory_exists(directory)
@ -322,11 +330,12 @@ module BackupRestore
FileUtils.mkdir_p(directory)
end
def log(message)
def log(message, ex = nil)
timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S")
puts(message)
publish_log(message, timestamp)
save_log(message, timestamp)
Rails.logger.error("#{ex}\n" + ex.backtrace.join("\n")) if ex
end
def publish_log(message, timestamp)

View File

@ -103,12 +103,9 @@ module BackupRestore
else
@success = true
ensure
begin
notify_user
clean_up
rescue => ex
Rails.logger.error("#{ex}\n" + ex.backtrace.join("\n"))
end
clean_up
notify_user
log "Finished!"
@success ? log("[SUCCESS]") : log("[FAILED]")
end
@ -459,6 +456,8 @@ module BackupRestore
else
log "Could not send notification to '#{@user_info[:username]}' (#{@user_info[:email]}), because the user does not exists..."
end
rescue => ex
log "Something went wrong while notifying user.", ex
end
def clean_up
@ -467,32 +466,35 @@ module BackupRestore
unpause_sidekiq
disable_readonly_mode if Discourse.readonly_mode?
mark_restore_as_not_running
log "Finished!"
end
def remove_tmp_directory
log "Removing tmp '#{@tmp_directory}' directory..."
FileUtils.rm_rf(@tmp_directory) if Dir[@tmp_directory].present?
rescue
log "Something went wrong while removing the following tmp directory: #{@tmp_directory}"
rescue => ex
log "Something went wrong while removing the following tmp directory: #{@tmp_directory}", ex
end
def unpause_sidekiq
log "Unpausing sidekiq..."
Sidekiq.unpause!
rescue
log "Something went wrong while unpausing Sidekiq."
rescue => ex
log "Something went wrong while unpausing Sidekiq.", ex
end
def disable_readonly_mode
return if @readonly_mode_was_enabled
log "Disabling readonly mode..."
Discourse.disable_readonly_mode
rescue => ex
log "Something went wrong while disabling readonly mode.", ex
end
def mark_restore_as_not_running
log "Marking restore as finished..."
BackupRestore.mark_as_not_running!
rescue => ex
log "Something went wrong while marking restore as finished.", ex
end
def ensure_directory_exists(directory)
@ -500,11 +502,12 @@ module BackupRestore
FileUtils.mkdir_p(directory)
end
def log(message)
def log(message, ex = nil)
timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S")
puts(message)
publish_log(message, timestamp)
save_log(message, timestamp)
Rails.logger.error("#{ex}\n" + ex.backtrace.join("\n")) if ex
end
def publish_log(message, timestamp)

View File

@ -7,15 +7,28 @@ class DistributedMutex
def initialize(key, redis = nil)
@key = key
@using_global_redis = true if !redis
@redis = redis || $redis
@mutex = Mutex.new
end
CHECK_READONLY_ATTEMPT ||= 10
# NOTE wrapped in mutex to maintain its semantics
def synchronize
@mutex.lock
attempts = 0
while !try_to_get_lock
sleep 0.001
# in readonly we will never be able to get a lock
if @using_global_redis && Discourse.recently_readonly?
attempts += 1
if attempts > CHECK_READONLY_ATTEMPT
raise Discourse::ReadOnly
end
end
end
yield

View File

@ -17,6 +17,7 @@ module FileStore
dir = Pathname.new(destination).dirname
FileUtils.mkdir_p(dir) unless Dir.exists?(dir)
FileUtils.move(source, destination, force: true)
FileUtils.touch(destination)
end
def has_been_uploaded?(url)

View File

@ -1,6 +1,23 @@
# mixin for all Guardian methods dealing with user permissions
module UserGuardian
def can_pick_avatar?(user_avatar, upload)
return false unless self.user
return true if is_admin?
# can always pick blank avatar
return true if !upload
return true if user_avatar.contains_upload?(upload.id)
return true if upload.user_id == user_avatar.user_id || upload.user_id == user.id
UserUpload.exists?(
upload_id: upload.id,
user_id: [upload.user_id, user.id]
)
end
def can_edit_user?(user)
is_me?(user) || is_staff?
end

View File

@ -1,169 +0,0 @@
module MemoryDiagnostics
def self.snapshot_exists?
File.exists?(snapshot_filename)
end
def self.compare(from = nil, to = nil)
from ||= snapshot_filename
if !to
filename = snapshot_filename + ".new"
snapshot_current_process(filename)
to = filename
end
from = Marshal::load(IO.binread(from));
to = Marshal::load(IO.binread(to));
diff = from - to
require 'objspace'
diff = diff.map do |id|
ObjectSpace._id2ref(id) rescue nil
end
diff.compact!
report = "#{diff.length} objects have leaked\n"
report << "Summary:\n"
summary = {}
diff.each do |obj|
begin
summary[obj.class] ||= 0
summary[obj.class] += 1
rescue
# don't care
end
end
report << summary.sort { |a, b| b[1] <=> a[1] }[0..50].map { |k, v|
"#{k}: #{v}"
}.join("\n")
report << "\n\nSample Items:\n"
diff[0..5000].each do |v|
report << "#{v.class}: #{String === v ? v[0..300] : (40 + ObjectSpace.memsize_of(v)).to_s + " bytes"}\n" rescue nil
end
report
end
def self.snapshot_path
"#{Rails.root}/tmp/mem_snapshots"
end
def self.snapshot_filename
"#{snapshot_path}/#{Process.pid}.snapshot"
end
def self.snapshot_current_process(filename = nil)
filename ||= snapshot_filename
pid = fork do
snapshot(filename)
end
Process.wait(pid)
end
def self.snapshot(filename)
require 'objspace'
FileUtils.mkdir_p snapshot_path
object_ids = []
full_gc
ObjectSpace.each_object do |o|
begin
object_ids << o.object_id
rescue
# skip
end
end
IO.binwrite(filename, Marshal::dump(object_ids))
end
def self.memory_report(opts = {})
begin
# ruby 2.1
GC.start(full_mark: true)
rescue
GC.start
end
classes = {}
large_objects = []
if opts[:class_report]
require 'objspace'
ObjectSpace.each_object do |o|
begin
classes[o.class] ||= 0
classes[o.class] += 1
if (size = ObjectSpace.memsize_of(o)) > 200
large_objects << [size, o]
end
rescue
# all sorts of stuff can happen here BasicObject etc.
classes[:unknown] ||= 0
classes[:unknown] += 1
end
end
classes = classes.sort { |a, b| b[1] <=> a[1] }[0..40].map { |klass, count| "#{klass}: #{count}" }
classes << "\nLarge Objects (#{large_objects.length} larger than 200 bytes total size #{large_objects.map { |x, _| x }.sum}):\n"
classes += large_objects.sort { |a, b| b[0] <=> a[0] }[0..800].map do |size, object|
rval = "#{object.class}: size #{size}"
rval << " " << object.to_s[0..500].gsub("\n", "") if (String === object) || (Regexp === object)
rval << "\n"
rval
end
end
stats = GC.stat.map { |k, v| "#{k}: #{v}" }
counts = ObjectSpace.count_objects.sort { |a, b| b[1] <=> a[1] }.map { |k, v| "#{k}: #{v}" }
<<TEXT
#{`hostname`.strip} pid:#{Process.pid} #{`cat /proc/#{Process.pid}/cmdline`.strip.gsub(/[^a-z1-9\/]/i, ' ')}
GC STATS:
#{stats.join("\n")}
Objects:
#{counts.join("\n")}
Process Info:
#{`cat /proc/#{Process.pid}/status`}
Classes:
#{classes.length > 0 ? classes.join("\n") : "Class report omitted use ?full=1 to include it"}
TEXT
end
def self.full_gc
# gc start may not collect everything
GC.start while new_count = decreased_count(new_count)
end
def self.decreased_count(old)
count = count_objects
if !old || count < old
count
else
nil
end
end
def self.count_objects
i = 0
ObjectSpace.each_object do |obj|
i += 1
end
end
end

View File

@ -248,13 +248,15 @@ module Oneboxer
end
end
def self.blacklisted_domains
SiteSetting.onebox_domains_blacklist.split("|")
end
def self.external_onebox(url)
Rails.cache.fetch(onebox_cache_key(url), expires_in: 1.day) do
ignored = SiteSetting.onebox_domains_blacklist.split("|")
fd = FinalDestination.new(url, ignore_redirects: ignore_redirects, ignore_hostnames: ignored, force_get_hosts: force_get_hosts)
fd = FinalDestination.new(url, ignore_redirects: ignore_redirects, ignore_hostnames: blacklisted_domains, force_get_hosts: force_get_hosts)
uri = fd.resolve
return blank_onebox if uri.blank? || ignored.map { |hostname| uri.hostname.match?(hostname) }.any?
return blank_onebox if uri.blank? || blacklisted_domains.map { |hostname| uri.hostname.match?(hostname) }.any?
options = {
cache: {},

View File

@ -24,7 +24,7 @@ class Promotion
def review_tl0
if Promotion.tl1_met?(@user) && change_trust_level!(TrustLevel[1])
@user.enqueue_member_welcome_message
@user.enqueue_member_welcome_message unless @user.badges.where(id: Badge::BasicUser).count > 0
return true
end
false

View File

@ -63,13 +63,14 @@ class Search
end
def self.blurb_for(cooked, term = nil, blurb_length = 200)
cooked = SearchIndexer::HtmlScrubber.scrub(cooked).squish
blurb = nil
cooked = SearchIndexer.scrub_html_for_search(cooked)
if term
terms = term.split(/\s+/)
blurb = TextHelper.excerpt(cooked, terms.first, radius: blurb_length / 2, seperator: " ")
end
blurb = TextHelper.truncate(cooked, length: blurb_length, seperator: " ") if blurb.blank?
Sanitize.clean(blurb)
end

View File

@ -70,7 +70,7 @@ class SocketServer
rescue IOError, Errno::EPIPE
# nothing to do here, case its normal on shutdown
rescue => e
Rails.logger.warn("Failed to handle connection in stats socket #{e}:\n#{e.backtrace.join("\n")}")
Rails.logger.warn("Failed to handle connection #{e}:\n#{e.backtrace.join("\n")}")
ensure
socket&.close
end

View File

@ -74,7 +74,10 @@ class UploadCreator
end
# return the previous upload if any
return @upload unless @upload.nil?
if @upload
UserUpload.find_or_create_by!(user_id: user_id, upload_id: @upload.id) if user_id
return @upload
end
fixed_original_filename = nil
if is_image
@ -132,6 +135,10 @@ class UploadCreator
Jobs.enqueue(:create_avatar_thumbnails, upload_id: @upload.id, user_id: user_id)
end
if @upload.errors.empty?
UserUpload.find_or_create_by!(user_id: user_id, upload_id: @upload.id) if user_id
end
@upload
end
ensure

View File

@ -4,22 +4,40 @@ class UploadRecovery
end
def recover(posts = Post)
posts.where("raw LIKE '%upload:\/\/%'").find_each do |post|
posts.where("raw LIKE '%upload:\/\/%' OR raw LIKE '%href=%'").find_each do |post|
begin
analyzer = PostAnalyzer.new(post.raw, post.topic_id)
analyzer.cooked_stripped.css("img").each do |img|
if dom_class = img["class"]
if (Post.white_listed_image_classes & dom_class.split).count > 0
next
analyzer.cooked_stripped.css("img", "a").each do |media|
if media.name == "img"
if dom_class = media["class"]
if (Post.white_listed_image_classes & dom_class.split).count > 0
next
end
end
end
if img["data-orig-src"]
if @dry_run
puts "#{post.full_url} #{img["data-orig-src"]}"
else
recover_post_upload(post, img["data-orig-src"])
orig_src = media["data-orig-src"]
if orig_src
if @dry_run
puts "#{post.full_url} #{orig_src}"
else
recover_post_upload(post, Upload.sha1_from_short_url(orig_src))
end
end
elsif media.name == "a"
href = media["href"]
if href && data = Upload.extract_upload_url(href)
sha1 = data[2]
unless upload = Upload.get_from_url(href)
if @dry_run
puts "#{post.full_url} #{href}"
else
recover_post_upload(post, sha1)
end
end
end
end
end
@ -32,9 +50,8 @@ class UploadRecovery
private
def recover_post_upload(post, short_url)
sha1 = Upload.sha1_from_short_url(short_url)
return unless sha1.present?
def recover_post_upload(post, sha1)
return unless sha1.present? && sha1.length == Upload::SHA1_LENGTH
attributes = {
post: post,
@ -73,10 +90,12 @@ class UploadRecovery
@paths.each do |path|
if path =~ /#{sha1}/
begin
file = File.open(path, "r")
create_upload(file, File.basename(path), post)
tmp = Tempfile.new
tmp.write(File.read(path))
tmp.rewind
create_upload(tmp, File.basename(path), post)
ensure
file&.close
tmp&.close
end
end
end

View File

@ -37,7 +37,7 @@ after_initialize do
end
if dates.present?
post.custom_fields[DiscourseLocalDates::POST_CUSTOM_FIELD] = dates.to_json
post.custom_fields[DiscourseLocalDates::POST_CUSTOM_FIELD] = dates
post.save_custom_fields
elsif !post.custom_fields[DiscourseLocalDates::POST_CUSTOM_FIELD].nil?
post.custom_fields.delete(DiscourseLocalDates::POST_CUSTOM_FIELD)

View File

@ -0,0 +1,28 @@
require 'rails_helper'
describe Post do
before do
SiteSetting.queue_jobs = false
end
describe '#local_dates' do
it "should have correct custom fields" do
post = Fabricate(:post, raw: <<~SQL)
[date=2018-09-17 time=01:39:00 format="LLL" timezones="Europe/Paris|America/Los_Angeles"]
SQL
CookedPostProcessor.new(post).post_process
expect(post.local_dates.count).to eq(1)
expect(post.local_dates[0]["date"]).to eq("2018-09-17")
expect(post.local_dates[0]["time"]).to eq("01:39:00")
post.raw = "Text removed"
post.save
CookedPostProcessor.new(post).post_process
expect(post.local_dates).to eq([])
end
end
end

View File

@ -173,7 +173,7 @@ class BulkImport::DiscourseMerger < BulkImport::Base
columns = Category.columns.map(&:name)
imported_ids = []
last_id = Category.unscoped.maximum(:id)
last_id = Category.unscoped.maximum(:id) || 1
sql = "COPY categories (#{columns.map { |c| "\"#{c}\"" }.join(', ')}) FROM STDIN"
@raw_connection.copy_data(sql, @encoder) do
@ -249,7 +249,7 @@ class BulkImport::DiscourseMerger < BulkImport::Base
columns = Tag.columns.map(&:name)
imported_ids = []
last_id = Tag.unscoped.maximum(:id)
last_id = Tag.unscoped.maximum(:id) || 1
sql = "COPY tags (#{columns.map { |c| "\"#{c}\"" }.join(', ')}) FROM STDIN"
@raw_connection.copy_data(sql, @encoder) do
@ -366,7 +366,7 @@ class BulkImport::DiscourseMerger < BulkImport::Base
puts "merging badges..."
columns = Badge.columns.map(&:name)
imported_ids = []
last_id = Badge.unscoped.maximum(:id)
last_id = Badge.unscoped.maximum(:id) || 1
sql = "COPY badges (#{columns.map { |c| "\"#{c}\"" }.join(', ')}) FROM STDIN"
@raw_connection.copy_data(sql, @encoder) do

View File

@ -45,4 +45,29 @@ describe DistributedMutex do
}.to raise_error(ThreadError)
end
context "readonly redis" do
before do
$redis.slaveof "127.0.0.1", "99991"
end
after do
$redis.slaveof "no", "one"
end
it "works even if redis is in readonly" do
m = DistributedMutex.new("test_readonly")
start = Time.now
done = false
expect {
m.synchronize do
done = true
end
}.to raise_error(Discourse::ReadOnly)
expect(done).to eq(false)
expect(Time.now - start).to be < (1.second)
end
end
end

View File

@ -10,7 +10,7 @@ describe FileStore::LocalStore do
let(:optimized_image) { Fabricate(:optimized_image) }
describe ".store_upload" do
describe "#store_upload" do
it "returns a relative url" do
store.expects(:copy_file)
@ -19,7 +19,7 @@ describe FileStore::LocalStore do
end
describe ".store_optimized_image" do
describe "#store_optimized_image" do
it "returns a relative url" do
store.expects(:copy_file)
@ -28,7 +28,7 @@ describe FileStore::LocalStore do
end
describe ".remove_upload" do
describe "#remove_upload" do
it "does not delete non uploaded" do
FileUtils.expects(:mkdir_p).never
@ -36,26 +36,58 @@ describe FileStore::LocalStore do
end
it "moves the file to the tombstone" do
filename = File.basename(store.path_for(upload))
store.remove_upload(upload)
expect(File.exist?(store.tombstone_dir + "/" + filename))
begin
upload = UploadCreator.new(
file_from_fixtures("smallest.png"),
"smallest.png"
).create_for(Fabricate(:user).id)
path = store.path_for(upload)
mtime = File.mtime(path)
sleep 0.01 # Delay a little for mtime to be updated
store.remove_upload(upload)
tombstone_path = path.sub("/uploads/", "/uploads/tombstone/")
expect(File.exist?(tombstone_path)).to eq(true)
expect(File.mtime(tombstone_path)).to_not eq(mtime)
ensure
[path, tombstone_path].each do |file_path|
File.delete(file_path) if File.exist?(file_path)
end
end
end
end
describe ".remove_optimized_image" do
let(:optimized_image) { Fabricate(:optimized_image, url: "/uploads/default/_optimized/42/253dc8edf9d4ada1.png") }
describe "#remove_optimized_image" do
it "moves the file to the tombstone" do
FileUtils.expects(:mkdir_p)
FileUtils.expects(:move)
File.expects(:exists?).returns(true)
store.remove_optimized_image(optimized_image)
begin
upload = UploadCreator.new(
file_from_fixtures("smallest.png"),
"smallest.png"
).create_for(Fabricate(:user).id)
upload.create_thumbnail!(1, 1)
upload.reload
optimized_image = upload.thumbnail(1, 1)
path = store.path_for(optimized_image)
store.remove_optimized_image(optimized_image)
tombstone_path = path.sub("/uploads/", "/uploads/tombstone/")
expect(File.exist?(tombstone_path)).to eq(true)
ensure
[path, tombstone_path].each do |file_path|
File.delete(file_path) if File.exist?(file_path)
end
end
end
end
describe ".has_been_uploaded?" do
describe "#has_been_uploaded?" do
it "identifies relatives urls" do
expect(store.has_been_uploaded?("/uploads/default/42/0123456789ABCDEF.jpg")).to eq(true)
@ -85,7 +117,7 @@ describe FileStore::LocalStore do
Discourse.stubs(:base_uri).returns("/forum")
end
describe ".absolute_base_url" do
describe "#absolute_base_url" do
it "is present" do
expect(store.absolute_base_url).to eq("http://test.localhost/uploads/default")
@ -98,7 +130,7 @@ describe FileStore::LocalStore do
end
describe ".relative_base_url" do
describe "#relative_base_url" do
it "is present" do
expect(store.relative_base_url).to eq("/uploads/default")

View File

@ -0,0 +1,100 @@
require 'rails_helper'
describe UserGuardian do
let :user do
Fabricate.build(:user, id: 1)
end
let :moderator do
Fabricate.build(:moderator, id: 2)
end
let :admin do
Fabricate.build(:admin, id: 3)
end
let :user_avatar do
UserAvatar.new(user_id: user.id)
end
let :users_upload do
Upload.new(user_id: user_avatar.user_id, id: 1)
end
let :already_uploaded do
u = Upload.new(user_id: 999, id: 2)
user_avatar.custom_upload_id = u.id
u
end
let :not_my_upload do
Upload.new(user_id: 999, id: 3)
end
let(:moderator_upload) do
Upload.new(user_id: moderator.id, id: 4)
end
describe '#can_pick_avatar?' do
let :guardian do
Guardian.new(user)
end
context 'anon user' do
let(:guardian) { Guardian.new }
it "should return the right value" do
expect(guardian.can_pick_avatar?(user_avatar, users_upload)).to eq(false)
end
end
context 'current user' do
it "can not set uploads not owned by current user" do
expect(guardian.can_pick_avatar?(user_avatar, users_upload)).to eq(true)
expect(guardian.can_pick_avatar?(user_avatar, already_uploaded)).to eq(true)
expect(guardian.can_pick_avatar?(user_avatar, not_my_upload)).to eq(false)
expect(guardian.can_pick_avatar?(user_avatar, nil)).to eq(true)
end
it "can handle uploads that are associated but not directly owned" do
yes_my_upload = not_my_upload
UserUpload.create!(upload_id: yes_my_upload.id, user_id: user_avatar.user_id)
expect(guardian.can_pick_avatar?(user_avatar, yes_my_upload)).to eq(true)
UserUpload.destroy_all
UserUpload.create!(upload_id: yes_my_upload.id, user_id: yes_my_upload.user_id)
expect(guardian.can_pick_avatar?(user_avatar, yes_my_upload)).to eq(true)
end
end
context 'moderator' do
let :guardian do
Guardian.new(moderator)
end
it "is secure" do
expect(guardian.can_pick_avatar?(user_avatar, moderator_upload)).to eq(true)
expect(guardian.can_pick_avatar?(user_avatar, users_upload)).to eq(true)
expect(guardian.can_pick_avatar?(user_avatar, already_uploaded)).to eq(true)
expect(guardian.can_pick_avatar?(user_avatar, not_my_upload)).to eq(false)
expect(guardian.can_pick_avatar?(user_avatar, nil)).to eq(true)
end
end
context 'admin' do
let :guardian do
Guardian.new(admin)
end
it "is secure" do
expect(guardian.can_pick_avatar?(user_avatar, not_my_upload)).to eq(true)
expect(guardian.can_pick_avatar?(user_avatar, nil)).to eq(true)
end
end
end
end

View File

@ -116,4 +116,12 @@ describe Oneboxer do
expect(Oneboxer.external_onebox('https://discourse.org/')[:onebox]).to be_empty
end
it "does not consider ignore_redirects domains as blacklisted" do
url = 'https://store.steampowered.com/app/271590/Grand_Theft_Auto_V/'
stub_request(:head, url).to_return(status: 200, body: "", headers: {})
stub_request(:get, url).to_return(status: 200, body: "", headers: {})
expect(Oneboxer.external_onebox(url)[:onebox]).to be_present
end
end

View File

@ -82,6 +82,17 @@ describe Promotion do
expect(job["args"][0]["message_type"]).to eq("welcome_tl1_user")
end
it "does not not send when the user already has the tl1 badge when recalculcating" do
SiteSetting.send_tl1_welcome_message = true
BadgeGranter.grant(Badge.find(1), user)
stat = user.user_stat
stat.topics_entered = SiteSetting.tl1_requires_topics_entered
stat.posts_read_count = SiteSetting.tl1_requires_read_posts
stat.time_read = SiteSetting.tl1_requires_time_spent_mins * 60
Promotion.recalculate(user)
expect(Jobs::SendSystemMessage.jobs.length).to eq(0)
end
it "can be turned off" do
SiteSetting.send_tl1_welcome_message = false
@result = promotion.review

BIN
spec/fixtures/images/smallest.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

5
spec/fixtures/pdf/small.pdf vendored Normal file
View File

@ -0,0 +1,5 @@
%PDF-1.
1 0 obj<</Pages 2 0 R>>endobj
2 0 obj<</Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Parent 2 0 R>>endobj
trailer <</Root 1 0 R>>

View File

@ -165,4 +165,15 @@ describe ApplicationHelper do
end
end
describe 'preloaded_json' do
it 'returns empty JSON if preloaded is empty' do
@preloaded = nil
expect(helper.preloaded_json).to eq('{}')
end
it 'escapes and strips invalid unicode and strips in json body' do
@preloaded = { test: %{["< \x80"]} }
expect(helper.preloaded_json).to eq(%{{"test":"[\\"\\u003c \uFFFD\\"]"}})
end
end
end

View File

@ -1,6 +1,6 @@
require 'rails_helper'
RSpec.describe Jobs::RecoverPostUploads do
RSpec.describe Jobs::PostUploadsRecovery do
describe '#grace_period' do
it 'should restrict the grace period to the right range' do
SiteSetting.purge_deleted_uploads_grace_period_days =

View File

@ -22,6 +22,18 @@ RSpec.describe UploadCreator do
expect(upload.extension).to eq('txt')
expect(File.extname(upload.url)).to eq('.txt')
expect(upload.original_filename).to eq('utf-8.txt')
expect(user.user_uploads.count).to eq(1)
expect(upload.user_uploads.count).to eq(1)
user2 = Fabricate(:user)
expect do
UploadCreator.new(file, "utf-8\n.txt").create_for(user2.id)
end.to change { Upload.count }.by(0)
expect(user.user_uploads.count).to eq(1)
expect(user2.user_uploads.count).to eq(1)
expect(upload.user_uploads.count).to eq(2)
end
end

View File

@ -6,14 +6,23 @@ RSpec.describe UploadRecovery do
let(:upload) do
UploadCreator.new(
file_from_fixtures("logo.png"),
file_from_fixtures("smallest.png"),
"logo.png"
).create_for(user.id)
end
let(:upload2) do
UploadCreator.new(
file_from_fixtures("small.pdf", "pdf"),
"some.pdf"
).create_for(user.id)
end
let(:post) do
Fabricate(:post,
raw: "![logo.png](#{upload.short_url})",
raw: <<~SQL,
![logo.png](#{upload.short_url})
SQL
user: user
).tap(&:link_post_uploads)
end
@ -21,17 +30,20 @@ RSpec.describe UploadRecovery do
let(:upload_recovery) { UploadRecovery.new }
before do
SiteSetting.authorized_extensions = 'png|pdf'
SiteSetting.queue_jobs = false
end
describe '#recover' do
after do
public_path = "#{Discourse.store.public_dir}#{upload.url}"
[upload, upload2].each do |u|
public_path = "#{Discourse.store.public_dir}#{u.url}"
[
public_path,
public_path.sub("uploads", "uploads/tombstone")
].each { |path| File.delete(path) if File.exists?(path) }
[
public_path,
public_path.sub("uploads", "uploads/tombstone")
].each { |path| File.delete(path) if File.exists?(path) }
end
end
describe 'when given an invalid sha1' do
@ -43,6 +55,12 @@ RSpec.describe UploadRecovery do
)
upload_recovery.recover
post.update!(
raw: "<a href=#{"/uploads/test/original/3X/a/6%0A/#{upload.sha1}.png"}>test</a>"
)
upload_recovery.recover
end
end
@ -54,7 +72,32 @@ RSpec.describe UploadRecovery do
upload_recovery.recover(Post.where("updated_at >= ?", 1.day.ago))
end
it 'should recover the upload' do
describe 'for a missing attachment' do
let(:post) do
Fabricate(:post,
raw: <<~SQL,
<a class="attachment" href="#{upload2.url}">some.pdf</a>
<a>blank</a>
SQL
user: user
).tap(&:link_post_uploads)
end
it 'should recover the attachment' do
expect do
upload2.destroy!
end.to change { post.reload.uploads.count }.from(1).to(0)
expect do
upload_recovery.recover
end.to change { post.reload.uploads.count }.from(0).to(1)
expect(File.read(Discourse.store.path_for(post.uploads.first)))
.to eq(File.read(file_from_fixtures("small.pdf", "pdf")))
end
end
it 'should recover uploads and attachments' do
stub_request(:get, "http://test.localhost#{upload.url}")
.to_return(status: 200)
@ -65,6 +108,9 @@ RSpec.describe UploadRecovery do
expect do
upload_recovery.recover
end.to change { post.reload.uploads.count }.from(0).to(1)
expect(File.read(Discourse.store.path_for(post.uploads.first)))
.to eq(File.read(file_from_fixtures("smallest.png")))
end
end
end

View File

@ -786,4 +786,29 @@ describe Group do
group.reload
expect(group.has_messages?).to eq true
end
describe '#automatic_group_membership' do
describe 'for a automatic_membership_retroactive group' do
let(:group) { Fabricate(:group, automatic_membership_retroactive: true) }
it "should be triggered on create and update" do
expect { group }
.to change { Jobs::AutomaticGroupMembership.jobs.size }.by(1)
job = Jobs::AutomaticGroupMembership.jobs.last
expect(job["args"].first["group_id"]).to eq(group.id)
Jobs::AutomaticGroupMembership.jobs.clear
expect do
group.update!(name: 'asdiaksjdias')
end.to change { Jobs::AutomaticGroupMembership.jobs.size }.by(1)
job = Jobs::AutomaticGroupMembership.jobs.last
expect(job["args"].first["group_id"]).to eq(group.id)
end
end
end
end

View File

@ -296,10 +296,17 @@ describe Topic do
expect(topic_image.fancy_title).to eq("Topic with &lt;img src=&lsquo;something&rsquo;&gt; image in its title")
end
it "always escapes title" do
topic_script.title = topic_script.title + "x" * Topic.max_fancy_title_length
expect(topic_script.fancy_title).to eq(ERB::Util.html_escape(topic_script.title))
# not really needed, but just in case
expect(topic_script.fancy_title).not_to include("<script>")
end
end
context 'fancy title' do
let(:topic) { Fabricate.build(:topic, title: "\"this topic\" -- has ``fancy stuff''") }
let(:topic) { Fabricate.build(:topic, title: %{"this topic" -- has ``fancy stuff''}) }
context 'title_fancy_entities disabled' do
before do
@ -319,7 +326,6 @@ describe Topic do
it "converts the title to have fancy entities and updates" do
expect(topic.fancy_title).to eq("&ldquo;this topic&rdquo; &ndash; has &ldquo;fancy stuff&rdquo;")
topic.title = "this is my test hello world... yay"
topic.user.save!
topic.save!
topic.reload
expect(topic.fancy_title).to eq("This is my test hello world&hellip; yay")
@ -336,7 +342,7 @@ describe Topic do
end
it "works with long title that results in lots of entities" do
long_title = "NEW STOCK PICK: PRCT - LAST PICK UP 233%, NNCO.................................................................................................................................................................. ofoum"
long_title = "NEW STOCK PICK: PRCT - LAST PICK UP 233%, NNCO#{"." * 150} ofoum"
topic.title = long_title
expect { topic.save! }.to_not raise_error

View File

@ -31,7 +31,11 @@ describe CategoriesController do
SiteSetting.categories_topics = 5
SiteSetting.categories_topics.times { Fabricate(:topic) }
get "/categories"
expect(response.body).to include(%{"more_topics_url":"/latest"})
expect(response.body).to have_tag("meta#data-preloaded") do |element|
json = JSON.parse(element.current_scope.attribute('data-preloaded').value)
expect(json['topic_list_latest']).to include(%{"more_topics_url":"/latest"})
end
end
end

View File

@ -30,6 +30,15 @@ describe ExportCsvController do
expect(response).to be_forbidden
expect(Jobs::ExportCsvFile.jobs.size).to eq(0)
end
it "correctly logs the entity export" do
post "/export_csv/export_entity.json", params: { entity: "user_archive" }
log_entry = UserHistory.last
expect(log_entry.action).to eq(UserHistory.actions[:entity_export])
expect(log_entry.acting_user_id).to eq(user.id)
expect(log_entry.subject).to eq("user_archive")
end
end
end
@ -58,6 +67,15 @@ describe ExportCsvController do
expect(job_data["entity"]).to eq("staff_action")
expect(job_data["user_id"]).to eq(admin.id)
end
it "correctly logs the entity export" do
post "/export_csv/export_entity.json", params: { entity: "user_list" }
log_entry = UserHistory.last
expect(log_entry.action).to eq(UserHistory.actions[:entity_export])
expect(log_entry.acting_user_id).to eq(admin.id)
expect(log_entry.subject).to eq("user_list")
end
end
end
end

View File

@ -0,0 +1,42 @@
require 'rails_helper'
RSpec.describe TagGroupsController do
let(:user) { Fabricate(:user) }
let(:tag_group) { Fabricate(:tag_group) }
describe '#index' do
describe 'for a non staff user' do
it 'should not be accessible' do
get "/tag_groups.json"
expect(response.status).to eq(404)
sign_in(user)
get "/tag_groups.json"
expect(response.status).to eq(404)
end
end
describe 'for a staff user' do
let(:admin) { Fabricate(:admin) }
before do
sign_in(admin)
end
it "should return the right response" do
tag_group
get "/tag_groups.json"
expect(response.status).to eq(200)
tag_groups = JSON.parse(response.body)["tag_groups"]
expect(tag_groups.count).to eq(1)
expect(tag_groups.first["id"]).to eq(tag_group.id)
end
end
end
end

View File

@ -190,7 +190,10 @@ describe UsersController do
)
expect(response.status).to eq(200)
expect(response.body).to include('{"is_developer":false,"admin":false,"second_factor_required":false,"backup_enabled":false}')
expect(response.body).to have_tag("meta#data-preloaded") do |element|
json = JSON.parse(element.current_scope.attribute('data-preloaded').value)
expect(json['password_reset']).to include('{"is_developer":false,"admin":false,"second_factor_required":false,"backup_enabled":false}')
end
expect(session["password-#{token}"]).to be_blank
expect(UserAuthToken.where(id: user_auth_token.id).count).to eq(0)
@ -255,7 +258,10 @@ describe UsersController do
get "/u/password-reset/#{token}"
expect(response.body).to include('{"is_developer":false,"admin":false,"second_factor_required":true,"backup_enabled":false}')
expect(response.body).to have_tag("meta#data-preloaded") do |element|
json = JSON.parse(element.current_scope.attribute('data-preloaded').value)
expect(json['password_reset']).to include('{"is_developer":false,"admin":false,"second_factor_required":true,"backup_enabled":false}')
end
put "/u/password-reset/#{token}", params: {
password: 'hg9ow8yHG32O',
@ -1764,9 +1770,13 @@ describe UsersController do
end
context 'while logged in' do
before do
sign_in(user)
end
let!(:user) { sign_in(Fabricate(:user)) }
let(:upload) { Fabricate(:upload) }
let(:upload) do
Fabricate(:upload, user: user)
end
it "raises an error when you don't have permission to toggle the avatar" do
another_user = Fabricate(:user)
@ -1803,6 +1813,9 @@ describe UsersController do
end
it 'can successfully pick a gravatar' do
user.user_avatar.update_columns(gravatar_upload_id: upload.id)
put "/u/#{user.username}/preferences/avatar/pick.json", params: {
upload_id: upload.id, type: "gravatar"
}
@ -1812,6 +1825,16 @@ describe UsersController do
expect(user.user_avatar.reload.gravatar_upload_id).to eq(upload.id)
end
it 'can not pick uploads that were not created by user' do
upload2 = Fabricate(:upload)
put "/u/#{user.username}/preferences/avatar/pick.json", params: {
upload_id: upload2.id, type: "custom"
}
expect(response.status).to eq(403)
end
it 'can successfully pick a custom avatar' do
put "/u/#{user.username}/preferences/avatar/pick.json", params: {
upload_id: upload.id, type: "custom"
@ -2262,7 +2285,7 @@ describe UsersController do
end
it "raises an error when logged in" do
moderator = sign_in(Fabricate(:moderator))
sign_in(Fabricate(:moderator))
post_user
put "/u/update-activation-email.json", params: {
@ -2274,7 +2297,7 @@ describe UsersController do
it "raises an error when the new email is taken" do
active_user = Fabricate(:user)
user = post_user
post_user
put "/u/update-activation-email.json", params: {
email: active_user.email
@ -2284,7 +2307,7 @@ describe UsersController do
end
it "raises an error when the email is blacklisted" do
user = post_user
post_user
SiteSetting.email_domains_blacklist = 'example.com'
put "/u/update-activation-email.json", params: { email: 'test@example.com' }
expect(response.status).to eq(422)
@ -2592,9 +2615,12 @@ describe UsersController do
expect(response.status).to eq(200)
expect(response.body).to include(
"{\"message\":\"#{I18n.t("login.activate_email", email: user.email).gsub!("</", "<\\/")}\",\"show_controls\":true,\"username\":\"#{user.username}\",\"email\":\"#{user.email}\"}"
)
expect(response.body).to have_tag("meta#data-preloaded") do |element|
json = JSON.parse(element.current_scope.attribute('data-preloaded').value)
expect(json['accountCreated']).to include(
"{\"message\":\"#{I18n.t("login.activate_email", email: user.email).gsub!("</", "<\\/")}\",\"show_controls\":true,\"username\":\"#{user.username}\",\"email\":\"#{user.email}\"}"
)
end
end
end
end

View File

@ -3,6 +3,10 @@ require 'rails_helper'
describe SearchIndexer do
let(:post_id) { 99 }
def scrub(html, strip_diacritics: false)
SearchIndexer.scrub_html_for_search(html, strip_diacritics: strip_diacritics)
end
it 'correctly indexes chinese' do
SiteSetting.default_locale = 'zh_CN'
data = "你好世界"
@ -16,26 +20,26 @@ describe SearchIndexer do
it 'extract youtube title' do
html = "<div class=\"lazyYT\" data-youtube-id=\"lmFgeFh2nlw\" data-youtube-title=\"Metallica Mixer Explains Missing Bass on 'And Justice for All' [Exclusive]\" data-width=\"480\" data-height=\"270\" data-parameters=\"feature=oembed&amp;wmode=opaque\"></div>"
scrubbed = SearchIndexer::HtmlScrubber.scrub(html)
expect(scrubbed).to eq(" Metallica Mixer Explains Missing Bass on 'And Justice for All' [Exclusive] ")
scrubbed = scrub(html)
expect(scrubbed).to eq("Metallica Mixer Explains Missing Bass on 'And Justice for All' [Exclusive]")
end
it 'extract a link' do
html = "<a href='http://meta.discourse.org/'>link</a>"
scrubbed = SearchIndexer::HtmlScrubber.scrub(html)
expect(scrubbed).to eq(" http://meta.discourse.org/ link ")
scrubbed = scrub(html)
expect(scrubbed).to eq("http://meta.discourse.org/ link")
end
it 'removes diacritics' do
it 'uses ignore_accent setting to strip diacritics' do
html = "<p>HELLO Hétérogénéité Здравствуйте هتاف للترحيب 你好</p>"
scrubbed = SearchIndexer::HtmlScrubber.scrub(html, strip_diacritics: true)
SiteSetting.search_ignore_accents = true
scrubbed = SearchIndexer.scrub_html_for_search(html)
expect(scrubbed).to eq("HELLO Heterogeneite Здравствуите هتاف للترحيب 你好")
expect(scrubbed).to eq(" HELLO Heterogeneite Здравствуите هتاف للترحيب 你好 ")
SiteSetting.search_ignore_accents = false
scrubbed = SearchIndexer.scrub_html_for_search(html)
expect(scrubbed).to eq("HELLO Hétérogénéité Здравствуйте هتاف للترحيب 你好")
end
it "doesn't index local files" do
@ -54,9 +58,9 @@ describe SearchIndexer do
</div>
HTML
scrubbed = SearchIndexer::HtmlScrubber.scrub(html).gsub(/\s+/, " ")
scrubbed = scrub(html)
expect(scrubbed).to eq(" Discourse 51%20PM Untitled design (21).jpg Untitled%20design%20(21) Untitled design (21).jpg 1280x1136 472 KB ")
expect(scrubbed).to eq("Discourse 51%20PM Untitled design (21).jpg Untitled%20design%20(21) Untitled design (21).jpg 1280x1136 472 KB")
end
it 'correctly indexes a post according to version' do

View File

@ -289,6 +289,35 @@ QUnit.test("Composer can toggle between edit and reply", async assert => {
);
});
QUnit.test("Composer can toggle whispers", async assert => {
await visit("/t/this-is-a-test-topic/9");
await click(".topic-post:eq(0) button.reply");
await selectKit(".toolbar-popup-menu-options").expand();
await selectKit(".toolbar-popup-menu-options").selectRowByValue(
"toggleWhisper"
);
assert.ok(
find(".composer-fields .whisper")
.text()
.indexOf(I18n.t("composer.whisper")) > 0,
"it sets the post type to whisper"
);
await selectKit(".toolbar-popup-menu-options").expand();
await selectKit(".toolbar-popup-menu-options").selectRowByValue(
"toggleWhisper"
);
assert.ok(
find(".composer-fields .whisper")
.text()
.indexOf(I18n.t("composer.whisper")) <= 0,
"it removes the whisper mode"
);
});
QUnit.test(
"Composer can toggle between reply and createTopic",
async assert => {

View File

@ -835,3 +835,55 @@ componentTest("without forceEscape", {
);
}
});
componentTest("onSelect", {
template:
"<div class='test-external-action'></div>{{single-select content=content onSelect=(action externalAction)}}",
beforeEach() {
this.set("externalAction", actual => {
find(".test-external-action").text(actual);
});
this.set("content", ["red", "blue"]);
},
async test(assert) {
await this.get("subject").expand();
await this.get("subject").selectRowByValue("red");
assert.equal(
find(".test-external-action")
.text()
.trim(),
"red"
);
}
});
componentTest("onDeselect", {
template:
"<div class='test-external-action'></div>{{single-select content=content onDeselect=(action externalAction)}}",
beforeEach() {
this.set("externalAction", actual => {
find(".test-external-action").text(actual);
});
this.set("content", ["red", "blue"]);
},
async test(assert) {
await this.get("subject").expand();
await this.get("subject").selectRowByValue("red");
await this.get("subject").expand();
await this.get("subject").selectRowByValue("blue");
assert.equal(
find(".test-external-action")
.text()
.trim(),
"red"
);
}
});