diff --git a/domain/attachment/endpoint.go b/domain/attachment/endpoint.go index a0c2593d..48281c87 100644 --- a/domain/attachment/endpoint.go +++ b/domain/attachment/endpoint.go @@ -15,9 +15,12 @@ import ( "bytes" "database/sql" "fmt" + "github.com/documize/community/domain/auth" + "github.com/documize/community/model/space" "io" "mime" "net/http" + "strings" "github.com/documize/community/core/env" "github.com/documize/community/core/request" @@ -47,9 +50,22 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) { method := "attachment.Download" ctx := domain.GetRequestContext(r) ctx.Subdomain = organization.GetSubdomainFromHost(r) + ctx.OrgID = request.Param(r, "orgID") - a, err := h.Store.Attachment.GetAttachment(ctx, request.Param(r, "orgID"), request.Param(r, "attachmentID")) + // Is caller permitted to download this attachment? + canDownload := false + // Do e have user authentication token? + authToken := strings.TrimSpace(request.Query(r, "token")) + + // Do we have secure sharing token (for external users)? + secureToken := strings.TrimSpace(request.Query(r, "secure")) + + // We now fetch attachment, the document and space it lives inside. + // Any data loading issue spells the end of this request. + + // Get attachment being requested. + a, err := h.Store.Attachment.GetAttachment(ctx, ctx.OrgID, request.Param(r, "attachmentID")) if err == sql.ErrNoRows { response.WriteNotFoundError(w, method, request.Param(r, "fileID")) return @@ -60,6 +76,86 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) { return } + // Get the document for this attachment + doc, err := h.Store.Document.Get(ctx, a.DocumentID) + if err == sql.ErrNoRows { + response.WriteNotFoundError(w, method, a.DocumentID) + return + } + if err != nil { + h.Runtime.Log.Error("get attachment document", err) + response.WriteServerError(w, method, err) + return + } + + // Get the space for this attachment + sp, err := h.Store.Space.Get(ctx, doc.SpaceID) + if err == sql.ErrNoRows { + response.WriteNotFoundError(w, method, a.DocumentID) + return + } + if err != nil { + h.Runtime.Log.Error("get attachment document", err) + response.WriteServerError(w, method, err) + return + } + + // At this point, all data associated data is loaded. + // We now begin security checks based upon the request. + + // If attachment is in public space then anyone can download + if sp.Type == space.ScopePublic { + canDownload = true + } + + // If an user authentication token was provided we check to see + // if user can view document. + // This check only applies to attachments NOT in public spaces. + if sp.Type != space.ScopePublic && len(authToken) > 0 { + // Decode and check incoming token + creds, _, err := auth.DecodeJWT(h.Runtime, authToken) + if err != nil { + h.Runtime.Log.Error("get attachment decode auth token", err) + response.WriteForbiddenError(w) + return + } + // Check for tampering. + if ctx.OrgID != creds.OrgID { + h.Runtime.Log.Error("get attachment org ID mismatch", err) + response.WriteForbiddenError(w) + return + } + + // Use token-based user ID for subsequent processing. + ctx.UserID = creds.UserID + + // Check to see if user can view BOTH space and document. + if !permission.CanViewSpace(ctx, *h.Store, sp.RefID) || !permission.CanViewDocument(ctx, *h.Store, a.DocumentID) { + h.Runtime.Log.Error("get attachment cannot view document", err) + response.WriteServerError(w, method, err) + return + } + + // Authenticated user can view attachment. + canDownload = true + } + + // External users can be sent secure document viewing links. + // Those documents may contain attachments that external viewers + // can download as required. + // Such secure document viewing links can have expiry dates. + if len(authToken) == 0 && len(secureToken) > 0 { + + } + + // Send back error if caller unable view attachment + if !canDownload { + h.Runtime.Log.Error("get attachment refused", err) + response.WriteForbiddenError(w) + return + } + + // At this point, user can view attachment so we send it back! typ := mime.TypeByExtension("." + a.Extension) if typ == "" { typ = "application/octet-stream" diff --git a/domain/meta/endpoint.go b/domain/meta/endpoint.go index ea1c4639..204afaa7 100644 --- a/domain/meta/endpoint.go +++ b/domain/meta/endpoint.go @@ -112,6 +112,10 @@ Disallow: /auth/* Disallow: /auth/** Disallow: /share Disallow: /share/* +Disallow: /attachments +Disallow: /attachments/* +Disallow: /attachment +Disallow: /attachment/* Sitemap: %s`, sitemap) } diff --git a/gui/app/components/customize/general-settings.js b/gui/app/components/customize/general-settings.js index 70eaad53..e2742836 100644 --- a/gui/app/components/customize/general-settings.js +++ b/gui/app/components/customize/general-settings.js @@ -62,7 +62,6 @@ export default Component.extend(Notifier, { } this.set('model.general.maxTags', this.get('maxTags')); - this.model.general.set('allowAnonymousAccess', $("#allowAnonymousAccess").prop('checked')); this.get('save')().then(() => { this.notifySuccess('Saved'); diff --git a/gui/app/components/document/sidebar-attachment.js b/gui/app/components/document/sidebar-attachment.js index 2380ea1f..565c35d1 100644 --- a/gui/app/components/document/sidebar-attachment.js +++ b/gui/app/components/document/sidebar-attachment.js @@ -20,11 +20,13 @@ export default Component.extend(Modals, Notifier, { documentService: service('document'), browserSvc: service('browser'), appMeta: service(), + session: service(), hasAttachments: notEmpty('files'), canEdit: computed('permissions.documentEdit', 'document.protection', function() { return this.get('document.protection') !== this.get('constants').ProtectionType.Lock && this.get('permissions.documentEdit'); }), showDialog: false, + downloadQuery: '', init() { this._super(...arguments); @@ -50,7 +52,7 @@ export default Component.extend(Modals, Notifier, { let dzone = new Dropzone("#upload-document-files", { headers: { - 'Authorization': 'Bearer ' + self.get('session.session.content.authenticated.token') + 'Authorization': 'Bearer ' + self.get('session.authToken') }, url: uploadUrl, method: "post", @@ -67,16 +69,16 @@ export default Component.extend(Modals, Notifier, { }); this.on("queuecomplete", function () { - self.notifySuccess('Saved'); + self.notifySuccess('Uploaded file'); self.getAttachments(); }); this.on("addedfile", function ( /*file*/ ) { }); - this.on("error", function (error, msg) { // // eslint-disable-line no-unused-vars + this.on("error", function (error, msg) { self.notifyError(msg); - console.log(msg); // eslint-disable-line no-console + self.notifyError(error); }); } }); @@ -86,6 +88,13 @@ export default Component.extend(Modals, Notifier, { }); this.set('drop', dzone); + + // For authenticated users we send server auth token. + let qry = ''; + if (this.get('session.authenticated')) { + qry = '?token=' + this.get('session.authToken'); + } + this.set('downloadQuery', qry); }, getAttachments() { diff --git a/gui/app/services/document.js b/gui/app/services/document.js index 950b45ef..666ffbca 100644 --- a/gui/app/services/document.js +++ b/gui/app/services/document.js @@ -356,7 +356,7 @@ export default Service.extend({ }, //************************************************** - // Export + // Export content to HTML //************************************************** export(spec) { @@ -367,6 +367,14 @@ export default Service.extend({ }); }, + //************************************************** + // Secure document attachment download + //************************************************** + + downloadAttachment(fileId) { + return this.get('ajax').get(`attachment/${fileId}`, {}); + }, + //************************************************** // Fetch bulk data //************************************************** diff --git a/gui/app/services/session.js b/gui/app/services/session.js index 15284717..9903e008 100644 --- a/gui/app/services/session.js +++ b/gui/app/services/session.js @@ -85,6 +85,16 @@ export default SimpleAuthSession.extend({ this.get('session.content.authenticated.user.viewUsers') === true; }), + authToken: computed('session.content.authenticated.user', function () { + if (is.null(this.get('session.authenticator')) || + this.get('appMeta.secureMode')) return ''; + + if (this.get('session.authenticator') === 'authenticator:anonymous' || + this.get('session.content.authenticated.user.id') === '0') return ''; + + return this.get('session.content.authenticated.token'); + }), + init() { this._super(...arguments); diff --git a/gui/app/styles/core/layout/sidebar.scss b/gui/app/styles/core/layout/sidebar.scss index 63e2ce4f..e754b147 100644 --- a/gui/app/styles/core/layout/sidebar.scss +++ b/gui/app/styles/core/layout/sidebar.scss @@ -84,4 +84,15 @@ } } } + + .empty-label { + margin: 10px 0; + font-size: 1rem; + font-weight: 500; + color: map-get($gray-shades, 700); + font-style: italic; + padding: 0 7px; + } } + + diff --git a/gui/app/templates/components/document/sidebar-attachment.hbs b/gui/app/templates/components/document/sidebar-attachment.hbs index 2887cb0f..159b4677 100644 --- a/gui/app/templates/components/document/sidebar-attachment.hbs +++ b/gui/app/templates/components/document/sidebar-attachment.hbs @@ -12,7 +12,7 @@ +{{else}} +

No attachments

{{/if}} {{#ui/ui-dialog title="Delete Attachment" confirmCaption="Delete" buttonColor=constants.Color.Red show=showDialog onAction=(action "onDelete")}} diff --git a/server/routing/routes.go b/server/routing/routes.go index 20ff9705..d5f8c0e7 100644 --- a/server/routing/routes.go +++ b/server/routing/routes.go @@ -92,7 +92,7 @@ func RegisterEndpoints(rt *env.Runtime, s *store.Store) { AddPublic(rt, "forgot", []string{"POST", "OPTIONS"}, nil, user.ForgotPassword) AddPublic(rt, "reset/{token}", []string{"POST", "OPTIONS"}, nil, user.ResetPassword) AddPublic(rt, "share/{spaceID}", []string{"POST", "OPTIONS"}, nil, space.AcceptInvitation) - AddPublic(rt, "attachments/{orgID}/{attachmentID}", []string{"GET", "OPTIONS"}, nil, attachment.Download) + AddPublic(rt, "attachment/{orgID}/{attachmentID}", []string{"GET", "OPTIONS"}, nil, attachment.Download) //************************************************** // Secured private routes (require authentication)