openvibers/openvibe.community Commits

Community: forum images and paste screenshots use the shared upload kit (#338)

OP OpenVibers committed 3 days ago · ee2c2228ba · 7 files, +181 −54

The new-thread, reply and new-paste pages load openvibe-shared/ov-upload.js
and ov-upload.css. Forum images go one by one to the Media-backed
/api/v1/spaces/:space/attachments before the form, which names their med_ ids;
the old inline multipart upload answers 410. The paste screenshot uses the
kit picker and progress while the same endpoint stores it in Media.

Changes

ddiff --git a/public/js/community.js b/public/js/community.js
iindex 3ff5cd0..1bf5cd3 100644
---- a/public/js/community.js
++++ b/public/js/community.js
@@@ -60,6 +60,62 @@
}
});
++ // ── Forum images: the shared picker, one request per image before the form ──
++ // The page loads ov-upload.js first. Each image goes to POST /api/v1/spaces/:space/attachments (Media,
++ // owned by the writer, metadata stripped by the server); the form then names the returned med_ ids, so
++ // the post itself is a plain urlencoded form and the old inline multipart upload is gone.
++ function uploadImage(space, file, opts) {
++ return new Promise(function (resolve, reject) {
++ var xhr = new XMLHttpRequest();
++ xhr.open('POST', '/api/v1/spaces/' + encodeURIComponent(space) + '/attachments');
++ xhr.withCredentials = true;
++ xhr.upload.onprogress = function (e) { if (e.lengthComputable && opts.onProgress) opts.onProgress({ uploadedBytes: e.loaded }); };
++ xhr.onload = function () {
++ var body = null; try { body = JSON.parse(xhr.responseText || 'null'); } catch (e) { body = null; }
++ if (xhr.status >= 200 && xhr.status < 300 && body && body.attachment) { resolve(body); return; }
++ var err = new Error((body && (body.detail || body.error)) || ('The image could not be uploaded (' + xhr.status + ').'));
++ err.status = xhr.status; reject(err);
++ };
++ xhr.onerror = function () { reject(new Error('The connection dropped. Try that image again.')); };
++ if (opts.signal) opts.signal.addEventListener('abort', function () { xhr.abort(); reject(new Error('Upload cancelled.')); }, { once: true });
++ var fd = new FormData(); fd.append('file', file, file.name); xhr.send(fd);
++ });
++ }
++ function attachmentUploads(signal) {
++ $$('[data-attachment-upload]').forEach(function (root) {
++ if (root.getAttribute('data-ready') || !window.OVUpload) return;
++ var form = root.closest('form');
++ if (!form) return;
++ root.setAttribute('data-ready', '1');
++ var status = form.querySelector('[data-attachment-status]');
++ var space = root.getAttribute('data-space');
++ var done = new Map();
++ function say(text) { if (status) status.textContent = text; }
++ var queue = window.OVUpload.queue(root, {
++ label: 'Choose images', accept: 'image/png,image/jpeg,image/gif,image/webp', multiple: true, concurrency: 2,
++ upload: function (file, opts) { return uploadImage(space, file, opts); },
++ onDone: function (file, out) {
++ if (out && out.attachment && out.attachment.media_id) done.set(file, out.attachment.media_id);
++ say(done.size + (done.size === 1 ? ' image ready.' : ' images ready.'));
++ }
++ });
++ form.addEventListener('submit', function (e) {
++ var busy = queue.items.some(function (i) { return i.state === 'waiting' || i.state === 'sending'; });
++ var failed = queue.items.some(function (i) { return i.state === 'failed'; });
++ if (busy || failed) { e.preventDefault(); say(busy ? 'Wait for the images to finish uploading.' : 'Retry or remove the image that failed.'); return; }
++ form.querySelectorAll('input[data-attachment-id]').forEach(function (n) { n.remove(); });
++ queue.items.slice(0, 4).forEach(function (i) {
++ var id = done.get(i.file);
++ if (i.state !== 'done' || !id) return;
++ var input = document.createElement('input'); input.type = 'hidden'; input.name = 'attachments';
++ input.value = id; input.setAttribute('data-attachment-id', ''); form.appendChild(input);
++ });
++ if (queue.items.length > 4) say('The first 4 images go with this post.');
++ }, { signal: signal, capture: true });
++ say('Up to 4 images. Each is uploaded before you post.');
++ });
++ }
++
// ── Per-page wiring ─────────────────────────────────────────
// Runs once on load and again after openvibe-shared/boost swaps <main> (it fires ov:boost:load on
// document), so the widgets of the page that just arrived are wired too. Each run replaces the
@@@ -70,6 +126,7 @@
if (wired) wired.abort();
wired = new AbortController();
var signal = wired.signal;
++ attachmentUploads(signal);
var shot = $('.shot.nsfw');
if (shot) shot.addEventListener('click', function (e) { if (!shot.classList.contains('revealed')) { e.preventDefault(); shot.classList.add('revealed'); } }, { signal });
@@@ -114,6 +171,22 @@
var visibilityInputs = $$('input[name="visibility"]', form);
var audienceLine = $('[data-lock-audience]', form);
var audienceText = $('[data-lock-audience-text]', form);
++ var shotRoot = $('[data-screenshot-upload]', form);
++ var shotStatus = $('[data-screenshot-status]', form);
++ var shotProgress = $('[data-screenshot-progress]', form);
++ var shotFile = null;
++ var shotMeter = shotProgress && window.OVUpload ? window.OVUpload.progress(shotProgress) : null;
++ var shotPicker = shotRoot && window.OVUpload ? window.OVUpload.picker(shotRoot, {
++ label: 'Choose an image', accept: 'image/png,image/jpeg,image/webp,image/gif',
++ onFile: function (f) {
++ shotFile = f;
++ if (shotProgress) shotProgress.hidden = true;
++ if (!form.elements.title.value) form.elements.title.value = f.name.replace(/\.[^.]+$/, '');
++ setMode('image');
++ },
++ onRemove: function () { shotFile = null; }
++ }) : null;
++ function sayShot(text, bad) { if (!shotStatus) return; shotStatus.textContent = text || ''; shotStatus.className = 'form-status' + (bad ? ' error' : ''); }
var LOCK_AUDIENCE = { people: 'For the people on the list', password: 'Anyone with the password', link: 'Anyone with the link' };
var syncLock = null;
var lockOn = function () { return !!(form.elements.lock_it && form.elements.lock_it.checked); };
@@@ -121,9 +194,8 @@
mode = m;
tabs.forEach(function (t) { var on = t.getAttribute('data-tab') === m; t.classList.toggle('active', on); t.setAttribute('aria-selected', on ? 'true' : 'false'); });
$$('[data-pane]', form).forEach(function (p) { p.hidden = p.getAttribute('data-pane') !== m; });
-- var content = form.elements.content, file = form.elements.screenshot, lang = form.elements.language;
++ var content = form.elements.content, lang = form.elements.language;
if (content) content.required = m === 'text';
-- if (file) { file.disabled = m !== 'image'; file.required = m === 'image'; }
if (lang) lang.closest('.field').style.display = m === 'image' ? 'none' : '';
// A screenshot stays on the proxy: locking is offered for text only.
if (lockSection) lockSection.hidden = m !== 'text';
@@@ -220,7 +292,7 @@
}
setMode('text');
// Dropping or pasting an image anywhere on the form switches to the screenshot mode.
-- function takeFile(f) { if (!f || !/^image\//.test(f.type)) return; var dt = new DataTransfer(); dt.items.add(f); setMode('image'); form.elements.screenshot.files = dt.files; if (!form.elements.title.value) form.elements.title.value = f.name.replace(/\.[^.]+$/, ''); }
++ function takeFile(f) { if (!f || !/^image\//.test(f.type)) return; setMode('image'); if (shotPicker) shotPicker.add([f]); }
form.addEventListener('dragover', function (e) { e.preventDefault(); }, { signal });
form.addEventListener('drop', function (e) { if (e.dataTransfer && e.dataTransfer.files[0]) { e.preventDefault(); takeFile(e.dataTransfer.files[0]); } }, { signal });
document.addEventListener('paste', function (e) { var items = e.clipboardData && e.clipboardData.items; if (!items) return; for (var i = 0; i < items.length; i++) { if (items[i].kind === 'file') { takeFile(items[i].getAsFile()); e.preventDefault(); return; } } }, { signal });
@@@ -239,6 +311,24 @@
}
function b64(u8) { var s = ''; for (var i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); }
++ /** The screenshot goes to Community as one multipart request (it stores it in Media as the person's picture). */
++ function uploadScreenshot(fd) {
++ return new Promise(function (resolve, reject) {
++ var xhr = new XMLHttpRequest();
++ xhr.open('POST', '/api/pastes/screenshot');
++ xhr.withCredentials = true;
++ xhr.upload.onprogress = function (e) { if (e.lengthComputable && shotMeter) shotMeter.update({ state: 'sending', done: e.loaded, total: e.total }); };
++ xhr.onload = function () {
++ var body = null; try { body = JSON.parse(xhr.responseText || 'null'); } catch (_) { body = null; }
++ if (xhr.status >= 200 && xhr.status < 300) { if (shotMeter) shotMeter.update({ state: 'done', done: shotFile ? shotFile.size : 0, total: shotFile ? shotFile.size : 0 }); resolve(body || {}); return; }
++ var err = new Error((body && (body.detail || body.error)) || ('The image could not be uploaded (' + xhr.status + ').'));
++ err.status = xhr.status; err.data = body; reject(err);
++ };
++ xhr.onerror = function () { reject(new Error('The connection dropped. Try the image again.')); };
++ xhr.send(fd);
++ });
++ }
++
/**
* A locked paste: the plaintext never leaves this page. The document is sealed here, the envelope is what goes
* up, and a people lock names who it is wrapped for (their public keys, each checked before anything is sent).
@@@ -314,15 +404,19 @@
});
return;
}
-- status.textContent = 'Creating…';
var common = { title: el.title.value, visibility: el.visibility.value, burn_after_read: el.burn_after_read.checked, is_nsfw: el.is_nsfw.checked };
if (mode === 'image') {
++ if (!shotFile) { btn.disabled = false; sayShot('Choose an image first.', true); return; }
++ sayShot('Uploading your image…');
++ if (shotProgress) shotProgress.hidden = false;
++ if (shotMeter) shotMeter.update({ state: 'sending', done: 0, total: shotFile.size });
var fd = new FormData();
-- fd.append('screenshot', el.screenshot.files[0]);
++ fd.append('screenshot', shotFile);
fd.append('title', common.title || 'Screenshot'); fd.append('description', el.description.value); fd.append('visibility', common.visibility);
if (common.is_nsfw) fd.append('is_nsfw', '1');
-- p = api('/api/pastes/screenshot', { method: 'POST', body: fd });
++ p = uploadScreenshot(fd);
} else {
++ status.textContent = 'Creating…';
p = api('/api/pastes', { method: 'POST', json: Object.assign({ content: el.content.value, language: el.language.value }, common) });
}
p.then(function (out) {
@@@ -332,8 +426,10 @@
status.textContent = 'Done — opening your paste…';
location.href = href || '/p/' + encodeURIComponent(slug);
}).catch(function (err) {
-- btn.disabled = false; status.className = 'form-status error';
-- status.textContent = err.status === 429 && err.data && err.data.cooldown ? 'Slow down — try again in ' + err.data.cooldown + 's.' : err.message;
++ btn.disabled = false;
++ var message = err.status === 429 && err.data && err.data.cooldown ? 'Slow down — try again in ' + err.data.cooldown + 's.' : err.message;
++ if (mode === 'image') { if (shotMeter) shotMeter.update({ state: 'failed', message: message }); sayShot(message, true); }
++ else { status.className = 'form-status error'; status.textContent = message; }
});
}, { signal });
}
ddiff --git a/server/forum/routes.js b/server/forum/routes.js
iindex f4653f4..a1953d8 100644
---- a/server/forum/routes.js
++++ b/server/forum/routes.js
@@@ -27,7 +27,6 @@
*/
const express = require('express');
const labelsUi = require('../render/labels');
--const multer = require('multer');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const discovery = require('../discovery');
@@@ -43,22 +42,20 @@ function createForumRoutes({ forum, viewers, config }) {
const router = express.Router();
const withViewer = viewers.middleware({ services: false });
const form = express.urlencoded({ extended: false, limit: '256kb' });
-- // New threads and replies may carry up to 4 images (multipart `attachments`); a urlencoded form passes through.
-- const imageParser = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024, files: 4, fields: 20, fieldSize: 64 * 1024 } }).array('attachments', 4);
-- const withImages = (req, res, next) => imageParser(req, res, (err) => {
-- if (err) req.uploadError = err.code === 'LIMIT_FILE_SIZE' ? 'Images are limited to 8 MB' : (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') ? 'At most 4 images per post' : 'The images could not be read';
-- next();
-- });
-- /** Store the form's images in Media first. → their media ids (the post then names them) */
-- async function uploadImages(req) {
-- if (req.uploadError) throw new ApiError(400, 'attachments.invalid', req.uploadError);
-- const ids = [];
-- for (const f of req.files || []) {
-- if (!f || !f.size) continue;
-- ids.push((await forum.uploadAttachment(req.viewer, req.params.space, f)).attachment.media_id);
-- }
-- return ids;
++ /**
++ * The image ids the shared uploader attached before the form was sent. Each was uploaded through
++ * POST /api/v1/spaces/:space/attachments (Media, owned by the writer); claimable() checks the rest.
++ */
++ function attachmentIds(body) {
++ const raw = body && body.attachments;
++ const list = Array.isArray(raw) ? raw : (raw == null ? [] : [raw]);
++ return list.map(String).filter(Boolean);
}
++ // The old inline multipart upload is gone: the page sends images one by one through the kit first.
++ const noInlineUpload = (req, res, next) => {
++ if (!req.is('multipart/form-data')) return next();
++ return html(res, pages.errorPage({ status: 410, title: 'Images moved', message: 'Images are uploaded one by one before the post is sent. Reload this page and choose them again.' }), 410);
++ };
const worthUploading = (title, body) => (title == null || String(title).trim().length >= 3) && String(body || '').trim().length > 0;
const html = (res, body, status = 200) => {
setRobotsHeader(res, body);
@@@ -203,7 +200,7 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
}));
-- router.post('/forum/:space/new', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {
++ router.post('/forum/:space/new', withViewer, sameOrigin, noInlineUpload, form, wrap(async (req, res, next) => {
const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };
if ((req.body || {}).members_only === '1') values.members_only = true;
if ((req.body || {}).category) values.category = String(req.body.category).slice(0, 40);
@@@ -211,8 +208,8 @@ function createForumRoutes({ forum, viewers, config }) {
const labels = labelsUi.fromForm(req.body);
if (labels !== undefined) values.labels = labels === null ? ['not-a-label'] : labels;
try {
-- // Images go to Media only when the rest can be saved (no strays from an empty form).
-- if (worthUploading(values.title, values.body)) { const ids = await uploadImages(req); if (ids.length) values.attachments = ids; }
++ // Name the images only when the rest can be saved (an empty form claims nothing).
++ if (worthUploading(values.title, values.body)) { const ids = attachmentIds(req.body); if (ids.length) values.attachments = ids; }
const out = await forum.createThread(req.viewer, req.params.space, values);
discovery.resetCaches();
res.redirect(303, out.thread.url);
@@@ -242,10 +239,10 @@ function createForumRoutes({ forum, viewers, config }) {
try { await renderThread(req, res); } catch (err) { failPage(req, res, err, next); }
}));
-- router.post('/forum/:space/:slug/reply', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {
++ router.post('/forum/:space/:slug/reply', withViewer, sameOrigin, noInlineUpload, form, wrap(async (req, res, next) => {
const draft = String((req.body || {}).body || '').slice(0, 40_000);
try {
-- const attachments = worthUploading(null, draft) ? await uploadImages(req) : [];
++ const attachments = worthUploading(null, draft) ? attachmentIds(req.body) : [];
const pastes = String((req.body || {}).pastes || '').slice(0, 400) || undefined;
const out = await forum.reply(req.viewer, req.params.space, req.params.slug, { body: draft, attachments: attachments.length ? attachments : undefined, pastes });
res.redirect(303, out.url);
ddiff --git a/server/render/board.js b/server/render/board.js
iindex 8cd8432..60c0da0 100644
---- a/server/render/board.js
++++ b/server/render/board.js
@@@ -218,11 +218,11 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
const indexable = space.visibility === 'public' && !gated;
const first = (page - 1) * perPage;
const replyBlock = viewer.can_reply
-- ? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply"${attachmentsEnabled ? ' enctype="multipart/form-data"' : ''}>
++ ? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply">
<h2 class="h3">Reply</h2>
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
<label class="field"><span class="sr-only">Your reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>
-- ${f.imageField(attachmentsEnabled)}
++ ${f.imageField(attachmentsEnabled, space)}
${f.pasteField()}
<div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-reply" aria-hidden="true"></i> Post reply</button><span class="muted small">**bold**, *italic*, \`code\`, &gt; quotes, [links](https://…)</span></div>
</form>`
@@@ -264,7 +264,8 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
historyType: 'page',
historyTitle: thread.title,
footerVariant: 'compact',
-- styles: ['kit.css'],
++ styles: ['kit.css', ...(attachmentsEnabled ? ['ov-upload.css'] : [])],
++ ...(attachmentsEnabled ? { scripts: ['ov-upload.js'] } : {}),
...(labelBox ? { pageCss: ['labels.css'] } : {}),
feeds: [
...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : []),
ddiff --git a/server/render/forum.js b/server/render/forum.js
iindex 0a2b6a3..633e398 100644
---- a/server/render/forum.js
++++ b/server/render/forum.js
@@@ -245,7 +245,11 @@ function reactionBar(p, base, page, catalogue = []) {
}
const pasteField = (value = '') => `<label class="field"><span>Pastes (optional: up to 4 paste links or codes, separated by spaces)</span><input type="text" name="pastes" maxlength="400" value="${esc(value || '')}" placeholder="https://openvibe.community/p/…"></label>`;
--const imageField = (on) => (on ? '<label class="field"><span>Images (optional, up to 4; PNG, JPEG, GIF or WebP, 8 MB each)</span><input type="file" name="attachments" accept="image/png,image/jpeg,image/gif,image/webp" multiple></label>' : '');
++const imageField = (on, space) => (on ? `<div class="field attachments-field"><span>Images (optional, up to 4; PNG, JPEG, GIF or WebP, 8 MB each)</span>
++<div data-attachment-upload data-space="${esc(space.slug)}"></div>
++<p class="muted small" data-attachment-status role="status" aria-live="polite"></p>
++<noscript><p class="muted small">Choosing images needs JavaScript; the rest of this form works without it.</p></noscript>
++</div>` : '');
function voteForm(base, thread, viewer) {
if (!viewer.can_vote) return `<div class="vote"><span class="vote-score" title="Score">${num(thread.score)}</span></div>`;
@@@ -279,10 +283,10 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
const indexable = space.visibility === 'public' && !gated;
const first = (page - 1) * perPage;
const replyBlock = viewer.can_reply
-- ? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply"${attachmentsEnabled ? ' enctype="multipart/form-data"' : ''}>
++ ? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply">
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
<label class="field"><span>Reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>
-- ${imageField(attachmentsEnabled)}
++ ${imageField(attachmentsEnabled, space)}
${pasteField()}
<div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-reply" aria-hidden="true"></i> Reply</button><span class="muted small">**bold**, *italic*, \`code\`, \`\`\` blocks, [links](https://…), &gt; quotes and lists.</span></div>
</form>`
@@@ -325,7 +329,8 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
historyType: 'page',
historyTitle: thread.title,
footerVariant: 'compact',
-- styles: ['kit.css'],
++ styles: ['kit.css', ...(attachmentsEnabled ? ['ov-upload.css'] : [])],
++ ...(attachmentsEnabled ? { scripts: ['ov-upload.js'] } : {}),
...(labelBox ? { pageCss: ['labels.css'] } : {}),
feeds: [
...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : []),
@@@ -342,13 +347,13 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
// ── /forum/:space/new ────────────────────────────────────────────
function newThreadPage({ space, user, values = {}, error = null, categories = [], attachments = false }) {
const categoryField = categories.length ? `<label class="field"><span>Category</span><select name="category"><option value="">None</option>${categories.map((c) => `<option value="${esc(c.slug)}"${values.category === c.slug ? ' selected' : ''}>${esc(c.name)}${c.description ? ` — ${esc(c.description)}` : ''}</option>`).join('')}</select></label>` : '';
-- const form = user ? `<form class="paste-form" method="post" action="/forum/${esc(space.slug)}/new"${attachments ? ' enctype="multipart/form-data"' : ''}>
++ const form = user ? `<form class="paste-form" method="post" action="/forum/${esc(space.slug)}/new">
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${space.thread_kind === 'request' ? '<p class="muted small">A request is open to votes. Search first: if it is already here, vote for it instead.</p>' : ''}
<label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label>
${categoryField}
<label class="field"><span>Post (Markdown)</span><textarea name="body" rows="14" maxlength="40000" required placeholder="Say it, own it.">${esc(values.body || '')}</textarea></label>
-- ${imageField(attachments)}
++ ${imageField(attachments, space)}
${pasteField(values.pastes)}
${labelsUi.picker({ current: values.labels || [], id: 'new-labels' })}
<label class="check"><input type="checkbox" name="members_only" value="1"${values.members_only ? ' checked' : ''}> Only my OpenVibe.VIP members can read and reply</label>
@@@ -371,6 +376,7 @@ ${form}`;
active: 'spaces',
footerVariant: 'compact',
...(user ? { pageCss: ['labels.css'] } : {}),
++ ...(attachments ? { styles: ['ov-upload.css'], scripts: ['ov-upload.js'] } : {}),
body,
});
}
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 8a95ddc..3d019f7 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -114,6 +114,7 @@ function footerInit(opts) {
* historyType ('page'|'paste'), historyTitle, footerVariant ('full'|'compact'), bodyClass,
* styles (Shared stylesheets by name, linked before community.css so this site's rules win: ['showcase.css']),
* pageCss (this site's own stylesheets for one page, linked after community.css: ['modlog.css']),
++ * scripts (Shared scripts by name, loaded before community.js so it can use them: ['ov-upload.js']),
* published/modified (ISO, for article:*), noFrame (error pages during outages)
*/
function renderPage(o) {
@@@ -150,6 +151,7 @@ function renderPage(o) {
...(o.inlineStyles || []).map((css) => `<style>${css}</style>`),
`<link rel="stylesheet" href="${asset('css/community.css')}">`,
...(o.pageCss || []).map((name) => `<link rel="stylesheet" href="${asset(`css/${name}`)}">`),
++ ...(o.scripts || []).map((name) => `<script src="${ovServe.url(name)}" defer></script>`),
`<script src="${asset('js/community.js')}" defer></script>`,
// openvibe-shared items.js + items.css: community.js loads them only on a page that shows someone.
`<meta name="ov-items" content="${escapeHtml(ovServe.url('items.js'))} ${escapeHtml(ovServe.url('items.css'))}">`,
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex a146612..b6b0001 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -680,7 +680,11 @@ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
</div>
</div>
<div data-pane="image" hidden>
-- <label class="field"><span>Image (PNG, JPEG, WebP or GIF)</span><input type="file" name="screenshot" accept="image/png,image/jpeg,image/webp,image/gif" disabled></label>
++ <div class="field"><span>Image (PNG, JPEG, WebP or GIF)</span>
++ <div data-screenshot-upload></div>
++ <p class="form-status" data-screenshot-status role="status" aria-live="polite"></p>
++ <div data-screenshot-progress hidden></div>
++ </div>
<label class="field"><span>Description</span><textarea name="description" rows="3" maxlength="2000" placeholder="What is this a picture of?"></textarea></label>
</div>
<section class="ovk-section pf-options" aria-labelledby="pf-options-h">
@@@ -702,7 +706,8 @@ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
robots: 'noindex,follow',
active: 'new',
footerVariant: 'compact',
-- styles: ['kit.css'], // the options card and the switch rows (openvibe-shared/kit)
++ styles: ['kit.css', 'ov-upload.css'], // the options card, the switch rows and the shared picker
++ scripts: ['ov-upload.js'],
body,
});
}
ddiff --git a/test/attachments.test.js b/test/attachments.test.js
iindex 09a418a..7d8a8ea 100644
---- a/test/attachments.test.js
++++ b/test/attachments.test.js
@@@ -114,26 +114,46 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
r = await json(alexJwt, `/api/v1/spaces/general/threads/${thread.slug}/posts`, { body: 'again', attachments: [first.media_id] });
assert.strictEqual(r.status, 400, 'already on a post');
const html = (await t.get(`/forum/general/${thread.slug}`, { cookies: [`ov_token=${samJwt}`] })).text;
-- assert.ok(html.includes(`src="${first.url}"`) && html.includes('enctype="multipart/form-data"') && html.includes('name="attachments"'));
++ assert.ok(html.includes(`src="${first.url}"`), 'the post shows the image');
++ assert.ok(html.includes('data-attachment-upload') && html.includes('/shared/ov-upload.js') && html.includes('/shared/ov-upload.css'), 'the form loads the shared picker');
++ assert.ok(!html.includes('enctype="multipart/form-data"'), 'the form no longer posts files itself');
});
-- await check('no-JS reply form: the images are uploaded and attached in one step', async () => {
-- const fd = new FormData();
-- fd.append('body', 'Two more');
-- fd.append('attachments', new Blob([PNG], { type: 'image/png' }), 'a.png');
-- fd.append('attachments', new Blob([PNG], { type: 'image/png' }), 'b.png');
-- const r = await t.get(`/forum/general/${thread.slug}/reply`, { method: 'POST', body: fd, cookies: [`ov_token=${samJwt}`] });
++ 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');
++ assert.strictEqual(a.status, 201); assert.strictEqual(b.status, 201);
++ const body = new URLSearchParams();
++ body.append('body', 'Two more');
++ body.append('attachments', a.json().attachment.media_id);
++ body.append('attachments', b.json().attachment.media_id);
++ const r = await t.get(`/forum/general/${thread.slug}/reply`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: body.toString(), cookies: [`ov_token=${samJwt}`] });
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
const page = (await t.get(`/api/v1/spaces/general/threads/${thread.slug}`)).json();
const reply = page.posts.find((p) => p.body_markdown === 'Two more');
-- assert.deepStrictEqual(reply.attachments.map((a) => a.filename), ['a.png', 'b.png']);
-- assert.ok(reply.attachments.every((a) => objects.get(a.media_id).owner === sam.subject_id));
-- const empty = new FormData();
-- empty.append('body', '');
-- empty.append('attachments', new Blob([PNG], { type: 'image/png' }), 'c.png');
++ assert.deepStrictEqual(reply.attachments.map((x) => x.filename), ['a.png', 'b.png']);
++ assert.ok(reply.attachments.every((x) => objects.get(x.media_id).owner === sam.subject_id));
++ });
++
++ await check('the old inline multipart upload is gone: 410, and nothing reaches Media', async () => {
const before = objects.size;
-- await t.get(`/forum/general/${thread.slug}/reply`, { method: 'POST', body: empty, cookies: [`ov_token=${samJwt}`] });
-- assert.strictEqual(objects.size, before, 'an empty reply uploads nothing');
++ const fd = new FormData();
++ fd.append('body', 'Old way');
++ fd.append('attachments', new Blob([PNG], { type: 'image/png' }), 'c.png');
++ const r = await t.get(`/forum/general/${thread.slug}/reply`, { method: 'POST', body: fd, cookies: [`ov_token=${samJwt}`] });
++ assert.strictEqual(r.status, 410, r.text.slice(0, 300));
++ assert.strictEqual(objects.size, before, 'the old upload path stores nothing');
++ });
++
++ await check('the new-thread and new-paste pages load the shared picker', async () => {
++ const fresh = await t.get('/forum/general/new', { cookies: [`ov_token=${alexJwt}`] });
++ assert.strictEqual(fresh.status, 200);
++ assert.ok(fresh.text.includes('data-attachment-upload') && fresh.text.includes('/shared/ov-upload.js') && fresh.text.includes('/shared/ov-upload.css'), 'the forum form uses the kit');
++ assert.ok(!fresh.text.includes('name="attachments" type="file"') && !fresh.text.includes('enctype="multipart/form-data"'), 'no hand-rolled file input');
++ const paste = await t.get('/new');
++ assert.strictEqual(paste.status, 200);
++ assert.ok(paste.text.includes('data-screenshot-upload') && paste.text.includes('/shared/ov-upload.js') && paste.text.includes('/shared/ov-upload.css'), 'the paste form uses the kit');
++ assert.ok(!paste.text.includes('name="screenshot"') && !paste.text.includes('type="file" name="screenshot"'), 'the old screenshot input is gone');
});
await check('without the service principal, attaching is refused and the forms have no file field', async () => {