Compare commits

...
This repository has been archived on 2023-03-18. You can view files and clone it, but cannot push or open issues or pull requests.

30 Commits

Author SHA1 Message Date
Jarek Radosz
38fdd842f5
UX: Fix chat separator alignment (#20669)
Also: work around 1px svg shift in scroll-to-bottom button
2023-03-18 18:03:54 +01:00
Joffrey JAFFEUX
aeab38aff1
UX: disable arrow up to edit if last message is not editable (#20729) 2023-03-17 23:08:10 +01:00
Joffrey JAFFEUX
aa8eff5e16
FIX: ensures updateLastRead is called when receiving a message (#20728)
This behavior is hard to test as it's mostly fixing a race condition: User A sends a message at the same time than User B, which as a result doesn't cause a scroll for the second message and we don't update last read unless we do a small up and down scroll.

`updateLastRead` is debounced so it has no direct consequences to call it slightly more often than what should ideally be needed.
2023-03-17 22:46:59 +01:00
Daniel Waterworth
293cb7bde2
FIX: An ember build is required to run the system tests (#20725) 2023-03-17 13:20:49 -05:00
Joffrey JAFFEUX
cfee0cfee9
FIX: ensures lightbox is working after collapse/expand (#20724)
Prior to this fix, the upload was removed from DOM when collapsed and not decorated again on expand, which was causing lightbox to not get reapplied. The fix is reverting to previous state where content was not removed from DOM.
2023-03-17 18:26:32 +01:00
Joffrey JAFFEUX
c5e5b6d5ab
DEV: fixes a flakey spec (#20721) 2023-03-17 18:01:19 +01:00
Joffrey JAFFEUX
184ce647ea
FIX: correctly infer polymorphic class from bookmarkable type (#20719)
Prior to this change `registered_bookmarkable` would return `nil` as  `type` in `Bookmark.registered_bookmarkable_from_type(type)` would be `ChatMessage` and we registered a `Chat::Message` class.

This commit will now properly rely on each model `polymorphic_class_for(name)` to help us infer the proper type from a a `bookmarkable_type`.

Tests have also been added to ensure that creating/destroying chat message bookmarks is working correctly.

---

Longer explanation

Currently when you save a bookmark in the database, it's associated to another object through a polymorphic relationship, which will is represented by two columns: `bookmarkable_id` and `bookmarkable_type`. The `bookmarkable_id` contains the id of the relationship (a post ID for example) and the `bookmarkable_type` contains the type of the object as a string by default, (`"Post"` for example).

Chat plugin just started namespacing objects, as a result a model named `ChatMessage` is now named `Chat::Message`, to avoid complex and risky migrations we rely on methods provided by rails to alter the `bookmarkable_type` when we save it: we want to still save it as `"ChatMessage"` and not `"Chat::Message"`. And, to retrieve the correct model when we load the bookmark from the database: we want `"ChatMessage"` to load the `Chat::Message` model and not the `ChatMessage`model which doesn't exist anymore.

On top of this the bookmark codepath is allowing plugins to register types and will check against these types, so we alter this code path to be able to do a similar ChatMessage <-> Chat::Message dance and allow to check the type is valid. In the specific case of this commit, we were retrieving a `"ChatMessage"` bookmarkable_type from the DB and looking for it in the registered bookmarkable types which contain `Chat::Message` and not `ChatMessage`.
2023-03-17 17:20:24 +01:00
TheJammiestDodger
f57ba758ce
UX: Update Install Popular items and links (#20688)
* UX: Update 'Install Popular' items and links

* Update popular-themes.js

* Update popular-themes.js

* Update popular-themes.js

* Lint

---------

Co-authored-by: Penar Musaraj <pmusaraj@gmail.com>
2023-03-17 16:05:36 +00:00
Joffrey JAFFEUX
12a18d4d55
DEV: properly namespace chat (#20690)
This commit main goal was to comply with Zeitwerk and properly rely on autoloading. To achieve this, most resources have been namespaced under the `Chat` module.

- Given all models are now namespaced with `Chat::` and would change the stored types in DB when using polymorphism or STI (single table inheritance), this commit uses various Rails methods to ensure proper class is loaded and the stored name in DB is unchanged, eg: `Chat::Message` model will be stored as `"ChatMessage"`, and `"ChatMessage"` will correctly load `Chat::Message` model.
- Jobs are now using constants only, eg: `Jobs::Chat::Foo` and should only be enqueued this way

Notes:
- This commit also used this opportunity to limit the number of registered css files in plugin.rb
- `discourse_dev` support has been removed within this commit and will be reintroduced later

<!-- NOTE: All pull requests should have tests (rspec in Ruby, qunit in JavaScript). If your code does not include test coverage, please include an explanation of why it was omitted. -->
2023-03-17 14:24:38 +01:00
David Taylor
74349e17c9
DEV: Migrate remaining admin classes to native syntax (#20717)
This commit was generated using the ember-native-class-codemod along with a handful of manual updates
2023-03-17 12:25:05 +00:00
David Taylor
1161c980f2
DEV: Resolve and unsilence ember.built-in-components deprecation (#20716)
- Install `@ember/legacy-built-in-components` and update our import statements to use it
- Remove our custom attributeBinding extensions of `TextField` and `TextArea`. Modern ember 'angle bracket syntax' allows us to apply html attributes to a component's element without needing attributeBindings
2023-03-17 11:55:29 +00:00
David Taylor
5e5024d3e7
DEV: Resolve and unsilence ember-global deprecation (#20702)
One of the problems here was coming from the ember-jquery addon. This commit skips the problematic shim from the addon and re-implements in Discourse. This hack will only be required short-term - we'll be totally dropping the ember-jquery integration as part of our upgrade to Ember 4.x.

Removing this shim means we can also remove our `discourse-ensure-deprecation-order` dummy addon which was ensuring that the ember-jquery-triggered deprecation was covered by ember-cli-deprecation-workflow.
2023-03-17 11:22:12 +00:00
David Taylor
64557c4076
DEV: Update admin models to native class syntax (#20704)
This commit was generated using the ember-native-class-codemod along with a handful of manual updates
2023-03-17 10:18:42 +00:00
David Taylor
303f97ce89
PERF: Use native postgres upsert for ApplicationRequest (#20706)
Using `create_or_find_by!`, followed by `update_all!` requires two or three queries (two when the row doesn't already exist, three when it does). Instead, we can use postgres's native `INSERT ... ON CONFLICT ... DO UPDATE SET` feature to do the logic in a single atomic call.
2023-03-17 09:35:29 +00:00
Blake Erickson
6b5743ba3c
Version bump to v3.1.0.beta3 (#20712) 2023-03-16 17:51:54 -06:00
Penar Musaraj
32ad46c551
UX: Adjust menu panels on iOS (#20703) 2023-03-16 19:23:15 -04:00
Blake Erickson
5103d249aa DEV: Skip chat channel test
The screenshot looks good, but the test appears to be bad. Skipping this
test for now, will follow-up later.
2023-03-16 15:27:09 -06:00
Ted Johansson
39c2f63b35 SECURITY: Add FinalDestination::FastImage that's SSRF safe 2023-03-16 15:27:09 -06:00
Blake Erickson
6dcb099547 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>
2023-03-16 15:27:09 -06:00
Blake Erickson
a373bf2a01 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>
2023-03-16 15:27:09 -06:00
Alan Guo Xiang Tan
fd16eade7f 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
2023-03-16 15:27:09 -06:00
Alan Guo Xiang Tan
52ef44f43b 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.
2023-03-16 15:27:09 -06:00
Blake Erickson
d89b537d8f 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.
2023-03-16 15:27:09 -06:00
Andrei Prigorshnev
7dd317b875
DEV: add test cases for email notifications about channel-wide mentions (#20691)
A follow-up to e6c04e2d.
2023-03-16 21:43:56 +04:00
Penar Musaraj
c213cc7211
DEV: Fix tag route fixture param (#20693)
This tag route is /tag/important/l/latest.json so the tag name should also be important.
2023-03-16 11:27:04 -04:00
Loïc Guitaut
0bd64788d2 SECURITY: Rate limit the creation of backups 2023-03-16 16:09:22 +01:00
TheJammiestDodger
272c31023d
UX: Change JPEG to JPG for search consistency (#20698) 2023-03-16 14:53:29 +00:00
David Taylor
150a6601c0
DEV: Check Zeitwerk eager loading in GitHub CI (#20699)
In production, `eager_load=true`. This sometimes leads to boot errors which are not present in dev/test environments. Running `zeitwerk:check` in CI will help us to pick up on any errors early.

This commit also introduces a `DISCOURSE_ZEITWERK_EAGER_LOAD` environment variable to make it easier to toggle the behaviour when developing locally.
2023-03-16 14:22:16 +00:00
David Taylor
9d1423b5aa
DEV: Drop impossible conditional from admin-logs-staff-action-logs (#20687)
`Object.keys(filters)` will never return 0
2023-03-16 12:27:27 +00:00
David Taylor
5d46a16ca5
DEV: Cleanup unrelated comment from development.rb (#20697)
This comment has nothing to do with the `eager_load` configuration. It must be left over from some historical refactoring. Removing to avoid confusion.
2023-03-16 11:23:34 +00:00
444 changed files with 10546 additions and 9764 deletions

View File

@ -159,6 +159,22 @@ jobs:
path: tmp/turbo_rspec_runtime.log path: tmp/turbo_rspec_runtime.log
key: rspec-runtime-backend-core key: rspec-runtime-backend-core
- name: Run Zeitwerk check
if: matrix.build_type == 'backend'
env:
LOAD_PLUGINS: ${{ (matrix.target == 'plugins') && '1' || '0' }}
run: |
if ! bin/rails zeitwerk:check --trace; then
echo
echo "---------------------------------------------"
echo
echo "::error::'bin/rails zeitwerk:check' failed - the app will fail to boot with 'eager_load=true' (e.g. in production)."
echo "To reproduce locally, run 'bin/rails zeitwerk:check'."
echo "Alternatively, you can run your local server/tests with the 'DISCOURSE_ZEITWERK_EAGER_LOAD=1' environment variable."
echo
exit 1
fi
- name: Core RSpec - name: Core RSpec
if: matrix.build_type == 'backend' && matrix.target == 'core' if: matrix.build_type == 'backend' && matrix.target == 'core'
run: bin/turbo_rspec --verbose run: bin/turbo_rspec --verbose

View File

@ -1,13 +1,13 @@
import RESTAdapter from "discourse/adapters/rest"; import RestAdapter from "discourse/adapters/rest";
export default RESTAdapter.extend({ export default class ApiKey extends RestAdapter {
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 RestAdapter.extend({ return class extends RestAdapter {
pathFor(store, type, findArgs) { pathFor(store, type, findArgs) {
return ( return (
"/admin/plugins/" + pluginName + this._super(store, type, findArgs) "/admin/plugins/" + pluginName + super.pathFor(store, type, findArgs)
); );
}, }
}); };
} }

View File

@ -1,7 +1,7 @@
import RestAdapter from "discourse/adapters/rest"; import RestAdapter from "discourse/adapters/rest";
export default RestAdapter.extend({ export default class CustomizationBase extends RestAdapter {
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 RestAdapter.extend({ export default class EmailStyle extends RestAdapter {
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 RestAdapter.extend({ export default class Embedding extends RestAdapter {
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 RestAdapter.extend({ export default class StaffActionLog extends RestAdapter {
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 RestAdapter.extend({ export default class TagGroup extends RestAdapter {
jsonMode: true, jsonMode = true;
}); }

View File

@ -1,9 +1,10 @@
import RestAdapter from "discourse/adapters/rest"; import RestAdapter from "discourse/adapters/rest";
export default RestAdapter.extend({ export default class Theme extends RestAdapter {
jsonMode = true;
basePath() { basePath() {
return "/admin/"; return "/admin/";
}, }
afterFindAll(results) { afterFindAll(results) {
let map = {}; let map = {};
@ -20,7 +21,5 @@ export default RestAdapter.extend({
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 RESTAdapter.extend({ export default class WebHookEvent extends RestAdapter {
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 RESTAdapter.extend({ export default class WebHook extends RestAdapter {
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 Helper.extend({ export default class DispositionIcon extends Helper {
compute([disposition]) { compute([disposition]) {
if (!disposition) { if (!disposition) {
return null; return null;
@ -24,5 +24,5 @@ export default Helper.extend({
} }
} }
return htmlSafe(iconHTML(icon, { title })); return htmlSafe(iconHTML(icon, { title }));
}, }
}); }

View File

@ -7,19 +7,17 @@ const GENERAL_ATTRIBUTES = [
"release_notes_link", "release_notes_link",
]; ];
const AdminDashboard = EmberObject.extend({}); export default class AdminDashboard extends EmberObject {
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;
}); });
}, }
fetchGeneral() { static 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();
@ -34,15 +32,13 @@ AdminDashboard.reopenClass({
return model; return model;
}); });
}, }
fetchProblems() { static 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,14 +10,30 @@ 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";
const wrapAdmin = (user) => (user ? AdminUser.create(user) : null); export default class AdminUser extends User {
static find(user_id) {
return ajax(`/admin/users/${user_id}.json`).then((result) => {
result.loadedDetails = true;
return AdminUser.create(result);
});
}
const AdminUser = User.extend({ static findAll(query, userFilter) {
adminUserView: true, return ajax(`/admin/users/list/${query}.json`, {
customGroups: filter("groups", (g) => !g.automatic && Group.create(g)), data: userFilter,
automaticGroups: filter("groups", (g) => g.automatic && Group.create(g)), }).then((users) => users.map((u) => AdminUser.create(u)));
}
canViewProfile: or("active", "staged"), adminUserView = true;
@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) {
@ -28,7 +44,7 @@ const AdminUser = User.extend({
} else { } else {
return bounce_score; return bounce_score;
} }
}, }
@discourseComputed("bounce_score") @discourseComputed("bounce_score")
bounceScoreExplanation(bounce_score) { bounceScoreExplanation(bounce_score) {
@ -39,14 +55,12 @@ const AdminUser = User.extend({
} 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`, {
@ -57,14 +71,14 @@ const AdminUser = User.extend({
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}`, {
@ -75,13 +89,13 @@ const AdminUser = User.extend({
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`, {
@ -97,7 +111,7 @@ const AdminUser = User.extend({
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`, {
@ -114,7 +128,7 @@ const AdminUser = User.extend({
return resp; return resp;
}); });
}, }
revokeModeration() { revokeModeration() {
return ajax(`/admin/users/${this.id}/revoke_moderation`, { return ajax(`/admin/users/${this.id}/revoke_moderation`, {
@ -130,7 +144,7 @@ const AdminUser = User.extend({
}); });
}) })
.catch(popupAjaxError); .catch(popupAjaxError);
}, }
grantModeration() { grantModeration() {
return ajax(`/admin/users/${this.id}/grant_moderation`, { return ajax(`/admin/users/${this.id}/grant_moderation`, {
@ -146,7 +160,7 @@ const AdminUser = User.extend({
}); });
}) })
.catch(popupAjaxError); .catch(popupAjaxError);
}, }
disableSecondFactor() { disableSecondFactor() {
return ajax(`/admin/users/${this.id}/disable_second_factor`, { return ajax(`/admin/users/${this.id}/disable_second_factor`, {
@ -156,7 +170,7 @@ const AdminUser = User.extend({
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`, {
@ -168,83 +182,76 @@ const AdminUser = User.extend({
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);
@ -254,7 +261,7 @@ const AdminUser = User.extend({
}) })
.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);
@ -265,20 +272,20 @@ const AdminUser = User.extend({
}) })
.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`, {
@ -295,14 +302,14 @@ const AdminUser = User.extend({
.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) {
@ -313,23 +320,29 @@ const AdminUser = User.extend({
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: wrapAdmin, suspendedBy(user) {
return user ? AdminUser.create(user) : null;
}
@discourseComputed("silenced_by") @discourseComputed("silenced_by")
silencedBy: wrapAdmin, silencedBy(user) {
return user ? AdminUser.create(user) : null;
}
@discourseComputed("approved_by") @discourseComputed("approved_by")
approvedBy: wrapAdmin, approvedBy(user) {
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`, {
@ -339,22 +352,5 @@ const AdminUser = User.extend({
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,24 +1,26 @@
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";
const ApiKey = RestModel.extend({ export default class ApiKey extends RestModel {
user: computed("_user", { @fmt("truncated_key", "%@...") truncatedKey;
get() {
return this._user; @computed("_user")
}, get user() {
set(key, value) { return this._user;
if (value && !(value instanceof AdminUser)) { }
this.set("_user", AdminUser.create(value));
} else { set user(value) {
this.set("_user", value); if (value && !(value instanceof AdminUser)) {
} this.set("_user", AdminUser.create(value));
return this._user; } else {
}, this.set("_user", value);
}), }
return this._user;
}
@discourseComputed("description") @discourseComputed("description")
shortDescription(description) { shortDescription(description) {
@ -26,32 +28,28 @@ const ApiKey = RestModel.extend({
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 EmberObject.extend({ export default class BackupStatus extends EmberObject {
restoreDisabled: not("restoreEnabled"), @not("restoreEnabled") restoreDisabled;
@discourseComputed("allowRestore", "isOperationRunning") @discourseComputed("allowRestore", "isOperationRunning")
restoreEnabled(allowRestore, isOperationRunning) { restoreEnabled(allowRestore, isOperationRunning) {
return allowRestore && !isOperationRunning; return allowRestore && !isOperationRunning;
}, }
}); }

View File

@ -2,25 +2,12 @@ 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";
const Backup = EmberObject.extend({ export default class Backup extends EmberObject {
destroy() { static find() {
return ajax("/admin/backups/" + this.filename, { type: "DELETE" });
},
restore() {
return ajax("/admin/backups/" + this.filename + "/restore", {
type: "POST",
data: { client_id: MessageBus.clientId },
});
},
});
Backup.reopenClass({
find() {
return ajax("/admin/backups.json"); return ajax("/admin/backups.json");
}, }
start(withUploads) { static start(withUploads) {
if (withUploads === undefined) { if (withUploads === undefined) {
withUploads = true; withUploads = true;
} }
@ -31,19 +18,28 @@ Backup.reopenClass({
client_id: MessageBus.clientId, client_id: MessageBus.clientId,
}, },
}); });
}, }
cancel() { static cancel() {
return ajax("/admin/backups/cancel.json", { return ajax("/admin/backups/cancel.json", {
type: "DELETE", type: "DELETE",
}); });
}, }
rollback() { static rollback() {
return ajax("/admin/backups/rollback.json", { return ajax("/admin/backups/rollback.json", {
type: "POST", type: "POST",
}); });
}, }
});
export default Backup; destroy() {
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, { import discourseComputed from "discourse-common/utils/decorators";
observes, import { observes, on } from "@ember-decorators/object";
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";
const ColorSchemeColor = EmberObject.extend({ export default class ColorSchemeColor extends EmberObject {
// 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,26 +26,23 @@ const ColorSchemeColor = EmberObject.extend({
} }
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) {
@ -54,7 +51,7 @@ const ColorSchemeColor = EmberObject.extend({
} else { } else {
return name; return name;
} }
}, }
@discourseComputed("name") @discourseComputed("name")
description(name) { description(name) {
@ -63,7 +60,7 @@ const ColorSchemeColor = EmberObject.extend({
} else { } else {
return ""; return "";
} }
}, }
/** /**
brightness returns a number between 0 (darkest) to 255 (brightest). brightness returns a number between 0 (darkest) to 255 (brightest).
@ -90,19 +87,17 @@ const ColorSchemeColor = EmberObject.extend({
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,3 +1,4 @@
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";
@ -5,26 +6,56 @@ 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";
const ColorScheme = EmberObject.extend({ class ColorSchemes extends ArrayProxy {}
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() {
this._super(...arguments); super.init(...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 = [];
@ -33,7 +64,7 @@ const ColorScheme = EmberObject.extend({
}); });
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({
@ -47,7 +78,7 @@ const ColorScheme = EmberObject.extend({
); );
}); });
return newScheme; return newScheme;
}, }
@discourseComputed( @discourseComputed(
"name", "name",
@ -70,7 +101,7 @@ const ColorScheme = EmberObject.extend({
} }
return false; return false;
}, }
@discourseComputed("changed") @discourseComputed("changed")
disableSave(changed) { disableSave(changed) {
@ -79,9 +110,7 @@ const ColorScheme = EmberObject.extend({
} }
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) {
@ -124,7 +153,7 @@ const ColorScheme = EmberObject.extend({
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) {
@ -137,45 +166,11 @@ const ColorScheme = EmberObject.extend({
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,10 +3,8 @@ 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";
const EmailLog = EmberObject.extend({}); export default class EmailLog extends EmberObject {
static create(attrs) {
EmailLog.reopenClass({
create(attrs) {
attrs = attrs || {}; attrs = attrs || {};
if (attrs.user) { if (attrs.user) {
@ -17,10 +15,10 @@ EmailLog.reopenClass({
attrs.post_url = getURL(attrs.post_url); attrs.post_url = getURL(attrs.post_url);
} }
return this._super(attrs); return super.create(attrs);
}, }
findAll(filter, offset) { static findAll(filter, offset) {
filter = filter || {}; filter = filter || {};
offset = offset || 0; offset = offset || 0;
@ -30,7 +28,5 @@ EmailLog.reopenClass({
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,25 +1,21 @@
import EmberObject from "@ember/object"; import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
const EmailPreview = EmberObject.extend({}); export default class EmailPreview extends EmberObject {
static findDigest(username, lastSeenAt) {
export function oneWeekAgo() {
return moment().locale("en").subtract(7, "days").format("YYYY-MM-DD");
}
EmailPreview.reopenClass({
findDigest(username, lastSeenAt) {
return ajax("/admin/email/preview-digest.json", { return ajax("/admin/email/preview-digest.json", {
data: { last_seen_at: lastSeenAt || oneWeekAgo(), username }, data: { last_seen_at: lastSeenAt || oneWeekAgo(), username },
}).then((result) => EmailPreview.create(result)); }).then((result) => EmailPreview.create(result));
}, }
sendDigest(username, lastSeenAt, email) { static sendDigest(username, lastSeenAt, email) {
return ajax("/admin/email/send-digest.json", { return ajax("/admin/email/send-digest.json", {
type: "POST", type: "POST",
data: { last_seen_at: lastSeenAt || oneWeekAgo(), username, email }, data: { last_seen_at: lastSeenAt || oneWeekAgo(), username, email },
}); });
}, }
}); }
export default EmailPreview; export function oneWeekAgo() {
return moment().locale("en").subtract(7, "days").format("YYYY-MM-DD");
}

View File

@ -1,14 +1,10 @@
import EmberObject from "@ember/object"; import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
const EmailSettings = EmberObject.extend({}); export default class EmailSettings extends EmberObject {
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 RestModel.extend({ export default class EmailStyle extends RestModel {
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 RestModel.extend({ export default class EmailTemplate extends RestModel {
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 RestModel.extend({ export default class FlagType extends RestModel {
@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,53 +1,51 @@
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,
}); });
}, }
updateTemplate(id, data) { static 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,
}); });
}, }
createOrUpdateTemplate(data) { static 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);
} }
}, }
deleteTemplate(id) { static deleteTemplate(id) {
return ajax(`/admin/customize/form-templates/${id}.json`, { return ajax(`/admin/customize/form-templates/${id}.json`, {
type: "DELETE", type: "DELETE",
}); });
}, }
findAll() { static 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);
}); });
}, }
findById(id) { static 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;
}); });
}, }
validateTemplate(data) { static 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,10 +2,8 @@ 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";
const IncomingEmail = EmberObject.extend({}); export default class IncomingEmail extends EmberObject {
static create(attrs) {
IncomingEmail.reopenClass({
create(attrs) {
attrs = attrs || {}; attrs = attrs || {};
if (attrs.user) { if (attrs.user) {
@ -13,17 +11,17 @@ IncomingEmail.reopenClass({
} }
return this._super(attrs); return this._super(attrs);
}, }
find(id) { static find(id) {
return ajax(`/admin/email/incoming/${id}.json`); return ajax(`/admin/email/incoming/${id}.json`);
}, }
findByBounced(id) { static findByBounced(id) {
return ajax(`/admin/email/incoming_from_bounced/${id}.json`); return ajax(`/admin/email/incoming_from_bounced/${id}.json`);
}, }
findAll(filter, offset) { static findAll(filter, offset) {
filter = filter || {}; filter = filter || {};
offset = offset || 0; offset = offset || 0;
@ -35,11 +33,9 @@ IncomingEmail.reopenClass({
}).then((incomings) => }).then((incomings) =>
incomings.map((incoming) => IncomingEmail.create(incoming)) incomings.map((incoming) => IncomingEmail.create(incoming))
); );
}, }
loadRawEmail(id) { static loadRawEmail(id) {
return ajax(`/admin/email/incoming/${id}/raw.json`); return ajax(`/admin/email/incoming/${id}/raw.json`);
}, }
}); }
export default IncomingEmail;

View File

@ -4,7 +4,15 @@ 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";
const Permalink = EmberObject.extend({ export default class Permalink extends EmberObject {
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",
@ -14,33 +22,21 @@ const Permalink = EmberObject.extend({
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,12 +19,188 @@ 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;
const Report = EmberObject.extend({ export default class Report extends EmberObject {
average: false, static groupingForDatapoints(count) {
percent: false, if (count < DAILY_LIMIT_DAYS) {
higher_is_better: true, return "daily";
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) {
@ -35,7 +211,7 @@ const Report = EmberObject.extend({
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) {
@ -49,7 +225,7 @@ const Report = EmberObject.extend({
} }
} }
return 0; return 0;
}, }
valueFor(startDaysAgo, endDaysAgo) { valueFor(startDaysAgo, endDaysAgo) {
if (this.data) { if (this.data) {
@ -70,46 +246,46 @@ const Report = EmberObject.extend({
} }
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) {
@ -118,39 +294,39 @@ const Report = EmberObject.extend({
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",
@ -161,7 +337,7 @@ const Report = EmberObject.extend({
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",
@ -180,7 +356,7 @@ const Report = EmberObject.extend({
lastThirtyDaysCount, lastThirtyDaysCount,
higherIsBetter higherIsBetter
); );
}, }
@discourseComputed("type") @discourseComputed("type")
method(type) { method(type) {
@ -189,7 +365,7 @@ const Report = EmberObject.extend({
} else { } else {
return "sum"; return "sum";
} }
}, }
percentChangeString(val1, val2) { percentChangeString(val1, val2) {
const change = this._computeChange(val1, val2); const change = this._computeChange(val1, val2);
@ -201,7 +377,7 @@ const Report = EmberObject.extend({
} 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) {
@ -224,7 +400,7 @@ const Report = EmberObject.extend({
prev, prev,
current, current,
}); });
}, }
changeTitle(valAtT1, valAtT2, prevPeriodString) { changeTitle(valAtT1, valAtT2, prevPeriodString) {
const change = this.percentChangeString(valAtT1, valAtT2); const change = this.percentChangeString(valAtT1, valAtT2);
@ -234,12 +410,12 @@ const Report = EmberObject.extend({
} }
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) {
@ -248,12 +424,12 @@ const Report = EmberObject.extend({
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) {
@ -262,12 +438,12 @@ const Report = EmberObject.extend({
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() {
@ -275,7 +451,7 @@ const Report = EmberObject.extend({
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) {
@ -359,7 +535,7 @@ const Report = EmberObject.extend({
}, },
}; };
}); });
}, }
_userLabel(properties, row) { _userLabel(properties, row) {
const username = row[properties.username]; const username = row[properties.username];
@ -388,7 +564,7 @@ const Report = EmberObject.extend({
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];
@ -403,7 +579,7 @@ const Report = EmberObject.extend({
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];
@ -419,21 +595,21 @@ const Report = EmberObject.extend({
? `<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)
@ -446,21 +622,21 @@ const Report = EmberObject.extend({
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);
@ -469,7 +645,7 @@ const Report = EmberObject.extend({
value, value,
formattedValue: value ? escaped : "—", formattedValue: value ? escaped : "—",
}; };
}, }
_linkLabel(properties, row) { _linkLabel(properties, row) {
const property = properties[0]; const property = properties[0];
@ -484,11 +660,11 @@ const Report = EmberObject.extend({
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);
@ -504,7 +680,7 @@ const Report = EmberObject.extend({
} 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) {
@ -519,8 +695,8 @@ const Report = EmberObject.extend({
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;
@ -529,183 +705,3 @@ 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,21 +3,8 @@ 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";
const ScreenedEmail = EmberObject.extend({ export default class ScreenedEmail extends EmberObject {
@discourseComputed("action") static findAll() {
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
) { ) {
@ -25,7 +12,16 @@ ScreenedEmail.reopenClass({
return ScreenedEmail.create(b); return ScreenedEmail.create(b);
}); });
}); });
}, }
});
export default ScreenedEmail; @discourseComputed("action")
actionName(action) {
return I18n.t("admin.logs.screened_actions." + action);
}
clearBlock() {
return ajax("/admin/logs/screened_emails/" + this.id, {
type: "DELETE",
});
}
}

View File

@ -1,21 +1,28 @@
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";
const ScreenedIpAddress = EmberObject.extend({ export default class ScreenedIpAddress extends EmberObject {
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(
@ -30,23 +37,11 @@ const ScreenedIpAddress = EmberObject.extend({
}, },
} }
); );
}, }
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,15 +3,8 @@ 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";
const ScreenedUrl = EmberObject.extend({ export default class ScreenedUrl extends EmberObject {
@discourseComputed("action") static findAll() {
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
) { ) {
@ -19,7 +12,10 @@ ScreenedUrl.reopenClass({
return ScreenedUrl.create(b); return ScreenedUrl.create(b);
}); });
}); });
}, }
});
export default ScreenedUrl; @discourseComputed("action")
actionName(action) {
return I18n.t("admin.logs.screened_actions." + action);
}
}

View File

@ -4,22 +4,8 @@ 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";
const SiteSetting = EmberObject.extend(Setting, { export default class SiteSetting extends EmberObject.extend(Setting) {
@discourseComputed("setting") static findAll() {
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 = {};
@ -38,9 +24,9 @@ SiteSetting.reopenClass({
}; };
}); });
}); });
}, }
update(key, value, opts = {}) { static update(key, value, opts = {}) {
const data = {}; const data = {};
data[key] = value; data[key] = value;
@ -49,7 +35,17 @@ SiteSetting.reopenClass({
} }
return ajax(`/admin/site_settings/${key}`, { type: "PUT", data }); return ajax(`/admin/site_settings/${key}`, { type: "PUT", data });
}, }
});
export default SiteSetting; @discourseComputed("setting")
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 RestModel.extend({ export default class SiteText extends RestModel {
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,13 +11,36 @@ function format(label, value, escape = true) {
: ""; : "";
} }
const StaffActionLog = RestModel.extend({ export default class StaffActionLog extends RestModel {
showFullDetails: false, static 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;
}
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",
@ -72,42 +95,15 @@ const StaffActionLog = RestModel.extend({
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 EmberObject.extend(Setting, {}); export default class ThemeSettings extends EmberObject.extend(Setting) {}

View File

@ -13,11 +13,13 @@ export const THEMES = "themes";
export const COMPONENTS = "components"; export const COMPONENTS = "components";
const SETTINGS_TYPE_ID = 5; const SETTINGS_TYPE_ID = 5;
const Theme = RestModel.extend({ class Theme extends RestModel {
isActive: or("default", "user_selectable"), @or("default", "user_selectable") isActive;
isPendingUpdates: gt("remote_theme.commits_behind", 0), @gt("remote_theme.commits_behind", 0) isPendingUpdates;
hasEditedFields: gt("editedFields.length", 0), @gt("editedFields.length", 0) hasEditedFields;
hasParents: gt("parent_themes.length", 0), @gt("parent_themes.length", 0) hasParents;
changed = false;
@discourseComputed("theme_fields.[]") @discourseComputed("theme_fields.[]")
targets() { targets() {
@ -45,7 +47,7 @@ const Theme = RestModel.extend({
target["error"] = this.hasError(target.name); target["error"] = this.hasError(target.name);
return target; return target;
}); });
}, }
@discourseComputed("theme_fields.[]") @discourseComputed("theme_fields.[]")
fieldNames() { fieldNames() {
@ -84,7 +86,7 @@ const Theme = RestModel.extend({
], ],
extra_scss: scss_fields, extra_scss: scss_fields,
}; };
}, }
@discourseComputed( @discourseComputed(
"fieldNames", "fieldNames",
@ -118,7 +120,7 @@ const Theme = RestModel.extend({
}); });
}); });
return hash; return hash;
}, }
@discourseComputed("theme_fields") @discourseComputed("theme_fields")
themeFields(fields) { themeFields(fields) {
@ -134,7 +136,7 @@ const Theme = RestModel.extend({
} }
}); });
return hash; return hash;
}, }
@discourseComputed("theme_fields", "theme_fields.[]") @discourseComputed("theme_fields", "theme_fields.[]")
uploads(fields) { uploads(fields) {
@ -144,32 +146,32 @@ const Theme = RestModel.extend({
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) {
@ -180,27 +182,27 @@ const Theme = RestModel.extend({
(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);
@ -209,7 +211,7 @@ const Theme = RestModel.extend({
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);
@ -249,25 +251,25 @@ const Theme = RestModel.extend({
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;
@ -278,7 +280,7 @@ const Theme = RestModel.extend({
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;
@ -287,38 +289,36 @@ const Theme = RestModel.extend({
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 EmberObject.extend({ export default class Tl3Requirements extends EmberObject {
@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 EmberObject.extend({
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,14 +2,8 @@ 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";
const UserField = RestModel.extend(); export default class UserField extends RestModel {
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" }),
@ -20,11 +14,13 @@ UserField.reopenClass({
} }
return this._fieldTypes; return this._fieldTypes;
}, }
fieldTypeById(id) { static fieldTypeById(id) {
return this.fieldTypes().findBy("id", id); return this.fieldTypes().findBy("id", id);
}, }
}); }
export default UserField; class UserFieldType extends EmberObject {
@i18n("id", "admin.user_fields.field_types.%@") name;
}

View File

@ -2,43 +2,39 @@ 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";
const VersionCheck = EmberObject.extend({ export default class VersionCheck extends EmberObject {
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,34 +2,8 @@ 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";
const WatchedWord = EmberObject.extend({ export default class WatchedWord extends EmberObject {
save() { static findAll() {
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 = {};
@ -50,7 +24,29 @@ WatchedWord.reopenClass({
}); });
}); });
}); });
}, }
});
export default WatchedWord; 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",
});
}
}

View File

@ -1,33 +1,34 @@
import discourseComputed, { observes } from "discourse-common/utils/decorators"; import { computed } from "@ember/object";
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 RestModel.extend({ export default class WebHook extends RestModel {
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;
@discourseComputed("wildcard_web_hook") @computed("wildcard_web_hook")
webhookType: { get wildcard() {
get(wildcard) { return this.wildcard_web_hook ? "wildcard" : "individual";
return wildcard ? "wildcard" : "individual"; }
},
set(value) { set wildcard(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() {
@ -41,11 +42,11 @@ export default RestModel.extend({
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) {
@ -57,7 +58,7 @@ export default RestModel.extend({
}); });
return isWildcardWebHook ? "*" : desc; return isWildcardWebHook ? "*" : desc;
}, }
createProperties() { createProperties() {
const types = this.web_hook_event_types; const types = this.web_hook_event_types;
@ -92,9 +93,9 @@ export default RestModel.extend({
return groupIds; return groupIds;
}, []), }, []),
}; };
}, }
updateProperties() { updateProperties() {
return this.createProperties(); return this.createProperties();
}, }
}); }

View File

@ -36,12 +36,8 @@ export default class AdminLogsStaffActionLogsRoute extends DiscourseRoute {
@action @action
onFiltersChange(filters) { onFiltersChange(filters) {
if (filters && Object.keys(filters) === 0) { this.transitionTo("adminLogs.staffActionLogs", {
this.transitionTo("adminLogs.staffActionLogs"); queryParams: { filters },
} else { });
this.transitionTo("adminLogs.staffActionLogs", {
queryParams: { filters },
});
}
} }
} }

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 Service.extend({ export default class AdminToolsService extends Service {
dialog: service(), @service dialog;
showActionLogs(target, filters) { showActionLogs(target, filters) {
const controller = getOwner(target).lookup( const controller = getOwner(target).lookup(
@ -20,15 +20,15 @@ export default Service.extend({
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 Service.extend({
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 Service.extend({
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 Service.extend({
}); });
}); });
}); });
}, }
}); }

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://theme-creator.discourse.org/theme/awesomerobot/graceful", preview: "https://discourse.theme-creator.io/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,8 +10,7 @@ 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: preview: "https://discourse.theme-creator.io/theme/tshenry/material-design",
"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",
@ -19,7 +18,7 @@ export const POPULAR_THEMES = [
{ {
name: "Minima", name: "Minima",
value: "https://github.com/discourse/minima", value: "https://github.com/discourse/minima",
preview: "https://theme-creator.discourse.org/theme/awesomerobot/minima", preview: "https://discourse.theme-creator.io/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",
@ -27,7 +26,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://theme-creator.discourse.org/theme/sam/simple", preview: "https://discourse.theme-creator.io/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:
@ -36,6 +35,8 @@ 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",
@ -45,7 +46,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://theme-creator.discourse.org/theme/Johani/custom-header-links", "https://discourse.theme-creator.io/theme/awesomerobot/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,
@ -61,7 +62,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://theme-creator.discourse.org/theme/awesomerobot/discourse-category-banners", "https://discourse.theme-creator.io/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",
@ -70,7 +71,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://theme-creator.discourse.org/theme/david/kanban", preview: "https://discourse.theme-creator.io/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",
@ -84,10 +85,19 @@ 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: "https://theme-creator.discourse.org/theme/Johani/header-submenus", preview:
"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,
@ -104,7 +114,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://theme-creator.discourse.org/theme/Johani/easy-footer", preview: "https://discourse.theme-creator.io/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

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

View File

@ -1,14 +0,0 @@
{
"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.fancy_title}} {{replace-emoji t.title}}
</span> </span>
<span class="topic-categories"> <span class="topic-categories">
{{bound-category-link {{bound-category-link

View File

@ -12,6 +12,7 @@ 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",
@ -84,7 +85,9 @@ export default Component.extend({
}, },
_formatReplyToUserPost(avatar, link) { _formatReplyToUserPost(avatar, link) {
const htmlLink = `<a class="user-link" href="${link.href}">${link.anchor}</a>`; const htmlLink = `<a class="user-link" href="${link.href}">${escape(
link.anchor
)}</a>`;
return htmlSafe(`${avatar}${htmlLink}`); return htmlSafe(`${avatar}${htmlLink}`);
}, },
}); });

View File

@ -1,4 +1,4 @@
import TextArea from "@ember/component/text-area"; import { TextArea } from "@ember/legacy-built-in-components";
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/component/text-area"; import { TextArea } from "@ember/legacy-built-in-components";
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/component/text-field"; import { TextField } from "@ember/legacy-built-in-components";
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,7 +1,9 @@
import { emojiUnescape } from "discourse/lib/text"; import { emojiUnescape } from "discourse/lib/text";
import { htmlSafe } from "@ember/template"; import { htmlSafe, isHTMLSafe } 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

@ -1,22 +0,0 @@
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,6 +2,7 @@
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

@ -0,0 +1,46 @@
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.fancy_title}} {{replace-emoji result.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.fancy_title}}</a> >{{replace-emoji rt.title}}</a>
</div> </div>
</td> </td>
<td class="reviewable-count"> <td class="reviewable-count">

View File

@ -3,9 +3,6 @@ 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,6 +163,16 @@ 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,6 +23,7 @@
"@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",
@ -46,7 +47,6 @@
"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,6 +3,13 @@
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_2 button.reply"); await click("article#post_3 button.reply");
assert.strictEqual( assert.strictEqual(
query(".action-title .user-link").innerText.trim(), query(".action-title .user-link").innerHTML.trim(),
"james, john, the third" "&lt;h1&gt;Tim Stone&lt;/h1&gt;"
); );
}); });

View File

@ -139,7 +139,7 @@ acceptance("Search - Anonymous", function (needs) {
assert.strictEqual( assert.strictEqual(
queryAll(contextSelector)[0].firstChild.textContent.trim(), queryAll(contextSelector)[0].firstChild.textContent.trim(),
`${I18n.t("search.in")} test`, `${I18n.t("search.in")} important`,
"contextual tag search is first available option with no term" "contextual tag search is first available option with no term"
); );
@ -147,7 +147,7 @@ acceptance("Search - Anonymous", function (needs) {
assert.strictEqual( assert.strictEqual(
queryAll(contextSelector)[1].firstChild.textContent.trim(), queryAll(contextSelector)[1].firstChild.textContent.trim(),
`smth ${I18n.t("search.in")} test`, `smth ${I18n.t("search.in")} important`,
"tag-scoped search is second available option" "tag-scoped search is second available option"
); );

View File

@ -4077,7 +4077,7 @@ export default {
tags: [ tags: [
{ {
id: 1, id: 1,
name: "test", name: "important",
topic_count: 2, topic_count: 2,
staff: false, staff: false,
}, },

View File

@ -6497,7 +6497,7 @@ export default {
}, },
{ {
id: 419, id: 419,
name: "Tim Stone", name: "<h1>Tim Stone</h1>",
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

@ -0,0 +1,29 @@
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,7 +6,6 @@
"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.fancy_title}}</div> <div class="topic-title">{{replace-emoji this.item.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,6 +1088,16 @@
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"
@ -3999,6 +4009,22 @@ 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,6 +155,7 @@
.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
@ -716,9 +717,7 @@ body.footer-nav-ipad {
--100dvh: 100dvh; --100dvh: 100dvh;
} }
--base-height: calc( --base-height: calc(var(--100dvh) - var(--header-top));
var(--100dvh) - var(--header-top) - env(safe-area-inset-bottom, 0px)
);
height: var(--base-height); height: var(--base-height);

View File

@ -11,6 +11,7 @@
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

@ -34,6 +34,14 @@ class Admin::BackupsController < Admin::AdminController
end end
def create def create
RateLimiter.new(
current_user,
"max-backups-per-minute",
1,
1.minute,
apply_limit_to_staff: true,
).performed!
opts = { opts = {
publish_to_message_bus: true, publish_to_message_bus: true,
with_uploads: params.fetch(:with_uploads) == "true", with_uploads: params.fetch(:with_uploads) == "true",

View File

@ -32,20 +32,14 @@ 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]
create_or_find_by!(date: date, req_type: req_type_id).id DB.exec(<<~SQL, date: date, req_type_id: req_type_id, count: count)
rescue StandardError # primary key violation INSERT INTO application_requests (date, req_type, count)
if retries == 0 VALUES (:date, :req_type_id, :count)
req_id(date, req_type, 1) ON CONFLICT (date, req_type)
else DO UPDATE SET count = application_requests.count + excluded.count
raise SQL
end
end end
def self.stats def self.stats

View File

@ -46,7 +46,8 @@ class Bookmark < ActiveRecord::Base
validates :name, length: { maximum: 100 } validates :name, length: { maximum: 100 }
def registered_bookmarkable def registered_bookmarkable
Bookmark.registered_bookmarkable_from_type(self.bookmarkable_type) type = Bookmark.polymorphic_class_for(self.bookmarkable_type).name
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.name, type: target.class.sti_name,
potential_spam: potential_spam == true ? true : nil, potential_spam: potential_spam == true ? true : nil,
} }

View File

@ -9,8 +9,7 @@ Discourse::Application.configure do
config.cache_classes = false config.cache_classes = false
config.file_watcher = ActiveSupport::EventedFileUpdateChecker config.file_watcher = ActiveSupport::EventedFileUpdateChecker
# Log error messages when you accidentally call methods on nil. config.eager_load = ENV["DISCOURSE_ZEITWERK_EAGER_LOAD"] == "1"
config.eager_load = false
# Use the schema_cache.yml file generated during db:migrate (via db:schema:cache:dump) # Use the schema_cache.yml file generated during db:migrate (via db:schema:cache:dump)
config.active_record.use_schema_cache_dump = true config.active_record.use_schema_cache_dump = true

View File

@ -44,7 +44,7 @@ Discourse::Application.configure do
config.assets.compile = true config.assets.compile = true
config.assets.digest = false config.assets.digest = false
config.eager_load = false config.eager_load = ENV["DISCOURSE_ZEITWERK_EAGER_LOAD"] == "1"
if ENV["RAILS_ENABLE_TEST_LOG"] if ENV["RAILS_ENABLE_TEST_LOG"]
config.logger = Logger.new(STDOUT) config.logger = Logger.new(STDOUT)

View File

@ -1864,7 +1864,7 @@ en:
composer_media_optimization_image_bytes_optimization_threshold: "Minimum image file size to trigger client-side optimization" composer_media_optimization_image_bytes_optimization_threshold: "Minimum image file size to trigger client-side optimization"
composer_media_optimization_image_resize_dimensions_threshold: "Minimum image width to trigger client-side resize" composer_media_optimization_image_resize_dimensions_threshold: "Minimum image width to trigger client-side resize"
composer_media_optimization_image_resize_width_target: "Images with widths larger than `composer_media_optimization_image_dimensions_resize_threshold` will be resized to this width. Must be >= than `composer_media_optimization_image_dimensions_resize_threshold`." composer_media_optimization_image_resize_width_target: "Images with widths larger than `composer_media_optimization_image_dimensions_resize_threshold` will be resized to this width. Must be >= than `composer_media_optimization_image_dimensions_resize_threshold`."
composer_media_optimization_image_encode_quality: "JPEG encode quality used in the re-encode process." composer_media_optimization_image_encode_quality: "JPG encode quality used in the re-encode process."
min_ratio_to_crop: "Ratio used to crop tall images. Enter the result of width / height." min_ratio_to_crop: "Ratio used to crop tall images. Enter the result of width / height."

View File

@ -5,7 +5,7 @@ require "file_store/s3_store"
module BackupRestore module BackupRestore
class Backuper class Backuper
attr_reader :success attr_reader :success, :store
def initialize(user_id, opts = {}) def initialize(user_id, opts = {})
@user_id = user_id @user_id = user_id
@ -46,7 +46,6 @@ module BackupRestore
rescue Exception => ex rescue Exception => ex
log "EXCEPTION: " + ex.message log "EXCEPTION: " + ex.message
log ex.backtrace.join("\n") log ex.backtrace.join("\n")
@success = false
else else
@success = true @success = true
@backup_filename @backup_filename
@ -55,7 +54,7 @@ module BackupRestore
clean_up clean_up
notify_user notify_user
log "Finished!" log "Finished!"
publish_completion(@success) publish_completion
end end
protected protected
@ -337,12 +336,12 @@ module BackupRestore
end end
def upload_archive def upload_archive
return unless @store.remote? return unless store.remote?
log "Uploading archive..." log "Uploading archive..."
content_type = MiniMime.lookup_by_filename(@backup_filename).content_type content_type = MiniMime.lookup_by_filename(@backup_filename).content_type
archive_path = File.join(@archive_directory, @backup_filename) archive_path = File.join(@archive_directory, @backup_filename)
@store.upload_file(@backup_filename, archive_path, content_type) store.upload_file(@backup_filename, archive_path, content_type)
end end
def after_create_hook def after_create_hook
@ -354,16 +353,16 @@ module BackupRestore
return if Rails.env.development? return if Rails.env.development?
log "Deleting old backups..." log "Deleting old backups..."
@store.delete_old store.delete_old
rescue => ex rescue => ex
log "Something went wrong while deleting old backups.", ex log "Something went wrong while deleting old backups.", ex
end end
def notify_user def notify_user
return if @success && @user.id == Discourse::SYSTEM_USER_ID return if success && @user.id == Discourse::SYSTEM_USER_ID
log "Notifying '#{@user.username}' of the end of the backup..." log "Notifying '#{@user.username}' of the end of the backup..."
status = @success ? :backup_succeeded : :backup_failed status = success ? :backup_succeeded : :backup_failed
logs = Discourse::Utils.logs_markdown(@logs, user: @user) logs = Discourse::Utils.logs_markdown(@logs, user: @user)
post = SystemMessage.create_from_system_user(@user, status, logs: logs) post = SystemMessage.create_from_system_user(@user, status, logs: logs)
@ -378,11 +377,11 @@ module BackupRestore
delete_uploaded_archive delete_uploaded_archive
remove_tar_leftovers remove_tar_leftovers
mark_backup_as_not_running mark_backup_as_not_running
refresh_disk_space refresh_disk_space if success
end end
def delete_uploaded_archive def delete_uploaded_archive
return unless @store.remote? return unless store.remote?
archive_path = File.join(@archive_directory, @backup_filename) archive_path = File.join(@archive_directory, @backup_filename)
@ -396,7 +395,7 @@ module BackupRestore
def refresh_disk_space def refresh_disk_space
log "Refreshing disk stats..." log "Refreshing disk stats..."
@store.reset_cache store.reset_cache
rescue => ex rescue => ex
log "Something went wrong while refreshing disk stats.", ex log "Something went wrong while refreshing disk stats.", ex
end end
@ -450,7 +449,7 @@ module BackupRestore
@logs << "[#{timestamp}] #{message}" @logs << "[#{timestamp}] #{message}"
end end
def publish_completion(success) def publish_completion
if success if success
log("[SUCCESS]") log("[SUCCESS]")
DiscourseEvent.trigger(:backup_complete, logs: @logs, ticket: @ticket) DiscourseEvent.trigger(:backup_complete, logs: @logs, ticket: @ticket)

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] = FastImage.size(absolute_url) @size_cache[url] = FinalDestination::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

@ -0,0 +1,23 @@
# 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,18 +7,47 @@ class FinalDestination
class LookupFailedError < SocketError class LookupFailedError < SocketError
end end
def self.standard_private_ranges # This is a list of private IPv4 IP ranges that are not allowed to be globally reachable as given by
@private_ranges ||= [ # https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml.
IPAddr.new("0.0.0.0/8"), PRIVATE_IPV4_RANGES = [
IPAddr.new("127.0.0.1"), IPAddr.new("0.0.0.0/8"),
IPAddr.new("172.16.0.0/12"), IPAddr.new("10.0.0.0/8"),
IPAddr.new("192.168.0.0/16"), IPAddr.new("100.64.0.0/10"),
IPAddr.new("10.0.0.0/8"), IPAddr.new("127.0.0.0/8"),
IPAddr.new("::1"), IPAddr.new("169.254.0.0/16"),
IPAddr.new("fc00::/7"), IPAddr.new("172.16.0.0/12"),
IPAddr.new("fe80::/10"), IPAddr.new("192.0.0.0/24"),
] IPAddr.new("192.0.0.0/29"),
end IPAddr.new("192.0.0.8/32"),
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
@ -54,10 +83,9 @@ 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
if ip_in_ranges?(ip, blocked_ip_blocks) || ip_in_ranges?(ip, standard_private_ranges) return false if ip_in_ranges?(ip, blocked_ip_blocks) || ip_in_ranges?(ip, PRIVATE_IP_RANGES)
return false
end
true true
end end

View File

@ -0,0 +1,27 @@
# 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,7 +213,10 @@ 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
@good &&= run_or_fail("bundle exec rspec spec/system".strip) if ENV["RUN_SYSTEM_TESTS"] 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 = "beta2" PRE = "beta3"
STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
end end

View File

@ -1,60 +0,0 @@
# 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,11 +0,0 @@
# 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,8 +0,0 @@
# 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

@ -1,29 +0,0 @@
# 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

@ -0,0 +1,64 @@
# 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,9 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
class Chat::Api::ChatChannelThreadsController < Chat::Api class Chat::Api::ChannelThreadsController < Chat::ApiController
def show def show
with_service(Chat::Service::LookupThread) do with_service(::Chat::LookupThread) do
on_success { render_serialized(result.thread, ChatThreadSerializer, root: "thread") } on_success { render_serialized(result.thread, ::Chat::ThreadSerializer, 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::ChatChannelsArchivesController < Chat::Api::ChatChannelsController class Chat::Api::ChannelsArchivesController < Chat::Api::ChannelsController
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::ChatChannelArchiveService.retry_archive_process(chat_channel: channel_from_params) Chat::ChannelArchiveService.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::ChatChannelsArchivesController < Chat::Api::ChatChannelsControl
end end
begin begin
Chat::ChatChannelArchiveService.create_archive_process( Chat::ChannelArchiveService.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::ChatChannelArchiveService::ArchiveValidationError => err rescue Chat::ChannelArchiveService::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::ChatChannelsController < Chat::Api class Chat::Api::ChannelsController < Chat::ApiController
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] = ChatChannel.statuses[permitted[:status]] ? permitted[:status] : nil options[:status] = Chat::Channel.statuses[permitted[:status]] ? permitted[:status] : nil
memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user) memberships = Chat::ChannelMembershipManager.all_for_user(current_user)
channels = Chat::ChatChannelFetcher.secured_public_channels(guardian, memberships, options) channels = Chat::ChannelFetcher.secured_public_channels(guardian, memberships, options)
serialized_channels = serialized_channels =
channels.map do |channel| channels.map do |channel|
ChatChannelSerializer.new( Chat::ChannelSerializer.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::ChatChannelsController < Chat::Api
end end
def destroy def destroy
with_service Chat::Service::TrashChannel do with_service Chat::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::ChatChannelsController < Chat::Api
raise Discourse::InvalidParameters.new(:name) raise Discourse::InvalidParameters.new(:name)
end end
if ChatChannel.exists?( if Chat::Channel.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::ChatChannelsController < Chat::Api
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::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
end end
render_serialized( render_serialized(
channel, channel,
ChatChannelSerializer, Chat::ChannelSerializer,
membership: channel.membership_for(current_user), membership: channel.membership_for(current_user),
root: "channel", root: "channel",
) )
@ -83,7 +83,7 @@ class Chat::Api::ChatChannelsController < Chat::Api
def show def show
render_serialized( render_serialized(
channel_from_params, channel_from_params,
ChatChannelSerializer, Chat::ChannelSerializer,
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::ChatChannelsController < Chat::Api
auto_join_limiter(channel_from_params).performed! auto_join_limiter(channel_from_params).performed!
end end
with_service(Chat::Service::UpdateChannel, **params_to_edit) do with_service(Chat::UpdateChannel, **params_to_edit) do
on_success do on_success do
render_serialized( render_serialized(
result.channel, result.channel,
ChatChannelSerializer, Chat::ChannelSerializer,
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::ChatChannelsController < Chat::Api
def channel_from_params def channel_from_params
@channel ||= @channel ||=
begin begin
channel = ChatChannel.find(params.require(:channel_id)) channel = Chat::Channel.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::ChatChannelsController < Chat::Api
@membership ||= @membership ||=
begin begin
membership = membership =
Chat::ChatChannelMembershipManager.new(channel_from_params).find_for_user(current_user) Chat::ChannelMembershipManager.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::ChatChannelsCurrentUserMembershipController < Chat::Api::ChatChannelsController class Chat::Api::ChannelsCurrentUserMembershipController < Chat::Api::ChannelsController
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),
UserChatChannelMembershipSerializer, Chat::UserChannelMembershipSerializer,
root: "membership", root: "membership",
) )
end end
@ -14,7 +14,7 @@ class Chat::Api::ChatChannelsCurrentUserMembershipController < Chat::Api::ChatCh
def destroy def destroy
render_serialized( render_serialized(
channel_from_params.remove(current_user), channel_from_params.remove(current_user),
UserChatChannelMembershipSerializer, Chat::UserChannelMembershipSerializer,
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::ChatChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChatChannelsController class Chat::Api::ChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChannelsController
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,
UserChatChannelMembershipSerializer, Chat::UserChannelMembershipSerializer,
root: "membership", root: "membership",
) )
end end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsController class Chat::Api::ChannelsMembershipsController < Chat::Api::ChannelsController
def index def index
params.permit(:username, :offset, :limit) params.permit(:username, :offset, :limit)
@ -8,7 +8,7 @@ class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsCont
limit = (params[:limit] || 50).to_i.clamp(1, 50) limit = (params[:limit] || 50).to_i.clamp(1, 50)
memberships = memberships =
ChatChannelMembershipsQuery.call( Chat::ChannelMembershipsQuery.call(
channel: channel_from_params, channel: channel_from_params,
offset: offset, offset: offset,
limit: limit, limit: limit,
@ -17,7 +17,7 @@ class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsCont
render_serialized( render_serialized(
memberships, memberships,
UserChatChannelMembershipSerializer, Chat::UserChannelMembershipSerializer,
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::ChatChannelsMessagesMovesController < Chat::Api::ChatChannelsController class Chat::Api::ChannelsMessagesMovesController < Chat::Api::ChannelsController
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,10 +8,7 @@ class Chat::Api::ChatChannelsMessagesMovesController < Chat::Api::ChatChannelsCo
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::ChatChannelFetcher.find_with_access_check( Chat::ChannelFetcher.find_with_access_check(move_params[:destination_channel_id], guardian)
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)

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