From dfb88962fdbd29f59abe92178bb042738d57c3e1 Mon Sep 17 00:00:00 2001 From: James Moger Date: Wed, 11 May 2011 22:07:18 -0400 Subject: [PATCH] Add user. Implemented repository view and git access restrictions. --- gitblit.properties | 14 +- src/com/gitblit/Constants.java | 37 +++- src/com/gitblit/GitBlit.java | 45 ++++- src/com/gitblit/GitBlitServer.java | 57 +++--- src/com/gitblit/GitBlitServlet.java | 84 ++++++++ src/com/gitblit/ILoginService.java | 9 +- src/com/gitblit/JettyLoginService.java | 180 +++++++++++++++++- .../gitblit/wicket/AuthorizationStrategy.java | 1 + .../gitblit/wicket/GitBlitWebApp.properties | 14 +- src/com/gitblit/wicket/GitBlitWebSession.java | 2 + src/com/gitblit/wicket/LoginPage.java | 1 + src/com/gitblit/wicket/RepositoryPage.java | 10 +- src/com/gitblit/wicket/User.java | 54 ------ src/com/gitblit/wicket/WicketUtils.java | 4 + .../wicket/models/RepositoryModel.java | 11 +- src/com/gitblit/wicket/models/User.java | 89 +++++++++ .../wicket/pages/EditRepositoryPage.html | 16 +- .../wicket/pages/EditRepositoryPage.java | 9 +- .../gitblit/wicket/pages/EditUserPage.html | 29 +++ .../gitblit/wicket/pages/EditUserPage.java | 97 ++++++++++ .../wicket/pages/RepositoriesPage.html | 2 +- .../wicket/pages/RepositoriesPage.java | 42 ++-- src/com/gitblit/wicket/resources/gitblit.css | 28 ++- .../wicket/resources/lock_go_16x16.png | Bin 0 -> 777 bytes .../wicket/resources/lock_pull_16x16.png | Bin 0 -> 850 bytes .../gitblit/wicket/resources/shield_16x16.png | Bin 0 -> 704 bytes users.properties | 5 +- 27 files changed, 679 insertions(+), 161 deletions(-) create mode 100644 src/com/gitblit/GitBlitServlet.java delete mode 100644 src/com/gitblit/wicket/User.java create mode 100644 src/com/gitblit/wicket/models/User.java create mode 100644 src/com/gitblit/wicket/pages/EditUserPage.html create mode 100644 src/com/gitblit/wicket/pages/EditUserPage.java create mode 100644 src/com/gitblit/wicket/resources/lock_go_16x16.png create mode 100644 src/com/gitblit/wicket/resources/lock_pull_16x16.png create mode 100644 src/com/gitblit/wicket/resources/shield_16x16.png diff --git a/gitblit.properties b/gitblit.properties index c51fffd5..2bdcf2c8 100644 --- a/gitblit.properties +++ b/gitblit.properties @@ -3,7 +3,7 @@ # # Allow push/pull over http/https with JGit servlet -git.allowPushPull = true +git.enableGitServlet = true # Base folder for repositories # Use forward slashes even on Windows!! @@ -24,9 +24,6 @@ git.cloneUrl = https://localhost/git/ # Authentication Settings # -# Require authentication for http/https push/pull access of git repositories -git.authenticate = true - # Require authentication to see everything but the admin pages web.authenticateViewPages = false @@ -131,11 +128,11 @@ server.log4jPattern.linux = # Jetty Settings # -# use NIO connectors. If false, socket connectors will be used. +# Use Jetty NIO connectors. If false, Jetty Socket connectors will be used. server.useNio = true # Standard http port to serve. <= 0 disables this connector. -server.httpPort = 80 +server.httpPort = 0 # Secure/SSL https port to serve. <= 0 disables this connector. server.httpsPort = 443 @@ -148,7 +145,10 @@ server.httpBindInterface = localhost # You may specify an ip or an empty value to bind to all interfaces. server.httpsBindInterface = localhost -# Password for SSL keystore (keystore password and certificate password must match) +# Password for SSL keystore. +# Keystore password and certificate password must match. +# This is provided for convenience, its probably more secure to set this value +# using the --storePassword command line parameter. server.storePassword = dosomegit # Port for shutdown monitor to listen on. diff --git a/src/com/gitblit/Constants.java b/src/com/gitblit/Constants.java index 38f2e7db..3ca917dc 100644 --- a/src/com/gitblit/Constants.java +++ b/src/com/gitblit/Constants.java @@ -6,14 +6,41 @@ public class Constants { public final static String VERSION = "0.1.0-SNAPSHOT"; - public final static String ADMIN_ROLE = "admin"; - - public final static String PULL_ROLE = "pull"; - - public final static String PUSH_ROLE = "push"; + public final static String ADMIN_ROLE = "#admin"; public final static String PROPERTIES_FILE = "gitblit.properties"; + public static enum AccessRestrictionType { + NONE, PUSH, CLONE, VIEW; + + public static AccessRestrictionType fromString(String name) { + for (AccessRestrictionType type : values()) { + if (type.toString().equalsIgnoreCase(name)) { + return type; + } + } + return NONE; + } + + public boolean atLeast(AccessRestrictionType type) { + return this.ordinal() >= type.ordinal(); + } + + public String toString() { + switch (this) { + case NONE: + return "none"; + case PUSH: + return "push"; + case CLONE: + return "clone"; + case VIEW: + return "view"; + } + return "none"; + } + } + public static String getGitBlitVersion() { return NAME + " v" + VERSION; } diff --git a/src/com/gitblit/GitBlit.java b/src/com/gitblit/GitBlit.java index c633f6e9..bdfa590e 100644 --- a/src/com/gitblit/GitBlit.java +++ b/src/com/gitblit/GitBlit.java @@ -19,9 +19,10 @@ import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.gitblit.Constants.AccessRestrictionType; import com.gitblit.utils.JGitUtils; -import com.gitblit.wicket.User; import com.gitblit.wicket.models.RepositoryModel; +import com.gitblit.wicket.models.User; public class GitBlit implements ServletContextListener { @@ -94,7 +95,18 @@ public class GitBlit implements ServletContextListener { userCookie.setPath("/"); response.addCookie(userCookie); } - + + public User getUser(String username) { + User user = loginService.getUserModel(username); + return user; + } + + public void editUserModel(User user, boolean isCreate) throws GitBlitException { + if (!loginService.updateUserModel(user)) { + throw new GitBlitException(isCreate ? "Failed to add user!" : "Failed to update user!"); + } + } + public List getRepositoryList() { return JGitUtils.getRepositoryList(repositoriesFolder, exportAll, storedSettings.getBoolean(Keys.git.nestedRepositories, true)); } @@ -112,17 +124,31 @@ public class GitBlit implements ServletContextListener { } return r; } - - public List getRepositoryModels() { + + public List getRepositoryModels(User user) { List list = getRepositoryList(); List repositories = new ArrayList(); for (String repo : list) { - RepositoryModel model = getRepositoryModel(repo); - repositories.add(model); + RepositoryModel model = getRepositoryModel(user, repo); + if (model != null) { + repositories.add(model); + } } return repositories; } + public RepositoryModel getRepositoryModel(User user, String repositoryName) { + RepositoryModel model = getRepositoryModel(repositoryName); + if (model.accessRestriction.atLeast(AccessRestrictionType.VIEW)) { + if (user != null && user.canView(model)) { + return model; + } + return null; + } else { + return model; + } + } + public RepositoryModel getRepositoryModel(String repositoryName) { Repository r = getRepository(repositoryName); RepositoryModel model = new RepositoryModel(); @@ -133,10 +159,9 @@ public class GitBlit implements ServletContextListener { if (config != null) { model.description = config.getString("gitblit", null, "description"); model.owner = config.getString("gitblit", null, "owner"); - model.group = config.getString("gitblit", null, "group"); model.useTickets = config.getBoolean("gitblit", "useTickets", false); model.useDocs = config.getBoolean("gitblit", "useDocs", false); - model.useRestrictedAccess = config.getBoolean("gitblit", "restrictedAccess", false); + model.accessRestriction = AccessRestrictionType.fromString(config.getString("gitblit", null, "accessRestriction")); model.showRemoteBranches = config.getBoolean("gitblit", "showRemoteBranches", false); } r.close(); @@ -149,7 +174,7 @@ public class GitBlit implements ServletContextListener { if (new File(repositoriesFolder, repository.name).exists()) { throw new GitBlitException(MessageFormat.format("Can not create repository {0} because it already exists.", repository.name)); } - // create repository + // create repository logger.info("create repository " + repository.name); r = JGitUtils.createRepository(repositoriesFolder, repository.name, true); } else { @@ -170,7 +195,7 @@ public class GitBlit implements ServletContextListener { config.setString("gitblit", null, "owner", repository.owner); config.setBoolean("gitblit", null, "useTickets", repository.useTickets); config.setBoolean("gitblit", null, "useDocs", repository.useDocs); - config.setBoolean("gitblit", null, "restrictedAccess", repository.useRestrictedAccess); + config.setString("gitblit", null, "accessRestriction", repository.accessRestriction.toString()); config.setBoolean("gitblit", null, "showRemoteBranches", repository.showRemoteBranches); try { config.save(); diff --git a/src/com/gitblit/GitBlitServer.java b/src/com/gitblit/GitBlitServer.java index 0978bc86..f5ed91aa 100644 --- a/src/com/gitblit/GitBlitServer.java +++ b/src/com/gitblit/GitBlitServer.java @@ -56,7 +56,6 @@ import org.eclipse.jetty.util.log.Log; import org.eclipse.jetty.util.log.Logger; import org.eclipse.jetty.util.thread.QueuedThreadPool; import org.eclipse.jetty.webapp.WebAppContext; -import org.eclipse.jgit.http.server.GitServlet; import com.beust.jcommander.JCommander; import com.beust.jcommander.Parameter; @@ -222,44 +221,42 @@ public class GitBlitServer { // Git Servlet ServletHolder gitServlet = null; String gitServletPathSpec = "/git/*"; - if (fileSettings.getBoolean(Keys.git.allowPushPull, true)) { - gitServlet = rootContext.addServlet(GitServlet.class, gitServletPathSpec); + if (fileSettings.getBoolean(Keys.git.enableGitServlet, true)) { + gitServlet = rootContext.addServlet(GitBlitServlet.class, gitServletPathSpec); gitServlet.setInitParameter("base-path", params.repositoriesFolder); - gitServlet.setInitParameter("export-all", params.exportAll ? "1" : "0"); + gitServlet.setInitParameter("export-all", fileSettings.getBoolean(Keys.git.exportAll, true) ? "1" : "0"); } // Login Service LoginService loginService = null; - String realmUsers = params.realmFile; - if (realmUsers != null && new File(realmUsers).exists()) { - logger.info("Setting up login service from " + realmUsers); - JettyLoginService jettyLoginService = new JettyLoginService(realmUsers); - GitBlit.self().setLoginService(jettyLoginService); - loginService = jettyLoginService; + String realmUsers = params.realmFile; + if (!StringUtils.isEmpty(realmUsers)) { + File realmFile = new File(realmUsers); + if (realmFile.exists()) { + logger.info("Setting up login service from " + realmUsers); + JettyLoginService jettyLoginService = new JettyLoginService(realmFile); + GitBlit.self().setLoginService(jettyLoginService); + loginService = jettyLoginService; + } } // Determine what handler to use Handler handler; if (gitServlet != null) { - if (loginService != null && params.authenticatePushPull) { - // Authenticate Pull/Push - String[] roles = new String[] { Constants.PULL_ROLE, Constants.PUSH_ROLE }; - logger.info("Authentication required for git servlet pull/push access"); + if (loginService != null) { + // Authenticate Clone/Push + logger.info("Setting up authenticated git servlet clone/push access"); Constraint constraint = new Constraint(); - constraint.setName("auth"); constraint.setAuthenticate(true); - constraint.setRoles(roles); + constraint.setRoles(new String [] { "*" }); ConstraintMapping mapping = new ConstraintMapping(); mapping.setPathSpec(gitServletPathSpec); mapping.setConstraint(constraint); - ConstraintSecurityHandler security = new ConstraintSecurityHandler(); + ConstraintSecurityHandler security = new ConstraintSecurityHandler(); security.addConstraintMapping(mapping); - for (String role : roles) { - security.addRole(role); - } security.setAuthenticator(new BasicAuthenticator()); security.setLoginService(loginService); security.setStrict(false); @@ -273,7 +270,7 @@ public class GitBlitServer { handler = rootContext; } } else { - logger.info("Git servlet pull/push disabled"); + logger.info("Git servlet clone/push disabled"); handler = rootContext; } @@ -448,37 +445,31 @@ public class GitBlitServer { @Parameter(names = { "--stop" }, description = "Stop Server") public Boolean stop = false; - @Parameter(names = { "--temp" }, description = "Server temp folder") + @Parameter(names = { "--tempFolder" }, description = "Server temp folder") public String temp = fileSettings.getString(Keys.server.tempFolder, "temp"); /* * GIT Servlet Parameters */ - @Parameter(names = { "--repos" }, description = "Git Repositories Folder") + @Parameter(names = { "--repositoriesFolder" }, description = "Git Repositories Folder") public String repositoriesFolder = fileSettings.getString(Keys.git.repositoriesFolder, "repos"); - @Parameter(names = { "--exportAll" }, description = "Export All Found Repositories") - public Boolean exportAll = fileSettings.getBoolean(Keys.git.exportAll, true); - /* * Authentication Parameters */ - @Parameter(names = { "--authenticatePushPull" }, description = "Authenticate Git Push/Pull access") - public Boolean authenticatePushPull = fileSettings.getBoolean(Keys.git.authenticate, true); - - @Parameter(names = { "--realm" }, description = "Users Realm Hash File") + @Parameter(names = { "--realmFile" }, description = "Users Realm Hash File") public String realmFile = fileSettings.getString(Keys.server.realmFile, "users.properties"); /* * JETTY Parameters */ - @Parameter(names = { "--nio" }, description = "Use NIO Connector else use Socket Connector.") + @Parameter(names = { "--useNio" }, description = "Use NIO Connector else use Socket Connector.") public Boolean useNIO = fileSettings.getBoolean(Keys.server.useNio, true); - @Parameter(names = "--port", description = "HTTP port for to serve. (port <= 0 will disable this connector)") + @Parameter(names = "--httpPort", description = "HTTP port for to serve. (port <= 0 will disable this connector)") public Integer port = fileSettings.getInteger(Keys.server.httpPort, 80); - @Parameter(names = "--securePort", description = "HTTPS port to serve. (port <= 0 will disable this connector)") + @Parameter(names = "--httpsPort", description = "HTTPS port to serve. (port <= 0 will disable this connector)") public Integer securePort = fileSettings.getInteger(Keys.server.httpsPort, 443); @Parameter(names = "--storePassword", description = "Password for SSL (https) keystore.") diff --git a/src/com/gitblit/GitBlitServlet.java b/src/com/gitblit/GitBlitServlet.java new file mode 100644 index 00000000..cb23e47e --- /dev/null +++ b/src/com/gitblit/GitBlitServlet.java @@ -0,0 +1,84 @@ +package com.gitblit; + +import java.io.IOException; +import java.text.MessageFormat; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jgit.http.server.GitServlet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.gitblit.Constants.AccessRestrictionType; +import com.gitblit.wicket.models.RepositoryModel; + +public class GitBlitServlet extends GitServlet { + + private static final long serialVersionUID = 1L; + + private final Logger logger = LoggerFactory.getLogger(GitBlitServlet.class); + + public GitBlitServlet() { + super(); + } + + @Override + protected void service(final HttpServletRequest req, final HttpServletResponse rsp) throws ServletException, IOException { + // admins have full git access to all repositories + if (req.isUserInRole(Constants.ADMIN_ROLE)) { + // admins can do whatever + super.service(req, rsp); + return; + } + + // try to intercept repository names for authenticated access + String url = req.getRequestURI().substring(req.getServletPath().length()); + if (url.charAt(0) == '/' && url.length() > 1) { + url = url.substring(1); + } + int forwardSlash = url.indexOf('/'); + if (forwardSlash > -1) { + String repository = url.substring(0, forwardSlash); + String function = url.substring(forwardSlash + 1); + String query = req.getQueryString(); + RepositoryModel model = GitBlit.self().getRepositoryModel(repository); + if (model != null) { + if (model.accessRestriction.atLeast(AccessRestrictionType.PUSH)) { + boolean authorizedUser = req.isUserInRole(repository); + if (function.startsWith("git-receive-pack") || (query.indexOf("service=git-receive-pack") > -1)) { + // Push request + boolean pushRestricted = model.accessRestriction.atLeast(AccessRestrictionType.PUSH); + if (!pushRestricted || (pushRestricted && authorizedUser)) { + // push-unrestricted or push-authorized + super.service(req, rsp); + return; + } else { + // user is unauthorized to push to this repository + logger.warn(MessageFormat.format("user {0} is not authorized to push to {1} ", req.getUserPrincipal().getName(), repository)); + rsp.sendError(HttpServletResponse.SC_FORBIDDEN, MessageFormat.format("you are not authorized to push to {0} ", repository)); + return; + } + } else if (function.startsWith("git-upload-pack") || (query.indexOf("service=git-upload-pack") > -1)) { + // Clone request + boolean cloneRestricted = model.accessRestriction.atLeast(AccessRestrictionType.CLONE); + if (!cloneRestricted || (cloneRestricted && authorizedUser)) { + // clone-unrestricted or clone-authorized + super.service(req, rsp); + return; + } else { + // user is unauthorized to clone this repository + logger.warn(MessageFormat.format("user {0} is not authorized to clone {1} ", req.getUserPrincipal().getName(), repository)); + rsp.sendError(HttpServletResponse.SC_FORBIDDEN, MessageFormat.format("you are not authorized to clone {0} ", repository)); + return; + } + } + } + } + } + + // pass-through to git servlet + super.service(req, rsp); + } +} diff --git a/src/com/gitblit/ILoginService.java b/src/com/gitblit/ILoginService.java index b58f4f1f..fc2801d9 100644 --- a/src/com/gitblit/ILoginService.java +++ b/src/com/gitblit/ILoginService.java @@ -1,10 +1,17 @@ package com.gitblit; -import com.gitblit.wicket.User; +import com.gitblit.wicket.models.User; public interface ILoginService { User authenticate(String username, char[] password); User authenticate(char[] cookie); + + User getUserModel(String username); + + boolean updateUserModel(User model); + + boolean deleteUserModel(User model); + } diff --git a/src/com/gitblit/JettyLoginService.java b/src/com/gitblit/JettyLoginService.java index 5173d213..ddd3722d 100644 --- a/src/com/gitblit/JettyLoginService.java +++ b/src/com/gitblit/JettyLoginService.java @@ -1,14 +1,33 @@ package com.gitblit; -import org.eclipse.jetty.security.HashLoginService; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.security.Principal; +import java.util.ArrayList; +import java.util.Map; +import java.util.Properties; + +import javax.security.auth.Subject; + +import org.eclipse.jetty.http.security.Credential; +import org.eclipse.jetty.security.IdentityService; +import org.eclipse.jetty.security.MappedLoginService; import org.eclipse.jetty.server.UserIdentity; +import org.eclipse.jetty.util.log.Log; + +import com.gitblit.utils.StringUtils; +import com.gitblit.wicket.models.User; -import com.gitblit.wicket.User; +public class JettyLoginService extends MappedLoginService implements ILoginService { -public class JettyLoginService extends HashLoginService implements ILoginService { + private final File realmFile; - public JettyLoginService(String realmFile) { - super(Constants.NAME, realmFile); + public JettyLoginService(File realmFile) { + super(); + setName(Constants.NAME); + this.realmFile = realmFile; } @Override @@ -17,10 +36,19 @@ public class JettyLoginService extends HashLoginService implements ILoginService if (identity == null || identity.equals(UserIdentity.UNAUTHENTICATED_IDENTITY)) { return null; } - User user = new User(username, password); + User user = new User(username); + user.setCookie(StringUtils.getSHA1((Constants.NAME + username + new String(password)))); user.canAdmin(identity.isUserInRole(Constants.ADMIN_ROLE, null)); - user.canClone(identity.isUserInRole(Constants.PULL_ROLE, null)); - user.canPush(identity.isUserInRole(Constants.PUSH_ROLE, null)); + + // Add repositories + for (Principal principal : identity.getSubject().getPrincipals()) { + if (principal instanceof RolePrincipal) { + RolePrincipal role = (RolePrincipal) principal; + if (role.getName().charAt(0) != '#') { + user.addRepository(role.getName().substring(1)); + } + } + } return user; } @@ -29,4 +57,140 @@ public class JettyLoginService extends HashLoginService implements ILoginService // TODO cookie login return null; } + + @Override + public User getUserModel(String username) { + User model = new User(username); + UserIdentity identity = _users.get(username); + Subject subject = identity.getSubject(); + for (Principal principal : subject.getPrincipals()) { + if (principal instanceof RolePrincipal) { + RolePrincipal role = (RolePrincipal) principal; + String name = role.getName(); + switch (name.charAt(0)) { + case '#': + // Permissions + if (name.equalsIgnoreCase(Constants.ADMIN_ROLE)) { + model.canAdmin(true); + } + break; + default: + model.addRepository(name.substring(1)); + } + } + } + return model; + } + + @Override + public boolean updateUserModel(User model) { + try { + Properties properties = new Properties(); + FileReader reader = new FileReader(realmFile); + properties.load(reader); + reader.close(); + + ArrayList roles = new ArrayList(); + + // Repositories + roles.addAll(model.getRepositories()); + + // Permissions + if (model.canAdmin()) { + roles.add(Constants.ADMIN_ROLE); + } + + StringBuilder sb = new StringBuilder(); + sb.append(model.getPassword()); + sb.append(','); + for (String role : roles) { + sb.append(role); + sb.append(','); + } + // trim trailing comma + sb.setLength(sb.length() - 1); + + // Update realm file + File realmFileCopy = new File(realmFile.getAbsolutePath() + ".tmp"); + FileWriter writer = new FileWriter(realmFileCopy); + properties.put(model.getUsername(), sb.toString()); + properties.store(writer, null); + writer.close(); + realmFile.delete(); + realmFileCopy.renameTo(realmFile); + + // Update login service + putUser(model.getUsername(), Credential.getCredential(model.getPassword()), roles.toArray(new String[0])); + return true; + } catch (Throwable t) { + t.printStackTrace(); + } + return false; + } + + @Override + public boolean deleteUserModel(User model) { + try { + // Read realm file + Properties properties = new Properties(); + FileReader reader = new FileReader(realmFile); + properties.load(reader); + reader.close(); + properties.remove(model.getUsername()); + + // Update realm file + File realmFileCopy = new File(realmFile.getAbsolutePath() + ".tmp"); + FileWriter writer = new FileWriter(realmFileCopy); + properties.store(writer, null); + writer.close(); + realmFile.delete(); + realmFileCopy.renameTo(realmFile); + + // Drop user from map + _users.remove(model.getUsername()); + return true; + } catch (Throwable t) { + t.printStackTrace(); + } + return false; + } + + /* ------------------------------------------------------------ */ + @Override + public void loadUsers() throws IOException { + if (realmFile == null) + return; + + if (Log.isDebugEnabled()) + Log.debug("Load " + this + " from " + realmFile); + Properties properties = new Properties(); + FileReader reader = new FileReader(realmFile); + properties.load(reader); + reader.close(); + + // Map Users + for (Map.Entry entry : properties.entrySet()) { + String username = ((String) entry.getKey()).trim(); + String credentials = ((String) entry.getValue()).trim(); + String roles = null; + int c = credentials.indexOf(','); + if (c > 0) { + roles = credentials.substring(c + 1).trim(); + credentials = credentials.substring(0, c).trim(); + } + + if (username != null && username.length() > 0 && credentials != null && credentials.length() > 0) { + String[] roleArray = IdentityService.NO_ROLES; + if (roles != null && roles.length() > 0) { + roleArray = roles.split(","); + } + putUser(username, Credential.getCredential(credentials), roleArray); + } + } + } + + @Override + protected UserIdentity loadUser(String username) { + return null; + } } diff --git a/src/com/gitblit/wicket/AuthorizationStrategy.java b/src/com/gitblit/wicket/AuthorizationStrategy.java index 3e7df36b..c4560adc 100644 --- a/src/com/gitblit/wicket/AuthorizationStrategy.java +++ b/src/com/gitblit/wicket/AuthorizationStrategy.java @@ -7,6 +7,7 @@ import org.apache.wicket.authorization.strategies.page.AbstractPageAuthorization import com.gitblit.GitBlit; import com.gitblit.Keys; +import com.gitblit.wicket.models.User; import com.gitblit.wicket.pages.RepositoriesPage; public class AuthorizationStrategy extends AbstractPageAuthorizationStrategy implements IUnauthorizedComponentInstantiationListener { diff --git a/src/com/gitblit/wicket/GitBlitWebApp.properties b/src/com/gitblit/wicket/GitBlitWebApp.properties index 39bdd29b..f2fe2327 100644 --- a/src/com/gitblit/wicket/GitBlitWebApp.properties +++ b/src/com/gitblit/wicket/GitBlitWebApp.properties @@ -70,11 +70,19 @@ gb.searchTooltip = Search Git:Blit gb.rename = rename gb.delete = delete gb.docs = docs -gb.restrictedAccess = restricted access +gb.accessRestriction = access restriction gb.name = name -gb.group = group gb.description = description gb.enableTickets = enable tickets gb.enableDocs = enable docs gb.save = save -gb.showRemoteBranches = show remote branches \ No newline at end of file +gb.showRemoteBranches = show remote branches +gb.editUsers = edit users +gb.password = password +gb.confirmPassword = confirm password +gb.repositories = repositories +gb.canAdmin can admin +gb.notRestricted = open repository +gb.cloneRestricted = clone-restricted repository +gb.pushRestricted = push-restricted repository +gb.viewRestricted = view-restricted repository \ No newline at end of file diff --git a/src/com/gitblit/wicket/GitBlitWebSession.java b/src/com/gitblit/wicket/GitBlitWebSession.java index 58ba4950..c6cc36d0 100644 --- a/src/com/gitblit/wicket/GitBlitWebSession.java +++ b/src/com/gitblit/wicket/GitBlitWebSession.java @@ -7,6 +7,8 @@ import org.apache.wicket.Session; import org.apache.wicket.protocol.http.WebSession; import org.apache.wicket.protocol.http.request.WebClientInfo; +import com.gitblit.wicket.models.User; + public final class GitBlitWebSession extends WebSession { private static final long serialVersionUID = 1L; diff --git a/src/com/gitblit/wicket/LoginPage.java b/src/com/gitblit/wicket/LoginPage.java index 3f8206e4..9e26a62a 100644 --- a/src/com/gitblit/wicket/LoginPage.java +++ b/src/com/gitblit/wicket/LoginPage.java @@ -18,6 +18,7 @@ import org.apache.wicket.protocol.http.WebResponse; import com.gitblit.Constants; import com.gitblit.GitBlit; import com.gitblit.Keys; +import com.gitblit.wicket.models.User; public class LoginPage extends WebPage { diff --git a/src/com/gitblit/wicket/RepositoryPage.java b/src/com/gitblit/wicket/RepositoryPage.java index 1f88075e..78fd33ce 100644 --- a/src/com/gitblit/wicket/RepositoryPage.java +++ b/src/com/gitblit/wicket/RepositoryPage.java @@ -161,11 +161,17 @@ public abstract class RepositoryPage extends BasePage { protected RepositoryModel getRepositoryModel() { if (m == null) { - m = GitBlit.self().getRepositoryModel(repositoryName); + RepositoryModel model = GitBlit.self().getRepositoryModel(GitBlitWebSession.get().getUser(), repositoryName); + if (model == null) { + error("Unauthorized access for repository " + repositoryName); + redirectToInterceptPage(new RepositoriesPage()); + return null; + } + m = model; } return m; } - + protected RevCommit getCommit() { RevCommit commit = JGitUtils.getCommit(r, objectId); if (commit == null) { diff --git a/src/com/gitblit/wicket/User.java b/src/com/gitblit/wicket/User.java deleted file mode 100644 index bd5e8c92..00000000 --- a/src/com/gitblit/wicket/User.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.gitblit.wicket; - -import java.io.Serializable; - -import com.gitblit.Constants; -import com.gitblit.utils.StringUtils; - -public class User implements Serializable { - - private static final long serialVersionUID = 1L; - - private String username; - private String cookie; - private boolean canAdmin = false; - private boolean canClone = false; - private boolean canPush = false; - - public User(String username, char[] password) { - this.username = username; - this.cookie = StringUtils.getSHA1((Constants.NAME + username + new String(password))); - } - - public void canAdmin(boolean value) { - canAdmin = value; - } - - public boolean canAdmin() { - return canAdmin; - } - - public void canClone(boolean value) { - canClone = value; - } - - public boolean canClone() { - return canClone; - } - - public void canPush(boolean value) { - canPush = value; - } - - public boolean canPush() { - return canPush; - } - - public String getCookie() { - return cookie; - } - - public String toString() { - return username; - } -} diff --git a/src/com/gitblit/wicket/WicketUtils.java b/src/com/gitblit/wicket/WicketUtils.java index bf2bcb93..f0ccbf4b 100644 --- a/src/com/gitblit/wicket/WicketUtils.java +++ b/src/com/gitblit/wicket/WicketUtils.java @@ -227,6 +227,10 @@ public class WicketUtils { public static int getPage(PageParameters params) { return params.getInt("page", 1); // index from 1 } + + public static String getUsername(PageParameters params) { + return params.getString("user", ""); + } public static Label createDateLabel(String wicketId, Date date, TimeZone timeZone) { DateFormat df = new SimpleDateFormat(GitBlit.self().settings().getString(Keys.web.datestampShortFormat, "MM/dd/yy")); diff --git a/src/com/gitblit/wicket/models/RepositoryModel.java b/src/com/gitblit/wicket/models/RepositoryModel.java index d21cff61..43a7ac18 100644 --- a/src/com/gitblit/wicket/models/RepositoryModel.java +++ b/src/com/gitblit/wicket/models/RepositoryModel.java @@ -3,28 +3,29 @@ package com.gitblit.wicket.models; import java.io.Serializable; import java.util.Date; +import com.gitblit.Constants.AccessRestrictionType; + public class RepositoryModel implements Serializable { private static final long serialVersionUID = 1L; public String name; public String description; public String owner; - public String group; public Date lastChange; public boolean hasCommits; public boolean showRemoteBranches; public boolean useTickets; public boolean useDocs; - public boolean useRestrictedAccess; + public AccessRestrictionType accessRestriction; public RepositoryModel() { - + } - + public RepositoryModel(String name, String description, String owner, Date lastchange) { this.name = name; this.description = description; this.owner = owner; this.lastChange = lastchange; - } + } } \ No newline at end of file diff --git a/src/com/gitblit/wicket/models/User.java b/src/com/gitblit/wicket/models/User.java new file mode 100644 index 00000000..07848396 --- /dev/null +++ b/src/com/gitblit/wicket/models/User.java @@ -0,0 +1,89 @@ +package com.gitblit.wicket.models; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +import com.gitblit.Constants.AccessRestrictionType; + +public class User implements Serializable { + + private static final long serialVersionUID = 1L; + + private String username; + private String password; + private String cookie; + private boolean canAdmin = false; + private List repositories = new ArrayList(); + + public User(String username) { + this.username = username; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public void canAdmin(boolean value) { + canAdmin = value; + } + + public boolean canAdmin() { + return canAdmin; + } + + public boolean canClone(RepositoryModel repository) { + return canAccess(repository, AccessRestrictionType.CLONE); + } + + public boolean canPush(RepositoryModel repository) { + return canAccess(repository, AccessRestrictionType.PUSH); + } + + public boolean canView(RepositoryModel repository) { + return canAccess(repository, AccessRestrictionType.VIEW); + } + + private boolean canAccess(RepositoryModel repository, AccessRestrictionType minimum) { + if (repository.accessRestriction.atLeast(minimum)) { + // repository is restricted, must check roles + return canAdmin || repositories.contains(repository.name); + } else { + // repository is not restricted + return true; + } + } + + public void setCookie(String cookie) { + this.cookie = cookie; + } + + public String getCookie() { + return cookie; + } + + public void setRepositories(List repositories) { + this.repositories.clear(); + this.repositories.addAll(repositories); + } + + public void addRepository(String name) { + repositories.add(name.toLowerCase()); + } + + public List getRepositories() { + return repositories; + } + + public String toString() { + return username; + } +} diff --git a/src/com/gitblit/wicket/pages/EditRepositoryPage.html b/src/com/gitblit/wicket/pages/EditRepositoryPage.html index bc965e7a..58723475 100644 --- a/src/com/gitblit/wicket/pages/EditRepositoryPage.html +++ b/src/com/gitblit/wicket/pages/EditRepositoryPage.html @@ -4,8 +4,8 @@ xml:lang="en" lang="en"> - +
@@ -15,18 +15,18 @@
- - - - + + + + - + - +
 distributed Ticgit issues
 enumerates repository Markdown documentation
 enumerates Markdown documentation in repository
 show remote branches
