Compare commits

..

2 Commits

Author SHA1 Message Date
Blake Erickson
05047c9233
Revert "SECURITY: Multiple commits for Version bump 3.1.0.beta3 (#20707)" (#20709)
This reverts commit fb019d1712.
2023-03-16 14:14:32 -06:00
Blake Erickson
fb019d1712
SECURITY: Multiple commits for Version bump 3.1.0.beta3 (#20707)
* SECURITY: Fix XSS in full name composer reply

We are using htmlSafe when rendering the name field so we need to escape
any html being passed in.

* SECURITY: Monkey-patch web-push gem to use safer HTTP client

`FinalDestination::HTTP` is our patch of `Net::HTTP` which defend us
against SSRF and DNS rebinding attacks.

* SECURITY: SSRF protection bypass with IPv4-mapped IPv6 addresses

As part of this commit, we've also expanded our list of private IP
ranges based on
https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml
and https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml

* SECURITY: XSS on chat excerpts

Non-markdown tags weren't being escaped in chat excerpts. This could be
triggered by editing a chat message containing a tag (self XSS), or by
replying to a chat message with a tag (XSS).

Co-authored-by: Jan Cernik <jancernik12@gmail.com>

* FIX: Escaped mentions in chat excerpts

Mentions are now displayed as using the non-cooked message which fixes
the problem. This is not ideal. I think we might want to rework how
these excerpts are created and rendered in the near future.

Co-authored-by: Jan Cernik <jancernik12@gmail.com>

* SECURITY: Add FinalDestination::FastImage that's SSRF safe

---------

Co-authored-by: Alan Guo Xiang Tan <gxtan1990@gmail.com>
Co-authored-by: Jan Cernik <jancernik12@gmail.com>
Co-authored-by: Ted Johansson <ted@discourse.org>
2023-03-16 14:04:46 -06:00
433 changed files with 9735 additions and 10384 deletions

View File

@ -1,13 +1,13 @@
import RestAdapter from "discourse/adapters/rest"; import RESTAdapter from "discourse/adapters/rest";
export default class ApiKey extends RestAdapter { export default RESTAdapter.extend({
jsonMode = true; jsonMode: true,
basePath() { basePath() {
return "/admin/api/"; return "/admin/api/";
} },
apiNameFor() { apiNameFor() {
return "key"; return "key";
} },
} });

View File

@ -1,11 +1,11 @@
import RestAdapter from "discourse/adapters/rest"; import RestAdapter from "discourse/adapters/rest";
export default function buildPluginAdapter(pluginName) { export default function buildPluginAdapter(pluginName) {
return class extends RestAdapter { return RestAdapter.extend({
pathFor(store, type, findArgs) { pathFor(store, type, findArgs) {
return ( return (
"/admin/plugins/" + pluginName + super.pathFor(store, type, findArgs) "/admin/plugins/" + pluginName + this._super(store, type, findArgs)
); );
} },
}; });
} }

View File

@ -1,7 +1,7 @@
import RestAdapter from "discourse/adapters/rest"; import RestAdapter from "discourse/adapters/rest";
export default class CustomizationBase extends RestAdapter { export default RestAdapter.extend({
basePath() { basePath() {
return "/admin/customize/"; return "/admin/customize/";
} },
} });

View File

@ -1,7 +1,7 @@
import RestAdapter from "discourse/adapters/rest"; import RestAdapter from "discourse/adapters/rest";
export default class EmailStyle extends RestAdapter { export default RestAdapter.extend({
pathFor() { pathFor() {
return "/admin/customize/email_style"; return "/admin/customize/email_style";
} },
} });

View File

@ -1,7 +1,7 @@
import RestAdapter from "discourse/adapters/rest"; import RestAdapter from "discourse/adapters/rest";
export default class Embedding extends RestAdapter { export default RestAdapter.extend({
pathFor() { pathFor() {
return "/admin/customize/embedding"; return "/admin/customize/embedding";
} },
} });

View File

@ -1,7 +1,7 @@
import RestAdapter from "discourse/adapters/rest"; import RestAdapter from "discourse/adapters/rest";
export default class StaffActionLog extends RestAdapter { export default RestAdapter.extend({
basePath() { basePath() {
return "/admin/logs/"; return "/admin/logs/";
} },
} });

View File

@ -1,5 +1,5 @@
import RestAdapter from "discourse/adapters/rest"; import RestAdapter from "discourse/adapters/rest";
export default class TagGroup extends RestAdapter { export default RestAdapter.extend({
jsonMode = true; jsonMode: true,
} });

View File

@ -1,10 +1,9 @@
import RestAdapter from "discourse/adapters/rest"; import RestAdapter from "discourse/adapters/rest";
export default class Theme extends RestAdapter { export default RestAdapter.extend({
jsonMode = true;
basePath() { basePath() {
return "/admin/"; return "/admin/";
} },
afterFindAll(results) { afterFindAll(results) {
let map = {}; let map = {};
@ -21,5 +20,7 @@ export default class Theme extends RestAdapter {
theme.set("parentThemes", mappedParents); theme.set("parentThemes", mappedParents);
}); });
return results; return results;
} },
}
jsonMode: true,
});

View File

@ -1,7 +1,7 @@
import RestAdapter from "discourse/adapters/rest"; import RESTAdapter from "discourse/adapters/rest";
export default class WebHookEvent extends RestAdapter { export default RESTAdapter.extend({
basePath() { basePath() {
return "/admin/api/"; return "/admin/api/";
} },
} });

View File

@ -1,7 +1,7 @@
import RestAdapter from "discourse/adapters/rest"; import RESTAdapter from "discourse/adapters/rest";
export default class WebHook extends RestAdapter { export default RESTAdapter.extend({
basePath() { basePath() {
return "/admin/api/"; return "/admin/api/";
} },
} });

View File

@ -2,7 +2,7 @@ import Helper from "@ember/component/helper";
import { iconHTML } from "discourse-common/lib/icon-library"; import { iconHTML } from "discourse-common/lib/icon-library";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
export default class DispositionIcon extends Helper { export default Helper.extend({
compute([disposition]) { compute([disposition]) {
if (!disposition) { if (!disposition) {
return null; return null;
@ -24,5 +24,5 @@ export default class DispositionIcon extends Helper {
} }
} }
return htmlSafe(iconHTML(icon, { title })); return htmlSafe(iconHTML(icon, { title }));
} },
} });

View File

@ -7,17 +7,19 @@ const GENERAL_ATTRIBUTES = [
"release_notes_link", "release_notes_link",
]; ];
export default class AdminDashboard extends EmberObject { const AdminDashboard = EmberObject.extend({});
static fetch() {
AdminDashboard.reopenClass({
fetch() {
return ajax("/admin/dashboard.json").then((json) => { return ajax("/admin/dashboard.json").then((json) => {
const model = AdminDashboard.create(); const model = AdminDashboard.create();
model.set("version_check", json.version_check); model.set("version_check", json.version_check);
return model; return model;
}); });
} },
static fetchGeneral() { fetchGeneral() {
return ajax("/admin/dashboard/general.json").then((json) => { return ajax("/admin/dashboard/general.json").then((json) => {
const model = AdminDashboard.create(); const model = AdminDashboard.create();
@ -32,13 +34,15 @@ export default class AdminDashboard extends EmberObject {
return model; return model;
}); });
} },
static fetchProblems() { fetchProblems() {
return ajax("/admin/dashboard/problems.json").then((json) => { return ajax("/admin/dashboard/problems.json").then((json) => {
const model = AdminDashboard.create(json); const model = AdminDashboard.create(json);
model.set("loaded", true); model.set("loaded", true);
return model; return model;
}); });
} },
} });
export default AdminDashboard;

View File

@ -10,30 +10,14 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
import { propertyNotEqual } from "discourse/lib/computed"; import { propertyNotEqual } from "discourse/lib/computed";
import { userPath } from "discourse/lib/url"; import { userPath } from "discourse/lib/url";
export default class AdminUser extends User { const wrapAdmin = (user) => (user ? AdminUser.create(user) : null);
static find(user_id) {
return ajax(`/admin/users/${user_id}.json`).then((result) => {
result.loadedDetails = true;
return AdminUser.create(result);
});
}
static findAll(query, userFilter) { const AdminUser = User.extend({
return ajax(`/admin/users/list/${query}.json`, { adminUserView: true,
data: userFilter, customGroups: filter("groups", (g) => !g.automatic && Group.create(g)),
}).then((users) => users.map((u) => AdminUser.create(u))); automaticGroups: filter("groups", (g) => g.automatic && Group.create(g)),
}
adminUserView = true; canViewProfile: or("active", "staged"),
@filter("groups", (g) => !g.automatic && Group.create(g)) customGroups;
@filter("groups", (g) => g.automatic && Group.create(g)) automaticGroups;
@or("active", "staged") canViewProfile;
@gt("bounce_score", 0) canResetBounceScore;
@propertyNotEqual("originalTrustLevel", "trust_level") dirty;
@lt("trust_level", 4) canLockTrustLevel;
@not("staff") canSuspend;
@not("staff") canSilence;
@discourseComputed("bounce_score", "reset_bounce_score_after") @discourseComputed("bounce_score", "reset_bounce_score_after")
bounceScore(bounce_score, reset_bounce_score_after) { bounceScore(bounce_score, reset_bounce_score_after) {
@ -44,7 +28,7 @@ export default class AdminUser extends User {
} else { } else {
return bounce_score; return bounce_score;
} }
} },
@discourseComputed("bounce_score") @discourseComputed("bounce_score")
bounceScoreExplanation(bounce_score) { bounceScoreExplanation(bounce_score) {
@ -55,12 +39,14 @@ export default class AdminUser extends User {
} else { } else {
return I18n.t("admin.user.bounce_score_explanation.threshold_reached"); return I18n.t("admin.user.bounce_score_explanation.threshold_reached");
} }
} },
@discourseComputed @discourseComputed
bounceLink() { bounceLink() {
return getURL("/admin/email/bounced"); return getURL("/admin/email/bounced");
} },
canResetBounceScore: gt("bounce_score", 0),
resetBounceScore() { resetBounceScore() {
return ajax(`/admin/users/${this.id}/reset_bounce_score`, { return ajax(`/admin/users/${this.id}/reset_bounce_score`, {
@ -71,14 +57,14 @@ export default class AdminUser extends User {
reset_bounce_score_after: null, reset_bounce_score_after: null,
}) })
); );
} },
groupAdded(added) { groupAdded(added) {
return ajax(`/admin/users/${this.id}/groups`, { return ajax(`/admin/users/${this.id}/groups`, {
type: "POST", type: "POST",
data: { group_id: added.id }, data: { group_id: added.id },
}).then(() => this.groups.pushObject(added)); }).then(() => this.groups.pushObject(added));
} },
groupRemoved(groupId) { groupRemoved(groupId) {
return ajax(`/admin/users/${this.id}/groups/${groupId}`, { return ajax(`/admin/users/${this.id}/groups/${groupId}`, {
@ -89,13 +75,13 @@ export default class AdminUser extends User {
this.set("primary_group_id", null); this.set("primary_group_id", null);
} }
}); });
} },
deleteAllPosts() { deleteAllPosts() {
return ajax(`/admin/users/${this.get("id")}/delete_posts_batch`, { return ajax(`/admin/users/${this.get("id")}/delete_posts_batch`, {
type: "PUT", type: "PUT",
}); });
} },
revokeAdmin() { revokeAdmin() {
return ajax(`/admin/users/${this.id}/revoke_admin`, { return ajax(`/admin/users/${this.id}/revoke_admin`, {
@ -111,7 +97,7 @@ export default class AdminUser extends User {
can_delete_all_posts: resp.can_delete_all_posts, can_delete_all_posts: resp.can_delete_all_posts,
}); });
}); });
} },
grantAdmin(data) { grantAdmin(data) {
return ajax(`/admin/users/${this.id}/grant_admin`, { return ajax(`/admin/users/${this.id}/grant_admin`, {
@ -128,7 +114,7 @@ export default class AdminUser extends User {
return resp; return resp;
}); });
} },
revokeModeration() { revokeModeration() {
return ajax(`/admin/users/${this.id}/revoke_moderation`, { return ajax(`/admin/users/${this.id}/revoke_moderation`, {
@ -144,7 +130,7 @@ export default class AdminUser extends User {
}); });
}) })
.catch(popupAjaxError); .catch(popupAjaxError);
} },
grantModeration() { grantModeration() {
return ajax(`/admin/users/${this.id}/grant_moderation`, { return ajax(`/admin/users/${this.id}/grant_moderation`, {
@ -160,7 +146,7 @@ export default class AdminUser extends User {
}); });
}) })
.catch(popupAjaxError); .catch(popupAjaxError);
} },
disableSecondFactor() { disableSecondFactor() {
return ajax(`/admin/users/${this.id}/disable_second_factor`, { return ajax(`/admin/users/${this.id}/disable_second_factor`, {
@ -170,7 +156,7 @@ export default class AdminUser extends User {
this.set("second_factor_enabled", false); this.set("second_factor_enabled", false);
}) })
.catch(popupAjaxError); .catch(popupAjaxError);
} },
approve(approvedBy) { approve(approvedBy) {
return ajax(`/admin/users/${this.id}/approve`, { return ajax(`/admin/users/${this.id}/approve`, {
@ -182,76 +168,83 @@ export default class AdminUser extends User {
approved_by: approvedBy, approved_by: approvedBy,
}); });
}); });
} },
setOriginalTrustLevel() { setOriginalTrustLevel() {
this.set("originalTrustLevel", this.trust_level); this.set("originalTrustLevel", this.trust_level);
} },
dirty: propertyNotEqual("originalTrustLevel", "trust_level"),
saveTrustLevel() { saveTrustLevel() {
return ajax(`/admin/users/${this.id}/trust_level`, { return ajax(`/admin/users/${this.id}/trust_level`, {
type: "PUT", type: "PUT",
data: { level: this.trust_level }, data: { level: this.trust_level },
}); });
} },
restoreTrustLevel() { restoreTrustLevel() {
this.set("trust_level", this.originalTrustLevel); this.set("trust_level", this.originalTrustLevel);
} },
lockTrustLevel(locked) { lockTrustLevel(locked) {
return ajax(`/admin/users/${this.id}/trust_level_lock`, { return ajax(`/admin/users/${this.id}/trust_level_lock`, {
type: "PUT", type: "PUT",
data: { locked: !!locked }, data: { locked: !!locked },
}); });
} },
canLockTrustLevel: lt("trust_level", 4),
canSuspend: not("staff"),
canSilence: not("staff"),
@discourseComputed("suspended_till", "suspended_at") @discourseComputed("suspended_till", "suspended_at")
suspendDuration(suspendedTill, suspendedAt) { suspendDuration(suspendedTill, suspendedAt) {
suspendedAt = moment(suspendedAt); suspendedAt = moment(suspendedAt);
suspendedTill = moment(suspendedTill); suspendedTill = moment(suspendedTill);
return suspendedAt.format("L") + " - " + suspendedTill.format("L"); return suspendedAt.format("L") + " - " + suspendedTill.format("L");
} },
suspend(data) { suspend(data) {
return ajax(`/admin/users/${this.id}/suspend`, { return ajax(`/admin/users/${this.id}/suspend`, {
type: "PUT", type: "PUT",
data, data,
}).then((result) => this.setProperties(result.suspension)); }).then((result) => this.setProperties(result.suspension));
} },
unsuspend() { unsuspend() {
return ajax(`/admin/users/${this.id}/unsuspend`, { return ajax(`/admin/users/${this.id}/unsuspend`, {
type: "PUT", type: "PUT",
}).then((result) => this.setProperties(result.suspension)); }).then((result) => this.setProperties(result.suspension));
} },
logOut() { logOut() {
return ajax("/admin/users/" + this.id + "/log_out", { return ajax("/admin/users/" + this.id + "/log_out", {
type: "POST", type: "POST",
data: { username_or_email: this.username }, data: { username_or_email: this.username },
}); });
} },
impersonate() { impersonate() {
return ajax("/admin/impersonate", { return ajax("/admin/impersonate", {
type: "POST", type: "POST",
data: { username_or_email: this.username }, data: { username_or_email: this.username },
}); });
} },
activate() { activate() {
return ajax(`/admin/users/${this.id}/activate`, { return ajax(`/admin/users/${this.id}/activate`, {
type: "PUT", type: "PUT",
}); });
} },
deactivate() { deactivate() {
return ajax(`/admin/users/${this.id}/deactivate`, { return ajax(`/admin/users/${this.id}/deactivate`, {
type: "PUT", type: "PUT",
data: { context: document.location.pathname }, data: { context: document.location.pathname },
}); });
} },
unsilence() { unsilence() {
this.set("silencingUser", true); this.set("silencingUser", true);
@ -261,7 +254,7 @@ export default class AdminUser extends User {
}) })
.then((result) => this.setProperties(result.unsilence)) .then((result) => this.setProperties(result.unsilence))
.finally(() => this.set("silencingUser", false)); .finally(() => this.set("silencingUser", false));
} },
silence(data) { silence(data) {
this.set("silencingUser", true); this.set("silencingUser", true);
@ -272,20 +265,20 @@ export default class AdminUser extends User {
}) })
.then((result) => this.setProperties(result.silence)) .then((result) => this.setProperties(result.silence))
.finally(() => this.set("silencingUser", false)); .finally(() => this.set("silencingUser", false));
} },
sendActivationEmail() { sendActivationEmail() {
return ajax(userPath("action/send_activation_email"), { return ajax(userPath("action/send_activation_email"), {
type: "POST", type: "POST",
data: { username: this.username }, data: { username: this.username },
}); });
} },
anonymize() { anonymize() {
return ajax(`/admin/users/${this.id}/anonymize.json`, { return ajax(`/admin/users/${this.id}/anonymize.json`, {
type: "PUT", type: "PUT",
}); });
} },
destroy(formData) { destroy(formData) {
return ajax(`/admin/users/${this.id}.json`, { return ajax(`/admin/users/${this.id}.json`, {
@ -302,14 +295,14 @@ export default class AdminUser extends User {
.catch(() => { .catch(() => {
this.find(this.id).then((u) => this.setProperties(u)); this.find(this.id).then((u) => this.setProperties(u));
}); });
} },
merge(formData) { merge(formData) {
return ajax(`/admin/users/${this.id}/merge.json`, { return ajax(`/admin/users/${this.id}/merge.json`, {
type: "POST", type: "POST",
data: formData, data: formData,
}); });
} },
loadDetails() { loadDetails() {
if (this.loadedDetails) { if (this.loadedDetails) {
@ -320,29 +313,23 @@ export default class AdminUser extends User {
const userProperties = Object.assign(result, { loadedDetails: true }); const userProperties = Object.assign(result, { loadedDetails: true });
this.setProperties(userProperties); this.setProperties(userProperties);
}); });
} },
@discourseComputed("tl3_requirements") @discourseComputed("tl3_requirements")
tl3Requirements(requirements) { tl3Requirements(requirements) {
if (requirements) { if (requirements) {
return this.store.createRecord("tl3Requirements", requirements); return this.store.createRecord("tl3Requirements", requirements);
} }
} },
@discourseComputed("suspended_by") @discourseComputed("suspended_by")
suspendedBy(user) { suspendedBy: wrapAdmin,
return user ? AdminUser.create(user) : null;
}
@discourseComputed("silenced_by") @discourseComputed("silenced_by")
silencedBy(user) { silencedBy: wrapAdmin,
return user ? AdminUser.create(user) : null;
}
@discourseComputed("approved_by") @discourseComputed("approved_by")
approvedBy(user) { approvedBy: wrapAdmin,
return user ? AdminUser.create(user) : null;
}
deleteSSORecord() { deleteSSORecord() {
return ajax(`/admin/users/${this.id}/sso_record.json`, { return ajax(`/admin/users/${this.id}/sso_record.json`, {
@ -352,5 +339,22 @@ export default class AdminUser extends User {
this.set("single_sign_on_record", null); this.set("single_sign_on_record", null);
}) })
.catch(popupAjaxError); .catch(popupAjaxError);
} },
} });
AdminUser.reopenClass({
find(user_id) {
return ajax(`/admin/users/${user_id}.json`).then((result) => {
result.loadedDetails = true;
return AdminUser.create(result);
});
},
findAll(query, userFilter) {
return ajax(`/admin/users/list/${query}.json`, {
data: userFilter,
}).then((users) => users.map((u) => AdminUser.create(u)));
},
});
export default AdminUser;

View File

@ -1,26 +1,24 @@
import { computed } from "@ember/object";
import AdminUser from "admin/models/admin-user"; import AdminUser from "admin/models/admin-user";
import RestModel from "discourse/models/rest"; import RestModel from "discourse/models/rest";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { computed } from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
import { fmt } from "discourse/lib/computed"; import { fmt } from "discourse/lib/computed";
export default class ApiKey extends RestModel { const ApiKey = RestModel.extend({
@fmt("truncated_key", "%@...") truncatedKey; user: computed("_user", {
get() {
@computed("_user") return this._user;
get user() { },
return this._user; set(key, value) {
} if (value && !(value instanceof AdminUser)) {
this.set("_user", AdminUser.create(value));
set user(value) { } else {
if (value && !(value instanceof AdminUser)) { this.set("_user", value);
this.set("_user", AdminUser.create(value)); }
} else { return this._user;
this.set("_user", value); },
} }),
return this._user;
}
@discourseComputed("description") @discourseComputed("description")
shortDescription(description) { shortDescription(description) {
@ -28,28 +26,32 @@ export default class ApiKey extends RestModel {
return description; return description;
} }
return `${description.substring(0, 40)}...`; return `${description.substring(0, 40)}...`;
} },
truncatedKey: fmt("truncated_key", "%@..."),
revoke() { revoke() {
return ajax(`${this.basePath}/revoke`, { return ajax(`${this.basePath}/revoke`, {
type: "POST", type: "POST",
}).then((result) => this.setProperties(result.api_key)); }).then((result) => this.setProperties(result.api_key));
} },
undoRevoke() { undoRevoke() {
return ajax(`${this.basePath}/undo-revoke`, { return ajax(`${this.basePath}/undo-revoke`, {
type: "POST", type: "POST",
}).then((result) => this.setProperties(result.api_key)); }).then((result) => this.setProperties(result.api_key));
} },
createProperties() { createProperties() {
return this.getProperties("description", "username", "scopes"); return this.getProperties("description", "username", "scopes");
} },
@discourseComputed() @discourseComputed()
basePath() { basePath() {
return this.store return this.store
.adapterFor("api-key") .adapterFor("api-key")
.pathFor(this.store, "api-key", this.id); .pathFor(this.store, "api-key", this.id);
} },
} });
export default ApiKey;

View File

@ -1,12 +1,12 @@
import { not } from "@ember/object/computed";
import EmberObject from "@ember/object"; import EmberObject from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
import { not } from "@ember/object/computed";
export default class BackupStatus extends EmberObject { export default EmberObject.extend({
@not("restoreEnabled") restoreDisabled; restoreDisabled: not("restoreEnabled"),
@discourseComputed("allowRestore", "isOperationRunning") @discourseComputed("allowRestore", "isOperationRunning")
restoreEnabled(allowRestore, isOperationRunning) { restoreEnabled(allowRestore, isOperationRunning) {
return allowRestore && !isOperationRunning; return allowRestore && !isOperationRunning;
} },
} });

View File

@ -2,12 +2,25 @@ import EmberObject from "@ember/object";
import MessageBus from "message-bus-client"; import MessageBus from "message-bus-client";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
export default class Backup extends EmberObject { const Backup = EmberObject.extend({
static find() { destroy() {
return ajax("/admin/backups.json"); return ajax("/admin/backups/" + this.filename, { type: "DELETE" });
} },
static start(withUploads) { restore() {
return ajax("/admin/backups/" + this.filename + "/restore", {
type: "POST",
data: { client_id: MessageBus.clientId },
});
},
});
Backup.reopenClass({
find() {
return ajax("/admin/backups.json");
},
start(withUploads) {
if (withUploads === undefined) { if (withUploads === undefined) {
withUploads = true; withUploads = true;
} }
@ -18,28 +31,19 @@ export default class Backup extends EmberObject {
client_id: MessageBus.clientId, client_id: MessageBus.clientId,
}, },
}); });
} },
static cancel() { cancel() {
return ajax("/admin/backups/cancel.json", { return ajax("/admin/backups/cancel.json", {
type: "DELETE", type: "DELETE",
}); });
} },
static rollback() { rollback() {
return ajax("/admin/backups/rollback.json", { return ajax("/admin/backups/rollback.json", {
type: "POST", type: "POST",
}); });
} },
});
destroy() { export default Backup;
return ajax("/admin/backups/" + this.filename, { type: "DELETE" });
}
restore() {
return ajax("/admin/backups/" + this.filename + "/restore", {
type: "POST",
data: { client_id: MessageBus.clientId },
});
}
}

