openvibers/openvibe.community Commits
A picture alone is a comment (#412)
Changes
@@@ -15,10 +15,11 @@ function parse(req, res, next) {async function bodyFor(comments, viewer, ref, req) {const body = req.body || {};const message = String(body.message || '').trim();-- if (!message || message.length > 2000) throw new ApiError(400, 'comment.message_invalid', 'A comment is 1 to 2,000 characters');++ const files = req.files || [];++ // A picture alone is a comment, as on any social app (the owner, 11-12): words are optional when a file is attached.++ if ((!message && !files.length) || message.length > 2000) throw new ApiError(400, 'comment.message_invalid', 'Write something or attach a picture (up to 2,000 characters)');const parent = String(body.parent_id || '');if (parent && !/^\d{1,15}$/.test(parent)) throw new ApiError(400, 'comment.parent_invalid', 'That reply target is not valid');-- const files = req.files || [];const attachments = [];for (const file of files) {if (!TYPES.has(file.mimetype)) throw new ApiError(415, 'comment.file_type', 'Attach JPEG, PNG, GIF, WebP, PDF or text files');@@@ -204,9 +204,10 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =return null;}-- function cleanMessage(raw) {++ /** A comment's words. Empty only with files attached (a picture alone is a comment, 11-12). */++ function cleanMessage(raw, { allowEmpty = false } = {}) {const message = String(raw == null ? '' : raw).replace(/\u0000/g, '').trim();-- if (!message) fail(400, 'comment.empty', 'Comment cannot be empty');++ if (!message && !allowEmpty) fail(400, 'comment.empty', 'Write something or attach a picture');if (message.length > MAX_MESSAGE) fail(400, 'comment.too_long', `Comment must be under ${MAX_MESSAGE} characters`);return message;}@@@ -328,7 +329,8 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =async add(v, id, body = {}) {const t = await visibleThread(v, id);if (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');-- const message = cleanMessage(body.message);++ const rawAttachments = body.attachments == null ? [] : (Array.isArray(body.attachments) ? body.attachments : [body.attachments]);++ const message = cleanMessage(body.message, { allowEmpty: rawAttachments.length > 0 });let parent = null;let parentId = null;@@@ -354,8 +356,9 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =anonName = String(body.anon_name || '').trim().slice(0, 32).replace(/[^a-zA-Z0-9 _-]/g, '').trim() || 'Anonymous';}const key = author ? `s:${author}` : null;-- commentLimiter.check(key, message);-- const rawAttachments = body.attachments == null ? [] : (Array.isArray(body.attachments) ? body.attachments : [body.attachments]);++ // The repeat check reads a picture-only comment by its files, so two different pictures are not "the same comment".++ const said = message || `files:${JSON.stringify(rawAttachments).slice(0, 400)}`;++ commentLimiter.check(key, said);if (rawAttachments.length > 4) fail(400, 'comment.attachments_too_many', 'At most four files can be attached to a comment');if (rawAttachments.length && !author) fail(401, 'auth.required', 'Sign in to attach a file');let attachmentIds = [];@@@ -377,7 +380,7 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =const forumItem = forumLinks.BOARDS[`${t.ref_service}/${t.ref_type}`] ? await forumLinks.publicExternalItem(t, fetchImpl) : null;const c = await store.insertComment(db, { thread_id: t.id, parent_id: parentId, reply_to_id: parent ? parent.id : null,author_subject: author, anon_name: anonName, origin, message, attachment_ids: attachmentIds, forum_item: forumItem });-- commentLimiter.record(key, message);++ commentLimiter.record(key, said);return { comment: (await shapeComments([c], v, t))[0] };},@@@ -229,6 +229,21 @@ const { checkCapability } = require('../server/identity/capabilities');assert.deepStrictEqual(after.comments, []);assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM comment_attachments WHERE media_id = ?').get(attachment.id)).n, 0);assert.ok(t.media.deletes.includes(attachment.id), 'removing the comment retires its Community Media object');++++ // A picture alone is a comment (the owner, 11-12: "it shouldn't require you to type text if you just put a++ // picture"); with neither words nor a file it is still refused.++ const form2 = new FormData();++ form2.set('ref', JSON.stringify(ref));++ form2.set('file', new Blob([Buffer.from('second picture')], { type: 'image/png' }), 'only.png');++ const up2 = (await t.get('/api/v1/comments/attachments', { method: 'POST', headers: { authorization: `Bearer ${fileOwnerJwt}` }, body: form2 })).json().attachment;++ const quiet = await post(thread.id, { message: '', attachments: [up2] }, { cookie: fileOwnerJwt });++ assert.strictEqual(quiet.status, 201, quiet.text);++ assert.deepStrictEqual([quiet.json().comment.message, quiet.json().comment.attachments.map((a) => a.id)], ['', [up2.id]], 'a picture with no caption');++ const page2 = await t.get(`/c/${thread.id}`);++ assert.match(page2.text, new RegExp(`<img src="https://openvibe\\.media/o/${up2.id}"`), 'and it shows');++ const bare = await post(thread.id, { message: ' ' }, { cookie: fileOwnerJwt });++ assert.strictEqual(bare.status, 400);++ assert.strictEqual(bare.json().code, 'comment.empty');});await check('section reactions: one thread per post, the section id is the key; people only', async () => {@@@ -545,7 +560,7 @@ const { checkCapability } = require('../server/identity/capabilities');const form = (body, who) => t.get(`/c/${liveVod.access_id}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': ip2(), ...(who.origin ? { origin: who.origin } : {}) }, body, cookies: who.cookie ? [`ov_token=${who.cookie}`] : [] });const empty = await form('message=+', { cookie: samJwt, origin: 'https://openvibe.community' });assert.strictEqual(empty.status, 400);-- assert.match(empty.text, /A comment is 1 to 2,000 characters/, 'the page explains the error');++ assert.match(empty.text, /Write something or attach a picture/, 'the page explains the error');const made = await form('message=said+on+Community', { cookie: samJwt });assert.strictEqual(made.status, 303, made.text.slice(0, 200));assert.match(made.headers.get('location'), new RegExp(`^/c/${liveVod.access_id}#comment-\\d+$`));
@@ -15,10 +15,11 @@ function parse(req, res, next) {
@@ -15,10 +15,11 @@ function parse(req, res, next) {
async function bodyFor(comments, viewer, ref, req) {
async function bodyFor(comments, viewer, ref, req) {
const body = req.body || {};
const body = req.body || {};
const message = String(body.message || '').trim();
const message = String(body.message || '').trim();
- if (!message || message.length > 2000) throw new ApiError(400, 'comment.message_invalid', 'A comment is 1 to 2,000 characters');
+ const files = req.files || [];
+ // A picture alone is a comment, as on any social app (the owner, 11-12): words are optional when a file is attached.
+ if ((!message && !files.length) || message.length > 2000) throw new ApiError(400, 'comment.message_invalid', 'Write something or attach a picture (up to 2,000 characters)');
const parent = String(body.parent_id || '');
const parent = String(body.parent_id || '');
if (parent && !/^\d{1,15}$/.test(parent)) throw new ApiError(400, 'comment.parent_invalid', 'That reply target is not valid');
if (parent && !/^\d{1,15}$/.test(parent)) throw new ApiError(400, 'comment.parent_invalid', 'That reply target is not valid');
- const files = req.files || [];
const attachments = [];
const attachments = [];
for (const file of files) {
for (const file of files) {
if (!TYPES.has(file.mimetype)) throw new ApiError(415, 'comment.file_type', 'Attach JPEG, PNG, GIF, WebP, PDF or text files');
if (!TYPES.has(file.mimetype)) throw new ApiError(415, 'comment.file_type', 'Attach JPEG, PNG, GIF, WebP, PDF or text files');
@@ -204,9 +204,10 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -204,9 +204,10 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
return null;
return null;
}
}
- function cleanMessage(raw) {
+ /** A comment's words. Empty only with files attached (a picture alone is a comment, 11-12). */
+ function cleanMessage(raw, { allowEmpty = false } = {}) {
const message = String(raw == null ? '' : raw).replace(/\u0000/g, '').trim();
const message = String(raw == null ? '' : raw).replace(/\u0000/g, '').trim();
- if (!message) fail(400, 'comment.empty', 'Comment cannot be empty');
+ if (!message && !allowEmpty) fail(400, 'comment.empty', 'Write something or attach a picture');
if (message.length > MAX_MESSAGE) fail(400, 'comment.too_long', `Comment must be under ${MAX_MESSAGE} characters`);
if (message.length > MAX_MESSAGE) fail(400, 'comment.too_long', `Comment must be under ${MAX_MESSAGE} characters`);
return message;
return message;
}
}
@@ -328,7 +329,8 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -328,7 +329,8 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
async add(v, id, body = {}) {
async add(v, id, body = {}) {
const t = await visibleThread(v, id);
const t = await visibleThread(v, id);
if (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');
if (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');
- const message = cleanMessage(body.message);
+ const rawAttachments = body.attachments == null ? [] : (Array.isArray(body.attachments) ? body.attachments : [body.attachments]);
+ const message = cleanMessage(body.message, { allowEmpty: rawAttachments.length > 0 });
let parent = null;
let parent = null;
let parentId = null;
let parentId = null;
@@ -354,8 +356,9 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -354,8 +356,9 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
anonName = String(body.anon_name || '').trim().slice(0, 32).replace(/[^a-zA-Z0-9 _-]/g, '').trim() || 'Anonymous';
anonName = String(body.anon_name || '').trim().slice(0, 32).replace(/[^a-zA-Z0-9 _-]/g, '').trim() || 'Anonymous';
}
}
const key = author ? `s:${author}` : null;
const key = author ? `s:${author}` : null;
- commentLimiter.check(key, message);
- const rawAttachments = body.attachments == null ? [] : (Array.isArray(body.attachments) ? body.attachments : [body.attachments]);
+ // The repeat check reads a picture-only comment by its files, so two different pictures are not "the same comment".
+ const said = message || `files:${JSON.stringify(rawAttachments).slice(0, 400)}`;
+ commentLimiter.check(key, said);
if (rawAttachments.length > 4) fail(400, 'comment.attachments_too_many', 'At most four files can be attached to a comment');
if (rawAttachments.length > 4) fail(400, 'comment.attachments_too_many', 'At most four files can be attached to a comment');
if (rawAttachments.length && !author) fail(401, 'auth.required', 'Sign in to attach a file');
if (rawAttachments.length && !author) fail(401, 'auth.required', 'Sign in to attach a file');
let attachmentIds = [];
let attachmentIds = [];
@@ -377,7 +380,7 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -377,7 +380,7 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
const forumItem = forumLinks.BOARDS[`${t.ref_service}/${t.ref_type}`] ? await forumLinks.publicExternalItem(t, fetchImpl) : null;
const forumItem = forumLinks.BOARDS[`${t.ref_service}/${t.ref_type}`] ? await forumLinks.publicExternalItem(t, fetchImpl) : null;
const c = await store.insertComment(db, { thread_id: t.id, parent_id: parentId, reply_to_id: parent ? parent.id : null,
const c = await store.insertComment(db, { thread_id: t.id, parent_id: parentId, reply_to_id: parent ? parent.id : null,
author_subject: author, anon_name: anonName, origin, message, attachment_ids: attachmentIds, forum_item: forumItem });
author_subject: author, anon_name: anonName, origin, message, attachment_ids: attachmentIds, forum_item: forumItem });
- commentLimiter.record(key, message);
+ commentLimiter.record(key, said);
return { comment: (await shapeComments([c], v, t))[0] };
return { comment: (await shapeComments([c], v, t))[0] };
},
},
@@ -229,6 +229,21 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -229,6 +229,21 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.deepStrictEqual(after.comments, []);
assert.deepStrictEqual(after.comments, []);
assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM comment_attachments WHERE media_id = ?').get(attachment.id)).n, 0);
assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM comment_attachments WHERE media_id = ?').get(attachment.id)).n, 0);
assert.ok(t.media.deletes.includes(attachment.id), 'removing the comment retires its Community Media object');
assert.ok(t.media.deletes.includes(attachment.id), 'removing the comment retires its Community Media object');
+
+ // A picture alone is a comment (the owner, 11-12: "it shouldn't require you to type text if you just put a
+ // picture"); with neither words nor a file it is still refused.
+ const form2 = new FormData();
+ form2.set('ref', JSON.stringify(ref));
+ form2.set('file', new Blob([Buffer.from('second picture')], { type: 'image/png' }), 'only.png');
+ const up2 = (await t.get('/api/v1/comments/attachments', { method: 'POST', headers: { authorization: `Bearer ${fileOwnerJwt}` }, body: form2 })).json().attachment;
+ const quiet = await post(thread.id, { message: '', attachments: [up2] }, { cookie: fileOwnerJwt });
+ assert.strictEqual(quiet.status, 201, quiet.text);
+ assert.deepStrictEqual([quiet.json().comment.message, quiet.json().comment.attachments.map((a) => a.id)], ['', [up2.id]], 'a picture with no caption');
+ const page2 = await t.get(`/c/${thread.id}`);
+ assert.match(page2.text, new RegExp(`<img src="https://openvibe\\.media/o/${up2.id}"`), 'and it shows');
+ const bare = await post(thread.id, { message: ' ' }, { cookie: fileOwnerJwt });
+ assert.strictEqual(bare.status, 400);
+ assert.strictEqual(bare.json().code, 'comment.empty');
});
});
await check('section reactions: one thread per post, the section id is the key; people only', async () => {
await check('section reactions: one thread per post, the section id is the key; people only', async () => {
@@ -545,7 +560,7 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -545,7 +560,7 @@ const { checkCapability } = require('../server/identity/capabilities');
const form = (body, who) => t.get(`/c/${liveVod.access_id}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': ip2(), ...(who.origin ? { origin: who.origin } : {}) }, body, cookies: who.cookie ? [`ov_token=${who.cookie}`] : [] });
const form = (body, who) => t.get(`/c/${liveVod.access_id}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': ip2(), ...(who.origin ? { origin: who.origin } : {}) }, body, cookies: who.cookie ? [`ov_token=${who.cookie}`] : [] });
const empty = await form('message=+', { cookie: samJwt, origin: 'https://openvibe.community' });
const empty = await form('message=+', { cookie: samJwt, origin: 'https://openvibe.community' });
assert.strictEqual(empty.status, 400);
assert.strictEqual(empty.status, 400);
- assert.match(empty.text, /A comment is 1 to 2,000 characters/, 'the page explains the error');
+ assert.match(empty.text, /Write something or attach a picture/, 'the page explains the error');
const made = await form('message=said+on+Community', { cookie: samJwt });
const made = await form('message=said+on+Community', { cookie: samJwt });
assert.strictEqual(made.status, 303, made.text.slice(0, 200));
assert.strictEqual(made.status, 303, made.text.slice(0, 200));
assert.match(made.headers.get('location'), new RegExp(`^/c/${liveVod.access_id}#comment-\\d+$`));
assert.match(made.headers.get('location'), new RegExp(`^/c/${liveVod.access_id}#comment-\\d+$`));