diff --git a/domain/backup/endpoint.go b/domain/backup/endpoint.go index a95ed94a..4c56ce48 100644 --- a/domain/backup/endpoint.go +++ b/domain/backup/endpoint.go @@ -32,11 +32,15 @@ package backup // operations. This is subject to further review. import ( + "bytes" "encoding/json" "fmt" + "github.com/documize/community/core/request" + "io" "io/ioutil" "net/http" "os" + "strconv" "github.com/documize/community/core/env" "github.com/documize/community/core/response" @@ -104,10 +108,13 @@ func (h *Handler) Backup(w http.ResponseWriter, r *http.Request) { return } + h.Runtime.Log.Info(fmt.Sprintf("Backup size pending download %d", len(bk))) + // Standard HTTP headers. w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`" ; `+`filename*="`+filename+`"`) w.Header().Set("Content-Length", fmt.Sprintf("%d", len(bk))) + // Custom HTTP header helps API consumer to extract backup filename cleanly // instead of parsing 'Content-Disposition' header. // This HTTP header is CORS white-listed. @@ -129,3 +136,51 @@ func (h *Handler) Backup(w http.ResponseWriter, r *http.Request) { os.Remove(filename) } } + +// Restore receives ZIP file for restore operation. +// Options are specified as HTTP query paramaters. +func (h *Handler) Restore(w http.ResponseWriter, r *http.Request) { + method := "system.restore" + ctx := domain.GetRequestContext(r) + + if !ctx.Administrator { + response.WriteForbiddenError(w) + h.Runtime.Log.Info(fmt.Sprintf("Non-admin attempted system restore operation (user ID: %s)", ctx.UserID)) + return + } + + h.Runtime.Log.Info(fmt.Sprintf("Restored attempted by user: %s", ctx.UserID)) + + overwriteOrg, err := strconv.ParseBool(request.Query(r, "org")) + if err != nil { + h.Runtime.Log.Info("Restore invoked without 'org' parameter") + response.WriteMissingDataError(w, method, "org=false/true missing") + return + } + + createUsers, err := strconv.ParseBool(request.Query(r, "users")) + if err != nil { + h.Runtime.Log.Info("Restore invoked without 'users' parameter") + response.WriteMissingDataError(w, method, "users=false/true missing") + return + } + + filedata, fileheader, err := r.FormFile("restore-file") + if err != nil { + response.WriteMissingDataError(w, method, "restore-file") + h.Runtime.Log.Error(method, err) + return + } + + b := new(bytes.Buffer) + _, err = io.Copy(b, filedata) + if err != nil { + h.Runtime.Log.Error(method, err) + response.WriteServerError(w, method, err) + return + } + + h.Runtime.Log.Info(fmt.Sprintf("%s %d %v %v", fileheader.Filename, len(b.Bytes()), overwriteOrg, createUsers)) + + response.WriteEmpty(w) +} diff --git a/gui/app/components/customize/backup-restore.js b/gui/app/components/customize/backup-restore.js index ad011a97..11506d68 100644 --- a/gui/app/components/customize/backup-restore.js +++ b/gui/app/components/customize/backup-restore.js @@ -9,6 +9,7 @@ // // https://documize.com +import $ from 'jquery'; import { inject as service } from '@ember/service'; import Notifier from '../../mixins/notifier'; import Component from '@ember/component'; @@ -16,20 +17,40 @@ import Component from '@ember/component'; export default Component.extend(Notifier, { appMeta: service(), browserSvc: service('browser'), - buttonLabel: 'Run Backup', + buttonLabel: 'Start Backup', backupSpec: null, backupFilename: '', backupError: false, - backupSuccess: false, + backupSuccess: false, + restoreSpec: null, + restoreButtonLabel: 'Perform Restore', + restoreUploading: false, + restoreUploadReady: false, didReceiveAttrs() { - this._super(...arguments); + this._super(...arguments); + this.set('backupSpec', { - retain: false, - // org: '*' + retain: true, org: this.get('appMeta.orgId') }); - }, + + this.set('restoreSpec', { + overwriteOrg: true, + recreateUsers: true + }); + + this.set('restoreFile', null); + }, + + didInsertElement() { + this._super(...arguments); + + this.$('#restore-file').on('change', function(){ + var fileName = document.getElementById("restore-file").files[0].name; + $(this).next('.custom-file-label').html(fileName); + }); + }, actions: { onBackup() { @@ -37,17 +58,91 @@ export default Component.extend(Notifier, { this.set('buttonLabel', 'Please wait, backup running...'); this.set('backupFilename', ''); this.set('backupSuccess', false); - this.set('backupFailed', false); + this.set('backupFailed', false); - this.get('onBackup')(this.get('backupSpec')).then((filename) => { - this.set('buttonLabel', 'Run Backup'); + // If Documize Global Admin we perform system-level backup. + // Otherwise it is current tenant backup. + let spec = this.get('backupSpec'); + if (this.get('session.isGlobalAdmin')) { + spec.org = "*"; + } + + this.get('onBackup')(spec).then((filename) => { this.showDone(); + this.set('buttonLabel', 'Start Backup'); this.set('backupSuccess', true); this.set('backupFilename', filename); }, ()=> { + this.showDone(); this.set('buttonLabel', 'Run Backup'); this.set('backupFailed', true); }); + }, + + onRestore() { + // do we have upload file? + // let files = document.getElementById("restore-file").files; + // if (is.undefined(files) || is.null(files)) { + // return; + // } + + // let file = document.getElementById("restore-file").files[0]; + // if (is.undefined(file) || is.null(file)) { + // return; + // } + + let filedata = this.get('restoreFile'); + if (is.null(filedata)) { + return; + } + + // start restore process + this.showWait(); + this.set('restoreButtonLabel', 'Please wait, restore running...'); + this.set('restoreSuccess', false); + this.set('restoreFailed', false); + + // If Documize Global Admin we perform system-level restore. + // Otherwise it is current tenant backup. + let spec = this.get('restoreSpec'); + if (this.get('session.isGlobalAdmin')) { + spec.org = "*"; + } + + this.get('onRestore')(spec, filedata).then(() => { + this.showDone(); + this.set('buttonLabel', 'Perform Restore'); + this.set('restoreSuccess', true); + }, ()=> { + this.showDone(); + this.set('restorButtonLabel', 'Perform Restore'); + this.set('restoreFailed', true); + }); + }, + + upload(event) { + this.set('restoreUploading', true); + this.set('restoreUploadReady', false); + this.set('restoreFile', null); + + // const reader = new FileReader(); + const file = event.target.files[0]; + + this.set('restoreFile', file); + this.set('restoreUploadReady', true); + this.set('restoreUploading', false); + + // let imageData; + // reader.onload = () => { + // imageData = reader.result; + // this.set('restoreFile', imageData); + // this.set('restoreUploadReady', true); + // this.set('restoreUploading', false); + // }; + + // if (file) { + // reader.readAsDataURL(file); + // } } } }); diff --git a/gui/app/services/global.js b/gui/app/services/global.js index 4beee5bc..c8416af7 100644 --- a/gui/app/services/global.js +++ b/gui/app/services/global.js @@ -18,6 +18,7 @@ export default Service.extend({ appMeta: service(), browserSvc: service('browser'), store: service(), + router: service(), // Returns SMTP configuration. getSMTPConfig() { @@ -141,8 +142,12 @@ export default Service.extend({ } }, - // Run tenant level backup. + // Run backup. backup(spec) { + if (!this.get('sessionService.isGlobalAdmin') || this.get('sessionService.isAdmin')) { + return; + } + return new EmberPromise((resolve, reject) => { let url = this.get('appMeta.endpoint'); let token = this.get('sessionService.session.content.authenticated.token'); @@ -184,5 +189,33 @@ export default Service.extend({ xhr.send(JSON.stringify(spec)); }); + }, + + restore(spec, file) { + var data = new FormData(); + data.set('restore-file', file); + + return new EmberPromise((resolve, reject) => { + let url = this.get('appMeta.endpoint'); + let token = this.get('sessionService.session.content.authenticated.token'); + let uploadUrl = `${url}/global/restore?token=${token}&org=${spec.overwriteOrg}&users=${spec.recreateUsers}`; + + var xhr = new XMLHttpRequest(); + xhr.open('POST', uploadUrl); + + xhr.onload = function() { + if (this.status == 200) { + resolve(); + } else { + reject(); + } + } + + xhr.onerror= function() { + reject(); + } + + xhr.send(data); + }); } }); diff --git a/gui/app/styles/bootstrap.scss b/gui/app/styles/bootstrap.scss index d0f4236b..ee14bf95 100644 --- a/gui/app/styles/bootstrap.scss +++ b/gui/app/styles/bootstrap.scss @@ -112,6 +112,7 @@ $link-hover-decoration: none; @import "node_modules/bootstrap/scss/button-group"; @import "node_modules/bootstrap/scss/dropdown"; @import "node_modules/bootstrap/scss/forms"; +@import "node_modules/bootstrap/scss/custom-forms"; @import "node_modules/bootstrap/scss/input-group"; @import "node_modules/bootstrap/scss/modal"; @import "node_modules/bootstrap/scss/utilities"; diff --git a/gui/app/styles/view/customize.scss b/gui/app/styles/view/customize.scss index a4b55598..3172a426 100644 --- a/gui/app/styles/view/customize.scss +++ b/gui/app/styles/view/customize.scss @@ -152,4 +152,78 @@ > .max-results { float: right; } + + > .backup-restore { + margin: 20px 0; + font-size: 1.1rem; + + > .backup-zone { + @include border-radius(3px); + border: 1px solid $color-border; + padding: 20px 20px; + background-color: lighten($color-green, 60%); + + > .explain { + color: $color-gray; + font-size: 1rem; + font-style: italic; + } + + > .backup-fail { + margin: 10px 0; + color: $color-red; + } + + > .backup-success { + margin: 10px 0; + color: $color-green; + } + } + + > .restore-zone { + @include border-radius(3px); + border: 1px solid $color-border; + margin: 50px 0; + padding: 20px 20px; + background-color: lighten($color-red, 60%); + + > .restore-fail { + margin: 10px 0; + color: $color-red; + } + + > .restore-success { + margin: 10px 0; + color: $color-green; + } + + > .upload-backup-file { + @include ease-in(); + margin: 50px 0 10px 0; + + > .dz-preview, .dz-processing { + display: none !important; + } + } + + .restore-upload-busy { + text-align: center; + + > img { + height: 50px; + width: 50px; + } + + > .wait { + color: $color-gray; + margin: 10px 0; + } + + > .ready { + color: $color-green; + margin: 10px 0; + } + } + } + } } diff --git a/gui/app/styles/widget/widget-checkbox.scss b/gui/app/styles/widget/widget-checkbox.scss index 1321e530..460119a1 100644 --- a/gui/app/styles/widget/widget-checkbox.scss +++ b/gui/app/styles/widget/widget-checkbox.scss @@ -7,33 +7,33 @@ margin: 0 0 5px 0; > .material-icons { - font-size: 1rem; + font-size: 1.5rem; color: $color-gray; vertical-align: top; } > .selected { - color: $color-link; + color: $color-blue; } &:hover { - color: $color-link; + color: $color-blue; } > .text { display: inline-block; - font-size: 0.9rem; - vertical-align: text-top; + font-size: 1.1rem; + vertical-align: sub; color: $color-off-black; } } .ui-checkbox-selected { - color: $color-link; + color: $color-blue; } .widget-checkbox { - color: $color-link; + color: $color-blue; cursor: pointer; } diff --git a/server/routing/routes.go b/server/routing/routes.go index 7078232b..595af5d3 100644 --- a/server/routing/routes.go +++ b/server/routing/routes.go @@ -97,7 +97,7 @@ func RegisterEndpoints(rt *env.Runtime, s *store.Store) { // Secured private routes (require authentication) //************************************************** - AddPrivate(rt, "import/folder/{folderID}", []string{"POST", "OPTIONS"}, nil, conversion.UploadConvert) + AddPrivate(rt, "import/folder/{spaceID}", []string{"POST", "OPTIONS"}, nil, conversion.UploadConvert) AddPrivate(rt, "documents", []string{"GET", "OPTIONS"}, nil, document.BySpace) AddPrivate(rt, "documents/{documentID}", []string{"GET", "OPTIONS"}, nil, document.Get) @@ -161,19 +161,19 @@ func RegisterEndpoints(rt *env.Runtime, s *store.Store) { AddPrivate(rt, "search", []string{"POST", "OPTIONS"}, nil, document.SearchDocuments) AddPrivate(rt, "templates", []string{"POST", "OPTIONS"}, nil, template.SaveAs) - AddPrivate(rt, "templates/{templateID}/folder/{folderID}", []string{"POST", "OPTIONS"}, []string{"type", "saved"}, template.Use) - AddPrivate(rt, "templates/{folderID}", []string{"GET", "OPTIONS"}, nil, template.SavedList) + AddPrivate(rt, "templates/{templateID}/folder/{spaceID}", []string{"POST", "OPTIONS"}, []string{"type", "saved"}, template.Use) + AddPrivate(rt, "templates/{spaceID}", []string{"GET", "OPTIONS"}, nil, template.SavedList) AddPrivate(rt, "sections", []string{"GET", "OPTIONS"}, nil, section.GetSections) AddPrivate(rt, "sections", []string{"POST", "OPTIONS"}, nil, section.RunSectionCommand) AddPrivate(rt, "sections/refresh", []string{"GET", "OPTIONS"}, nil, section.RefreshSections) - AddPrivate(rt, "sections/blocks/space/{folderID}", []string{"GET", "OPTIONS"}, nil, block.GetBySpace) + AddPrivate(rt, "sections/blocks/space/{spaceID}", []string{"GET", "OPTIONS"}, nil, block.GetBySpace) AddPrivate(rt, "sections/blocks/{blockID}", []string{"GET", "OPTIONS"}, nil, block.Get) AddPrivate(rt, "sections/blocks/{blockID}", []string{"PUT", "OPTIONS"}, nil, block.Update) AddPrivate(rt, "sections/blocks/{blockID}", []string{"DELETE", "OPTIONS"}, nil, block.Delete) AddPrivate(rt, "sections/blocks", []string{"POST", "OPTIONS"}, nil, block.Add) - AddPrivate(rt, "links/{folderID}/{documentID}/{pageID}", []string{"GET", "OPTIONS"}, nil, link.GetLinkCandidates) + AddPrivate(rt, "links/{spaceID}/{documentID}/{pageID}", []string{"GET", "OPTIONS"}, nil, link.GetLinkCandidates) AddPrivate(rt, "links", []string{"GET", "OPTIONS"}, nil, link.SearchLinkCandidates) AddPrivate(rt, "documents/{documentID}/links", []string{"GET", "OPTIONS"}, nil, document.DocumentLinks) @@ -220,6 +220,7 @@ func RegisterEndpoints(rt *env.Runtime, s *store.Store) { AddPrivate(rt, "global/ldap/preview", []string{"POST", "OPTIONS"}, nil, ldap.Preview) AddPrivate(rt, "global/ldap/sync", []string{"GET", "OPTIONS"}, nil, ldap.Sync) AddPrivate(rt, "global/backup", []string{"POST", "OPTIONS"}, nil, backup.Backup) + AddPrivate(rt, "global/restore", []string{"POST", "OPTIONS"}, nil, backup.Restore) Add(rt, RoutePrefixRoot, "robots.txt", []string{"GET", "OPTIONS"}, nil, meta.RobotsTxt) Add(rt, RoutePrefixRoot, "sitemap.xml", []string{"GET", "OPTIONS"}, nil, meta.Sitemap)