Merge "Mention in design doc solution template that effects on replication should be considered"
diff --git a/Documentation/dev-design-doc-conclusion-template.md b/Documentation/dev-design-doc-conclusion-template.md
index 36bfa2a..0625f2b 100644
--- a/Documentation/dev-design-doc-conclusion-template.md
+++ b/Documentation/dev-design-doc-conclusion-template.md
@@ -1,3 +1,13 @@
+---
+title: "Design Doc - ${title} - Conclusion"
+sidebar: gerritdoc_sidebar
+permalink: design-doc-${folder-name}-conclusion.html
+hide_sidebar: true
+hide_navtoggle: true
+toc: false
+folder: design-docs/${folder-name}
+---
+
# Conclusion
Describe which decision was made and what were the reasons for it.
diff --git a/Documentation/dev-design-doc-index-template.md b/Documentation/dev-design-doc-index-template.md
index 604126d..10b4a81 100644
--- a/Documentation/dev-design-doc-index-template.md
+++ b/Documentation/dev-design-doc-index-template.md
@@ -1,3 +1,13 @@
+---
+title: "Design Doc - ${title}"
+sidebar: gerritdoc_sidebar
+permalink: design-doc-${folder-name}.html
+hide_sidebar: true
+hide_navtoggle: true
+toc: false
+folder: design-docs/${folder-name}
+---
+
# Design Doc - ${title}
* [Use Cases](use-cases.html)
diff --git a/Documentation/dev-design-doc-solution-template.md b/Documentation/dev-design-doc-solution-template.md
index 622c840..8935902 100644
--- a/Documentation/dev-design-doc-solution-template.md
+++ b/Documentation/dev-design-doc-solution-template.md
@@ -1,3 +1,13 @@
+---
+title: "Design Doc - ${title} - Solution - ${solution-name}"
+sidebar: gerritdoc_sidebar
+permalink: design-doc-${folder-name}-solution-${solution-name}.html
+hide_sidebar: true
+hide_navtoggle: true
+toc: false
+folder: design-docs/${folder-name}
+---
+
# Solution - ${solution-name}
## <a id="overview"> Overview
diff --git a/Documentation/dev-design-doc-use-cases-template.md b/Documentation/dev-design-doc-use-cases-template.md
index 704ad14c..02c2fb5 100644
--- a/Documentation/dev-design-doc-use-cases-template.md
+++ b/Documentation/dev-design-doc-use-cases-template.md
@@ -1,3 +1,13 @@
+---
+title: "Design Doc - ${title} - Use Cases"
+sidebar: gerritdoc_sidebar
+permalink: design-doc-${folder-name}-use-cases.html
+hide_sidebar: true
+hide_navtoggle: true
+toc: false
+folder: design-docs/${folder-name}
+---
+
# Use Cases
In a few sentences, describe the use-cases as interactions between a
diff --git a/contrib/Refresh_plugin_in_testsite.sh b/contrib/Refresh_plugin_in_testsite.sh
new file mode 100755
index 0000000..bb42ce8
--- /dev/null
+++ b/contrib/Refresh_plugin_in_testsite.sh
@@ -0,0 +1,63 @@
+#!/bin/bash
+#
+# Copyright (C) 2019 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This script compiles a Gerrit plugin whose name is passed as first parameter
+# and copies it over to the plugin folder of the testsite. The path to the
+# testsite needs to be provided by the variable GERRIT_TESTSITE or as second
+# parameter.
+
+SCRIPT_DIR=$(dirname -- "$(readlink -f -- "$BASH_SOURCE")")
+GERRIT_CODE_DIR="$SCRIPT_DIR/.."
+cd "$GERRIT_CODE_DIR"
+
+if [ "$#" -lt 1 ]
+then
+ echo "No plugin name provided as first argument. Stopping."
+ exit 1
+else
+ PLUGIN_NAME="$1"
+fi
+
+
+if [ "$#" -lt 2 ]
+then
+ if [ -z ${GERRIT_TESTSITE+x} ]
+ then
+ echo "Path to local testsite is neiter set as GERRIT_TESTSITE nor passed as second argument. Stopping."
+ exit 1
+ fi
+else
+ GERRIT_TESTSITE="$2"
+fi
+
+if [ ! -d "$GERRIT_TESTSITE" ]
+then
+ echo "Testsite directory $GERRIT_TESTSITE does not exist. Stopping."
+ exit 1
+fi
+
+bazel build //plugins/"$PLUGIN_NAME"/...
+if [ $? -ne 0 ]
+then
+ echo "Building the $PLUGIN_NAME plugin failed"
+ exit 1
+fi
+
+yes | cp -f "$GERRIT_CODE_DIR/bazel-genfiles/plugins/$PLUGIN_NAME/$PLUGIN_NAME.jar" "$GERRIT_TESTSITE/plugins/"
+if [ $? -eq 0 ]
+then
+ echo "Plugin $PLUGIN_NAME copied successfully to testsite."
+fi
diff --git a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
index e3ab70d..aa38c27 100644
--- a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -31,6 +31,7 @@
import com.google.gerrit.server.account.AuthRequest;
import com.google.gerrit.server.account.AuthResult;
import com.google.gerrit.server.account.AuthenticationFailedException;
+import com.google.gerrit.server.account.externalids.PasswordVerifier;
import com.google.gerrit.server.auth.NoSuchUserException;
import com.google.gerrit.server.config.AuthConfig;
import com.google.inject.Inject;
@@ -140,7 +141,7 @@
GitBasicAuthPolicy gitBasicAuthPolicy = authConfig.getGitBasicAuthPolicy();
if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP
|| gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP_LDAP) {
- if (who.checkPassword(password, username)) {
+ if (PasswordVerifier.checkPassword(who.getExternalIds(), username, password)) {
return succeedAuthentication(who);
}
}
@@ -157,7 +158,7 @@
setUserIdentified(whoAuthResult.getAccountId());
return true;
} catch (NoSuchUserException e) {
- if (who.checkPassword(password, username)) {
+ if (PasswordVerifier.checkPassword(who.getExternalIds(), username, password)) {
return succeedAuthentication(who);
}
logger.atWarning().withCause(e).log(authenticationFailedMsg(username, req));
diff --git a/java/com/google/gerrit/server/account/AccountState.java b/java/com/google/gerrit/server/account/AccountState.java
index 8555166..6eb6ca1 100644
--- a/java/com/google/gerrit/server/account/AccountState.java
+++ b/java/com/google/gerrit/server/account/AccountState.java
@@ -14,13 +14,9 @@
package com.google.gerrit.server.account;
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-
import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
-import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.client.DiffPreferencesInfo;
import com.google.gerrit.extensions.client.EditPreferencesInfo;
@@ -34,7 +30,6 @@
import java.io.IOException;
import java.util.Collection;
import java.util.Optional;
-import org.apache.commons.codec.DecoderException;
import org.eclipse.jgit.lib.ObjectId;
/**
@@ -45,8 +40,6 @@
* account cache (see {@link AccountCache#get(Account.Id)}).
*/
public class AccountState {
- private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
/**
* Creates an AccountState from the given account config.
*
@@ -175,29 +168,6 @@
return userName;
}
- public boolean checkPassword(@Nullable String password, String username) {
- if (password == null) {
- return false;
- }
- for (ExternalId id : getExternalIds()) {
- // Only process the "username:$USER" entry, which is unique.
- if (!id.isScheme(SCHEME_USERNAME) || !username.equals(id.key().id())) {
- continue;
- }
-
- String hashedStr = id.password();
- if (!Strings.isNullOrEmpty(hashedStr)) {
- try {
- return HashedPassword.decode(hashedStr).checkPassword(password);
- } catch (DecoderException e) {
- logger.atSevere().log("DecoderException for user %s: %s ", username, e.getMessage());
- return false;
- }
- }
- }
- return false;
- }
-
/** The external identities that identify the account holder. */
public ImmutableSet<ExternalId> getExternalIds() {
return externalIds;
diff --git a/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java b/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java
new file mode 100644
index 0000000..e4bf27b
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+
+import com.google.common.base.Strings;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.account.HashedPassword;
+import java.util.Collection;
+import org.apache.commons.codec.DecoderException;
+
+/** Checks if a given username and password match a user's external IDs. */
+public class PasswordVerifier {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ /** Returns {@code true} if there is an external ID matching both the username and password. */
+ public static boolean checkPassword(
+ Collection<ExternalId> externalIds, String username, @Nullable String password) {
+ if (password == null) {
+ return false;
+ }
+ for (ExternalId id : externalIds) {
+ // Only process the "username:$USER" entry, which is unique.
+ if (!id.isScheme(SCHEME_USERNAME) || !username.equals(id.key().id())) {
+ continue;
+ }
+
+ String hashedStr = id.password();
+ if (!Strings.isNullOrEmpty(hashedStr)) {
+ try {
+ return HashedPassword.decode(hashedStr).checkPassword(password);
+ } catch (DecoderException e) {
+ logger.atSevere().log("DecoderException for user %s: %s ", username, e.getMessage());
+ return false;
+ }
+ }
+ }
+ return false;
+ }
+}
diff --git a/java/com/google/gerrit/server/auth/InternalAuthBackend.java b/java/com/google/gerrit/server/auth/InternalAuthBackend.java
index c06c66b..2821bf6 100644
--- a/java/com/google/gerrit/server/auth/InternalAuthBackend.java
+++ b/java/com/google/gerrit/server/auth/InternalAuthBackend.java
@@ -16,6 +16,7 @@
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.externalids.PasswordVerifier;
import com.google.gerrit.server.config.AuthConfig;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -62,7 +63,7 @@
+ ": account inactive or not provisioned in Gerrit");
}
- if (!who.checkPassword(req.getPassword().get(), username)) {
+ if (!PasswordVerifier.checkPassword(who.getExternalIds(), username, req.getPassword().get())) {
throw new InvalidCredentialsException();
}
return new AuthUser(AuthUser.UUID.create(username), username);
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index 154fc36..2b6528a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -438,7 +438,7 @@
},
_computeParents(change) {
- if (!change.current_revision ||
+ if (!change || !change.current_revision ||
!change.revisions[change.current_revision] ||
!change.revisions[change.current_revision].commit) {
return undefined;
@@ -447,13 +447,13 @@
},
_computeParentsLabel(parents) {
- return parents.length > 1 ? 'Parents' : 'Parent';
+ return parents && parents.length > 1 ? 'Parents' : 'Parent';
},
_computeParentListClass(parents, parentIsCurrent) {
return [
'parentList',
- parents.length > 1 ? 'merge' : 'nonMerge',
+ parents && parents.length > 1 ? 'merge' : 'nonMerge',
parentIsCurrent ? 'current' : 'notCurrent',
].join(' ');
},
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index 3a5f326..66667ba 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -303,6 +303,7 @@
'_labelsChanged(_change.labels.*)',
'_paramsAndChangeChanged(params, _change)',
'_patchNumChanged(_patchRange.patchNum)',
+ '_loadDynamicTabHeaderAndContent(_change, _selectedRevision)',
],
keyboardShortcuts() {
@@ -337,17 +338,6 @@
this._setDiffViewMode();
});
- Gerrit.awaitPluginsLoaded().then(() => {
- this._dynamicTabHeaderEndpoints =
- Gerrit._endpoints.getDynamicEndpoints('change-view-tab-header');
- this._dynamicTabContentEndpoints =
- Gerrit._endpoints.getDynamicEndpoints('change-view-tab-content');
- if (this._dynamicTabContentEndpoints.length
- !== this._dynamicTabHeaderEndpoints.length) {
- console.warn('Different number of tab headers and tab content.');
- }
- });
-
this.addEventListener('comment-save', this._handleCommentSave.bind(this));
this.addEventListener('comment-refresh', this._reloadDrafts.bind(this));
this.addEventListener('comment-discard',
@@ -770,6 +760,41 @@
});
},
+ /**
+ * We use an observer to observe 'change' and 'selectedRevision'
+ * variables. This fixes an issue under Polymer 2 so that the dynamic
+ * plugins loads when these variables load.
+ */
+ _loadDynamicTabHeaderAndContent(change, selectedRevision) {
+ // These vars are unused, but because primaryTabs extension point
+ // uses it, we makes sure we doin't load the plugin until these vars
+ // exist.
+ if (!change || !selectedRevision) return;
+
+ // We cache the _dynamicTabHeaderEndpoints and _dynamicTabContentEndpoints
+ // var so that we doin't keep loading the same dynamic plugin
+ // over and over when 'change' or 'selectedRevision' change.
+ if (this._dynamicTabHeaderEndpoints || this._dynamicTabContentEndpoints) {
+ return;
+ }
+
+ Gerrit.awaitPluginsLoaded().then(() => {
+ this._dynamicTabHeaderEndpoints =
+ Gerrit._endpoints.getDynamicEndpoints('change-view-tab-header');
+ this._dynamicTabContentEndpoints =
+ Gerrit._endpoints.getDynamicEndpoints('change-view-tab-content');
+ if (this._dynamicTabContentEndpoints.length
+ !== this._dynamicTabHeaderEndpoints.length) {
+ console.warn('Different number of tab headers and tab content.');
+ }
+ }).then(() => {
+ // We need a second then(..) to ensure that the dynamic endpoints
+ // are created before we call _performPostLoadTasks(). This ensures it has
+ // enough time before the primary tab gets selected.
+ this._performPostLoadTasks();
+ });
+ },
+
_paramsAndChangeChanged(value) {
// If the change number or patch range is different, then reset the
// selected file index.
@@ -909,8 +934,8 @@
return 'PARENT';
},
- _computeShowPrimaryTabs(dynamicTabContentEndpoints) {
- return dynamicTabContentEndpoints.length > 0;
+ _computeShowPrimaryTabs(dynamicTabHeaderEndpoints) {
+ return dynamicTabHeaderEndpoints && dynamicTabHeaderEndpoints.length > 0;
},
_computeChangeUrl(change) {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index 5603391..7f0a754 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -159,6 +159,9 @@
],
attached() {
+ // Polymer 2: anchor tag won't work on shadow DOM
+ // we need to manually calling scrollIntoView when hash changed
+ this.listen(window, 'location-change', '_handleLocationChange');
this.fire('title-change', {title: 'Settings'});
this._isDark = !!window.localStorage.getItem('dark-theme');
@@ -215,9 +218,28 @@
this._loadingPromise = Promise.all(promises).then(() => {
this._loading = false;
+
+ // Handle anchor tag for initial load
+ this._handleLocationChange();
});
},
+ detached() {
+ this.unlisten(window, 'location-change', '_handleLocationChange');
+ },
+
+ _handleLocationChange() {
+ // Handle anchor tag after dom attached
+ const urlHash = window.location.hash;
+ if (urlHash) {
+ // Use shadowRoot for Polymer 2
+ const elem = (this.shadowRoot || document).querySelector(urlHash);
+ if (elem) {
+ elem.scrollIntoView();
+ }
+ }
+ },
+
reloadAccountDetail() {
Promise.all([
this.$.accountInfo.loadData(),
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
index 2b043c4..cc6453c 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
@@ -35,7 +35,6 @@
box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
color: var(--view-background-color);
left: 1.25rem;
- padding: 1em 1.5em;
position: fixed;
transform: translateY(5rem);
transition: transform var(--gr-alert-transition-duration, 80ms) ease-out;
@@ -44,6 +43,17 @@
:host([shown]) {
transform: translateY(0);
}
+ /**
+ * NOTE: To avoid style being overwritten by outside of the shadow DOM
+ * (as outside styles always win), .content-wrapper is introduced as a
+ * wrapper around main content to have better encapsulation, styles that
+ * may be affected by outside should be defined on it.
+ * In this case, `padding:0px` is defined in main.css for all elements
+ * with the universal selector: *.
+ */
+ .content-wrapper {
+ padding: 1em 1.5em;
+ }
.text {
color: var(--tooltip-text-color);
display: inline-block;
@@ -62,12 +72,14 @@
}
}
</style>
- <span class="text">[[text]]</span>
- <gr-button
- link
- class="action"
- hidden$="[[_hideActionButton]]"
- on-tap="_handleActionTap">[[actionText]]</gr-button>
+ <div class="content-wrapper">
+ <span class="text">[[text]]</span>
+ <gr-button
+ link
+ class="action"
+ hidden$="[[_hideActionButton]]"
+ on-tap="_handleActionTap">[[actionText]]</gr-button>
+ </div>
</template>
<script src="gr-alert.js"></script>
</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index c3fc3a2..322cf5a 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -1095,6 +1095,8 @@
getLoggedIn() {
return this.getAccount().then(account => {
return account != null;
+ }).catch(() => {
+ return false;
});
},
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index 4c35151..748cb83 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -494,6 +494,15 @@
});
});
+ test('getLoggedIn returns false when network/auth failure', done => {
+ window.fetch.returns(
+ Promise.reject(new Error('Failed to fetch')));
+ element.getLoggedIn().then(isLoggedIn => {
+ assert.isFalse(isLoggedIn);
+ done();
+ });
+ });
+
test('checkCredentials', done => {
const responses = [
{
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index e849469..faf28ad 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -103,7 +103,7 @@
func handleIndex(writer http.ResponseWriter, originalRequest *http.Request) {
fakeRequest := &http.Request{
URL: &url.URL{
- Path: "/",
+ Path: "/",
RawQuery: originalRequest.URL.RawQuery,
},
}
@@ -170,7 +170,7 @@
func patchResponse(req *http.Request, res *http.Response) io.Reader {
switch req.URL.EscapedPath() {
case "/":
- return replaceCdn(res.Body)
+ return rewriteHostPage(res.Body)
case "/config/server/info":
return injectLocalPlugins(res.Body)
default:
@@ -178,13 +178,39 @@
}
}
-func replaceCdn(reader io.Reader) io.Reader {
+func rewriteHostPage(reader io.Reader) io.Reader {
buf := new(bytes.Buffer)
buf.ReadFrom(reader)
original := buf.String()
+ // Simply remove all CDN references, so files are loaded from the local file system or the proxy
+ // server instead.
replaced := cdnPattern.ReplaceAllString(original, "")
+ // Modify window.INITIAL_DATA so that it has the same effect as injectLocalPlugins. To achieve
+ // this let's add JavaScript lines at the end of the <script>...</script> snippet that also
+ // contains window.INITIAL_DATA=...
+ // Here we rely on the fact that the <script> snippet that we want to append to is the first one.
+ if len(*plugins) > 0 {
+ insertionPoint := strings.Index(replaced, "</script>")
+ builder := new(strings.Builder)
+ builder.WriteString(
+ "window.INITIAL_DATA['/config/server/info'].plugin.html_resource_paths = []; ")
+ builder.WriteString(
+ "window.INITIAL_DATA['/config/server/info'].plugin.js_resource_paths = []; ")
+ for _, p := range strings.Split(*plugins, ",") {
+ if filepath.Ext(p) == ".html" {
+ builder.WriteString(
+ "window.INITIAL_DATA['/config/server/info'].plugin.html_resource_paths.push('" + p + "'); ")
+ }
+ if filepath.Ext(p) == ".js" {
+ builder.WriteString(
+ "window.INITIAL_DATA['/config/server/info'].plugin.js_resource_paths.push('" + p + "'); ")
+ }
+ }
+ replaced = replaced[:insertionPoint] + builder.String() + replaced[insertionPoint:]
+ }
+
return strings.NewReader(replaced)
}
@@ -209,11 +235,11 @@
jsResources := getJsonPropByPath(response, jsPluginsPath).([]interface{})
for _, p := range strings.Split(*plugins, ",") {
- if strings.HasSuffix(p, ".html") {
+ if filepath.Ext(p) == ".html" {
htmlResources = append(htmlResources, p)
}
- if strings.HasSuffix(p, ".js") {
+ if filepath.Ext(p) == ".js" {
jsResources = append(jsResources, p)
}
}