-
+ \ No newline at end of file diff --git a/src/com/gitblit/wicket/pages/EditRepositoryPage.java b/src/com/gitblit/wicket/pages/EditRepositoryPage.java index 6bf16599..2d2b0ae2 100644 --- a/src/com/gitblit/wicket/pages/EditRepositoryPage.java +++ b/src/com/gitblit/wicket/pages/EditRepositoryPage.java @@ -1,13 +1,16 @@ package com.gitblit.wicket.pages; +import java.util.Arrays; import java.util.Date; import org.apache.wicket.PageParameters; import org.apache.wicket.markup.html.form.CheckBox; +import org.apache.wicket.markup.html.form.DropDownChoice; import org.apache.wicket.markup.html.form.Form; import org.apache.wicket.markup.html.form.TextField; import org.apache.wicket.model.CompoundPropertyModel; +import com.gitblit.Constants.AccessRestrictionType; import com.gitblit.GitBlit; import com.gitblit.GitBlitException; import com.gitblit.wicket.AdminPage; @@ -40,7 +43,7 @@ public class EditRepositoryPage extends BasePage { if (isCreate) { super.setupPage("", getString("gb.newRepository")); } else { - super.setupPage("", getString("gb.edit")); + super.setupPage("", getString("gb.edit") + " " + repositoryModel.name); } CompoundPropertyModel model = new CompoundPropertyModel(repositoryModel); Form form = new Form("editForm", model) { @@ -59,12 +62,12 @@ public class EditRepositoryPage extends BasePage { setResponsePage(RepositoriesPage.class); } }; - + // field names reflective match RepositoryModel fields form.add(new TextField("name").setEnabled(isCreate)); form.add(new TextField("description")); form.add(new TextField("owner")); - form.add(new TextField("group")); + form.add(new DropDownChoice("accessRestriction", Arrays.asList(AccessRestrictionType.values()))); form.add(new CheckBox("useTickets")); form.add(new CheckBox("useDocs")); form.add(new CheckBox("showRemoteBranches")); diff --git a/src/com/gitblit/wicket/pages/EditUserPage.html b/src/com/gitblit/wicket/pages/EditUserPage.html new file mode 100644 index 00000000..57407d23 --- /dev/null +++ b/src/com/gitblit/wicket/pages/EditUserPage.html @@ -0,0 +1,29 @@ + + + + + + +
+ +
[Feedback Panel]
+ + +
+ + + + + + + + + +
 can administer Git:Blit server
+
+ +
+ \ No newline at end of file diff --git a/src/com/gitblit/wicket/pages/EditUserPage.java b/src/com/gitblit/wicket/pages/EditUserPage.java new file mode 100644 index 00000000..84bf7bc9 --- /dev/null +++ b/src/com/gitblit/wicket/pages/EditUserPage.java @@ -0,0 +1,97 @@ +package com.gitblit.wicket.pages; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import org.apache.wicket.PageParameters; +import org.apache.wicket.extensions.markup.html.form.palette.Palette; +import org.apache.wicket.markup.html.form.CheckBox; +import org.apache.wicket.markup.html.form.ChoiceRenderer; +import org.apache.wicket.markup.html.form.Form; +import org.apache.wicket.markup.html.form.PasswordTextField; +import org.apache.wicket.markup.html.form.TextField; +import org.apache.wicket.model.CompoundPropertyModel; +import org.apache.wicket.model.Model; +import org.apache.wicket.model.util.CollectionModel; +import org.apache.wicket.model.util.ListModel; +import org.eclipse.jetty.http.security.Credential.MD5; + +import com.gitblit.GitBlit; +import com.gitblit.GitBlitException; +import com.gitblit.wicket.AdminPage; +import com.gitblit.wicket.BasePage; +import com.gitblit.wicket.WicketUtils; +import com.gitblit.wicket.models.User; + +@AdminPage +public class EditUserPage extends BasePage { + + private final boolean isCreate; + + public EditUserPage() { + // create constructor + super(); + isCreate = true; + setupPage(new User("")); + } + + public EditUserPage(PageParameters params) { + // edit constructor + super(params); + isCreate = false; + String name = WicketUtils.getUsername(params); + User model = GitBlit.self().getUser(name); + setupPage(model); + } + + protected void setupPage(final User userModel) { + if (isCreate) { + super.setupPage("", getString("gb.newUser")); + } else { + super.setupPage("", getString("gb.edit")); + } + final Model confirmPassword = new Model(); + CompoundPropertyModel model = new CompoundPropertyModel(userModel); + + List repos = GitBlit.self().getRepositoryList(); + repos.add(0, "*"); // all repositories wildcard + final Palette repositories = new Palette("repositories", new ListModel(userModel.getRepositories()), new CollectionModel(repos), new ChoiceRenderer("", ""), 10, false); + Form form = new Form("editForm", model) { + + private static final long serialVersionUID = 1L; + + @Override + protected void onSubmit() { + if (!userModel.getPassword().equals(confirmPassword.getObject())) { + error("Passwords do not match!"); + return; + } + userModel.setPassword(MD5.digest(userModel.getPassword())); + + Iterator selectedRepositories = repositories.getSelectedChoices(); + List repos = new ArrayList(); + while (selectedRepositories.hasNext()) { + repos.add(selectedRepositories.next()); + } + userModel.setRepositories(repos); + try { + GitBlit.self().editUserModel(userModel, isCreate); + } catch (GitBlitException e) { + error(e.getMessage()); + return; + } + setRedirect(true); + setResponsePage(EditUserPage.class); + } + }; + + // field names reflective match UserModel fields + form.add(new TextField("username").setEnabled(isCreate)); + form.add(new PasswordTextField("password")); + form.add(new PasswordTextField("confirmPassword", confirmPassword)); + form.add(new CheckBox("canAdmin")); + form.add(repositories); + add(form); + } +} diff --git a/src/com/gitblit/wicket/pages/RepositoriesPage.html b/src/com/gitblit/wicket/pages/RepositoriesPage.html index 09de115c..3016f648 100644 --- a/src/com/gitblit/wicket/pages/RepositoriesPage.html +++ b/src/com/gitblit/wicket/pages/RepositoriesPage.html @@ -41,7 +41,7 @@ diff --git a/src/com/gitblit/wicket/pages/RepositoriesPage.java b/src/com/gitblit/wicket/pages/RepositoriesPage.java index 111084c3..9f3aa2a3 100644 --- a/src/com/gitblit/wicket/pages/RepositoriesPage.java +++ b/src/com/gitblit/wicket/pages/RepositoriesPage.java @@ -33,6 +33,7 @@ import com.gitblit.wicket.GitBlitWebSession; import com.gitblit.wicket.LinkPanel; import com.gitblit.wicket.WicketUtils; import com.gitblit.wicket.models.RepositoryModel; +import com.gitblit.wicket.models.User; public class RepositoriesPage extends BasePage { @@ -50,7 +51,8 @@ public class RepositoriesPage extends BasePage { Fragment adminLinks = new Fragment("adminPanel", "adminLinks", this); adminLinks.add(new BookmarkablePageLink("newRepository", EditRepositoryPage.class)); - adminLinks.add(new BookmarkablePageLink("newUser", RepositoriesPage.class)); + adminLinks.add(new BookmarkablePageLink("newUser", EditUserPage.class)); + adminLinks.add(new BookmarkablePageLink("editUsers", RepositoriesPage.class)); add(adminLinks.setVisible(showAdmin)); // display an error message cached from a redirect @@ -59,7 +61,7 @@ public class RepositoriesPage extends BasePage { error(cachedMessage); System.out.println("displayed message"); } - + // Load the markdown welcome message String messageSource = GitBlit.self().settings().getString(Keys.web.repositoriesMessage, "gitblit"); String message = ""; @@ -97,7 +99,8 @@ public class RepositoriesPage extends BasePage { } add(repositoriesMessage); - List rows = GitBlit.self().getRepositoryModels(); + User user = GitBlitWebSession.get().getUser(); + List rows = GitBlit.self().getRepositoryModels(user); DataProvider dp = new DataProvider(rows); DataView dataView = new DataView("repository", dp) { private static final long serialVersionUID = 1L; @@ -113,27 +116,38 @@ public class RepositoriesPage extends BasePage { } else { // New repository item.add(new Label("repositoryName", entry.name + "(empty)").setEscapeModelStrings(false)); - item.add(new Label("repositoryDescription", entry.description)); + item.add(new Label("repositoryDescription", entry.description)); } - + if (entry.useTickets) { item.add(WicketUtils.newImage("ticketsIcon", "bug_16x16.png", getString("gb.tickets"))); } else { - item.add(WicketUtils.newClearPixel("ticketsIcon")); + item.add(WicketUtils.newBlankImage("ticketsIcon")); } - + if (entry.useDocs) { item.add(WicketUtils.newImage("docsIcon", "book_16x16.png", getString("gb.docs"))); } else { - item.add(WicketUtils.newClearPixel("docsIcon")); + item.add(WicketUtils.newBlankImage("docsIcon")); } - - if (entry.useRestrictedAccess) { - item.add(WicketUtils.newImage("restrictedAccessIcon", "lock_16x16.png", getString("gb.restrictedAccess"))); - } else { - item.add(WicketUtils.newClearPixel("restrictedAccessIcon")); + + switch (entry.accessRestriction) { + case NONE: + item.add(WicketUtils.newBlankImage("restrictedAccessIcon")); + break; + case PUSH: + item.add(WicketUtils.newImage("restrictedAccessIcon", "lock_go_16x16.png", getString("gb.pushRestricted"))); + break; + case CLONE: + item.add(WicketUtils.newImage("restrictedAccessIcon", "lock_pull_16x16.png", getString("gb.cloneRestricted"))); + break; + case VIEW: + item.add(WicketUtils.newImage("restrictedAccessIcon", "shield_16x16.png", getString("gb.viewRestricted"))); + break; + default: + item.add(WicketUtils.newBlankImage("restrictedAccessIcon")); } - + item.add(new Label("repositoryOwner", entry.owner)); String lastChange = TimeUtils.timeAgo(entry.lastChange); diff --git a/src/com/gitblit/wicket/resources/gitblit.css b/src/com/gitblit/wicket/resources/gitblit.css index 3d0a1cd5..13e761d4 100644 --- a/src/com/gitblit/wicket/resources/gitblit.css +++ b/src/com/gitblit/wicket/resources/gitblit.css @@ -444,10 +444,15 @@ table.plain { padding: 8px; } -table.plain td.edit { +table.plain td.edit { padding: 3px; } +table.plain td.editButton { + padding:0px; + padding-top: 10px; +} + table.plain td.edit input { margin: 0px; outline: 1px solid transparent; @@ -518,6 +523,19 @@ table.repositories th.wicket_orderDown a, table.repositories th.wicket_orderUp a font-weight: bold; } +table.palette { border:0;} +table.palette td.header { + font-weight: bold; + background-color: #D2C3AF !important; + padding: 3px !important; + border: 1px solid #808080 !important; + border-bottom: 0px solid !important; + border-radius: 3px 3px 0 0; +} +table.palette td.pane { + padding: 0px; +} + tr th a { padding-right: 15px; background-position: right; background-repeat:no-repeat; } tr th.wicket_orderDown a {background-image: url(arrow_down.png); } tr th.wicket_orderUp a { background-image: url(arrow_up.png); } @@ -619,22 +637,22 @@ span .tagRef a:hover, span .headRef a:hover, span .remoteRef a:hover, span .othe span .otherRef { background-color: #ffaaff; - border-color: #ffccff #ff00ee #ff00ee #ffccff; + border-color: #ff00ee; } span .remoteRef { background-color: #cAc2f5; - border-color: #ccccff #0033cc #0033cc #ccccff; + border-color: #6c6cbf; } span .tagRef { background-color: #ffffaa; - border-color: #ffcc00 #ffcc00 #ffcc00 #ffcc00; + border-color: #ffcc00; } span .headRef { background-color: #ccffcc; - border-color: #ccffcc #00cc33 #00cc33 #ccffcc; + border-color: #00cc33; } .feedbackPanelERROR { diff --git a/src/com/gitblit/wicket/resources/lock_go_16x16.png b/src/com/gitblit/wicket/resources/lock_go_16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..63d428598e0e6a9993810050c9d61305eeb671aa GIT binary patch literal 777 zcmV+k1NQuhP) zR2mhIl2bW4^XAQauXE>(Vr9^Q^FHpm=XcN7WmqLQZ{6(#3mgVe(Y#1))2!m zk~@;p(RJfXK|%f&>(1?DV31`As;Xi#_#Qr=|LbYTiLL(xw6?W(7Fey0O}V-7y!K!| z98PY?$eQa zt91twsz#~duT%v7Sd3{%=mM7LP0mhQ8spm9(tLSlb{;y}WGQ2h znNCEM$KZ(=hEDJtctgD?FR6wg_f!eZ;03FY5}F2Ri+Jl9A%Ki5QhsVU?#JkK|4OlR zqYsQ+yt6$xxVHu`ecgwW5vU1@FTQ}?MWql$i6R)IVgldcg}lKGpK1kNH9?{X^n1IJ zh+x13Xk=9>0`D+H%0Gi5*qVh!kBqU z_~~ihA+S8)!zW;TKp1yB^%dvk`Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_00007bV*G`2ipY; z6EY1ZZF|@N00PoUL_t(I%XO1qh*WhH$3N#c_s*b1xJxpjWt%%YJ7tYw5^fKnhKp!E z31U5jm5Dngh7>*d5*4(@B3rs;X%d12`x5z(P-4(Su1w^JGILAT9slhn?z*Duy>svH zcY2t1TZ_JMI0p{r%lY#?qN)Ih2-EoT(AR(hvLCdk83PC)16yBxxinMvZ-RXT2PhOe zsa9)O3Wd%E`Hpr}1>g6GIJP}N$;>9W6Iv2yX8crW>F?`(A1LpBueZFnZ_fwU{v0Q92Jtcg+(R%K z8qSwTKFmJ2n7h{JR>KDvoz8vt13A;kQeNS=sRkUu@fehyg73W!ACjvl4A1DIQ)pm(JqT*hUE91hG{-r)+#-`D>If zA4GgOIlKe&k0@E*){YVIJTNB3`N|MBj##>Q4T-aO$!$&?J9}{Tq6J`Wv&i_(u~pzE zXHkKMO|VXRyto3JG$0%&i6J%o6DOsOCG+Of-r5SrgF}N*4xn6vA1cGF%J{sPn@8f5 zv1@g%kJia9YNvW8z*E(_h-_0hu=07ja~*&WKR>sY=*}dUF4jI5G8Tb)xt$p7QE cs@4Gh0%~SO6@%F?wEzGB07*qoM6N<$f;O~*S^xk5 literal 0 HcmV?d00001 diff --git a/src/com/gitblit/wicket/resources/shield_16x16.png b/src/com/gitblit/wicket/resources/shield_16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..4eb8031c4fab9efda65adbe64ff05f186d69db7c GIT binary patch literal 704 zcmV;x0zdtUP)blGV<`Bkv+M$F5UR zAuO{LuD>v(XdP#1=0@>HSV_Hq`i&-42f2F!vB&*0%rvjTvfw#!3i)gV`7dD@+ey@Q z4Z+jiVuUF#d8aP>8O<)6xY)2xLfr zt{@y}0_vf2$SuFd`0TGhnLzMkd?gv(STeUVaMU!5)6HB4vXcZf2ejJLFca(8(pQ!^ zw@iTdPjEgpmI;SYb*vkvkw=jP6sHq~-zg{!J=hMtKyZF*jC1WG=|C%cmhSb=9rq9P zDGl{VEWCoVrVieYVHD#@tiE~n$v66$ngv|