openvibers/openvibe.space Commits

Use shared upload kit for Space deploys (#291)

OP OpenVibers committed 3 days ago · 8c04898135 · 6 files, +129 −8

Changes

ddiff --git a/public/js/hosting-upload.js b/public/js/hosting-upload.js
nnew file mode 100644
iindex 0000000..6688ce3
---- /dev/null
++++ b/public/js/hosting-upload.js
@@@ -0,0 +1,107 @@
++/* Space deploy form: shared pickers, with the native folder FileList kept for relative paths. */
++(function () {
++ 'use strict';
++ var form = document.querySelector('form.hd-form[enctype="multipart/form-data"]');
++ var source = form && form.querySelector('.hd-sources');
++ var error = form && form.querySelector('[data-deploy-error]');
++ var progressRoot = form && form.querySelector('[data-deploy-progress]');
++ if (!form || !source || !error || !progressRoot || !window.OVUpload || !window.DataTransfer) return;
++ var limits = { files: Number(source.dataset.maxFiles), file: Number(source.dataset.maxFileBytes), upload: Number(source.dataset.maxUploadBytes) };
++ var inputs = { folder: form.querySelector('[data-deploy-folder]'), archive: form.querySelector('[data-deploy-archive]') };
++ var roots = { folder: form.querySelector('[data-folder-picker]'), archive: form.querySelector('[data-archive-picker]') };
++ var pickers = {};
++ var selected = { folder: [], archive: [] };
++ var sending = false;
++ var meter = OVUpload.progress(progressRoot, { onRetry: send });
++ function fail(message) { error.textContent = message; error.hidden = false; }
++ function empty(name) {
++ selected[name] = [];
++ inputs[name].value = '';
++ if (pickers[name]) {
++ pickers[name].rows.querySelectorAll('.ov-upload__row').forEach(function (row) { row.dispatchEvent(new Event('remove')); });
++ pickers[name].rows.replaceChildren();
++ }
++ }
++ function validate(name, files) {
++ if (!files.length) return 'Choose a folder or a .tar, .tgz or .tar.gz archive.';
++ if (name === 'folder' && files.some(function (file) { return !file.webkitRelativePath || !file.webkitRelativePath.includes('/'); })) return 'Choose a folder with the folder button so file paths are kept.';
++ if (name === 'archive' && (files.length !== 1 || !/\.(tar|tgz|tar\.gz)$/i.test(files[0].name))) return 'Choose one .tar, .tgz or .tar.gz archive.';
++ if (name === 'folder' && files.length > limits.files) return 'This project allows at most ' + limits.files + ' files in one deploy.';
++ if (name === 'folder' && files.some(function (file) { return file.size > limits.file; })) return 'A file is larger than this project’s per-file limit of ' + OVUpload.size(limits.file) + '.';
++ if (files.reduce(function (total, file) { return total + file.size; }, 0) > limits.upload) return 'This upload is larger than ' + OVUpload.size(limits.upload) + '.';
++ return '';
++ }
++ function accept(name, fileList) {
++ var files = Array.from(fileList || []);
++ var problem = validate(name, files);
++ if (problem) { fail(problem); return false; }
++ var other = name === 'folder' ? 'archive' : 'folder';
++ empty(other); empty(name);
++ selected[name] = files;
++ inputs[name].files = fileList;
++ error.hidden = true;
++ meter.update({ state: 'waiting', done: 0, total: files.reduce(function (sum, file) { return sum + file.size; }, 0) });
++ return true;
++ }
++ Object.keys(inputs).forEach(function (name) {
++ var input = inputs[name];
++ var root = roots[name];
++ var picker = OVUpload.picker(root, {
++ label: name === 'folder' ? 'Choose a folder' : 'Choose an archive',
++ accept: name === 'archive' ? input.accept : '', multiple: name === 'folder',
++ onRemove: function (file) {
++ selected[name] = selected[name].filter(function (item) { return item !== file; });
++ var transfer = new DataTransfer();
++ selected[name].forEach(function (item) { transfer.items.add(item); });
++ input.files = transfer.files;
++ if (!selected[name].length) meter.update({ state: 'waiting', done: 0, total: 0 });
++ }
++ });
++ pickers[name] = picker;
++ picker.input.setAttribute('aria-describedby', 'deploy-limit');
++ if (name === 'folder') picker.input.setAttribute('webkitdirectory', '');
++ picker.input.addEventListener('change', function (event) {
++ if (!accept(name, picker.input.files)) { event.stopImmediatePropagation(); picker.input.value = ''; }
++ }, true);
++ root.addEventListener('drop', function (event) {
++ if (!event.dataTransfer || !accept(name, event.dataTransfer.files)) {
++ event.preventDefault(); event.stopImmediatePropagation();
++ }
++ }, true);
++ input.hidden = true;
++ });
++
++ function send() {
++ if (sending) return;
++ var files = selected.folder.length ? selected.folder : selected.archive;
++ var name = selected.folder.length ? 'folder' : 'archive';
++ var problem = validate(name, files);
++ if (problem) return fail(problem);
++ sending = true; error.hidden = true;
++ var button = form.querySelector('[type="submit"]');
++ button.disabled = true;
++ form.querySelectorAll('.ov-upload__remove').forEach(function (remove) { remove.disabled = true; });
++ var total = files.reduce(function (sum, file) { return sum + file.size; }, 0);
++ var xhr = new XMLHttpRequest();
++ xhr.open('POST', form.action);
++ xhr.upload.onprogress = function (event) { if (event.lengthComputable) meter.update({ state: 'sending', done: event.loaded, total: event.total }); };
++ xhr.upload.onload = function () { meter.update({ state: 'processing', done: total, total: total, message: 'Checking the deploy' }); };
++ function retry(message) {
++ sending = false; button.disabled = false;
++ form.querySelectorAll('.ov-upload__remove').forEach(function (remove) { remove.disabled = false; });
++ meter.update({ state: 'failed', message: message });
++ }
++ xhr.onerror = function () { retry('The connection stopped. Try again.'); };
++ xhr.onload = function () {
++ if (xhr.status >= 200 && xhr.status < 400 && xhr.responseURL && xhr.responseURL !== form.action) {
++ meter.update({ state: 'done', done: total, total: total });
++ window.location.assign(xhr.responseURL); return;
++ }
++ var page = new DOMParser().parseFromString(xhr.responseText || '', 'text/html');
++ retry(page.querySelector('.hd-flash, .hd-error, h1')?.textContent || 'The deploy could not be uploaded. Try again.');
++ };
++ meter.update({ state: 'sending', done: 0, total: total });
++ xhr.send(new FormData(form));
++ }
++ form.addEventListener('submit', function (event) { event.preventDefault(); send(); });
++})();
ddiff --git a/server/http/dashboard.js b/server/http/dashboard.js
iindex 176832b..9888dc4 100644
---- a/server/http/dashboard.js
++++ b/server/http/dashboard.js
@@@ -26,8 +26,8 @@ function createDashboard(ctx) {
if (ctx.actorLimits) router.use(ctx.actorLimits);
router.use((req, res, next) => { privateNoStore(res); next(); });
-- function page(req, res, status, title, body, notice, { indexable = false, styles, css, jsonLd } = {}) {
-- res.status(status).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, notice, indexable, styles, css, jsonLd }));
++ function page(req, res, status, title, body, notice, { indexable = false, styles, css, jsonLd, uploadKit = false } = {}) {
++ res.status(status).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, notice, indexable, styles, css, jsonLd, uploadKit }));
}
function fail(req, res, err) {
@@@ -84,11 +84,12 @@ function createDashboard(ctx) {
const cfg = await siteConfig.getForSite(site.id);
page(req, res, 200, site.name, pages.site({
site, project, role, url: siteUrl(site), csrf: csrfToken(config, req.viewer), now: ctx.store.now(),
++ uploadLimits: { ...(await deploys.limitsFor(project)), maxUploadBytes: config.uploads.maxUploadBytes },
deploys: await deploys.list(site.id, 50), activations: await deploys.activationsOf(site.id, 10),
domains: (await domains.listForSite(site.id)).map((d) => ({ domain: d, instructions: domains.instructions(d, site) })),
siteConfig: { ...cfg, headersText: siteConfig.headersToText(cfg.headers), redirectsText: siteConfig.redirectsToText(cfg.redirects) },
source: await siteSources.forSite(site.id),
-- }), takedownNotice(await ctx.takedowns.ofSite(site)) || notice(req));
++ }), takedownNotice(await ctx.takedowns.ofSite(site)) || notice(req), { uploadKit: true });
}));
router.get('/deploys/:id', view(async (req, res) => {
ddiff --git a/server/render/hosting-layout.js b/server/render/hosting-layout.js
iindex 3b68505..ff2bac1 100644
---- a/server/render/hosting-layout.js
++++ b/server/render/hosting-layout.js
@@@ -2,6 +2,6 @@
const { renderPage: spacePage, escapeHtml: esc } = require('./layout');
function renderPage(o) {
const notice = o.notice ? `<p role="status" class="hd-flash hd-flash--${o.notice.kind === 'error' ? 'bad' : 'ok'}">${esc(o.notice.text)}</p>` : '';
-- return spacePage({ title: o.title || 'Hosting', canonicalPath: o.path || '/dashboard', robots: o.indexable ? 'index,follow' : 'noindex,nofollow', body: `${notice}${o.body || ''}`, styles: o.styles || [], localStyles: ['css/hosting.css'], bodyClass: 'hosting-dashboard' });
++ return spacePage({ title: o.title || 'Hosting', canonicalPath: o.path || '/dashboard', robots: o.indexable ? 'index,follow' : 'noindex,nofollow', body: `${notice}${o.body || ''}`, styles: o.uploadKit ? [...(o.styles || []), 'ov-upload.css'] : o.styles || [], localStyles: ['css/hosting.css'], bodyClass: 'hosting-dashboard', uploadKit: o.uploadKit });
}
module.exports = { renderPage, esc };
ddiff --git a/server/render/hosting-pages.js b/server/render/hosting-pages.js
iindex 91cf987..0ff651a 100644
---- a/server/render/hosting-pages.js
++++ b/server/render/hosting-pages.js
@@@ -105,7 +105,7 @@ function domainBlock(d, instructions, csrf) {
return `${body}</li>`;
}
--function site({ site: s, project: p, role, deploys, activations, domains, siteConfig, csrf, url, now = Date.now() }) {
++function site({ site: s, project: p, role, deploys, activations, domains, siteConfig, csrf, url, uploadLimits, now = Date.now() }) {
const canMaintain = role === 'owner' || role === 'maintainer' || role === 'staff';
const canDeploy = role !== 'staff';
// The site's one live preview (plan T12 J4): served to project members on this dashboard until it expires.
@@@ -127,10 +127,11 @@ function site({ site: s, project: p, role, deploys, activations, domains, siteCo
return `<div class="hd-head">${crumbs([['/dashboard', 'Projects'], [`/projects/${p.id}`, p.name]])}
${kit.pageHead({ title: `${s.name}`, icon: 'server', back: { href: '/', label: 'Home' } })}
<p class="hd-chips"><a class="hd-url hd-url--big" href="${esc(url)}" rel="noopener">${esc(url.replace(/^https?:\/\//, ''))} ${icon('fa-arrow-up-right-from-square')}</a> ${status} <code>${esc(s.id)}</code></p></div>
--${canDeploy ? card('Upload a deploy', form(`/sites/${s.id}/deploys`, csrf, `<div class="hd-sources">
--<fieldset class="hd-source"><legend>${icon('fa-folder-open')} A folder</legend><input type="file" name="files" multiple webkitdirectory aria-label="A folder to upload"><input type="hidden" name="strip" value="folder"></fieldset>
--<fieldset class="hd-source"><legend>${icon('fa-box-archive')} …or an archive (.tar.gz / .tar)</legend><input type="file" name="archive" accept=".tar,.tgz,.gz,application/gzip,application/x-tar" aria-label="An archive to upload (.tar.gz or .tar)"></fieldset>
++${canDeploy ? card('Upload a deploy', form(`/sites/${s.id}/deploys`, csrf, `<div class="hd-sources" data-max-files="${uploadLimits.maxFiles}" data-max-file-bytes="${uploadLimits.maxFileBytes}" data-max-upload-bytes="${uploadLimits.maxUploadBytes}">
++<fieldset class="hd-source"><legend>${icon('fa-folder-open')} A folder</legend><input type="file" name="files" multiple webkitdirectory aria-label="A folder to upload" data-deploy-folder><div data-folder-picker></div><input type="hidden" name="strip" value="folder"></fieldset>
++<fieldset class="hd-source"><legend>${icon('fa-box-archive')} …or an archive (.tar.gz / .tar)</legend><input type="file" name="archive" accept=".tar,.tgz,.gz,application/gzip,application/x-tar" aria-label="An archive to upload (.tar.gz or .tar)" data-deploy-archive><div data-archive-picker></div></fieldset>
</div>
++<p class="hd-muted" id="deploy-limit">Up to ${uploadLimits.maxFiles.toLocaleString('en-US')} files, ${bytes(uploadLimits.maxFileBytes)} per file and ${bytes(uploadLimits.maxUploadBytes)} per upload. Archives must be .tar, .tgz or .tar.gz.</p><p role="alert" data-deploy-error hidden></p><div data-deploy-progress></div>
<label class="hd-field hd-field--narrow">Deploy from subfolder (optional) <input name="root" placeholder="dist"></label>
<fieldset class="hd-choices"><legend>When it is ready</legend>
<label class="hd-choice"><input type="radio" name="mode" value="activate" checked><span><strong>Activate it</strong><span class="hd-muted">Live as soon as it is checked.</span></span></label>
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 4e19297..a9f0ea1 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -137,6 +137,7 @@ function renderPage(o) {
navbar: nav, home: '/', navLinks: nav.links.map(({ label, href }) => ({ label, href })),
footer: { service: 'space', variant: 'auto', path: o.canonicalPath, updates: '/updates' }, // full on the front page only (Shared 4.19)
head: [`<script src="${ovServe.url('kit-web.js')}" defer></script>`,
++ o.uploadKit ? `<script src="${ovServe.url('ov-upload.js')}" defer></script><script src="${asset('js/hosting-upload.js')}" defer></script>` : '',
o.published ? `<meta property="article:published_time" content="${escapeHtml(o.published)}">` : '',
o.modified ? `<meta property="article:modified_time" content="${escapeHtml(o.modified)}">` : '',
appIcon.headTags({ site: 'space', iconBase: '/assets' }),
ddiff --git a/test/hosting-quota-auth.test.js b/test/hosting-quota-auth.test.js
iindex 86a9e90..07a6120 100644
---- a/test/hosting-quota-auth.test.js
++++ b/test/hosting-quota-auth.test.js
@@@ -18,6 +18,17 @@ const DAY = 24 * 3600 * 1000;
const site = await t.site(alice, project.id, 'quota-site');
const setQuota = (q) => t.api('PUT', `/api/v1/projects/${project.id}/quota`, { as: staff, json: q });
++ await check('site upload form shows the shared picker and its live project limits', async () => {
++ const page = await t.api('GET', `/sites/${site.id}`, { session: alice });
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /ov-upload\.css/);
++ assert.match(page.text, /ov-upload\.js/);
++ assert.match(page.text, /js\/hosting-upload\.js/);
++ assert.match(page.text, /data-folder-picker/);
++ assert.match(page.text, /data-archive-picker/);
++ assert.match(page.text, /data-max-upload-bytes="65536"/);
++ });
++
await check('only staff change quotas; the owner reads them', async () => {
const mine = await t.api('PUT', `/api/v1/projects/${project.id}/quota`, { as: alice, json: { deploys_per_day: 1000 } });
assert.strictEqual(mine.status, 403);