openvibers/openvibe.space Commits
Use shared upload kit for Space deploys (#291)
Changes
nnew file mode 100644@@@ -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(); });++})();@@@ -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) => {@@@ -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 };@@@ -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, siteCoreturn `<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>@@@ -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' }),@@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -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(); });
+})();
@@ -26,8 +26,8 @@ function createDashboard(ctx) {
@@ -26,8 +26,8 @@ function createDashboard(ctx) {
if (ctx.actorLimits) router.use(ctx.actorLimits);
if (ctx.actorLimits) router.use(ctx.actorLimits);
router.use((req, res, next) => { privateNoStore(res); next(); });
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) {
function fail(req, res, err) {
@@ -84,11 +84,12 @@ function createDashboard(ctx) {
@@ -84,11 +84,12 @@ function createDashboard(ctx) {
const cfg = await siteConfig.getForSite(site.id);
const cfg = await siteConfig.getForSite(site.id);
page(req, res, 200, site.name, pages.site({
page(req, res, 200, site.name, pages.site({
site, project, role, url: siteUrl(site), csrf: csrfToken(config, req.viewer), now: ctx.store.now(),
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),
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) })),
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) },
siteConfig: { ...cfg, headersText: siteConfig.headersToText(cfg.headers), redirectsText: siteConfig.redirectsToText(cfg.redirects) },
source: await siteSources.forSite(site.id),
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) => {
router.get('/deploys/:id', view(async (req, res) => {
@@ -2,6 +2,6 @@
@@ -2,6 +2,6 @@
const { renderPage: spacePage, escapeHtml: esc } = require('./layout');
const { renderPage: spacePage, escapeHtml: esc } = require('./layout');
function renderPage(o) {
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>` : '';
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 };
module.exports = { renderPage, esc };
@@ -105,7 +105,7 @@ function domainBlock(d, instructions, csrf) {
@@ -105,7 +105,7 @@ function domainBlock(d, instructions, csrf) {
return `${body}</li>`;
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 canMaintain = role === 'owner' || role === 'maintainer' || role === 'staff';
const canDeploy = 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.
// 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
@@ -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]])}
return `<div class="hd-head">${crumbs([['/dashboard', 'Projects'], [`/projects/${p.id}`, p.name]])}
${kit.pageHead({ title: `${s.name}`, icon: 'server', back: { href: '/', label: 'Home' } })}
${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>
<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>
</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>
<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>
<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>
<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>
@@ -137,6 +137,7 @@ function renderPage(o) {
@@ -137,6 +137,7 @@ function renderPage(o) {
navbar: nav, home: '/', navLinks: nav.links.map(({ label, href }) => ({ label, href })),
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)
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>`,
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.published ? `<meta property="article:published_time" content="${escapeHtml(o.published)}">` : '',
o.modified ? `<meta property="article:modified_time" content="${escapeHtml(o.modified)}">` : '',
o.modified ? `<meta property="article:modified_time" content="${escapeHtml(o.modified)}">` : '',
appIcon.headTags({ site: 'space', iconBase: '/assets' }),
appIcon.headTags({ site: 'space', iconBase: '/assets' }),
@@ -18,6 +18,17 @@ const DAY = 24 * 3600 * 1000;
@@ -18,6 +18,17 @@ const DAY = 24 * 3600 * 1000;
const site = await t.site(alice, project.id, 'quota-site');
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 });
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 () => {
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 } });
const mine = await t.api('PUT', `/api/v1/projects/${project.id}/quota`, { as: alice, json: { deploys_per_day: 1000 } });
assert.strictEqual(mine.status, 403);
assert.strictEqual(mine.status, 403);