From d502d82016e4a35325351fe9d31fcefe907b17d7 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Mon, 20 Jul 2015 17:16:43 -0400 Subject: [PATCH 01/48] Provide a replacement message for email notifications for posts only containing files --- api/post.go | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/api/post.go b/api/post.go index 27dedbf714..172347a46c 100644 --- a/api/post.go +++ b/api/post.go @@ -405,6 +405,31 @@ func fireAndForgetNotifications(post *model.Post, teamId, teamUrl string) { bodyPage.Props["PostMessage"] = model.ClearMentionTags(post.Message) bodyPage.Props["TeamLink"] = teamUrl + "/channels/" + channel.Name + // attempt to fill in a message body based if the message has none + if len(strings.TrimSpace(bodyPage.Props["PostMessage"])) == 0 { + // extract the filenames from their paths and determine what type of files are attached + filenames := make([]string, len(post.Filenames)) + onlyImages := true + for i, filename := range post.Filenames { + filenames[i] = strings.Replace(filepath.Base(filename), "+", " ", -1) + ext := filepath.Ext(filename) + onlyImages = onlyImages && model.IsFileExtImage(ext) + } + filenamesString := strings.Join(filenames, ", ") + + var attachmentPrefix string + if onlyImages { + attachmentPrefix = "Image" + } else { + attachmentPrefix = "File" + } + if len(post.Filenames) > 1 { + attachmentPrefix += "s" + } + + bodyPage.Props["PostMessage"] = fmt.Sprintf("%s: %s sent", attachmentPrefix, filenamesString) + } + if err := utils.SendMail(profileMap[id].Email, subjectPage.Render(), bodyPage.Render()); err != nil { l4g.Error("Failed to send mention email successfully email=%v err=%v", profileMap[id].Email, err) } @@ -636,9 +661,9 @@ func deletePost(c *Context, w http.ResponseWriter, r *http.Request) { post := result.Data.(*model.PostList).Posts[postId] - if !c.HasPermissionsToChannel(cchan, "deletePost") && !c.IsTeamAdmin(post.UserId){ - return - } + if !c.HasPermissionsToChannel(cchan, "deletePost") && !c.IsTeamAdmin(post.UserId) { + return + } if post == nil { c.SetInvalidParam("deletePost", "postId") @@ -651,7 +676,7 @@ func deletePost(c *Context, w http.ResponseWriter, r *http.Request) { return } - if post.UserId != c.Session.UserId && !strings.Contains(c.Session.Roles,model.ROLE_ADMIN) { + if post.UserId != c.Session.UserId && !strings.Contains(c.Session.Roles, model.ROLE_ADMIN) { c.Err = model.NewAppError("deletePost", "You do not have the appropriate permissions", "") c.Err.StatusCode = http.StatusForbidden return From 55c148b59a058f3845345a45c7aa33371a96e971 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Mon, 20 Jul 2015 17:49:13 -0400 Subject: [PATCH 02/48] Added additional check when adding a message to an email notification for a blank post and corrected a comment --- api/post.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/post.go b/api/post.go index 172347a46c..1170d6a4f6 100644 --- a/api/post.go +++ b/api/post.go @@ -405,8 +405,8 @@ func fireAndForgetNotifications(post *model.Post, teamId, teamUrl string) { bodyPage.Props["PostMessage"] = model.ClearMentionTags(post.Message) bodyPage.Props["TeamLink"] = teamUrl + "/channels/" + channel.Name - // attempt to fill in a message body based if the message has none - if len(strings.TrimSpace(bodyPage.Props["PostMessage"])) == 0 { + // attempt to fill in a message body if the post doesn't have any text + if len(strings.TrimSpace(bodyPage.Props["PostMessage"])) == 0 && len(post.Filenames) > 0 { // extract the filenames from their paths and determine what type of files are attached filenames := make([]string, len(post.Filenames)) onlyImages := true From 156e3a94c49fa43a862d0e8de6683d9d96794447 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Tue, 21 Jul 2015 09:49:05 -0400 Subject: [PATCH 03/48] Use QueryUnescape function to convert to a human readable filename in email notifications --- api/post.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/api/post.go b/api/post.go index 1170d6a4f6..65f2d59780 100644 --- a/api/post.go +++ b/api/post.go @@ -11,6 +11,7 @@ import ( "github.com/mattermost/platform/store" "github.com/mattermost/platform/utils" "net/http" + "net/url" "path/filepath" "strconv" "strings" @@ -411,7 +412,12 @@ func fireAndForgetNotifications(post *model.Post, teamId, teamUrl string) { filenames := make([]string, len(post.Filenames)) onlyImages := true for i, filename := range post.Filenames { - filenames[i] = strings.Replace(filepath.Base(filename), "+", " ", -1) + var err error + if filenames[i], err = url.QueryUnescape(filepath.Base(filename)); err != nil { + // this should never error since filepath was escaped using url.QueryEscape + filenames[i] = filepath.Base(filename) + } + ext := filepath.Ext(filename) onlyImages = onlyImages && model.IsFileExtImage(ext) } From e32aee8977bf99b0f5ca446cb028b04c25e2b918 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Thu, 16 Jul 2015 15:16:11 -0400 Subject: [PATCH 04/48] Added events for when the active thread (ie the message thread that a user is responding to) changes --- web/react/stores/post_store.jsx | 16 ++++++++++++++++ web/react/utils/constants.jsx | 1 + 2 files changed, 17 insertions(+) diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx index e773bb6889..1f07dca622 100644 --- a/web/react/stores/post_store.jsx +++ b/web/react/stores/post_store.jsx @@ -18,6 +18,7 @@ var SEARCH_TERM_CHANGE_EVENT = 'search_term_change'; var SELECTED_POST_CHANGE_EVENT = 'selected_post_change'; var MENTION_DATA_CHANGE_EVENT = 'mention_data_change'; var ADD_MENTION_EVENT = 'add_mention'; +var ACTIVE_THREAD_CHANGED_EVENT = 'active_thread_changed'; var PostStore = assign({}, EventEmitter.prototype, { @@ -93,6 +94,18 @@ var PostStore = assign({}, EventEmitter.prototype, { this.removeListener(ADD_MENTION_EVENT, callback); }, + emitActiveThreadChanged: function(rootId, parentId) { + this.emit(ACTIVE_THREAD_CHANGED_EVENT, rootId, parentId); + }, + + addActiveThreadChangedListener: function(callback) { + this.on(ACTIVE_THREAD_CHANGED_EVENT, callback); + }, + + removeActiveThreadChangedListener: function(callback) { + this.removeListener(ACTIVE_THREAD_CHANGED_EVENT, callback); + }, + getCurrentPosts: function() { var currentId = ChannelStore.getCurrentId(); @@ -186,6 +199,9 @@ PostStore.dispatchToken = AppDispatcher.register(function(payload) { case ActionTypes.RECIEVED_ADD_MENTION: PostStore.emitAddMention(action.id, action.username); break; + case ActionTypes.RECEIVED_ACTIVE_THREAD_CHANGED: + PostStore.emitActiveThreadChanged(action.root_id, action.parent_id); + break; default: } diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 3aadfb4b0d..943787630b 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -18,6 +18,7 @@ module.exports = { RECIEVED_POST_SELECTED: null, RECIEVED_MENTION_DATA: null, RECIEVED_ADD_MENTION: null, + RECEIVED_ACTIVE_THREAD_CHANGED: null, RECIEVED_PROFILES: null, RECIEVED_ME: null, From 25b2e75dc664bfb80470713331c33c88e726dcf5 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Thu, 16 Jul 2015 15:25:28 -0400 Subject: [PATCH 05/48] Added function to reply to earlier messages by prefixing a message with carets --- web/react/components/create_post.jsx | 68 +++++++++++++++++++++++++ web/react/components/post.jsx | 2 +- web/react/components/post_list.jsx | 17 ++++++- web/sass-files/sass/partials/_post.scss | 6 ++- 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index d38a6798f9..681ca252ff 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -68,6 +68,9 @@ module.exports = React.createClass({ post.channel_id = this.state.channel_id; post.filenames = this.state.previews; + post.root_id = this.state.rootId; + post.parent_id = this.state.parentId; + client.createPost(post, ChannelStore.getCurrent(), function(data) { PostStore.storeDraft(data.channel_id, data.user_id, null); @@ -92,6 +95,17 @@ module.exports = React.createClass({ } $(".post-list-holder-by-time").perfectScrollbar('update'); + + if (this.state.rootId || this.state.parentId) { + this.setState({rootId: "", parentId: ""}); + + // clear the active thread since we've now sent our message + AppDispatcher.handleViewAction({ + type: ActionTypes.RECEIVED_ACTIVE_THREAD_CHANGED, + root_id: "", + parent_id: "" + }); + } }, componentDidUpdate: function() { this.resizePostHolder(); @@ -112,6 +126,60 @@ module.exports = React.createClass({ handleUserInput: function(messageText) { this.resizePostHolder(); this.setState({messageText: messageText}); + + // look to see if the message begins with any carets to indicate that it's a reply + var replyMatch = messageText.match(/^\^+/g); + if (replyMatch) { + // the number of carets indicates how many message threads back we're replying to + var caretCount = replyMatch[0].length; + + var posts = PostStore.getCurrentPosts(); + + var rootId = ""; + + // find the nth most recent post that isn't a comment on another (ie it has no parent) where n is caretCount + for (var i = 0; i < posts.order.length; i++) { + var postId = posts.order[i]; + + if (posts.posts[postId].parent_id === "") { + if (caretCount == 1) { + rootId = postId; + break; + } else { + caretCount -= 1; + } + } + } + + if (rootId) { + // set the parent id to match the root id so that we're replying to the first post in the thread + var parentId = rootId; + + // alert the post list so that it can display the active thread + AppDispatcher.handleViewAction({ + type: ActionTypes.RECEIVED_ACTIVE_THREAD_CHANGED, + root_id: rootId, + parent_id: parentId + }); + + // save these so that we don't need to recalculate them when we send this post + this.setState({rootId: rootId, parentId: parentId}); + } else { + // we couldn't find a post to respond to + this.setState({rootId: "", parentId: ""}); + } + } else { + if (this.state.rootId || this.state.parentId) { + this.setState({rootId: "", parentId: ""}); + + AppDispatcher.handleViewAction({ + type: ActionTypes.RECEIVED_ACTIVE_THREAD_CHANGED, + root_id: "", + parent_id: "" + }); + } + } + var draft = PostStore.getCurrentDraft(); if (!draft) { draft = {} diff --git a/web/react/components/post.jsx b/web/react/components/post.jsx index e72a2d001c..e3586ecded 100644 --- a/web/react/components/post.jsx +++ b/web/react/components/post.jsx @@ -83,7 +83,7 @@ module.exports = React.createClass({ : null } -
+
diff --git a/web/react/components/post_list.jsx b/web/react/components/post_list.jsx index c058455bae..0fe668aeff 100644 --- a/web/react/components/post_list.jsx +++ b/web/react/components/post_list.jsx @@ -22,7 +22,8 @@ function getStateFromStores() { return { post_list: PostStore.getCurrentPosts(), - channel: channel + channel: channel, + activeThreadRootId: "" }; } @@ -51,6 +52,7 @@ module.exports = React.createClass({ ChannelStore.addChangeListener(this._onChange); UserStore.addStatusesChangeListener(this._onTimeChange); SocketStore.addChangeListener(this._onSocketChange); + PostStore.addActiveThreadChangedListener(this._onActiveThreadChanged); $(".post-list-holder-by-time").perfectScrollbar(); @@ -131,6 +133,7 @@ module.exports = React.createClass({ ChannelStore.removeChangeListener(this._onChange); UserStore.removeStatusesChangeListener(this._onTimeChange); SocketStore.removeChangeListener(this._onSocketChange); + PostStore.removeActiveThreadChangedListener(this._onActiveThreadChanged); $('body').off('click.userpopover'); }, resize: function() { @@ -228,6 +231,9 @@ module.exports = React.createClass({ this.refs[id].forceUpdateInfo(); } }, + _onActiveThreadChanged: function(rootId, parentId) { + this.setState({"activeThreadRootId": rootId}); + }, getMorePosts: function(e) { e.preventDefault(); @@ -419,7 +425,14 @@ module.exports = React.createClass({ // it is the last comment if it is last post in the channel or the next post has a different root post var isLastComment = utils.isComment(post) && (i === 0 || posts[order[i-1]].root_id != post.root_id); - var postCtl = ; + // check if this is part of the thread that we're currently replying to + var isActiveThread = this.state.activeThreadRootId && (post.id === this.state.activeThreadRootId || post.root_id === this.state.activeThreadRootId); + + var postCtl = ( + + ); currentPostDay = utils.getDateForUnixTicks(post.create_at); if (currentPostDay.toDateString() != previousPostDay.toDateString()) { diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss index 481ed63a52..87c7a427c2 100644 --- a/web/sass-files/sass/partials/_post.scss +++ b/web/sass-files/sass/partials/_post.scss @@ -322,6 +322,10 @@ body.ios { max-width: 100%; @include legacy-pie-clearfix; } + &.active-thread__content { + // HARRISON remove me + color: blue; + } } .post-image__columns { @include legacy-pie-clearfix; @@ -437,4 +441,4 @@ body.ios { width: 40px; } } -} \ No newline at end of file +} From 644c78755cb7cf8ef818106b9629df1018d0d736 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Thu, 16 Jul 2015 16:03:06 -0400 Subject: [PATCH 06/48] Change the create_post component to track the active thread using the event dispatcher so that it stays in sync with the post_list --- web/react/components/create_post.jsx | 36 ++++++++++++++++++---------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 681ca252ff..7bc5e2481d 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -152,26 +152,29 @@ module.exports = React.createClass({ } if (rootId) { - // set the parent id to match the root id so that we're replying to the first post in the thread - var parentId = rootId; + // only dispatch an event if something changed + if (rootId != this.state.rootId) { + // set the parent id to match the root id so that we're replying to the first post in the thread + var parentId = rootId; - // alert the post list so that it can display the active thread + // alert the post list so that it can display the active thread + AppDispatcher.handleViewAction({ + type: ActionTypes.RECEIVED_ACTIVE_THREAD_CHANGED, + root_id: rootId, + parent_id: parentId + }); + } + } else { + // we couldn't find a post to respond to so clear the active thread AppDispatcher.handleViewAction({ type: ActionTypes.RECEIVED_ACTIVE_THREAD_CHANGED, - root_id: rootId, - parent_id: parentId + root_id: "", + parent_id: "" }); - - // save these so that we don't need to recalculate them when we send this post - this.setState({rootId: rootId, parentId: parentId}); - } else { - // we couldn't find a post to respond to - this.setState({rootId: "", parentId: ""}); } } else { if (this.state.rootId || this.state.parentId) { - this.setState({rootId: "", parentId: ""}); - + // clear the active thread since there no longer is one AppDispatcher.handleViewAction({ type: ActionTypes.RECEIVED_ACTIVE_THREAD_CHANGED, root_id: "", @@ -242,10 +245,12 @@ module.exports = React.createClass({ }, componentDidMount: function() { ChannelStore.addChangeListener(this._onChange); + PostStore.addActiveThreadChangedListener(this._onActiveThreadChanged); this.resizePostHolder(); }, componentWillUnmount: function() { ChannelStore.removeChangeListener(this._onChange); + PostStore.removeActiveThreadChangedListener(this._onActiveThreadChanged); }, _onChange: function() { var channel_id = ChannelStore.getCurrentId(); @@ -262,6 +267,11 @@ module.exports = React.createClass({ this.setState({ channel_id: channel_id, messageText: messageText, initialText: messageText, submitting: false, post_error: null, previews: previews, uploadsInProgress: uploadsInProgress }); } }, + _onActiveThreadChanged: function(rootId, parentId) { + // note that we register for our own events and set the state from there so we don't need to manually set + // our state and dispatch an event each time the active thread changes + this.setState({"rootId": rootId, "parentId": parentId}); + }, getInitialState: function() { PostStore.clearDraftUploads(); From 5b917dd4a6fc4259cd17ad0b2e106f3b71254ef2 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Thu, 16 Jul 2015 16:58:05 -0400 Subject: [PATCH 07/48] Trim the carets from the beginning of reply messages when they're posted --- web/react/components/create_post.jsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 7bc5e2481d..a3e3545992 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -71,6 +71,11 @@ module.exports = React.createClass({ post.root_id = this.state.rootId; post.parent_id = this.state.parentId; + // if this is a reply, trim off any carets from the beginning of a message + if (post.root_id && post.message.startsWith("^")) { + post.message = post.message.replace(/^\^+\s*/g, ""); + } + client.createPost(post, ChannelStore.getCurrent(), function(data) { PostStore.storeDraft(data.channel_id, data.user_id, null); From 141d928d3c74621c277cc118c49e5b0f845e4691 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Fri, 17 Jul 2015 09:34:35 -0400 Subject: [PATCH 08/48] Changed the placeholder highlight for active thread to be more subtle --- web/sass-files/sass/partials/_post.scss | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss index 87c7a427c2..c8f259c11d 100644 --- a/web/sass-files/sass/partials/_post.scss +++ b/web/sass-files/sass/partials/_post.scss @@ -323,8 +323,10 @@ body.ios { @include legacy-pie-clearfix; } &.active-thread__content { - // HARRISON remove me - color: blue; + // this still needs a final style applied to it + & .post-body { + font-weight: bold; + } } } .post-image__columns { From fafce8cb72cc8a10b98fab22c6b3547c77d75cc9 Mon Sep 17 00:00:00 2001 From: nickago Date: Tue, 21 Jul 2015 08:03:10 -0700 Subject: [PATCH 09/48] Upon changing to a new comment thread, fixes the the bug of the RHS scrolling to whitespace below the pane --- web/react/components/post_right.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/react/components/post_right.jsx b/web/react/components/post_right.jsx index 581a1abe9e..ad521adbad 100644 --- a/web/react/components/post_right.jsx +++ b/web/react/components/post_right.jsx @@ -294,6 +294,8 @@ module.exports = React.createClass({ }); }, componentDidUpdate: function() { + $(".post-right__scroll").scrollTop($(".post-right__scroll")[0].scrollHeight); + $(".post-right__scroll").perfectScrollbar('update'); this.resize(); }, componentWillUnmount: function() { From 7b28880294865c7441ce8b4b3efc14b1417cb5e5 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Tue, 21 Jul 2015 15:54:47 -0400 Subject: [PATCH 10/48] Track caretCount as part of createPost's state so that we don't unnecessarily search for a thread to reply to when the user is typing --- web/react/components/create_post.jsx | 44 ++++++++++++++-------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index a3e3545992..7b7e38ac21 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -102,7 +102,7 @@ module.exports = React.createClass({ $(".post-list-holder-by-time").perfectScrollbar('update'); if (this.state.rootId || this.state.parentId) { - this.setState({rootId: "", parentId: ""}); + this.setState({rootId: "", parentId: "", caretCount: 0}); // clear the active thread since we've now sent our message AppDispatcher.handleViewAction({ @@ -138,25 +138,30 @@ module.exports = React.createClass({ // the number of carets indicates how many message threads back we're replying to var caretCount = replyMatch[0].length; - var posts = PostStore.getCurrentPosts(); + // note that if someone else replies to this thread while a user is typing a reply, the message to which they're replying + // won't change unless they change the number of carets. this is probably the desired behaviour since we don't want the + // active message thread to change without the user noticing + if (caretCount != this.state.caretCount) { + this.setState({caretCount: caretCount}); - var rootId = ""; + var posts = PostStore.getCurrentPosts(); - // find the nth most recent post that isn't a comment on another (ie it has no parent) where n is caretCount - for (var i = 0; i < posts.order.length; i++) { - var postId = posts.order[i]; + var rootId = ""; - if (posts.posts[postId].parent_id === "") { - if (caretCount == 1) { - rootId = postId; - break; - } else { + // find the nth most recent post that isn't a comment on another (ie it has no parent) where n is caretCount + for (var i = 0; i < posts.order.length; i++) { + var postId = posts.order[i]; + + if (posts.posts[postId].parent_id === "") { caretCount -= 1; + + if (caretCount < 1) { + rootId = postId; + break; + } } } - } - if (rootId) { // only dispatch an event if something changed if (rootId != this.state.rootId) { // set the parent id to match the root id so that we're replying to the first post in the thread @@ -169,16 +174,11 @@ module.exports = React.createClass({ parent_id: parentId }); } - } else { - // we couldn't find a post to respond to so clear the active thread - AppDispatcher.handleViewAction({ - type: ActionTypes.RECEIVED_ACTIVE_THREAD_CHANGED, - root_id: "", - parent_id: "" - }); } } else { - if (this.state.rootId || this.state.parentId) { + if (this.state.caretCount > 0) { + this.setState({caretCount: 0}); + // clear the active thread since there no longer is one AppDispatcher.handleViewAction({ type: ActionTypes.RECEIVED_ACTIVE_THREAD_CHANGED, @@ -287,7 +287,7 @@ module.exports = React.createClass({ previews = draft['previews']; messageText = draft['message']; } - return { channel_id: ChannelStore.getCurrentId(), messageText: messageText, uploadsInProgress: 0, previews: previews, submitting: false, initialText: messageText }; + return { channel_id: ChannelStore.getCurrentId(), messageText: messageText, uploadsInProgress: 0, previews: previews, submitting: false, initialText: messageText, caretCount: 0 }; }, setUploads: function(val) { var oldInProgress = this.state.uploadsInProgress From d4fd000ccfc4f9c128cf178755dd3b1df0cf3894 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Tue, 21 Jul 2015 15:55:34 -0400 Subject: [PATCH 11/48] Added handling for when the user is ^ responding to a post that gets deleted --- web/react/components/create_post.jsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 7b7e38ac21..efb5efd80f 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -92,7 +92,13 @@ module.exports = React.createClass({ }.bind(this), function(err) { var state = {} - state.server_error = err.message; + + if (err.message === "Invalid RootId parameter") { + if ($('#post_deleted').length > 0) $('#post_deleted').modal('show'); + } else { + state.server_error = err.message; + } + state.submitting = false; this.setState(state); }.bind(this) From 0967a131a152e056c1cb971f895b2d1f8df4d0ed Mon Sep 17 00:00:00 2001 From: hmhealey Date: Tue, 21 Jul 2015 16:01:30 -0400 Subject: [PATCH 12/48] Prevent users from sending empty ^ reply messages --- web/react/components/create_post.jsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index efb5efd80f..a2448b5697 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -31,6 +31,11 @@ module.exports = React.createClass({ post.message = this.state.messageText; + // if this is a reply, trim off any carets from the beginning of a message + if (this.state.rootId && post.message.startsWith("^")) { + post.message = post.message.replace(/^\^+\s*/g, ""); + } + if (post.message.trim().length === 0 && this.state.previews.length === 0) { return; } @@ -71,11 +76,6 @@ module.exports = React.createClass({ post.root_id = this.state.rootId; post.parent_id = this.state.parentId; - // if this is a reply, trim off any carets from the beginning of a message - if (post.root_id && post.message.startsWith("^")) { - post.message = post.message.replace(/^\^+\s*/g, ""); - } - client.createPost(post, ChannelStore.getCurrent(), function(data) { PostStore.storeDraft(data.channel_id, data.user_id, null); From f5837c1b64994a15537a0b8df109bed504d0d20a Mon Sep 17 00:00:00 2001 From: Christopher Speller Date: Tue, 21 Jul 2015 16:15:24 -0400 Subject: [PATCH 13/48] Old files are saved with full paths, this changes so that new files are not saved with absolute paths and detects old files saved and fixes them. --- api/file.go | 2 +- web/react/components/post_body.jsx | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/api/file.go b/api/file.go index 362cdf896b..5d676b9fd6 100644 --- a/api/file.go +++ b/api/file.go @@ -115,7 +115,7 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) { return } - fileUrl := c.GetSiteURL() + "/api/v1/files/get/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + url.QueryEscape(files[i].Filename) + fileUrl := "/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + url.QueryEscape(files[i].Filename) resStruct.Filenames = append(resStruct.Filenames, fileUrl) } diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index d9678df30a..96b441c0e7 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -28,6 +28,12 @@ module.exports = React.createClass({ var type = utils.getFileType(fileInfo.ext); + // This is a temporary patch to fix issue with old files using absolute paths + if (fileInfo.path.indexOf("/api/v1/files/get") == -1) { + fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; + } + fileInfo.path = window.location.origin + "/api/v1/files/get" + fileInfo.path; + if (type === "image") { $('').attr('src', fileInfo.path+'_thumb.jpg').load(function(path, name){ return function() { $(this).remove(); @@ -102,6 +108,12 @@ module.exports = React.createClass({ var type = utils.getFileType(fileInfo.ext); + // This is a temporary patch to fix issue with old files using absolute paths + if (fileInfo.path.indexOf("/api/v1/files/get") == -1) { + fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; + } + fileInfo.path = window.location.origin + "/api/v1/files/get" + fileInfo.path; + if (type === "image") { if (i < Constants.MAX_DISPLAY_FILES) { postFiles.push( From 21520f00f33517031ecd84d75477241854681e6e Mon Sep 17 00:00:00 2001 From: Reed Garmsen Date: Tue, 21 Jul 2015 13:28:13 -0700 Subject: [PATCH 14/48] Fixed typo in the 'Sign up Complete' page: you email -> your email --- web/templates/signup_team_confirm.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/templates/signup_team_confirm.html b/web/templates/signup_team_confirm.html index 9e21126da8..3c2099a71d 100644 --- a/web/templates/signup_team_confirm.html +++ b/web/templates/signup_team_confirm.html @@ -11,7 +11,7 @@

Did you mean to sign-in rather than sign up? Sign in here.

Sign up Complete

Please check your email: {{ .Props.Email }}
- You email contains a link to set up your team

+ Your email contains a link to set up your team

From 1c08a33b92f72d77d6c5f1fce916e2cd7c655ff0 Mon Sep 17 00:00:00 2001 From: Christopher Speller Date: Tue, 21 Jul 2015 16:54:24 -0400 Subject: [PATCH 15/48] Fixing file uploads and previews for new /team/ URL structure --- web/react/components/file_preview.jsx | 1 + web/react/components/post_body.jsx | 4 ++-- web/react/components/view_image.jsx | 5 +++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/web/react/components/file_preview.jsx b/web/react/components/file_preview.jsx index 17a1e2bc2b..553293d2b1 100644 --- a/web/react/components/file_preview.jsx +++ b/web/react/components/file_preview.jsx @@ -19,6 +19,7 @@ module.exports = React.createClass({ var filenameSplit = filename.split('.'); var ext = filenameSplit[filenameSplit.length-1]; var type = utils.getFileType(ext); + filename = window.location.origin + "/api/v1/files/get" + filename; if (type === "image") { previews.push( diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index 96b441c0e7..7871f52b7d 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -29,7 +29,7 @@ module.exports = React.createClass({ var type = utils.getFileType(fileInfo.ext); // This is a temporary patch to fix issue with old files using absolute paths - if (fileInfo.path.indexOf("/api/v1/files/get") == -1) { + if (fileInfo.path.indexOf("/api/v1/files/get") != -1) { fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; } fileInfo.path = window.location.origin + "/api/v1/files/get" + fileInfo.path; @@ -109,7 +109,7 @@ module.exports = React.createClass({ var type = utils.getFileType(fileInfo.ext); // This is a temporary patch to fix issue with old files using absolute paths - if (fileInfo.path.indexOf("/api/v1/files/get") == -1) { + if (fileInfo.path.indexOf("/api/v1/files/get") != -1) { fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; } fileInfo.path = window.location.origin + "/api/v1/files/get" + fileInfo.path; diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx index 2274f3f2e6..4675269d51 100644 --- a/web/react/components/view_image.jsx +++ b/web/react/components/view_image.jsx @@ -36,6 +36,11 @@ module.exports = React.createClass({ src = this.props.filenames[id]; } else { var fileInfo = utils.splitFileLocation(this.props.filenames[id]); + // This is a temporary patch to fix issue with old files using absolute paths + if (fileInfo.path.indexOf("/api/v1/files/get") != -1) { + fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; + } + fileInfo.path = window.location.origin + "/api/v1/files/get" + fileInfo.path; src = fileInfo['path'] + '_preview.jpg'; } From bd4fd876a89fea4d220dd237f65d21ecb6b0a0fc Mon Sep 17 00:00:00 2001 From: hmhealey Date: Tue, 21 Jul 2015 17:35:14 -0400 Subject: [PATCH 16/48] Changed post menu label to just be [...] regardless of whether or not the user is hovering over it --- web/sass-files/sass/partials/_post.scss | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss index 481ed63a52..98b17120d4 100644 --- a/web/sass-files/sass/partials/_post.scss +++ b/web/sass-files/sass/partials/_post.scss @@ -215,9 +215,6 @@ body.ios { @include opacity(1); } .dropdown-toggle:after { - content: '...'; - } - .dropdown-toggle:hover:after { content: '[...]'; } } @@ -437,4 +434,4 @@ body.ios { width: 40px; } } -} \ No newline at end of file +} From b7d523f97d08465f3103c673ffd30306018f13f8 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Tue, 21 Jul 2015 17:38:51 -0400 Subject: [PATCH 17/48] Changed post menu label to be ... and to make it visible at all times on small screens (ie mobile devices) --- web/sass-files/sass/partials/_responsive.scss | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/web/sass-files/sass/partials/_responsive.scss b/web/sass-files/sass/partials/_responsive.scss index a33d693784..b3d3cd7eab 100644 --- a/web/sass-files/sass/partials/_responsive.scss +++ b/web/sass-files/sass/partials/_responsive.scss @@ -239,6 +239,11 @@ } &:hover { background: none; + .post-header .post-header-col.post-header__reply { + .dropdown-toggle:after { + content: '...'; + } + } } &.post--comment { &.other--root { @@ -247,6 +252,11 @@ } } } + .post-header .post-header-col.post-header__reply { + .dropdown-toggle:after { + content: '...'; + } + } } .signup-team__container { padding: 30px 0; From 73ee387319d59dca073e4cd0c1b9b60b392b137d Mon Sep 17 00:00:00 2001 From: Christopher Speller Date: Tue, 21 Jul 2015 17:49:08 -0400 Subject: [PATCH 18/48] Fixing issues with files trasitioning to relative paths --- api/post.go | 33 --------------------------- api/post_test.go | 2 +- web/react/components/file_preview.jsx | 4 ++++ 3 files changed, 5 insertions(+), 34 deletions(-) diff --git a/api/post.go b/api/post.go index fb9fdd1eff..2d25f7ab01 100644 --- a/api/post.go +++ b/api/post.go @@ -160,39 +160,6 @@ func CreatePost(c *Context, post *model.Post, doUpdateLastViewed bool) (*model.P post.UserId = c.Session.UserId - if len(post.Filenames) > 0 { - doRemove := false - for i := len(post.Filenames) - 1; i >= 0; i-- { - path := post.Filenames[i] - - doRemove = false - if model.UrlRegex.MatchString(path) { - continue - } else if model.PartialUrlRegex.MatchString(path) { - matches := model.PartialUrlRegex.FindAllStringSubmatch(path, -1) - if len(matches) == 0 || len(matches[0]) < 5 { - doRemove = true - } - - channelId := matches[0][2] - if channelId != post.ChannelId { - doRemove = true - } - - userId := matches[0][3] - if userId != post.UserId { - doRemove = true - } - } else { - doRemove = true - } - if doRemove { - l4g.Error("Bad filename discarded, filename=%v", path) - post.Filenames = append(post.Filenames[:i], post.Filenames[i+1:]...) - } - } - } - var rpost *model.Post if result := <-Srv.Store.Post().Save(post); result.Err != nil { return nil, result.Err diff --git a/api/post_test.go b/api/post_test.go index 3249ed7faf..0cccc74d32 100644 --- a/api/post_test.go +++ b/api/post_test.go @@ -37,7 +37,7 @@ func TestCreatePost(t *testing.T) { channel2 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel) - filenames := []string{"/api/v1/files/get/12345678901234567890123456/12345678901234567890123456/test.png", "/api/v1/files/get/" + channel1.Id + "/" + user1.Id + "/test.png", "www.mattermost.com/fake/url", "junk"} + filenames := []string{"/api/v1/files/get/12345678901234567890123456/12345678901234567890123456/test.png", "/api/v1/files/get/" + channel1.Id + "/" + user1.Id + "/test.png"} post1 := &model.Post{ChannelId: channel1.Id, Message: "#hashtag a" + model.NewId() + "a", Filenames: filenames} rpost1, err := Client.CreatePost(post1) diff --git a/web/react/components/file_preview.jsx b/web/react/components/file_preview.jsx index 553293d2b1..e69607206c 100644 --- a/web/react/components/file_preview.jsx +++ b/web/react/components/file_preview.jsx @@ -19,6 +19,10 @@ module.exports = React.createClass({ var filenameSplit = filename.split('.'); var ext = filenameSplit[filenameSplit.length-1]; var type = utils.getFileType(ext); + // This is a temporary patch to fix issue with old files using absolute paths + if (filename.indexOf("/api/v1/files/get") != -1) { + filename = filename.split("/api/v1/files/get")[1]; + } filename = window.location.origin + "/api/v1/files/get" + filename; if (type === "image") { From 2d51e93d8e2b455fc325ab10c0b13fa9a497bb9a Mon Sep 17 00:00:00 2001 From: Christopher Speller Date: Tue, 21 Jul 2015 18:37:00 -0400 Subject: [PATCH 19/48] Fixing image previews --- web/react/components/view_image.jsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx index 4675269d51..4d5d54e7ff 100644 --- a/web/react/components/view_image.jsx +++ b/web/react/components/view_image.jsx @@ -144,6 +144,11 @@ module.exports = React.createClass({ if (this.props.imgCount > 0) { preview_filename = this.props.filenames[this.state.imgId]; } else { + // This is a temporary patch to fix issue with old files using absolute paths + if (info.path.indexOf("/api/v1/files/get") != -1) { + info.path = info.path.split("/api/v1/files/get")[1]; + } + info.path = window.location.origin + "/api/v1/files/get" + info.path; preview_filename = info['path'] + '_preview.jpg'; } From ada84835eec1d69a962769afb590088d2f5a7d0a Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Thu, 16 Jul 2015 08:54:09 -0400 Subject: [PATCH 20/48] initial implementation of local server storage for files --- api/file.go | 169 ++++++++++++++++++++++---------------- api/user.go | 43 +++------- config/config.json | 4 +- config/config_docker.json | 4 +- utils/config.go | 22 ++--- 5 files changed, 127 insertions(+), 115 deletions(-) diff --git a/api/file.go b/api/file.go index 5d676b9fd6..d6f9e6c1d5 100644 --- a/api/file.go +++ b/api/file.go @@ -18,8 +18,10 @@ import ( _ "image/gif" "image/jpeg" "io" + "io/ioutil" "net/http" "net/url" + "os" "path/filepath" "strconv" "strings" @@ -27,7 +29,7 @@ import ( ) func InitFile(r *mux.Router) { - l4g.Debug("Initializing post api routes") + l4g.Debug("Initializing file api routes") sr := r.PathPrefix("/files").Subrouter() sr.Handle("/upload", ApiUserRequired(uploadFile)).Methods("POST") @@ -36,8 +38,8 @@ func InitFile(r *mux.Router) { } func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) { - if !utils.IsS3Configured() { - c.Err = model.NewAppError("uploadFile", "Unable to upload file. Amazon S3 not configured. ", "") + if !utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + c.Err = model.NewAppError("uploadFile", "Unable to upload file. Amazon S3 not configured and local server storage turned off. ", "") c.Err.StatusCode = http.StatusNotImplemented return } @@ -48,13 +50,6 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) { return } - var auth aws.Auth - auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId - auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey - - s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) - bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) - m := r.MultipartForm props := m.Value @@ -94,25 +89,18 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) { buf := bytes.NewBuffer(nil) io.Copy(buf, file) - ext := filepath.Ext(files[i].Filename) - uid := model.NewId() path := "teams/" + c.Session.TeamId + "/channels/" + channelId + "/users/" + c.Session.UserId + "/" + uid + "/" + files[i].Filename - if model.IsFileExtImage(ext) { - options := s3.Options{} - err = bucket.Put(path, buf.Bytes(), model.GetImageMimeType(ext), s3.Private, options) - imageNameList = append(imageNameList, uid+"/"+files[i].Filename) - imageDataList = append(imageDataList, buf.Bytes()) - } else { - options := s3.Options{} - err = bucket.Put(path, buf.Bytes(), "binary/octet-stream", s3.Private, options) + if err := writeFile(buf.Bytes(), path); err != nil { + c.Err = err + return } - if err != nil { - c.Err = model.NewAppError("uploadFile", "Unable to upload file. ", err.Error()) - return + if model.IsFileExtImage(filepath.Ext(files[i].Filename)) { + imageNameList = append(imageNameList, uid+"/"+files[i].Filename) + imageDataList = append(imageDataList, buf.Bytes()) } fileUrl := "/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + url.QueryEscape(files[i].Filename) @@ -127,13 +115,6 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) { func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, channelId, userId string) { go func() { - var auth aws.Auth - auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId - auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey - - s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) - bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) - dest := "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" for i, filename := range filenames { @@ -169,9 +150,7 @@ func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, ch return } - // Upload thumbnail to S3 - options := s3.Options{} - err = bucket.Put(dest+name+"_thumb.jpg", buf.Bytes(), "image/jpeg", s3.Private, options) + err = writeFile(buf.Bytes(), dest+name+"_thumb.jpg") if err != nil { l4g.Error("Unable to upload thumbnail to S3 channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err) return @@ -188,17 +167,14 @@ func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, ch } buf := new(bytes.Buffer) - err = jpeg.Encode(buf, preview, &jpeg.Options{Quality: 90}) - //err = png.Encode(buf, preview) + err = jpeg.Encode(buf, preview, &jpeg.Options{Quality: 90}) if err != nil { l4g.Error("Unable to encode image as preview jpg channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err) return } - // Upload preview to S3 - options := s3.Options{} - err = bucket.Put(dest+name+"_preview.jpg", buf.Bytes(), "image/jpeg", s3.Private, options) + err = writeFile(buf.Bytes(), dest+name+"_preview.jpg") if err != nil { l4g.Error("Unable to upload preview to S3 channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err) return @@ -215,8 +191,8 @@ type ImageGetResult struct { } func getFile(c *Context, w http.ResponseWriter, r *http.Request) { - if !utils.IsS3Configured() { - c.Err = model.NewAppError("getFile", "Unable to get file. Amazon S3 not configured. ", "") + if !utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + c.Err = model.NewAppError("getFile", "Unable to upload file. Amazon S3 not configured and local server storage turned off. ", "") c.Err.StatusCode = http.StatusNotImplemented return } @@ -247,13 +223,6 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) { cchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, channelId, c.Session.UserId) - var auth aws.Auth - auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId - auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey - - s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) - bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) - path := "" if len(teamId) == 26 { path = "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + filename @@ -262,7 +231,7 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) { } fileData := make(chan []byte) - asyncGetFile(bucket, path, fileData) + asyncGetFile(path, fileData) if len(hash) > 0 && len(data) > 0 && len(teamId) == 26 { if !model.ComparePassword(hash, fmt.Sprintf("%v:%v", data, utils.Cfg.ServiceSettings.PublicLinkSalt)) { @@ -283,26 +252,7 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) { f := <-fileData if f == nil { - var f2 []byte - tries := 0 - for { - time.Sleep(3000 * time.Millisecond) - tries++ - - asyncGetFile(bucket, path, fileData) - f2 = <-fileData - - if f2 != nil { - w.Header().Set("Cache-Control", "max-age=2592000, public") - w.Header().Set("Content-Length", strconv.Itoa(len(f2))) - w.Write(f2) - return - } else if tries >= 2 { - break - } - } - - c.Err = model.NewAppError("getFile", "Could not find file.", "url extenstion: "+path) + c.Err = model.NewAppError("getFile", "Could not find file.", "path="+path) c.Err.StatusCode = http.StatusNotFound return } @@ -312,9 +262,9 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) { w.Write(f) } -func asyncGetFile(bucket *s3.Bucket, path string, fileData chan []byte) { +func asyncGetFile(path string, fileData chan []byte) { go func() { - data, getErr := bucket.Get(path) + data, getErr := readFile(path) if getErr != nil { fileData <- nil } else { @@ -329,8 +279,8 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) { c.Err.StatusCode = http.StatusForbidden } - if !utils.IsS3Configured() { - c.Err = model.NewAppError("getPublicLink", "Unable to get link. Amazon S3 not configured. ", "") + if !utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + c.Err = model.NewAppError("getPublicLink", "Unable to upload file. Amazon S3 not configured and local server storage turned off. ", "") c.Err.StatusCode = http.StatusNotImplemented return } @@ -374,3 +324,78 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.MapToJson(rData))) } + +func writeFile(f []byte, path string) *model.AppError { + + if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + var auth aws.Auth + auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId + auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey + + s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) + bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) + + ext := filepath.Ext(path) + + var err error + if model.IsFileExtImage(ext) { + options := s3.Options{} + err = bucket.Put(path, f, model.GetImageMimeType(ext), s3.Private, options) + + } else { + options := s3.Options{} + err = bucket.Put(path, f, "binary/octet-stream", s3.Private, options) + } + + if err != nil { + return model.NewAppError("writeFile", "Encountered an error writing to S3", err.Error()) + } + } else if utils.Cfg.ServiceSettings.UseLocalStorage && len(utils.Cfg.ServiceSettings.StorageDirectory) > 0 { + if err := os.MkdirAll(filepath.Dir(utils.Cfg.ServiceSettings.StorageDirectory+path), 0774); err != nil { + return model.NewAppError("writeFile", "Encountered an error creating the directory for the new file", err.Error()) + } + + if err := ioutil.WriteFile(utils.Cfg.ServiceSettings.StorageDirectory+path, f, 0644); err != nil { + return model.NewAppError("writeFile", "Encountered an error writing to local server storage", err.Error()) + } + } else { + return model.NewAppError("writeFile", "File storage not configured properly. Please configure for either S3 or local server file storage.", "") + } + + return nil +} + +func readFile(path string) ([]byte, *model.AppError) { + + if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + var auth aws.Auth + auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId + auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey + + s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) + bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) + + // try to get the file from S3 with some basic retry logic + tries := 0 + for { + tries++ + + f, err := bucket.Get(path) + + if f != nil { + return f, nil + } else if tries >= 3 { + return nil, model.NewAppError("readFile", "Unable to get file from S3", "path="+path+", err="+err.Error()) + } + time.Sleep(3000 * time.Millisecond) + } + } else if utils.Cfg.ServiceSettings.UseLocalStorage && len(utils.Cfg.ServiceSettings.StorageDirectory) > 0 { + if f, err := ioutil.ReadFile(utils.Cfg.ServiceSettings.StorageDirectory + path); err != nil { + return nil, model.NewAppError("readFile", "Encountered an error reading from local server storage", err.Error()) + } else { + return f, nil + } + } else { + return nil, model.NewAppError("readFile", "File storage not configured properly. Please configure for either S3 or local server file storage.", "") + } +} diff --git a/api/user.go b/api/user.go index 18c5e863a2..7035613ea2 100644 --- a/api/user.go +++ b/api/user.go @@ -7,8 +7,6 @@ import ( "bytes" l4g "code.google.com/p/log4go" "fmt" - "github.com/goamz/goamz/aws" - "github.com/goamz/goamz/s3" "github.com/gorilla/mux" "github.com/mattermost/platform/model" "github.com/mattermost/platform/store" @@ -598,7 +596,7 @@ func createProfileImage(username string, userId string) ([]byte, *model.AppError buf := new(bytes.Buffer) if imgErr := png.Encode(buf, img); imgErr != nil { - return nil, model.NewAppError("getProfileImage", "Could not encode default profile image", imgErr.Error()) + return nil, model.NewAppError("createProfileImage", "Could not encode default profile image", imgErr.Error()) } else { return buf.Bytes(), nil } @@ -613,34 +611,25 @@ func getProfileImage(c *Context, w http.ResponseWriter, r *http.Request) { return } else { var img []byte - var err *model.AppError - if !utils.IsS3Configured() { - img, err = createProfileImage(result.Data.(*model.User).Username, id) - if err != nil { + if !utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + var err *model.AppError + if img, err = createProfileImage(result.Data.(*model.User).Username, id); err != nil { c.Err = err return } } else { - var auth aws.Auth - auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId - auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey - - s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) - bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) - path := "teams/" + c.Session.TeamId + "/users/" + id + "/profile.png" - if data, getErr := bucket.Get(path); getErr != nil { - img, err = createProfileImage(result.Data.(*model.User).Username, id) - if err != nil { + if data, err := readFile(path); err != nil { + + if img, err = createProfileImage(result.Data.(*model.User).Username, id); err != nil { c.Err = err return } - options := s3.Options{} - if err := bucket.Put(path, img, "image", s3.Private, options); err != nil { - c.Err = model.NewAppError("getImage", "Couldn't upload default profile image", err.Error()) + if err := writeFile(img, path); err != nil { + c.Err = err return } @@ -660,8 +649,8 @@ func getProfileImage(c *Context, w http.ResponseWriter, r *http.Request) { } func uploadProfileImage(c *Context, w http.ResponseWriter, r *http.Request) { - if !utils.IsS3Configured() { - c.Err = model.NewAppError("uploadProfileImage", "Unable to upload image. Amazon S3 not configured. ", "") + if !utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + c.Err = model.NewAppError("uploadProfileImage", "Unable to upload file. Amazon S3 not configured and local server storage turned off. ", "") c.Err.StatusCode = http.StatusNotImplemented return } @@ -671,13 +660,6 @@ func uploadProfileImage(c *Context, w http.ResponseWriter, r *http.Request) { return } - var auth aws.Auth - auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId - auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey - - s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) - bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) - m := r.MultipartForm imageArray, ok := m.File["image"] @@ -721,8 +703,7 @@ func uploadProfileImage(c *Context, w http.ResponseWriter, r *http.Request) { path := "teams/" + c.Session.TeamId + "/users/" + c.Session.UserId + "/profile.png" - options := s3.Options{} - if err := bucket.Put(path, buf.Bytes(), "image", s3.Private, options); err != nil { + if err := writeFile(buf.Bytes(), path); err != nil { c.Err = model.NewAppError("uploadProfileImage", "Couldn't upload profile image", "") return } diff --git a/config/config.json b/config/config.json index e6025ef517..1ca7b66ec5 100644 --- a/config/config.json +++ b/config/config.json @@ -19,7 +19,9 @@ "InviteSalt": "gxHVDcKUyP2y1eiyW8S8na1UYQAfq6J6", "PublicLinkSalt": "TO3pTyXIZzwHiwyZgGql7lM7DG3zeId4", "ResetSalt": "IPxFzSfnDFsNsRafZxz8NaYqFKhf9y2t", - "AnalyticsUrl": "" + "AnalyticsUrl": "", + "UseLocalStorage": true, + "StorageDirectory": "/mattermost/" }, "SqlSettings": { "DriverName": "mysql", diff --git a/config/config_docker.json b/config/config_docker.json index 9be8370724..b7d7939ca1 100644 --- a/config/config_docker.json +++ b/config/config_docker.json @@ -19,7 +19,9 @@ "InviteSalt": "gxHVDcKUyP2y1eiyW8S8na1UYQAfq6J6", "PublicLinkSalt": "TO3pTyXIZzwHiwyZgGql7lM7DG3zeId4", "ResetSalt": "IPxFzSfnDFsNsRafZxz8NaYqFKhf9y2t", - "AnalyticsUrl": "" + "AnalyticsUrl": "", + "UseLocalStorage": "", + "StorageDirectory": "/mattermost/" }, "SqlSettings": { "DriverName": "mysql", diff --git a/utils/config.go b/utils/config.go index efa4b263a9..e8fa9a4778 100644 --- a/utils/config.go +++ b/utils/config.go @@ -18,16 +18,18 @@ const ( ) type ServiceSettings struct { - SiteName string - Mode string - AllowTesting bool - UseSSL bool - Port string - Version string - InviteSalt string - PublicLinkSalt string - ResetSalt string - AnalyticsUrl string + SiteName string + Mode string + AllowTesting bool + UseSSL bool + Port string + Version string + InviteSalt string + PublicLinkSalt string + ResetSalt string + AnalyticsUrl string + UseLocalStorage bool + StorageDirectory string } type SqlSettings struct { From a6fc129a01bf760aa163c8f842a3f2b67b375e3e Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Thu, 16 Jul 2015 12:50:38 -0400 Subject: [PATCH 21/48] update file unit tests --- api/file.go | 10 ++-- api/file_test.go | 153 ++++++++++++++++++++++++++++++++++------------- api/user_test.go | 23 ++++--- 3 files changed, 131 insertions(+), 55 deletions(-) diff --git a/api/file.go b/api/file.go index d6f9e6c1d5..4fead46271 100644 --- a/api/file.go +++ b/api/file.go @@ -150,9 +150,8 @@ func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, ch return } - err = writeFile(buf.Bytes(), dest+name+"_thumb.jpg") - if err != nil { - l4g.Error("Unable to upload thumbnail to S3 channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err) + if err := writeFile(buf.Bytes(), dest+name+"_thumb.jpg"); err != nil { + l4g.Error("Unable to upload thumbnail channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err) return } }() @@ -174,9 +173,8 @@ func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, ch return } - err = writeFile(buf.Bytes(), dest+name+"_preview.jpg") - if err != nil { - l4g.Error("Unable to upload preview to S3 channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err) + if err := writeFile(buf.Bytes(), dest+name+"_preview.jpg"); err != nil { + l4g.Error("Unable to upload preview channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err) return } }() diff --git a/api/file_test.go b/api/file_test.go index 79ee03c778..d5817234dc 100644 --- a/api/file_test.go +++ b/api/file_test.go @@ -68,12 +68,14 @@ func TestUploadFile(t *testing.T) { } resp, appErr := Client.UploadFile("/files/upload", body.Bytes(), writer.FormDataContentType()) - if utils.IsS3Configured() { + if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { if appErr != nil { t.Fatal(appErr) } - filenames := resp.Data.(*model.FileUploadResponse).Filenames + filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/") + filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1] + fileId := strings.Split(filename, ".")[0] var auth aws.Auth auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId @@ -82,12 +84,10 @@ func TestUploadFile(t *testing.T) { s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) - fileId := strings.Split(filenames[0], ".")[0] - // wait a bit for files to ready time.Sleep(5 * time.Second) - err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filenames[0]) + err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename) if err != nil { t.Fatal(err) } @@ -97,13 +97,35 @@ func TestUploadFile(t *testing.T) { t.Fatal(err) } - err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.png") + err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg") if err != nil { t.Fatal(err) } + } else if utils.Cfg.ServiceSettings.UseLocalStorage && len(utils.Cfg.ServiceSettings.StorageDirectory) > 0 { + filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/") + filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1] + fileId := strings.Split(filename, ".")[0] + + // wait a bit for files to ready + time.Sleep(5 * time.Second) + + path := utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } + + path = utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg" + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } + + path = utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg" + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } } else { if appErr == nil { - t.Fatal("S3 not configured, should have failed") + t.Fatal("S3 and local storage not configured, should have failed") } } } @@ -123,7 +145,7 @@ func TestGetFile(t *testing.T) { channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) - if utils.IsS3Configured() { + if utils.IsS3Configured() || utils.Cfg.ServiceSettings.UseLocalStorage { body := &bytes.Buffer{} writer := multipart.NewWriter(body) @@ -225,28 +247,51 @@ func TestGetFile(t *testing.T) { t.Fatal("Should have errored - user not logged in and link not public") } - var auth aws.Auth - auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId - auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey + if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + var auth aws.Auth + auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId + auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey - s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) - bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) + s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) + bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) - fileId := strings.Split(filenames[0], ".")[0] + filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/") + filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1] + fileId := strings.Split(filename, ".")[0] - err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filenames[0]) - if err != nil { - t.Fatal(err) - } + err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename) + if err != nil { + t.Fatal(err) + } - err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg") - if err != nil { - t.Fatal(err) - } + err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg") + if err != nil { + t.Fatal(err) + } - err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.png") - if err != nil { - t.Fatal(err) + err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg") + if err != nil { + t.Fatal(err) + } + } else { + filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/") + filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1] + fileId := strings.Split(filename, ".")[0] + + path := utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } + + path = utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg" + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } + + path = utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg" + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } } } else { if _, downErr := Client.GetFile("/files/get/yxebdmbz5pgupx7q6ez88rw11a/n3btzxu9hbnapqk36iwaxkjxhc/junk.jpg", false); downErr.StatusCode != http.StatusNotImplemented { @@ -274,7 +319,7 @@ func TestGetPublicLink(t *testing.T) { channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) - if utils.IsS3Configured() { + if utils.IsS3Configured() || utils.Cfg.ServiceSettings.UseLocalStorage { body := &bytes.Buffer{} writer := multipart.NewWriter(body) @@ -350,26 +395,52 @@ func TestGetPublicLink(t *testing.T) { t.Fatal("should have errored, user not member of channel") } - // perform clean-up on s3 - var auth aws.Auth - auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId - auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey + if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + // perform clean-up on s3 + var auth aws.Auth + auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId + auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey - s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) - bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) + s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) + bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) - fileId := strings.Split(filenames[0], ".")[0] + filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/") + filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1] + fileId := strings.Split(filename, ".")[0] - if err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + rpost1.Data.(*model.Post).UserId + "/" + filenames[0]); err != nil { - t.Fatal(err) - } + err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename) + if err != nil { + t.Fatal(err) + } - if err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + rpost1.Data.(*model.Post).UserId + "/" + fileId + "_thumb.jpg"); err != nil { - t.Fatal(err) - } + err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg") + if err != nil { + t.Fatal(err) + } - if err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + rpost1.Data.(*model.Post).UserId + "/" + fileId + "_preview.png"); err != nil { - t.Fatal(err) + err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg") + if err != nil { + t.Fatal(err) + } + } else { + filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/") + filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1] + fileId := strings.Split(filename, ".")[0] + + path := utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } + + path = utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg" + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } + + path = utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg" + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } } } else { data := make(map[string]string) diff --git a/api/user_test.go b/api/user_test.go index fbd13492b8..6cd47defac 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -368,7 +368,7 @@ func TestUserUploadProfileImage(t *testing.T) { user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) store.Must(Srv.Store.User().VerifyEmail(user.Id)) - if utils.IsS3Configured() { + if utils.IsS3Configured() || utils.Cfg.ServiceSettings.UseLocalStorage { body := &bytes.Buffer{} writer := multipart.NewWriter(body) @@ -436,15 +436,22 @@ func TestUserUploadProfileImage(t *testing.T) { Client.DoGet("/users/"+user.Id+"/image", "", "") - var auth aws.Auth - auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId - auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey + if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + var auth aws.Auth + auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId + auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey - s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) - bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) + s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) + bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) - if err := bucket.Del("teams/" + user.TeamId + "/users/" + user.Id + "/profile.png"); err != nil { - t.Fatal(err) + if err := bucket.Del("teams/" + user.TeamId + "/users/" + user.Id + "/profile.png"); err != nil { + t.Fatal(err) + } + } else { + path := utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + user.TeamId + "/users/" + user.Id + "/profile.png" + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } } } else { body := &bytes.Buffer{} From 1e7fe834e901043b12007219acff547fb7ae8ae0 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Fri, 17 Jul 2015 07:56:08 -0400 Subject: [PATCH 22/48] turned off local server storage by default --- config/config.json | 4 ++-- config/config_docker.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/config.json b/config/config.json index 1ca7b66ec5..a35303784b 100644 --- a/config/config.json +++ b/config/config.json @@ -20,8 +20,8 @@ "PublicLinkSalt": "TO3pTyXIZzwHiwyZgGql7lM7DG3zeId4", "ResetSalt": "IPxFzSfnDFsNsRafZxz8NaYqFKhf9y2t", "AnalyticsUrl": "", - "UseLocalStorage": true, - "StorageDirectory": "/mattermost/" + "UseLocalStorage": false, + "StorageDirectory": "" }, "SqlSettings": { "DriverName": "mysql", diff --git a/config/config_docker.json b/config/config_docker.json index b7d7939ca1..e2c7a093d7 100644 --- a/config/config_docker.json +++ b/config/config_docker.json @@ -20,8 +20,8 @@ "PublicLinkSalt": "TO3pTyXIZzwHiwyZgGql7lM7DG3zeId4", "ResetSalt": "IPxFzSfnDFsNsRafZxz8NaYqFKhf9y2t", "AnalyticsUrl": "", - "UseLocalStorage": "", - "StorageDirectory": "/mattermost/" + "UseLocalStorage": false, + "StorageDirectory": "" }, "SqlSettings": { "DriverName": "mysql", From edf26b4bc6f997d3dc2927b24e5787e6f66f2eb6 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Fri, 17 Jul 2015 12:26:18 -0400 Subject: [PATCH 23/48] create default storage directory in Dockerfile, turn on server storage by default for docker config --- Dockerfile | 11 +++++++---- config/config_docker.json | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5c389c0560..e6c04d5419 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,7 +40,7 @@ WORKDIR /go # # Install SQL -# +# ENV MYSQL_ROOT_PASSWORD=mostest ENV MYSQL_USER=mmuser @@ -60,7 +60,7 @@ RUN echo "deb http://repo.mysql.com/apt/debian/ wheezy mysql-${MYSQL_MAJOR}" > / RUN apt-get update \ && export DEBIAN_FRONTEND=noninteractive \ - && apt-get -y install mysql-server \ + && apt-get -y install mysql-server \ && rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/mysql && mkdir -p /var/lib/mysql @@ -88,12 +88,15 @@ ADD . /go/src/github.com/mattermost/platform ADD ./docker/main.cf /etc/postfix/ RUN go get github.com/tools/godep -RUN cd /go/src/github.com/mattermost/platform; godep restore +RUN cd /go/src/github.com/mattermost/platform; godep restore RUN go install github.com/mattermost/platform -RUN cd /go/src/github.com/mattermost/platform/web/react; npm install +RUN cd /go/src/github.com/mattermost/platform/web/react; npm install RUN chmod +x /go/src/github.com/mattermost/platform/docker/docker-entry.sh ENTRYPOINT /go/src/github.com/mattermost/platform/docker/docker-entry.sh +# Create default storage directory +RUN mkdir /mattermost/ + # Ports EXPOSE 80 diff --git a/config/config_docker.json b/config/config_docker.json index e2c7a093d7..e862a6540e 100644 --- a/config/config_docker.json +++ b/config/config_docker.json @@ -20,8 +20,8 @@ "PublicLinkSalt": "TO3pTyXIZzwHiwyZgGql7lM7DG3zeId4", "ResetSalt": "IPxFzSfnDFsNsRafZxz8NaYqFKhf9y2t", "AnalyticsUrl": "", - "UseLocalStorage": false, - "StorageDirectory": "" + "UseLocalStorage": true, + "StorageDirectory": "/mattermost/" }, "SqlSettings": { "DriverName": "mysql", From c63fbd4ccc5e7a11c4ce15fe7d19a3daf4e5c45e Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Fri, 17 Jul 2015 15:55:06 -0400 Subject: [PATCH 24/48] add proper url encoding for filenames --- api/file.go | 5 ++++- utils/urlencode.go | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 utils/urlencode.go diff --git a/api/file.go b/api/file.go index 4fead46271..2abaca7091 100644 --- a/api/file.go +++ b/api/file.go @@ -103,7 +103,9 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) { imageDataList = append(imageDataList, buf.Bytes()) } - fileUrl := "/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + url.QueryEscape(files[i].Filename) + encName := utils.UrlEncode(files[i].Filename) + + fileUrl := "/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + encName resStruct.Filenames = append(resStruct.Filenames, fileUrl) } @@ -264,6 +266,7 @@ func asyncGetFile(path string, fileData chan []byte) { go func() { data, getErr := readFile(path) if getErr != nil { + l4g.Error(getErr) fileData <- nil } else { fileData <- data diff --git a/utils/urlencode.go b/utils/urlencode.go new file mode 100644 index 0000000000..63a8f7880d --- /dev/null +++ b/utils/urlencode.go @@ -0,0 +1,19 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package utils + +import ( + "net/url" + "strings" +) + +func UrlEncode(str string) string { + strs := strings.Split(str, " ") + + for i, s := range strs { + strs[i] = url.QueryEscape(s) + } + + return strings.Join(strs, "%20") +} From 83ef05328f44494ff7ec3b7bcab3378f29e54605 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Mon, 20 Jul 2015 07:58:56 -0400 Subject: [PATCH 25/48] added relative data directory as default for storage and added it to gitignore --- .gitignore | 3 +++ config/config.json | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index c899ddd8f0..de70d1d0b5 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ web/sass-files/sass/.sass-cache/ *config.codekit *.sass-cache *styles.css + +# Default local file storage +data/* diff --git a/config/config.json b/config/config.json index a35303784b..085dd6de65 100644 --- a/config/config.json +++ b/config/config.json @@ -20,8 +20,8 @@ "PublicLinkSalt": "TO3pTyXIZzwHiwyZgGql7lM7DG3zeId4", "ResetSalt": "IPxFzSfnDFsNsRafZxz8NaYqFKhf9y2t", "AnalyticsUrl": "", - "UseLocalStorage": false, - "StorageDirectory": "" + "UseLocalStorage": true, + "StorageDirectory": "./data/" }, "SqlSettings": { "DriverName": "mysql", From 3fd5891a1aed6fadd0a8de31ec2b3f67da4c68cc Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Mon, 20 Jul 2015 08:00:07 -0400 Subject: [PATCH 26/48] change docker default storage directory to /mattermost/data --- config/config_docker.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config_docker.json b/config/config_docker.json index e862a6540e..062cdef653 100644 --- a/config/config_docker.json +++ b/config/config_docker.json @@ -21,7 +21,7 @@ "ResetSalt": "IPxFzSfnDFsNsRafZxz8NaYqFKhf9y2t", "AnalyticsUrl": "", "UseLocalStorage": true, - "StorageDirectory": "/mattermost/" + "StorageDirectory": "/mattermost/data/" }, "SqlSettings": { "DriverName": "mysql", From 25cfe548e6e41f3e2972f5b22c4191af7459f3e1 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Mon, 20 Jul 2015 08:06:55 -0400 Subject: [PATCH 27/48] remove data dirs in make clean and updated gitignore with api/data --- .gitignore | 1 + Makefile | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index de70d1d0b5..b8017c1988 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ web/sass-files/sass/.sass-cache/ # Default local file storage data/* +api/data/* diff --git a/Makefile b/Makefile index 589521c371..8793ba98ad 100644 --- a/Makefile +++ b/Makefile @@ -112,6 +112,8 @@ clean: rm -f web/static/js/bundle*.js rm -f web/static/css/styles.css + rm -rf data/* + rm -rf api/data/* rm -rf logs/* From 04408eea375439d69b61d65155fea863b3b1834c Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Mon, 20 Jul 2015 08:18:05 -0400 Subject: [PATCH 28/48] updated create profile image unit test to clean up images properly --- api/user_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/api/user_test.go b/api/user_test.go index 6cd47defac..e236adeafe 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -356,6 +356,24 @@ func TestUserCreateImage(t *testing.T) { Client.DoGet("/users/"+user.Id+"/image", "", "") + if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + var auth aws.Auth + auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId + auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey + + s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) + bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) + + if err := bucket.Del("teams/" + user.TeamId + "/users/" + user.Id + "/profile.png"); err != nil { + t.Fatal(err) + } + } else { + path := utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + user.TeamId + "/users/" + user.Id + "/profile.png" + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } + } + } func TestUserUploadProfileImage(t *testing.T) { From 39abf24708870cec71a84c01063e647b859b2b67 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Tue, 21 Jul 2015 15:18:17 -0400 Subject: [PATCH 29/48] added sanitization to filenames to remove the possibility of relative paths --- api/file.go | 8 +++++--- api/file_test.go | 8 +++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/api/file.go b/api/file.go index 2abaca7091..1dd179422c 100644 --- a/api/file.go +++ b/api/file.go @@ -89,9 +89,11 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) { buf := bytes.NewBuffer(nil) io.Copy(buf, file) + filename := filepath.Base(files[i].Filename) + uid := model.NewId() - path := "teams/" + c.Session.TeamId + "/channels/" + channelId + "/users/" + c.Session.UserId + "/" + uid + "/" + files[i].Filename + path := "teams/" + c.Session.TeamId + "/channels/" + channelId + "/users/" + c.Session.UserId + "/" + uid + "/" + filename if err := writeFile(buf.Bytes(), path); err != nil { c.Err = err @@ -99,11 +101,11 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) { } if model.IsFileExtImage(filepath.Ext(files[i].Filename)) { - imageNameList = append(imageNameList, uid+"/"+files[i].Filename) + imageNameList = append(imageNameList, uid+"/"+filename) imageDataList = append(imageDataList, buf.Bytes()) } - encName := utils.UrlEncode(files[i].Filename) + encName := utils.UrlEncode(filename) fileUrl := "/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + encName resStruct.Filenames = append(resStruct.Filenames, fileUrl) diff --git a/api/file_test.go b/api/file_test.go index d5817234dc..3f414d768d 100644 --- a/api/file_test.go +++ b/api/file_test.go @@ -38,7 +38,7 @@ func TestUploadFile(t *testing.T) { body := &bytes.Buffer{} writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("files", "test.png") + part, err := writer.CreateFormFile("files", "../test.png") if err != nil { t.Fatal(err) } @@ -75,6 +75,9 @@ func TestUploadFile(t *testing.T) { filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/") filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1] + if strings.Contains(filename, "../") { + t.Fatal("relative path should have been sanitized out") + } fileId := strings.Split(filename, ".")[0] var auth aws.Auth @@ -104,6 +107,9 @@ func TestUploadFile(t *testing.T) { } else if utils.Cfg.ServiceSettings.UseLocalStorage && len(utils.Cfg.ServiceSettings.StorageDirectory) > 0 { filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/") filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1] + if strings.Contains(filename, "../") { + t.Fatal("relative path should have been sanitized out") + } fileId := strings.Split(filename, ".")[0] // wait a bit for files to ready From b821d23ed71c89b14aa294debcf390057de27b37 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Tue, 21 Jul 2015 19:21:05 -0400 Subject: [PATCH 30/48] fixed unit tests to work with team domain changes and update partial url regex for files --- api/file.go | 11 +++++------ api/file_test.go | 23 +++++++++++++---------- api/post.go | 34 ++++++++++++++++++++++++++++++++++ api/post_test.go | 2 +- model/client.go | 2 +- model/utils.go | 2 +- 6 files changed, 55 insertions(+), 19 deletions(-) diff --git a/api/file.go b/api/file.go index 1dd179422c..82cee9d1e7 100644 --- a/api/file.go +++ b/api/file.go @@ -297,15 +297,14 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) { } matches := model.PartialUrlRegex.FindAllStringSubmatch(filename, -1) - if len(matches) == 0 || len(matches[0]) < 5 { + if len(matches) == 0 || len(matches[0]) < 4 { c.SetInvalidParam("getPublicLink", "filename") return } - getType := matches[0][1] - channelId := matches[0][2] - userId := matches[0][3] - filename = matches[0][4] + channelId := matches[0][1] + userId := matches[0][2] + filename = matches[0][3] cchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, channelId, c.Session.UserId) @@ -316,7 +315,7 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) { data := model.MapToJson(newProps) hash := model.HashPassword(fmt.Sprintf("%v:%v", data, utils.Cfg.ServiceSettings.PublicLinkSalt)) - url := fmt.Sprintf("%s/api/v1/files/%s/%s/%s/%s?d=%s&h=%s&t=%s", c.GetSiteURL(), getType, channelId, userId, filename, url.QueryEscape(data), url.QueryEscape(hash), c.Session.TeamId) + url := fmt.Sprintf("%s/api/v1/files/get/%s/%s/%s?d=%s&h=%s&t=%s", c.GetSiteURL(), channelId, userId, filename, url.QueryEscape(data), url.QueryEscape(hash), c.Session.TeamId) if !c.HasPermissionsToChannel(cchan, "getPublicLink") { return diff --git a/api/file_test.go b/api/file_test.go index 3f414d768d..a708e9bb1a 100644 --- a/api/file_test.go +++ b/api/file_test.go @@ -5,6 +5,7 @@ package api import ( "bytes" + l4g "code.google.com/p/log4go" "fmt" "github.com/goamz/goamz/aws" "github.com/goamz/goamz/s3" @@ -197,8 +198,9 @@ func TestGetFile(t *testing.T) { // wait a bit for files to ready time.Sleep(5 * time.Second) - if _, downErr := Client.GetFile(filenames[0], true); downErr != nil { - t.Fatal("file get failed") + l4g.Debug(filenames) + if _, downErr := Client.GetFile(filenames[0], false); downErr != nil { + t.Fatal(downErr) } team2 := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} @@ -217,35 +219,35 @@ func TestGetFile(t *testing.T) { Client.LoginByEmail(team2.Name, user2.Email, "pwd") - if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h="+url.QueryEscape(hash)+"&t="+team.Id, true); downErr != nil { + if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h="+url.QueryEscape(hash)+"&t="+team.Id, false); downErr != nil { t.Fatal(downErr) } - if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h="+url.QueryEscape(hash), true); downErr == nil { + if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h="+url.QueryEscape(hash), false); downErr == nil { t.Fatal("Should have errored - missing team id") } - if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h="+url.QueryEscape(hash)+"&t=junk", true); downErr == nil { + if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h="+url.QueryEscape(hash)+"&t=junk", false); downErr == nil { t.Fatal("Should have errored - bad team id") } - if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h="+url.QueryEscape(hash)+"&t=12345678901234567890123456", true); downErr == nil { + if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h="+url.QueryEscape(hash)+"&t=12345678901234567890123456", false); downErr == nil { t.Fatal("Should have errored - bad team id") } - if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&t="+team.Id, true); downErr == nil { + if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&t="+team.Id, false); downErr == nil { t.Fatal("Should have errored - missing hash") } - if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h=junk&t="+team.Id, true); downErr == nil { + if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h=junk&t="+team.Id, false); downErr == nil { t.Fatal("Should have errored - bad hash") } - if _, downErr := Client.GetFile(filenames[0]+"?h="+url.QueryEscape(hash)+"&t="+team.Id, true); downErr == nil { + if _, downErr := Client.GetFile(filenames[0]+"?h="+url.QueryEscape(hash)+"&t="+team.Id, false); downErr == nil { t.Fatal("Should have errored - missing data") } - if _, downErr := Client.GetFile(filenames[0]+"?d=junk&h="+url.QueryEscape(hash)+"&t="+team.Id, true); downErr == nil { + if _, downErr := Client.GetFile(filenames[0]+"?d=junk&h="+url.QueryEscape(hash)+"&t="+team.Id, false); downErr == nil { t.Fatal("Should have errored - bad data") } @@ -429,6 +431,7 @@ func TestGetPublicLink(t *testing.T) { t.Fatal(err) } } else { + l4g.Debug(resp.Data.(*model.FileUploadResponse).Filenames[0]) filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/") filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1] fileId := strings.Split(filename, ".")[0] diff --git a/api/post.go b/api/post.go index 2d25f7ab01..70ff134978 100644 --- a/api/post.go +++ b/api/post.go @@ -160,6 +160,40 @@ func CreatePost(c *Context, post *model.Post, doUpdateLastViewed bool) (*model.P post.UserId = c.Session.UserId + if len(post.Filenames) > 0 { + doRemove := false + for i := len(post.Filenames) - 1; i >= 0; i-- { + path := post.Filenames[i] + + doRemove = false + l4g.Debug(path) + if model.UrlRegex.MatchString(path) { + continue + } else if model.PartialUrlRegex.MatchString(path) { + matches := model.PartialUrlRegex.FindAllStringSubmatch(path, -1) + if len(matches) == 0 || len(matches[0]) < 4 { + doRemove = true + } + + channelId := matches[0][1] + if channelId != post.ChannelId { + doRemove = true + } + + userId := matches[0][2] + if userId != post.UserId { + doRemove = true + } + } else { + doRemove = true + } + if doRemove { + l4g.Error("Bad filename discarded, filename=%v", path) + post.Filenames = append(post.Filenames[:i], post.Filenames[i+1:]...) + } + } + } + var rpost *model.Post if result := <-Srv.Store.Post().Save(post); result.Err != nil { return nil, result.Err diff --git a/api/post_test.go b/api/post_test.go index 0cccc74d32..19a88f7378 100644 --- a/api/post_test.go +++ b/api/post_test.go @@ -37,7 +37,7 @@ func TestCreatePost(t *testing.T) { channel2 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel) - filenames := []string{"/api/v1/files/get/12345678901234567890123456/12345678901234567890123456/test.png", "/api/v1/files/get/" + channel1.Id + "/" + user1.Id + "/test.png"} + filenames := []string{"/12345678901234567890123456/12345678901234567890123456/12345678901234567890123456/test.png", "/" + channel1.Id + "/" + user1.Id + "/test.png", "www.mattermost.com/fake/url", "junk"} post1 := &model.Post{ChannelId: channel1.Id, Message: "#hashtag a" + model.NewId() + "a", Filenames: filenames} rpost1, err := Client.CreatePost(post1) diff --git a/model/client.go b/model/client.go index c7e17a6db7..9a144095a9 100644 --- a/model/client.go +++ b/model/client.go @@ -550,7 +550,7 @@ func (c *Client) GetFile(url string, isFullUrl bool) (*Result, *AppError) { if isFullUrl { rq, _ = http.NewRequest("GET", url, nil) } else { - rq, _ = http.NewRequest("GET", c.Url+url, nil) + rq, _ = http.NewRequest("GET", c.Url+"/files/get"+url, nil) } if len(c.AuthToken) > 0 { diff --git a/model/utils.go b/model/utils.go index 38592b984b..093a54e38c 100644 --- a/model/utils.go +++ b/model/utils.go @@ -319,6 +319,6 @@ func ClearMentionTags(post string) string { } var UrlRegex = regexp.MustCompile(`^((?:[a-z]+:\/\/)?(?:(?:[a-z0-9\-]+\.)+(?:[a-z]{2}|aero|arpa|biz|com|coop|edu|gov|info|int|jobs|mil|museum|name|nato|net|org|pro|travel|local|internal))(:[0-9]{1,5})?(?:\/[a-z0-9_\-\.~]+)*(\/([a-z0-9_\-\.]*)(?:\?[a-z0-9+_~\-\.%=&]*)?)?(?:#[a-zA-Z0-9!$&'()*+.=-_~:@/?]*)?)(?:\s+|$)$`) -var PartialUrlRegex = regexp.MustCompile(`/api/v1/files/(get|get_image)/([A-Za-z0-9]{26})/([A-Za-z0-9]{26})/(([A-Za-z0-9]+/)?.+\.[A-Za-z0-9]{3,})`) +var PartialUrlRegex = regexp.MustCompile(`/([A-Za-z0-9]{26})/([A-Za-z0-9]{26})/((?:[A-Za-z0-9]{26})?.+\.[A-Za-z0-9]{3,})`) var SplitRunes = map[rune]bool{',': true, ' ': true, '.': true, '!': true, '?': true, ':': true, ';': true, '\n': true, '<': true, '>': true, '(': true, ')': true, '{': true, '}': true, '[': true, ']': true, '+': true, '/': true, '\\': true} From 5886e695e41a500d80b5121d1b6c5beb4e087780 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Tue, 21 Jul 2015 19:29:40 -0400 Subject: [PATCH 31/48] fix click through on the image viewer modal to work with new team domain changes --- web/react/components/view_image.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx index 4d5d54e7ff..ac0ecf2999 100644 --- a/web/react/components/view_image.jsx +++ b/web/react/components/view_image.jsx @@ -155,7 +155,7 @@ module.exports = React.createClass({ var imgClass = "hidden"; if (this.state.loaded[id] && this.state.imgId == id) imgClass = ""; - img[info['path']] = ; + img[info['path']] = ; } } From 237920e314f3974880d9913aff69faafbe094107 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Tue, 21 Jul 2015 19:39:59 -0400 Subject: [PATCH 32/48] removed unnecessary debug statements --- api/file_test.go | 3 --- api/post.go | 1 - 2 files changed, 4 deletions(-) diff --git a/api/file_test.go b/api/file_test.go index a708e9bb1a..566fd69d0e 100644 --- a/api/file_test.go +++ b/api/file_test.go @@ -5,7 +5,6 @@ package api import ( "bytes" - l4g "code.google.com/p/log4go" "fmt" "github.com/goamz/goamz/aws" "github.com/goamz/goamz/s3" @@ -198,7 +197,6 @@ func TestGetFile(t *testing.T) { // wait a bit for files to ready time.Sleep(5 * time.Second) - l4g.Debug(filenames) if _, downErr := Client.GetFile(filenames[0], false); downErr != nil { t.Fatal(downErr) } @@ -431,7 +429,6 @@ func TestGetPublicLink(t *testing.T) { t.Fatal(err) } } else { - l4g.Debug(resp.Data.(*model.FileUploadResponse).Filenames[0]) filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/") filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1] fileId := strings.Split(filename, ".")[0] diff --git a/api/post.go b/api/post.go index 70ff134978..268a9be209 100644 --- a/api/post.go +++ b/api/post.go @@ -166,7 +166,6 @@ func CreatePost(c *Context, post *model.Post, doUpdateLastViewed bool) (*model.P path := post.Filenames[i] doRemove = false - l4g.Debug(path) if model.UrlRegex.MatchString(path) { continue } else if model.PartialUrlRegex.MatchString(path) { From aa99a3443aa1096eec3c0d169eb000f8b6666f84 Mon Sep 17 00:00:00 2001 From: Reed Garmsen Date: Tue, 21 Jul 2015 19:01:07 -0700 Subject: [PATCH 33/48] Changed the subject line for emails sent regarding mentions and other team notifications. This is meant to prevent notification emails from different teams from combining into a single email thread --- api/templates/post_subject.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/templates/post_subject.html b/api/templates/post_subject.html index 8ebc9550b9..7d8941549a 100644 --- a/api/templates/post_subject.html +++ b/api/templates/post_subject.html @@ -1 +1 @@ -{{define "post_subject"}}[{{.Props.TeamDisplayName}} {{.SiteName}}] {{.Props.SubjectText}} for {{.Props.Month}} {{.Props.Day}}, {{.Props.Year}}{{end}} +{{define "post_subject"}}[{{.SiteName}}] {{.Props.TeamDisplayName}} Team Notifications for {{.Props.Month}} {{.Props.Day}}, {{.Props.Year}}{{end}} From d58398708ee4c47ba403bc56ee5b14fc0941f797 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Tue, 21 Jul 2015 23:34:20 -0400 Subject: [PATCH 34/48] fixed download link in image viewer --- web/react/components/view_image.jsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx index ac0ecf2999..c107de4d79 100644 --- a/web/react/components/view_image.jsx +++ b/web/react/components/view_image.jsx @@ -37,7 +37,7 @@ module.exports = React.createClass({ } else { var fileInfo = utils.splitFileLocation(this.props.filenames[id]); // This is a temporary patch to fix issue with old files using absolute paths - if (fileInfo.path.indexOf("/api/v1/files/get") != -1) { + if (fileInfo.path.indexOf("/api/v1/files/get") !== -1) { fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; } fileInfo.path = window.location.origin + "/api/v1/files/get" + fileInfo.path; @@ -145,7 +145,7 @@ module.exports = React.createClass({ preview_filename = this.props.filenames[this.state.imgId]; } else { // This is a temporary patch to fix issue with old files using absolute paths - if (info.path.indexOf("/api/v1/files/get") != -1) { + if (info.path.indexOf("/api/v1/files/get") !== -1) { info.path = info.path.split("/api/v1/files/get")[1]; } info.path = window.location.origin + "/api/v1/files/get" + info.path; @@ -161,6 +161,13 @@ module.exports = React.createClass({ var imgFragment = React.addons.createFragment(img); + // This is a temporary patch to fix issue with old files using absolute paths + var download_link = this.props.filenames[this.state.imgId]; + if (download_link.indexOf("/api/v1/files/get") !== -1) { + download_link = download_link.split("/api/v1/files/get")[1]; + } + download_link = window.location.origin + "/api/v1/files/get" + download_link; + return ( {loading} From 29dd714d8254de13cddff133dfa00f2bb23ea7ec Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Wed, 22 Jul 2015 07:53:54 -0400 Subject: [PATCH 35/48] fixed thumbnails and files in RHS --- web/react/components/post_right.jsx | 49 ++++++++++++++--------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/web/react/components/post_right.jsx b/web/react/components/post_right.jsx index 581a1abe9e..7315266e69 100644 --- a/web/react/components/post_right.jsx +++ b/web/react/components/post_right.jsx @@ -91,28 +91,27 @@ RootPost = React.createClass({ var re2 = new RegExp('\\(', 'g'); var re3 = new RegExp('\\)', 'g'); for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) { - var fileSplit = filenames[i].split('.'); - if (fileSplit.length < 2) continue; + var fileInfo = utils.splitFileLocation(filenames[i]); + var ftype = utils.getFileType(fileInfo.ext); - var ext = fileSplit[fileSplit.length-1]; - fileSplit.splice(fileSplit.length-1,1); - var filePath = fileSplit.join('.'); - var filename = filePath.split('/')[filePath.split('/').length-1]; - - var ftype = utils.getFileType(ext); + // This is a temporary patch to fix issue with old files using absolute paths + if (fileInfo.path.indexOf("/api/v1/files/get") != -1) { + fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; + } + fileInfo.path = window.location.origin + "/api/v1/files/get" + fileInfo.path; if (ftype === "image") { - var url = filePath.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29'); + var url = fileInfo.path.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29'); postFiles.push( -
-
+
+
); images.push(filenames[i]); } else { postFiles.push( -
- +
+ @@ -201,28 +200,28 @@ CommentPost = React.createClass({ var re2 = new RegExp('\\(', 'g'); var re3 = new RegExp('\\)', 'g'); for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) { - var fileSplit = filenames[i].split('.'); - if (fileSplit.length < 2) continue; - var ext = fileSplit[fileSplit.length-1]; - fileSplit.splice(fileSplit.length-1,1) - var filePath = fileSplit.join('.'); - var filename = filePath.split('/')[filePath.split('/').length-1]; + var fileInfo = utils.splitFileLocation(filenames[i]); + var type = utils.getFileType(fileInfo.ext); - var type = utils.getFileType(ext); + // This is a temporary patch to fix issue with old files using absolute paths + if (fileInfo.path.indexOf("/api/v1/files/get") != -1) { + fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; + } + fileInfo.path = window.location.origin + "/api/v1/files/get" + fileInfo.path; if (type === "image") { - var url = filePath.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29'); + var url = fileInfo.path.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29'); postFiles.push( -
-
+
+
); images.push(filenames[i]); } else { postFiles.push( -
- +
+ From 2182953be5ba86ef46a512c189cf7d5f4dc2eada Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Wed, 22 Jul 2015 08:01:10 -0400 Subject: [PATCH 36/48] fix the ability to remove file previews once uploaded --- web/react/components/file_preview.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/react/components/file_preview.jsx b/web/react/components/file_preview.jsx index e69607206c..fdd12feec4 100644 --- a/web/react/components/file_preview.jsx +++ b/web/react/components/file_preview.jsx @@ -16,6 +16,7 @@ module.exports = React.createClass({ var previews = []; this.props.files.forEach(function(filename) { + var originalFilename = filename; var filenameSplit = filename.split('.'); var ext = filenameSplit[filenameSplit.length-1]; var type = utils.getFileType(ext); @@ -27,14 +28,14 @@ module.exports = React.createClass({ if (type === "image") { previews.push( -
+
); } else { previews.push( -
+
From 03cd1cc7d6581975cc85cacee678c90499e0f0aa Mon Sep 17 00:00:00 2001 From: Christopher Speller Date: Wed, 22 Jul 2015 09:51:42 -0400 Subject: [PATCH 37/48] Small patch for a null TypeError in post_list updating code --- web/react/components/post_list.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/react/components/post_list.jsx b/web/react/components/post_list.jsx index c058455bae..a2b2ae03f6 100644 --- a/web/react/components/post_list.jsx +++ b/web/react/components/post_list.jsx @@ -223,6 +223,7 @@ module.exports = React.createClass({ } }, _onTimeChange: function() { + if (!this.state.post_list) return; for (var id in this.state.post_list.posts) { if (!this.refs[id]) continue; this.refs[id].forceUpdateInfo(); From b3b01339306a93d227c4f29337750c4730dd25f1 Mon Sep 17 00:00:00 2001 From: Reed Garmsen Date: Wed, 1 Jul 2015 18:40:20 -0700 Subject: [PATCH 38/48] Adding back Access History and Active Devices --- web/react/components/access_history_modal.jsx | 100 ++++++++++ web/react/components/activity_log_modal.jsx | 116 ++++++++++++ web/react/components/user_settings.jsx | 172 ++---------------- web/react/components/user_settings_modal.jsx | 2 - web/react/pages/channel.jsx | 12 ++ .../sass/partials/_access-history.scss | 29 +++ .../sass/partials/_activity-log.scss | 31 ++++ web/sass-files/sass/partials/_responsive.scss | 43 ++++- web/sass-files/sass/partials/_settings.scss | 11 +- web/templates/channel.html | 2 + 10 files changed, 350 insertions(+), 168 deletions(-) create mode 100644 web/react/components/access_history_modal.jsx create mode 100644 web/react/components/activity_log_modal.jsx create mode 100644 web/sass-files/sass/partials/_access-history.scss create mode 100644 web/sass-files/sass/partials/_activity-log.scss diff --git a/web/react/components/access_history_modal.jsx b/web/react/components/access_history_modal.jsx new file mode 100644 index 0000000000..b23b3213f7 --- /dev/null +++ b/web/react/components/access_history_modal.jsx @@ -0,0 +1,100 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var UserStore = require('../stores/user_store.jsx'); +var AsyncClient = require('../utils/async_client.jsx'); +var Utils = require('../utils/utils.jsx'); + +function getStateFromStoresForAudits() { + return { + audits: UserStore.getAudits() + }; +} + +module.exports = React.createClass({ + componentDidMount: function() { + UserStore.addAuditsChangeListener(this._onChange); + AsyncClient.getAudits(); + + var self = this; + $(this.refs.modal.getDOMNode()).on('hidden.bs.modal', function(e) { + self.setState({ moreInfo: [] }); + }); + }, + componentWillUnmount: function() { + UserStore.removeAuditsChangeListener(this._onChange); + }, + _onChange: function() { + this.setState(getStateFromStoresForAudits()); + }, + handleMoreInfo: function(index) { + var newMoreInfo = this.state.moreInfo; + newMoreInfo[index] = true; + this.setState({ moreInfo: newMoreInfo }); + }, + getInitialState: function() { + var initialState = getStateFromStoresForAudits(); + initialState.moreInfo = []; + return initialState; + }, + render: function() { + var accessList = []; + var currentHistoryDate = null; + + for (var i = 0; i < this.state.audits.length; i++) { + var currentAudit = this.state.audits[i]; + var newHistoryDate = new Date(currentAudit.create_at); + var newDate = null; + + if (!currentHistoryDate || currentHistoryDate.toLocaleDateString() !== newHistoryDate.toLocaleDateString()) { + currentHistoryDate = newHistoryDate; + newDate = (
{currentHistoryDate.toDateString()}
); + } + + accessList[i] = ( +
+
{newDate}
+
+
{newHistoryDate.toLocaleTimeString(navigator.language, {hour: '2-digit', minute:'2-digit'})}
+
+
{"IP: " + currentAudit.ip_address}
+ { this.state.moreInfo[i] ? +
+
{"Session ID: " + currentAudit.session_id}
+
{"URL: " + currentAudit.action.replace("/api/v1", "")}
+
+ : + More info + } +
+ {i < this.state.audits.length - 1 ? +
+ : + null + } +
+
+ ); + } + + return ( +
+ +
+ ); + } +}); diff --git a/web/react/components/activity_log_modal.jsx b/web/react/components/activity_log_modal.jsx new file mode 100644 index 0000000000..d6f8f40ebc --- /dev/null +++ b/web/react/components/activity_log_modal.jsx @@ -0,0 +1,116 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var UserStore = require('../stores/user_store.jsx'); +var Client = require('../utils/client.jsx'); +var AsyncClient = require('../utils/async_client.jsx'); + +function getStateFromStoresForSessions() { + return { + sessions: UserStore.getSessions(), + server_error: null, + client_error: null + }; +} + +module.exports = React.createClass({ + submitRevoke: function(altId) { + var self = this; + Client.revokeSession(altId, + function(data) { + AsyncClient.getSessions(); + }.bind(this), + function(err) { + state = getStateFromStoresForSessions(); + state.server_error = err; + this.setState(state); + }.bind(this) + ); + }, + componentDidMount: function() { + UserStore.addSessionsChangeListener(this._onChange); + AsyncClient.getSessions(); + + var self = this; + $(this.refs.modal.getDOMNode()).on('hidden.bs.modal', function(e) { + self.setState({ moreInfo: [] }); + }); + }, + componentWillUnmount: function() { + UserStore.removeSessionsChangeListener(this._onChange); + }, + _onChange: function() { + this.setState(getStateFromStoresForSessions()); + }, + handleMoreInfo: function(index) { + var newMoreInfo = this.state.moreInfo; + newMoreInfo[index] = true; + this.setState({ moreInfo: newMoreInfo }); + }, + getInitialState: function() { + var initialState = getStateFromStoresForSessions(); + initialState.moreInfo = []; + return initialState; + }, + render: function() { + var activityList = []; + var server_error = this.state.server_error ? this.state.server_error : null; + + for (var i = 0; i < this.state.sessions.length; i++) { + var currentSession = this.state.sessions[i]; + var lastAccessTime = new Date(currentSession.last_activity_at); + var firstAccessTime = new Date(currentSession.create_at); + var devicePicture = ""; + + if (currentSession.props.platform === "Windows") { + devicePicture = "fa fa-windows"; + } + else if (currentSession.props.platform === "Macintosh" || currentSession.props.platform === "iPhone") { + devicePicture = "fa fa-apple"; + } + + activityList[i] = ( +
+
+
{currentSession.props.platform}
+
+
{"Last activity: " + lastAccessTime.toDateString() + ", " + lastAccessTime.toLocaleTimeString()}
+ { this.state.moreInfo[i] ? +
+
{"First time active: " + firstAccessTime.toDateString() + ", " + lastAccessTime.toLocaleTimeString()}
+
{"OS: " + currentSession.props.os}
+
{"Browser: " + currentSession.props.browser}
+
{"Session ID: " + currentSession.alt_id}
+
+ : + More info + } +
+
+
+
+ ); + } + + return ( +
+ +
+ ); + } +}); diff --git a/web/react/components/user_settings.jsx b/web/react/components/user_settings.jsx index 59c97c3096..ad890334ea 100644 --- a/web/react/components/user_settings.jsx +++ b/web/react/components/user_settings.jsx @@ -5,6 +5,8 @@ var UserStore = require('../stores/user_store.jsx'); var SettingItemMin = require('./setting_item_min.jsx'); var SettingItemMax = require('./setting_item_max.jsx'); var SettingPicture = require('./setting_picture.jsx'); +var AccessHistoryModal = require('./access_history_modal.jsx'); +var ActivityLogModal = require('./activity_log_modal.jsx'); var client = require('../utils/client.jsx'); var AsyncClient = require('../utils/async_client.jsx'); var utils = require('../utils/utils.jsx'); @@ -443,149 +445,6 @@ var NotificationsTab = React.createClass({ } }); -function getStateFromStoresForSessions() { - return { - sessions: UserStore.getSessions(), - server_error: null, - client_error: null - }; -} - -var SessionsTab = React.createClass({ - submitRevoke: function(altId) { - client.revokeSession(altId, - function(data) { - AsyncClient.getSessions(); - }.bind(this), - function(err) { - state = this.getStateFromStoresForSessions(); - state.server_error = err; - this.setState(state); - }.bind(this) - ); - }, - componentDidMount: function() { - UserStore.addSessionsChangeListener(this._onChange); - AsyncClient.getSessions(); - }, - componentWillUnmount: function() { - UserStore.removeSessionsChangeListener(this._onChange); - }, - _onChange: function() { - this.setState(getStateFromStoresForSessions()); - }, - getInitialState: function() { - return getStateFromStoresForSessions(); - }, - render: function() { - var server_error = this.state.server_error ? this.state.server_error : null; - - return ( -
-
- -

Sessions

-
-
-

Sessions

-
- { server_error } -
- - - - - - { - this.state.sessions.map(function(value, index) { - return ( - - - - - - - - - - ); - }, this) - } - -
IdPlatformOSBrowserCreatedLast ActivityRevoke
{ value.alt_id }{value.props.platform}{value.props.os}{value.props.browser}{ new Date(value.create_at).toLocaleString() }{ new Date(value.last_activity_at).toLocaleString() }
-
-
-
-
- ); - } -}); - -function getStateFromStoresForAudits() { - return { - audits: UserStore.getAudits() - }; -} - -var AuditTab = React.createClass({ - componentDidMount: function() { - UserStore.addAuditsChangeListener(this._onChange); - AsyncClient.getAudits(); - }, - componentWillUnmount: function() { - UserStore.removeAuditsChangeListener(this._onChange); - }, - _onChange: function() { - this.setState(getStateFromStoresForAudits()); - }, - getInitialState: function() { - return getStateFromStoresForAudits(); - }, - render: function() { - return ( -
-
- -

Activity Log

-
-
-

Activity Log

-
-
- - - - - - - - - - - - { - this.state.audits.map(function(value, index) { - return ( - - - - - - - - ); - }, this) - } - -
TimeActionIP AddressSessionOther Info
{ new Date(value.create_at).toLocaleString() }{ value.action.replace("/api/v1", "") }{ value.ip_address }{ value.session_id }{ value.extra_info }
-
-
-
-
- ); - } -}); - var SecurityTab = React.createClass({ submitPassword: function(e) { e.preventDefault(); @@ -637,6 +496,12 @@ var SecurityTab = React.createClass({ updateConfirmPassword: function(e) { this.setState({ confirm_password: e.target.value }); }, + handleHistoryOpen: function() { + $("#user_settings1").modal('hide'); + }, + handleDevicesOpen: function() { + $("#user_settings1").modal('hide'); + }, getInitialState: function() { return { current_password: '', new_password: '', confirm_password: '' }; }, @@ -711,6 +576,10 @@ var SecurityTab = React.createClass({ ); @@ -1225,23 +1094,6 @@ module.exports = React.createClass({
); - - /* Temporarily removing sessions and activity_log tabs - - } else if (this.props.activeTab === 'sessions') { - return ( -
- -
- ); - } else if (this.props.activeTab === 'activity_log') { - return ( -
- -
- ); - */ - } else if (this.props.activeTab === 'appearance') { return (
diff --git a/web/react/components/user_settings_modal.jsx b/web/react/components/user_settings_modal.jsx index 1761e575a5..4210272440 100644 --- a/web/react/components/user_settings_modal.jsx +++ b/web/react/components/user_settings_modal.jsx @@ -30,8 +30,6 @@ module.exports = React.createClass({ tabs.push({name: "security", ui_name: "Security", icon: "glyphicon glyphicon-lock"}); tabs.push({name: "notifications", ui_name: "Notifications", icon: "glyphicon glyphicon-exclamation-sign"}); tabs.push({name: "appearance", ui_name: "Appearance", icon: "glyphicon glyphicon-wrench"}); - //tabs.push({name: "sessions", ui_name: "Sessions", icon: "glyphicon glyphicon-globe"}); - //tabs.push({name: "activity_log", ui_name: "Activity Log", icon: "glyphicon glyphicon-time"}); return (