James Moger
2013-05-15 366bec6ae90ef4adadb5df0e2e9232ba7b954f8e
Allow client apps to specify a minimum required access permission
6 files modified
341 ■■■■■ changed files
src/main/distrib/data/clientapps.json 24 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/GitBlit.java 3 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/SparkleShareInviteServlet.java 151 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/models/GitClientApplication.java 2 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/panels/RepositoryUrlPanel.html 52 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/panels/RepositoryUrlPanel.java 109 ●●●● patch | view | raw | blame | history
src/main/distrib/data/clientapps.json
@@ -4,7 +4,7 @@
        "title": "Git",
        "description": "a fast, open-source, distributed VCS",
        "legal": "released under the GPLv2 open source license",
        "command": "git clone {0}",
        "command": "git clone ${repoUrl}",
        "productUrl": "http://git-scm.com",
        "icon": "git-black_32x32.png",
        "isActive": true
@@ -14,7 +14,7 @@
        "title": "syntevo SmartGit/Hg\u2122",
        "description": "a Git client for Windows, Mac, & Linux",
        "legal": "\u00a9 2013 syntevo GmbH. All rights reserved.",
        "cloneUrl": "smartgit://cloneRepo/{0}",
        "cloneUrl": "smartgit://cloneRepo/${repoUrl}",
        "productUrl": "http://www.syntevo.com/smartgithg",
        "platforms": [ "windows", "macintosh", "linux" ],
        "icon": "smartgithg_32x32.png",
@@ -25,7 +25,7 @@
        "title": "Atlassian SourceTree\u2122",
        "description": "a free Git client for Windows or Mac",
        "legal": "\u00a9 2013 Atlassian. All rights reserved.",
        "cloneUrl": "sourcetree://cloneRepo/{0}",
        "cloneUrl": "sourcetree://cloneRepo/${repoUrl}",
        "productUrl": "http://sourcetreeapp.com",
        "platforms": [ "windows", "macintosh" ],
        "icon": "sourcetree_32x32.png",
@@ -36,7 +36,7 @@
        "title": "fournova Tower\u2122",
        "description": "a Git client for Mac",
        "legal": "\u00a9 2013 fournova Software GmbH. All rights reserved.",
        "cloneUrl": "gittower://openRepo/{0}",
        "cloneUrl": "gittower://openRepo/${repoUrl}",
        "productUrl": "http://www.git-tower.com",
        "platforms": [ "macintosh" ],
        "icon": "tower_32x32.png",
@@ -47,7 +47,7 @@
        "title": "GitHub\u2122 for Macintosh",
        "description": "a free Git client for Mac OS X",
        "legal": "\u00a9 2013 GitHub. All rights reserved.",
        "cloneUrl": "github-mac://openRepo/{0}",
        "cloneUrl": "github-mac://openRepo/${repoUrl}",
        "productUrl": "http://mac.github.com",
        "platforms": [ "macintosh" ],
        "isActive": false
