* feat: new routes for flags API

+ flag get
+ flag creation, migration from socket method
+ flag update, migration from socket method
* fixed bug where you could not unassign someone from a flag

* feat: tests for new flags API

added missing files for schema update

* fix: flag tests to use Write API instead of sockets

* feat: flag notes API + tests

* chore: remove debug line

* test: fix breaking test on mongo
v1.18.x
Julian Lam 4 years ago committed by GitHub
parent 71bc258731
commit cc6cbfcdc4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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",

@ -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"

@ -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:

@ -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}:

@ -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

@ -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

@ -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

@ -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

@ -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', {

@ -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;
}

@ -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);
}

@ -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 };
};

@ -6,4 +6,5 @@ module.exports = {
topics: require('./topics'),
posts: require('./posts'),
categories: require('./categories'),
flags: require('./flags'),
};

@ -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);
};

@ -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');

@ -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();

@ -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:///')) {

@ -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;
};

@ -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')());

@ -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 () {

@ -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);

@ -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,

@ -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: '"<script>alert(\'ok\');</script>',
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: '"<script>alert(\'ok\');</script>',
},
json: true,
});
const flagData = await Flags.get(flagId);
const flagData = await Flags.get(response.flagId);
assert.strictEqual(flagData.reports[0].value, '&quot;&lt;script&gt;alert(&#x27;ok&#x27;);&lt;&#x2F;script&gt;');
});
@ -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);
});
});
});

Loading…
Cancel
Save