Compare commits

...

7 Commits

Author SHA1 Message Date
Julian Lam
0df3ea8661 refactor: client-side to use flag notes API 2021-07-16 13:51:37 -04:00
Julian Lam
b6c84222c2 test: fix breaking test on mongo 2021-07-16 12:29:39 -04:00
Julian Lam
6bcc0d0ddc chore: remove debug line 2021-07-16 11:33:23 -04:00
Julian Lam
e219cf0226 feat: flag notes API + tests 2021-07-16 11:28:52 -04:00
Julian Lam
b5da3f136b fix: flag tests to use Write API instead of sockets 2021-07-14 16:39:55 -04:00
Julian Lam
8a02c66ed5 feat: tests for new flags API
added missing files for schema update
2021-07-14 15:41:01 -04:00
Julian Lam
66946be9f0 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
2021-07-14 15:21:25 -04:00
23 changed files with 769 additions and 357 deletions

View File

@@ -85,6 +85,7 @@
"read_more": "read more", "read_more": "read more",
"more": "More", "more": "More",
"none": "None",
"posted_ago_by_guest": "posted %1 by Guest", "posted_ago_by_guest": "posted %1 by Guest",
"posted_ago_by": "posted %1 by %2", "posted_ago_by": "posted %1 by %2",

View File

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

View File

@@ -16,178 +16,9 @@ get:
application/json: application/json:
schema: schema:
allOf: allOf:
- $ref: ../../components/schemas/FlagObject.yaml#/FlagObject
- type: object - type: object
properties: 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_path:
type: string type: string
assignees: assignees:

View File

