diff --git a/.github/ISSUE_TEMPLATE b/.github/ISSUE_TEMPLATE new file mode 100644 index 000000000..d1fb00f91 --- /dev/null +++ b/.github/ISSUE_TEMPLATE @@ -0,0 +1,5 @@ +If you are reporting a problem, please make sure the following information are provided: +1)Version of docker engine and docker-compose +2)Config files of harbor, you can get them by packaging "Deploy/config" directory +3)Log files, you can get them by package the /var/log/harbor/ + diff --git a/Deploy/config/nginx/nginx.conf b/Deploy/config/nginx/nginx.conf index 8168137ea..8f3430e48 100644 --- a/Deploy/config/nginx/nginx.conf +++ b/Deploy/config/nginx/nginx.conf @@ -33,7 +33,10 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # When setting up Harbor behind other proxy, such as an Nginx instance, remove the below line if the proxy already has similar settings. proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; proxy_request_buffering off; } @@ -47,7 +50,10 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # When setting up Harbor behind other proxy, such as an Nginx instance, remove the below line if the proxy already has similar settings. proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; proxy_request_buffering off; @@ -58,7 +64,10 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # When setting up Harbor behind other proxy, such as an Nginx instance, remove the below line if the proxy already has similar settings. proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; proxy_request_buffering off; } diff --git a/Deploy/config/nginx/nginx.https.conf b/Deploy/config/nginx/nginx.https.conf index 7e03b9585..c802943c1 100644 --- a/Deploy/config/nginx/nginx.https.conf +++ b/Deploy/config/nginx/nginx.https.conf @@ -47,7 +47,10 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # When setting up Harbor behind other proxy, such as an Nginx instance, remove the below line if the proxy already has similar settings. proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; proxy_request_buffering off; } @@ -61,7 +64,10 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # When setting up Harbor behind other proxy, such as an Nginx instance, remove the below line if the proxy already has similar settings. proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; proxy_request_buffering off; @@ -72,7 +78,10 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # When setting up Harbor behind other proxy, such as an Nginx instance, remove the below line if the proxy already has similar settings. proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; proxy_request_buffering off; } diff --git a/Deploy/db/registry.sql b/Deploy/db/registry.sql index 9e4a342b3..bd0644b33 100644 --- a/Deploy/db/registry.sql +++ b/Deploy/db/registry.sql @@ -94,6 +94,7 @@ create table access_log ( user_id int NOT NULL, project_id int NOT NULL, repo_name varchar (40), + repo_tag varchar (20), GUID varchar(64), operation varchar(20) NOT NULL, op_time timestamp, diff --git a/Deploy/docker-compose.yml b/Deploy/docker-compose.yml index a00421fb7..2e9147ee1 100644 --- a/Deploy/docker-compose.yml +++ b/Deploy/docker-compose.yml @@ -7,14 +7,14 @@ services: ports: - 1514:514 registry: - image: library/registry:2.3.0 + image: library/registry:2.4.0 volumes: - /data/registry:/storage - ./config/registry/:/etc/registry/ ports: - 5001:5001 command: - /etc/registry/config.yml + ["serve", "/etc/registry/config.yml"] depends_on: - log logging: diff --git a/Deploy/harbor.cfg b/Deploy/harbor.cfg index 90dc66540..bd949cea5 100644 --- a/Deploy/harbor.cfg +++ b/Deploy/harbor.cfg @@ -9,14 +9,15 @@ hostname = reg.mydomain.com ui_url_protocol = http #Email account settings for sending out password resetting emails. -email_server = smtp.mydomain.com +email_server = smtp.mydomain.com email_server_port = 25 email_username = sample_admin@mydomain.com email_password = abc email_from = admin +email_ssl = false ##The password of Harbor admin, change this before any production use. -harbor_admin_password= Harbor12345 +harbor_admin_password = Harbor12345 ##By default the auth mode is db_auth, i.e. the credentials are stored in a local database. #Set it to ldap_auth if you want to verify a user's credentials against an LDAP server. @@ -33,4 +34,16 @@ db_password = root123 #Turn on or off the self-registration feature self_registration = on + +#Turn on or off the customize your certicate +customize_crt = on + +#fill in your certicate message +crt_country = CN +crt_state = State +crt_location = CN +crt_organization = organization +crt_organizationalunit = organizational unit +crt_commonname = example.com +crt_email = example@example.com ##### diff --git a/Deploy/prepare b/Deploy/prepare index 5571e3c20..33288d06e 100755 --- a/Deploy/prepare +++ b/Deploy/prepare @@ -29,12 +29,21 @@ email_server_port = rcp.get("configuration", "email_server_port") email_username = rcp.get("configuration", "email_username") email_password = rcp.get("configuration", "email_password") email_from = rcp.get("configuration", "email_from") +email_ssl = rcp.get("configuration", "email_ssl") harbor_admin_password = rcp.get("configuration", "harbor_admin_password") auth_mode = rcp.get("configuration", "auth_mode") ldap_url = rcp.get("configuration", "ldap_url") ldap_basedn = rcp.get("configuration", "ldap_basedn") db_password = rcp.get("configuration", "db_password") self_registration = rcp.get("configuration", "self_registration") +customize_crt = rcp.get("configuration", "customize_crt") +crt_country = rcp.get("configuration", "crt_country") +crt_state = rcp.get("configuration", "crt_state") +crt_location = rcp.get("configuration", "crt_location") +crt_organization = rcp.get("configuration", "crt_organization") +crt_organizationalunit = rcp.get("configuration", "crt_organizationalunit") +crt_commonname = rcp.get("configuration", "crt_commonname") +crt_email = rcp.get("configuration", "crt_email") ######## base_dir = os.path.dirname(__file__) @@ -62,10 +71,12 @@ registry_conf = os.path.join(config_dir, "registry", "config.yml") db_conf_env = os.path.join(config_dir, "db", "env") conf_files = [ ui_conf, ui_conf_env, registry_conf, db_conf_env ] -for f in conf_files: - if os.path.exists(f): - print("Clearing the configuration file: %s" % f) - os.remove(f) +def rmdir(cf): + for f in cf: + if os.path.exists(f): + print("Clearing the configuration file: %s" % f) + os.remove(f) +rmdir(conf_files) render(os.path.join(templates_dir, "ui", "env"), ui_conf_env, @@ -73,7 +84,7 @@ render(os.path.join(templates_dir, "ui", "env"), db_password=db_password, ui_url=ui_url, auth_mode=auth_mode, - admin_pwd=harbor_admin_password, + harbor_admin_password=harbor_admin_password, ldap_url=ldap_url, ldap_basedn=ldap_basedn, self_registration=self_registration) @@ -82,9 +93,10 @@ render(os.path.join(templates_dir, "ui", "app.conf"), ui_conf, email_server=email_server, email_server_port=email_server_port, - email_user_name=email_username, - email_user_password=email_password, + email_username=email_username, + email_password=email_password, email_from=email_from, + email_ssl=email_ssl, ui_url=ui_url) render(os.path.join(templates_dir, "registry", "config.yml"), @@ -95,4 +107,58 @@ render(os.path.join(templates_dir, "db", "env"), db_conf_env, db_password=db_password) +def validate_crt_subj(dirty_subj): + subj_list = [item for item in dirty_subj.strip().split("/") \ + if len(item.split("=")) == 2 and len(item.split("=")[1]) > 0] + return "/" + "/".join(subj_list) + +FNULL = open(os.devnull, 'w') + +from functools import wraps +def stat_decorator(func): + #@wraps(func) + def check_wrapper(*args, **kwargs): + stat = func(*args, **kwargs) + message = "Generated configuration file: %s" % kwargs['path'] \ + if stat == 0 else "Fail to generate %s" % kwargs['path'] + print(message) + if stat != 0: + sys.exit(1) + return check_wrapper + +@stat_decorator +def check_private_key_stat(*args, **kwargs): + return subprocess.call(["openssl", "genrsa", "-out", kwargs['path'], "4096"],\ + stdout=FNULL, stderr=subprocess.STDOUT) + +@stat_decorator +def check_certificate_stat(*args, **kwargs): + dirty_subj = "/C={0}/ST={1}/L={2}/O={3}/OU={4}/CN={5}/emailAddress={6}"\ + .format(crt_country, crt_state, crt_location, crt_organization,\ + crt_organizationalunit, crt_commonname, crt_email) + subj = validate_crt_subj(dirty_subj) + return subprocess.call(["openssl", "req", "-new", "-x509", "-key",\ + private_key_pem, "-out", root_crt, "-days", "3650", "-subj", subj], \ + stdout=FNULL, stderr=subprocess.STDOUT) + +def openssl_is_installed(stat): + if stat == 0: + return True + else: + print("Cannot find openssl installed in this computer\nUse default SSL certificate file") + return False + +if customize_crt == 'on': + import subprocess + shell_stat = subprocess.check_call(["which", "openssl"], stdout=FNULL, stderr=subprocess.STDOUT) + if openssl_is_installed(shell_stat): + private_key_pem = os.path.join(config_dir, "ui", "private_key.pem") + root_crt = os.path.join(config_dir, "registry", "root.crt") + crt_conf_files = [ private_key_pem, root_crt ] + rmdir(crt_conf_files) + + check_private_key_stat(path=private_key_pem) + check_certificate_stat(path=root_crt) + +FNULL.close() print("The configuration files are ready, please use docker-compose to start the service.") diff --git a/Deploy/templates/registry/config.yml b/Deploy/templates/registry/config.yml index a1b19a8ed..999488a95 100644 --- a/Deploy/templates/registry/config.yml +++ b/Deploy/templates/registry/config.yml @@ -30,6 +30,6 @@ notifications: - name: harbor disabled: false url: http://ui/service/notifications - timeout: 500 + timeout: 500ms threshold: 5 - backoff: 1000 + backoff: 1s diff --git a/Deploy/templates/ui/app.conf b/Deploy/templates/ui/app.conf index 63dbd3451..a5cf4dfdf 100644 --- a/Deploy/templates/ui/app.conf +++ b/Deploy/templates/ui/app.conf @@ -2,8 +2,8 @@ appname = registry runmode = dev [lang] -types = en-US|zh-CN -names = en-US|zh-CN +types = en-US|zh-CN|de-DE +names = en-US|zh-CN|de-DE [dev] httpport = 80 @@ -11,6 +11,7 @@ httpport = 80 [mail] host = $email_server port = $email_server_port -username = $email_user_name -password = $email_user_password +username = $email_username +password = $email_password from = $email_from +ssl = $email_ssl diff --git a/Deploy/templates/ui/env b/Deploy/templates/ui/env index e20e2bc6d..383e5f15a 100644 --- a/Deploy/templates/ui/env +++ b/Deploy/templates/ui/env @@ -5,8 +5,8 @@ MYSQL_PWD=$db_password REGISTRY_URL=http://registry:5000 CONFIG_PATH=/etc/ui/app.conf HARBOR_REG_URL=$hostname -HARBOR_ADMIN_PASSWORD=$admin_pwd -HARBOR_URL=$ui_url +HARBOR_ADMIN_PASSWORD=$harbor_admin_password +HARBOR_URL=$hostname AUTH_MODE=$auth_mode LDAP_URL=$ldap_url LDAP_BASE_DN=$ldap_basedn diff --git a/README.md b/README.md index c7b4e2b06..06dbb8cf9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Project Harbor is an enterprise-class registry server. It extends the open sourc * **Graphical user portal**: User can easily browse, search docker repositories, manage projects/namespaces. * **AD/LDAP support**: Harbor integrates with existing AD/LDAP of the enterprise for user authentication and management. * **Auditing**: All the operations to the repositories are tracked and can be used for auditing purpose. -* **Internationalization**: Localized for English and Chinese languages. More languages can be added. +* **Internationalization**: Localized for English, Chinese and German languages. More languages can be added. * **RESTful API**: RESTful APIs are provided for most administrative operations of Harbor. The integration with other management softwares becomes easy. ### Getting Started @@ -57,8 +57,8 @@ To simplify the installation process, a pre-built installation package of Harbor For information on how to use Harbor, please see [User Guide](docs/user_guide.md) . -### Deploy harbor on Kubernetes -Detailed instruction about deploying harbor on Kubernetes is described [here](https://github.com/vmware/harbor/blob/master/kubernetes_deployment.md). +### Deploy Harbor on Kubernetes +Detailed instruction about deploying Harbor on Kubernetes is described [here](docs/kubernetes_deployment.md). ### Contribution We welcome contributions from the community. If you wish to contribute code and you have not signed our contributor license agreement (CLA), our bot will update the issue when you open a pull request. For any questions about the CLA process, please refer to our [FAQ](https://cla.vmware.com/faq). diff --git a/api/base.go b/api/base.go index 70388009e..f2529b61e 100644 --- a/api/base.go +++ b/api/base.go @@ -86,3 +86,11 @@ func (b *BaseAPI) ValidateUser() int { } return userID } + +// Redirect does redirection to resource URI with http header status code. +func (b *BaseAPI) Redirect(statusCode int, resouceID string) { + requestURI := b.Ctx.Request.RequestURI + resoucreURI := requestURI + "/" + resouceID + + b.Ctx.Redirect(statusCode, resoucreURI) +} diff --git a/api/project.go b/api/project.go index dbc0a4ecb..993cc9ad7 100644 --- a/api/project.go +++ b/api/project.go @@ -87,11 +87,13 @@ func (p *ProjectAPI) Post() { return } project := models.Project{OwnerID: p.userID, Name: projectName, CreationTime: time.Now(), Public: public} - err = dao.AddProject(project) + projectID, err := dao.AddProject(project) if err != nil { log.Errorf("Failed to add project, error: %v", err) p.RenderError(http.StatusInternalServerError, "Failed to add project") } + + p.Redirect(http.StatusCreated, strconv.FormatInt(projectID, 10)) } // Head ... @@ -183,6 +185,7 @@ func (p *ProjectAPI) FilterAccessLog() { p.CustomAbort(http.StatusInternalServerError, "Internal error.") } p.Data["json"] = accessLogList + p.ServeJSON() } diff --git a/api/repository.go b/api/repository.go index e4e4ea62b..b0361af3a 100644 --- a/api/repository.go +++ b/api/repository.go @@ -168,18 +168,6 @@ type tag struct { Tags []string `json:"tags"` } -type histroyItem struct { - V1Compatibility string `json:"v1Compatibility"` -} - -type manifest struct { - Name string `json:"name"` - Tag string `json:"tag"` - Architecture string `json:"architecture"` - SchemaVersion int `json:"schemaVersion"` - History []histroyItem `json:"history"` -} - // GetTags handles GET /api/repositories/tags func (ra *RepositoryAPI) GetTags() { repoName := ra.GetString("repo_name") @@ -240,8 +228,7 @@ func (ra *RepositoryAPI) GetManifests() { ra.CustomAbort(http.StatusInternalServerError, "internal error") } } - - mani := manifest{} + mani := models.Manifest{} err = json.Unmarshal(payload, &mani) if err != nil { log.Errorf("Failed to decode json from response for manifests, repo name: %s, tag: %s, error: %v", repoName, tag, err) diff --git a/api/user.go b/api/user.go index 58aa29ec3..c9bb99800 100644 --- a/api/user.go +++ b/api/user.go @@ -36,6 +36,11 @@ type UserAPI struct { AuthMode string } +type passwordReq struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` +} + // Prepare validates the URL and parms func (ua *UserAPI) Prepare() { @@ -153,13 +158,14 @@ func (ua *UserAPI) Post() { user := models.User{} ua.DecodeJSONReq(&user) - _, err := dao.Register(user) + userID, err := dao.Register(user) if err != nil { log.Errorf("Error occurred in Register: %v", err) ua.RenderError(http.StatusInternalServerError, "Internal error.") return } + ua.Redirect(http.StatusCreated, strconv.FormatInt(userID, 10)) } // Delete ... @@ -177,3 +183,46 @@ func (ua *UserAPI) Delete() { return } } + +// ChangePassword handles PUT to /api/users/{}/password +func (ua *UserAPI) ChangePassword() { + + if !(ua.AuthMode == "db_auth") { + ua.CustomAbort(http.StatusForbidden, "") + } + + if !ua.IsAdmin { + if ua.userID != ua.currentUserID { + log.Error("Guests can only change their own account.") + ua.CustomAbort(http.StatusForbidden, "Guests can only change their own account.") + } + } + + var req passwordReq + ua.DecodeJSONReq(&req) + if req.OldPassword == "" { + log.Error("Old password is blank") + ua.CustomAbort(http.StatusBadRequest, "Old password is blank") + } + + queryUser := models.User{UserID: ua.userID, Password: req.OldPassword} + user, err := dao.CheckUserPassword(queryUser) + if err != nil { + log.Errorf("Error occurred in CheckUserPassword: %v", err) + ua.CustomAbort(http.StatusInternalServerError, "Internal error.") + } + if user == nil { + log.Warning("Password input is not correct") + ua.CustomAbort(http.StatusForbidden, "old_password_is_not_correct") + } + + if req.NewPassword == "" { + ua.CustomAbort(http.StatusBadRequest, "please_input_new_password") + } + updateUser := models.User{UserID: ua.userID, Password: req.NewPassword, Salt: user.Salt} + err = dao.ChangeUserPassword(updateUser, req.OldPassword) + if err != nil { + log.Errorf("Error occurred in ChangeUserPassword: %v", err) + ua.CustomAbort(http.StatusInternalServerError, "Internal error.") + } +} diff --git a/auth/ldap/ldap.go b/auth/ldap/ldap.go index 53d17a174..8de4d47fb 100644 --- a/auth/ldap/ldap.go +++ b/auth/ldap/ldap.go @@ -76,31 +76,25 @@ func (l *Auth) Authenticate(m models.AuthModel) (*models.User, error) { scope := openldap.LDAP_SCOPE_SUBTREE // LDAP_SCOPE_BASE, LDAP_SCOPE_ONELEVEL, LDAP_SCOPE_SUBTREE filter := "objectClass=*" - attributes := []string{"cn", "mail", "uid"} + attributes := []string{"mail"} result, err := ldap.SearchAll(baseDn, scope, filter, attributes) if err != nil { return nil, err } - if len(result.Entries()) != 1 { - log.Warningf("Found more than one entry.") - return nil, nil - } - en := result.Entries()[0] u := models.User{} - for _, attr := range en.Attributes() { - val := attr.Values()[0] - switch attr.Name() { - case "uid": - u.Username = val - case "mail": - u.Email = val - case "cn": - u.Realname = val + if len(result.Entries()) == 1 { + en := result.Entries()[0] + for _, attr := range en.Attributes() { + val := attr.Values()[0] + if attr.Name() == "mail" { + u.Email = val + } } } - log.Debug("username:", u.Username, ",email:", u.Email, ",realname:", u.Realname) + u.Username = m.Principal + log.Debug("username:", u.Username, ",email:", u.Email) exist, err := dao.UserExists(u, "username") if err != nil { @@ -114,6 +108,7 @@ func (l *Auth) Authenticate(m models.AuthModel) (*models.User, error) { } u.UserID = currentUser.UserID } else { + u.Realname = m.Principal u.Password = "12345678AbC" u.Comment = "registered from LDAP." userID, err := dao.Register(u) diff --git a/contrib/docker-compose.yml.daocloud b/contrib/docker-compose.yml.daocloud new file mode 100644 index 000000000..e93fb42f1 --- /dev/null +++ b/contrib/docker-compose.yml.daocloud @@ -0,0 +1,68 @@ +version: '2' +services: + log: + image: daocloud.io/harbor/deploy_log:latest + volumes: + - /var/log/harbor/:/var/log/docker/ + ports: + - 1514:514 + registry: + image: daocloud.io/library/registry:2.3.0 + volumes: + - /data/registry:/storage + - ./config/registry/:/etc/registry/ + ports: + - 5001:5001 + command: + /etc/registry/config.yml + depends_on: + - log + logging: + driver: "syslog" + options: + syslog-address: "tcp://127.0.0.1:1514" + syslog-tag: "registry" + mysql: + image: daocloud.io/harbor/deploy_mysql:latest + volumes: + - /data/database:/var/lib/mysql + env_file: + - ./config/db/env + depends_on: + - log + logging: + driver: "syslog" + options: + syslog-address: "tcp://127.0.0.1:1514" + syslog-tag: "mysql" + ui: + image: daocloud.io/harbor/deploy_ui:latest + env_file: + - ./config/ui/env + volumes: + - ./config/ui/app.conf:/etc/ui/app.conf + - ./config/ui/private_key.pem:/etc/ui/private_key.pem + depends_on: + - log + logging: + driver: "syslog" + options: + syslog-address: "tcp://127.0.0.1:1514" + syslog-tag: "ui" + proxy: + image: daocloud.io/library/nginx:1.9 + volumes: + - ./config/nginx:/etc/nginx + ports: + - 80:80 + - 443:443 + depends_on: + - mysql + - registry + - ui + - log + logging: + driver: "syslog" + options: + syslog-address: "tcp://127.0.0.1:1514" + syslog-tag: "proxy" diff --git a/controllers/login.go b/controllers/login.go index f87c3dfc3..42d324f08 100644 --- a/controllers/login.go +++ b/controllers/login.go @@ -69,7 +69,7 @@ func (c *CommonController) Login() { // SwitchLanguage handles UI request to switch between different languages and re-render template based on language. func (c *CommonController) SwitchLanguage() { lang := c.GetString("lang") - if lang == "en-US" || lang == "zh-CN" { + if lang == "en-US" || lang == "zh-CN" || lang == "de-DE" { c.SetSession("lang", lang) c.Data["Lang"] = lang } diff --git a/controllers/password.go b/controllers/password.go index 3d569b6d9..210e5cf9f 100644 --- a/controllers/password.go +++ b/controllers/password.go @@ -46,47 +46,6 @@ func (cpc *ChangePasswordController) Get() { cpc.ForwardTo("page_title_change_password", "change-password") } -// UpdatePassword handles UI request to update user's password, it only works when the auth mode is db_auth. -func (cc *CommonController) UpdatePassword() { - - sessionUserID := cc.GetSession("userId") - - if sessionUserID == nil { - log.Warning("User does not login.") - cc.CustomAbort(http.StatusUnauthorized, "please_login_first") - } - - oldPassword := cc.GetString("old_password") - if oldPassword == "" { - log.Error("Old password is blank") - cc.CustomAbort(http.StatusBadRequest, "Old password is blank") - } - - queryUser := models.User{UserID: sessionUserID.(int), Password: oldPassword} - user, err := dao.CheckUserPassword(queryUser) - if err != nil { - log.Errorf("Error occurred in CheckUserPassword: %v", err) - cc.CustomAbort(http.StatusInternalServerError, "Internal error.") - } - - if user == nil { - log.Warning("Password input is not correct") - cc.CustomAbort(http.StatusForbidden, "old_password_is_not_correct") - } - - password := cc.GetString("password") - if password != "" { - updateUser := models.User{UserID: sessionUserID.(int), Password: password, Salt: user.Salt} - err = dao.ChangeUserPassword(updateUser, oldPassword) - if err != nil { - log.Errorf("Error occurred in ChangeUserPassword: %v", err) - cc.CustomAbort(http.StatusInternalServerError, "Internal error.") - } - } else { - cc.CustomAbort(http.StatusBadRequest, "please_input_new_password") - } -} - // ForgotPasswordController handles request to /forgotPassword type ForgotPasswordController struct { BaseController diff --git a/dao/accesslog.go b/dao/accesslog.go index 721ac92cd..dbf447353 100644 --- a/dao/accesslog.go +++ b/dao/accesslog.go @@ -19,6 +19,7 @@ import ( "strings" "github.com/vmware/harbor/models" + "github.com/vmware/harbor/utils/log" "github.com/astaxie/beego/orm" ) @@ -27,14 +28,14 @@ import ( func AddAccessLog(accessLog models.AccessLog) error { o := orm.NewOrm() p, err := o.Raw(`insert into access_log - (user_id, project_id, repo_name, guid, operation, op_time) - values (?, ?, ?, ?, ?, now())`).Prepare() + (user_id, project_id, repo_name, repo_tag, guid, operation, op_time) + values (?, ?, ?, ?, ?, ?, now())`).Prepare() if err != nil { return err } defer p.Close() - _, err = p.Exec(accessLog.UserID, accessLog.ProjectID, accessLog.RepoName, accessLog.GUID, accessLog.Operation) + _, err = p.Exec(accessLog.UserID, accessLog.ProjectID, accessLog.RepoName, accessLog.RepoTag, accessLog.GUID, accessLog.Operation) return err } @@ -43,7 +44,7 @@ func AddAccessLog(accessLog models.AccessLog) error { func GetAccessLogs(accessLog models.AccessLog) ([]models.AccessLog, error) { o := orm.NewOrm() - sql := `select a.log_id, u.username, a.repo_name, a.operation, a.op_time + sql := `select a.log_id, u.username, a.repo_name, a.repo_tag, a.operation, a.op_time from access_log a left join user u on a.user_id = u.user_id where a.project_id = ? ` queryParam := make([]interface{}, 1) @@ -96,12 +97,15 @@ func GetAccessLogs(accessLog models.AccessLog) ([]models.AccessLog, error) { } // AccessLog ... -func AccessLog(username, projectName, repoName, action string) error { +func AccessLog(username, projectName, repoName, repoTag, action string) error { o := orm.NewOrm() - sql := "insert into access_log (user_id, project_id, repo_name, operation, op_time) " + + sql := "insert into access_log (user_id, project_id, repo_name, repo_tag, operation, op_time) " + "select (select user_id as user_id from user where username=?), " + - "(select project_id as project_id from project where name=?), ?, ?, now() " - _, err := o.Raw(sql, username, projectName, repoName, action).Exec() + "(select project_id as project_id from project where name=?), ?, ?, ?, now() " + _, err := o.Raw(sql, username, projectName, repoName, repoTag, action).Exec() + if err != nil { + log.Errorf("error in AccessLog: %v ", err) + } return err } diff --git a/dao/base.go b/dao/base.go index 721708fc7..fe1c5a0ed 100644 --- a/dao/base.go +++ b/dao/base.go @@ -77,7 +77,7 @@ func InitDB() { var err error var c net.Conn for { - c, err = net.Dial("tcp", addr+":"+port) + c, err = net.DialTimeout("tcp", addr+":"+port, 20*time.Second) if err == nil { c.Close() ch <- 1 diff --git a/dao/dao_test.go b/dao/dao_test.go index 67ccef833..da0ad6993 100644 --- a/dao/dao_test.go +++ b/dao/dao_test.go @@ -372,7 +372,7 @@ func TestAddProject(t *testing.T) { OwnerName: currentUser.Username, } - err := AddProject(project) + _, err := AddProject(project) if err != nil { t.Errorf("Error occurred in AddProject: %v", err) } diff --git a/dao/project.go b/dao/project.go index d2a611e9f..9eba4f78e 100644 --- a/dao/project.go +++ b/dao/project.go @@ -29,41 +29,41 @@ import ( //TODO:transaction, return err // AddProject adds a project to the database along with project roles information and access log records. -func AddProject(project models.Project) error { +func AddProject(project models.Project) (int64, error) { if isIllegalLength(project.Name, 4, 30) { - return errors.New("project name is illegal in length. (greater than 4 or less than 30)") + return 0, errors.New("project name is illegal in length. (greater than 4 or less than 30)") } if isContainIllegalChar(project.Name, []string{"~", "-", "$", "\\", "[", "]", "{", "}", "(", ")", "&", "^", "%", "*", "<", ">", "\"", "'", "/", "?", "@"}) { - return errors.New("project name contains illegal characters") + return 0, errors.New("project name contains illegal characters") } o := orm.NewOrm() p, err := o.Raw("insert into project (owner_id, name, creation_time, update_time, deleted, public) values (?, ?, ?, ?, ?, ?)").Prepare() if err != nil { - return err + return 0, err } now := time.Now() r, err := p.Exec(project.OwnerID, project.Name, now, now, project.Deleted, project.Public) if err != nil { - return err + return 0, err } projectID, err := r.LastInsertId() if err != nil { - return err + return 0, err } if err = AddProjectMember(projectID, project.OwnerID, models.PROJECTADMIN); err != nil { - return err + return projectID, err } - accessLog := models.AccessLog{UserID: project.OwnerID, ProjectID: projectID, RepoName: project.Name + "/", GUID: "N/A", Operation: "create", OpTime: time.Now()} + accessLog := models.AccessLog{UserID: project.OwnerID, ProjectID: projectID, RepoName: project.Name + "/", RepoTag: "N/A", GUID: "N/A", Operation: "create", OpTime: time.Now()} err = AddAccessLog(accessLog) - return err + return projectID, err } // IsProjectPublic ... diff --git a/dao/register.go b/dao/register.go index f3e66cb1a..6f5351504 100644 --- a/dao/register.go +++ b/dao/register.go @@ -74,16 +74,13 @@ func validate(user models.User) error { return errors.New("Username already exists.") } - if m, _ := regexp.MatchString(`^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$`, user.Email); !m { - return errors.New("Email with illegal format.") - } - - if isIllegalLength(user.Email, 0, -1) { - return errors.New("Email cannot empty.") - } - - if exist, _ := UserExists(models.User{Email: user.Email}, "email"); exist { - return errors.New("Email already exists.") + if len(user.Email) > 0 { + if m, _ := regexp.MatchString(`^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$`, user.Email); !m { + return errors.New("Email with illegal format.") + } + if exist, _ := UserExists(models.User{Email: user.Email}, "email"); exist { + return errors.New("Email already exists.") + } } if isIllegalLength(user.Realname, 0, 20) { diff --git a/docs/configure_https.md b/docs/configure_https.md index 003fcfd6a..7eed8db8b 100644 --- a/docs/configure_https.md +++ b/docs/configure_https.md @@ -124,3 +124,4 @@ After setting up HTTPS for Harbor, you can verify it by the follow steps: cp yourdomain.com.crt /etc/pki/ca-trust/source/anchors/reg.yourdomain.com.crt update-ca-trust ``` + \ No newline at end of file diff --git a/docs/configure_swagger.md b/docs/configure_swagger.md index 5350975c2..80acee716 100644 --- a/docs/configure_swagger.md +++ b/docs/configure_swagger.md @@ -22,6 +22,10 @@ From time to time, you may need to mannually test Harbor REST API. You can deplo ```sh vi prepare-swagger.sh ``` +* Change the SCHEME to the protocol scheme of your Harbor server. +```sh + SCHEME= +``` * Change the SERVER_IP to the IP address of your Harbor server. ```sh SERVER_ID= diff --git a/docs/customize_token_service.md b/docs/customize_token_service.md new file mode 100644 index 000000000..ca358b2ae --- /dev/null +++ b/docs/customize_token_service.md @@ -0,0 +1,60 @@ +#Customize Harbor token service with your key and certificate + +Harbor requires Docker client to access the Harbor registry with a token. The procedure to generate a token is like [Docker Registry v2 authentication](https://github.com/docker/distribution/blob/master/docs/spec/auth/token.md). Firstly, you should make a request to the token service for a token. The token is signed by the private key. After that, you make a new request with the token to the Harbor registry, Harbor registry will verify the token with the public key in the rootcert bundle. Then Harbor registry will authorize the Docker client to push/pull images. + +By default, Harbor uses default private key and certificate in authentication. Also, you can customize your configuration with your own key and certificate with the following steps: + +1.If you already have a certificate, go to step 3. + +2.If not, you can generate a root certificate using openSSL with following commands: + +**1)Generate a private key:** + +```sh + $ openssl genrsa -out private_key.pem 4096 +``` + +**2)Generate a certificate:** +```sh + $ openssl req -new -x509 -key private_key.pem -out root.crt -days 3650 +``` +You are about to be asked to enter information that will be incorporated into your certificate request. +What you are about to enter is what is called a Distinguished Name or a DN. +There are quite a few fields but you can leave some blank +For some fields there will be a default value, +If you enter '.', the field will be left blank. Following are what you're asked to enter. + +Country Name (2 letter code) [AU]: + +State or Province Name (full name) [Some-State]: + +Locality Name (eg, city) []: + +Organization Name (eg, company) [Internet Widgits Pty Ltd]: + +Organizational Unit Name (eg, section) []: + +Common Name (eg, server FQDN or YOUR name) []: + +Email Address []: + +After you execute these two commands, you will see private_key.pem and root.crt in the **current directory**, just type "ls", you'll see them. + +3.Refer to [Installation Guide](https://github.com/vmware/harbor/blob/master/docs/installation_guide.md) to install Harbor, After you execute ./prepare, Harbor generates several config files. We need to replace the original private key and certificate with your own key and certificate. + +4.Replace the default key and certificate. Assume that you key and certificate are in the directory /root/cert, following are what you should do: + +``` +$ cd config/ui +$ cp /root/cert/private_key.pem private_key.pem +$ cp /root/cert/root.crt ../registry/root.crt +``` + +5.After these, go back to the Deploy directory, you can start Harbor using following command: +``` + $ docker-compose up -d +``` + +6.Then you can push/pull images to see if your own certificate works. Please refer [User Guide](https://github.com/vmware/harbor/blob/master/docs/user_guide.md) for more info. + + diff --git a/docs/image_pulling_chinese_user.md b/docs/image_pulling_chinese_user.md new file mode 100644 index 000000000..f16c7a4b7 --- /dev/null +++ b/docs/image_pulling_chinese_user.md @@ -0,0 +1,11 @@ +### A faster way to pull images for Chinese Harbor users +By default, Harbor not only build images according to Dockerfile but also pull images from Docker Hub. For the reason we all know, it is difficult for Chinese Harbor users to pull images from the Docker Hub. We put images on daocloud.io platform, we'll put images on other platforms later. If you have difficulty to pull images from Docker Hub, or you think it wastes too much time to build images. We recommend you to use the following way to accelerate the pulling procedure(make sure you're in the harbor diectory): +``` +$ cd contrib +$ cp docker-compose.yml.daocloud ../Deploy +$ cd ../Deploy +$ mv docker-compose.yml docker-compose.yml.bak +$ mv docker-compose.yml.daocloud docker-compose.yml +$ docker-compose up -d +``` +Then you'll see docker pulling imges faster than before. diff --git a/docs/installation_guide.md b/docs/installation_guide.md index 56d0c5152..f6f6691ea 100644 --- a/docs/installation_guide.md +++ b/docs/installation_guide.md @@ -29,12 +29,13 @@ At minimum, you need to change the **hostname** attribute in **harbor.cfg**. The **hostname**: The hostname for a user to access the user interface and the registry service. It should be the IP address or the fully qualified domain name (FQDN) of your target machine, for example 192.168.1.10 or reg.yourdomain.com . Do NOT use localhost or 127.0.0.1 for the hostname because the registry service needs to be accessed by external clients. **ui_url_protocol**: The protocol for accessing the user interface and the token/notification service, by default it is http. To set up the https protocol, refer to [Configuring Harbor with HTTPS Access](configure_https.md). -**Email settings**: the following 5 attributes are used to send an email to reset a user's password, they are not mandatory unless the password reset function is needed in Harbor. +**Email settings**: the following 6 attributes are used to send an email to reset a user's password, they are not mandatory unless the password reset function is needed in Harbor. By default SSL connection is not enabled, if your smtp server(such as exmail.qq.com) requires SSL connection and doesn't support STARTTLS, then you should enable it by set **email_ssl = true**. * email_server = smtp.mydomain.com * email_server_port = 25 * email_username = sample_admin@mydomain.com * email_password = abc * email_from = admin +* email_ssl = false **harbor_admin_password**: The password for the administrator of Harbor, by default the password is Harbor12345, the user name is admin. **auth_mode**: The authentication mode of Harbor. By default it is *db_auth*, i.e. the credentials are stored in a database. Please set it to *ldap_auth* if you want to verify user's credentials against an LDAP server. @@ -203,4 +204,10 @@ $ rm -r /data/registry [Docker Compose command-line reference](https://docs.docker.com/compose/reference/) describes the usage information for the docker-compose subcommands. ### Persistent data and log files -By default, the data of database and image files in the registry are persisted in the directory **/data/** of the target machine. When Harbor's containers are removed and recreated, the data remain unchanged. Harbor leverages rsyslog to collect the logs of each container, by default the log files are stored in the directory **/var/log/harbor/** on Harbor's host. +By default, the data of database and image files in the registry are persisted in the directory **/data/** of the target machine. When Harbor's containers are removed and recreated, the data remain unchanged. Harbor leverages rsyslog to collect the logs of each container, by default the log files are stored in the directory **/var/log/harbor/** on Harbor's host. + +##Troubleshooting +1.When setting up Harbor behind another nginx proxy or elastic load balancing, remove the below line if the proxy already has similar settings. Be sure to remove the line under these 3 sections: "location /", "location /v2/" and "location /service/". +``` +proxy_set_header X-Forwarded-Proto $scheme; +``` diff --git a/kubernetes_deployment.md b/docs/kubernetes_deployment.md similarity index 100% rename from kubernetes_deployment.md rename to docs/kubernetes_deployment.md diff --git a/docs/prepare-swagger.sh b/docs/prepare-swagger.sh index f0fe84bc2..dee9b2e9b 100755 --- a/docs/prepare-swagger.sh +++ b/docs/prepare-swagger.sh @@ -1,5 +1,6 @@ #!/bin/bash -SERVER_IP=10.117.170.65 +SCHEME=http +SERVER_IP=reg.mydomain.com set -e echo "Doing some clean up..." rm -f *.tar.gz @@ -8,9 +9,10 @@ wget https://github.com/swagger-api/swagger-ui/archive/v2.1.4.tar.gz -O swagger. echo "Untarring Swagger UI package to the static file path..." tar -C ../static/vendors -zxf swagger.tar.gz swagger-ui-2.1.4/dist echo "Executing some processes..." -sed -i 's/http:\/\/petstore\.swagger\.io\/v2\/swagger\.json/http:\/\/'$SERVER_IP'\/static\/resources\/yaml\/swagger\.yaml/g' \ +sed -i 's/http:\/\/petstore\.swagger\.io\/v2\/swagger\.json/'$SCHEME':\/\/'$SERVER_IP'\/static\/resources\/yaml\/swagger\.yaml/g' \ ../static/vendors/swagger-ui-2.1.4/dist/index.html mkdir -p ../static/resources/yaml cp swagger.yaml ../static/resources/yaml sed -i 's/host: localhost/host: '$SERVER_IP'/g' ../static/resources/yaml/swagger.yaml +sed -i 's/ \- http$/ \- '$SCHEME'/g' ../static/resources/yaml/swagger.yaml echo "Finish preparation for the Swagger UI." diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 170f0bd25..404a0b431 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -473,6 +473,36 @@ paths: description: Project ID does not exist. 500: description: Unexpected internal errors. + delete: + summary: Delete a repository or a tag in a repository. + description: | + This endpoint let user delete repositories and tags with repo name and tag. + parameters: + - name: repo_name + in: query + type: string + format: string + required: true + description: The name of repository which will be deleted. + - name: tag + in: query + type: string + format: string + required: false + description: Tag of a repository. + tags: + - Products + responses: + 200: + description: Delete repository or tag successfully. + 400: + description: Invalid repo_name. + 401: + description: Unauthorized. + 404: + description: Repository or tag not found. + 403: + description: Forbidden. /repositories/tags: get: summary: Get tags of a relevant repository. diff --git a/models/accesslog.go b/models/accesslog.go index 150f3b588..3111e3a44 100644 --- a/models/accesslog.go +++ b/models/accesslog.go @@ -25,6 +25,7 @@ type AccessLog struct { UserID int `orm:"column(user_id)" json:"UserId"` ProjectID int64 `orm:"column(project_id)" json:"ProjectId"` RepoName string `orm:"column(repo_name)"` + RepoTag string `orm:"column(repo_tag)"` GUID string `orm:"column(GUID)" json:"Guid"` Operation string `orm:"column(operation)"` OpTime time.Time `orm:"column(op_time)"` diff --git a/models/notification.go b/models/notification.go index 0f608e2c6..3f30ae7e4 100644 --- a/models/notification.go +++ b/models/notification.go @@ -40,6 +40,7 @@ type Target struct { Digest string Repository string URL string `json:"Url"` + Tag string } // Actor holds information about actor. diff --git a/models/repo.go b/models/repo.go index 6b8de742b..f224c902e 100644 --- a/models/repo.go +++ b/models/repo.go @@ -42,3 +42,21 @@ type Tag struct { Version string `json:"version"` ImageID string `json:"image_id"` } + +// Manifest ... +type Manifest struct { + SchemaVersion int `json:"schemaVersion"` + Name string `json:"name"` + Tag string `json:"tag"` + Architecture string `json:"architecture"` + FsLayers []blobSumItem `json:"fsLayers"` + History []histroyItem `json:"history"` +} + +type histroyItem struct { + V1Compatibility string `json:"v1Compatibility"` +} + +type blobSumItem struct { + BlobSum string `json:"blobSum"` +} diff --git a/service/notification.go b/service/notification.go index beab28778..913d45b04 100644 --- a/service/notification.go +++ b/service/notification.go @@ -46,7 +46,7 @@ func (n *NotificationHandler) Post() { log.Errorf("error while decoding json: %v", err) return } - var username, action, repo, project string + var username, action, repo, project, repoTag string var matched bool for _, e := range notification.Events { matched, err = regexp.MatchString(manifestPattern, e.Target.MediaType) @@ -58,13 +58,16 @@ func (n *NotificationHandler) Post() { username = e.Actor.Name action = e.Action repo = e.Target.Repository + repoTag = e.Target.Tag + log.Debugf("repo tag is : %v ", repoTag) + if strings.Contains(repo, "/") { project = repo[0:strings.LastIndex(repo, "/")] } if username == "" { username = "anonymous" } - go dao.AccessLog(username, project, repo, action) + go dao.AccessLog(username, project, repo, repoTag, action) if action == "push" { go func() { err2 := svc_utils.RefreshCatalogCache() diff --git a/static/i18n/locale_de-DE.ini b/static/i18n/locale_de-DE.ini new file mode 100644 index 000000000..67a926c72 --- /dev/null +++ b/static/i18n/locale_de-DE.ini @@ -0,0 +1,86 @@ +page_title_index = Harbor +page_title_sign_in = Anmelden - Harbor +page_title_project = Projekt - Harbor +page_title_item_details = Details - Harbor +page_title_registration = Registrieren - Harbor +page_title_add_user = Benutzer anlegen - Harbor +page_title_forgot_password = Passwort vergessen - Harbor +title_forgot_password = Passwort vergessen +page_title_reset_password = Passwort zurücksetzen - Harbor +title_reset_password = Passwort zurücksetzen +page_title_change_password = Passwort ändern - Harbor +title_change_password = Passwort ändern +page_title_search = Suche - Harbor +sign_in = Anmelden +sign_up = Registrieren +add_user = Benutzer anlegen +log_out = Abmelden +search_placeholder = Projekte oder Repositories +change_password = Passwort ändern +username_email = Benutzername/E-Mail +password = Passwort +forgot_password = Passwort vergessen +welcome = Willkommen +my_projects = Meine Projekte +public_projects = Öffentliche Projekte +admin_options = Admin Optionen +project_name = Projektname +creation_time = Erstellungsdatum +publicity = Öffentlich +add_project = Projekt hinzufügen +check_for_publicity = öffentliches Projekt +button_save = Speichern +button_cancel = Abbrechen +button_submit = Absenden +username = Benutzername +email = E-Mail +system_admin = System Admininistrator +dlg_button_ok = OK +dlg_button_cancel = Abbrechen +registration = Registrieren +username_description = Dies wird Ihr Benutzername sein. +email_description = Die E-Mail Adresse wird für das Zurücksetzen des Passworts genutzt. +full_name = Sollständiger Name +full_name_description = Vor- und Nachname. +password_description = Mindestens sieben Zeichen bestehend aus einem Kleinbuchstaben, einem Großbuchstaben und einer Zahl +confirm_password = Passwort bestätigen +note_to_the_admin = Kommentar +old_password = Altes Passwort +new_password = Neues Passwort +forgot_password_description = Bitte gebe die E-Mail Adresse ein, die du zur Registrierung verwendet hast. Ein Link zur Wiederherstellung wird dir per E-Mail an diese Adresse geschickt. + +projects = Projekte +repositories = Repositories +search = Suche +home = Home +project = Projekt +owner = Besitzer +repo = Repositories +user = Benutzer +logs = Logs +repo_name = Repository +add_members = Benutzer hinzufügen +operation = Aktion +advance = erweiterte Suche +all = Alle +others = Andere +start_date = Start Datum +end_date = End Datum +timestamp = Zeitstempel +role = Rolle +reset_email_hint = Bitte klicke auf diesen Link um dein Passwort zurückzusetzen +reset_email_subject = Passwort zurücksetzen +language = Deutsch +language_en-US = English +language_zh-CN = 中文 +language_de-DE = Deutsch +copyright = Copyright +all_rights_reserved = Alle Rechte vorbehalten. +index_desc = Project Harbor ist ein zuverlässiger Enterprise-Class Registry Server. Unternehmen können ihren eigenen Registry Server aufsetzen um die Produktivität und Sicherheit zu erhöhen. Project Harbor kann für Entwicklungs- wie auch Produktiv-Umgebungen genutzt werden. +index_desc_0 = Vorteile: +index_desc_1 = 1. Sicherheit: Halten Sie ihr geistiges Eigentum innerhalb der Organisation. +index_desc_2 = 2. Effizienz: Ein privater Registry Server innerhalb des Netzwerks ihrer Organisation kann den Traffic zu öffentlichen Services im Internet signifikant reduzieren. +index_desc_3 = 3. Zugriffskontrolle: RBAC (Role Based Access Control) wird zur Verfügung gestellt. Benutzerverwaltung kann mit bestehenden Identitätsservices wie AD/LDAP integriert werden. +index_desc_4 = 4. Audit: Jeglicher Zugriff auf die Registry wird protokolliert und kann für ein Audit verwendet werden. +index_desc_5 = 5. GUI: Benutzerfreundliche Verwaltung über eine einzige Management-Konsole +index_title = Ein Enterprise-Class Registry Server diff --git a/static/i18n/locale_en-US.ini b/static/i18n/locale_en-US.ini index 981213cf0..30f7009e0 100644 --- a/static/i18n/locale_en-US.ini +++ b/static/i18n/locale_en-US.ini @@ -59,6 +59,7 @@ repo = Repositories user = Users logs = Logs repo_name = Repository Name +repo_tag = Tag add_members = Add Members operation = Operation advance = Advanced Search @@ -73,6 +74,7 @@ reset_email_subject = Reset your password language = English language_en-US = English language_zh-CN = 中文 +language_de-DE = Deutsch copyright = Copyright all_rights_reserved = All rights reserved. index_desc = Project Harbor is to build an enterprise-class, reliable registry server. Enterprises can set up a private registry server in their own environment to improve productivity as well as security. Project Harbor can be used in both development and production environment. diff --git a/static/i18n/locale_messages.js b/static/i18n/locale_messages.js index 93fdcaabb..d2eb902a4 100644 --- a/static/i18n/locale_messages.js +++ b/static/i18n/locale_messages.js @@ -15,254 +15,317 @@ var global_messages = { "username_is_required" : { "en-US": "Username is required.", - "zh-CN": "用户名为必填项。" + "zh-CN": "用户名为必填项。", + "de-DE": "Benutzername erforderlich." }, "username_has_been_taken" : { - "en-US": "Username has been taken.", - "zh-CN": "用户名已被占用。" + "en-US": "Username has been taken.", + "zh-CN": "用户名已被占用。", + "de-DE": "Benutzername bereits vergeben." }, "username_is_too_long" : { "en-US": "Username is too long. (maximum 20 characters)", - "zh-CN": "用户名长度超出限制。(最长为20个字符)" + "zh-CN": "用户名长度超出限制。(最长为20个字符)", + "de-DE": "Benutzername ist zu lang. (maximal 20 Zeichen)" }, "username_contains_illegal_chars": { "en-US": "Username contains illegal character(s).", - "zh-CN": "用户名包含不合法的字符。" + "zh-CN": "用户名包含不合法的字符。", + "de-DE": "Benutzername enthält ungültige Zeichen." }, "email_is_required" : { "en-US": "Email is required.", - "zh-CN": "邮箱为必填项。" + "zh-CN": "邮箱为必填项。", + "de-DE": "E-Mail Adresse erforderlich." }, "email_contains_illegal_chars" : { "en-US": "Email contains illegal character(s).", - "zh-CN": "邮箱包含不合法的字符。" + "zh-CN": "邮箱包含不合法的字符。", + "de-DE": "E-Mail Adresse enthält ungültige Zeichen." }, "email_has_been_taken" : { "en-US": "Email has been taken.", - "zh-CN": "邮箱已被占用。" + "zh-CN": "邮箱已被占用。", + "de-DE": "E-Mail Adresse wird bereits verwendet." }, "email_content_illegal" : { "en-US": "Email format is illegal.", - "zh-CN": "邮箱格式不合法。" + "zh-CN": "邮箱格式不合法。", + "de-DE": "Format der E-Mail Adresse ist ungültig." }, "email_does_not_exist" : { "en-US": "Email does not exist.", - "zh-CN": "邮箱不存在。" + "zh-CN": "邮箱不存在。", + "de-DE": "E-Mail Adresse existiert nicht." }, "realname_is_required" : { "en-US": "Full name is required.", - "zh-CN": "全名为必填项。" + "zh-CN": "全名为必填项。", + "de-DE": "Vollständiger Name erforderlich." }, "realname_is_too_long" : { "en-US": "Full name is too long. (maximum 20 characters)", - "zh-CN": "全名长度超出限制。(最长为20个字符)" + "zh-CN": "全名长度超出限制。(最长为20个字符)", + "de-DE": "Vollständiger Name zu lang. (maximal 20 Zeichen)" }, "realname_contains_illegal_chars" : { "en-US": "Full name contains illegal character(s).", - "zh-CN": "全名包含不合法的字符。" + "zh-CN": "全名包含不合法的字符。", + "de-DE": "Vollständiger Name enthält ungültige Zeichen." }, "password_is_required" : { "en-US": "Password is required.", - "zh-CN": "密码为必填项。" + "zh-CN": "密码为必填项。", + "de-DE": "Passwort erforderlich." }, "password_is_invalid" : { "en-US": "Password is invalid. At least 7 characters with 1 lowercase letter, 1 capital letter and 1 numeric character.", - "zh-CN": "密码无效。至少输入 7个字符且包含 1个小写字母,1个大写字母和 1个数字。" + "zh-CN": "密码无效。至少输入 7个字符且包含 1个小写字母,1个大写字母和 1个数字。", + "de-DE": "Passwort ungültig. Mindestens sieben Zeichen bestehend aus einem Kleinbuchstaben, einem Großbuchstaben und einer Zahl" }, "password_is_too_long" : { "en-US": "Password is too long. (maximum 20 characters)", - "zh-CN": "密码长度超出限制。(最长为20个字符)" + "zh-CN": "密码长度超出限制。(最长为20个字符)", + "de-DE": "Passwort zu lang. (maximal 20 Zeichen)" }, "password_does_not_match" : { "en-US": "Passwords do not match.", - "zh-CN": "两次密码输入不一致。" + "zh-CN": "两次密码输入不一致。", + "de-DE": "Passwörter stimmen nicht überein." }, "comment_is_too_long" : { "en-US": "Comment is too long. (maximum 20 characters)", - "zh-CN": "备注长度超出限制。(最长为20个字符)" + "zh-CN": "备注长度超出限制。(最长为20个字符)", + "de-DE": "Kommentar zu lang. (maximal 20 Zeichen)" }, "comment_contains_illegal_chars" : { "en-US": "Comment contains illegal character(s).", - "zh-CN": "备注包含不合法的字符。" + "zh-CN": "备注包含不合法的字符。", + "de-DE": "Kommentar enthält ungültige Zeichen." }, "project_name_is_required" : { "en-US": "Project name is required.", - "zh-CN": "项目名称为必填项。" + "zh-CN": "项目名称为必填项。", + "de-DE": "Projektname erforderlich." }, "project_name_is_too_short" : { "en-US": "Project name is too short. (minimum 4 characters)", - "zh-CN": "项目名称至少要求 4个字符。" + "zh-CN": "项目名称至少要求 4个字符。", + "de-DE": "Projektname zu kurz. (mindestens 4 Zeichen)" }, "project_name_is_too_long" : { "en-US": "Project name is too long. (maximum 30 characters)", - "zh-CN": "项目名称长度超出限制。(最长为30个字符)" + "zh-CN": "项目名称长度超出限制。(最长为30个字符)", + "de-DE": "Projektname zu lang. (maximal 30 Zeichen)" }, "project_name_contains_illegal_chars" : { "en-US": "Project name contains illegal character(s).", - "zh-CN": "项目名称包含不合法的字符。" + "zh-CN": "项目名称包含不合法的字符。", + "de-DE": "Projektname enthält ungültige Zeichen." }, "project_exists" : { "en-US": "Project exists.", - "zh-CN": "项目已存在。" + "zh-CN": "项目已存在。", + "de-DE": "Projekt existiert bereits." }, "delete_user" : { "en-US": "Delete User", - "zh-CN": "删除用户" + "zh-CN": "删除用户", + "de-DE": "Benutzer löschen" }, "are_you_sure_to_delete_user" : { "en-US": "Are you sure to delete ", - "zh-CN": "确认要删除用户 " + "zh-CN": "确认要删除用户 ", + "de-DE": "Sind Sie sich sicher, dass Sie folgenden Benutzer löschen möchten: " }, "input_your_username_and_password" : { "en-US": "Please input your username and password.", - "zh-CN": "请输入用户名和密码。" + "zh-CN": "请输入用户名和密码。", + "de-DE": "Bitte geben Sie ihr Benutzername und Passwort ein." }, "check_your_username_or_password" : { "en-US": "Please check your username or password.", - "zh-CN": "请输入正确的用户名或密码。" + "zh-CN": "请输入正确的用户名或密码。", + "de-DE": "Bitte überprüfen Sie ihren Benutzernamen und Passwort." }, "title_login_failed" : { "en-US": "Login Failed", - "zh-CN": "登录失败" + "zh-CN": "登录失败", + "de-DE": "Anmeldung fehlgeschlagen" }, "title_change_password" : { "en-US": "Change Password", - "zh-CN": "修改密码" + "zh-CN": "修改密码", + "de-DE": "Passwort ändern" }, "change_password_successfully" : { "en-US": "Password changed successfully.", - "zh-CN": "密码已修改。" + "zh-CN": "密码已修改。", + "de-DE": "Passwort erfolgreich geändert." }, "title_forgot_password" : { - "en-US": "Forgot Password", - "zh-CN": "忘记密码" + "en-US": "Forgot Password", + "zh-CN": "忘记密码", + "de-DE": "Passwort vergessen" }, "email_has_been_sent" : { "en-US": "Email for resetting password has been sent.", - "zh-CN": "重置密码邮件已发送。" + "zh-CN": "重置密码邮件已发送。", + "de-DE": "Eine E-Mail mit einem Wiederherstellungslink wurde an Sie gesendet." }, "send_email_failed" : { "en-US": "Failed to send Email for resetting password.", - "zh-CN": "重置密码邮件发送失败。" + "zh-CN": "重置密码邮件发送失败。", + "de-DE": "Fehler beim Senden der Wiederherstellungs-E-Mail." }, "please_login_first" : { "en-US": "Please login first.", - "zh-CN": "请先登录。" + "zh-CN": "请先登录。", + "de-DE": "Bitte melden Sie sich zuerst an." }, "old_password_is_not_correct" : { "en-US": "Old password is not correct.", - "zh-CN": "原密码输入不正确。" + "zh-CN": "原密码输入不正确。", + "de-DE": "Altes Passwort ist nicht korrekt." }, "please_input_new_password" : { "en-US": "Please input new password.", - "zh-CN": "请输入新密码。" + "zh-CN": "请输入新密码。", + "de-DE": "Bitte geben Sie ihr neues Passwort ein." }, "invalid_reset_url": { "en-US": "Invalid URL for resetting password.", - "zh-CN": "无效密码重置链接。" + "zh-CN": "无效密码重置链接。", + "de-DE": "Ungültige URL zum Passwort wiederherstellen." }, "reset_password_successfully" : { "en-US": "Reset password successfully.", - "zh-CN": "密码重置成功。" + "zh-CN": "密码重置成功。", + "de-DE": "Passwort erfolgreich wiederhergestellt." }, "internal_error": { "en-US": "Internal error.", - "zh-CN": "内部错误,请联系系统管理员。" + "zh-CN": "内部错误,请联系系统管理员。", + "de-DE": "Interner Fehler." }, "title_reset_password" : { "en-US": "Reset Password", - "zh-CN": "重置密码" + "zh-CN": "重置密码", + "de-DE": "Passwort zurücksetzen" }, "title_sign_up" : { "en-US": "Sign Up", - "zh-CN": "注册" + "zh-CN": "注册", + "de-DE": "Registrieren" }, "title_add_user": { - "en-US": "Add User", - "zh-CN": "新增用户" + "en-US": "Add User", + "zh-CN": "新增用户", + "de-DE": "Benutzer hinzufügen" }, "registered_successfully": { "en-US": "Signed up successfully.", - "zh-CN": "注册成功。" + "zh-CN": "注册成功。", + "de-DE": "Erfolgreich registriert." }, "registered_failed" : { "en-US": "Failed to sign up.", - "zh-CN": "注册失败。" + "zh-CN": "注册失败。", + "de-DE": "Registrierung fehlgeschlagen." }, "added_user_successfully": { - "en-US": "Added user successfully.", - "zh-CN": "新增用户成功。" + "en-US": "Added user successfully.", + "zh-CN": "新增用户成功。", + "de-DE": "Benutzer erfolgreich erstellt." }, "added_user_failed": { - "en-US": "Added user failed.", - "zh-CN": "新增用户失败。" + "en-US": "Adding user failed.", + "zh-CN": "新增用户失败。", + "de-DE": "Benutzer erstellen fehlgeschlagen." }, - "projects" : { + "projects": { "en-US": "Projects", - "zh-CN": "项目" + "zh-CN": "项目", + "de-DE": "Projekte" }, "repositories" : { "en-US": "Repositories", - "zh-CN": "镜像仓库" + "zh-CN": "镜像仓库", + "de-DE": "Repositories" }, - "no_repo_exists" :{ - "en-US": "No repositories found, please use 'docker push' to upload images.", - "zh-CN": "未发现镜像,请用‘docker push’命令上传镜像。" + "no_repo_exists" : { + "en-US": "No repositories found, please use 'docker push' to upload images.", + "zh-CN": "未发现镜像,请用‘docker push’命令上传镜像。", + "de-DE": "Keine Repositories gefunden, bitte benutzen Sie 'docker push' um ein Image hochzuladen." }, "tag" : { - "en-US": "Tag", - "zh-CN": "标签" + "en-US": "Tag", + "zh-CN": "标签", + "de-DE": "Tag" }, "pull_command": { "en-US": "Pull Command", - "zh-CN": "Pull 命令" + "zh-CN": "Pull 命令", + "de-DE": "Pull Befehl" }, "image_details" : { "en-US": "Image Details", - "zh-CN": "镜像详细信息" + "zh-CN": "镜像详细信息", + "de-DE": "Image Details" }, "add_members" : { "en-US": "Add Member", - "zh-CN": "添加成员" + "zh-CN": "添加成员", + "de-DE": "Mitglied hinzufügen" }, "edit_members" : { - "en-US": "Edit Member", - "zh-CN": "编辑成员" + "en-US": "Edit Members", + "zh-CN": "编辑成员", + "de-DE": "Mitglieder bearbeiten" }, "add_member_failed" : { "en-US": "Adding Member Failed", - "zh-CN": "添加成员失败" + "zh-CN": "添加成员失败", + "de-DE": "Mitglied hinzufügen fehlgeschlagen" }, "please_input_username" : { "en-US": "Please input a username.", - "zh-CN": "请输入用户名。" + "zh-CN": "请输入用户名。", + "de-DE": "Bitte geben Sie einen Benutzernamen ein." }, "please_assign_a_role_to_user" : { "en-US": "Please assign a role to the user.", - "zh-CN": "请为用户分配角色。" + "zh-CN": "请为用户分配角色。", + "de-DE": "Bitte weisen Sie dem Benutzer eine Rolle zu." }, "user_id_exists" : { "en-US": "User is already a member.", - "zh-CN": "用户已经是成员。" + "zh-CN": "用户已经是成员。", + "de-DE": "Benutzer ist bereits Mitglied." }, "user_id_does_not_exist" : { "en-US": "User does not exist.", - "zh-CN": "不存在此用户。" + "zh-CN": "不存在此用户。", + "de-DE": "Benutzer existiert nicht." }, "insufficient_privileges" : { "en-US": "Insufficient privileges.", - "zh-CN": "权限不足。" + "zh-CN": "权限不足。", + "de-DE": "Unzureichende Berechtigungen." }, "operation_failed" : { "en-US": "Operation Failed", - "zh-CN": "操作失败" + "zh-CN": "操作失败", + "de-DE": "Befehl fehlgeschlagen" }, "button_on" : { - "en-US": "On", - "zh-CN": "打开" + "en-US": "On", + "zh-CN": "打开", + "de-DE": "An" }, "button_off" : { - "en-US": "Off", - "zh-CN": "关闭" + "en-US": "Off", + "zh-CN": "关闭", + "de-DE": "Aus" } -}; \ No newline at end of file +}; diff --git a/static/i18n/locale_zh-CN.ini b/static/i18n/locale_zh-CN.ini index 90ea4fc04..7f92b463f 100644 --- a/static/i18n/locale_zh-CN.ini +++ b/static/i18n/locale_zh-CN.ini @@ -59,6 +59,7 @@ repo = 镜像仓库 user = 用户 logs = 日志 repo_name = 镜像名称 +repo_tag = 镜像标签 add_members = 添加成员 operation = 操作 advance = 高级检索 @@ -73,6 +74,7 @@ reset_email_subject = 重置您的密码 language = 中文 language_en-US = English language_zh-CN = 中文 +language_de-DE = Deutsch copyright = 版权所有 all_rights_reserved = 保留所有权利。 index_desc = Harbor是可靠的企业级Registry服务器。企业用户可使用Harbor搭建私有容器Registry服务,提高生产效率和安全度,既可应用于生产环境,也可以在开发环境中使用。 @@ -82,4 +84,4 @@ index_desc_2 = 2. 效率: 搭建组织内部的私有容器Registry服务,可 index_desc_3 = 3. 访问控制: 提供基于角色的访问控制,可集成企业目前拥有的用户管理系统(如:AD/LDAP)。 index_desc_4 = 4. 审计: 所有访问Registry服务的操作均被记录,便于日后审计。 index_desc_5 = 5. 管理界面: 具有友好易用图形管理界面。 -index_title = 企业级 Registry 服务 \ No newline at end of file +index_title = 企业级 Registry 服务 diff --git a/static/resources/js/change-password.js b/static/resources/js/change-password.js index a09e298b3..4c8c2efdc 100644 --- a/static/resources/js/change-password.js +++ b/static/resources/js/change-password.js @@ -56,16 +56,18 @@ jQuery(function(){ validateOptions.Validate(function(){ var oldPassword = $("#OldPassword").val(); var password = $("#Password").val(); - $.ajax({ - "url": "/updatePassword", - "type": "post", - "data": {"old_password": oldPassword, "password" : password}, - "beforeSend": function(e){ + new AjaxUtil({ + url: "/api/users/current/password", + type: "put", + data: {"old_password": oldPassword, "new_password" : password}, + beforeSend: function(e){ unbindEnterKey(); $("h1").append(spinner.el); $("#btnSubmit").prop("disabled", true); }, - "success": function(data, status, xhr){ + complete: function(xhr, status){ + spinner.stop(); + $("#btnSubmit").prop("disabled", false); if(xhr && xhr.status == 200){ $("#dlgModal") .dialogModal({ @@ -77,22 +79,20 @@ jQuery(function(){ }); } }, - "error": function(jqXhr, status, error){ - $("#dlgModal") - .dialogModal({ - "title": i18n.getMessage("title_change_password"), - "content": i18n.getMessage(jqXhr.responseText), - "callback": function(){ - bindEnterKey(); - return; - } - }); - }, - "complete": function(){ - spinner.stop(); - $("#btnSubmit").prop("disabled", false); + error: function(jqXhr, status, error){ + if(jqXhr && jqXhr.responseText.length){ + $("#dlgModal") + .dialogModal({ + "title": i18n.getMessage("title_change_password"), + "content": i18n.getMessage(jqXhr.responseText), + "callback": function(){ + bindEnterKey(); + return; + } + }); + } } - }); + }).exec(); }); }); }); \ No newline at end of file diff --git a/static/resources/js/common.js b/static/resources/js/common.js index 850d1b9ab..3b63c7f0f 100644 --- a/static/resources/js/common.js +++ b/static/resources/js/common.js @@ -68,7 +68,8 @@ AjaxUtil.prototype.exec = function(){ var SUPPORT_LANGUAGES = { "en-US": "English", - "zh-CN": "Chinese" + "zh-CN": "Chinese", + "de-DE": "German" }; var DEFAULT_LANGUAGE = "en-US"; @@ -157,4 +158,4 @@ jQuery(function(){ } $(self).modal('show'); } -}); \ No newline at end of file +}); diff --git a/static/resources/js/item-detail.js b/static/resources/js/item-detail.js index 1b6ffa771..6f445f717 100644 --- a/static/resources/js/item-detail.js +++ b/static/resources/js/item-detail.js @@ -286,6 +286,7 @@ jQuery(function(){ '' + '' + e.Username + '' + '' + e.RepoName + '' + + '' + e.RepoTag + '' + '' + e.Operation + '' + '' + moment(new Date(e.OpTime)).format("YYYY-MM-DD HH:mm:ss") + '' + ''); diff --git a/static/resources/js/project.js b/static/resources/js/project.js index 69ab51a5f..97da2d6f9 100644 --- a/static/resources/js/project.js +++ b/static/resources/js/project.js @@ -161,8 +161,8 @@ jQuery(function(){ $("#tblUser tbody tr").remove(); $.each(data || [], function(i, e){ var row = '' + - '' + e.Username + '' + - '' + e.Email + ''; + '' + e.username + '' + + '' + e.email + ''; if(e.HasAdminRole == 1){ row += ''; } else { diff --git a/static/resources/js/register.js b/static/resources/js/register.js index 8f970946f..3fa2882b4 100644 --- a/static/resources/js/register.js +++ b/static/resources/js/register.js @@ -57,7 +57,7 @@ jQuery(function(){ }, complete: function(xhr, status){ $("#btnPageSignUp").prop("disabled", false); - if(xhr && xhr.status == 200){ + if(xhr && xhr.status == 201){ $("#dlgModal") .dialogModal({ "title": isAdmin == "true" ? i18n.getMessage("title_add_user") : i18n.getMessage("title_sign_up"), diff --git a/ui/router.go b/ui/router.go index fe3ee5d8f..401745410 100644 --- a/ui/router.go +++ b/ui/router.go @@ -36,7 +36,6 @@ func initRouters() { beego.Router("/userExists", &controllers.CommonController{}, "post:UserExists") beego.Router("/reset", &controllers.CommonController{}, "post:ResetPassword") beego.Router("/sendEmail", &controllers.CommonController{}, "get:SendEmail") - beego.Router("/updatePassword", &controllers.CommonController{}, "post:UpdatePassword") beego.Router("/", &controllers.IndexController{}) beego.Router("/signIn", &controllers.SignInController{}) @@ -58,6 +57,7 @@ func initRouters() { beego.Router("/api/projects/:id/logs/filter", &api.ProjectAPI{}, "post:FilterAccessLog") beego.Router("/api/users", &api.UserAPI{}) beego.Router("/api/users/?:id", &api.UserAPI{}) + beego.Router("/api/users/:id/password", &api.UserAPI{}, "put:ChangePassword") beego.Router("/api/repositories", &api.RepositoryAPI{}) beego.Router("/api/repositories/tags", &api.RepositoryAPI{}, "get:GetTags") beego.Router("/api/repositories/manifests", &api.RepositoryAPI{}, "get:GetManifests") diff --git a/utils/mail.go b/utils/mail.go index 8dca9f413..fc2843e2c 100644 --- a/utils/mail.go +++ b/utils/mail.go @@ -17,6 +17,8 @@ package utils import ( "bytes" + "crypto/tls" + "strings" "net/smtp" "text/template" @@ -39,6 +41,7 @@ type MailConfig struct { Port string Username string Password string + TLS bool } var mc MailConfig @@ -58,10 +61,66 @@ func (m Mail) SendMail() error { if err != nil { return err } - return smtp. - SendMail(mc.Host+":"+mc.Port, - smtp.PlainAuth(mc.Identity, mc.Username, mc.Password, mc.Host), - m.From, m.To, mailContent.Bytes()) + content := mailContent.Bytes() + + auth := smtp.PlainAuth(mc.Identity, mc.Username, mc.Password, mc.Host) + if mc.TLS { + err = sendMailWithTLS(m, auth, content) + } else { + err = sendMail(m, auth, content) + } + + return err +} + +func sendMail(m Mail, auth smtp.Auth, content []byte) error { + return smtp.SendMail(mc.Host+":"+mc.Port, auth, m.From, m.To, content) +} + +func sendMailWithTLS(m Mail, auth smtp.Auth, content []byte) error { + conn, err := tls.Dial("tcp", mc.Host+":"+mc.Port, nil) + if err != nil { + return err + } + + client, err := smtp.NewClient(conn, mc.Host) + if err != nil { + return err + } + defer client.Close() + + if ok, _ := client.Extension("AUTH"); ok { + if err = client.Auth(auth); err != nil { + return err + } + } + + if err = client.Mail(m.From); err != nil { + return err + } + + for _, to := range m.To { + if err = client.Rcpt(to); err != nil { + return err + } + } + + w, err := client.Data() + if err != nil { + return err + } + + _, err = w.Write(content) + if err != nil { + return err + } + + err = w.Close() + if err != nil { + return err + } + + return client.Quit() } func loadConfig() { @@ -69,11 +128,17 @@ func loadConfig() { if err != nil { panic(err) } + + var useTLS = false + if config["ssl"] != "" && strings.ToLower(config["ssl"]) == "true" { + useTLS = true + } mc = MailConfig{ Identity: "Mail Config", Host: config["host"], Port: config["port"], Username: config["username"], Password: config["password"], + TLS: useTLS, } } diff --git a/views/item-detail.tpl b/views/item-detail.tpl index a1ce83e96..e9cdb2753 100644 --- a/views/item-detail.tpl +++ b/views/item-detail.tpl @@ -159,10 +159,11 @@ - - - - + + + + + diff --git a/views/project.tpl b/views/project.tpl index af8ca3b96..fae2878d1 100644 --- a/views/project.tpl +++ b/views/project.tpl @@ -42,7 +42,7 @@ - + @@ -65,9 +65,9 @@
{{i18n .Lang "username"}}{{i18n .Lang "repo_name"}}{{i18n .Lang "operation"}}{{i18n .Lang "timestamp"}}{{i18n .Lang "username"}}{{i18n .Lang "repo_name"}}{{i18n .Lang "repo_tag"}}{{i18n .Lang "operation"}}{{i18n .Lang "timestamp"}}
{{i18n .Lang "project_name"}} {{i18n .Lang "creation_time"}}{{i18n .Lang "publicity"}}{{i18n .Lang "publicity"}}
- - - + + + diff --git a/views/segment/header-content.tpl b/views/segment/header-content.tpl index 6c88e0d25..464cd09aa 100644 --- a/views/segment/header-content.tpl +++ b/views/segment/header-content.tpl @@ -36,6 +36,7 @@ @@ -80,4 +81,4 @@ {{ end }} - \ No newline at end of file +
{{i18n .Lang "username"}}{{i18n .Lang "email"}}{{i18n .Lang "system_admin"}}{{i18n .Lang "username"}}{{i18n .Lang "email"}}{{i18n .Lang "system_admin"}}