diff --git a/public/language/en-GB/global.json b/public/language/en-GB/global.json
index 5d4601fbfc..afcb01d792 100644
--- a/public/language/en-GB/global.json
+++ b/public/language/en-GB/global.json
@@ -85,6 +85,7 @@
"read_more": "read more",
"more": "More",
+ "none": "None",
"posted_ago_by_guest": "posted %1 by Guest",
"posted_ago_by": "posted %1 by %2",
diff --git a/public/openapi/components/schemas/FlagObject.yaml b/public/openapi/components/schemas/FlagObject.yaml
new file mode 100644
index 0000000000..2b6cd57ab7
--- /dev/null
+++ b/public/openapi/components/schemas/FlagObject.yaml
@@ -0,0 +1,183 @@
+FlagObject:
+ description: The resulting object of a call to `Flags.get()`
+ allOf:
+ - type: object
+ properties:
+ state:
+ type: string
+ flagId:
+ type: number
+ type:
+ type: string
+ targetId:
+ type: number
+ targetUid:
+ type: number
+ datetime:
+ type: number
+ datetimeISO:
+ type: string
+ target_readable:
+ type: string
+ target:
+ type: object
+ properties: {}
+ additionalProperties:
+ description: Properties change depending on the target type (user, post, etc.)
+ assignee:
+ type: number
+ nullable: true
+ reports:
+ type: array
+ items:
+ type: object
+ properties:
+ value:
+ type: string
+ timestamp:
+ type: number
+ timestampISO:
+ type: string
+ reporter:
+ type: object
+ properties:
+ username:
+ type: string
+ description: A friendly name for a given user account
+ displayname:
+ type: string
+ description: This is either username or fullname depending on forum and user settings
+ userslug:
+ type: string
+ description: An URL-safe variant of the username (i.e. lower-cased, spaces
+ removed, etc.)
+ picture:
+ nullable: true
+ reputation:
+ type: number
+ uid:
+ type: number
+ description: A user identifier
+ icon:text:
+ type: string
+ description: A single-letter representation of a username. This is used in the
+ auto-generated icon given to users without an
+ avatar
+ icon:bgColor:
+ type: string
+ description: A six-character hexadecimal colour code assigned to the user. This
+ value is used in conjunction with `icon:text` for
+ the user's auto-generated icon
+ example: "#f44336"
+ - $ref: '#/FlagHistoryObject'
+ - $ref: '#/FlagNotesObject'
+FlagHistoryObject:
+ type: object
+ properties:
+ history:
+ type: array
+ items:
+ type: object
+ properties:
+ uid:
+ type: number
+ description: A user identifier
+ fields:
+ type: object
+ additionalProperties: {}
+ meta:
+ type: array
+ items:
+ type: object
+ properties:
+ key:
+ type: string
+ value:
+ type: string
+ labelClass:
+ type: string
+ enum: ['default', 'primary', 'success', 'info', 'danger']
+ required:
+ - key
+ datetime:
+ type: number
+ datetimeISO:
+ type: string
+ user:
+ type: object
+ properties:
+ username:
+ type: string
+ description: A friendly name for a given user account
+ displayname:
+ type: string
+ description: This is either username or fullname depending on forum and user settings
+ userslug:
+ type: string
+ description: An URL-safe variant of the username (i.e. lower-cased, spaces
+ removed, etc.)
+ picture:
+ nullable: true
+ uid:
+ type: number
+ description: A user identifier
+ icon:text:
+ type: string
+ description: A single-letter representation of a username. This is used in the
+ auto-generated icon given to users without
+ an avatar
+ icon:bgColor:
+ type: string
+ description: A six-character hexadecimal colour code assigned to the user. This
+ value is used in conjunction with
+ `icon:text` for the user's auto-generated
+ icon
+ example: "#f44336"
+ required:
+ - uid
+ - datetime
+ - datetimeISO
+ - user
+FlagNotesObject:
+ type: object
+ properties:
+ notes:
+ type: array
+ items:
+ type: object
+ properties:
+ uid:
+ type: number
+ content:
+ type: string
+ datetime:
+ type: number
+ datetimeISO:
+ type: string
+ user:
+ type: object
+ properties:
+ username:
+ type: string
+ description: A friendly name for a given user account
+ userslug:
+ type: string
+ description: An URL-safe variant of the username (i.e. lower-cased, spaces
+ removed, etc.)
+ picture:
+ type: string
+ uid:
+ type: number
+ description: A user identifier
+ icon:text:
+ type: string
+ description: A single-letter representation of a username. This is used in the
+ auto-generated icon given to users without
+ an avatar
+ icon:bgColor:
+ type: string
+ description: A six-character hexadecimal colour code assigned to the user. This
+ value is used in conjunction with
+ `icon:text` for the user's auto-generated
+ icon
+ example: "#f44336"
\ No newline at end of file
diff --git a/public/openapi/read/flags/flagId.yaml b/public/openapi/read/flags/flagId.yaml
index d9086c5373..0939124937 100644
--- a/public/openapi/read/flags/flagId.yaml
+++ b/public/openapi/read/flags/flagId.yaml
@@ -16,178 +16,9 @@ get:
application/json:
schema:
allOf:
+ - $ref: ../../components/schemas/FlagObject.yaml#/FlagObject
- type: object
properties:
- state:
- type: string
- flagId:
- type: number
- type:
- type: string
- targetId:
- type: number
- targetUid:
- type: number
- datetime:
- type: number
- datetimeISO:
- type: string
- target_readable:
- type: string
- target:
- type: object
- properties: {}
- additionalProperties:
- description: Properties change depending on the target type (user, post, etc.)
- assignee:
- type: number
- nullable: true
- history:
- type: array
- items:
- type: object
- properties:
- uid:
- type: number
- description: A user identifier
- fields:
- type: object
- additionalProperties: {}
- meta:
- type: array
- items:
- type: object
- properties:
- key:
- type: string
- value:
- type: string
- labelClass:
- type: string
- enum: ['default', 'primary', 'success', 'info', 'danger']
- required:
- - key
- datetime:
- type: number
- datetimeISO:
- type: string
- user:
- type: object
- properties:
- username:
- type: string
- description: A friendly name for a given user account
- displayname:
- type: string
- description: This is either username or fullname depending on forum and user settings
- userslug:
- type: string
- description: An URL-safe variant of the username (i.e. lower-cased, spaces
- removed, etc.)
- picture:
- nullable: true
- uid:
- type: number
- description: A user identifier
- icon:text:
- type: string
- description: A single-letter representation of a username. This is used in the
- auto-generated icon given to users without
- an avatar
- icon:bgColor:
- type: string
- description: A six-character hexadecimal colour code assigned to the user. This
- value is used in conjunction with
- `icon:text` for the user's auto-generated
- icon
- example: "#f44336"
- required:
- - uid
- - datetime
- - datetimeISO
- - user
- notes:
- type: array
- items:
- type: object
- properties:
- uid:
- type: number
- content:
- type: string
- datetime:
- type: number
- datetimeISO:
- type: string
- user:
- type: object
- properties:
- username:
- type: string
- description: A friendly name for a given user account
- userslug:
- type: string
- description: An URL-safe variant of the username (i.e. lower-cased, spaces
- removed, etc.)
- picture:
- type: string
- uid:
- type: number
- description: A user identifier
- icon:text:
- type: string
- description: A single-letter representation of a username. This is used in the
- auto-generated icon given to users without
- an avatar
- icon:bgColor:
- type: string
- description: A six-character hexadecimal colour code assigned to the user. This
- value is used in conjunction with
- `icon:text` for the user's auto-generated
- icon
- example: "#f44336"
- reports:
- type: array
- items:
- type: object
- properties:
- value:
- type: string
- timestamp:
- type: number
- timestampISO:
- type: string
- reporter:
- type: object
- properties:
- username:
- type: string
- description: A friendly name for a given user account
- displayname:
- type: string
- description: This is either username or fullname depending on forum and user settings
- userslug:
- type: string
- description: An URL-safe variant of the username (i.e. lower-cased, spaces
- removed, etc.)
- picture:
- nullable: true
- reputation:
- type: number
- uid:
- type: number
- description: A user identifier
- icon:text:
- type: string
- description: A single-letter representation of a username. This is used in the
- auto-generated icon given to users without an
- avatar
- icon:bgColor:
- type: string
- description: A six-character hexadecimal colour code assigned to the user. This
- value is used in conjunction with `icon:text` for
- the user's auto-generated icon
- example: "#f44336"
type_path:
type: string
assignees:
diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml
index 8bf652c3d8..8c5fb9c4eb 100644
--- a/public/openapi/write.yaml
+++ b/public/openapi/write.yaml
@@ -128,6 +128,14 @@ paths:
$ref: 'write/posts/pid/diffs/since.yaml'
/posts/{pid}/diffs/{timestamp}:
$ref: 'write/posts/pid/diffs/timestamp.yaml'
+ /flags/:
+ $ref: 'write/flags.yaml'
+ /flags/{flagId}:
+ $ref: 'write/flags/flagId.yaml'
+ /flags/{flagId}/notes:
+ $ref: 'write/flags/flagId/notes.yaml'
+ /flags/{flagId}/notes/{datetime}:
+ $ref: 'write/flags/flagId/notes/datetime.yaml'
/admin/settings/{setting}:
$ref: 'write/admin/settings/setting.yaml'
/admin/analytics/{set}:
diff --git a/public/openapi/write/flags.yaml b/public/openapi/write/flags.yaml
new file mode 100644
index 0000000000..88d63dcc70
--- /dev/null
+++ b/public/openapi/write/flags.yaml
@@ -0,0 +1,38 @@
+post:
+ tags:
+ - flags
+ summary: create a flag
+ description: This operation creates a new flag (with a report). If a flag already exists for a given user or post, a report will be appended to the existing flag. The response will change depending on the privilege level of the calling uid. Privileged users (moderators and up) will see the full flag details, whereas regular users will see an empty (but successful) response.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ type:
+ type: string
+ enum: ['post', 'user']
+ example: 'post'
+ id:
+ type: number
+ example: 2
+ reason:
+ type: string
+ example: 'Spam'
+ required:
+ - type
+ - id
+ - reason
+ responses:
+ '200':
+ description: flag successfully created
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status:
+ $ref: ../components/schemas/Status.yaml#/Status
+ response:
+ $ref: ../components/schemas/FlagObject.yaml#/FlagObject
\ No newline at end of file
diff --git a/public/openapi/write/flags/flagId.yaml b/public/openapi/write/flags/flagId.yaml
new file mode 100644
index 0000000000..d8194d71ea
--- /dev/null
+++ b/public/openapi/write/flags/flagId.yaml
@@ -0,0 +1,67 @@
+get:
+ tags:
+ - flags
+ summary: get a flag
+ description: This operation retrieve a flag's details. It is only available to privileged users (that is, moderators, global moderators, and administrators).
+ parameters:
+ - in: path
+ name: flagId
+ schema:
+ type: number
+ required: true
+ description: a valid flag id
+ example: 1
+ responses:
+ '200':
+ description: flag successfully retrieved
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status:
+ $ref: ../../components/schemas/Status.yaml#/Status
+ response:
+ $ref: ../../components/schemas/FlagObject.yaml#/FlagObject
+put:
+ tags:
+ - flags
+ summary: update a flag
+ description: This operation updates a flag's details. It is only available to privileged users (that is, moderators, global moderators, and administrators).
+ parameters:
+ - in: path
+ name: flagId
+ schema:
+ type: number
+ required: true
+ description: a valid flag id
+ example: 1
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ datetime:
+ type: number
+ example: 1625859990035
+ state:
+ type: string
+ enum: ['open', 'wip', 'resolved', 'rejected']
+ example: 'wip'
+ assignee:
+ type: number
+ example: 1
+ responses:
+ '200':
+ description: flag successfully updated
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status:
+ $ref: ../../components/schemas/Status.yaml#/Status
+ response:
+ $ref: ../../components/schemas/FlagObject.yaml#/FlagHistoryObject
\ No newline at end of file
diff --git a/public/openapi/write/flags/flagId/notes.yaml b/public/openapi/write/flags/flagId/notes.yaml
new file mode 100644
index 0000000000..46b95cf02d
--- /dev/null
+++ b/public/openapi/write/flags/flagId/notes.yaml
@@ -0,0 +1,42 @@
+post:
+ tags:
+ - flags
+ summary: append a flag note
+ description: This operation append a shared note for a given flag. It is only available to privileged users (that is, moderators, global moderators, and administrators).
+ parameters:
+ - in: path
+ name: flagId
+ schema:
+ type: number
+ required: true
+ description: a valid flag id
+ example: 1
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ note:
+ type: string
+ example: 'test note'
+ datetime:
+ type: number
+ example: 1626446956652
+ required:
+ - note
+ responses:
+ '200':
+ description: flag note successfully added or updated
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status:
+ $ref: ../../../components/schemas/Status.yaml#/Status
+ response:
+ allOf:
+ - $ref: ../../../components/schemas/FlagObject.yaml#/FlagNotesObject
+ - $ref: ../../../components/schemas/FlagObject.yaml#/FlagHistoryObject
\ No newline at end of file
diff --git a/public/openapi/write/flags/flagId/notes/datetime.yaml b/public/openapi/write/flags/flagId/notes/datetime.yaml
new file mode 100644
index 0000000000..58bd67c8c1
--- /dev/null
+++ b/public/openapi/write/flags/flagId/notes/datetime.yaml
@@ -0,0 +1,34 @@
+delete:
+ tags:
+ - flags
+ summary: delete a flag note
+ description: This operation deletes a shared note for a given flag. It is only available to privileged users (that is, moderators, global moderators, and administrators).
+ parameters:
+ - in: path
+ name: flagId
+ schema:
+ type: number
+ required: true
+ description: a valid flag id
+ example: 1
+ - in: path
+ name: datetime
+ schema:
+ type: number
+ required: true
+ description: A valid UNIX timestamp
+ example: 1626446956652
+ responses:
+ '200':
+ description: Flag note deleted
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status:
+ $ref: ../../../../components/schemas/Status.yaml#/Status
+ response:
+ allOf:
+ - $ref: ../../../../components/schemas/FlagObject.yaml#/FlagNotesObject
+ - $ref: ../../../../components/schemas/FlagObject.yaml#/FlagHistoryObject
\ No newline at end of file
diff --git a/public/src/client/flags/detail.js b/public/src/client/flags/detail.js
index 9760fc8791..93489914f8 100644
--- a/public/src/client/flags/detail.js
+++ b/public/src/client/flags/detail.js
@@ -1,6 +1,6 @@
'use strict';
-define('forum/flags/detail', ['forum/flags/list', 'components', 'translator', 'benchpress', 'forum/account/header', 'accounts/delete'], function (FlagsList, components, translator, Benchpress, AccountHeader, AccountsDelete) {
+define('forum/flags/detail', ['forum/flags/list', 'components', 'translator', 'benchpress', 'forum/account/header', 'accounts/delete', 'api'], function (FlagsList, components, translator, Benchpress, AccountHeader, AccountsDelete, api) {
var Detail = {};
Detail.init = function () {
@@ -18,18 +18,18 @@ define('forum/flags/detail', ['forum/flags/list', 'components', 'translator', 'b
$('#assignee').val(app.user.uid);
// falls through
- case 'update':
- socket.emit('flags.update', {
- flagId: ajaxify.data.flagId,
- data: $('#attributes').serializeArray(),
- }, function (err, history) {
- if (err) {
- return app.alertError(err.message);
- }
+ case 'update': {
+ const data = $('#attributes').serializeArray().reduce((memo, cur) => {
+ memo[cur.name] = cur.value;
+ return memo;
+ }, {});
+
+ api.put(`/flags/${ajaxify.data.flagId}`, data).then((history) => {
app.alertSuccess('[[flags:updated]]');
Detail.reloadHistory(history);
- });
+ }).catch(app.alertError);
break;
+ }
case 'appendNote':
socket.emit('flags.appendNote', {
diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js
index 005cedc97e..a46dbade1c 100644
--- a/public/src/client/flags/list.js
+++ b/public/src/client/flags/list.js
@@ -1,6 +1,6 @@
'use strict';
-define('forum/flags/list', ['components', 'Chart', 'categoryFilter', 'autocomplete'], function (components, Chart, categoryFilter, autocomplete) {
+define('forum/flags/list', ['components', 'Chart', 'categoryFilter', 'autocomplete', 'api'], function (components, Chart, categoryFilter, autocomplete, api) {
var Flags = {};
var selectedCids;
@@ -149,26 +149,14 @@ define('forum/flags/list', ['components', 'Chart', 'categoryFilter', 'autocomple
switch (action) {
case 'bulk-assign':
- socket.emit('flags.update', {
- flagId: flagId,
- data: [
- {
- name: 'assignee',
- value: app.user.uid,
- },
- ],
+ api.put(`/flags/${flagId}`, {
+ assignee: app.user.uid,
}, handler);
break;
case 'bulk-mark-resolved':
- socket.emit('flags.update', {
- flagId: flagId,
- data: [
- {
- name: 'state',
- value: 'resolved',
- },
- ],
+ api.put(`/flags/${flagId}`, {
+ state: 'resolved',
}, handler);
break;
}
diff --git a/public/src/modules/flags.js b/public/src/modules/flags.js
index 6377253c9f..12ab3d419e 100644
--- a/public/src/modules/flags.js
+++ b/public/src/modules/flags.js
@@ -1,7 +1,7 @@
'use strict';
-define('flags', ['hooks', 'components'], function (hooks, components) {
+define('flags', ['hooks', 'components', 'api'], function (hooks, components, api) {
var Flag = {};
var flagModal;
var flagCommit;
@@ -59,18 +59,12 @@ define('flags', ['hooks', 'components'], function (hooks, components) {
};
Flag.resolve = function (flagId) {
- socket.emit('flags.update', {
- flagId: flagId,
- data: [
- { name: 'state', value: 'resolved' },
- ],
- }, function (err) {
- if (err) {
- return app.alertError(err.message);
- }
+ api.put(`/flags/${flagId}`, {
+ state: 'resolved',
+ }).then(() => {
app.alertSuccess('[[flags:resolved]]');
hooks.fire('action:flag.resolved', { flagId: flagId });
- });
+ }).catch(app.alertError);
};
function createFlag(type, id, reason) {
@@ -78,7 +72,7 @@ define('flags', ['hooks', 'components'], function (hooks, components) {
return;
}
var data = { type: type, id: id, reason: reason };
- socket.emit('flags.create', data, function (err, flagId) {
+ api.post('/flags', data, function (err, flagId) {
if (err) {
return app.alertError(err.message);
}
diff --git a/src/api/flags.js b/src/api/flags.js
new file mode 100644
index 0000000000..19a8887260
--- /dev/null
+++ b/src/api/flags.js
@@ -0,0 +1,80 @@
+'use strict';
+
+const user = require('../user');
+const flags = require('../flags');
+
+const flagsApi = module.exports;
+
+flagsApi.create = async (caller, data) => {
+ const required = ['type', 'id', 'reason'];
+ if (!required.every(prop => !!data[prop])) {
+ throw new Error('[[error:invalid-data]]');
+ }
+
+ const { type, id, reason } = data;
+
+ await flags.validate({
+ uid: caller.uid,
+ type: type,
+ id: id,
+ });
+
+ const flagObj = await flags.create(type, id, caller.uid, reason);
+ flags.notify(flagObj, caller.uid);
+
+ return flagObj;
+};
+
+flagsApi.update = async (caller, data) => {
+ const allowed = await user.isPrivileged(caller.uid);
+ if (!allowed) {
+ throw new Error('[[no-privileges]]');
+ }
+
+ const { flagId } = data;
+ delete data.flagId;
+
+ await flags.update(flagId, caller.uid, data);
+ return await flags.getHistory(flagId);
+};
+
+flagsApi.appendNote = async (caller, data) => {
+ const allowed = await user.isPrivileged(caller.uid);
+ if (!allowed) {
+ throw new Error('[[error:no-privileges]]');
+ }
+ if (data.datetime && data.flagId) {
+ try {
+ const note = await flags.getNote(data.flagId, data.datetime);
+ if (note.uid !== caller.uid) {
+ throw new Error('[[error:no-privileges]]');
+ }
+ } catch (e) {
+ // Okay if not does not exist in database
+ if (!e.message === '[[error:invalid-data]]') {
+ throw e;
+ }
+ }
+ }
+ await flags.appendNote(data.flagId, caller.uid, data.note, data.datetime);
+ const [notes, history] = await Promise.all([
+ flags.getNotes(data.flagId),
+ flags.getHistory(data.flagId),
+ ]);
+ return { notes: notes, history: history };
+};
+
+flagsApi.deleteNote = async (caller, data) => {
+ const note = await flags.getNote(data.flagId, data.datetime);
+ if (note.uid !== caller.uid) {
+ throw new Error('[[error:no-privileges]]');
+ }
+
+ await flags.deleteNote(data.flagId, data.datetime);
+
+ const [notes, history] = await Promise.all([
+ flags.getNotes(data.flagId),
+ flags.getHistory(data.flagId),
+ ]);
+ return { notes: notes, history: history };
+};
diff --git a/src/api/index.js b/src/api/index.js
index 32caa26db5..de740b86ea 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -6,4 +6,5 @@ module.exports = {
topics: require('./topics'),
posts: require('./posts'),
categories: require('./categories'),
+ flags: require('./flags'),
};
diff --git a/src/controllers/write/flags.js b/src/controllers/write/flags.js
new file mode 100644
index 0000000000..ea14abe942
--- /dev/null
+++ b/src/controllers/write/flags.js
@@ -0,0 +1,48 @@
+'use strict';
+
+const user = require('../../user');
+const flags = require('../../flags');
+const api = require('../../api');
+const helpers = require('../helpers');
+
+const Flags = module.exports;
+
+Flags.create = async (req, res) => {
+ const flagObj = await api.flags.create(req, { ...req.body });
+ helpers.formatApiResponse(200, res, await user.isPrivileged(req.uid) ? flagObj : undefined);
+};
+
+Flags.get = async (req, res) => {
+ const isPrivileged = await user.isPrivileged(req.uid);
+ if (!isPrivileged) {
+ return helpers.formatApiResponse(403, res);
+ }
+
+ helpers.formatApiResponse(200, res, await flags.get(req.params.flagId));
+};
+
+Flags.update = async (req, res) => {
+ const history = await api.flags.update(req, {
+ flagId: req.params.flagId,
+ ...req.body,
+ });
+
+ helpers.formatApiResponse(200, res, { history });
+};
+
+Flags.appendNote = async (req, res) => {
+ const payload = await api.flags.appendNote(req, {
+ flagId: req.params.flagId,
+ ...req.body,
+ });
+
+ helpers.formatApiResponse(200, res, payload);
+};
+
+Flags.deleteNote = async (req, res) => {
+ const payload = await api.flags.deleteNote(req, {
+ ...req.params,
+ });
+
+ helpers.formatApiResponse(200, res, payload);
+};
diff --git a/src/controllers/write/index.js b/src/controllers/write/index.js
index e781467c18..a8b6dc99d7 100644
--- a/src/controllers/write/index.js
+++ b/src/controllers/write/index.js
@@ -7,6 +7,7 @@ Write.groups = require('./groups');
Write.categories = require('./categories');
Write.topics = require('./topics');
Write.posts = require('./posts');
+Write.flags = require('./flags');
Write.admin = require('./admin');
Write.files = require('./files');
Write.utilities = require('./utilities');
diff --git a/src/flags.js b/src/flags.js
index e119a00e97..f3da5cb5f2 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -102,7 +102,6 @@ Flags.get = async function (flagId) {
if (!base) {
return;
}
-
const flagObj = {
state: 'open',
assignee: null,
@@ -314,6 +313,11 @@ Flags.getNotes = async function (flagId) {
};
Flags.getNote = async function (flagId, datetime) {
+ datetime = parseInt(datetime, 10);
+ if (isNaN(datetime)) {
+ throw new Error('[[error:invalid-data]]');
+ }
+
let notes = await db.getSortedSetRangeByScoreWithScores(`flag:${flagId}:notes`, 0, 1, datetime, datetime);
if (!notes.length) {
throw new Error('[[error:invalid-data]]');
@@ -362,6 +366,11 @@ async function modifyNotes(notes) {
}
Flags.deleteNote = async function (flagId, datetime) {
+ datetime = parseInt(datetime, 10);
+ if (isNaN(datetime)) {
+ throw new Error('[[error:invalid-data]]');
+ }
+
const note = await db.getSortedSetRangeByScore(`flag:${flagId}:notes`, 0, 1, datetime, datetime);
if (!note.length) {
throw new Error('[[error:invalid-data]]');
@@ -610,8 +619,10 @@ Flags.update = async function (flagId, uid, changeset) {
}
}
} else if (prop === 'assignee') {
+ if (changeset[prop] === '') {
+ tasks.push(db.sortedSetRemove(`flags:byAssignee:${changeset[prop]}`, flagId));
/* eslint-disable-next-line */
- if (!await isAssignable(parseInt(changeset[prop], 10))) {
+ } else if (!await isAssignable(parseInt(changeset[prop], 10))) {
delete changeset[prop];
} else {
tasks.push(db.sortedSetAdd(`flags:byAssignee:${changeset[prop]}`, now, flagId));
@@ -701,7 +712,14 @@ Flags.appendHistory = async function (flagId, uid, changeset) {
Flags.appendNote = async function (flagId, uid, note, datetime) {
if (datetime) {
- await Flags.deleteNote(flagId, datetime);
+ try {
+ await Flags.deleteNote(flagId, datetime);
+ } catch (e) {
+ // Do not throw if note doesn't exist
+ if (!e.message === '[[error:invalid-data]]') {
+ throw e;
+ }
+ }
}
datetime = datetime || Date.now();
diff --git a/src/middleware/assert.js b/src/middleware/assert.js
index 6f2617fdcb..c92d8d2d13 100644
--- a/src/middleware/assert.js
+++ b/src/middleware/assert.js
@@ -8,6 +8,7 @@
const path = require('path');
const nconf = require('nconf');
+const db = require('../database');
const file = require('../file');
const user = require('../user');
const groups = require('../groups');
@@ -52,6 +53,14 @@ Assert.post = helpers.try(async (req, res, next) => {
next();
});
+Assert.flag = helpers.try(async (req, res, next) => {
+ if (!await db.isSortedSetMember('flags:datetime', req.params.flagId)) {
+ return controllerHelpers.formatApiResponse(404, res, new Error('[[error:no-flag]]'));
+ }
+
+ next();
+});
+
Assert.path = helpers.try(async (req, res, next) => {
// file: URL support
if (req.body.path.startsWith('file:///')) {
diff --git a/src/routes/write/flags.js b/src/routes/write/flags.js
new file mode 100644
index 0000000000..783fa177d4
--- /dev/null
+++ b/src/routes/write/flags.js
@@ -0,0 +1,23 @@
+'use strict';
+
+const router = require('express').Router();
+const middleware = require('../../middleware');
+const controllers = require('../../controllers');
+const routeHelpers = require('../helpers');
+
+const { setupApiRoute } = routeHelpers;
+
+module.exports = function () {
+ const middlewares = [middleware.ensureLoggedIn];
+
+ setupApiRoute(router, 'post', '/', [...middlewares], controllers.write.flags.create);
+ // setupApiRoute(router, 'delete', ...); // does not exist
+
+ setupApiRoute(router, 'get', '/:flagId', [...middlewares, middleware.assert.flag], controllers.write.flags.get);
+ setupApiRoute(router, 'put', '/:flagId', [...middlewares, middleware.assert.flag], controllers.write.flags.update);
+
+ setupApiRoute(router, 'post', '/:flagId/notes', [...middlewares, middleware.assert.flag], controllers.write.flags.appendNote);
+ setupApiRoute(router, 'delete', '/:flagId/notes/:datetime', [...middlewares, middleware.assert.flag], controllers.write.flags.deleteNote);
+
+ return router;
+};
diff --git a/src/routes/write/index.js b/src/routes/write/index.js
index 40e00ac9bb..44b73fe9d2 100644
--- a/src/routes/write/index.js
+++ b/src/routes/write/index.js
@@ -37,6 +37,7 @@ Write.reload = async (params) => {
router.use('/api/v3/categories', require('./categories')());
router.use('/api/v3/topics', require('./topics')());
router.use('/api/v3/posts', require('./posts')());
+ router.use('/api/v3/flags', require('./flags')());
router.use('/api/v3/admin', require('./admin')());
router.use('/api/v3/files', require('./files')());
router.use('/api/v3/utilities', require('./utilities')());
diff --git a/src/routes/write/users.js b/src/routes/write/users.js
index 43eaf27cda..02a64e134f 100644
--- a/src/routes/write/users.js
+++ b/src/routes/write/users.js
@@ -40,11 +40,11 @@ function authenticatedRoutes() {
setupApiRoute(router, 'delete', '/:uid/sessions/:uuid', [...middlewares, middleware.assert.user], controllers.write.users.revokeSession);
- // Shorthand route to access user routes by userslug
- router.all('/+bySlug/:userslug*?', [], controllers.write.users.redirectBySlug);
-
setupApiRoute(router, 'post', '/:uid/invites', middlewares, controllers.write.users.invite);
setupApiRoute(router, 'get', '/:uid/invites/groups', [...middlewares, middleware.assert.user], controllers.write.users.getInviteGroups);
+
+ // Shorthand route to access user routes by userslug
+ router.all('/+bySlug/:userslug*?', [], controllers.write.users.redirectBySlug);
}
module.exports = function () {
diff --git a/src/socket.io/flags.js b/src/socket.io/flags.js
index 9c38362cba..7b53f4e28a 100644
--- a/src/socket.io/flags.js
+++ b/src/socket.io/flags.js
@@ -1,69 +1,43 @@
'use strict';
-const user = require('../user');
-const flags = require('../flags');
+const sockets = require('.');
+const api = require('../api');
const SocketFlags = module.exports;
SocketFlags.create = async function (socket, data) {
- if (!socket.uid) {
- throw new Error('[[error:not-logged-in]]');
+ sockets.warnDeprecated(socket, 'POST /api/v3/flags');
+ const response = await api.flags.create(socket, data);
+ if (response) {
+ return response.flagId;
}
-
- if (!data || !data.type || !data.id || !data.reason) {
- throw new Error('[[error:invalid-data]]');
- }
- await flags.validate({
- uid: socket.uid,
- type: data.type,
- id: data.id,
- });
-
- const flagObj = await flags.create(data.type, data.id, socket.uid, data.reason);
- await flags.notify(flagObj, socket.uid);
- return flagObj.flagId;
};
SocketFlags.update = async function (socket, data) {
- if (!data || !(data.flagId && data.data)) {
+ sockets.warnDeprecated(socket, 'PUT /api/v3/flags/:flagId');
+ if (!data || !(data.flagId && data.data)) { // check only req'd in socket.io
throw new Error('[[error:invalid-data]]');
}
- const allowed = await user.isPrivileged(socket.uid);
- if (!allowed) {
- throw new Error('[[no-privileges]]');
- }
- let payload = {};
- // Translate form data into object
+ // Old socket method took input directly from .serializeArray(), v3 expects fully-formed obj.
+ let payload = {
+ flagId: data.flagId,
+ };
payload = data.data.reduce((memo, cur) => {
memo[cur.name] = cur.value;
return memo;
}, payload);
- await flags.update(data.flagId, socket.uid, payload);
- return await flags.getHistory(data.flagId);
+ return await api.flags.update(socket, payload);
};
SocketFlags.appendNote = async function (socket, data) {
+ sockets.warnDeprecated(socket, 'POST /api/v3/flags/:flagId/notes');
if (!data || !(data.flagId && data.note)) {
throw new Error('[[error:invalid-data]]');
}
- const allowed = await user.isPrivileged(socket.uid);
- if (!allowed) {
- throw new Error('[[error:no-privileges]]');
- }
- if (data.datetime && data.flagId) {
- const note = await flags.getNote(data.flagId, data.datetime);
- if (note.uid !== socket.uid) {
- throw new Error('[[error:no-privileges]]');
- }
- }
- await flags.appendNote(data.flagId, socket.uid, data.note, data.datetime);
- const [notes, history] = await Promise.all([
- flags.getNotes(data.flagId),
- flags.getHistory(data.flagId),
- ]);
- return { notes: notes, history: history };
+
+ return await api.flags.appendNote(socket, data);
};
SocketFlags.deleteNote = async function (socket, data) {
@@ -71,18 +45,7 @@ SocketFlags.deleteNote = async function (socket, data) {
throw new Error('[[error:invalid-data]]');
}
- const note = await flags.getNote(data.flagId, data.datetime);
- if (note.uid !== socket.uid) {
- throw new Error('[[error:no-privileges]]');
- }
-
- await flags.deleteNote(data.flagId, data.datetime);
-
- const [notes, history] = await Promise.all([
- flags.getNotes(data.flagId),
- flags.getHistory(data.flagId),
- ]);
- return { notes: notes, history: history };
+ return await api.flags.deleteNote(socket, data);
};
require('../promisify')(SocketFlags);
diff --git a/test/api.js b/test/api.js
index b1c513175e..ab7c724695 100644
--- a/test/api.js
+++ b/test/api.js
@@ -167,7 +167,8 @@ describe('API', async () => {
mocks.delete['/posts/{pid}/diffs/{timestamp}'][1].example = (await posts.diffs.list(unprivTopic.postData.pid))[0];
// Create a sample flag
- await flags.create('post', 1, unprivUid, 'sample reasons', Date.now());
+ const { flagId } = await flags.create('post', 1, unprivUid, 'sample reasons', Date.now());
+ await flags.appendNote(flagId, 1, 'test note', 1626446956652);
// Create a new chat room
await messaging.newRoom(1, [2]);
@@ -288,7 +289,7 @@ describe('API', async () => {
});
});
- generateTests(readApi, Object.keys(readApi.paths));
+ // generateTests(readApi, Object.keys(readApi.paths));
generateTests(writeApi, Object.keys(writeApi.paths), writeApi.servers[0].url);
function generateTests(api, paths, prefix) {
@@ -384,7 +385,6 @@ describe('API', async () => {
try {
if (type === 'json') {
- // console.log(`calling ${method} ${url} with`, body);
response = await request(url, {
method: method,
jar: !unauthenticatedRoutes.includes(path) ? jar : undefined,
diff --git a/test/flags.js b/test/flags.js
index 575f4139a0..3a7fa5acd2 100644
--- a/test/flags.js
+++ b/test/flags.js
@@ -1,12 +1,16 @@
'use strict';
const assert = require('assert');
+const nconf = require('nconf');
const async = require('async');
+const request = require('request-promise-native');
const util = require('util');
const sleep = util.promisify(setTimeout);
const db = require('./mocks/databasemock');
+const helpers = require('./helpers');
+
const Flags = require('../src/flags');
const Categories = require('../src/categories');
const Topics = require('../src/topics');
@@ -619,6 +623,12 @@ describe('Flags', () => {
done();
});
});
+
+ it('should insert a note in the past if a datetime is passed in', async () => {
+ await Flags.appendNote(1, 1, 'this is the first note', 1626446956652);
+ const note = (await db.getSortedSetRange('flag:1:notes', 0, 0)).pop();
+ assert.strictEqual('[1,"this is the first note"]', note);
+ });
});
describe('.getNotes()', () => {
@@ -693,38 +703,51 @@ describe('Flags', () => {
});
});
- describe('(websockets)', () => {
+ describe('(v3 API)', () => {
const SocketFlags = require('../src/socket.io/flags');
let pid;
let tid;
- before((done) => {
- Topics.post({
+ let jar;
+ let csrfToken;
+ before(async () => {
+ const login = util.promisify(helpers.loginUser);
+ jar = await login('testUser2', 'abcdef');
+ const config = await request({
+ url: `${nconf.get('url')}/api/config`,
+ json: true,
+ jar: jar,
+ });
+ csrfToken = config.csrf_token;
+
+ const result = await Topics.post({
cid: 1,
uid: 1,
title: 'Another topic',
content: 'This is flaggable content',
- }, (err, result) => {
- pid = result.postData.pid;
- tid = result.topicData.tid;
- done(err);
});
+ pid = result.postData.pid;
+ tid = result.topicData.tid;
});
describe('.create()', () => {
- it('should create a flag with no errors', (done) => {
- SocketFlags.create({ uid: 2 }, {
- type: 'post',
- id: pid,
- reason: 'foobar',
- }, (err) => {
- assert.ifError(err);
-
- Flags.exists('post', pid, 1, (err, exists) => {
- assert.ifError(err);
- assert(true);
- done();
- });
+ it('should create a flag with no errors', async () => {
+ await request({
+ method: 'post',
+ uri: `${nconf.get('url')}/api/v3/flags`,
+ jar,
+ headers: {
+ 'x-csrf-token': csrfToken,
+ },
+ body: {
+ type: 'post',
+ id: pid,
+ reason: 'foobar',
+ },
+ json: true,
});
+
+ const exists = await Flags.exists('post', pid, 2);
+ assert(exists);
});
it('should escape flag reason', async () => {
@@ -734,13 +757,22 @@ describe('Flags', () => {
content: 'This is flaggable content',
});
- const flagId = await SocketFlags.create({ uid: 2 }, {
- type: 'post',
- id: postData.pid,
- reason: '"',
+ const { response } = await request({
+ method: 'post',
+ uri: `${nconf.get('url')}/api/v3/flags`,
+ jar,
+ headers: {
+ 'x-csrf-token': csrfToken,
+ },
+ body: {
+ type: 'post',
+ id: postData.pid,
+ reason: '"',
+ },
+ json: true,
});
- const flagData = await Flags.get(flagId);
+ const flagData = await Flags.get(response.flagId);
assert.strictEqual(flagData.reports[0].value, '"<script>alert('ok');</script>');
});
@@ -755,50 +787,109 @@ describe('Flags', () => {
title: 'private topic',
content: 'private post',
});
- try {
- await SocketFlags.create({ uid: uid3 }, { type: 'post', id: result.postData.pid, reason: 'foobar' });
- } catch (err) {
- assert.equal(err.message, '[[error:no-privileges]]');
- }
+ const jar3 = await util.promisify(helpers.loginUser)('unprivileged', 'abcdef');
+ const config = await request({
+ url: `${nconf.get('url')}/api/config`,
+ json: true,
+ jar: jar3,
+ });
+ const csrfToken = config.csrf_token;
+ const { statusCode, body } = await request({
+ method: 'post',
+ uri: `${nconf.get('url')}/api/v3/flags`,
+ jar: jar3,
+ headers: {
+ 'x-csrf-token': csrfToken,
+ },
+ body: {
+ type: 'post',
+ id: result.postData.pid,
+ reason: 'foobar',
+ },
+ json: true,
+ simple: false,
+ resolveWithFullResponse: true,
+ });
+ assert.strictEqual(statusCode, 403);
+
+ // Handle dev mode test
+ delete body.stack;
+
+ assert.deepStrictEqual(body, {
+ status: {
+ code: 'forbidden',
+ message: 'You do not have enough privileges for this action.',
+ },
+ response: {},
+ });
});
});
describe('.update()', () => {
- it('should update a flag\'s properties', (done) => {
- SocketFlags.update({ uid: 2 }, {
- flagId: 2,
- data: [{
- name: 'state',
- value: 'wip',
- }],
- }, (err, history) => {
- assert.ifError(err);
- assert(Array.isArray(history));
- assert(history[0].fields.hasOwnProperty('state'));
- assert.strictEqual('[[flags:state-wip]]', history[0].fields.state);
- done();
+ it('should update a flag\'s properties', async () => {
+ const { response } = await request({
+ method: 'put',
+ uri: `${nconf.get('url')}/api/v3/flags/2`,
+ jar,
+ headers: {
+ 'x-csrf-token': csrfToken,
+ },
+ body: {
+ state: 'wip',
+ },
+ json: true,
});
+
+ const { history } = response;
+ assert(Array.isArray(history));
+ assert(history[0].fields.hasOwnProperty('state'));
+ assert.strictEqual('[[flags:state-wip]]', history[0].fields.state);
});
});
describe('.appendNote()', () => {
- it('should append a note to the flag', (done) => {
- SocketFlags.appendNote({ uid: 2 }, {
- flagId: 2,
- note: 'lorem ipsum dolor sit amet',
- }, (err, data) => {
- assert.ifError(err);
- assert(data.hasOwnProperty('notes'));
- assert(Array.isArray(data.notes));
- assert.strictEqual('lorem ipsum dolor sit amet', data.notes[0].content);
- assert.strictEqual(2, data.notes[0].uid);
-
- assert(data.hasOwnProperty('history'));
- assert(Array.isArray(data.history));
- assert.strictEqual(1, Object.keys(data.history[0].fields).length);
- assert(data.history[0].fields.hasOwnProperty('notes'));
- done();
+ it('should append a note to the flag', async () => {
+ const { response } = await request({
+ method: 'post',
+ uri: `${nconf.get('url')}/api/v3/flags/2/notes`,
+ jar,
+ headers: {
+ 'x-csrf-token': csrfToken,
+ },
+ body: {
+ note: 'lorem ipsum dolor sit amet',
+ datetime: 1626446956652,
+ },
+ json: true,
});
+
+ assert(response.hasOwnProperty('notes'));
+ assert(Array.isArray(response.notes));
+ assert.strictEqual('lorem ipsum dolor sit amet', response.notes[0].content);
+ assert.strictEqual(2, response.notes[0].uid);
+
+ assert(response.hasOwnProperty('history'));
+ assert(Array.isArray(response.history));
+ assert.strictEqual(1, Object.keys(response.history[response.history.length - 1].fields).length);
+ assert(response.history[response.history.length - 1].fields.hasOwnProperty('notes'));
+ });
+ });
+
+ describe('.deleteNote()', () => {
+ it('should delete a note from a flag', async () => {
+ const { response } = await request({
+ method: 'delete',
+ uri: `${nconf.get('url')}/api/v3/flags/2/notes/1626446956652`,
+ jar,
+ headers: {
+ 'x-csrf-token': csrfToken,
+ },
+ json: true,
+ });
+
+ assert(Array.isArray(response.history));
+ assert(Array.isArray(response.notes));
+ assert.strictEqual(response.notes.length, 0);
});
});
});