@@ -57,9 +57,21 @@
        "title": "GitHub\u2122 for Windows",
        "description": "a free Git client for Windows",
        "legal": "\u00a9 2013 GitHub. All rights reserved.",
        "cloneUrl": "github-windows://openRepo/{0}",
        "cloneUrl": "github-windows://openRepo/${repoUrl}",
        "productUrl": "http://windows.github.com",
        "platforms": [ "windows" ],
        "isActive": false
    },
    {
        "name": "SparkleShare",
        "title": "SparkleShare\u2122",
        "description": "an open source collaboration and sharing tool",
        "legal": "released under the GPLv3 open source license",
        "cloneUrl": "sparkleshare://inviteRepo/${baseUrl}/sparkleshare/${repoUrl}.xml",
        "productUrl": "http://sparkleshare.org",
        "platforms": [ "windows", "macintosh", "linux" ],
        "icon": "sparkleshare_32x32.png",
        "minimumPermission" : "RW+",
        "isActive": false
    }
]
src/main/java/com/gitblit/GitBlit.java
@@ -128,7 +128,6 @@
import com.gitblit.wicket.GitBlitWebSession;
import com.gitblit.wicket.WicketUtils;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonIOException;
import com.google.gson.JsonSyntaxException;
import com.google.gson.reflect.TypeToken;
@@ -615,7 +614,7 @@
            Type type = new TypeToken<Collection<GitClientApplication>>() {
            }.getType();
            InputStreamReader reader = new InputStreamReader(is);
            Gson gson = new GsonBuilder().create();
            Gson gson = JsonUtils.gson();
            Collection<GitClientApplication> links = gson.fromJson(reader, type);
            return links;
        } catch (JsonIOException e) {
src/main/java/com/gitblit/SparkleShareInviteServlet.java
@@ -17,14 +17,12 @@
import java.io.IOException;
import java.text.MessageFormat;
import java.util.List;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.gitblit.Constants.AccessRestrictionType;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.UserModel;
import com.gitblit.utils.StringUtils;
@@ -41,27 +39,6 @@
    public SparkleShareInviteServlet() {
        super();
    }
    /**
     * Returns an Sparkleshare invite url to this servlet for the repository.
     * https://github.com/hbons/SparkleShare/wiki/Invites
     *
     * @param baseURL
     * @param repository
     * @param username
     * @return an url
     */
    public static String asLink(String baseURL, String repository, String username) {
        if (baseURL.length() > 0 && baseURL.charAt(baseURL.length() - 1) == '/') {
            baseURL = baseURL.substring(0, baseURL.length() - 1);
        }
        String url = baseURL + Constants.SPARKLESHARE_INVITE_PATH
                + ((StringUtils.isEmpty(username) ? "" : (username + "@")))
                + repository + ".xml";
        url = url.replace("https://", "sparkleshare://");
        url = url.replace("http://", "sparkleshare-unsafe://");
        return url;
    }
    
    @Override
@@ -81,22 +58,22 @@
            java.io.IOException {        
        
        // extract repo name from request
        String path = request.getPathInfo();
        if (path != null && path.length() > 1) {
            if (path.charAt(0) == '/') {
                path = path.substring(1);
            }
        }
        String repoUrl = request.getPathInfo().substring(1);
        // trim trailing .xml
        if (path.endsWith(".xml")) {
            path = path.substring(0, path.length() - 4);
        if (repoUrl.endsWith(".xml")) {
            repoUrl = repoUrl.substring(0, repoUrl.length() - 4);
        }
        
        String servletPath =  Constants.GIT_PATH;
        int schemeIndex = repoUrl.indexOf("://") + 3;
        String host = repoUrl.substring(0, repoUrl.indexOf('/', schemeIndex));
        String path = repoUrl.substring(repoUrl.indexOf(servletPath) + servletPath.length());
        String username = null;
        int fetch = path.indexOf('@');
        if (fetch > -1) {
            username = path.substring(0, fetch);
            path = path.substring(fetch + 1);
        int fetchIndex = repoUrl.indexOf('@');
        if (fetchIndex > -1) {
            username = repoUrl.substring(schemeIndex, fetchIndex);
        }
        UserModel user;
        if (StringUtils.isEmpty(username)) {
@@ -109,102 +86,28 @@
            username = "";
        }
        
        // ensure that the requested repository exists and is sparkleshared
        // ensure that the requested repository exists
        RepositoryModel model = GitBlit.self().getRepositoryModel(path);
        if (model == null) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            response.getWriter().append(MessageFormat.format("Repository \"{0}\" not found!", path));
            return;
        } else if (!model.isSparkleshared()) {
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.getWriter().append(MessageFormat.format("Repository \"{0}\" is not sparkleshared!", path));
            return;
        }
        
        if (GitBlit.getBoolean(Keys.git.enableGitServlet, true)
                || GitBlit.getInteger(Keys.git.daemonPort, 0) > 0) {
            // Gitblit as server
            // determine username for repository url
            if (model.accessRestriction.exceeds(AccessRestrictionType.NONE)) {
                if (!user.canRewindRef(model)) {
                    response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                    response.getWriter().append(MessageFormat.format("\"{0}\" does not have RW+ permissions for {1}!", user.username, path));
                    return;
                }
            }
            if (model.accessRestriction.exceeds(AccessRestrictionType.NONE)) {
                username = user.username + "@";
            } else {
                username = "";
            }
            String serverPort = "";
            if (request.getScheme().equals("https")) {
                if (request.getServerPort() != 443) {
                    serverPort = ":" + request.getServerPort();
                }
            } else if (request.getScheme().equals("http")) {
                if (request.getServerPort() != 80) {
                    serverPort = ":" + request.getServerPort();
                }
            }
            // assume http/https serving
            String scheme = request.getScheme();
            String servletPath = Constants.GIT_PATH;
            // try to switch to git://, if git servlet disabled and repo has no restrictions
            if (!GitBlit.getBoolean(Keys.git.enableGitServlet, true)
                    && (GitBlit.getInteger(Keys.git.daemonPort, 0) > 0)
                    && AccessRestrictionType.NONE == model.accessRestriction) {
                scheme = "git";
                servletPath = "/";
                serverPort = GitBlit.getString(Keys.git.daemonPort, "");
            }
            // construct Sparkleshare invite
            StringBuilder sb = new StringBuilder();
            sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
            sb.append("<sparkleshare><invite>\n");
            sb.append(MessageFormat.format("<address>{0}://{1}{2}{3}{4}</address>\n", scheme, username, request.getServerName(), serverPort, request.getContextPath()));
            sb.append(MessageFormat.format("<remote_path>{0}{1}</remote_path>\n", servletPath, model.name));
            if (GitBlit.getInteger(Keys.fanout.port, 0) > 0) {
                // Gitblit is running it's own fanout service for pubsub notifications
                sb.append(MessageFormat.format("<announcements_url>tcp://{0}:{1}</announcements_url>\n", request.getServerName(), GitBlit.getString(Keys.fanout.port, "")));
            }
            sb.append("</invite></sparkleshare>\n");
            // write invite to client
            response.setContentType("application/xml");
            response.setContentLength(sb.length());
            response.getWriter().append(sb.toString());
        } else {
            // Gitblit as viewer, repository access handled externally so
            // assume RW+ permission
            List<String> others = GitBlit.getStrings(Keys.web.otherUrls);
            if (others.size() == 0) {
                return;
            }
            String address = MessageFormat.format(others.get(0), "", username);
            StringBuilder sb = new StringBuilder();
            sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
            sb.append("<sparkleshare><invite>\n");
            sb.append(MessageFormat.format("<address>{0}</address>\n", address));
            sb.append(MessageFormat.format("<remote_path>{0}</remote_path>\n", model.name));
            if (GitBlit.getInteger(Keys.fanout.port, 0) > 0) {
                // Gitblit is running it's own fanout service for pubsub notifications
                sb.append(MessageFormat.format("<announcements_url>tcp://{0}:{1}</announcements_url>\n", request.getServerName(), GitBlit.getString(Keys.fanout.port, "")));
            }
            sb.append("</invite></sparkleshare>\n");
            // write invite to client
            response.setContentType("application/xml");
            response.setContentLength(sb.length());
            response.getWriter().append(sb.toString());
        StringBuilder sb = new StringBuilder();
        sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
        sb.append("<sparkleshare><invite>\n");
        sb.append(MessageFormat.format("<address>{0}</address>\n", host));
        sb.append(MessageFormat.format("<remote_path>{0}{1}</remote_path>\n", servletPath, model.name));
        if (GitBlit.getInteger(Keys.fanout.port, 0) > 0) {
            // Gitblit is running it's own fanout service for pubsub notifications
            sb.append(MessageFormat.format("<announcements_url>tcp://{0}:{1}</announcements_url>\n", request.getServerName(), GitBlit.getString(Keys.fanout.port, "")));
        }
        sb.append("</invite></sparkleshare>\n");
        // write invite to client
        response.setContentType("application/xml");
        response.setContentLength(sb.length());
        response.getWriter().append(sb.toString());
    }
}
src/main/java/com/gitblit/models/GitClientApplication.java
@@ -17,6 +17,7 @@
import java.io.Serializable;
import com.gitblit.Constants.AccessPermission;
import com.gitblit.utils.ArrayUtils;
import com.gitblit.utils.StringUtils;
@@ -39,6 +40,7 @@
    public String command;
    public String productUrl;
    public String[] platforms;
    public AccessPermission minimumPermission;
    public boolean isActive;
    public boolean allowsPlatform(String p) {
src/main/java/com/gitblit/wicket/panels/RepositoryUrlPanel.html
@@ -15,10 +15,10 @@
            <div class="btn-group repositoryUrlContainer">
                <img style="vertical-align: middle;padding: 0px 0px 1px 3px;" wicket:id="accessRestrictionIcon"></img>
                <span wicket:id="menu"></span>
                   <span class="repositoryUrl">
                   <div class="repositoryUrl">
                       <span wicket:id="primaryUrl">[repository primary url]</span>
                       <span class="hidden-phone hidden-tablet" wicket:id="copyFunction"></span>
                   </span>
                   </div>
                   <span class="hidden-phone hidden-tablet repositoryUrlRightCap" wicket:id="primaryUrlPermission">[repository primary url permission]</span>
               </div>
        </div>
@@ -27,32 +27,36 @@
    <wicket:fragment wicket:id="applicationMenusFragment">
        <div class="btn-toolbar" style="margin: 4px 0px 0px 0px;">
            <div class="btn-group" wicket:id="appMenus">
                   <a class="btn btn-mini btn-appmenu" data-toggle="dropdown" href="#">
                    <span wicket:id="applicationName"></span>
                    <span class="caret"></span>
                   </a>
                   <ul class="dropdown-menu applicationMenu">
                       <li>
                           <div class="applicationHeaderMenuItem">
                               <div style="float:right">
                                   <img style="padding-right: 5px;vertical-align: middle;" wicket:id="applicationIcon"></img>
                               </div>
                               <span class="applicationTitle" wicket:id="applicationTitle"></span>
                           </div>
                       </li>
                       <li><div class="applicationHeaderMenuItem"><span wicket:id="applicationDescription"></span></div></li>
                       <li><div class="applicationLegalMenuItem"><span wicket:id="applicationLegal"></span></div></li>
                       <li class="divider" style="margin: 5px 1px 0px 1px;clear:both;" ></li>
                       <li class="action" wicket:id="actionItems">
                           <span wicket:id="actionItem"></span>
                       </li>
                   </ul>
                <span wicket:id="appMenu"></span>
               </div>
        </div>
    </wicket:fragment>
    
    <wicket:fragment wicket:id="appMenuFragment">
        <a class="btn btn-mini btn-appmenu" data-toggle="dropdown" href="#">
            <span wicket:id="applicationName"></span>
            <span class="caret"></span>
           </a>
           <ul class="dropdown-menu applicationMenu">
               <li>
                   <div class="applicationHeaderMenuItem">
                       <div style="float:right">
                           <img style="padding-right: 5px;vertical-align: middle;" wicket:id="applicationIcon"></img>
                       </div>
                       <span class="applicationTitle" wicket:id="applicationTitle"></span>
                   </div>
               </li>
               <li><div class="applicationHeaderMenuItem"><span wicket:id="applicationDescription"></span></div></li>
               <li><div class="applicationLegalMenuItem"><span wicket:id="applicationLegal"></span></div></li>
               <li class="divider" style="margin: 5px 1px 0px 1px;clear:both;" ></li>
               <li class="action" wicket:id="actionItems">
                   <span wicket:id="actionItem"></span>
               </li>
           </ul>
    </wicket:fragment>
    <wicket:fragment wicket:id="urlProtocolMenuFragment">
        <a class="" data-toggle="dropdown" href="#">                   
            <span class="repositoryUrlLeftCap" wicket:id="menuText">URLs</span>
src/main/java/com/gitblit/wicket/panels/RepositoryUrlPanel.java
@@ -38,12 +38,12 @@
import com.gitblit.Constants.AccessRestrictionType;
import com.gitblit.GitBlit;
import com.gitblit.Keys;
import com.gitblit.SparkleShareInviteServlet;
import com.gitblit.models.GitClientApplication;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.RepositoryUrl;
import com.gitblit.models.UserModel;
import com.gitblit.utils.StringUtils;
import com.gitblit.wicket.ExternalImage;
import com.gitblit.wicket.GitBlitWebSession;
import com.gitblit.wicket.WicketUtils;
@@ -200,7 +200,7 @@
        return urlPanel;
    }
    
    protected Fragment createApplicationMenus(String wicketId, UserModel user, final RepositoryModel repository, List<RepositoryUrl> repositoryUrls) {
    protected Fragment createApplicationMenus(String wicketId, UserModel user, final RepositoryModel repository, final List<RepositoryUrl> repositoryUrls) {
        final List<GitClientApplication> displayedApps = new ArrayList<GitClientApplication>();
        final String userAgent = ((WebClientInfo) GitBlitWebSession.get().getClientInfo()).getUserAgent();
        
@@ -210,14 +210,9 @@
                    displayedApps.add(app);
                }
            }
            GitClientApplication sparkleshare = getSparkleShareAppMenu(user, repository);
            if (sparkleshare != null) {
                displayedApps.add(sparkleshare);
            }
        }
        final ListDataProvider<RepositoryUrl> urlsDp = new ListDataProvider<RepositoryUrl>(repositoryUrls);
        final String baseURL = WicketUtils.getGitblitURL(RequestCycle.get().getRequest());
        ListDataProvider<GitClientApplication> displayedAppsDp = new ListDataProvider<GitClientApplication>(displayedApps);
        DataView<GitClientApplication> appMenus = new DataView<GitClientApplication>("appMenus", displayedAppsDp) {
            private static final long serialVersionUID = 1L;
@@ -225,58 +220,92 @@
            public void populateItem(final Item<GitClientApplication> item) {
                final GitClientApplication clientApp = item.getModelObject();
                // filter the urls for the client app
                List<RepositoryUrl> urls;
                if (clientApp.minimumPermission == null) {
                    // client app does not specify minimum access permission
                    urls = repositoryUrls;
                } else {
                    urls = new ArrayList<RepositoryUrl>();
                    for (RepositoryUrl repoUrl : repositoryUrls) {
                        if (repoUrl.permission == null) {
                            // external permissions, assume it is satisfactory
                            urls.add(repoUrl);
                        } else if (repoUrl.permission.atLeast(clientApp.minimumPermission)) {
                            // repo url meets minimum permission requirement
                            urls.add(repoUrl);
                        }
                    }
                }
                if (urls.size() == 0) {
                    // do not show this app menu because there are no urls
                    item.add(new Label("appMenu").setVisible(false));
                    return;
                }
                Fragment appMenu = new Fragment("appMenu", "appMenuFragment", this);
                appMenu.setRenderBodyOnly(true);
                item.add(appMenu);
                // menu button
                item.add(new Label("applicationName", clientApp.name));
                appMenu.add(new Label("applicationName", clientApp.name));
                
                // application icon
                Component img;
                if (StringUtils.isEmpty(clientApp.icon)) {
                    img = WicketUtils.newClearPixel("applicationIcon").setVisible(false);    
                } else {
                    img = WicketUtils.newImage("applicationIcon", clientApp.icon);
                    if (clientApp.icon.contains("://")) {
                        // external image
                        img = new ExternalImage("applicationIcon", clientApp.icon);
                    } else {
                        // context image
                        img = WicketUtils.newImage("applicationIcon", clientApp.icon);
                    }
                }                
                item.add(img);
                appMenu.add(img);
                
                // application menu title, may be a link
                if (StringUtils.isEmpty(clientApp.productUrl)) {
                    item.add(new Label("applicationTitle", clientApp.toString()));
                    appMenu.add(new Label("applicationTitle", clientApp.toString()));
                } else {
                    item.add(new LinkPanel("applicationTitle", null, clientApp.toString(), clientApp.productUrl, true));
                    appMenu.add(new LinkPanel("applicationTitle", null, clientApp.toString(), clientApp.productUrl, true));
                }
                
                // brief application description
                if (StringUtils.isEmpty(clientApp.description)) {
                    item.add(new Label("applicationDescription").setVisible(false));
                    appMenu.add(new Label("applicationDescription").setVisible(false));
                } else {
                    item.add(new Label("applicationDescription", clientApp.description));
                    appMenu.add(new Label("applicationDescription", clientApp.description));
                }
                
                // brief application legal info, copyright, license, etc
                if (StringUtils.isEmpty(clientApp.legal)) {
                    item.add(new Label("applicationLegal").setVisible(false));
                    appMenu.add(new Label("applicationLegal").setVisible(false));
                } else {
                    item.add(new Label("applicationLegal", clientApp.legal));
                    appMenu.add(new Label("applicationLegal", clientApp.legal));
                }
                
                // a nested repeater for all action items
                ListDataProvider<RepositoryUrl> urlsDp = new ListDataProvider<RepositoryUrl>(urls);
                DataView<RepositoryUrl> actionItems = new DataView<RepositoryUrl>("actionItems", urlsDp) {
                    private static final long serialVersionUID = 1L;
                    public void populateItem(final Item<RepositoryUrl> repoLinkItem) {
                        RepositoryUrl repoUrl = repoLinkItem.getModelObject();
                        Fragment fragment = new Fragment("actionItem", "actionFragment", this);
                        fragment.add(createPermissionBadge("permission", repoUrl));
                        if (!StringUtils.isEmpty(clientApp.cloneUrl)) {
                            // custom registered url
                            String url = MessageFormat.format(clientApp.cloneUrl, repoUrl);
                            String url = substitute(clientApp.cloneUrl, repoUrl.url, baseURL);
                            fragment.add(new LinkPanel("content", "applicationMenuItem", getString("gb.clone") + " " + repoUrl.url, url));
                            repoLinkItem.add(fragment);
                            fragment.add(new Label("copyFunction").setVisible(false));
                        } else if (!StringUtils.isEmpty(clientApp.command)) {
                            // command-line
                            String command = MessageFormat.format(clientApp.command, repoUrl);
                            String command = substitute(clientApp.command, repoUrl.url, baseURL);
                            Label content = new Label("content", command);
                            WicketUtils.setCssClass(content, "commandMenuItem");
                            fragment.add(content);
@@ -286,7 +315,7 @@
                            fragment.add(createCopyFragment(command));
                        }
                    }};
                    item.add(actionItems);
                    appMenu.add(actionItems);
            }
        };
        
@@ -295,42 +324,8 @@
        return applicationMenus;
    }
    
    protected GitClientApplication getSparkleShareAppMenu(UserModel user, RepositoryModel repository) {
        String url = null;
        if (repository.isBare && repository.isSparkleshared()) {
            String username = null;
            if (UserModel.ANONYMOUS != user) {
                username = user.username;
            }
            if (isGitblitServingRepositories()) {
                // Gitblit as server
                // ensure user can rewind
                if (user.canRewindRef(repository)) {
                    String baseURL = WicketUtils.getGitblitURL(RequestCycle.get().getRequest());
                    url = SparkleShareInviteServlet.asLink(baseURL, repository.name, username);
                }
            } else {
                // Gitblit as viewer, assume RW+ permission
                String baseURL = WicketUtils.getGitblitURL(RequestCycle.get().getRequest());
                url = SparkleShareInviteServlet.asLink(baseURL, repository.name, username);
            }
        }
        // sparkleshare invite url
        if (!StringUtils.isEmpty(url)) {
            GitClientApplication app = new GitClientApplication();
            app.name = "SparkleShare";
            app.title = "SparkleShare\u2122";
            app.description = "an open source collaboration and sharing tool";
            app.legal = "released under the GPLv3 open source license";
            app.cloneUrl = url;
            app.platforms = new String [] { "windows", "macintosh", "linux" };
            app.productUrl = "http://sparkleshare.org";
            app.icon = "sparkleshare_32x32.png";
            app.isActive = true;
            return app;
        }
        return null;
    protected String substitute(String pattern, String repoUrl, String baseUrl) {
        return pattern.replace("${repoUrl}", repoUrl).replace("${baseUrl}", baseUrl);
    }
    
    protected boolean isGitblitServingRepositories() {