openvibers/openvibe.community Commits
A picture alone is a post (#413)
Changes
@@@ -133,12 +133,14 @@ function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '', meconst body = req.body || {};if (Object.keys(body).some((k) => !['text', 'pictures', 'attachments'].includes(k))) fail(400, 'post.invalid', 'Only text, pictures and attachments are accepted');const text = typeof body.text === 'string' ? body.text.trim() : '';-- if (!text) fail(400, 'post.invalid', 'Write something first');++ const hasFiles = (Array.isArray(body.pictures) && body.pictures.length > 0) || (Array.isArray(body.attachments) && body.attachments.length > 0);++ // A picture alone is a post, as on any social app (the owner, 11-12); with nothing at all it is not.++ if (!text && !hasFiles) fail(400, 'post.invalid', 'Write something or add a picture');if (text.length > 2000) fail(400, 'post.invalid', 'Text is at most 2000 characters');const pictures = body.pictures == null ? [] : body.pictures;if (!Array.isArray(pictures) || pictures.length > 4) fail(400, 'post.invalid_pictures', 'pictures is an array of at most four { media_id, alt }');for (const p of pictures) {-- if (!p || typeof p.media_id !== 'string' || typeof p.alt !== 'string') fail(400, 'post.invalid_pictures', 'Every picture is { media_id, alt }');++ if (!p || typeof p.media_id !== 'string' || (p.alt != null && typeof p.alt !== 'string')) fail(400, 'post.invalid_pictures', 'Every picture is { media_id, alt? }');}const fileIds = body.attachments == null ? [] : body.attachments;if (!Array.isArray(fileIds) || fileIds.length > 10 || fileIds.some((id) => !/^att_[0-9A-HJKMNP-TV-Z]{26}$/.test(id)) || new Set(fileIds).size !== fileIds.length) {@@@ -164,8 +166,10 @@ function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '', me}files.push({ id, name: String(f.name || 'File').slice(0, 255), mime: String(f.mime || 'application/octet-stream').slice(0, 120), size: Number(f.size), url: url.href });}-- const title = `Status: ${text.replace(/https?:\/\/[^\s<>]+/gi, '').trim().slice(0, 110) || 'Shared a picture'}`;-- const { thread, post } = await forum.createThread(v, 'status', { title, body: text, pictures: pictures.map((p) => ({ media_id: p.media_id, alt: p.alt })) });++ // A picture-only post is named by what it shows (its alt text), so two of them are not "the same thread".++ const firstAlt = String((pictures[0] && pictures[0].alt) || '').trim();++ const title = `Status: ${text.replace(/https?:\/\/[^\s<>]+/gi, '').trim().slice(0, 110) || firstAlt.slice(0, 110) || `Shared a picture · ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`}`;++ const { thread, post } = await forum.createThread(v, 'status', { title, body: text, pictures: pictures.map((p) => ({ media_id: p.media_id, alt: String(p.alt || '') })) });if (files.length) await forum.attachComposerFiles(v, post.id, files);if (pulse) {await pulse.forgetLocal('thread', thread.id);@@@ -247,9 +247,10 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi}const cleanTitle = (t) => String(t == null ? '' : t).replace(/\u0000/g, '').replace(/\s+/g, ' ').trim();-- function cleanBody(b) {++ /** A post's words. Empty only beside a picture or a file (a picture alone is a post, as on any social app, 11-12). */++ function cleanBody(b, { allowEmpty = false } = {}) {const body = String(b == null ? '' : b).replace(/\u0000/g, '').replace(/\r\n?/g, '\n').replace(/\s+$/, '');-- if (!body.trim()) fail(400, 'post.empty', 'Write something first');++ if (!body.trim()) { if (allowEmpty) return ''; fail(400, 'post.empty', 'Write something or add a picture'); }if (body.length > BODY_MAX) fail(400, 'post.too_long', `Posts are limited to ${BODY_MAX.toLocaleString('en-US')} characters`);return body;}@@@ -307,7 +308,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vireturn rows;}/**-- * The pictures a post names: [{ media_id, alt }], alt required, at most four. An upload made through++ * The pictures a post names: [{ media_id, alt }], at most four. The alt text is the composer's (written by the person++ * or by AI when they leave it, 11-12); a picture without one is still posted, its alt left empty. An upload made through* /api/v1/spaces/:space/attachments is already an attachments row; a direct Media upload (the SDK's* posts.create path) is checked with Media's object API, where the person owns the image, and remembered* here with the alt text. → rows ready for claimable()@@@ -322,7 +324,6 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst mediaId = String((p && p.media_id) || '');const alt = String((p && p.alt) == null ? '' : p.alt).replace(/\s+/g, ' ').trim();if (!MED_ID.test(mediaId)) fail(400, 'pictures.invalid', 'Every picture needs a Media media_id');-- if (!alt) fail(400, 'pictures.alt_required', 'Every picture needs alt text');if (alt.length > 300) fail(400, 'pictures.alt_too_long', 'Alt text is at most 300 characters');let row = await db.prepare('SELECT * FROM attachments WHERE media_id = ?').get(mediaId);if (row) {@@@ -1024,7 +1025,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);const labels = body.labels === undefined || body.labels === null ? null : contentLabels.parseLabels(body.labels);if (body.labels !== undefined && body.labels !== null && labels === null) fail(400, 'thread.labels_invalid', `Labels are from this list: ${contentLabels.LABELS.join(', ')}`);-- const text = cleanBody(body.body != null ? body.body : body.body_markdown);++ const withFiles = [body.pictures, body.attachments].some((x) => (Array.isArray(x) ? x.length > 0 : x != null));++ const text = cleanBody(body.body != null ? body.body : body.body_markdown, { allowEmpty: withFiles });const pictureRows = await preparePictures(w, body.pictures);const listed = Array.isArray(body.attachments) ? body.attachments : (body.attachments == null ? [] : [body.attachments]);const images = await claimable(w, [...listed, ...pictureRows.map((x) => x.media_id)]);@@@ -129,6 +129,18 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',assert.ok(!html.includes('enctype="multipart/form-data"'), 'the form no longer posts files itself');});++ await check('a picture alone is a post (11-12); nothing at all is not', async () => {++ const up = await upload(alexJwt, PNG, 'only.png');++ const pic = up.json().attachment;++ let r = await json(alexJwt, '/api/v1/spaces/general/threads', { title: 'Just a picture', body: '', attachments: [pic.media_id] });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().post.body_markdown || '', '', 'no words');++ assert.deepStrictEqual(r.json().post.attachments.map((a) => a.media_id), [pic.media_id]);++ r = await json(alexJwt, '/api/v1/spaces/general/threads', { title: 'Nothing', body: ' ' });++ assert.strictEqual(r.status, 400);++ assert.strictEqual(r.json().code, 'post.empty');++ });++await check('the reply form names images the shared uploader already stored', async () => {const a = await upload(samJwt, PNG, 'a.png');const b = await upload(samJwt, PNG, 'b.png');@@@ -174,7 +174,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toStringassert.match(reply.headers.get('location'), new RegExp(`^${url}#post-\\d+$`));const empty = await t.get(`${url}/reply`, as(samJwt, form({ body: ' ' })));assert.strictEqual(empty.status, 400);-- has(empty.text, 'Write something first');++ has(empty.text, 'Write something or add a picture');const vote = await t.get(`${url}/vote`, as(samJwt, form({ value: '1' })));assert.strictEqual(vote.status, 303);assert.strictEqual(vote.headers.get('location'), url);
@@ -133,12 +133,14 @@ function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '', me
@@ -133,12 +133,14 @@ function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '', me
const body = req.body || {};
const body = req.body || {};
if (Object.keys(body).some((k) => !['text', 'pictures', 'attachments'].includes(k))) fail(400, 'post.invalid', 'Only text, pictures and attachments are accepted');
if (Object.keys(body).some((k) => !['text', 'pictures', 'attachments'].includes(k))) fail(400, 'post.invalid', 'Only text, pictures and attachments are accepted');
const text = typeof body.text === 'string' ? body.text.trim() : '';
const text = typeof body.text === 'string' ? body.text.trim() : '';
- if (!text) fail(400, 'post.invalid', 'Write something first');
+ const hasFiles = (Array.isArray(body.pictures) && body.pictures.length > 0) || (Array.isArray(body.attachments) && body.attachments.length > 0);
+ // A picture alone is a post, as on any social app (the owner, 11-12); with nothing at all it is not.
+ if (!text && !hasFiles) fail(400, 'post.invalid', 'Write something or add a picture');
if (text.length > 2000) fail(400, 'post.invalid', 'Text is at most 2000 characters');
if (text.length > 2000) fail(400, 'post.invalid', 'Text is at most 2000 characters');
const pictures = body.pictures == null ? [] : body.pictures;
const pictures = body.pictures == null ? [] : body.pictures;
if (!Array.isArray(pictures) || pictures.length > 4) fail(400, 'post.invalid_pictures', 'pictures is an array of at most four { media_id, alt }');
if (!Array.isArray(pictures) || pictures.length > 4) fail(400, 'post.invalid_pictures', 'pictures is an array of at most four { media_id, alt }');
for (const p of pictures) {
for (const p of pictures) {
- if (!p || typeof p.media_id !== 'string' || typeof p.alt !== 'string') fail(400, 'post.invalid_pictures', 'Every picture is { media_id, alt }');
+ if (!p || typeof p.media_id !== 'string' || (p.alt != null && typeof p.alt !== 'string')) fail(400, 'post.invalid_pictures', 'Every picture is { media_id, alt? }');
}
}
const fileIds = body.attachments == null ? [] : body.attachments;
const fileIds = body.attachments == null ? [] : body.attachments;
if (!Array.isArray(fileIds) || fileIds.length > 10 || fileIds.some((id) => !/^att_[0-9A-HJKMNP-TV-Z]{26}$/.test(id)) || new Set(fileIds).size !== fileIds.length) {
if (!Array.isArray(fileIds) || fileIds.length > 10 || fileIds.some((id) => !/^att_[0-9A-HJKMNP-TV-Z]{26}$/.test(id)) || new Set(fileIds).size !== fileIds.length) {
@@ -164,8 +166,10 @@ function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '', me
@@ -164,8 +166,10 @@ function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '', me
}
}
files.push({ id, name: String(f.name || 'File').slice(0, 255), mime: String(f.mime || 'application/octet-stream').slice(0, 120), size: Number(f.size), url: url.href });
files.push({ id, name: String(f.name || 'File').slice(0, 255), mime: String(f.mime || 'application/octet-stream').slice(0, 120), size: Number(f.size), url: url.href });
}
}
- const title = `Status: ${text.replace(/https?:\/\/[^\s<>]+/gi, '').trim().slice(0, 110) || 'Shared a picture'}`;
- const { thread, post } = await forum.createThread(v, 'status', { title, body: text, pictures: pictures.map((p) => ({ media_id: p.media_id, alt: p.alt })) });
+ // A picture-only post is named by what it shows (its alt text), so two of them are not "the same thread".
+ const firstAlt = String((pictures[0] && pictures[0].alt) || '').trim();
+ const title = `Status: ${text.replace(/https?:\/\/[^\s<>]+/gi, '').trim().slice(0, 110) || firstAlt.slice(0, 110) || `Shared a picture · ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`}`;
+ const { thread, post } = await forum.createThread(v, 'status', { title, body: text, pictures: pictures.map((p) => ({ media_id: p.media_id, alt: String(p.alt || '') })) });
if (files.length) await forum.attachComposerFiles(v, post.id, files);
if (files.length) await forum.attachComposerFiles(v, post.id, files);
if (pulse) {
if (pulse) {
await pulse.forgetLocal('thread', thread.id);
await pulse.forgetLocal('thread', thread.id);
@@ -247,9 +247,10 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -247,9 +247,10 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
}
}
const cleanTitle = (t) => String(t == null ? '' : t).replace(/\u0000/g, '').replace(/\s+/g, ' ').trim();
const cleanTitle = (t) => String(t == null ? '' : t).replace(/\u0000/g, '').replace(/\s+/g, ' ').trim();
- function cleanBody(b) {
+ /** A post's words. Empty only beside a picture or a file (a picture alone is a post, as on any social app, 11-12). */
+ function cleanBody(b, { allowEmpty = false } = {}) {
const body = String(b == null ? '' : b).replace(/\u0000/g, '').replace(/\r\n?/g, '\n').replace(/\s+$/, '');
const body = String(b == null ? '' : b).replace(/\u0000/g, '').replace(/\r\n?/g, '\n').replace(/\s+$/, '');
- if (!body.trim()) fail(400, 'post.empty', 'Write something first');
+ if (!body.trim()) { if (allowEmpty) return ''; fail(400, 'post.empty', 'Write something or add a picture'); }
if (body.length > BODY_MAX) fail(400, 'post.too_long', `Posts are limited to ${BODY_MAX.toLocaleString('en-US')} characters`);
if (body.length > BODY_MAX) fail(400, 'post.too_long', `Posts are limited to ${BODY_MAX.toLocaleString('en-US')} characters`);
return body;
return body;
}
}
@@ -307,7 +308,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -307,7 +308,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
return rows;
return rows;
}
}
/**
/**
- * The pictures a post names: [{ media_id, alt }], alt required, at most four. An upload made through
+ * The pictures a post names: [{ media_id, alt }], at most four. The alt text is the composer's (written by the person
+ * or by AI when they leave it, 11-12); a picture without one is still posted, its alt left empty. An upload made through
* /api/v1/spaces/:space/attachments is already an attachments row; a direct Media upload (the SDK's
* /api/v1/spaces/:space/attachments is already an attachments row; a direct Media upload (the SDK's
* posts.create path) is checked with Media's object API, where the person owns the image, and remembered
* posts.create path) is checked with Media's object API, where the person owns the image, and remembered
* here with the alt text. → rows ready for claimable()
* here with the alt text. → rows ready for claimable()
@@ -322,7 +324,6 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -322,7 +324,6 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const mediaId = String((p && p.media_id) || '');
const mediaId = String((p && p.media_id) || '');
const alt = String((p && p.alt) == null ? '' : p.alt).replace(/\s+/g, ' ').trim();
const alt = String((p && p.alt) == null ? '' : p.alt).replace(/\s+/g, ' ').trim();
if (!MED_ID.test(mediaId)) fail(400, 'pictures.invalid', 'Every picture needs a Media media_id');
if (!MED_ID.test(mediaId)) fail(400, 'pictures.invalid', 'Every picture needs a Media media_id');
- if (!alt) fail(400, 'pictures.alt_required', 'Every picture needs alt text');
if (alt.length > 300) fail(400, 'pictures.alt_too_long', 'Alt text is at most 300 characters');
if (alt.length > 300) fail(400, 'pictures.alt_too_long', 'Alt text is at most 300 characters');
let row = await db.prepare('SELECT * FROM attachments WHERE media_id = ?').get(mediaId);
let row = await db.prepare('SELECT * FROM attachments WHERE media_id = ?').get(mediaId);
if (row) {
if (row) {
@@ -1024,7 +1025,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -1024,7 +1025,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);
if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);
const labels = body.labels === undefined || body.labels === null ? null : contentLabels.parseLabels(body.labels);
const labels = body.labels === undefined || body.labels === null ? null : contentLabels.parseLabels(body.labels);
if (body.labels !== undefined && body.labels !== null && labels === null) fail(400, 'thread.labels_invalid', `Labels are from this list: ${contentLabels.LABELS.join(', ')}`);
if (body.labels !== undefined && body.labels !== null && labels === null) fail(400, 'thread.labels_invalid', `Labels are from this list: ${contentLabels.LABELS.join(', ')}`);
- const text = cleanBody(body.body != null ? body.body : body.body_markdown);
+ const withFiles = [body.pictures, body.attachments].some((x) => (Array.isArray(x) ? x.length > 0 : x != null));
+ const text = cleanBody(body.body != null ? body.body : body.body_markdown, { allowEmpty: withFiles });
const pictureRows = await preparePictures(w, body.pictures);
const pictureRows = await preparePictures(w, body.pictures);
const listed = Array.isArray(body.attachments) ? body.attachments : (body.attachments == null ? [] : [body.attachments]);
const listed = Array.isArray(body.attachments) ? body.attachments : (body.attachments == null ? [] : [body.attachments]);
const images = await claimable(w, [...listed, ...pictureRows.map((x) => x.media_id)]);
const images = await claimable(w, [...listed, ...pictureRows.map((x) => x.media_id)]);
@@ -129,6 +129,18 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
@@ -129,6 +129,18 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
assert.ok(!html.includes('enctype="multipart/form-data"'), 'the form no longer posts files itself');
assert.ok(!html.includes('enctype="multipart/form-data"'), 'the form no longer posts files itself');
});
});
+ await check('a picture alone is a post (11-12); nothing at all is not', async () => {
+ const up = await upload(alexJwt, PNG, 'only.png');
+ const pic = up.json().attachment;
+ let r = await json(alexJwt, '/api/v1/spaces/general/threads', { title: 'Just a picture', body: '', attachments: [pic.media_id] });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().post.body_markdown || '', '', 'no words');
+ assert.deepStrictEqual(r.json().post.attachments.map((a) => a.media_id), [pic.media_id]);
+ r = await json(alexJwt, '/api/v1/spaces/general/threads', { title: 'Nothing', body: ' ' });
+ assert.strictEqual(r.status, 400);
+ assert.strictEqual(r.json().code, 'post.empty');
+ });
+
await check('the reply form names images the shared uploader already stored', async () => {
await check('the reply form names images the shared uploader already stored', async () => {
const a = await upload(samJwt, PNG, 'a.png');
const a = await upload(samJwt, PNG, 'a.png');
const b = await upload(samJwt, PNG, 'b.png');
const b = await upload(samJwt, PNG, 'b.png');
@@ -174,7 +174,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -174,7 +174,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
assert.match(reply.headers.get('location'), new RegExp(`^${url}#post-\\d+$`));
assert.match(reply.headers.get('location'), new RegExp(`^${url}#post-\\d+$`));
const empty = await t.get(`${url}/reply`, as(samJwt, form({ body: ' ' })));
const empty = await t.get(`${url}/reply`, as(samJwt, form({ body: ' ' })));
assert.strictEqual(empty.status, 400);
assert.strictEqual(empty.status, 400);
- has(empty.text, 'Write something first');
+ has(empty.text, 'Write something or add a picture');
const vote = await t.get(`${url}/vote`, as(samJwt, form({ value: '1' })));
const vote = await t.get(`${url}/vote`, as(samJwt, form({ value: '1' })));
assert.strictEqual(vote.status, 303);
assert.strictEqual(vote.status, 303);
assert.strictEqual(vote.headers.get('location'), url);
assert.strictEqual(vote.headers.get('location'), url);