diff --git a/.gitignore b/.gitignore index 00c8845..3868498 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ ssl/*key data/minio node_modules dist +start-local-* \ No newline at end of file diff --git a/app/controllers/admin.js b/app/controllers/admin.js index 33bc8e9..51c4449 100644 --- a/app/controllers/admin.js +++ b/app/controllers/admin.js @@ -84,6 +84,8 @@ class AdminController extends SiteController { constellation: await coreNodeService.getConstellationStats(), }; + res.locals.pageTitle = `Admin Dashbord for ${this.dtp.config.site.name}`; + res.render('admin/index'); } } diff --git a/app/controllers/admin/otp.js b/app/controllers/admin/otp.js index aa319eb..0363aa9 100644 --- a/app/controllers/admin/otp.js +++ b/app/controllers/admin/otp.js @@ -26,13 +26,9 @@ class OtpAdminController extends SiteController { }); // router.param('otp', this.populateOtp.bind(this)); - - - + router.get('/', this.getIndex.bind(this)); - // router.delete('/:postId', this.deletePost.bind(this)); - return router; } diff --git a/app/controllers/admin/settings.js b/app/controllers/admin/settings.js index e5eefcb..2543106 100644 --- a/app/controllers/admin/settings.js +++ b/app/controllers/admin/settings.js @@ -16,6 +16,13 @@ class SettingsController extends SiteController { async start ( ) { const router = express.Router(); + + const imageUpload = this.createMulter('uploads', { + limits: { + fileSize: 1024 * 1000 * 12, + }, + }); + router.use(async (req, res, next) => { res.locals.currentView = 'admin'; res.locals.adminView = 'settings'; @@ -23,9 +30,13 @@ class SettingsController extends SiteController { }); router.post('/', this.postUpdateSettings.bind(this)); + + router.post('/images/updateSiteIcon', imageUpload.single('imageFile'), this.postUpdateSiteIcon.bind(this)); router.get('/', this.getSettingsView.bind(this)); + router.get('/images', this.getImageSettings.bind(this)); + return router; } @@ -47,6 +58,33 @@ class SettingsController extends SiteController { return next(error); } } + + async getImageSettings (req, res, next) { + const { image: imageService } = this.dtp.services; + res.locals.adminView = 'image-settings'; + res.locals.pageTitle = `Image settings for ${this.dtp.config.site.name}`; + try { + res.locals.siteIcon = await imageService.getSiteIconInfo(); + res.render('admin/settings/images'); + } catch (error) { + return next(error); + } + } + + async postUpdateSiteIcon (req, res, next) { + const { image: imageService } = this.dtp.services; + try { + res.locals.image = await imageService.updateSiteIcon(req.body, req.file); + res.status(200).json({ + success: true, + imageId: res.locals.image.toString(), + }); + } catch (error) { + this.log.error('failed to create image', { error }); + return next(error); + } + } + } module.exports = { diff --git a/app/services/image.js b/app/services/image.js index ea46e51..4547483 100644 --- a/app/services/image.js +++ b/app/services/image.js @@ -16,7 +16,7 @@ const { SiteService, SiteAsync } = require('../../lib/site-lib'); class ImageService extends SiteService { - constructor (dtp) { + constructor(dtp) { super(dtp, module.exports); this.populateImage = [ { @@ -26,20 +26,20 @@ class ImageService extends SiteService { ]; } - async start ( ) { + async start() { await super.start(); await fs.promises.mkdir(process.env.DTP_IMAGE_WORK_PATH, { recursive: true }); } - async create (owner, imageDefinition, file) { + async create(owner, imageDefinition, file) { const NOW = new Date(); const { minio: minioService } = this.dtp.services; try { this.log.debug('processing uploaded image', { imageDefinition, file }); - - const sharpImage = await sharp(file.path); + + const sharpImage = sharp(file.path); const metadata = await sharpImage.metadata(); - + // create an Image model instance, but leave it here in application memory. // we don't persist it to the db until MinIO accepts the binary data. const image = new SiteImage(); @@ -49,12 +49,12 @@ class ImageService extends SiteService { image.size = file.size; image.file.bucket = process.env.MINIO_IMAGE_BUCKET; image.metadata = this.makeImageMetadata(metadata); - + const imageId = image._id.toString(); const ownerId = owner._id.toString(); const fileKey = `/${ownerId.slice(0, 3)}/${ownerId}/${imageId.slice(0, 3)}/${imageId}`; image.file.key = fileKey; - + // upload the image file to MinIO const response = await minioService.uploadFile({ bucket: image.file.bucket, @@ -65,13 +65,13 @@ class ImageService extends SiteService { 'Content-Length': file.size, }, }); - + // store the eTag from MinIO in the Image model image.file.etag = response.etag; - + // save the Image model to the db await image.save(); - + this.log.info('processed uploaded image', { ownerId, imageId, fileKey }); return image.toObject(); } catch (error) { @@ -83,14 +83,14 @@ class ImageService extends SiteService { } } - async getImageById (imageId) { + async getImageById(imageId) { const image = await SiteImage .findById(imageId) .populate(this.populateImage); return image; } - async getRecentImagesForOwner (owner) { + async getRecentImagesForOwner(owner) { const images = await SiteImage .find({ owner: owner._id }) .sort({ created: -1 }) @@ -100,7 +100,7 @@ class ImageService extends SiteService { return images; } - async deleteImage (image) { + async deleteImage(image) { const { minio: minioService } = this.dtp.services; this.log.debug('removing image from storage', { bucket: image.file.bucket, key: image.file.key }); @@ -110,13 +110,13 @@ class ImageService extends SiteService { await SiteImage.deleteOne({ _id: image._id }); } - async processImageFile (owner, file, outputs, options) { + async processImageFile(owner, file, outputs, options) { this.log.debug('processing image file', { owner, file, outputs }); const sharpImage = sharp(file.path); return this.processImage(owner, sharpImage, outputs, options); } - async processImage (owner, sharpImage, outputs, options) { + async processImage(owner, sharpImage, outputs, options) { const NOW = new Date(); const service = this; const { minio: minioService } = this.dtp.services; @@ -128,7 +128,7 @@ class ImageService extends SiteService { const imageWorkPath = process.env.DTP_IMAGE_WORK_PATH || '/tmp'; const metadata = await sharpImage.metadata(); - async function processOutputImage (output) { + async function processOutputImage(output) { const outputMetadata = service.makeImageMetadata(metadata); outputMetadata.width = output.width; outputMetadata.height = output.height; @@ -149,7 +149,7 @@ class ImageService extends SiteService { height: output.height, options: output.resizeOptions, }) - ; + ; chain = chain[output.format](output.formatParameters); output.filePath = path.join(imageWorkPath, `${image._id}.${output.width}x${output.height}.${output.format}`); @@ -165,11 +165,11 @@ class ImageService extends SiteService { const imageId = image._id.toString(); const ownerId = owner._id.toString(); const fileKey = `/${ownerId.slice(0, 3)}/${ownerId}/images/${imageId.slice(0, 3)}/${imageId}.${output.format}`; - + image.file.bucket = process.env.MINIO_IMAGE_BUCKET; image.file.key = fileKey; image.size = output.stat.size; - + // upload the image file to MinIO const response = await minioService.uploadFile({ bucket: image.file.bucket, @@ -180,13 +180,13 @@ class ImageService extends SiteService { 'Content-Length': output.stat.size, }, }); - + // store the eTag from MinIO in the Image model image.file.etag = response.etag; - + // save the Image model to the db await image.save(); - + service.log.info('processed uploaded image', { ownerId, imageId, fileKey }); if (options.removeWorkFiles) { @@ -216,7 +216,73 @@ class ImageService extends SiteService { await SiteAsync.each(outputs, processOutputImage, 4); } - makeImageMetadata (metadata) { + async getSiteIconInfo() { + const siteDomain = this.dtp.config.site.domainKey; + + const siteImagesDir = path.join(this.dtp.config.root, 'client', 'img'); + const siteIconDir = path.join(siteImagesDir, 'icon', siteDomain); + + let icon; + + try { + + await fs.promises.access(siteIconDir); + const iconMetadata = await sharp(path.join(siteIconDir, 'icon-512x512.png')).metadata(); + icon = { + metadata: iconMetadata, + path: `/img/icon/${siteDomain}/icon-512x512.png`, + }; + + } catch (error) { + + icon = null; + } + + return icon; + } + + async updateSiteIcon(imageDefinition, file) { + + this.log.debug('updating site icon', { imageDefinition, file }); + try { + + + const siteDomain = this.dtp.config.site.domainKey; + + const siteImagesDir = path.join(this.dtp.config.root, 'client', 'img'); + + const siteIconDir = path.join(siteImagesDir, 'icon', siteDomain); + + const sourceIconFilePath = file.path; + + const sizes = [16, 32, 36, 48, 57, 60, 70, 72, 76, 96, 114, 120, 144, 150, 152, 180, 192, 256, 310, 384, 512]; + + await fs.promises.mkdir(siteIconDir, { force: true, recursive: true }); + + for (var size of sizes) { + await sharp(sourceIconFilePath).resize({ + fit: sharp.fit.contain, + width: size, + height: size, + }).png() + .toFile(path.join(siteIconDir, `icon-${size}x${size}.png`)); + } + + await fs.promises.cp(sourceIconFilePath, path.join(siteIconDir, `${siteDomain}.png`)); + + await fs.promises.cp(sourceIconFilePath, path.join(siteImagesDir, 'social-cards', `${siteDomain}.png`)); + return path.join(siteIconDir, 'icon-512x512.png'); + } catch (error) { + this.log.error('failed to update site icon', { error }); + throw error; + } finally { + this.log.info('removing uploaded image from local file system', { file: file.path }); + await fs.promises.rm(file.path); + } + + } + + makeImageMetadata(metadata) { return { format: metadata.format, size: metadata.size, diff --git a/app/services/otp-auth.js b/app/services/otp-auth.js index 84d8b31..69cfbe2 100644 --- a/app/services/otp-auth.js +++ b/app/services/otp-auth.js @@ -219,7 +219,7 @@ class OtpAuthService extends SiteService { async destroyOtpSession (req, serviceName) { delete req.session.otp[serviceName]; - await this.saveSession(req) + await this.saveSession(req); } async removeForUser (user, serviceName) { diff --git a/app/views/admin/components/file-upload-image.pug b/app/views/admin/components/file-upload-image.pug new file mode 100644 index 0000000..3ca78f6 --- /dev/null +++ b/app/views/admin/components/file-upload-image.pug @@ -0,0 +1,51 @@ +mixin renderFileUploadImage (actionUrl, containerId, imageId, imageClass, defaultImage, currentImage, cropperOptions) + div(id= containerId).dtp-file-upload + form(method="POST", action= actionUrl, enctype="multipart/form-data", onsubmit= "return dtp.app.submitImageForm(event);").uk-form + .uk-margin + .uk-card.uk-card-default.uk-card-small + .uk-card-body + div(uk-grid).uk-flex-middle.uk-flex-center + div(class="uk-width-1-1 uk-width-auto@m") + .upload-image-container.size-512 + if !!currentImage + img(id= imageId, src= currentImage.path, class= imageClass).sb-large + else + img(id= imageId, src= defaultImage, class= imageClass) + + div(class="uk-width-1-1 uk-width-auto@m") + .uk-text-small.uk-margin + #file-select + .uk-margin(class="uk-text-center uk-text-left@m") + span.uk-text-middle Select an image + div(uk-form-custom).uk-margin-small-left + input( + type="file", + formenctype="multipart/form-data", + accept=".jpg,.png,image/jpeg,image/png", + data-file-select-container= containerId, + data-file-select="test-image-upload", + data-file-size-element= "file-size", + data-file-max-size= 15 * 1024000, + data-image-id= imageId, + data-cropper-options= cropperOptions, + onchange="return dtp.app.selectImageFile(event);", + ) + button(type="button", tabindex="-1").uk-button.uk-button-default Select + + #file-info(class="uk-text-center uk-text-left@m", hidden) + #file-name.uk-text-bold + if currentImage + div resolution: #[span#image-resolution-w= numeral(currentImage.metadata.width).format('0,0')]x#[span#image-resolution-h= numeral(currentImage.metadata.height).format('0,0')] + div size: #[span#file-size= numeral(currentImage.metadata.size).format('0,0.00b')] + div last modified: #[span#file-modified= moment(currentImage.created).format('MMM DD, YYYY')] + else + div resolution: #[span#image-resolution-w 512]x#[span#image-resolution-h 512] + div size: #[span#file-size N/A] + div last modified: #[span#file-modified N/A] + + .uk-card-footer + div(class="uk-flex-center", uk-grid) + #file-save-btn(hidden).uk-width-auto + button( + type="submit", + ).uk-button.uk-button-primary Save diff --git a/app/views/admin/components/menu.pug b/app/views/admin/components/menu.pug index b78195e..2adc469 100644 --- a/app/views/admin/components/menu.pug +++ b/app/views/admin/components/menu.pug @@ -12,6 +12,12 @@ ul(uk-nav).uk-nav-default span.nav-item-icon i.fas.fa-cog span.uk-margin-small-left Settings + + li(class={ 'uk-active': (adminView === 'image-settings') }) + a(href="/admin/settings/images") + span.nav-item-icon + i.fas.fa-image + span.uk-margin-small-left Image Settings li(class={ 'uk-active': (adminView === 'otp') }) a(href="/admin/otp") diff --git a/app/views/admin/settings/images.pug b/app/views/admin/settings/images.pug new file mode 100644 index 0000000..00220bf --- /dev/null +++ b/app/views/admin/settings/images.pug @@ -0,0 +1,28 @@ +extends ../layouts/main +block vendorcss + link(rel='stylesheet', href=`/cropperjs/cropper.min.css?v=${pkg.version}`) +block vendorjs + script(src=`/cropperjs/cropper.min.js?v=${pkg.version}`) +block content + + include ../components/file-upload-image + + //- h2 Add or replace your site images here + div(uk-grid).uk-flex-middle + .uk-width-expand + fieldset + legend Site Icon + .uk-margin + if siteIcon + p.uk-card-title Replace your site icon below. + else + p.uk-card-title You do not currently have a site icon. Add one below. + +renderFileUploadImage( + `/admin/settings/images/updateSiteIcon`, + 'site-icon-upload', + 'site-icon-file', + 'site-icon-picture', + `/img/icon/dtp-base.png`, + siteIcon, + { aspectRatio: 1 }, + ) \ No newline at end of file diff --git a/app/views/otp/welcome.pug b/app/views/otp/welcome.pug index 2d4bb84..c03ac7d 100644 --- a/app/views/otp/welcome.pug +++ b/app/views/otp/welcome.pug @@ -41,7 +41,7 @@ block content .uk-margin button(type="submit").uk-button.dtp-button-primary.uk-border-pill Enable 2FA - div(class="uk-width-1-1 uk-text-center uk-text-left@m", hidden) + div(class="uk-width-1-1 uk-text-center uk-text-left@m") .uk-margin p Or, if your authenticator doesn't support scanning QR codes, you can enter the OTP configuration information shown here to begin displaying codes: pre( diff --git a/app/views/welcome/core-home.pug b/app/views/welcome/core-home.pug index bb313d9..7298915 100644 --- a/app/views/welcome/core-home.pug +++ b/app/views/welcome/core-home.pug @@ -3,26 +3,34 @@ block content section.uk-section.uk-section-default .uk-container - - .uk-card.uk-card-default - .uk-card-header - h1.uk-card-title Select Community - - .uk-card-body - div(uk-grid).uk-grid-small - each core in connectedCores - div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@xl") - //- pre= JSON.stringify(connectedCores, null, 2) - a(href=`/auth/core/${core._id}`).uk-display-block.uk-link-reset - .dtp-core-list-item.uk-border-rounded - div(uk-grid).uk-grid-small.uk-flex-middle - .uk-width-auto - img(src=`http://${core.meta.domain}/img/icon/dtp-core.svg`, style="width: 48px; height: auto;") - .uk-width-expand - .core-name= core.meta.name - .core-description= core.meta.description - + + if Array.isArray(hosts) && (hosts.length > 0) + .uk-card.uk-card-default + .uk-card-header + h1.uk-card-title Select Community + .uk-card-body + div(uk-grid).uk-grid-small + each core in connectedCores + div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@xl") + //- pre= JSON.stringify(connectedCores, null, 2) + a(href=`/auth/core/${core._id}`).uk-display-block.uk-link-reset + .dtp-core-list-item.uk-border-rounded + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-auto + img(src=`http://${core.meta.domain}/img/icon/dtp-core.svg`, style="width: 48px; height: auto;") + .uk-width-expand + .core-name= core.meta.name + .core-description= core.meta.description + .uk-card-footer div(uk-grid).uk-grid-small .uk-width-expand - +renderBackButton() \ No newline at end of file + +renderBackButton() + else + .uk-card.uk-card-default + .uk-card-header + h1.uk-card-title There are no communities connected to this site + .uk-card-footer + div(uk-grid).uk-grid-small + .uk-width-expand + +renderBackButton() \ No newline at end of file diff --git a/client/js/site-admin-app.js b/client/js/site-admin-app.js index 39d62f5..67d729f 100644 --- a/client/js/site-admin-app.js +++ b/client/js/site-admin-app.js @@ -415,6 +415,43 @@ export default class DtpSiteAdminHostStatsApp extends DtpApp { return false; } + + async submitImageForm (event) { + event.preventDefault(); + event.stopPropagation(); + + const formElement = event.currentTarget || event.target; + const form = new FormData(formElement); + + this.cropper.getCroppedCanvas().toBlob(async (imageData) => { + try { + form.append('imageFile', imageData, 'icon.png'); + + this.log.info('submitImageForm', 'updating site image', { event, action: formElement.action }); + const response = await fetch(formElement.action, { + method: formElement.method, + body: form, + }); + + if (!response.ok) { + let json; + try { + json = await response.json(); + } catch (error) { + throw new Error('Server error'); + } + throw new Error(json.message || 'Server error'); + } + + await this.processResponse(response); + window.location.reload(); + } catch (error) { + UIkit.modal.alert(`Failed to update site image: ${error.message}`); + } + }); + + return; + } } dtp.DtpSiteAdminHostStatsApp = DtpSiteAdminHostStatsApp; \ No newline at end of file