View File

@ -1,19 +1,19 @@
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed, {
import { observes, on } from "@ember-decorators/object"; observes,
on,
} from "discourse-common/utils/decorators";
import EmberObject from "@ember/object"; import EmberObject from "@ember/object";
import I18n from "I18n"; import I18n from "I18n";
import { propertyNotEqual } from "discourse/lib/computed"; import { propertyNotEqual } from "discourse/lib/computed";
export default class ColorSchemeColor extends EmberObject { const ColorSchemeColor = EmberObject.extend({
// Whether the current value is different than Discourse's default color scheme.
@propertyNotEqual("hex", "default_hex") overridden;
@on("init") @on("init")
startTrackingChanges() { startTrackingChanges() {
this.set("originals", { hex: this.hex || "FFFFFF" }); this.set("originals", { hex: this.hex || "FFFFFF" });
// force changed property to be recalculated // force changed property to be recalculated
this.notifyPropertyChange("hex"); this.notifyPropertyChange("hex");
} },
// Whether value has changed since it was last saved. // Whether value has changed since it was last saved.
@discourseComputed("hex") @discourseComputed("hex")
@ -26,23 +26,26 @@ export default class ColorSchemeColor extends EmberObject {
} }
return false; return false;
} },
// Whether the current value is different than Discourse's default color scheme.
overridden: propertyNotEqual("hex", "default_hex"),
// Whether the saved value is different than Discourse's default color scheme. // Whether the saved value is different than Discourse's default color scheme.
@discourseComputed("default_hex", "hex") @discourseComputed("default_hex", "hex")
savedIsOverriden(defaultHex) { savedIsOverriden(defaultHex) {
return this.originals.hex !== defaultHex; return this.originals.hex !== defaultHex;
} },
revert() { revert() {
this.set("hex", this.default_hex); this.set("hex", this.default_hex);
} },
undo() { undo() {
if (this.originals) { if (this.originals) {
this.set("hex", this.originals.hex); this.set("hex", this.originals.hex);
} }
} },
@discourseComputed("name") @discourseComputed("name")
translatedName(name) { translatedName(name) {
@ -51,7 +54,7 @@ export default class ColorSchemeColor extends EmberObject {
} else { } else {
return name; return name;
} }
} },
@discourseComputed("name") @discourseComputed("name")
description(name) { description(name) {
@ -60,7 +63,7 @@ export default class ColorSchemeColor extends EmberObject {
} else { } else {
return ""; return "";
} }
} },
/** /**
brightness returns a number between 0 (darkest) to 255 (brightest). brightness returns a number between 0 (darkest) to 255 (brightest).
@ -87,17 +90,19 @@ export default class ColorSchemeColor extends EmberObject {
1000 1000
); );
} }
} },
@observes("hex") @observes("hex")
hexValueChanged() { hexValueChanged() {
if (this.hex) { if (this.hex) {
this.set("hex", this.hex.toString().replace(/[^0-9a-fA-F]/g, "")); this.set("hex", this.hex.toString().replace(/[^0-9a-fA-F]/g, ""));
} }
} },
@discourseComputed("hex") @discourseComputed("hex")
valid(hex) { valid(hex) {
return hex.match(/^([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/) !== null; return hex.match(/^([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/) !== null;
} },
} });
export default ColorSchemeColor;

View File

@ -1,4 +1,3 @@
import { not } from "@ember/object/computed";
import { A } from "@ember/array"; import { A } from "@ember/array";
import ArrayProxy from "@ember/array/proxy"; import ArrayProxy from "@ember/array/proxy";
import ColorSchemeColor from "admin/models/color-scheme-color"; import ColorSchemeColor from "admin/models/color-scheme-color";
@ -6,56 +5,26 @@ import EmberObject from "@ember/object";
import I18n from "I18n"; import I18n from "I18n";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
import { not } from "@ember/object/computed";
class ColorSchemes extends ArrayProxy {} const ColorScheme = EmberObject.extend({
export default class ColorScheme extends EmberObject {
static findAll() {
const colorSchemes = ColorSchemes.create({ content: [], loading: true });
return ajax("/admin/color_schemes").then((all) => {
all.forEach((colorScheme) => {
colorSchemes.pushObject(
ColorScheme.create({
id: colorScheme.id,
name: colorScheme.name,
is_base: colorScheme.is_base,
theme_id: colorScheme.theme_id,
theme_name: colorScheme.theme_name,
base_scheme_id: colorScheme.base_scheme_id,
user_selectable: colorScheme.user_selectable,
colors: colorScheme.colors.map((c) => {
return ColorSchemeColor.create({
name: c.name,
hex: c.hex,
default_hex: c.default_hex,
is_advanced: c.is_advanced,
});
}),
})
);
});
return colorSchemes;
});
}
@not("id") newRecord;
init() { init() {
super.init(...arguments); this._super(...arguments);
this.startTrackingChanges(); this.startTrackingChanges();
} },
@discourseComputed @discourseComputed
description() { description() {
return "" + this.name; return "" + this.name;
} },
startTrackingChanges() { startTrackingChanges() {
this.set("originals", { this.set("originals", {
name: this.name, name: this.name,
user_selectable: this.user_selectable, user_selectable: this.user_selectable,
}); });
} },
schemeJson() { schemeJson() {
const buffer = []; const buffer = [];
@ -64,7 +33,7 @@ export default class ColorScheme extends EmberObject {
}); });
return [`"${this.name}": {`, buffer.join(",\n"), "}"].join("\n"); return [`"${this.name}": {`, buffer.join(",\n"), "}"].join("\n");
} },
copy() { copy() {
const newScheme = ColorScheme.create({ const newScheme = ColorScheme.create({
@ -78,7 +47,7 @@ export default class ColorScheme extends EmberObject {
); );
}); });
return newScheme; return newScheme;
} },
@discourseComputed( @discourseComputed(
"name", "name",
@ -101,7 +70,7 @@ export default class ColorScheme extends EmberObject {
} }
return false; return false;
} },
@discourseComputed("changed") @discourseComputed("changed")
disableSave(changed) { disableSave(changed) {
@ -110,7 +79,9 @@ export default class ColorScheme extends EmberObject {
} }
return !changed || this.saving || this.colors.any((c) => !c.get("valid")); return !changed || this.saving || this.colors.any((c) => !c.get("valid"));
} },
newRecord: not("id"),
save(opts) { save(opts) {
if (this.is_base || this.disableSave) { if (this.is_base || this.disableSave) {
@ -153,7 +124,7 @@ export default class ColorScheme extends EmberObject {
this.setProperties({ savingStatus: I18n.t("saved"), saving: false }); this.setProperties({ savingStatus: I18n.t("saved"), saving: false });
this.notifyPropertyChange("description"); this.notifyPropertyChange("description");
}); });
} },
updateUserSelectable(value) { updateUserSelectable(value) {
if (!this.id) { if (!this.id) {
@ -166,11 +137,45 @@ export default class ColorScheme extends EmberObject {
dataType: "json", dataType: "json",
contentType: "application/json", contentType: "application/json",
}); });
} },
destroy() { destroy() {
if (this.id) { if (this.id) {
return ajax(`/admin/color_schemes/${this.id}`, { type: "DELETE" }); return ajax(`/admin/color_schemes/${this.id}`, { type: "DELETE" });
} }
} },
} });
const ColorSchemes = ArrayProxy.extend({});
ColorScheme.reopenClass({
findAll() {
const colorSchemes = ColorSchemes.create({ content: [], loading: true });
return ajax("/admin/color_schemes").then((all) => {
all.forEach((colorScheme) => {
colorSchemes.pushObject(
ColorScheme.create({
id: colorScheme.id,
name: colorScheme.name,
is_base: colorScheme.is_base,
theme_id: colorScheme.theme_id,
theme_name: colorScheme.theme_name,
base_scheme_id: colorScheme.base_scheme_id,
user_selectable: colorScheme.user_selectable,
colors: colorScheme.colors.map((c) => {
return ColorSchemeColor.create({
name: c.name,
hex: c.hex,
default_hex: c.default_hex,
is_advanced: c.is_advanced,
});
}),
})
);
});
return colorSchemes;
});
},
});
export default ColorScheme;

View File

@ -3,8 +3,10 @@ import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import getURL from "discourse-common/lib/get-url"; import getURL from "discourse-common/lib/get-url";
export default class EmailLog extends EmberObject { const EmailLog = EmberObject.extend({});
static create(attrs) {
EmailLog.reopenClass({
create(attrs) {
attrs = attrs || {}; attrs = attrs || {};
if (attrs.user) { if (attrs.user) {
@ -15,10 +17,10 @@ export default class EmailLog extends EmberObject {
attrs.post_url = getURL(attrs.post_url); attrs.post_url = getURL(attrs.post_url);
} }
return super.create(attrs); return this._super(attrs);
} },
static findAll(filter, offset) { findAll(filter, offset) {
filter = filter || {}; filter = filter || {};
offset = offset || 0; offset = offset || 0;
@ -28,5 +30,7 @@ export default class EmailLog extends EmberObject {
return ajax(`/admin/email/${status}.json?offset=${offset}`, { return ajax(`/admin/email/${status}.json?offset=${offset}`, {
data: filter, data: filter,
}).then((logs) => logs.map((log) => EmailLog.create(log))); }).then((logs) => logs.map((log) => EmailLog.create(log)));
} },
} });
export default EmailLog;

View File

@ -1,21 +1,25 @@
import EmberObject from "@ember/object"; import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
export default class EmailPreview extends EmberObject { const EmailPreview = EmberObject.extend({});
static findDigest(username, lastSeenAt) {
return ajax("/admin/email/preview-digest.json", {
data: { last_seen_at: lastSeenAt || oneWeekAgo(), username },
}).then((result) => EmailPreview.create(result));
}
static sendDigest(username, lastSeenAt, email) {
return ajax("/admin/email/send-digest.json", {
type: "POST",
data: { last_seen_at: lastSeenAt || oneWeekAgo(), username, email },
});
}
}
export function oneWeekAgo() { export function oneWeekAgo() {
return moment().locale("en").subtract(7, "days").format("YYYY-MM-DD"); return moment().locale("en").subtract(7, "days").format("YYYY-MM-DD");
} }
EmailPreview.reopenClass({
findDigest(username, lastSeenAt) {
return ajax("/admin/email/preview-digest.json", {
data: { last_seen_at: lastSeenAt || oneWeekAgo(), username },
}).then((result) => EmailPreview.create(result));
},
sendDigest(username, lastSeenAt, email) {
return ajax("/admin/email/send-digest.json", {
type: "POST",
data: { last_seen_at: lastSeenAt || oneWeekAgo(), username, email },
});
},
});
export default EmailPreview;

View File

@ -1,10 +1,14 @@
import EmberObject from "@ember/object"; import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
export default class EmailSettings extends EmberObject { const EmailSettings = EmberObject.extend({});
static find() {
EmailSettings.reopenClass({
find() {
return ajax("/admin/email.json").then(function (settings) { return ajax("/admin/email.json").then(function (settings) {
return EmailSettings.create(settings); return EmailSettings.create(settings);
}); });
} },
} });
export default EmailSettings;

View File

@ -1,10 +1,10 @@
import RestModel from "discourse/models/rest"; import RestModel from "discourse/models/rest";
export default class EmailStyle extends RestModel { export default RestModel.extend({
changed = false; changed: false,
setField(fieldName, value) { setField(fieldName, value) {
this.set(`${fieldName}`, value); this.set(`${fieldName}`, value);
this.set("changed", true); this.set("changed", true);
} },
} });

View File

@ -2,12 +2,12 @@ import RestModel from "discourse/models/rest";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { getProperties } from "@ember/object"; import { getProperties } from "@ember/object";
export default class EmailTemplate extends RestModel { export default RestModel.extend({
revert() { revert() {
return ajax(`/admin/customize/email_templates/${this.id}`, { return ajax(`/admin/customize/email_templates/${this.id}`, {
type: "DELETE", type: "DELETE",
}).then((result) => }).then((result) =>
getProperties(result.email_template, "subject", "body", "can_revert") getProperties(result.email_template, "subject", "body", "can_revert")
); );
} },
} });

View File

@ -2,9 +2,9 @@ import I18n from "I18n";
import RestModel from "discourse/models/rest"; import RestModel from "discourse/models/rest";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
export default class FlagType extends RestModel { export default RestModel.extend({
@discourseComputed("id") @discourseComputed("id")
name(id) { name(id) {
return I18n.t(`admin.flags.summary.action_type_${id}`, { count: 1 }); return I18n.t(`admin.flags.summary.action_type_${id}`, { count: 1 });
} },
} });

View File

@ -1,51 +1,53 @@
import RestModel from "discourse/models/rest"; import RestModel from "discourse/models/rest";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
export default class FormTemplate extends RestModel { export default class FormTemplate extends RestModel {}
static createTemplate(data) {
FormTemplate.reopenClass({
createTemplate(data) {
return ajax("/admin/customize/form-templates.json", { return ajax("/admin/customize/form-templates.json", {
type: "POST", type: "POST",
data, data,
}); });
} },
static updateTemplate(id, data) { updateTemplate(id, data) {
return ajax(`/admin/customize/form-templates/${id}.json`, { return ajax(`/admin/customize/form-templates/${id}.json`, {
type: "PUT", type: "PUT",
data, data,
}); });
} },
static createOrUpdateTemplate(data) { createOrUpdateTemplate(data) {
if (data.id) { if (data.id) {
return this.updateTemplate(data.id, data); return this.updateTemplate(data.id, data);
} else { } else {
return this.createTemplate(data); return this.createTemplate(data);
} }
} },
static deleteTemplate(id) { deleteTemplate(id) {
return ajax(`/admin/customize/form-templates/${id}.json`, { return ajax(`/admin/customize/form-templates/${id}.json`, {
type: "DELETE", type: "DELETE",
}); });
} },
static findAll() { findAll() {
return ajax(`/admin/customize/form-templates.json`).then((model) => { return ajax(`/admin/customize/form-templates.json`).then((model) => {
return model.form_templates.sort((a, b) => a.id - b.id); return model.form_templates.sort((a, b) => a.id - b.id);
}); });
} },
static findById(id) { findById(id) {
return ajax(`/admin/customize/form-templates/${id}.json`).then((model) => { return ajax(`/admin/customize/form-templates/${id}.json`).then((model) => {
return model.form_template; return model.form_template;
}); });
} },
static validateTemplate(data) { validateTemplate(data) {
return ajax(`/admin/customize/form-templates/preview.json`, { return ajax(`/admin/customize/form-templates/preview.json`, {
type: "GET", type: "GET",
data, data,
}); });
} },
} });

View File

@ -2,8 +2,10 @@ import AdminUser from "admin/models/admin-user";
import EmberObject from "@ember/object"; import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
export default class IncomingEmail extends EmberObject { const IncomingEmail = EmberObject.extend({});
static create(attrs) {
IncomingEmail.reopenClass({
create(attrs) {
attrs = attrs || {}; attrs = attrs || {};
if (attrs.user) { if (attrs.user) {
@ -11,17 +13,17 @@ export default class IncomingEmail extends EmberObject {
} }
return this._super(attrs); return this._super(attrs);
} },
static find(id) { find(id) {
return ajax(`/admin/email/incoming/${id}.json`); return ajax(`/admin/email/incoming/${id}.json`);
} },
static findByBounced(id) { findByBounced(id) {
return ajax(`/admin/email/incoming_from_bounced/${id}.json`); return ajax(`/admin/email/incoming_from_bounced/${id}.json`);
} },
static findAll(filter, offset) { findAll(filter, offset) {
filter = filter || {}; filter = filter || {};
offset = offset || 0; offset = offset || 0;
@ -33,9 +35,11 @@ export default class IncomingEmail extends EmberObject {
}).then((incomings) => }).then((incomings) =>
incomings.map((incoming) => IncomingEmail.create(incoming)) incomings.map((incoming) => IncomingEmail.create(incoming))
); );
} },
static loadRawEmail(id) { loadRawEmail(id) {
return ajax(`/admin/email/incoming/${id}/raw.json`); return ajax(`/admin/email/incoming/${id}/raw.json`);
} },
} });
export default IncomingEmail;

View File

@ -4,15 +4,7 @@ import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
export default class Permalink extends EmberObject { const Permalink = EmberObject.extend({
static findAll(filter) {
return ajax("/admin/permalinks.json", { data: { filter } }).then(function (
permalinks
) {
return permalinks.map((p) => Permalink.create(p));
});
}
save() { save() {
return ajax("/admin/permalinks.json", { return ajax("/admin/permalinks.json", {
type: "POST", type: "POST",
@ -22,21 +14,33 @@ export default class Permalink extends EmberObject {
permalink_type_value: this.permalink_type_value, permalink_type_value: this.permalink_type_value,
}, },
}); });
} },
@discourseComputed("category_id") @discourseComputed("category_id")
category(category_id) { category(category_id) {
return Category.findById(category_id); return Category.findById(category_id);
} },
@discourseComputed("external_url") @discourseComputed("external_url")
linkIsExternal(external_url) { linkIsExternal(external_url) {
return !DiscourseURL.isInternal(external_url); return !DiscourseURL.isInternal(external_url);
} },
destroy() { destroy() {
return ajax("/admin/permalinks/" + this.id + ".json", { return ajax("/admin/permalinks/" + this.id + ".json", {
type: "DELETE", type: "DELETE",
}); });
} },
} });
Permalink.reopenClass({
findAll(filter) {
return ajax("/admin/permalinks.json", { data: { filter } }).then(function (
permalinks
) {
return permalinks.map((p) => Permalink.create(p));
});
},
});
export default Permalink;

View File

@ -19,188 +19,12 @@ import round from "discourse/lib/round";
// and you want to ensure cache is reset // and you want to ensure cache is reset
export const SCHEMA_VERSION = 4; export const SCHEMA_VERSION = 4;
export default class Report extends EmberObject { const Report = EmberObject.extend({
static groupingForDatapoints(count) { average: false,
if (count < DAILY_LIMIT_DAYS) { percent: false,
return "daily"; higher_is_better: true,
} description_link: null,
description: null,
if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) {
return "weekly";
}
if (count >= WEEKLY_LIMIT_DAYS) {
return "monthly";
}
}
static unitForDatapoints(count) {
if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) {
return "week";
} else if (count >= WEEKLY_LIMIT_DAYS) {
return "month";
} else {
return "day";
}
}
static unitForGrouping(grouping) {
switch (grouping) {
case "monthly":
return "month";
case "weekly":
return "week";
default:
return "day";
}
}
static collapse(model, data, grouping) {
grouping = grouping || Report.groupingForDatapoints(data.length);
if (grouping === "daily") {
return data;
} else if (grouping === "weekly" || grouping === "monthly") {
const isoKind = grouping === "weekly" ? "isoWeek" : "month";
const kind = grouping === "weekly" ? "week" : "month";
const startMoment = moment(model.start_date, "YYYY-MM-DD");
let currentIndex = 0;
let currentStart = startMoment.clone().startOf(isoKind);
let currentEnd = startMoment.clone().endOf(isoKind);
const transformedData = [
{
x: currentStart.format("YYYY-MM-DD"),
y: 0,
},
];
let appliedAverage = false;
data.forEach((d) => {
const date = moment(d.x, "YYYY-MM-DD");
if (
!date.isSame(currentStart) &&
!date.isBetween(currentStart, currentEnd)
) {
if (model.average) {
transformedData[currentIndex].y = applyAverage(
transformedData[currentIndex].y,
currentStart,
currentEnd
);
appliedAverage = true;
}
currentIndex += 1;
currentStart = currentStart.add(1, kind).startOf(isoKind);
currentEnd = currentEnd.add(1, kind).endOf(isoKind);
} else {
appliedAverage = false;
}
if (transformedData[currentIndex]) {
transformedData[currentIndex].y += d.y;
} else {
transformedData[currentIndex] = {
x: d.x,
y: d.y,
};
}
});
if (model.average && !appliedAverage) {
transformedData[currentIndex].y = applyAverage(
transformedData[currentIndex].y,
currentStart,
moment(model.end_date).subtract(1, "day") // remove 1 day as model end date is at 00:00 of next day
);
}
return transformedData;
}
// ensure we return something if grouping is unknown
return data;
}
static fillMissingDates(report, options = {}) {
const dataField = options.dataField || "data";
const filledField = options.filledField || "data";
const startDate = options.startDate || "start_date";
const endDate = options.endDate || "end_date";
if (Array.isArray(report[dataField])) {
const startDateFormatted = moment
.utc(report[startDate])
.locale("en")
.format("YYYY-MM-DD");
const endDateFormatted = moment
.utc(report[endDate])
.locale("en")
.format("YYYY-MM-DD");
if (report.modes[0] === "stacked_chart") {
report[filledField] = report[dataField].map((rep) => {
return {
req: rep.req,
label: rep.label,
color: rep.color,
data: fillMissingDates(
JSON.parse(JSON.stringify(rep.data)),
startDateFormatted,
endDateFormatted
),
};
});
} else {
report[filledField] = fillMissingDates(
JSON.parse(JSON.stringify(report[dataField])),
startDateFormatted,
endDateFormatted
);
}
}
}
static find(type, startDate, endDate, categoryId, groupId) {
return ajax("/admin/reports/" + type, {
data: {
start_date: startDate,
end_date: endDate,
category_id: categoryId,
group_id: groupId,
},
}).then((json) => {
// dont fill for large multi column tables
// which are not date based
const modes = json.report.modes;
if (modes.length !== 1 && modes[0] !== "table") {
Report.fillMissingDates(json.report);
}
const model = Report.create({ type });
model.setProperties(json.report);
if (json.report.related_report) {
// TODO: fillMissingDates if xaxis is date
const related = Report.create({
type: json.report.related_report.type,
});
related.setProperties(json.report.related_report);
model.set("relatedReport", related);
}
return model;
});
}
average = false;
percent = false;
higher_is_better = true;
description_link = null;
description = null;
@discourseComputed("type", "start_date", "end_date") @discourseComputed("type", "start_date", "end_date")
reportUrl(type, start_date, end_date) { reportUrl(type, start_date, end_date) {
@ -211,7 +35,7 @@ export default class Report extends EmberObject {
return getURL( return getURL(
`/admin/reports/${type}?start_date=${start_date}&end_date=${end_date}` `/admin/reports/${type}?start_date=${start_date}&end_date=${end_date}`
); );
} },
valueAt(numDaysAgo) { valueAt(numDaysAgo) {
if (this.data) { if (this.data) {
@ -225,7 +49,7 @@ export default class Report extends EmberObject {
} }
} }
return 0; return 0;
} },
valueFor(startDaysAgo, endDaysAgo) { valueFor(startDaysAgo, endDaysAgo) {
if (this.data) { if (this.data) {
@ -246,46 +70,46 @@ export default class Report extends EmberObject {
} }
return round(sum, -2); return round(sum, -2);
} }
} },
@discourseComputed("data", "average") @discourseComputed("data", "average")
todayCount() { todayCount() {
return this.valueAt(0); return this.valueAt(0);
} },
@discourseComputed("data", "average") @discourseComputed("data", "average")
yesterdayCount() { yesterdayCount() {
return this.valueAt(1); return this.valueAt(1);
} },
@discourseComputed("data", "average") @discourseComputed("data", "average")
sevenDaysAgoCount() { sevenDaysAgoCount() {
return this.valueAt(7); return this.valueAt(7);
} },
@discourseComputed("data", "average") @discourseComputed("data", "average")
thirtyDaysAgoCount() { thirtyDaysAgoCount() {
return this.valueAt(30); return this.valueAt(30);
} },
@discourseComputed("data", "average") @discourseComputed("data", "average")
lastSevenDaysCount() { lastSevenDaysCount() {
return this.averageCount(7, this.valueFor(1, 7)); return this.averageCount(7, this.valueFor(1, 7));
} },
@discourseComputed("data", "average") @discourseComputed("data", "average")
lastThirtyDaysCount() { lastThirtyDaysCount() {
return this.averageCount(30, this.valueFor(1, 30)); return this.averageCount(30, this.valueFor(1, 30));
} },
averageCount(count, value) { averageCount(count, value) {
return this.average ? value / count : value; return this.average ? value / count : value;
} },
@discourseComputed("yesterdayCount", "higher_is_better") @discourseComputed("yesterdayCount", "higher_is_better")
yesterdayTrend(yesterdayCount, higherIsBetter) { yesterdayTrend(yesterdayCount, higherIsBetter) {
return this._computeTrend(this.valueAt(2), yesterdayCount, higherIsBetter); return this._computeTrend(this.valueAt(2), yesterdayCount, higherIsBetter);
} },
@discourseComputed("lastSevenDaysCount", "higher_is_better") @discourseComputed("lastSevenDaysCount", "higher_is_better")
sevenDaysTrend(lastSevenDaysCount, higherIsBetter) { sevenDaysTrend(lastSevenDaysCount, higherIsBetter) {
@ -294,39 +118,39 @@ export default class Report extends EmberObject {
lastSevenDaysCount, lastSevenDaysCount,
higherIsBetter higherIsBetter
); );
} },
@discourseComputed("data") @discourseComputed("data")
currentTotal(data) { currentTotal(data) {
return data.reduce((cur, pair) => cur + pair.y, 0); return data.reduce((cur, pair) => cur + pair.y, 0);
} },
@discourseComputed("data", "currentTotal") @discourseComputed("data", "currentTotal")
currentAverage(data, total) { currentAverage(data, total) {
return makeArray(data).length === 0 return makeArray(data).length === 0
? 0 ? 0
: parseFloat((total / parseFloat(data.length)).toFixed(1)); : parseFloat((total / parseFloat(data.length)).toFixed(1));
} },
@discourseComputed("trend", "higher_is_better") @discourseComputed("trend", "higher_is_better")
trendIcon(trend, higherIsBetter) { trendIcon(trend, higherIsBetter) {
return this._iconForTrend(trend, higherIsBetter); return this._iconForTrend(trend, higherIsBetter);
} },
@discourseComputed("sevenDaysTrend", "higher_is_better") @discourseComputed("sevenDaysTrend", "higher_is_better")
sevenDaysTrendIcon(sevenDaysTrend, higherIsBetter) { sevenDaysTrendIcon(sevenDaysTrend, higherIsBetter) {
return this._iconForTrend(sevenDaysTrend, higherIsBetter); return this._iconForTrend(sevenDaysTrend, higherIsBetter);
} },
@discourseComputed("thirtyDaysTrend", "higher_is_better") @discourseComputed("thirtyDaysTrend", "higher_is_better")
thirtyDaysTrendIcon(thirtyDaysTrend, higherIsBetter) { thirtyDaysTrendIcon(thirtyDaysTrend, higherIsBetter) {
return this._iconForTrend(thirtyDaysTrend, higherIsBetter); return this._iconForTrend(thirtyDaysTrend, higherIsBetter);
} },
@discourseComputed("yesterdayTrend", "higher_is_better") @discourseComputed("yesterdayTrend", "higher_is_better")
yesterdayTrendIcon(yesterdayTrend, higherIsBetter) { yesterdayTrendIcon(yesterdayTrend, higherIsBetter) {
return this._iconForTrend(yesterdayTrend, higherIsBetter); return this._iconForTrend(yesterdayTrend, higherIsBetter);
} },
@discourseComputed( @discourseComputed(
"prev_period", "prev_period",
@ -337,7 +161,7 @@ export default class Report extends EmberObject {
trend(prev, currentTotal, currentAverage, higherIsBetter) { trend(prev, currentTotal, currentAverage, higherIsBetter) {
const total = this.average ? currentAverage : currentTotal; const total = this.average ? currentAverage : currentTotal;
return this._computeTrend(prev, total, higherIsBetter); return this._computeTrend(prev, total, higherIsBetter);
} },
@discourseComputed( @discourseComputed(
"prev30Days", "prev30Days",
@ -356,7 +180,7 @@ export default class Report extends EmberObject {
lastThirtyDaysCount, lastThirtyDaysCount,
higherIsBetter higherIsBetter
); );
} },
@discourseComputed("type") @discourseComputed("type")
method(type) { method(type) {
@ -365,7 +189,7 @@ export default class Report extends EmberObject {
} else { } else {
return "sum"; return "sum";
} }
} },
percentChangeString(val1, val2) { percentChangeString(val1, val2) {
const change = this._computeChange(val1, val2); const change = this._computeChange(val1, val2);
@ -377,7 +201,7 @@ export default class Report extends EmberObject {
} else { } else {
return change.toFixed(0) + "%"; return change.toFixed(0) + "%";
} }
} },
@discourseComputed("prev_period", "currentTotal", "currentAverage") @discourseComputed("prev_period", "currentTotal", "currentAverage")
trendTitle(prev, currentTotal, currentAverage) { trendTitle(prev, currentTotal, currentAverage) {
@ -400,7 +224,7 @@ export default class Report extends EmberObject {
prev, prev,
current, current,
}); });
} },
changeTitle(valAtT1, valAtT2, prevPeriodString) { changeTitle(valAtT1, valAtT2, prevPeriodString) {
const change = this.percentChangeString(valAtT1, valAtT2); const change = this.percentChangeString(valAtT1, valAtT2);
@ -410,12 +234,12 @@ export default class Report extends EmberObject {
} }
title += `Was ${number(valAtT1)} ${prevPeriodString}.`; title += `Was ${number(valAtT1)} ${prevPeriodString}.`;
return title; return title;
} },
@discourseComputed("yesterdayCount") @discourseComputed("yesterdayCount")
yesterdayCountTitle(yesterdayCount) { yesterdayCountTitle(yesterdayCount) {
return this.changeTitle(this.valueAt(2), yesterdayCount, "two days ago"); return this.changeTitle(this.valueAt(2), yesterdayCount, "two days ago");
} },
@discourseComputed("lastSevenDaysCount") @discourseComputed("lastSevenDaysCount")
sevenDaysCountTitle(lastSevenDaysCount) { sevenDaysCountTitle(lastSevenDaysCount) {
@ -424,12 +248,12 @@ export default class Report extends EmberObject {
lastSevenDaysCount, lastSevenDaysCount,
"two weeks ago" "two weeks ago"
); );
} },
@discourseComputed("prev30Days", "prev_period") @discourseComputed("prev30Days", "prev_period")
canDisplayTrendIcon(prev30Days, prev_period) { canDisplayTrendIcon(prev30Days, prev_period) {
return prev30Days ?? prev_period; return prev30Days ?? prev_period;
} },
@discourseComputed("prev30Days", "prev_period", "lastThirtyDaysCount") @discourseComputed("prev30Days", "prev_period", "lastThirtyDaysCount")
thirtyDaysCountTitle(prev30Days, prev_period, lastThirtyDaysCount) { thirtyDaysCountTitle(prev30Days, prev_period, lastThirtyDaysCount) {
@ -438,12 +262,12 @@ export default class Report extends EmberObject {
lastThirtyDaysCount, lastThirtyDaysCount,
"in the previous 30 day period" "in the previous 30 day period"
); );
} },
@discourseComputed("data") @discourseComputed("data")
sortedData(data) { sortedData(data) {
return this.xAxisIsDate ? data.toArray().reverse() : data.toArray(); return this.xAxisIsDate ? data.toArray().reverse() : data.toArray();
} },
@discourseComputed("data") @discourseComputed("data")
xAxisIsDate() { xAxisIsDate() {
@ -451,7 +275,7 @@ export default class Report extends EmberObject {
return false; return false;
} }
return this.data && this.data[0].x.match(/\d{4}-\d{1,2}-\d{1,2}/); return this.data && this.data[0].x.match(/\d{4}-\d{1,2}-\d{1,2}/);
} },
@discourseComputed("labels") @discourseComputed("labels")
computedLabels(labels) { computedLabels(labels) {
@ -535,7 +359,7 @@ export default class Report extends EmberObject {
}, },
}; };
}); });
} },
_userLabel(properties, row) { _userLabel(properties, row) {
const username = row[properties.username]; const username = row[properties.username];
@ -564,7 +388,7 @@ export default class Report extends EmberObject {
value: username, value: username,
formattedValue: username ? formattedValue() : "—", formattedValue: username ? formattedValue() : "—",
}; };
} },
_topicLabel(properties, row) { _topicLabel(properties, row) {
const topicTitle = row[properties.title]; const topicTitle = row[properties.title];
@ -579,7 +403,7 @@ export default class Report extends EmberObject {
value: topicTitle, value: topicTitle,
formattedValue: topicTitle ? formattedValue() : "—", formattedValue: topicTitle ? formattedValue() : "—",
}; };
} },
_postLabel(properties, row) { _postLabel(properties, row) {
const postTitle = row[properties.truncated_raw]; const postTitle = row[properties.truncated_raw];
@ -595,21 +419,21 @@ export default class Report extends EmberObject {
? `<a href='${href}'>${escapeExpression(postTitle)}</a>` ? `<a href='${href}'>${escapeExpression(postTitle)}</a>`
: "—", : "—",
}; };
} },
_secondsLabel(value) { _secondsLabel(value) {
return { return {
value: toNumber(value), value: toNumber(value),
formattedValue: durationTiny(value), formattedValue: durationTiny(value),
}; };
} },
_percentLabel(value) { _percentLabel(value) {
return { return {
value: toNumber(value), value: toNumber(value),
formattedValue: value ? `${value}%` : "—", formattedValue: value ? `${value}%` : "—",
}; };
} },
_numberLabel(value, options = {}) { _numberLabel(value, options = {}) {
const formatNumbers = isEmpty(options.formatNumbers) const formatNumbers = isEmpty(options.formatNumbers)
@ -622,21 +446,21 @@ export default class Report extends EmberObject {
value: toNumber(value), value: toNumber(value),
formattedValue: value ? formattedValue() : "—", formattedValue: value ? formattedValue() : "—",
}; };
} },
_bytesLabel(value) { _bytesLabel(value) {
return { return {
value: toNumber(value), value: toNumber(value),
formattedValue: I18n.toHumanSize(value), formattedValue: I18n.toHumanSize(value),
}; };
} },
_dateLabel(value, date, format = "LL") { _dateLabel(value, date, format = "LL") {
return { return {
value, value,
formattedValue: value ? date.format(format) : "—", formattedValue: value ? date.format(format) : "—",
}; };
} },
_textLabel(value) { _textLabel(value) {
const escaped = escapeExpression(value); const escaped = escapeExpression(value);
@ -645,7 +469,7 @@ export default class Report extends EmberObject {
value, value,
formattedValue: value ? escaped : "—", formattedValue: value ? escaped : "—",
}; };
} },
_linkLabel(properties, row) { _linkLabel(properties, row) {
const property = properties[0]; const property = properties[0];
@ -660,11 +484,11 @@ export default class Report extends EmberObject {
value, value,
formattedValue: value ? formattedValue(value, row[properties[1]]) : "—", formattedValue: value ? formattedValue(value, row[properties[1]]) : "—",
}; };
} },
_computeChange(valAtT1, valAtT2) { _computeChange(valAtT1, valAtT2) {
return ((valAtT2 - valAtT1) / valAtT1) * 100; return ((valAtT2 - valAtT1) / valAtT1) * 100;
} },
_computeTrend(valAtT1, valAtT2, higherIsBetter) { _computeTrend(valAtT1, valAtT2, higherIsBetter) {
const change = this._computeChange(valAtT1, valAtT2); const change = this._computeChange(valAtT1, valAtT2);
@ -680,7 +504,7 @@ export default class Report extends EmberObject {
} else if (change < -2) { } else if (change < -2) {
return higherIsBetter ? "trending-down" : "trending-up"; return higherIsBetter ? "trending-down" : "trending-up";
} }
} },
_iconForTrend(trend, higherIsBetter) { _iconForTrend(trend, higherIsBetter) {
switch (trend) { switch (trend) {
@ -695,8 +519,8 @@ export default class Report extends EmberObject {
default: default:
return "minus"; return "minus";
} }
} },
} });
export const WEEKLY_LIMIT_DAYS = 365; export const WEEKLY_LIMIT_DAYS = 365;
export const DAILY_LIMIT_DAYS = 34; export const DAILY_LIMIT_DAYS = 34;
@ -705,3 +529,183 @@ function applyAverage(value, start, end) {
const count = end.diff(start, "day") + 1; // 1 to include start const count = end.diff(start, "day") + 1; // 1 to include start
return parseFloat((value / count).toFixed(2)); return parseFloat((value / count).toFixed(2));
} }
Report.reopenClass({
groupingForDatapoints(count) {
if (count < DAILY_LIMIT_DAYS) {
return "daily";
}
if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) {
return "weekly";
}
if (count >= WEEKLY_LIMIT_DAYS) {
return "monthly";
}
},
unitForDatapoints(count) {
if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) {
return "week";
} else if (count >= WEEKLY_LIMIT_DAYS) {
return "month";
} else {
return "day";
}
},
unitForGrouping(grouping) {
switch (grouping) {
case "monthly":
return "month";
case "weekly":
return "week";
default:
return "day";
}
},
collapse(model, data, grouping) {
grouping = grouping || Report.groupingForDatapoints(data.length);
if (grouping === "daily") {
return data;
} else if (grouping === "weekly" || grouping === "monthly") {
const isoKind = grouping === "weekly" ? "isoWeek" : "month";
const kind = grouping === "weekly" ? "week" : "month";
const startMoment = moment(model.start_date, "YYYY-MM-DD");
let currentIndex = 0;
let currentStart = startMoment.clone().startOf(isoKind);
let currentEnd = startMoment.clone().endOf(isoKind);
const transformedData = [
{
x: currentStart.format("YYYY-MM-DD"),
y: 0,
},
];
let appliedAverage = false;
data.forEach((d) => {
const date = moment(d.x, "YYYY-MM-DD");
if (
!date.isSame(currentStart) &&
!date.isBetween(currentStart, currentEnd)
) {
if (model.average) {
transformedData[currentIndex].y = applyAverage(
transformedData[currentIndex].y,
currentStart,
currentEnd
);
appliedAverage = true;
}
currentIndex += 1;
currentStart = currentStart.add(1, kind).startOf(isoKind);
currentEnd = currentEnd.add(1, kind).endOf(isoKind);
} else {
appliedAverage = false;
}
if (transformedData[currentIndex]) {
transformedData[currentIndex].y += d.y;
} else {
transformedData[currentIndex] = {
x: d.x,
y: d.y,
};
}
});
if (model.average && !appliedAverage) {
transformedData[currentIndex].y = applyAverage(
transformedData[currentIndex].y,
currentStart,
moment(model.end_date).subtract(1, "day") // remove 1 day as model end date is at 00:00 of next day
);
}
return transformedData;
}
// ensure we return something if grouping is unknown
return data;
},
fillMissingDates(report, options = {}) {
const dataField = options.dataField || "data";
const filledField = options.filledField || "data";
const startDate = options.startDate || "start_date";
const endDate = options.endDate || "end_date";
if (Array.isArray(report[dataField])) {
const startDateFormatted = moment
.utc(report[startDate])
.locale("en")
.format("YYYY-MM-DD");
const endDateFormatted = moment
.utc(report[endDate])
.locale("en")
.format("YYYY-MM-DD");
if (report.modes[0] === "stacked_chart") {
report[filledField] = report[dataField].map((rep) => {
return {
req: rep.req,
label: rep.label,
color: rep.color,
data: fillMissingDates(
JSON.parse(JSON.stringify(rep.data)),
startDateFormatted,
endDateFormatted
),
};
});
} else {
report[filledField] = fillMissingDates(
JSON.parse(JSON.stringify(report[dataField])),
startDateFormatted,
endDateFormatted
);
}
}
},
find(type, startDate, endDate, categoryId, groupId) {
return ajax("/admin/reports/" + type, {
data: {
start_date: startDate,
end_date: endDate,
category_id: categoryId,
group_id: groupId,
},
}).then((json) => {
// dont fill for large multi column tables
// which are not date based
const modes = json.report.modes;
if (modes.length !== 1 && modes[0] !== "table") {
Report.fillMissingDates(json.report);
}
const model = Report.create({ type });
model.setProperties(json.report);
if (json.report.related_report) {
// TODO: fillMissingDates if xaxis is date
const related = Report.create({
type: json.report.related_report.type,
});
related.setProperties(json.report.related_report);
model.set("relatedReport", related);
}
return model;
});
},
});
export default Report;

View File

@ -3,8 +3,21 @@ import I18n from "I18n";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
export default class ScreenedEmail extends EmberObject { const ScreenedEmail = EmberObject.extend({
static findAll() { @discourseComputed("action")
actionName(action) {
return I18n.t("admin.logs.screened_actions." + action);
},
clearBlock() {
return ajax("/admin/logs/screened_emails/" + this.id, {
type: "DELETE",
});
},
});
ScreenedEmail.reopenClass({
findAll() {
return ajax("/admin/logs/screened_emails.json").then(function ( return ajax("/admin/logs/screened_emails.json").then(function (
screened_emails screened_emails
) { ) {
@ -12,16 +25,7 @@ export default class ScreenedEmail extends EmberObject {
return ScreenedEmail.create(b); return ScreenedEmail.create(b);
}); });
}); });
} },
});
@discourseComputed("action") export default ScreenedEmail;
actionName(action) {
return I18n.t("admin.logs.screened_actions." + action);
}
clearBlock() {
return ajax("/admin/logs/screened_emails/" + this.id, {
type: "DELETE",
});
}
}

View File

@ -1,28 +1,21 @@
import { equal } from "@ember/object/computed";
import EmberObject from "@ember/object"; import EmberObject from "@ember/object";
import I18n from "I18n"; import I18n from "I18n";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
import { equal } from "@ember/object/computed";
export default class ScreenedIpAddress extends EmberObject { const ScreenedIpAddress = EmberObject.extend({
static findAll(filter) {
return ajax("/admin/logs/screened_ip_addresses.json", {
data: { filter },
}).then((screened_ips) =>
screened_ips.map((b) => ScreenedIpAddress.create(b))
);
}
@equal("action_name", "block") isBlocked;
@discourseComputed("action_name") @discourseComputed("action_name")
actionName(actionName) { actionName(actionName) {
return I18n.t(`admin.logs.screened_ips.actions.${actionName}`); return I18n.t(`admin.logs.screened_ips.actions.${actionName}`);
} },
isBlocked: equal("action_name", "block"),
@discourseComputed("ip_address") @discourseComputed("ip_address")
isRange(ipAddress) { isRange(ipAddress) {
return ipAddress.indexOf("/") > 0; return ipAddress.indexOf("/") > 0;
} },
save() { save() {
return ajax( return ajax(
@ -37,11 +30,23 @@ export default class ScreenedIpAddress extends EmberObject {
}, },
} }
); );
} },
destroy() { destroy() {
return ajax("/admin/logs/screened_ip_addresses/" + this.id + ".json", { return ajax("/admin/logs/screened_ip_addresses/" + this.id + ".json", {
type: "DELETE", type: "DELETE",
}); });
} },
} });
ScreenedIpAddress.reopenClass({
findAll(filter) {
return ajax("/admin/logs/screened_ip_addresses.json", {
data: { filter },
}).then((screened_ips) =>
screened_ips.map((b) => ScreenedIpAddress.create(b))
);
},
});
export default ScreenedIpAddress;

View File

@ -3,8 +3,15 @@ import I18n from "I18n";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
export default class ScreenedUrl extends EmberObject { const ScreenedUrl = EmberObject.extend({
static findAll() { @discourseComputed("action")
actionName(action) {
return I18n.t("admin.logs.screened_actions." + action);
},
});
ScreenedUrl.reopenClass({
findAll() {
return ajax("/admin/logs/screened_urls.json").then(function ( return ajax("/admin/logs/screened_urls.json").then(function (
screened_urls screened_urls
) { ) {
@ -12,10 +19,7 @@ export default class ScreenedUrl extends EmberObject {
return ScreenedUrl.create(b); return ScreenedUrl.create(b);
}); });
}); });
} },
});
@discourseComputed("action") export default ScreenedUrl;
actionName(action) {
return I18n.t("admin.logs.screened_actions." + action);
}
}

View File

@ -4,8 +4,22 @@ import Setting from "admin/mixins/setting-object";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
export default class SiteSetting extends EmberObject.extend(Setting) { const SiteSetting = EmberObject.extend(Setting, {
static findAll() { @discourseComputed("setting")
staffLogFilter(setting) {
if (!setting) {
return;
}
return {
subject: setting,
action_name: "change_site_setting",
};
},
});
SiteSetting.reopenClass({
findAll() {
return ajax("/admin/site_settings").then(function (settings) { return ajax("/admin/site_settings").then(function (settings) {
// Group the results by category // Group the results by category
const categories = {}; const categories = {};
@ -24,9 +38,9 @@ export default class SiteSetting extends EmberObject.extend(Setting) {
}; };
}); });
}); });
} },
static update(key, value, opts = {}) { update(key, value, opts = {}) {
const data = {}; const data = {};
data[key] = value; data[key] = value;
@ -35,17 +49,7 @@ export default class SiteSetting extends EmberObject.extend(Setting) {
} }
return ajax(`/admin/site_settings/${key}`, { type: "PUT", data }); return ajax(`/admin/site_settings/${key}`, { type: "PUT", data });
} },
});
@discourseComputed("setting") export default SiteSetting;
staffLogFilter(setting) {
if (!setting) {
return;
}
return {
subject: setting,
action_name: "change_site_setting",
};
}
}

View File

@ -2,10 +2,10 @@ import RestModel from "discourse/models/rest";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { getProperties } from "@ember/object"; import { getProperties } from "@ember/object";
export default class SiteText extends RestModel { export default RestModel.extend({
revert(locale) { revert(locale) {
return ajax(`/admin/customize/site_texts/${this.id}?locale=${locale}`, { return ajax(`/admin/customize/site_texts/${this.id}?locale=${locale}`, {
type: "DELETE", type: "DELETE",
}).then((result) => getProperties(result.site_text, "value", "can_revert")); }).then((result) => getProperties(result.site_text, "value", "can_revert"));
} },
} });

View File

@ -11,36 +11,13 @@ function format(label, value, escape = true) {
: ""; : "";
} }
export default class StaffActionLog extends RestModel { const StaffActionLog = RestModel.extend({
static munge(json) { showFullDetails: false,
if (json.acting_user) {
json.acting_user = AdminUser.create(json.acting_user);
}
if (json.target_user) {
json.target_user = AdminUser.create(json.target_user);
}
return json;
}
static findAll(data) {
return ajax("/admin/logs/staff_action_logs.json", { data }).then(
(result) => {
return {
staff_action_logs: result.staff_action_logs.map((s) =>
StaffActionLog.create(s)
),
user_history_actions: result.user_history_actions,
};
}
);
}
showFullDetails = false;
@discourseComputed("action_name") @discourseComputed("action_name")
actionName(actionName) { actionName(actionName) {
return I18n.t(`admin.logs.staff_actions.actions.${actionName}`); return I18n.t(`admin.logs.staff_actions.actions.${actionName}`);
} },
@discourseComputed( @discourseComputed(
"email", "email",
@ -95,15 +72,42 @@ export default class StaffActionLog extends RestModel {
const formatted = lines.filter((l) => l.length > 0).join("<br/>"); const formatted = lines.filter((l) => l.length > 0).join("<br/>");
return formatted.length > 0 ? formatted + "<br/>" : ""; return formatted.length > 0 ? formatted + "<br/>" : "";
} },
@discourseComputed("details") @discourseComputed("details")
useModalForDetails(details) { useModalForDetails(details) {
return details && details.length > 100; return details && details.length > 100;
} },
@discourseComputed("action_name") @discourseComputed("action_name")
useCustomModalForDetails(actionName) { useCustomModalForDetails(actionName) {
return ["change_theme", "delete_theme"].includes(actionName); return ["change_theme", "delete_theme"].includes(actionName);
} },
} });
StaffActionLog.reopenClass({
munge(json) {
if (json.acting_user) {
json.acting_user = AdminUser.create(json.acting_user);
}
if (json.target_user) {
json.target_user = AdminUser.create(json.target_user);
}
return json;
},
findAll(data) {
return ajax("/admin/logs/staff_action_logs.json", { data }).then(
(result) => {
return {
staff_action_logs: result.staff_action_logs.map((s) =>
StaffActionLog.create(s)
),
user_history_actions: result.user_history_actions,
};
}
);
},
});
export default StaffActionLog;

View File

@ -1,4 +1,4 @@
import EmberObject from "@ember/object"; import EmberObject from "@ember/object";
import Setting from "admin/mixins/setting-object"; import Setting from "admin/mixins/setting-object";
export default class ThemeSettings extends EmberObject.extend(Setting) {} export default EmberObject.extend(Setting, {});

View File

@ -13,13 +13,11 @@ export const THEMES = "themes";
export const COMPONENTS = "components"; export const COMPONENTS = "components";
const SETTINGS_TYPE_ID = 5; const SETTINGS_TYPE_ID = 5;
class Theme extends RestModel { const Theme = RestModel.extend({
@or("default", "user_selectable") isActive; isActive: or("default", "user_selectable"),
@gt("remote_theme.commits_behind", 0) isPendingUpdates; isPendingUpdates: gt("remote_theme.commits_behind", 0),
@gt("editedFields.length", 0) hasEditedFields; hasEditedFields: gt("editedFields.length", 0),
@gt("parent_themes.length", 0) hasParents; hasParents: gt("parent_themes.length", 0),
changed = false;
@discourseComputed("theme_fields.[]") @discourseComputed("theme_fields.[]")
targets() { targets() {
@ -47,7 +45,7 @@ class Theme extends RestModel {
target["error"] = this.hasError(target.name); target["error"] = this.hasError(target.name);
return target; return target;
}); });
} },
@discourseComputed("theme_fields.[]") @discourseComputed("theme_fields.[]")
fieldNames() { fieldNames() {
@ -86,7 +84,7 @@ class Theme extends RestModel {
], ],
extra_scss: scss_fields, extra_scss: scss_fields,
}; };
} },
@discourseComputed( @discourseComputed(
"fieldNames", "fieldNames",
@ -120,7 +118,7 @@ class Theme extends RestModel {
}); });
}); });
return hash; return hash;
} },
@discourseComputed("theme_fields") @discourseComputed("theme_fields")
themeFields(fields) { themeFields(fields) {
@ -136,7 +134,7 @@ class Theme extends RestModel {
} }
}); });
return hash; return hash;
} },
@discourseComputed("theme_fields", "theme_fields.[]") @discourseComputed("theme_fields", "theme_fields.[]")
uploads(fields) { uploads(fields) {
@ -146,32 +144,32 @@ class Theme extends RestModel {
return fields.filter( return fields.filter(
(f) => f.target === "common" && f.type_id === THEME_UPLOAD_VAR (f) => f.target === "common" && f.type_id === THEME_UPLOAD_VAR
); );
} },
@discourseComputed("theme_fields", "theme_fields.@each.error") @discourseComputed("theme_fields", "theme_fields.@each.error")
isBroken(fields) { isBroken(fields) {
return ( return (
fields && fields.any((field) => field.error && field.error.length > 0) fields && fields.any((field) => field.error && field.error.length > 0)
); );
} },
@discourseComputed("theme_fields.[]") @discourseComputed("theme_fields.[]")
editedFields(fields) { editedFields(fields) {
return fields.filter( return fields.filter(
(field) => !isBlank(field.value) && field.type_id !== SETTINGS_TYPE_ID (field) => !isBlank(field.value) && field.type_id !== SETTINGS_TYPE_ID
); );
} },
@discourseComputed("remote_theme.last_error_text") @discourseComputed("remote_theme.last_error_text")
remoteError(errorText) { remoteError(errorText) {
if (errorText && errorText.length > 0) { if (errorText && errorText.length > 0) {
return errorText; return errorText;
} }
} },
getKey(field) { getKey(field) {
return `${field.target} ${field.name}`; return `${field.target} ${field.name}`;
} },
hasEdited(target, name) { hasEdited(target, name) {
if (name) { if (name) {
@ -182,27 +180,27 @@ class Theme extends RestModel {
(field) => field.target === target && !isEmpty(field.value) (field) => field.target === target && !isEmpty(field.value)
); );
} }
} },
hasError(target, name) { hasError(target, name) {
return this.theme_fields return this.theme_fields
.filter((f) => f.target === target && (!name || name === f.name)) .filter((f) => f.target === target && (!name || name === f.name))
.any((f) => f.error); .any((f) => f.error);
} },
getError(target, name) { getError(target, name) {
let themeFields = this.themeFields; let themeFields = this.themeFields;
let key = this.getKey({ target, name }); let key = this.getKey({ target, name });
let field = themeFields[key]; let field = themeFields[key];
return field ? field.error : ""; return field ? field.error : "";
} },
getField(target, name) { getField(target, name) {
let themeFields = this.themeFields; let themeFields = this.themeFields;
let key = this.getKey({ target, name }); let key = this.getKey({ target, name });
let field = themeFields[key]; let field = themeFields[key];
return field ? field.value : ""; return field ? field.value : "";
} },
removeField(field) { removeField(field) {
this.set("changed", true); this.set("changed", true);
@ -211,7 +209,7 @@ class Theme extends RestModel {
field.value = null; field.value = null;
return this.saveChanges("theme_fields"); return this.saveChanges("theme_fields");
} },
setField(target, name, value, upload_id, type_id) { setField(target, name, value, upload_id, type_id) {
this.set("changed", true); this.set("changed", true);
@ -251,25 +249,25 @@ class Theme extends RestModel {
this.notifyPropertyChange("theme_fields.[]"); this.notifyPropertyChange("theme_fields.[]");
} }
} }
} },
@discourseComputed("childThemes.[]") @discourseComputed("childThemes.[]")
child_theme_ids(childThemes) { child_theme_ids(childThemes) {
if (childThemes) { if (childThemes) {
return childThemes.map((theme) => get(theme, "id")); return childThemes.map((theme) => get(theme, "id"));
} }
} },
@discourseComputed("recentlyInstalled", "component", "hasParents") @discourseComputed("recentlyInstalled", "component", "hasParents")
warnUnassignedComponent(recent, component, hasParents) { warnUnassignedComponent(recent, component, hasParents) {
return recent && component && !hasParents; return recent && component && !hasParents;
} },
removeChildTheme(theme) { removeChildTheme(theme) {
const childThemes = this.childThemes; const childThemes = this.childThemes;
childThemes.removeObject(theme); childThemes.removeObject(theme);
return this.saveChanges("child_theme_ids"); return this.saveChanges("child_theme_ids");
} },
addChildTheme(theme) { addChildTheme(theme) {
let childThemes = this.childThemes; let childThemes = this.childThemes;
@ -280,7 +278,7 @@ class Theme extends RestModel {
childThemes.removeObject(theme); childThemes.removeObject(theme);
childThemes.pushObject(theme); childThemes.pushObject(theme);
return this.saveChanges("child_theme_ids"); return this.saveChanges("child_theme_ids");
} },
addParentTheme(theme) { addParentTheme(theme) {
let parentThemes = this.parentThemes; let parentThemes = this.parentThemes;
@ -289,36 +287,38 @@ class Theme extends RestModel {
this.set("parentThemes", parentThemes); this.set("parentThemes", parentThemes);
} }
parentThemes.addObject(theme); parentThemes.addObject(theme);
} },
checkForUpdates() { checkForUpdates() {
return this.save({ remote_check: true }).then(() => return this.save({ remote_check: true }).then(() =>
this.set("changed", false) this.set("changed", false)
); );
} },
updateToLatest() { updateToLatest() {
return this.save({ remote_update: true }).then(() => return this.save({ remote_update: true }).then(() =>
this.set("changed", false) this.set("changed", false)
); );
} },
changed: false,
saveChanges() { saveChanges() {
const hash = this.getProperties.apply(this, arguments); const hash = this.getProperties.apply(this, arguments);
return this.save(hash) return this.save(hash)
.finally(() => this.set("changed", false)) .finally(() => this.set("changed", false))
.catch(popupAjaxError); .catch(popupAjaxError);
} },
saveSettings(name, value) { saveSettings(name, value) {
const settings = {}; const settings = {};
settings[name] = value; settings[name] = value;
return this.save({ settings }); return this.save({ settings });
} },
saveTranslation(name, value) { saveTranslation(name, value) {
return this.save({ translations: { [name]: value } }); return this.save({ translations: { [name]: value } });
} },
} });
export default Theme; export default Theme;

View File

@ -1,21 +1,21 @@
import EmberObject from "@ember/object"; import EmberObject from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
export default class Tl3Requirements extends EmberObject { export default EmberObject.extend({
@discourseComputed("days_visited", "time_period") @discourseComputed("days_visited", "time_period")
days_visited_percent(daysVisited, timePeriod) { days_visited_percent(daysVisited, timePeriod) {
return Math.round((daysVisited * 100) / timePeriod); return Math.round((daysVisited * 100) / timePeriod);
} },
@discourseComputed("min_days_visited", "time_period") @discourseComputed("min_days_visited", "time_period")
min_days_visited_percent(minDaysVisited, timePeriod) { min_days_visited_percent(minDaysVisited, timePeriod) {
return Math.round((minDaysVisited * 100) / timePeriod); return Math.round((minDaysVisited * 100) / timePeriod);
} },
@discourseComputed("num_topics_replied_to", "min_topics_replied_to") @discourseComputed("num_topics_replied_to", "min_topics_replied_to")
capped_topics_replied_to(numReplied, minReplied) { capped_topics_replied_to(numReplied, minReplied) {
return numReplied > minReplied; return numReplied > minReplied;
} },
@discourseComputed( @discourseComputed(
"days_visited", "days_visited",
@ -71,5 +71,5 @@ export default class Tl3Requirements extends EmberObject {
silenced: this.get("penalty_counts.silenced") === 0, silenced: this.get("penalty_counts.silenced") === 0,
suspended: this.get("penalty_counts.suspended") === 0, suspended: this.get("penalty_counts.suspended") === 0,
}; };
} },
} });

View File

@ -2,8 +2,14 @@ import EmberObject from "@ember/object";
import RestModel from "discourse/models/rest"; import RestModel from "discourse/models/rest";
import { i18n } from "discourse/lib/computed"; import { i18n } from "discourse/lib/computed";
export default class UserField extends RestModel { const UserField = RestModel.extend();
static fieldTypes() {
const UserFieldType = EmberObject.extend({
name: i18n("id", "admin.user_fields.field_types.%@"),
});
UserField.reopenClass({
fieldTypes() {
if (!this._fieldTypes) { if (!this._fieldTypes) {
this._fieldTypes = [ this._fieldTypes = [
UserFieldType.create({ id: "text" }), UserFieldType.create({ id: "text" }),
@ -14,13 +20,11 @@ export default class UserField extends RestModel {
} }
return this._fieldTypes; return this._fieldTypes;
} },
static fieldTypeById(id) { fieldTypeById(id) {
return this.fieldTypes().findBy("id", id); return this.fieldTypes().findBy("id", id);
} },
} });
class UserFieldType extends EmberObject { export default UserField;
@i18n("id", "admin.user_fields.field_types.%@") name;
}

View File

@ -2,39 +2,43 @@ import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
export default class VersionCheck extends EmberObject { const VersionCheck = EmberObject.extend({
static find() {
return ajax("/admin/version_check").then((json) =>
VersionCheck.create(json)
);
}
@discourseComputed("updated_at") @discourseComputed("updated_at")
noCheckPerformed(updatedAt) { noCheckPerformed(updatedAt) {
return updatedAt === null; return updatedAt === null;
} },
@discourseComputed("missing_versions_count") @discourseComputed("missing_versions_count")
upToDate(missingVersionsCount) { upToDate(missingVersionsCount) {
return missingVersionsCount === 0 || missingVersionsCount === null; return missingVersionsCount === 0 || missingVersionsCount === null;
} },
@discourseComputed("missing_versions_count") @discourseComputed("missing_versions_count")
behindByOneVersion(missingVersionsCount) { behindByOneVersion(missingVersionsCount) {
return missingVersionsCount === 1; return missingVersionsCount === 1;
} },
@discourseComputed("installed_sha") @discourseComputed("installed_sha")
gitLink(installedSHA) { gitLink(installedSHA) {
if (installedSHA) { if (installedSHA) {
return `https://github.com/discourse/discourse/commits/${installedSHA}`; return `https://github.com/discourse/discourse/commits/${installedSHA}`;
} }
} },
@discourseComputed("installed_sha") @discourseComputed("installed_sha")
shortSha(installedSHA) { shortSha(installedSHA) {
if (installedSHA) { if (installedSHA) {
return installedSHA.slice(0, 10); return installedSHA.slice(0, 10);
} }
} },
} });
VersionCheck.reopenClass({
find() {
return ajax("/admin/version_check").then((json) =>
VersionCheck.create(json)
);
},
});
export default VersionCheck;

View File

@ -2,8 +2,34 @@ import EmberObject from "@ember/object";
import I18n from "I18n"; import I18n from "I18n";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
export default class WatchedWord extends EmberObject { const WatchedWord = EmberObject.extend({
static findAll() { save() {
return ajax(
"/admin/customize/watched_words" +
(this.id ? "/" + this.id : "") +
".json",
{
type: this.id ? "PUT" : "POST",
data: {
word: this.word,
replacement: this.replacement,
action_key: this.action,
case_sensitive: this.isCaseSensitive,
},
dataType: "json",
}
);
},
destroy() {
return ajax("/admin/customize/watched_words/" + this.id + ".json", {
type: "DELETE",
});
},
});
WatchedWord.reopenClass({
findAll() {
return ajax("/admin/customize/watched_words.json").then((list) => { return ajax("/admin/customize/watched_words.json").then((list) => {
const actions = {}; const actions = {};
@ -24,29 +50,7 @@ export default class WatchedWord extends EmberObject {
}); });
}); });
}); });
} },
});
save() { export default WatchedWord;
return ajax(
"/admin/customize/watched_words" +
(this.id ? "/" + this.id : "") +
".json",
{
type: this.id ? "PUT" : "POST",
data: {
word: this.word,
replacement: this.replacement,
action_key: this.action,
case_sensitive: this.isCaseSensitive,
},
dataType: "json",
}
);
}
destroy() {
return ajax("/admin/customize/watched_words/" + this.id + ".json", {
type: "DELETE",
});
}
}

View File

@ -1,34 +1,33 @@
import { computed } from "@ember/object"; import discourseComputed, { observes } from "discourse-common/utils/decorators";
import discourseComputed from "discourse-common/utils/decorators";
import { observes } from "@ember-decorators/object";
import Category from "discourse/models/category"; import Category from "discourse/models/category";
import Group from "discourse/models/group"; import Group from "discourse/models/group";
import RestModel from "discourse/models/rest"; import RestModel from "discourse/models/rest";
import Site from "discourse/models/site"; import Site from "discourse/models/site";
import { isEmpty } from "@ember/utils"; import { isEmpty } from "@ember/utils";
export default class WebHook extends RestModel { export default RestModel.extend({
content_type = 1; // json content_type: 1, // json
last_delivery_status = 1; // inactive last_delivery_status: 1, // inactive
wildcard_web_hook = false; wildcard_web_hook: false,
verify_certificate = true; verify_certificate: true,
active = false; active: false,
web_hook_event_types = null; web_hook_event_types: null,
groupsFilterInName = null; groupsFilterInName: null,
@computed("wildcard_web_hook") @discourseComputed("wildcard_web_hook")
get wildcard() { webhookType: {
return this.wildcard_web_hook ? "wildcard" : "individual"; get(wildcard) {
} return wildcard ? "wildcard" : "individual";
},
set wildcard(value) { set(value) {
this.set("wildcard_web_hook", value === "wildcard"); this.set("wildcard_web_hook", value === "wildcard");
} },
},
@discourseComputed("category_ids") @discourseComputed("category_ids")
categories(categoryIds) { categories(categoryIds) {
return Category.findByIds(categoryIds); return Category.findByIds(categoryIds);
} },
@observes("group_ids") @observes("group_ids")
updateGroupsFilter() { updateGroupsFilter() {
@ -42,11 +41,11 @@ export default class WebHook extends RestModel {
return groupNames; return groupNames;
}, []) }, [])
); );
} },
groupFinder(term) { groupFinder(term) {
return Group.findAll({ term, ignore_automatic: false }); return Group.findAll({ term, ignore_automatic: false });
} },
@discourseComputed("wildcard_web_hook", "web_hook_event_types.[]") @discourseComputed("wildcard_web_hook", "web_hook_event_types.[]")
description(isWildcardWebHook, types) { description(isWildcardWebHook, types) {
@ -58,7 +57,7 @@ export default class WebHook extends RestModel {
}); });
return isWildcardWebHook ? "*" : desc; return isWildcardWebHook ? "*" : desc;
} },
createProperties() { createProperties() {
const types = this.web_hook_event_types; const types = this.web_hook_event_types;
@ -93,9 +92,9 @@ export default class WebHook extends RestModel {
return groupIds; return groupIds;
}, []), }, []),
}; };
} },
updateProperties() { updateProperties() {
return this.createProperties(); return this.createProperties();
} },
} });

View File

@ -10,8 +10,8 @@ import { htmlSafe } from "@ember/template";
// A service that can act as a bridge between the front end Discourse application // A service that can act as a bridge between the front end Discourse application
// and the admin application. Use this if you need front end code to access admin // and the admin application. Use this if you need front end code to access admin
// modules. Inject it optionally, and if it exists go to town! // modules. Inject it optionally, and if it exists go to town!
export default class AdminToolsService extends Service { export default Service.extend({
@service dialog; dialog: service(),
showActionLogs(target, filters) { showActionLogs(target, filters) {
const controller = getOwner(target).lookup( const controller = getOwner(target).lookup(
@ -20,15 +20,15 @@ export default class AdminToolsService extends Service {
target.transitionToRoute("adminLogs.staffActionLogs").then(() => { target.transitionToRoute("adminLogs.staffActionLogs").then(() => {
controller.changeFilters(filters); controller.changeFilters(filters);
}); });
} },
checkSpammer(userId) { checkSpammer(userId) {
return AdminUser.find(userId).then((au) => this.spammerDetails(au)); return AdminUser.find(userId).then((au) => this.spammerDetails(au));
} },
deleteUser(id, formData) { deleteUser(id, formData) {
return AdminUser.find(id).then((user) => user.destroy(formData)); return AdminUser.find(id).then((user) => user.destroy(formData));
} },
spammerDetails(adminUser) { spammerDetails(adminUser) {
return { return {
@ -37,7 +37,7 @@ export default class AdminToolsService extends Service {
adminUser.get("can_be_deleted") && adminUser.get("can_be_deleted") &&
adminUser.get("can_delete_all_posts"), adminUser.get("can_delete_all_posts"),
}; };
} },
_showControlModal(type, user, opts) { _showControlModal(type, user, opts) {
opts = opts || {}; opts = opts || {};
@ -67,15 +67,15 @@ export default class AdminToolsService extends Service {
controller.finishedSetup(); controller.finishedSetup();
}); });
} },
showSilenceModal(user, opts) { showSilenceModal(user, opts) {
this._showControlModal("silence", user, opts); this._showControlModal("silence", user, opts);
} },
showSuspendModal(user, opts) { showSuspendModal(user, opts) {
this._showControlModal("suspend", user, opts); this._showControlModal("suspend", user, opts);
} },
_deleteSpammer(adminUser) { _deleteSpammer(adminUser) {
// Try loading the email if the site supports it // Try loading the email if the site supports it
@ -131,5 +131,5 @@ export default class AdminToolsService extends Service {
}); });
}); });
}); });
} },
} });

View File

@ -2,7 +2,7 @@ export const POPULAR_THEMES = [
{ {
name: "Graceful", name: "Graceful",
value: "https://github.com/discourse/graceful", value: "https://github.com/discourse/graceful",
preview: "https://discourse.theme-creator.io/theme/awesomerobot/graceful", preview: "https://theme-creator.discourse.org/theme/awesomerobot/graceful",
description: "A light and graceful theme for Discourse.", description: "A light and graceful theme for Discourse.",
meta_url: meta_url:
"https://meta.discourse.org/t/a-graceful-theme-for-discourse/93040", "https://meta.discourse.org/t/a-graceful-theme-for-discourse/93040",
@ -10,7 +10,8 @@ export const POPULAR_THEMES = [
{ {
name: "Material Design Theme", name: "Material Design Theme",
value: "https://github.com/discourse/material-design-stock-theme", value: "https://github.com/discourse/material-design-stock-theme",
preview: "https://discourse.theme-creator.io/theme/tshenry/material-design", preview:
"https://theme-creator.discourse.org/theme/tshenry/material-design",
description: description:
"Inspired by Material Design, this theme comes with several color palettes (incl. a dark one).", "Inspired by Material Design, this theme comes with several color palettes (incl. a dark one).",
meta_url: "https://meta.discourse.org/t/material-design-stock-theme/47142", meta_url: "https://meta.discourse.org/t/material-design-stock-theme/47142",
@ -18,7 +19,7 @@ export const POPULAR_THEMES = [
{ {
name: "Minima", name: "Minima",
value: "https://github.com/discourse/minima", value: "https://github.com/discourse/minima",
preview: "https://discourse.theme-creator.io/theme/awesomerobot/minima", preview: "https://theme-creator.discourse.org/theme/awesomerobot/minima",
description: "A minimal theme with reduced UI elements and focus on text.", description: "A minimal theme with reduced UI elements and focus on text.",
meta_url: meta_url:
"https://meta.discourse.org/t/minima-a-minimal-theme-for-discourse/108178", "https://meta.discourse.org/t/minima-a-minimal-theme-for-discourse/108178",
@ -26,7 +27,7 @@ export const POPULAR_THEMES = [
{ {
name: "Sam's Simple Theme", name: "Sam's Simple Theme",
value: "https://github.com/discourse/discourse-simple-theme", value: "https://github.com/discourse/discourse-simple-theme",
preview: "https://discourse.theme-creator.io/theme/sam/simple", preview: "https://theme-creator.discourse.org/theme/sam/simple",
description: description:
"Simplified front page design with classic colors and typography.", "Simplified front page design with classic colors and typography.",
meta_url: meta_url:
@ -35,8 +36,6 @@ export const POPULAR_THEMES = [
{ {
name: "Brand Header", name: "Brand Header",
value: "https://github.com/discourse/discourse-brand-header", value: "https://github.com/discourse/discourse-brand-header",
preview:
"https://discourse.theme-creator.io/theme/vinothkannans/brand-header",
description: description:
"Add an extra top header with your logo, navigation links and social icons.", "Add an extra top header with your logo, navigation links and social icons.",
meta_url: "https://meta.discourse.org/t/brand-header-theme-component/77977", meta_url: "https://meta.discourse.org/t/brand-header-theme-component/77977",
@ -46,7 +45,7 @@ export const POPULAR_THEMES = [
name: "Custom Header Links", name: "Custom Header Links",
value: "https://github.com/discourse/discourse-custom-header-links", value: "https://github.com/discourse/discourse-custom-header-links",
preview: preview:
"https://discourse.theme-creator.io/theme/awesomerobot/custom-header-links", "https://theme-creator.discourse.org/theme/Johani/custom-header-links",
description: "Easily add custom text-based links to the header.", description: "Easily add custom text-based links to the header.",
meta_url: "https://meta.discourse.org/t/custom-header-links/90588", meta_url: "https://meta.discourse.org/t/custom-header-links/90588",
component: true, component: true,
@ -62,7 +61,7 @@ export const POPULAR_THEMES = [
name: "Category Banners", name: "Category Banners",
value: "https://github.com/discourse/discourse-category-banners", value: "https://github.com/discourse/discourse-category-banners",
preview: preview:
"https://discourse.theme-creator.io/theme/awesomerobot/discourse-category-banners", "https://theme-creator.discourse.org/theme/awesomerobot/discourse-category-banners",
description: description:
"Show banners on category pages using your existing category details.", "Show banners on category pages using your existing category details.",
meta_url: "https://meta.discourse.org/t/discourse-category-banners/86241", meta_url: "https://meta.discourse.org/t/discourse-category-banners/86241",
@ -71,7 +70,7 @@ export const POPULAR_THEMES = [
{ {
name: "Kanban Board", name: "Kanban Board",
value: "https://github.com/discourse/discourse-kanban-theme", value: "https://github.com/discourse/discourse-kanban-theme",
preview: "https://discourse.theme-creator.io/theme/david/kanban", preview: "https://theme-creator.discourse.org/theme/david/kanban",
description: "Display and organize topics using a Kanban board interface.", description: "Display and organize topics using a Kanban board interface.",
meta_url: meta_url:
"https://meta.discourse.org/t/kanban-board-theme-component/118164", "https://meta.discourse.org/t/kanban-board-theme-component/118164",
@ -85,19 +84,10 @@ export const POPULAR_THEMES = [
meta_url: "https://meta.discourse.org/t/hamburger-theme-selector/61210", meta_url: "https://meta.discourse.org/t/hamburger-theme-selector/61210",
component: true, component: true,
}, },
{
name: "Sidebar Theme Toggle",
value: "https://github.com/discourse/discourse-sidebar-theme-toggle",
description:
"Displays a theme selector in the sidebar menus footer provided there is more than one user-selectable theme.",
meta_url: "https://meta.discourse.org/t/sidebar-theme-toggle/242802",
component: true,
},
{ {
name: "Header Submenus", name: "Header Submenus",
value: "https://github.com/discourse/discourse-header-submenus", value: "https://github.com/discourse/discourse-header-submenus",
preview: preview: "https://theme-creator.discourse.org/theme/Johani/header-submenus",
"https://discourse.theme-creator.io/theme/awesomerobot/header-submenus",
description: "Lets you build a header menu with submenus (dropdowns).", description: "Lets you build a header menu with submenus (dropdowns).",
meta_url: "https://meta.discourse.org/t/header-submenus/94584", meta_url: "https://meta.discourse.org/t/header-submenus/94584",
component: true, component: true,
@ -114,7 +104,7 @@ export const POPULAR_THEMES = [
{ {
name: "Easy Responsive Footer", name: "Easy Responsive Footer",
value: "https://github.com/discourse/Discourse-easy-footer", value: "https://github.com/discourse/Discourse-easy-footer",
preview: "https://discourse.theme-creator.io/theme/Johani/easy-footer", preview: "https://theme-creator.discourse.org/theme/Johani/easy-footer",
description: "Add a fully responsive footer without writing any HTML.", description: "Add a fully responsive footer without writing any HTML.",
meta_url: "https://meta.discourse.org/t/easy-responsive-footer/95818", meta_url: "https://meta.discourse.org/t/easy-responsive-footer/95818",
component: true, component: true,

View File

@ -0,0 +1,5 @@
"use strict";
module.exports = {
name: require("./package").name,
};

View File

@ -0,0 +1,14 @@
{
"name": "discourse-ensure-deprecation-order",
"version": "1.0.0",
"description": "A dummy addon which ensures ember-cli-deprecation-workflow is loaded before @ember/jquery",
"author": "Discourse",
"license": "GPL-2.0-only",
"keywords": [
"ember-addon"
],
"ember-addon": {
"before": "@ember/jquery",
"after": "ember-cli-deprecation-workflow"
}
}

View File

@ -30,7 +30,7 @@
/> />
<TopicStatus @topic={{t}} @disableActions={{true}} /> <TopicStatus @topic={{t}} @disableActions={{true}} />
<span class="topic-title"> <span class="topic-title">
{{replace-emoji t.title}} {{replace-emoji t.fancy_title}}
</span> </span>
<span class="topic-categories"> <span class="topic-categories">
{{bound-category-link {{bound-category-link

View File

@ -12,7 +12,6 @@ import { alias } from "@ember/object/computed";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
import { iconHTML } from "discourse-common/lib/icon-library"; import { iconHTML } from "discourse-common/lib/icon-library";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import { escape } from "pretty-text/sanitizer";
const TITLES = { const TITLES = {
[PRIVATE_MESSAGE]: "topic.private_message", [PRIVATE_MESSAGE]: "topic.private_message",
@ -85,9 +84,7 @@ export default Component.extend({
}, },
_formatReplyToUserPost(avatar, link) { _formatReplyToUserPost(avatar, link) {
const htmlLink = `<a class="user-link" href="${link.href}">${escape( const htmlLink = `<a class="user-link" href="${link.href}">${link.anchor}</a>`;
link.anchor
)}</a>`;
return htmlSafe(`${avatar}${htmlLink}`); return htmlSafe(`${avatar}${htmlLink}`);
}, },
}); });

View File

@ -1,4 +1,4 @@
import { TextArea } from "@ember/legacy-built-in-components"; import TextArea from "@ember/component/text-area";
export default TextArea.extend({ export default TextArea.extend({
attributeBindings: ["aria-label"], attributeBindings: ["aria-label"],

View File

@ -1,5 +1,5 @@
import { observes, on } from "discourse-common/utils/decorators"; import { observes, on } from "discourse-common/utils/decorators";
import { TextArea } from "@ember/legacy-built-in-components"; import TextArea from "@ember/component/text-area";
import autosize from "discourse/lib/autosize"; import autosize from "discourse/lib/autosize";
import { schedule } from "@ember/runloop"; import { schedule } from "@ember/runloop";

View File

@ -1,7 +1,7 @@
import { cancel, next } from "@ember/runloop"; import { cancel, next } from "@ember/runloop";
import { isLTR, isRTL, siteDir } from "discourse/lib/text-direction"; import { isLTR, isRTL, siteDir } from "discourse/lib/text-direction";
import I18n from "I18n"; import I18n from "I18n";
import { TextField } from "@ember/legacy-built-in-components"; import TextField from "@ember/component/text-field";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";

View File

@ -1,9 +1,7 @@
import { emojiUnescape } from "discourse/lib/text"; import { emojiUnescape } from "discourse/lib/text";
import { htmlSafe, isHTMLSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import { registerUnbound } from "discourse-common/lib/helpers"; import { registerUnbound } from "discourse-common/lib/helpers";
import { escapeExpression } from "discourse/lib/utilities";
registerUnbound("replace-emoji", (text, options) => { registerUnbound("replace-emoji", (text, options) => {
text = isHTMLSafe(text) ? text.toString() : escapeExpression(text);
return htmlSafe(emojiUnescape(text, options)); return htmlSafe(emojiUnescape(text, options));
}); });

View File

@ -0,0 +1,22 @@
import TextField from "@ember/component/text-field";
import TextArea from "@ember/component/text-area";
let initializedOnce = false;
export default {
name: "ember-input-component-extensions",
initialize() {
if (initializedOnce) {
return;
}
TextField.reopen({
attributeBindings: ["aria-describedby", "aria-invalid"],
});
TextArea.reopen({
attributeBindings: ["aria-describedby", "aria-invalid"],
});
initializedOnce = true;
},
};

View File

@ -2,7 +2,6 @@
import Component from "@ember/component"; import Component from "@ember/component";
import EmberObject from "@ember/object"; import EmberObject from "@ember/object";
import { actionModifier } from "./ember-action-modifier"; import { actionModifier } from "./ember-action-modifier";
import Ember from "ember";
/** /**
* Classic Ember components (i.e. "@ember/component") rely upon "event * Classic Ember components (i.e. "@ember/component") rely upon "event

View File

@ -1,46 +0,0 @@
import { assert, deprecate } from "@ember/debug";
import EmberObject from "@ember/object";
import Component from "@ember/component";
import jQuery from "jquery";
let done = false;
// Adapted from https://github.com/emberjs/ember-jquery/blob/master/vendor/jquery/component.dollar.js
// but implemented in a module to avoid transpiled version triggering the Ember Global deprecation.
// To be dropped when we remove the jquery integration as part of the 4.x update.
export default {
name: "deprecate-jquery-integration",
initialize() {
if (done) {
return;
}
EmberObject.reopen.call(Component, {
$(sel) {
assert(
"You cannot access this.$() on a component with `tagName: ''` specified.",
this.tagName !== ""
);
deprecate(
"Using this.$() in a component has been deprecated, consider using this.element",
false,
{
id: "ember-views.curly-components.jquery-element",
since: "3.4.0",
until: "4.0.0",
url: "https://emberjs.com/deprecations/v3.x#toc_jquery-apis",
for: "ember-source",
}
);
if (this.element) {
return sel ? jQuery(sel, this.element) : jQuery(this.element);
}
},
});
done = true;
},
};

View File

@ -94,8 +94,8 @@
@value={{this.accountName}} @value={{this.accountName}}
@id="new-account-name" @id="new-account-name"
@class={{value-entered this.accountName}} @class={{value-entered this.accountName}}
aria-describedby="fullname-validation" @aria-describedby="fullname-validation"
aria-invalid={{this.nameValidation.failed}} @aria-invalid={{this.nameValidation.failed}}
/> />
<label class="alt-placeholder" for="new-account-name"> <label class="alt-placeholder" for="new-account-name">
{{i18n "user.name.title"}} {{i18n "user.name.title"}}
@ -134,8 +134,8 @@
id="new-account-password" id="new-account-password"
@autocomplete="current-password" @autocomplete="current-password"
@capsLockOn={{this.capsLockOn}} @capsLockOn={{this.capsLockOn}}
aria-describedby="password-validation" @aria-describedby="password-validation"
aria-invalid={{this.passwordValidation.failed}} @aria-invalid={{this.passwordValidation.failed}}
/> />
<label class="alt-placeholder" for="new-account-password"> <label class="alt-placeholder" for="new-account-password">
{{i18n "user.password.title"}} {{i18n "user.password.title"}}

View File

@ -21,7 +21,7 @@
data-title={{result.fancy_title}} data-title={{result.fancy_title}}
> >
<TopicStatus @topic={{result}} @disableActions={{true}} /> <TopicStatus @topic={{result}} @disableActions={{true}} />
{{replace-emoji result.title}} {{replace-emoji result.fancy_title}}
<div class="search-category"> <div class="search-category">
{{#if result.category.parentCategory}} {{#if result.category.parentCategory}}
{{category-link result.category.parentCategory}} {{category-link result.category.parentCategory}}

View File

@ -16,7 +16,7 @@
href={{rt.relative_url}} href={{rt.relative_url}}
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
>{{replace-emoji rt.title}}</a> >{{replace-emoji rt.fancy_title}}</a>
</div> </div>
</td> </td>
<td class="reviewable-count"> <td class="reviewable-count">

View File

@ -3,6 +3,9 @@ globalThis.deprecationWorkflow.config = {
// We're using RAISE_ON_DEPRECATION in environment.js instead of // We're using RAISE_ON_DEPRECATION in environment.js instead of
// `throwOnUnhandled` here since it is easier to toggle. // `throwOnUnhandled` here since it is easier to toggle.
workflow: [ workflow: [
{ handler: "silence", matchId: "ember-global" },
{ handler: "silence", matchId: "ember.built-in-components.reopen" },
{ handler: "silence", matchId: "ember.built-in-components.import" },
{ handler: "silence", matchId: "implicit-injections" }, { handler: "silence", matchId: "implicit-injections" },
{ handler: "silence", matchId: "route-render-template" }, { handler: "silence", matchId: "route-render-template" },
{ handler: "silence", matchId: "routing.transition-methods" }, { handler: "silence", matchId: "routing.transition-methods" },

View File

@ -163,16 +163,6 @@ module.exports = function (defaults) {
} }
}; };
// @ember/jquery introduces a shim which triggers the ember-global deprecation.
// We remove that shim, and re-implement ourselves in the deprecate-jquery-integration pre-initializer
const vendorScripts = app._scriptOutputFiles["/assets/vendor.js"];
const componentDollarShimIndex = vendorScripts.indexOf(
"vendor/jquery/component.dollar.js"
);
if (componentDollarShimIndex) {
vendorScripts.splice(componentDollarShimIndex, 1);
}
// WARNING: We should only import scripts here if they are not in NPM. // WARNING: We should only import scripts here if they are not in NPM.
// For example: our very specific version of bootstrap-modal. // For example: our very specific version of bootstrap-modal.
app.import(vendorJs + "bootbox.js"); app.import(vendorJs + "bootbox.js");

View File

@ -23,7 +23,6 @@
"@discourse/virtual-dom": "^2.1.2-0", "@discourse/virtual-dom": "^2.1.2-0",
"@ember-compat/tracked-built-ins": "^0.9.1", "@ember-compat/tracked-built-ins": "^0.9.1",
"@ember/jquery": "^2.0.0", "@ember/jquery": "^2.0.0",
"@ember/legacy-built-in-components": "^0.4.2",
"@ember/optional-features": "^2.0.0", "@ember/optional-features": "^2.0.0",
"@ember/render-modifiers": "^2.0.5", "@ember/render-modifiers": "^2.0.5",
"@ember/test-helpers": "^2.9.3", "@ember/test-helpers": "^2.9.3",
@ -47,6 +46,7 @@
"deepmerge": "^4.3.0", "deepmerge": "^4.3.0",
"dialog-holder": "1.0.0", "dialog-holder": "1.0.0",
"discourse-common": "1.0.0", "discourse-common": "1.0.0",
"discourse-ensure-deprecation-order": "1.0.0",
"discourse-hbr": "1.0.0", "discourse-hbr": "1.0.0",
"discourse-plugins": "1.0.0", "discourse-plugins": "1.0.0",
"discourse-widget-hbs": "1.0.0", "discourse-widget-hbs": "1.0.0",

View File

@ -3,13 +3,6 @@
throw "Unsupported browser detected"; throw "Unsupported browser detected";
} }
// In Ember 3.28, the `ember` package is responsible for configuring `Helper.helper`,
// so we need to require('ember') before setting up any helpers.
// https://github.com/emberjs/ember.js/blob/744e536d37/packages/ember/index.js#L493-L493
// In modern Ember, the Helper.helper definition has moved to the helper module itself
// https://github.com/emberjs/ember.js/blob/0c5518ea7b/packages/%40ember/-internals/glimmer/lib/helper.ts#L134-L138
require("ember");
window.__widget_helpers = require("discourse-widget-hbs/helpers").default; window.__widget_helpers = require("discourse-widget-hbs/helpers").default;
// TODO: Eliminate this global // TODO: Eliminate this global

View File

@ -518,11 +518,11 @@ acceptance("Prioritize Full Name", function (needs) {
test("Reply to post use full name", async function (assert) { test("Reply to post use full name", async function (assert) {
await visit("/t/short-topic-with-two-posts/54079"); await visit("/t/short-topic-with-two-posts/54079");
await click("article#post_3 button.reply"); await click("article#post_2 button.reply");
assert.strictEqual( assert.strictEqual(
query(".action-title .user-link").innerHTML.trim(), query(".action-title .user-link").innerText.trim(),
"&lt;h1&gt;Tim Stone&lt;/h1&gt;" "james, john, the third"
); );
}); });

View File

@ -6497,7 +6497,7 @@ export default {
}, },
{ {
id: 419, id: 419,
name: "<h1>Tim Stone</h1>", name: "Tim Stone",
username: "tms", username: "tms",
avatar_template: "/letter_avatar_proxy/v4/letter/t/3be4f8/{size}.png", avatar_template: "/letter_avatar_proxy/v4/letter/t/3be4f8/{size}.png",
uploaded_avatar_id: 40181, uploaded_avatar_id: 40181,

View File

@ -1,29 +0,0 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
module("Integration | Helper | replace-emoji", function (hooks) {
setupRenderingTest(hooks);
test("it replaces the emoji", async function (assert) {
await render(hbs`<span>{{replace-emoji "some text :heart:"}}</span>`);
assert.dom(`span`).includesText("some text");
assert.dom(`.emoji[title="heart"]`).exists();
});
test("it escapes the text", async function (assert) {
await render(
hbs`<span>{{replace-emoji "<style>body: {background: red;}</style>"}}</span>`
);
assert.dom(`span`).hasText("<style>body: {background: red;}</style>");
});
test("it renders html-safe text", async function (assert) {
await render(hbs`<span>{{replace-emoji (html-safe "safe text")}}</span>`);
assert.dom(`span`).hasText("safe text");
});
});

View File

@ -6,6 +6,7 @@
"dialog-holder", "dialog-holder",
"discourse", "discourse",
"discourse-common", "discourse-common",
"discourse-ensure-deprecation-order",
"discourse-hbr", "discourse-hbr",
"discourse-plugins", "discourse-plugins",
"discourse-widget-hbs", "discourse-widget-hbs",

View File

@ -1,5 +1,5 @@
<TopicStatus @topic={{this.item}} @disableActions={{true}} /> <TopicStatus @topic={{this.item}} @disableActions={{true}} />
<div class="topic-title">{{replace-emoji this.item.title}}</div> <div class="topic-title">{{replace-emoji this.item.fancy_title}}</div>
<div class="topic-categories"> <div class="topic-categories">
{{bound-category-link {{bound-category-link
this.item.category this.item.category

View File

@ -1088,16 +1088,6 @@
jquery "^3.5.0" jquery "^3.5.0"
resolve "^1.15.1" resolve "^1.15.1"
"@ember/legacy-built-in-components@^0.4.2":
version "0.4.2"
resolved "https://registry.yarnpkg.com/@ember/legacy-built-in-components/-/legacy-built-in-components-0.4.2.tgz#79a97d66153ff17909759b368b2a117bc9e168e5"
integrity sha512-rJulbyVQIVe1zEDQDqAQHechHy44DsS2qxO24+NmU/AYxwPFSzWC/OZNCDFSfLU+Y5BVd/00qjxF0pu7Nk+TNA==
dependencies:
"@embroider/macros" "^1.0.0"
ember-cli-babel "^7.26.6"
ember-cli-htmlbars "^5.7.1"
ember-cli-typescript "^4.1.0"
"@ember/optional-features@^2.0.0": "@ember/optional-features@^2.0.0":
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/@ember/optional-features/-/optional-features-2.0.0.tgz#c809abd5a27d5b0ef3c6de3941334ab6153313f0" resolved "https://registry.yarnpkg.com/@ember/optional-features/-/optional-features-2.0.0.tgz#c809abd5a27d5b0ef3c6de3941334ab6153313f0"
@ -4009,22 +3999,6 @@ ember-cli-typescript@^2.0.2:
stagehand "^1.0.0" stagehand "^1.0.0"
walk-sync "^1.0.0" walk-sync "^1.0.0"
ember-cli-typescript@^4.1.0:
version "4.2.1"
resolved "https://registry.yarnpkg.com/ember-cli-typescript/-/ember-cli-typescript-4.2.1.tgz#54d08fc90318cc986f3ea562f93ce58a6cc4c24d"
integrity sha512-0iKTZ+/wH6UB/VTWKvGuXlmwiE8HSIGcxHamwNhEC5x1mN3z8RfvsFZdQWYUzIWFN2Tek0gmepGRPTwWdBYl/A==
dependencies:
ansi-to-html "^0.6.15"
broccoli-stew "^3.0.0"
debug "^4.0.0"
execa "^4.0.0"
fs-extra "^9.0.1"
resolve "^1.5.0"
rsvp "^4.8.1"
semver "^7.3.2"
stagehand "^1.0.0"
walk-sync "^2.2.0"
ember-cli-typescript@^5.0.0: ember-cli-typescript@^5.0.0:
version "5.1.0" version "5.1.0"
resolved "https://registry.yarnpkg.com/ember-cli-typescript/-/ember-cli-typescript-5.1.0.tgz#460eb848564e29d64f2b36b2a75bbe98172b72a4" resolved "https://registry.yarnpkg.com/ember-cli-typescript/-/ember-cli-typescript-5.1.0.tgz#460eb848564e29d64f2b36b2a75bbe98172b72a4"

View File

@ -155,7 +155,6 @@
.quick-access-panel { .quick-access-panel {
width: 320px; width: 320px;
padding: 0.75em; padding: 0.75em;
padding-bottom: env(safe-area-inset-bottom, 0.75em);
justify-content: space-between; justify-content: space-between;
box-sizing: border-box; box-sizing: border-box;
min-width: 0; // makes sure menu tabs don't go off screen min-width: 0; // makes sure menu tabs don't go off screen
@ -717,7 +716,9 @@ body.footer-nav-ipad {
--100dvh: 100dvh; --100dvh: 100dvh;
} }
--base-height: calc(var(--100dvh) - var(--header-top)); --base-height: calc(
var(--100dvh) - var(--header-top) - env(safe-area-inset-bottom, 0px)
);
height: var(--base-height); height: var(--base-height);

View File

@ -11,7 +11,6 @@
border-top: 1.5px solid var(--primary-low); border-top: 1.5px solid var(--primary-low);
background: var(--primary-very-low); background: var(--primary-very-low);
padding: 0.5em 0.8em; padding: 0.5em 0.8em;
padding-bottom: env(safe-area-inset-bottom, 0.5em);
&:before { &:before {
// fade to make scroll more apparent // fade to make scroll more apparent
position: absolute; position: absolute;

View File

@ -32,14 +32,20 @@ class ApplicationRequest < ActiveRecord::Base
end end
def self.write_cache!(req_type, count, date) def self.write_cache!(req_type, count, date)
id = req_id(date, req_type)
where(id: id).update_all(["count = count + ?", count])
end
def self.req_id(date, req_type, retries = 0)
req_type_id = req_types[req_type] req_type_id = req_types[req_type]
DB.exec(<<~SQL, date: date, req_type_id: req_type_id, count: count) create_or_find_by!(date: date, req_type: req_type_id).id
INSERT INTO application_requests (date, req_type, count) rescue StandardError # primary key violation
VALUES (:date, :req_type_id, :count) if retries == 0
ON CONFLICT (date, req_type) req_id(date, req_type, 1)
DO UPDATE SET count = application_requests.count + excluded.count else
SQL raise
end
end end
def self.stats def self.stats

View File

@ -46,8 +46,7 @@ class Bookmark < ActiveRecord::Base
validates :name, length: { maximum: 100 } validates :name, length: { maximum: 100 }
def registered_bookmarkable def registered_bookmarkable
type = Bookmark.polymorphic_class_for(self.bookmarkable_type).name Bookmark.registered_bookmarkable_from_type(self.bookmarkable_type)
Bookmark.registered_bookmarkable_from_type(type)
end end
def polymorphic_columns_present def polymorphic_columns_present

View File

@ -129,7 +129,7 @@ class Reviewable < ActiveRecord::Base
update_args = { update_args = {
status: statuses[:pending], status: statuses[:pending],
id: target.id, id: target.id,
type: target.class.sti_name, type: target.class.name,
potential_spam: potential_spam == true ? true : nil, potential_spam: potential_spam == true ? true : nil,
} }

View File

@ -193,7 +193,7 @@ module CookedProcessorMixin
if upload && upload.width && upload.width > 0 if upload && upload.width && upload.width > 0
@size_cache[url] = [upload.width, upload.height] @size_cache[url] = [upload.width, upload.height]
else else
@size_cache[url] = FinalDestination::FastImage.size(absolute_url) @size_cache[url] = FastImage.size(absolute_url)
end end
rescue Zlib::BufError, URI::Error, OpenSSL::SSL::SSLError rescue Zlib::BufError, URI::Error, OpenSSL::SSL::SSLError
# FastImage.size raises BufError for some gifs, leave it. # FastImage.size raises BufError for some gifs, leave it.

View File

@ -1,23 +0,0 @@
# frozen_string_literal: true
class FinalDestination::FastImage < ::FastImage
def initialize(url, options = {})
uri = URI(normalized_url(url))
options.merge!(http_header: { "Host" => uri.hostname })
uri.hostname = resolved_ip(uri)
super(uri.to_s, options)
rescue FinalDestination::SSRFDetector::DisallowedIpError, SocketError, Timeout::Error
super("")
end
private
def resolved_ip(uri)
FinalDestination::SSRFDetector.lookup_and_filter_ips(uri.hostname).first
end
def normalized_url(uri)
UrlHelper.normalized_encode(uri)
end
end

View File

@ -7,47 +7,18 @@ class FinalDestination
class LookupFailedError < SocketError class LookupFailedError < SocketError
end end
# This is a list of private IPv4 IP ranges that are not allowed to be globally reachable as given by def self.standard_private_ranges
# https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml. @private_ranges ||= [
PRIVATE_IPV4_RANGES = [ IPAddr.new("0.0.0.0/8"),
IPAddr.new("0.0.0.0/8"), IPAddr.new("127.0.0.1"),
IPAddr.new("10.0.0.0/8"), IPAddr.new("172.16.0.0/12"),
IPAddr.new("100.64.0.0/10"), IPAddr.new("192.168.0.0/16"),
IPAddr.new("127.0.0.0/8"), IPAddr.new("10.0.0.0/8"),
IPAddr.new("169.254.0.0/16"), IPAddr.new("::1"),
IPAddr.new("172.16.0.0/12"), IPAddr.new("fc00::/7"),
IPAddr.new("192.0.0.0/24"), IPAddr.new("fe80::/10"),
IPAddr.new("192.0.0.0/29"), ]
IPAddr.new("192.0.0.8/32"), end
IPAddr.new("192.0.0.170/32"),
IPAddr.new("192.0.0.171/32"),
IPAddr.new("192.0.2.0/24"),
IPAddr.new("192.168.0.0/16"),
IPAddr.new("192.175.48.0/24"),
IPAddr.new("198.18.0.0/15"),
IPAddr.new("198.51.100.0/24"),
IPAddr.new("203.0.113.0/24"),
IPAddr.new("240.0.0.0/4"),
IPAddr.new("255.255.255.255/32"),
]
# This is a list of private IPv6 IP ranges that are not allowed to be globally reachable as given by
# https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml.
#
# ::ffff:0:0/96 is excluded from the list because it is used for IPv4-mapped IPv6 addresses which is something we want to allow.
PRIVATE_IPV6_RANGES = [
IPAddr.new("::1/128"),
IPAddr.new("::/128"),
IPAddr.new("64:ff9b:1::/48"),
IPAddr.new("100::/64"),
IPAddr.new("2001::/23"),
IPAddr.new("2001:2::/48"),
IPAddr.new("2001:db8::/32"),
IPAddr.new("fc00::/7"),
IPAddr.new("fe80::/10"),
]
PRIVATE_IP_RANGES = PRIVATE_IPV4_RANGES + PRIVATE_IPV6_RANGES
def self.blocked_ip_blocks def self.blocked_ip_blocks
SiteSetting SiteSetting
@ -83,9 +54,10 @@ class FinalDestination
def self.ip_allowed?(ip) def self.ip_allowed?(ip)
ip = ip.is_a?(IPAddr) ? ip : IPAddr.new(ip) ip = ip.is_a?(IPAddr) ? ip : IPAddr.new(ip)
ip = ip.native
return false if ip_in_ranges?(ip, blocked_ip_blocks) || ip_in_ranges?(ip, PRIVATE_IP_RANGES) if ip_in_ranges?(ip, blocked_ip_blocks) || ip_in_ranges?(ip, standard_private_ranges)
return false
end
true true
end end

View File

@ -1,27 +0,0 @@
# frozen_string_literal: true
# This is a patch to avoid the direct use of `Net::HTTP` in the `webpush` gem and instead rely on `FinalDestination::HTTP`
# which protects us from DNS rebinding attacks as well as server side forgery requests.
#
# This patch is considered temporary until we can decide on a longer term solution. In the meantime, we need to patch
# the SSRF vulnerability being exposed by this gem.
module WebPushPatch
def perform
http = FinalDestination::HTTP.new(uri.host, uri.port, *proxy_options)
http.use_ssl = true
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
req = FinalDestination::HTTP::Post.new(uri.request_uri, headers)
req.body = body
resp = http.request(req)
verify_response(resp)
resp
end
end
klass = defined?(WebPush) ? WebPush : Webpush
klass::Request.prepend(WebPushPatch)

View File

@ -213,10 +213,7 @@ task "docker:test" do
@good &&= run_or_fail("bundle exec rspec #{params.join(" ")}".strip) @good &&= run_or_fail("bundle exec rspec #{params.join(" ")}".strip)
end end
if ENV["RUN_SYSTEM_TESTS"] @good &&= run_or_fail("bundle exec rspec spec/system".strip) if ENV["RUN_SYSTEM_TESTS"]
@good &&= run_or_fail("bin/ember-cli --build")
@good &&= run_or_fail("bundle exec rspec spec/system")
end
end end
unless ENV["SKIP_PLUGINS"] unless ENV["SKIP_PLUGINS"]

View File

@ -10,7 +10,7 @@ module Discourse
MAJOR = 3 MAJOR = 3
MINOR = 1 MINOR = 1
TINY = 0 TINY = 0
PRE = "beta3" PRE = "beta2"
STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
end end

View File

@ -0,0 +1,60 @@
# frozen_string_literal: true
class Chat::AdminIncomingChatWebhooksController < Admin::AdminController
requires_plugin Chat::PLUGIN_NAME
def index
render_serialized(
{
chat_channels: ChatChannel.public_channels,
incoming_chat_webhooks: IncomingChatWebhook.includes(:chat_channel).all,
},
AdminChatIndexSerializer,
root: false,
)
end
def create
params.require(%i[name chat_channel_id])
chat_channel = ChatChannel.find_by(id: params[:chat_channel_id])
raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel?
webhook = IncomingChatWebhook.new(name: params[:name], chat_channel: chat_channel)
if webhook.save
render_serialized(webhook, IncomingChatWebhookSerializer, root: false)
else
render_json_error(webhook)
end
end
def update
params.require(%i[incoming_chat_webhook_id name chat_channel_id])
webhook = IncomingChatWebhook.find_by(id: params[:incoming_chat_webhook_id])
raise Discourse::NotFound unless webhook
chat_channel = ChatChannel.find_by(id: params[:chat_channel_id])
raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel?
if webhook.update(
name: params[:name],
description: params[:description],
emoji: params[:emoji],
username: params[:username],
chat_channel: chat_channel,
)
render json: success_json
else
render_json_error(webhook)
end
end
def destroy
params.require(:incoming_chat_webhook_id)
webhook = IncomingChatWebhook.find_by(id: params[:incoming_chat_webhook_id])
webhook.destroy if webhook
render json: success_json
end
end

View File

@ -1,9 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
class Chat::Api::ChannelThreadsController < Chat::ApiController class Chat::Api::ChatChannelThreadsController < Chat::Api
def show def show
with_service(::Chat::LookupThread) do with_service(Chat::Service::LookupThread) do
on_success { render_serialized(result.thread, ::Chat::ThreadSerializer, root: "thread") } on_success { render_serialized(result.thread, ChatThreadSerializer, root: "thread") }
on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound } on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound }
on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound } on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound }
on_model_not_found(:thread) { raise Discourse::NotFound } on_model_not_found(:thread) { raise Discourse::NotFound }

View File

@ -1,13 +1,13 @@
# frozen_string_literal: true # frozen_string_literal: true
class Chat::Api::ChannelsArchivesController < Chat::Api::ChannelsController class Chat::Api::ChatChannelsArchivesController < Chat::Api::ChatChannelsController
def create def create
existing_archive = channel_from_params.chat_channel_archive existing_archive = channel_from_params.chat_channel_archive
if existing_archive.present? if existing_archive.present?
guardian.ensure_can_change_channel_status!(channel_from_params, :archived) guardian.ensure_can_change_channel_status!(channel_from_params, :archived)
raise Discourse::InvalidAccess if !existing_archive.failed? raise Discourse::InvalidAccess if !existing_archive.failed?
Chat::ChannelArchiveService.retry_archive_process(chat_channel: channel_from_params) Chat::ChatChannelArchiveService.retry_archive_process(chat_channel: channel_from_params)
return render json: success_json return render json: success_json
end end
@ -20,12 +20,12 @@ class Chat::Api::ChannelsArchivesController < Chat::Api::ChannelsController
end end
begin begin
Chat::ChannelArchiveService.create_archive_process( Chat::ChatChannelArchiveService.create_archive_process(
chat_channel: channel_from_params, chat_channel: channel_from_params,
acting_user: current_user, acting_user: current_user,
topic_params: topic_params, topic_params: topic_params,
) )
rescue Chat::ChannelArchiveService::ArchiveValidationError => err rescue Chat::ChatChannelArchiveService::ArchiveValidationError => err
return render json: failed_json.merge(errors: err.errors), status: 400 return render json: failed_json.merge(errors: err.errors), status: 400
end end

View File

@ -3,19 +3,19 @@
CHANNEL_EDITABLE_PARAMS = %i[name description slug] CHANNEL_EDITABLE_PARAMS = %i[name description slug]
CATEGORY_CHANNEL_EDITABLE_PARAMS = %i[auto_join_users allow_channel_wide_mentions] CATEGORY_CHANNEL_EDITABLE_PARAMS = %i[auto_join_users allow_channel_wide_mentions]
class Chat::Api::ChannelsController < Chat::ApiController class Chat::Api::ChatChannelsController < Chat::Api
def index def index
permitted = params.permit(:filter, :limit, :offset, :status) permitted = params.permit(:filter, :limit, :offset, :status)
options = { filter: permitted[:filter], limit: (permitted[:limit] || 25).to_i } options = { filter: permitted[:filter], limit: (permitted[:limit] || 25).to_i }
options[:offset] = permitted[:offset].to_i options[:offset] = permitted[:offset].to_i
options[:status] = Chat::Channel.statuses[permitted[:status]] ? permitted[:status] : nil options[:status] = ChatChannel.statuses[permitted[:status]] ? permitted[:status] : nil
memberships = Chat::ChannelMembershipManager.all_for_user(current_user) memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user)
channels = Chat::ChannelFetcher.secured_public_channels(guardian, memberships, options) channels = Chat::ChatChannelFetcher.secured_public_channels(guardian, memberships, options)
serialized_channels = serialized_channels =
channels.map do |channel| channels.map do |channel|
Chat::ChannelSerializer.new( ChatChannelSerializer.new(
channel, channel,
scope: Guardian.new(current_user), scope: Guardian.new(current_user),
membership: memberships.find { |membership| membership.chat_channel_id == channel.id }, membership: memberships.find { |membership| membership.chat_channel_id == channel.id },
@ -29,7 +29,7 @@ class Chat::Api::ChannelsController < Chat::ApiController
end end
def destroy def destroy
with_service Chat::TrashChannel do with_service Chat::Service::TrashChannel do
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound } on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
end end
end end
@ -43,7 +43,7 @@ class Chat::Api::ChannelsController < Chat::ApiController
raise Discourse::InvalidParameters.new(:name) raise Discourse::InvalidParameters.new(:name)
end end
if Chat::Channel.exists?( if ChatChannel.exists?(
chatable_type: "Category", chatable_type: "Category",
chatable_id: channel_params[:chatable_id], chatable_id: channel_params[:chatable_id],
name: channel_params[:name], name: channel_params[:name],
@ -69,12 +69,12 @@ class Chat::Api::ChannelsController < Chat::ApiController
channel.user_chat_channel_memberships.create!(user: current_user, following: true) channel.user_chat_channel_memberships.create!(user: current_user, following: true)
if channel.auto_join_users if channel.auto_join_users
Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
end end
render_serialized( render_serialized(
channel, channel,
Chat::ChannelSerializer, ChatChannelSerializer,
membership: channel.membership_for(current_user), membership: channel.membership_for(current_user),
root: "channel", root: "channel",
) )
@ -83,7 +83,7 @@ class Chat::Api::ChannelsController < Chat::ApiController
def show def show
render_serialized( render_serialized(
channel_from_params, channel_from_params,
Chat::ChannelSerializer, ChatChannelSerializer,
membership: channel_from_params.membership_for(current_user), membership: channel_from_params.membership_for(current_user),
root: "channel", root: "channel",
) )
@ -96,11 +96,11 @@ class Chat::Api::ChannelsController < Chat::ApiController
auto_join_limiter(channel_from_params).performed! auto_join_limiter(channel_from_params).performed!
end end
with_service(Chat::UpdateChannel, **params_to_edit) do with_service(Chat::Service::UpdateChannel, **params_to_edit) do
on_success do on_success do
render_serialized( render_serialized(
result.channel, result.channel,
Chat::ChannelSerializer, ChatChannelSerializer,
root: "channel", root: "channel",
membership: result.channel.membership_for(current_user), membership: result.channel.membership_for(current_user),
) )
@ -116,7 +116,7 @@ class Chat::Api::ChannelsController < Chat::ApiController
def channel_from_params def channel_from_params
@channel ||= @channel ||=
begin begin
channel = Chat::Channel.find(params.require(:channel_id)) channel = ChatChannel.find(params.require(:channel_id))
guardian.ensure_can_preview_chat_channel!(channel) guardian.ensure_can_preview_chat_channel!(channel)
channel channel
end end
@ -126,7 +126,7 @@ class Chat::Api::ChannelsController < Chat::ApiController
@membership ||= @membership ||=
begin begin
membership = membership =
Chat::ChannelMembershipManager.new(channel_from_params).find_for_user(current_user) Chat::ChatChannelMembershipManager.new(channel_from_params).find_for_user(current_user)
raise Discourse::NotFound if membership.blank? raise Discourse::NotFound if membership.blank?
membership membership
end end

View File

@ -1,12 +1,12 @@
# frozen_string_literal: true # frozen_string_literal: true
class Chat::Api::ChannelsCurrentUserMembershipController < Chat::Api::ChannelsController class Chat::Api::ChatChannelsCurrentUserMembershipController < Chat::Api::ChatChannelsController
def create def create
guardian.ensure_can_join_chat_channel!(channel_from_params) guardian.ensure_can_join_chat_channel!(channel_from_params)
render_serialized( render_serialized(
channel_from_params.add(current_user), channel_from_params.add(current_user),
Chat::UserChannelMembershipSerializer, UserChatChannelMembershipSerializer,
root: "membership", root: "membership",
) )
end end
@ -14,7 +14,7 @@ class Chat::Api::ChannelsCurrentUserMembershipController < Chat::Api::ChannelsCo
def destroy def destroy
render_serialized( render_serialized(
channel_from_params.remove(current_user), channel_from_params.remove(current_user),
Chat::UserChannelMembershipSerializer, UserChatChannelMembershipSerializer,
root: "membership", root: "membership",
) )
end end

View File

@ -2,13 +2,13 @@
MEMBERSHIP_EDITABLE_PARAMS = %i[muted desktop_notification_level mobile_notification_level] MEMBERSHIP_EDITABLE_PARAMS = %i[muted desktop_notification_level mobile_notification_level]
class Chat::Api::ChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChannelsController class Chat::Api::ChatChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChatChannelsController
def update def update
settings_params = params.require(:notifications_settings).permit(MEMBERSHIP_EDITABLE_PARAMS) settings_params = params.require(:notifications_settings).permit(MEMBERSHIP_EDITABLE_PARAMS)
membership_from_params.update!(settings_params.to_h) membership_from_params.update!(settings_params.to_h)
render_serialized( render_serialized(
membership_from_params, membership_from_params,
Chat::UserChannelMembershipSerializer, UserChatChannelMembershipSerializer,
root: "membership", root: "membership",
) )
end end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Chat::Api::ChannelsMembershipsController < Chat::Api::ChannelsController class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsController
def index def index
params.permit(:username, :offset, :limit) params.permit(:username, :offset, :limit)
@ -8,7 +8,7 @@ class Chat::Api::ChannelsMembershipsController < Chat::Api::ChannelsController
limit = (params[:limit] || 50).to_i.clamp(1, 50) limit = (params[:limit] || 50).to_i.clamp(1, 50)
memberships = memberships =
Chat::ChannelMembershipsQuery.call( ChatChannelMembershipsQuery.call(
channel: channel_from_params, channel: channel_from_params,
offset: offset, offset: offset,
limit: limit, limit: limit,
@ -17,7 +17,7 @@ class Chat::Api::ChannelsMembershipsController < Chat::Api::ChannelsController
render_serialized( render_serialized(
memberships, memberships,
Chat::UserChannelMembershipSerializer, UserChatChannelMembershipSerializer,
root: "memberships", root: "memberships",
meta: { meta: {
total_rows: channel_from_params.user_count, total_rows: channel_from_params.user_count,

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Chat::Api::ChannelsMessagesMovesController < Chat::Api::ChannelsController class Chat::Api::ChatChannelsMessagesMovesController < Chat::Api::ChatChannelsController
def create def create
move_params = params.require(:move) move_params = params.require(:move)
move_params.require(:message_ids) move_params.require(:message_ids)
@ -8,7 +8,10 @@ class Chat::Api::ChannelsMessagesMovesController < Chat::Api::ChannelsController
raise Discourse::InvalidAccess if !guardian.can_move_chat_messages?(channel_from_params) raise Discourse::InvalidAccess if !guardian.can_move_chat_messages?(channel_from_params)
destination_channel = destination_channel =
Chat::ChannelFetcher.find_with_access_check(move_params[:destination_channel_id], guardian) Chat::ChatChannelFetcher.find_with_access_check(
move_params[:destination_channel_id],
guardian,
)
begin begin
message_ids = move_params[:message_ids].map(&:to_i) message_ids = move_params[:message_ids].map(&:to_i)

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class Chat::Api::ChatChannelsStatusController < Chat::Api::ChatChannelsController
def update
with_service(Chat::Service::UpdateChannelStatus) do
on_success { render_serialized(result.channel, ChatChannelSerializer, root: "channel") }
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess }
end
end
end

View File

@ -1,14 +1,13 @@
# frozen_string_literal: true # frozen_string_literal: true
class Chat::Api::ChatablesController < Chat::ApiController class Chat::Api::ChatChatablesController < Chat::Api
def index def index
params.require(:filter) params.require(:filter)
filter = params[:filter].downcase filter = params[:filter].downcase
memberships = Chat::ChannelMembershipManager.all_for_user(current_user) memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user)
public_channels = public_channels =
Chat::ChannelFetcher.secured_public_channels( Chat::ChatChannelFetcher.secured_public_channels(
guardian, guardian,
memberships, memberships,
filter: filter, filter: filter,
@ -42,7 +41,7 @@ class Chat::Api::ChatablesController < Chat::ApiController
direct_message_channels = direct_message_channels =
if users.count > 0 if users.count > 0
# FIXME: investigate the cost of this query # FIXME: investigate the cost of this query
Chat::Channel ChatChannel
.includes(chatable: :users) .includes(chatable: :users)
.joins(direct_message: :direct_message_users) .joins(direct_message: :direct_message_users)
.group(1) .group(1)
@ -76,7 +75,7 @@ class Chat::Api::ChatablesController < Chat::ApiController
users: users_without_channel, users: users_without_channel,
memberships: memberships, memberships: memberships,
}, },
Chat::ChannelSearchSerializer, ChatChannelSearchSerializer,
root: false, root: false,
) )
end end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class Chat::Api::ChatCurrentUserChannelsController < Chat::Api
def index
structured = Chat::ChatChannelFetcher.structured(guardian)
render_serialized(structured, ChatChannelIndexSerializer, root: false)
end
end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
class Chat::Api < Chat::ChatBaseController
before_action :ensure_logged_in
before_action :ensure_can_chat
include Chat::WithServiceHelper
private
def ensure_can_chat
raise Discourse::NotFound unless SiteSetting.chat_enabled
guardian.ensure_can_chat!
end
def default_actions_for_service
proc do
on_success { render(json: success_json) }
on_failure { render(json: failed_json, status: 422) }
on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess }
on_failed_contract do
render(
json: failed_json.merge(errors: result[:"result.contract.default"].errors.full_messages),
status: 400,
)
end
end
end
end

View File

@ -1,64 +0,0 @@
# frozen_string_literal: true
module Chat
module Admin
class IncomingWebhooksController < ::Admin::AdminController
requires_plugin Chat::PLUGIN_NAME
def index
render_serialized(
{
chat_channels: Chat::Channel.public_channels,
incoming_chat_webhooks: Chat::IncomingWebhook.includes(:chat_channel).all,
},
Chat::AdminChatIndexSerializer,
root: false,
)
end
def create
params.require(%i[name chat_channel_id])
chat_channel = Chat::Channel.find_by(id: params[:chat_channel_id])
raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel?
webhook = Chat::IncomingWebhook.new(name: params[:name], chat_channel: chat_channel)
if webhook.save
render_serialized(webhook, Chat::IncomingWebhookSerializer, root: false)
else
render_json_error(webhook)
end
end
def update
params.require(%i[incoming_chat_webhook_id name chat_channel_id])
webhook = Chat::IncomingWebhook.find_by(id: params[:incoming_chat_webhook_id])
raise Discourse::NotFound unless webhook
chat_channel = Chat::Channel.find_by(id: params[:chat_channel_id])
raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel?
if webhook.update(
name: params[:name],
description: params[:description],
emoji: params[:emoji],
username: params[:username],
chat_channel: chat_channel,
)
render json: success_json
else
render_json_error(webhook)
end
end
def destroy
params.require(:incoming_chat_webhook_id)
webhook = Chat::IncomingWebhook.find_by(id: params[:incoming_chat_webhook_id])
webhook.destroy if webhook
render json: success_json
end
end
end
end

View File

@ -1,11 +0,0 @@
# frozen_string_literal: true
class Chat::Api::ChannelsStatusController < Chat::Api::ChannelsController
def update
with_service(Chat::UpdateChannelStatus) do
on_success { render_serialized(result.channel, Chat::ChannelSerializer, root: "channel") }
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess }
end
end
end

View File

@ -1,8 +0,0 @@
# frozen_string_literal: true
class Chat::Api::CurrentUserChannelsController < Chat::ApiController
def index
structured = Chat::ChannelFetcher.structured(guardian)
render_serialized(structured, Chat::ChannelIndexSerializer, root: false)
end
end

View File

@ -1,32 +0,0 @@
# frozen_string_literal: true
module Chat
class ApiController < ::Chat::BaseController
before_action :ensure_logged_in
before_action :ensure_can_chat
include Chat::WithServiceHelper
private
def ensure_can_chat
raise Discourse::NotFound unless SiteSetting.chat_enabled
guardian.ensure_can_chat!
end
def default_actions_for_service
proc do
on_success { render(json: success_json) }
on_failure { render(json: failed_json, status: 422) }
on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess }
on_failed_contract do
render(
json:
failed_json.merge(errors: result[:"result.contract.default"].errors.full_messages),
status: 400,
)
end
end
end
end
end

View File

@ -1,22 +0,0 @@
# frozen_string_literal: true
module Chat
class BaseController < ::ApplicationController
before_action :ensure_logged_in
before_action :ensure_can_chat
private
def ensure_can_chat
raise Discourse::NotFound unless SiteSetting.chat_enabled
guardian.ensure_can_chat!
end
def set_channel_and_chatable_with_access_check(chat_channel_id: nil)
params.require(:chat_channel_id) if chat_channel_id.blank?
id_or_name = chat_channel_id || params[:chat_channel_id]
@chat_channel = Chat::ChannelFetcher.find_with_access_check(id_or_name, guardian)
@chatable = @chat_channel.chatable
end
end
end

View File

@ -1,481 +0,0 @@
# frozen_string_literal: true
module Chat
class ChatController < ::Chat::BaseController
PAST_MESSAGE_LIMIT = 40
FUTURE_MESSAGE_LIMIT = 40
PAST = "past"
FUTURE = "future"
CHAT_DIRECTIONS = [PAST, FUTURE]
# Other endpoints use set_channel_and_chatable_with_access_check, but
# these endpoints require a standalone find because they need to be
# able to get deleted channels and recover them.
before_action :find_chatable, only: %i[enable_chat disable_chat]
before_action :find_chat_message,
only: %i[delete restore lookup_message edit_message rebake message_link]
before_action :set_channel_and_chatable_with_access_check,
except: %i[
respond
enable_chat
disable_chat
message_link
lookup_message
set_user_chat_status
dismiss_retention_reminder
flag
]
def respond
render
end
def enable_chat
chat_channel = Chat::Channel.with_deleted.find_by(chatable_id: @chatable)
guardian.ensure_can_join_chat_channel!(chat_channel) if chat_channel
if chat_channel && chat_channel.trashed?
chat_channel.recover!
elsif chat_channel
return render_json_error I18n.t("chat.already_enabled")
else
chat_channel = @chatable.chat_channel
guardian.ensure_can_join_chat_channel!(chat_channel)
end
success = chat_channel.save
if success && chat_channel.chatable_has_custom_fields?
@chatable.custom_fields[Chat::HAS_CHAT_ENABLED] = true
@chatable.save!
end
if success
membership = Chat::ChannelMembershipManager.new(channel).follow(user)
render_serialized(chat_channel, Chat::ChannelSerializer, membership: membership)
else
render_json_error(chat_channel)
end
Chat::ChannelMembershipManager.new(channel).follow(user)
end
def disable_chat
chat_channel = Chat::Channel.with_deleted.find_by(chatable_id: @chatable)
guardian.ensure_can_join_chat_channel!(chat_channel)
return render json: success_json if chat_channel.trashed?
chat_channel.trash!(current_user)
success = chat_channel.save
if success
if chat_channel.chatable_has_custom_fields?
@chatable.custom_fields.delete(Chat::HAS_CHAT_ENABLED)
@chatable.save!
end
render json: success_json
else
render_json_error(chat_channel)
end
end
def create_message
raise Discourse::InvalidAccess if current_user.silenced?
Chat::MessageRateLimiter.run!(current_user)
@user_chat_channel_membership =
Chat::ChannelMembershipManager.new(@chat_channel).find_for_user(
current_user,
following: true,
)
raise Discourse::InvalidAccess unless @user_chat_channel_membership
reply_to_msg_id = params[:in_reply_to_id]
if reply_to_msg_id
rm = Chat::Message.find(reply_to_msg_id)
raise Discourse::NotFound if rm.chat_channel_id != @chat_channel.id
end
content = params[:message]
chat_message_creator =
Chat::MessageCreator.create(
chat_channel: @chat_channel,
user: current_user,
in_reply_to_id: reply_to_msg_id,
content: content,
staged_id: params[:staged_id],
upload_ids: params[:upload_ids],
)
return render_json_error(chat_message_creator.error) if chat_message_creator.failed?
@user_chat_channel_membership.update!(
last_read_message_id: chat_message_creator.chat_message.id,
)
if @chat_channel.direct_message_channel?
# If any of the channel users is ignoring, muting, or preventing DMs from
# the current user then we shold not auto-follow the channel once again or
# publish the new channel.
user_ids_allowing_communication =
UserCommScreener.new(
acting_user: current_user,
target_user_ids: @chat_channel.user_chat_channel_memberships.pluck(:user_id),
).allowing_actor_communication
if user_ids_allowing_communication.any?
Chat::Publisher.publish_new_channel(
@chat_channel,
@chat_channel.chatable.users.where(id: user_ids_allowing_communication),
)
@chat_channel
.user_chat_channel_memberships
.where(user_id: user_ids_allowing_communication)
.update_all(following: true)
end
end
Chat::Publisher.publish_user_tracking_state(
current_user,
@chat_channel.id,
chat_message_creator.chat_message.id,
)
render json: success_json
end
def edit_message
chat_message_updater =
Chat::MessageUpdater.update(
guardian: guardian,
chat_message: @message,
new_content: params[:new_message],
upload_ids: params[:upload_ids] || [],
)
return render_json_error(chat_message_updater.error) if chat_message_updater.failed?
render json: success_json
end
def update_user_last_read
membership =
Chat::ChannelMembershipManager.new(@chat_channel).find_for_user(
current_user,
following: true,
)
raise Discourse::NotFound if membership.nil?
if membership.last_read_message_id &&
params[:message_id].to_i < membership.last_read_message_id
raise Discourse::InvalidParameters.new(:message_id)
end
unless Chat::Message.with_deleted.exists?(
chat_channel_id: @chat_channel.id,
id: params[:message_id],
)
raise Discourse::NotFound
end
membership.update!(last_read_message_id: params[:message_id])
Notification
.where(notification_type: Notification.types[:chat_mention])
.where(user: current_user)
.where(read: false)
.joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id")
.joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id")
.where("chat_messages.id <= ?", params[:message_id].to_i)
.where("chat_messages.chat_channel_id = ?", @chat_channel.id)
.update_all(read: true)
Chat::Publisher.publish_user_tracking_state(
current_user,
@chat_channel.id,
params[:message_id],
)
render json: success_json
end
def messages
page_size = params[:page_size]&.to_i || 1000
direction = params[:direction].to_s
message_id = params[:message_id]
if page_size > 50 ||
(
message_id.blank? ^ direction.blank? &&
(direction.present? && !CHAT_DIRECTIONS.include?(direction))
)
raise Discourse::InvalidParameters
end
messages = preloaded_chat_message_query.where(chat_channel: @chat_channel)
messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable)
if message_id.present?
condition = direction == PAST ? "<" : ">"
messages = messages.where("id #{condition} ?", message_id.to_i)
end
# NOTE: This order is reversed when we return the Chat::View below if the direction
# is not FUTURE.
order = direction == FUTURE ? "ASC" : "DESC"
messages = messages.order("created_at #{order}, id #{order}").limit(page_size).to_a
can_load_more_past = nil
can_load_more_future = nil
if direction == FUTURE
can_load_more_future = messages.size == page_size
elsif direction == PAST
can_load_more_past = messages.size == page_size
else
# When direction is blank, we'll return the latest messages.
can_load_more_future = false
can_load_more_past = messages.size == page_size
end
chat_view =
Chat::View.new(
chat_channel: @chat_channel,
chat_messages: direction == FUTURE ? messages : messages.reverse,
user: current_user,
can_load_more_past: can_load_more_past,
can_load_more_future: can_load_more_future,
)
render_serialized(chat_view, Chat::ViewSerializer, root: false)
end
def react
params.require(%i[message_id emoji react_action])
guardian.ensure_can_react!
Chat::MessageReactor.new(current_user, @chat_channel).react!(
message_id: params[:message_id],
react_action: params[:react_action].to_sym,
emoji: params[:emoji],
)
render json: success_json
end
def delete
guardian.ensure_can_delete_chat!(@message, @chatable)
Chat::MessageDestroyer.new.trash_message(@message, current_user)
head :ok
end
def restore
chat_channel = @message.chat_channel
guardian.ensure_can_restore_chat!(@message, chat_channel.chatable)
updated = @message.recover!
if updated
Chat::Publisher.publish_restore!(chat_channel, @message)
render json: success_json
else
render_json_error(@message)
end
end
def rebake
guardian.ensure_can_rebake_chat_message!(@message)
@message.rebake!(invalidate_oneboxes: true)
render json: success_json
end
def message_link
raise Discourse::NotFound if @message.blank? || @message.deleted_at.present?
raise Discourse::NotFound if @message.chat_channel.blank?
set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id)
render json:
success_json.merge(
chat_channel_id: @chat_channel.id,
chat_channel_title: @chat_channel.title(current_user),
)
end
def lookup_message
set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id)
messages = preloaded_chat_message_query.where(chat_channel: @chat_channel)
messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable)
past_messages =
messages
.where("created_at < ?", @message.created_at)
.order(created_at: :desc)
.limit(PAST_MESSAGE_LIMIT)
future_messages =
messages
.where("created_at > ?", @message.created_at)
.order(created_at: :asc)
.limit(FUTURE_MESSAGE_LIMIT)
can_load_more_past = past_messages.count == PAST_MESSAGE_LIMIT
can_load_more_future = future_messages.count == FUTURE_MESSAGE_LIMIT
messages = [past_messages.reverse, [@message], future_messages].reduce([], :concat)
chat_view =
Chat::View.new(
chat_channel: @chat_channel,
chat_messages: messages,
user: current_user,
can_load_more_past: can_load_more_past,
can_load_more_future: can_load_more_future,
)
render_serialized(chat_view, Chat::ViewSerializer, root: false)
end
def set_user_chat_status
params.require(:chat_enabled)
current_user.user_option.update(chat_enabled: params[:chat_enabled])
render json: { chat_enabled: current_user.user_option.chat_enabled }
end
def invite_users
params.require(:user_ids)
users =
User
.includes(:groups)
.joins(:user_option)
.where(user_options: { chat_enabled: true })
.not_suspended
.where(id: params[:user_ids])
users.each do |user|
guardian = Guardian.new(user)
if guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel)
data = {
message: "chat.invitation_notification",
chat_channel_id: @chat_channel.id,
chat_channel_title: @chat_channel.title(user),
chat_channel_slug: @chat_channel.slug,
invited_by_username: current_user.username,
}
data[:chat_message_id] = params[:chat_message_id] if params[:chat_message_id]
user.notifications.create(
notification_type: Notification.types[:chat_invitation],
high_priority: true,
data: data.to_json,
)
end
end
render json: success_json
end
def dismiss_retention_reminder
params.require(:chatable_type)
guardian.ensure_can_chat!
unless Chat::Channel.chatable_types.include?(params[:chatable_type])
raise Discourse::InvalidParameters
end
field =
(
if Chat::Channel.public_channel_chatable_types.include?(params[:chatable_type])
:dismissed_channel_retention_reminder
else
:dismissed_dm_retention_reminder
end
)
current_user.user_option.update(field => true)
render json: success_json
end
def quote_messages
params.require(:message_ids)
message_ids = params[:message_ids].map(&:to_i)
markdown =
Chat::TranscriptService.new(
@chat_channel,
current_user,
messages_or_ids: message_ids,
).generate_markdown
render json: success_json.merge(markdown: markdown)
end
def flag
RateLimiter.new(current_user, "flag_chat_message", 4, 1.minutes).performed!
permitted_params =
params.permit(
%i[chat_message_id flag_type_id message is_warning take_action queue_for_review],
)
chat_message =
Chat::Message.includes(:chat_channel, :revisions).find(permitted_params[:chat_message_id])
flag_type_id = permitted_params[:flag_type_id].to_i
if !ReviewableScore.types.values.include?(flag_type_id)
raise Discourse::InvalidParameters.new(:flag_type_id)
end
set_channel_and_chatable_with_access_check(chat_channel_id: chat_message.chat_channel_id)
result =
Chat::ReviewQueue.new.flag_message(chat_message, guardian, flag_type_id, permitted_params)
if result[:success]
render json: success_json
else
render_json_error(result[:errors])
end
end
def set_draft
if params[:data].present?
Chat::Draft.find_or_initialize_by(
user: current_user,
chat_channel_id: @chat_channel.id,
).update!(data: params[:data])
else
Chat::Draft.where(user: current_user, chat_channel_id: @chat_channel.id).destroy_all
end
render json: success_json
end
private
def preloaded_chat_message_query
query =
Chat::Message
.includes(in_reply_to: [:user, chat_webhook_event: [:incoming_chat_webhook]])
.includes(:revisions)
.includes(user: :primary_group)
.includes(chat_webhook_event: :incoming_chat_webhook)
.includes(reactions: :user)
.includes(:bookmarks)
.includes(:uploads)
.includes(chat_channel: :chatable)
query = query.includes(user: :user_status) if SiteSetting.enable_user_status
query
end
def find_chatable
@chatable = Category.find_by(id: params[:chatable_id])
guardian.ensure_can_moderate_chat!(@chatable)
end
def find_chat_message
@message = preloaded_chat_message_query.with_deleted
@message = @message.where(chat_channel_id: params[:chat_channel_id]) if params[
:chat_channel_id
]
@message = @message.find_by(id: params[:message_id])
raise Discourse::NotFound unless @message
end
end
end

View File

@ -1,57 +0,0 @@
# frozen_string_literal: true
module Chat
class DirectMessagesController < ::Chat::BaseController
# NOTE: For V1 of chat channel archiving and deleting we are not doing
# anything for DM channels, their behaviour will stay as is.
def create
guardian.ensure_can_chat!
users = users_from_usernames(current_user, params)
begin
chat_channel =
Chat::DirectMessageChannelCreator.create!(acting_user: current_user, target_users: users)
render_serialized(
chat_channel,
Chat::ChannelSerializer,
root: "channel",
membership: chat_channel.membership_for(current_user),
)
rescue Chat::DirectMessageChannelCreator::NotAllowed => err
render_json_error(err.message)
end
end
def index
guardian.ensure_can_chat!
users = users_from_usernames(current_user, params)
direct_message = Chat::DirectMessage.for_user_ids(users.map(&:id).uniq)
if direct_message
chat_channel = Chat::Channel.find_by(chatable_id: direct_message)
render_serialized(
chat_channel,
Chat::ChannelSerializer,
root: "channel",
membership: chat_channel.membership_for(current_user),
)
else
render body: nil, status: 404
end
end
private
def users_from_usernames(current_user, params)
params.require(:usernames)
usernames =
(params[:usernames].is_a?(String) ? params[:usernames].split(",") : params[:usernames])
users = [current_user]
other_usernames = usernames - [current_user.username]
users.concat(User.where(username: other_usernames).to_a) if other_usernames.any?
users
end
end
end

View File

@ -1,10 +0,0 @@
# frozen_string_literal: true
module Chat
class EmojisController < ::Chat::BaseController
def index
emojis = Emoji.all.group_by(&:group)
render json: MultiJson.dump(emojis)
end
end
end

Some files were not shown because too many files have changed in this diff Show More