@@ -128,6 +128,14 @@ paths:
$ref: 'write/posts/pid/diffs/since.yaml' $ref: 'write/posts/pid/diffs/since.yaml'
/posts/{pid}/diffs/{timestamp}: /posts/{pid}/diffs/{timestamp}:
$ref: 'write/posts/pid/diffs/timestamp.yaml' $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}: /admin/settings/{setting}:
$ref: 'write/admin/settings/setting.yaml' $ref: 'write/admin/settings/setting.yaml'
/admin/analytics/{set}: /admin/analytics/{set}:

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
'use strict'; '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 = {}; var Detail = {};
Detail.init = function () { Detail.init = function () {
@@ -18,33 +18,43 @@ define('forum/flags/detail', ['forum/flags/list', 'components', 'translator', 'b
$('#assignee').val(app.user.uid); $('#assignee').val(app.user.uid);
// falls through // falls through
case 'update': case 'update': {
socket.emit('flags.update', { const data = $('#attributes').serializeArray().reduce((memo, cur) => {
flagId: ajaxify.data.flagId, memo[cur.name] = cur.value;
data: $('#attributes').serializeArray(), return memo;
}, function (err, history) { }, {});
if (err) {
return app.alertError(err.message); api.put(`/flags/${ajaxify.data.flagId}`, data).then((history) => {
}
app.alertSuccess('[[flags:updated]]'); app.alertSuccess('[[flags:updated]]');
Detail.reloadHistory(history); Detail.reloadHistory(history);
}); }).catch(app.alertError);
break; break;
}
case 'appendNote': case 'appendNote':
socket.emit('flags.appendNote', { // socket.emit('flags.appendNote', {
flagId: ajaxify.data.flagId, api.post(`/flags/${ajaxify.data.flagId}/notes`, {
note: noteEl.value, note: noteEl.value,
datetime: parseInt(noteEl.getAttribute('data-datetime'), 10), datetime: parseInt(noteEl.getAttribute('data-datetime'), 10),
}, function (err, payload) { }).then((payload) => {
if (err) {
return app.alertError(err.message);
}
app.alertSuccess('[[flags:note-added]]'); app.alertSuccess('[[flags:note-added]]');
Detail.reloadNotes(payload.notes); Detail.reloadNotes(payload.notes);
Detail.reloadHistory(payload.history); Detail.reloadHistory(payload.history);
noteEl.removeAttribute('data-datetime'); noteEl.removeAttribute('data-datetime');
}).catch(app.alertError);
break;
case 'delete-note':
var datetime = parseInt(this.closest('[data-datetime]').getAttribute('data-datetime'), 10);
bootbox.confirm('[[flags:delete-note-confirm]]', function (ok) {
if (ok) {
api.delete(`/flags/${ajaxify.data.flagId}/notes/${datetime}`, {}).then((payload) => {
app.alertSuccess('[[flags:note-deleted]]');
Detail.reloadNotes(payload.notes);
Detail.reloadHistory(payload.history);
}).catch(app.alertError);
}
}); });
break; break;
@@ -80,26 +90,6 @@ define('forum/flags/detail', ['forum/flags/list', 'components', 'translator', 'b
postAction('restore', ajaxify.data.target.pid, ajaxify.data.target.tid); postAction('restore', ajaxify.data.target.pid, ajaxify.data.target.tid);
break; break;
case 'delete-note':
var datetime = parseInt(this.closest('[data-datetime]').getAttribute('data-datetime'), 10);
bootbox.confirm('[[flags:delete-note-confirm]]', function (ok) {
if (ok) {
socket.emit('flags.deleteNote', {
flagId: ajaxify.data.flagId,
datetime: datetime,
}, function (err, payload) {
if (err) {
return app.alertError(err.message);
}
app.alertSuccess('[[flags:note-deleted]]');
Detail.reloadNotes(payload.notes);
Detail.reloadHistory(payload.history);
});
}
});
break;
case 'prepare-edit': case 'prepare-edit':
var selectedNoteEl = this.closest('[data-index]'); var selectedNoteEl = this.closest('[data-index]');
var index = selectedNoteEl.getAttribute('data-index'); var index = selectedNoteEl.getAttribute('data-index');

View File

@@ -1,6 +1,6 @@
'use strict'; '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 Flags = {};
var selectedCids; var selectedCids;
@@ -149,26 +149,14 @@ define('forum/flags/list', ['components', 'Chart', 'categoryFilter', 'autocomple
switch (action) { switch (action) {
case 'bulk-assign': case 'bulk-assign':
socket.emit('flags.update', { api.put(`/flags/${flagId}`, {
flagId: flagId, assignee: app.user.uid,
data: [
{
name: 'assignee',
value: app.user.uid,
},
],
}, handler); }, handler);
break; break;
case 'bulk-mark-resolved': case 'bulk-mark-resolved':
socket.emit('flags.update', { api.put(`/flags/${flagId}`, {
flagId: flagId, state: 'resolved',
data: [
{
name: 'state',
value: 'resolved',
},
],
}, handler); }, handler);
break; break;
} }

View File

@@ -1,7 +1,7 @@
'use strict'; 'use strict';
define('flags', ['hooks', 'components'], function (hooks, components) { define('flags', ['hooks', 'components', 'api'], function (hooks, components, api) {
var Flag = {}; var Flag = {};
var flagModal; var flagModal;
var flagCommit; var flagCommit;
@@ -59,18 +59,12 @@ define('flags', ['hooks', 'components'], function (hooks, components) {
}; };
Flag.resolve = function (flagId) { Flag.resolve = function (flagId) {
socket.emit('flags.update', { api.put(`/flags/${flagId}`, {
flagId: flagId, state: 'resolved',
data: [ }).then(() => {
{ name: 'state', value: 'resolved' },
],
}, function (err) {
if (err) {
return app.alertError(err.message);
}
app.alertSuccess('[[flags:resolved]]'); app.alertSuccess('[[flags:resolved]]');
hooks.fire('action:flag.resolved', { flagId: flagId }); hooks.fire('action:flag.resolved', { flagId: flagId });
}); }).catch(app.alertError);
}; };
function createFlag(type, id, reason) { function createFlag(type, id, reason) {
@@ -78,7 +72,7 @@ define('flags', ['hooks', 'components'], function (hooks, components) {
return; return;
} }
var data = { type: type, id: id, reason: reason }; 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) { if (err) {
return app.alertError(err.message); return app.alertError(err.message);
} }

80
src/api/flags.js Normal file
View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ Write.groups = require('./groups');
Write.categories = require('./categories'); Write.categories = require('./categories');
Write.topics = require('./topics'); Write.topics = require('./topics');
Write.posts = require('./posts'); Write.posts = require('./posts');
Write.flags = require('./flags');
Write.admin = require('./admin'); Write.admin = require('./admin');
Write.files = require('./files'); Write.files = require('./files');
Write.utilities = require('./utilities'); Write.utilities = require('./utilities');

View File

@@ -102,7 +102,6 @@ Flags.get = async function (flagId) {
if (!base) { if (!base) {
return; return;
} }
const flagObj = { const flagObj = {
state: 'open', state: 'open',
assignee: null, assignee: null,
@@ -314,6 +313,11 @@ Flags.getNotes = async function (flagId) {
}; };
Flags.getNote = async function (flagId, datetime) { 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); let notes = await db.getSortedSetRangeByScoreWithScores(`flag:${flagId}:notes`, 0, 1, datetime, datetime);
if (!notes.length) { if (!notes.length) {
throw new Error('[[error:invalid-data]]'); throw new Error('[[error:invalid-data]]');
@@ -362,6 +366,11 @@ async function modifyNotes(notes) {
} }
Flags.deleteNote = async function (flagId, datetime) { 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); const note = await db.getSortedSetRangeByScore(`flag:${flagId}:notes`, 0, 1, datetime, datetime);
if (!note.length) { if (!note.length) {
throw new Error('[[error:invalid-data]]'); throw new Error('[[error:invalid-data]]');
@@ -610,8 +619,10 @@ Flags.update = async function (flagId, uid, changeset) {
} }
} }
} else if (prop === 'assignee') { } else if (prop === 'assignee') {
if (changeset[prop] === '') {
tasks.push(db.sortedSetRemove(`flags:byAssignee:${changeset[prop]}`, flagId));
/* eslint-disable-next-line */ /* eslint-disable-next-line */
if (!await isAssignable(parseInt(changeset[prop], 10))) { } else if (!await isAssignable(parseInt(changeset[prop], 10))) {
delete changeset[prop]; delete changeset[prop];
} else { } else {
tasks.push(db.sortedSetAdd(`flags:byAssignee:${changeset[prop]}`, now, flagId)); 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) { Flags.appendNote = async function (flagId, uid, note, datetime) {
if (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(); datetime = datetime || Date.now();

View File

@@ -8,6 +8,7 @@
const path = require('path'); const path = require('path');
const nconf = require('nconf'); const nconf = require('nconf');
const db = require('../database');
const file = require('../file'); const file = require('../file');
const user = require('../user'); const user = require('../user');
const groups = require('../groups'); const groups = require('../groups');
@@ -52,6 +53,14 @@ Assert.post = helpers.try(async (req, res, next) => {
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) => { Assert.path = helpers.try(async (req, res, next) => {
// file: URL support // file: URL support
if (req.body.path.startsWith('file:///')) { if (req.body.path.startsWith('file:///')) {

23
src/routes/write/flags.js Normal file
View 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;
};

View File

@@ -37,6 +37,7 @@ Write.reload = async (params) => {
router.use('/api/v3/categories', require('./categories')()); router.use('/api/v3/categories', require('./categories')());
router.use('/api/v3/topics', require('./topics')()); router.use('/api/v3/topics', require('./topics')());
router.use('/api/v3/posts', require('./posts')()); 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/admin', require('./admin')());
router.use('/api/v3/files', require('./files')()); router.use('/api/v3/files', require('./files')());
router.use('/api/v3/utilities', require('./utilities')()); router.use('/api/v3/utilities', require('./utilities')());

View File

@@ -40,11 +40,11 @@ function authenticatedRoutes() {
setupApiRoute(router, 'delete', '/:uid/sessions/:uuid', [...middlewares, middleware.assert.user], controllers.write.users.revokeSession); 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, 'post', '/:uid/invites', middlewares, controllers.write.users.invite);
setupApiRoute(router, 'get', '/:uid/invites/groups', [...middlewares, middleware.assert.user], controllers.write.users.getInviteGroups); 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 () { module.exports = function () {

View File

@@ -1,88 +1,52 @@
'use strict'; 'use strict';
const user = require('../user'); const sockets = require('.');
const flags = require('../flags'); const api = require('../api');
const SocketFlags = module.exports; const SocketFlags = module.exports;
SocketFlags.create = async function (socket, data) { SocketFlags.create = async function (socket, data) {
if (!socket.uid) { sockets.warnDeprecated(socket, 'POST /api/v3/flags');
throw new Error('[[error:not-logged-in]]'); 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) { 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]]'); throw new Error('[[error:invalid-data]]');
} }
const allowed = await user.isPrivileged(socket.uid); // Old socket method took input directly from .serializeArray(), v3 expects fully-formed obj.
if (!allowed) { let payload = {
throw new Error('[[no-privileges]]'); flagId: data.flagId,
} };
let payload = {};
// Translate form data into object
payload = data.data.reduce((memo, cur) => { payload = data.data.reduce((memo, cur) => {
memo[cur.name] = cur.value; memo[cur.name] = cur.value;
return memo; return memo;
}, payload); }, payload);
await flags.update(data.flagId, socket.uid, payload); return await api.flags.update(socket, payload);
return await flags.getHistory(data.flagId);
}; };
SocketFlags.appendNote = async function (socket, data) { SocketFlags.appendNote = async function (socket, data) {
sockets.warnDeprecated(socket, 'POST /api/v3/flags/:flagId/notes');
if (!data || !(data.flagId && data.note)) { if (!data || !(data.flagId && data.note)) {
throw new Error('[[error:invalid-data]]'); throw new Error('[[error:invalid-data]]');
} }
const allowed = await user.isPrivileged(socket.uid);
if (!allowed) { return await api.flags.appendNote(socket, data);
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 };
}; };
SocketFlags.deleteNote = async function (socket, data) { SocketFlags.deleteNote = async function (socket, data) {
sockets.warnDeprecated(socket, 'DELETE /api/v3/flags/:flagId/notes/:datetime');
if (!data || !(data.flagId && data.datetime)) { if (!data || !(data.flagId && data.datetime)) {
throw new Error('[[error:invalid-data]]'); throw new Error('[[error:invalid-data]]');
} }
const note = await flags.getNote(data.flagId, data.datetime); return await api.flags.deleteNote(socket, data);
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 };
}; };
require('../promisify')(SocketFlags); require('../promisify')(SocketFlags);

View File

@@ -167,7 +167,8 @@ describe('API', async () => {
mocks.delete['/posts/{pid}/diffs/{timestamp}'][1].example = (await posts.diffs.list(unprivTopic.postData.pid))[0]; mocks.delete['/posts/{pid}/diffs/{timestamp}'][1].example = (await posts.diffs.list(unprivTopic.postData.pid))[0];
// Create a sample flag // 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 // Create a new chat room
await messaging.newRoom(1, [2]); 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); generateTests(writeApi, Object.keys(writeApi.paths), writeApi.servers[0].url);
function generateTests(api, paths, prefix) { function generateTests(api, paths, prefix) {
@@ -384,7 +385,6 @@ describe('API', async () => {
try { try {
if (type === 'json') { if (type === 'json') {
// console.log(`calling ${method} ${url} with`, body);
response = await request(url, { response = await request(url, {
method: method, method: method,
jar: !unauthenticatedRoutes.includes(path) ? jar : undefined, jar: !unauthenticatedRoutes.includes(path) ? jar : undefined,

View File

@@ -1,12 +1,16 @@
'use strict'; 'use strict';
const assert = require('assert'); const assert = require('assert');
const nconf = require('nconf');
const async = require('async'); const async = require('async');
const request = require('request-promise-native');
const util = require('util'); const util = require('util');
const sleep = util.promisify(setTimeout); const sleep = util.promisify(setTimeout);
const db = require('./mocks/databasemock'); const db = require('./mocks/databasemock');
const helpers = require('./helpers');
const Flags = require('../src/flags'); const Flags = require('../src/flags');
const Categories = require('../src/categories'); const Categories = require('../src/categories');
const Topics = require('../src/topics'); const Topics = require('../src/topics');
@@ -619,6 +623,12 @@ describe('Flags', () => {
done(); 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()', () => { describe('.getNotes()', () => {
@@ -693,38 +703,51 @@ describe('Flags', () => {
}); });
}); });
describe('(websockets)', () => { describe('(v3 API)', () => {
const SocketFlags = require('../src/socket.io/flags'); const SocketFlags = require('../src/socket.io/flags');
let pid; let pid;
let tid; let tid;
before((done) => { let jar;
Topics.post({ 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, cid: 1,
uid: 1, uid: 1,
title: 'Another topic', title: 'Another topic',
content: 'This is flaggable content', 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()', () => { describe('.create()', () => {
it('should create a flag with no errors', (done) => { it('should create a flag with no errors', async () => {
SocketFlags.create({ uid: 2 }, { await request({
type: 'post', method: 'post',
id: pid, uri: `${nconf.get('url')}/api/v3/flags`,
reason: 'foobar', jar,
}, (err) => { headers: {
assert.ifError(err); 'x-csrf-token': csrfToken,
},
Flags.exists('post', pid, 1, (err, exists) => { body: {
assert.ifError(err); type: 'post',
assert(true); id: pid,
done(); reason: 'foobar',
}); },
json: true,
}); });
const exists = await Flags.exists('post', pid, 2);
assert(exists);
}); });
it('should escape flag reason', async () => { it('should escape flag reason', async () => {
@@ -734,13 +757,22 @@ describe('Flags', () => {
content: 'This is flaggable content', content: 'This is flaggable content',
}); });
const flagId = await SocketFlags.create({ uid: 2 }, { const { response } = await request({
type: 'post', method: 'post',
id: postData.pid, uri: `${nconf.get('url')}/api/v3/flags`,
reason: '"<script>alert(\'ok\');</script>', 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;'); 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', title: 'private topic',
content: 'private post', content: 'private post',
}); });
try { const jar3 = await util.promisify(helpers.loginUser)('unprivileged', 'abcdef');
await SocketFlags.create({ uid: uid3 }, { type: 'post', id: result.postData.pid, reason: 'foobar' }); const config = await request({
} catch (err) { url: `${nconf.get('url')}/api/config`,
assert.equal(err.message, '[[error:no-privileges]]'); 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()', () => { describe('.update()', () => {
it('should update a flag\'s properties', (done) => { it('should update a flag\'s properties', async () => {
SocketFlags.update({ uid: 2 }, { const { response } = await request({
flagId: 2, method: 'put',
data: [{ uri: `${nconf.get('url')}/api/v3/flags/2`,
name: 'state', jar,
value: 'wip', headers: {
}], 'x-csrf-token': csrfToken,
}, (err, history) => { },
assert.ifError(err); body: {
assert(Array.isArray(history)); state: 'wip',
assert(history[0].fields.hasOwnProperty('state')); },
assert.strictEqual('[[flags:state-wip]]', history[0].fields.state); json: true,
done();
}); });
const { history } = response;
assert(Array.isArray(history));
assert(history[0].fields.hasOwnProperty('state'));
assert.strictEqual('[[flags:state-wip]]', history[0].fields.state);
}); });
}); });
describe('.appendNote()', () => { describe('.appendNote()', () => {
it('should append a note to the flag', (done) => { it('should append a note to the flag', async () => {
SocketFlags.appendNote({ uid: 2 }, { const { response } = await request({
flagId: 2, method: 'post',
note: 'lorem ipsum dolor sit amet', uri: `${nconf.get('url')}/api/v3/flags/2/notes`,
}, (err, data) => { jar,
assert.ifError(err); headers: {
assert(data.hasOwnProperty('notes')); 'x-csrf-token': csrfToken,
assert(Array.isArray(data.notes)); },
assert.strictEqual('lorem ipsum dolor sit amet', data.notes[0].content); body: {
assert.strictEqual(2, data.notes[0].uid); note: 'lorem ipsum dolor sit amet',
datetime: 1626446956652,
assert(data.hasOwnProperty('history')); },
assert(Array.isArray(data.history)); json: true,
assert.strictEqual(1, Object.keys(data.history[0].fields).length);
assert(data.history[0].fields.hasOwnProperty('notes'));
done();
}); });
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);
}); });
}); });
}); });