openvibers/openvibe.community Commits
Community: forum images and paste screenshots use the shared upload kit (#338)
Changes
@@@ -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 });}@@@ -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);@@@ -218,11 +218,11 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, vieweconst 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\`, > quotes, [links](https://…)</span></div></form>`@@@ -264,7 +264,8 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewehistoryType: '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` }] : []),@@@ -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, uconst 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://…), > quotes and lists.</span></div></form>`@@@ -325,7 +329,8 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, uhistoryType: '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,});}@@@ -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'))}">`,@@@ -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,});}@@@ -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 () => {
@@ -60,6 +60,62 @@
@@ -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 ─────────────────────────────────────────
// ── Per-page wiring ─────────────────────────────────────────
// Runs once on load and again after openvibe-shared/boost swaps <main> (it fires ov:boost:load on
// 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
// document), so the widgets of the page that just arrived are wired too. Each run replaces the
@@ -70,6 +126,7 @@
@@ -70,6 +126,7 @@
if (wired) wired.abort();
if (wired) wired.abort();
wired = new AbortController();
wired = new AbortController();
var signal = wired.signal;
var signal = wired.signal;
+ attachmentUploads(signal);
var shot = $('.shot.nsfw');
var shot = $('.shot.nsfw');
if (shot) shot.addEventListener('click', function (e) { if (!shot.classList.contains('revealed')) { e.preventDefault(); shot.classList.add('revealed'); } }, { signal });
if (shot) shot.addEventListener('click', function (e) { if (!shot.classList.contains('revealed')) { e.preventDefault(); shot.classList.add('revealed'); } }, { signal });
@@ -114,6 +171,22 @@
@@ -114,6 +171,22 @@
var visibilityInputs = $$('input[name="visibility"]', form);
var visibilityInputs = $$('input[name="visibility"]', form);
var audienceLine = $('[data-lock-audience]', form);
var audienceLine = $('[data-lock-audience]', form);
var audienceText = $('[data-lock-audience-text]', 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 LOCK_AUDIENCE = { people: 'For the people on the list', password: 'Anyone with the password', link: 'Anyone with the link' };
var syncLock = null;
var syncLock = null;
var lockOn = function () { return !!(form.elements.lock_it && form.elements.lock_it.checked); };
var lockOn = function () { return !!(form.elements.lock_it && form.elements.lock_it.checked); };
@@ -121,9 +194,8 @@
@@ -121,9 +194,8 @@
mode = m;
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'); });
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; });
$$('[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 (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' : '';
if (lang) lang.closest('.field').style.display = m === 'image' ? 'none' : '';
// A screenshot stays on the proxy: locking is offered for text only.
// A screenshot stays on the proxy: locking is offered for text only.
if (lockSection) lockSection.hidden = m !== 'text';
if (lockSection) lockSection.hidden = m !== 'text';
@@ -220,7 +292,7 @@
@@ -220,7 +292,7 @@
}
}
setMode('text');
setMode('text');
// Dropping or pasting an image anywhere on the form switches to the screenshot mode.
// 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('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 });
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 });
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 @@
@@ -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); }
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
* 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).
* up, and a people lock names who it is wrapped for (their public keys, each checked before anything is sent).
@@ -314,15 +404,19 @@
@@ -314,15 +404,19 @@
});
});
return;
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 };
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 (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();
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);
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');
if (common.is_nsfw) fd.append('is_nsfw', '1');
- p = api('/api/pastes/screenshot', { method: 'POST', body: fd });
+ p = uploadScreenshot(fd);
} else {
} else {
+ status.textContent = 'Creating…';
p = api('/api/pastes', { method: 'POST', json: Object.assign({ content: el.content.value, language: el.language.value }, common) });
p = api('/api/pastes', { method: 'POST', json: Object.assign({ content: el.content.value, language: el.language.value }, common) });
}
}
p.then(function (out) {
p.then(function (out) {
@@ -332,8 +426,10 @@
@@ -332,8 +426,10 @@
status.textContent = 'Done — opening your paste…';
status.textContent = 'Done — opening your paste…';
location.href = href || '/p/' + encodeURIComponent(slug);
location.href = href || '/p/' + encodeURIComponent(slug);
}).catch(function (err) {
}).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 });
}, { signal });
}
}
@@ -27,7 +27,6 @@
@@ -27,7 +27,6 @@
*/
*/
const express = require('express');
const express = require('express');
const labelsUi = require('../render/labels');
const labelsUi = require('../render/labels');
-const multer = require('multer');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const discovery = require('../discovery');
const discovery = require('../discovery');
@@ -43,22 +42,20 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -43,22 +42,20 @@ function createForumRoutes({ forum, viewers, config }) {
const router = express.Router();
const router = express.Router();
const withViewer = viewers.middleware({ services: false });
const withViewer = viewers.middleware({ services: false });
const form = express.urlencoded({ extended: false, limit: '256kb' });
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 worthUploading = (title, body) => (title == null || String(title).trim().length >= 3) && String(body || '').trim().length > 0;
const html = (res, body, status = 200) => {
const html = (res, body, status = 200) => {
setRobotsHeader(res, body);
setRobotsHeader(res, body);
@@ -203,7 +200,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -203,7 +200,7 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
} 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) };
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 || {}).members_only === '1') values.members_only = true;
if ((req.body || {}).category) values.category = String(req.body.category).slice(0, 40);
if ((req.body || {}).category) values.category = String(req.body.category).slice(0, 40);
@@ -211,8 +208,8 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -211,8 +208,8 @@ function createForumRoutes({ forum, viewers, config }) {
const labels = labelsUi.fromForm(req.body);
const labels = labelsUi.fromForm(req.body);
if (labels !== undefined) values.labels = labels === null ? ['not-a-label'] : labels;
if (labels !== undefined) values.labels = labels === null ? ['not-a-label'] : labels;
try {
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);
const out = await forum.createThread(req.viewer, req.params.space, values);
discovery.resetCaches();
discovery.resetCaches();
res.redirect(303, out.thread.url);
res.redirect(303, out.thread.url);
@@ -242,10 +239,10 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -242,10 +239,10 @@ function createForumRoutes({ forum, viewers, config }) {
try { await renderThread(req, res); } catch (err) { failPage(req, res, err, next); }
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);
const draft = String((req.body || {}).body || '').slice(0, 40_000);
try {
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 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 });
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);
res.redirect(303, out.url);
@@ -218,11 +218,11 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
@@ -218,11 +218,11 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
const indexable = space.visibility === 'public' && !gated;
const indexable = space.visibility === 'public' && !gated;
const first = (page - 1) * perPage;
const first = (page - 1) * perPage;
const replyBlock = viewer.can_reply
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>
<h2 class="h3">Reply</h2>
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${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>
<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()}
${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\`, > quotes, [links](https://…)</span></div>
<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\`, > quotes, [links](https://…)</span></div>
</form>`
</form>`
@@ -264,7 +264,8 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
@@ -264,7 +264,8 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
historyType: 'page',
historyType: 'page',
historyTitle: thread.title,
historyTitle: thread.title,
footerVariant: 'compact',
footerVariant: 'compact',
- styles: ['kit.css'],
+ styles: ['kit.css', ...(attachmentsEnabled ? ['ov-upload.css'] : [])],
+ ...(attachmentsEnabled ? { scripts: ['ov-upload.js'] } : {}),
...(labelBox ? { pageCss: ['labels.css'] } : {}),
...(labelBox ? { pageCss: ['labels.css'] } : {}),
feeds: [
feeds: [
...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : []),
...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : []),
@@ -245,7 +245,11 @@ function reactionBar(p, base, page, catalogue = []) {
@@ -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 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) {
function voteForm(base, thread, viewer) {
if (!viewer.can_vote) return `<div class="vote"><span class="vote-score" title="Score">${num(thread.score)}</span></div>`;
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
@@ -279,10 +283,10 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
const indexable = space.visibility === 'public' && !gated;
const indexable = space.visibility === 'public' && !gated;
const first = (page - 1) * perPage;
const first = (page - 1) * perPage;
const replyBlock = viewer.can_reply
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>` : ''}
${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>
<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()}
${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://…), > quotes and lists.</span></div>
<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://…), > quotes and lists.</span></div>
</form>`
</form>`
@@ -325,7 +329,8 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -325,7 +329,8 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
historyType: 'page',
historyType: 'page',
historyTitle: thread.title,
historyTitle: thread.title,
footerVariant: 'compact',
footerVariant: 'compact',
- styles: ['kit.css'],
+ styles: ['kit.css', ...(attachmentsEnabled ? ['ov-upload.css'] : [])],
+ ...(attachmentsEnabled ? { scripts: ['ov-upload.js'] } : {}),
...(labelBox ? { pageCss: ['labels.css'] } : {}),
...(labelBox ? { pageCss: ['labels.css'] } : {}),
feeds: [
feeds: [
...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : []),
...(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
@@ -342,13 +347,13 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
// ── /forum/:space/new ────────────────────────────────────────────
// ── /forum/:space/new ────────────────────────────────────────────
function newThreadPage({ space, user, values = {}, error = null, categories = [], attachments = false }) {
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 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>` : ''}
${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>' : ''}
${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>
<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}
${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>
<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)}
${pasteField(values.pastes)}
${labelsUi.picker({ current: values.labels || [], id: 'new-labels' })}
${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>
<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}`;
@@ -371,6 +376,7 @@ ${form}`;
active: 'spaces',
active: 'spaces',
footerVariant: 'compact',
footerVariant: 'compact',
...(user ? { pageCss: ['labels.css'] } : {}),
...(user ? { pageCss: ['labels.css'] } : {}),
+ ...(attachments ? { styles: ['ov-upload.css'], scripts: ['ov-upload.js'] } : {}),
body,
body,
});
});
}
}
@@ -114,6 +114,7 @@ function footerInit(opts) {
@@ -114,6 +114,7 @@ function footerInit(opts) {
* historyType ('page'|'paste'), historyTitle, footerVariant ('full'|'compact'), bodyClass,
* 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']),
* 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']),
* 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)
* published/modified (ISO, for article:*), noFrame (error pages during outages)
*/
*/
function renderPage(o) {
function renderPage(o) {
@@ -150,6 +151,7 @@ function renderPage(o) {
@@ -150,6 +151,7 @@ function renderPage(o) {
...(o.inlineStyles || []).map((css) => `<style>${css}</style>`),
...(o.inlineStyles || []).map((css) => `<style>${css}</style>`),
`<link rel="stylesheet" href="${asset('css/community.css')}">`,
`<link rel="stylesheet" href="${asset('css/community.css')}">`,
...(o.pageCss || []).map((name) => `<link rel="stylesheet" href="${asset(`css/${name}`)}">`),
...(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>`,
`<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.
// 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'))}">`,
`<meta name="ov-items" content="${escapeHtml(ovServe.url('items.js'))} ${escapeHtml(ovServe.url('items.css'))}">`,
@@ -680,7 +680,11 @@ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
@@ -680,7 +680,11 @@ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
</div>
</div>
</div>
</div>
<div data-pane="image" hidden>
<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>
<label class="field"><span>Description</span><textarea name="description" rows="3" maxlength="2000" placeholder="What is this a picture of?"></textarea></label>
</div>
</div>
<section class="ovk-section pf-options" aria-labelledby="pf-options-h">
<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>` : ''}
@@ -702,7 +706,8 @@ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
robots: 'noindex,follow',
robots: 'noindex,follow',
active: 'new',
active: 'new',
footerVariant: 'compact',
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,
body,
});
});
}
}
@@ -114,26 +114,46 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
@@ -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] });
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');
assert.strictEqual(r.status, 400, 'already on a post');
const html = (await t.get(`/forum/general/${thread.slug}`, { cookies: [`ov_token=${samJwt}`] })).text;
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));
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
const page = (await t.get(`/api/v1/spaces/general/threads/${thread.slug}`)).json();
const page = (await t.get(`/api/v1/spaces/general/threads/${thread.slug}`)).json();
const reply = page.posts.find((p) => p.body_markdown === 'Two more');
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;
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 () => {
await check('without the service principal, attaching is refused and the forms have no file field', async () => {