FEATURE: Review posts with media. (#10693)

To check if a post contains any embedded media, we look if the "image_sizes" attribute is present in the new post manager arguments.

We want to see one boxed links, but we only store the raw content of the post. To work around this, I extracted the onebox logic from the composer editor into a module.
This commit is contained in:
Roman Rizzi 2020-09-18 12:45:09 -03:00 committed by GitHub
parent f3156a6478
commit f85f73be88
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 167 additions and 73 deletions

View File

@ -16,8 +16,6 @@ import {
fetchUnseenHashtags, fetchUnseenHashtags,
} from "discourse/lib/link-hashtags"; } from "discourse/lib/link-hashtags";
import Composer from "discourse/models/composer"; import Composer from "discourse/models/composer";
import { load, LOADING_ONEBOX_CSS_CLASS } from "pretty-text/oneboxer";
import { applyInlineOneboxes } from "pretty-text/inline-oneboxer";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import EmberObject from "@ember/object"; import EmberObject from "@ember/object";
import { findRawTemplate } from "discourse-common/lib/raw-templates"; import { findRawTemplate } from "discourse-common/lib/raw-templates";
@ -43,6 +41,7 @@ import {
resolveAllShortUrls, resolveAllShortUrls,
} from "pretty-text/upload-short-url"; } from "pretty-text/upload-short-url";
import { isTesting } from "discourse-common/config/environment"; import { isTesting } from "discourse-common/config/environment";
import { loadOneboxes } from "discourse/lib/load-oneboxes";
const REBUILD_SCROLL_MAP_EVENTS = ["composer:resized", "composer:typed-reply"]; const REBUILD_SCROLL_MAP_EVENTS = ["composer:resized", "composer:typed-reply"];
@ -531,36 +530,6 @@ export default Component.extend({
} }
}, },
_loadInlineOneboxes(inline) {
applyInlineOneboxes(inline, ajax, {
categoryId: this.get("composer.category.id"),
topicId: this.get("composer.topic.id"),
});
},
_loadOneboxes(oneboxes) {
const post = this.get("composer.post");
let refresh = false;
// If we are editing a post, we'll refresh its contents once.
if (post && !post.get("refreshedPost")) {
refresh = true;
post.set("refreshedPost", true);
}
Object.values(oneboxes).forEach((onebox) => {
onebox.forEach(($onebox) => {
load({
elem: $onebox,
refresh,
ajax,
categoryId: this.get("composer.category.id"),
topicId: this.get("composer.topic.id"),
});
});
});
},
_warnMentionedGroups($preview) { _warnMentionedGroups($preview) {
schedule("afterRender", () => { schedule("afterRender", () => {
var found = this.warnedGroupMentions || []; var found = this.warnedGroupMentions || [];
@ -934,50 +903,28 @@ export default Component.extend({
} }
// Paint oneboxes // Paint oneboxes
debounce( const paintFunc = () => {
this, const post = this.get("composer.post");
() => { let refresh = false;
const oneboxes = {};
const inlineOneboxes = {};
// Oneboxes = `a.onebox` -> `a.onebox-loading` -> `aside.onebox` //If we are editing a post, we'll refresh its contents once.
// Inline Oneboxes = `a.inline-onebox-loading` -> `a.inline-onebox` if (post && !post.get("refreshedPost")) {
refresh = true;
}
let loadedOneboxes = $preview.find( const paintedCount = loadOneboxes(
`aside.onebox, a.${LOADING_ONEBOX_CSS_CLASS}, a.inline-onebox` $preview[0],
).length; ajax,
this.get("composer.topic.id"),
this.get("composer.category.id"),
this.siteSettings.max_oneboxes_per_post,
refresh
);
$preview.find(`a.onebox, a.inline-onebox-loading`).each((_, link) => { if (refresh && paintedCount > 0) post.set("refreshedPost", true);
const $link = $(link); };
const text = $link.text();
const isInline = $link.attr("class") === "inline-onebox-loading";
const m = isInline ? inlineOneboxes : oneboxes;
if (loadedOneboxes < this.siteSettings.max_oneboxes_per_post) { debounce(this, paintFunc, 450);
if (m[text] === undefined) {
m[text] = [];
loadedOneboxes++;
}
m[text].push(link);
} else {
if (m[text] !== undefined) {
m[text].push(link);
} else if (isInline) {
$link.removeClass("inline-onebox-loading");
}
}
});
if (Object.keys(oneboxes).length > 0) {
this._loadOneboxes(oneboxes);
}
if (Object.keys(inlineOneboxes).length > 0) {
this._loadInlineOneboxes(inlineOneboxes);
}
},
450
);
// Short upload urls need resolution // Short upload urls need resolution
resolveAllShortUrls(ajax, this.siteSettings, $preview[0]); resolveAllShortUrls(ajax, this.siteSettings, $preview[0]);

View File

@ -2,6 +2,7 @@ import Component from "@ember/component";
import { afterRender } from "discourse-common/utils/decorators"; import { afterRender } from "discourse-common/utils/decorators";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { cookAsync } from "discourse/lib/text"; import { cookAsync } from "discourse/lib/text";
import { loadOneboxes } from "discourse/lib/load-oneboxes";
import { resolveAllShortUrls } from "pretty-text/upload-short-url"; import { resolveAllShortUrls } from "pretty-text/upload-short-url";
const CookText = Component.extend({ const CookText = Component.extend({
@ -11,10 +12,25 @@ const CookText = Component.extend({
this._super(...arguments); this._super(...arguments);
cookAsync(this.rawText).then((cooked) => { cookAsync(this.rawText).then((cooked) => {
this.set("cooked", cooked); this.set("cooked", cooked);
if (this.paintOneboxes) this._loadOneboxes();
this._resolveUrls(); this._resolveUrls();
}); });
}, },
@afterRender
_loadOneboxes() {
const refresh = false;
loadOneboxes(
this.element,
ajax,
this.topicId,
this.categoryId,
this.siteSettings.max_oneboxes_per_post,
refresh
);
},
@afterRender @afterRender
_resolveUrls() { _resolveUrls() {
resolveAllShortUrls(ajax, this.siteSettings, this.element, this.opts); resolveAllShortUrls(ajax, this.siteSettings, this.element, this.opts);

View File

@ -0,0 +1,78 @@
import { applyInlineOneboxes } from "pretty-text/inline-oneboxer";
import { load, LOADING_ONEBOX_CSS_CLASS } from "pretty-text/oneboxer";
export function loadOneboxes(
container,
ajax,
topicId,
categoryId,
maxOneboxes,
refresh
) {
const oneboxes = {};
const inlineOneboxes = {};
// Oneboxes = `a.onebox` -> `a.onebox-loading` -> `aside.onebox`
// Inline Oneboxes = `a.inline-onebox-loading` -> `a.inline-onebox`
let loadedOneboxes = container.querySelectorAll(
`aside.onebox, a.${LOADING_ONEBOX_CSS_CLASS}, a.inline-onebox`
).length;
container
.querySelectorAll(`a.onebox, a.inline-onebox-loading`)
.forEach((link) => {
const text = link.textContent;
const isInline = link.getAttribute("class") === "inline-onebox-loading";
const m = isInline ? inlineOneboxes : oneboxes;
if (loadedOneboxes < maxOneboxes) {
if (m[text] === undefined) {
m[text] = [];
loadedOneboxes++;
}
m[text].push(link);
} else {
if (m[text] !== undefined) {
m[text].push(link);
} else if (isInline) {
link.classList.remove("inline-onebox-loading");
}
}
});
let newBoxes = 0;
if (Object.keys(oneboxes).length > 0) {
_loadOneboxes(oneboxes, ajax, newBoxes, topicId, categoryId, refresh);
}
if (Object.keys(inlineOneboxes).length > 0) {
_loadInlineOneboxes(inlineOneboxes, ajax, topicId, categoryId);
}
return newBoxes;
}
function _loadInlineOneboxes(inline, ajax, topicId, categoryId) {
applyInlineOneboxes(inline, ajax, {
categoryId: topicId,
topicId: categoryId,
});
}
function _loadOneboxes(oneboxes, ajax, count, topicId, categoryId, refresh) {
Object.values(oneboxes).forEach((onebox) => {
onebox.forEach((o) => {
load({
elem: o,
refresh,
ajax,
categoryId: categoryId,
topicId: topicId,
});
count++;
});
});
}

View File

@ -18,7 +18,14 @@
<div class="post-contents"> <div class="post-contents">
{{reviewable-post-header reviewable=reviewable createdBy=reviewable.created_by tagName=""}} {{reviewable-post-header reviewable=reviewable createdBy=reviewable.created_by tagName=""}}
{{cook-text reviewable.payload.raw class="post-body" opts=(hash removeMissing=true)}} {{cook-text
reviewable.payload.raw
class="post-body"
categoryId=reviewable.category_id
topicId=reviewable.topic_id
paintOneboxes=true
opts=(hash removeMissing=true)
}}
{{yield}} {{yield}}
</div> </div>

View File

@ -2145,6 +2145,7 @@ en:
new_user_notice_tl: "Minimum trust level required to see new user post notices." new_user_notice_tl: "Minimum trust level required to see new user post notices."
returning_user_notice_tl: "Minimum trust level required to see returning user post notices." returning_user_notice_tl: "Minimum trust level required to see returning user post notices."
returning_users_days: "How many days should pass before a user is considered to be returning." returning_users_days: "How many days should pass before a user is considered to be returning."
review_media_unless_trust_level: "Staff will review posts of users with lower trust levels if it contains embedded media."
enable_page_publishing: "Allow staff members to publish topics to new URLs with their own styling." enable_page_publishing: "Allow staff members to publish topics to new URLs with their own styling."
show_published_pages_login_required: "Anonymous users can see published pages, even when login is required." show_published_pages_login_required: "Anonymous users can see published pages, even when login is required."
@ -4812,6 +4813,7 @@ en:
email_auth_res_enqueue: "This email failed a DMARC check, it most likely isn't from whom it seems to be from. Check the raw email headers for more information." email_auth_res_enqueue: "This email failed a DMARC check, it most likely isn't from whom it seems to be from. Check the raw email headers for more information."
email_spam: "This email was flagged as spam by the header defined in `email_in_spam_header`." email_spam: "This email was flagged as spam by the header defined in `email_in_spam_header`."
suspect_user: "This new user entered profile information without reading any topics or posts, which strongly suggests they may be a spammer. See `approve_suspect_users`." suspect_user: "This new user entered profile information without reading any topics or posts, which strongly suggests they may be a spammer. See `approve_suspect_users`."
contains_media: "This posts includes embedded media. See `review_media_unless_trust_level`."
actions: actions:
agree: agree:

View File

@ -960,6 +960,9 @@ posting:
returning_users_days: returning_users_days:
default: 120 default: 120
max: 36500 max: 36500
review_media_unless_trust_level:
default: 0
enum: "TrustLevelSetting"
enable_page_publishing: enable_page_publishing:
default: false default: false
show_published_pages_login_required: show_published_pages_login_required:

View File

@ -108,6 +108,11 @@ class NewPostManager
return :category if post_needs_approval_in_its_category?(manager) return :category if post_needs_approval_in_its_category?(manager)
return :contains_media if (
manager.args[:image_sizes].present? &&
user.trust_level < SiteSetting.review_media_unless_trust_level.to_i
)
:skip :skip
end end

View File

@ -239,6 +239,42 @@ describe NewPostManager do
end end
end end
context 'with media' do
let(:user) { manager.user }
let(:manager_opts) do
{
raw: 'this is new post content', topic_id: topic.id, first_post_checks: false,
image_sizes: {
"http://localhost:3000/uploads/default/original/1X/652fc9667040b1b89dc4d9b061a823ddb3c0cef0.jpeg" => {
"width" => "500", "height" => "500"
}
}
}
end
before do
user.update!(trust_level: 0)
end
it 'queues the post for review because if it contains embedded media.' do
SiteSetting.review_media_unless_trust_level = 1
manager = NewPostManager.new(topic.user, manager_opts)
result = NewPostManager.default_handler(manager)
expect(result.action).to eq(:enqueued)
expect(result.reason).to eq(:contains_media)
end
it 'does not enqueue the post if the poster is a trusted user' do
SiteSetting.review_media_unless_trust_level = 0
manager = NewPostManager.new(topic.user, manager_opts)
result = NewPostManager.default_handler(manager)
expect(result).to be_nil
end
end
end end
context "new topic handler" do context "new topic handler" do