diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 95a0822..e56432c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ GitBucket ========= -GitBucket is a Github clone by Scala, Easy to setup. +GitBucket is the easily installable Github clone written with Scala. The current version of GitBucket provides a basic features below: @@ -9,12 +9,12 @@ - Repository viewer (some advanced features are not implemented) - Wiki - Issues +- Activity timeline - User management (for Administrators) Following features are not implemented, but we will make them in the future release! - Fork and pull request -- Timeline - Search - Network graph - Statics @@ -32,8 +32,20 @@ The default administrator account is **root** and password is **root**. +To upgrade GitBucket, only replace gitbucket.war. + Release Notes -------- +### 1.3 - 18 Jul 2013 +- Batch updating for issues. +- Display assigned user on issue list. +- User icon and Gravatar support. +- Convert @xxxx to link to the account page. +- Add copy to clipboard button for git clone URL. +- Allows multi-byte characters as wiki page name. +- Allows to create the empty repository. +- Fixed some bugs. + ### 1.2 - 09 Jul 2013 - Added activity timeline. - Bugfix for Git 1.8.1.5 or later. diff --git a/src/main/resources/update/1_3.sql b/src/main/resources/update/1_3.sql index 4227448..59ab009 100644 --- a/src/main/resources/update/1_3.sql +++ b/src/main/resources/update/1_3.sql @@ -1,4 +1,8 @@ ALTER TABLE ACCOUNT ADD COLUMN IMAGE VARCHAR(100); -ALTER TABLE REPOSITORY ADD COLUMN ORIGIN_USER_NAME VARCHAR(100); -ALTER TABLE REPOSITORY ADD COLUMN ORIGIN_REPOSITORY_NAME VARCHAR(100); +UPDATE ISSUE_COMMENT SET ACTION = 'comment' WHERE ACTION IS NULL; + +ALTER TABLE ISSUE_COMMENT ALTER COLUMN ACTION VARCHAR(20) NOT NULL; + +UPDATE ISSUE_COMMENT SET ACTION = 'close_comment' WHERE ACTION = 'close'; +UPDATE ISSUE_COMMENT SET ACTION = 'reopen_comment' WHERE ACTION = 'reopen'; diff --git a/src/main/resources/update/1_4.sql b/src/main/resources/update/1_4.sql index 4750019..a68ca43 100644 --- a/src/main/resources/update/1_4.sql +++ b/src/main/resources/update/1_4.sql @@ -1,15 +1,10 @@ -CREATE TABLE PULL_REQUEST( - USER_NAME VARCHAR(100) NOT NULL, - REPOSITORY_NAME VARCHAR(100) NOT NULL, - ISSUE_ID INT NOT NULL, - BRANCH VARCHAR(100) NOT NULL, - REQUEST_USER_NAME VARCHAR(100) NOT NULL, - REQUEST_REPOSITORY_NAME VARCHAR(100) NOT NULL, - REQUEST_BRANCH VARCHAR(100) NOT NULL, - MERGE_START_ID VARCHAR(40), - MERGE_END_ID VARCHAR(40) +CREATE TABLE GROUP_MEMBER( + GROUP_NAME VARCHAR(100) NOT NULL, + USER_NAME VARCHAR(100) NOT NULL ); -ALTER TABLE PULL_REQUEST ADD CONSTRAINT IDX_PULL_REQUEST_PK PRIMARY KEY (USER_NAME, REPOSITORY_NAME, ISSUE_ID); -ALTER TABLE PULL_REQUEST ADD CONSTRAINT IDX_PULL_REQUEST_FK0 FOREIGN KEY (USER_NAME, REPOSITORY_NAME, ISSUE_ID) REFERENCES ISSUE (USER_NAME, REPOSITORY_NAME, ISSUE_ID); -ALTER TABLE PULL_REQUEST ADD CONSTRAINT IDX_PULL_REQUEST_FK1 FOREIGN KEY (REQUEST_USER_NAME, REQUEST_REPOSITORY_NAME) REFERENCES REPOSITORY (USER_NAME, REPOSITORY_NAME); +ALTER TABLE GROUP_MEMBER ADD CONSTRAINT IDX_GROUP_MEMBER_PK PRIMARY KEY (GROUP_NAME, USER_NAME); +ALTER TABLE GROUP_MEMBER ADD CONSTRAINT IDX_GROUP_MEMBER_FK0 FOREIGN KEY (GROUP_NAME) REFERENCES ACCOUNT (USER_NAME); +ALTER TABLE GROUP_MEMBER ADD CONSTRAINT IDX_GROUP_MEMBER_FK1 FOREIGN KEY (USER_NAME) REFERENCES ACCOUNT (USER_NAME); + +ALTER TABLE ACCOUNT ADD COLUMN GROUP_ACCOUNT BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/main/scala/ScalatraBootstrap.scala b/src/main/scala/ScalatraBootstrap.scala index 69ba545..60a8dbb 100644 --- a/src/main/scala/ScalatraBootstrap.scala +++ b/src/main/scala/ScalatraBootstrap.scala @@ -5,6 +5,7 @@ class ScalatraBootstrap extends LifeCycle { override def init(context: ServletContext) { context.mount(new IndexController, "/") + context.mount(new SearchController, "/") context.mount(new FileUploadController, "/upload") context.mount(new SignInController, "/*") context.mount(new UserManagementController, "/*") diff --git a/src/main/scala/app/AccountController.scala b/src/main/scala/app/AccountController.scala index 68b2d53..acf066f 100644 --- a/src/main/scala/app/AccountController.scala +++ b/src/main/scala/app/AccountController.scala @@ -1,11 +1,10 @@ package app import service._ -import util.{FileUtil, FileUploadUtil, OneselfAuthenticator} +import util.{FileUtil, OneselfAuthenticator} import util.StringUtil._ import util.Directory._ import jp.sf.amateras.scalatra.forms._ -import org.apache.commons.io.FileUtils import org.scalatra.FlashMapSupport class AccountController extends AccountControllerBase @@ -43,12 +42,23 @@ */ get("/:userName") { val userName = params("userName") - getAccountByUserName(userName).map { x => + getAccountByUserName(userName).map { account => params.getOrElse("tab", "repositories") match { // Public Activity - case "activity" => account.html.activity(x, getActivitiesByUser(userName, true)) + case "activity" => + _root_.account.html.activity(account, + if(account.isGroupAccount) Nil else getGroupsByUserName(userName), + getActivitiesByUser(userName, true)) + + // Members + case "members" if(account.isGroupAccount) => + _root_.account.html.members(account, getGroupMembers(account.userName)) + // Repositories - case _ => account.html.repositories(x, getVisibleRepositories(userName, baseUrl, context.loginAccount.map(_.userName))) + case _ => + _root_.account.html.repositories(account, + if(account.isGroupAccount) Nil else getGroupsByUserName(userName), + getVisibleRepositories(userName, baseUrl, context.loginAccount.map(_.userName))) } } getOrElse NotFound } diff --git a/src/main/scala/app/ControllerBase.scala b/src/main/scala/app/ControllerBase.scala index b338f36..bff5994 100644 --- a/src/main/scala/app/ControllerBase.scala +++ b/src/main/scala/app/ControllerBase.scala @@ -1,7 +1,7 @@ package app import _root_.util.Directory._ -import _root_.util.{FileUploadUtil, FileUtil, Validations} +import _root_.util.{FileUtil, Validations} import org.scalatra._ import org.scalatra.json._ import org.json4s._ @@ -10,7 +10,9 @@ import model.Account import scala.Some import service.AccountService -import javax.servlet.http.HttpServletRequest +import javax.servlet.http.{HttpSession, HttpServletRequest} +import java.text.SimpleDateFormat +import javax.servlet.{FilterChain, ServletResponse, ServletRequest} /** * Provides generic features for controller implementations. @@ -20,6 +22,21 @@ implicit val jsonFormats = DefaultFormats + override def doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) { + val httpRequest = request.asInstanceOf[HttpServletRequest] + val path = httpRequest.getRequestURI.substring(request.getServletContext.getContextPath.length) + + if(path.startsWith("/console/")){ + Option(httpRequest.getSession.getAttribute("LOGIN_ACCOUNT").asInstanceOf[Account]).collect { + case account if(account.isAdmin) => chain.doFilter(request, response) + } + } else if(path.startsWith("/git/")){ + chain.doFilter(request, response) + } else { + super.doFilter(request, response, chain) + } + } + /** * Returns the context object for the request. */ @@ -116,7 +133,8 @@ /** * Base trait for controllers which manages account information. */ -trait AccountManagementControllerBase extends ControllerBase { self: AccountService => +trait AccountManagementControllerBase extends ControllerBase with FileUploadControllerBase { + self: AccountService => protected def updateImage(userName: String, fileId: Option[String], clearImage: Boolean): Unit = { if(clearImage){ @@ -126,9 +144,9 @@ } } else { fileId.map { fileId => - val filename = "avatar." + FileUtil.getExtension(FileUploadUtil.getUploadedFilename(fileId).get) + val filename = "avatar." + FileUtil.getExtension(getUploadedFilename(fileId).get) FileUtils.moveFile( - FileUploadUtil.getTemporaryFile(fileId), + getTemporaryFile(fileId), new java.io.File(getUserUploadDir(userName), filename) ) updateAvatarImage(userName, Some(filename)) @@ -148,4 +166,34 @@ .map { _ => "Mail address is already registered." } } +} + +/** + * Base trait for controllers which needs file uploading feature. + */ +trait FileUploadControllerBase { + + def generateFileId: String = + new SimpleDateFormat("yyyyMMddHHmmSSsss").format(new java.util.Date(System.currentTimeMillis)) + + def TemporaryDir(implicit session: HttpSession): java.io.File = + new java.io.File(GitBucketHome, s"tmp/_upload/${session.getId}") + + def getTemporaryFile(fileId: String)(implicit session: HttpSession): java.io.File = + new java.io.File(TemporaryDir, fileId) + + // def removeTemporaryFile(fileId: String)(implicit session: HttpSession): Unit = + // getTemporaryFile(fileId).delete() + + def removeTemporaryFiles()(implicit session: HttpSession): Unit = + FileUtils.deleteDirectory(TemporaryDir) + + def getUploadedFilename(fileId: String)(implicit session: HttpSession): Option[String] = { + val filename = Option(session.getAttribute("upload_" + fileId).asInstanceOf[String]) + if(filename.isDefined){ + session.removeAttribute("upload_" + fileId) + } + filename + } + } \ No newline at end of file diff --git a/src/main/scala/app/CreateRepositoryController.scala b/src/main/scala/app/CreateRepositoryController.scala index 30e2d22..0aa12e9 100644 --- a/src/main/scala/app/CreateRepositoryController.scala +++ b/src/main/scala/app/CreateRepositoryController.scala @@ -5,9 +5,9 @@ import service._ import java.io.File import org.eclipse.jgit.api.Git -import org.eclipse.jgit.lib._ import org.apache.commons.io._ import jp.sf.amateras.scalatra.forms._ +import org.eclipse.jgit.lib.PersonIdent class CreateRepositoryController extends CreateRepositoryControllerBase with RepositoryService with AccountService with WikiService with LabelsService with ActivityService @@ -17,14 +17,15 @@ * Creates new repository. */ trait CreateRepositoryControllerBase extends ControllerBase { - self: RepositoryService with WikiService with LabelsService with ActivityService + self: RepositoryService with AccountService with WikiService with LabelsService with ActivityService with UsersAuthenticator with ReferrerAuthenticator => - case class RepositoryCreationForm(name: String, description: Option[String], isPrivate: Boolean, createReadme: Boolean) + case class RepositoryCreationForm(owner: String, name: String, description: Option[String], isPrivate: Boolean, createReadme: Boolean) case class ForkRepositoryForm(owner: String, name: String) val newForm = mapping( + "owner" -> trim(label("Owner" , text(required, maxlength(40), identifier, existsAccount))), "name" -> trim(label("Repository name", text(required, maxlength(40), identifier, unique))), "description" -> trim(label("Description" , optional(text()))), "isPrivate" -> trim(label("Repository Type", boolean())), @@ -40,28 +41,36 @@ * Show the new repository form. */ get("/new")(usersOnly { - html.newrepo() + html.newrepo(getGroupsByUserName(context.loginAccount.get.userName)) }) /** * Create new repository. */ post("/new", newForm)(usersOnly { form => + val ownerAccount = getAccountByUserName(form.owner).get val loginAccount = context.loginAccount.get val loginUserName = loginAccount.userName // Insert to the database at first - createRepository(form.name, loginUserName, form.description, form.isPrivate) + createRepository(form.name, form.owner, form.description, form.isPrivate) + + // Add collaborators for group repository + if(ownerAccount.isGroupAccount){ + getGroupMembers(form.owner).foreach { userName => + addCollaborator(form.owner, form.name, userName) + } + } // Insert default labels insertDefaultLabels(loginUserName, form.name) // Create the actual repository - val gitdir = getRepositoryDir(loginUserName, form.name) + val gitdir = getRepositoryDir(form.owner, form.name) JGitUtil.initRepository(gitdir) if(form.createReadme){ - val tmpdir = getInitRepositoryDir(loginUserName, form.name) + val tmpdir = getInitRepositoryDir(form.owner, form.name) try { // Clone the repository Git.cloneRepository.setURI(gitdir.toURI.toString).setDirectory(tmpdir).call @@ -91,13 +100,13 @@ } // Create Wiki repository - createWikiRepository(loginAccount, form.name) + createWikiRepository(loginAccount, form.owner, form.name) // Record activity - recordCreateRepositoryActivity(loginUserName, form.name, loginUserName) + recordCreateRepositoryActivity(form.owner, form.name, loginUserName) // redirect to the repository - redirect(s"/${loginUserName}/${form.name}") + redirect(s"/${form.owner}/${form.name}") }) post("/:owner/:repository/_fork")(referrersOnly { repository => @@ -153,12 +162,19 @@ createLabel(userName, repositoryName, "wontfix", "ffffff") } + private def existsAccount: Constraint = new Constraint(){ + def validate(name: String, value: String): Option[String] = + if(getAccountByUserName(value).isEmpty) Some("User or group does not exist.") else None + } + /** * Duplicate check for the repository name. */ private def unique: Constraint = new Constraint(){ def validate(name: String, value: String): Option[String] = - getRepositoryNamesOfUser(context.loginAccount.get.userName).find(_ == value).map(_ => "Repository already exists.") + params.get("owner").flatMap { userName => + getRepositoryNamesOfUser(userName).find(_ == value).map(_ => "Repository already exists.") + } } } \ No newline at end of file diff --git a/src/main/scala/app/FileUploadController.scala b/src/main/scala/app/FileUploadController.scala index 5d5ed3b..350dfed 100644 --- a/src/main/scala/app/FileUploadController.scala +++ b/src/main/scala/app/FileUploadController.scala @@ -1,6 +1,6 @@ package app -import util.{FileUtil, FileUploadUtil} +import util.{FileUtil} import org.scalatra._ import org.scalatra.servlet.{MultipartConfig, FileUploadSupport} import org.apache.commons.io.FileUtils @@ -9,17 +9,18 @@ * Provides Ajax based file upload functionality. * * This servlet saves uploaded file as temporary file and returns the unique id. - * You can get uploaded file using [[util.FileUploadUtil#getTemporaryFile()]] with this id. + * You can get uploaded file using [[app.FileUploadControllerBase#getTemporaryFile()]] with this id. */ -// TODO Remove temporary files at session timeout by session listener. -class FileUploadController extends ScalatraServlet with FileUploadSupport with FlashMapSupport { +class FileUploadController extends ScalatraServlet + with FileUploadSupport with FlashMapSupport with FileUploadControllerBase { + configureMultipartHandling(MultipartConfig(maxFileSize = Some(3 * 1024 * 1024))) post("/image"){ fileParams.get("file") match { case Some(file) if(FileUtil.isImage(file.name)) => { - val fileId = FileUploadUtil.generateFileId - FileUtils.writeByteArrayToFile(FileUploadUtil.getTemporaryFile(fileId), file.get) + val fileId = generateFileId + FileUtils.writeByteArrayToFile(getTemporaryFile(fileId), file.get) session += "upload_" + fileId -> file.name Ok(fileId) } diff --git a/src/main/scala/app/IndexController.scala b/src/main/scala/app/IndexController.scala index c16873b..5612f9b 100644 --- a/src/main/scala/app/IndexController.scala +++ b/src/main/scala/app/IndexController.scala @@ -1,13 +1,17 @@ package app +import util._ import service._ +import jp.sf.amateras.scalatra.forms._ class IndexController extends IndexControllerBase - with RepositoryService with AccountService with SystemSettingsService with ActivityService + with RepositoryService with SystemSettingsService with ActivityService with AccountService +with UsersAuthenticator -trait IndexControllerBase extends ControllerBase { self: RepositoryService - with SystemSettingsService with ActivityService => - +trait IndexControllerBase extends ControllerBase { + self: RepositoryService with SystemSettingsService with ActivityService with AccountService + with UsersAuthenticator => + get("/"){ val loginAccount = context.loginAccount @@ -18,4 +22,14 @@ ) } -} \ No newline at end of file + /** + * JSON API for collaborator completion. + */ + // TODO Move to other controller? + get("/_user/proposals")(usersOnly { + contentType = formats("json") + org.json4s.jackson.Serialization.write(Map("options" -> getAllUsers.filter(!_.isGroupAccount).map(_.userName).toArray)) + }) + + +} diff --git a/src/main/scala/app/IssuesController.scala b/src/main/scala/app/IssuesController.scala index 307610d..0c8b35f 100644 --- a/src/main/scala/app/IssuesController.scala +++ b/src/main/scala/app/IssuesController.scala @@ -67,7 +67,7 @@ getComments(owner, name, issueId.toInt), getIssueLabels(owner, name, issueId.toInt), (getCollaborators(owner, name) :+ owner).sorted, - getMilestones(owner, name), + getMilestonesWithIssueCount(owner, name), getLabels(owner, name), hasWritePermission(owner, name, context.loginAccount), repository) @@ -112,7 +112,7 @@ // record activity recordCreateIssueActivity(owner, name, userName, issueId, form.title) - redirect("/%s/%s/issues/%d".format(owner, name, issueId)) + redirect(s"/${owner}/${name}/issues/${issueId}") }) ajaxPost("/:owner/:repository/issues/edit/:id", issueEditForm)(readableUsersOnly { (form, repository) => @@ -122,17 +122,21 @@ getIssue(owner, name, params("id")).map { issue => if(isEditable(owner, name, issue.openedUserName)){ updateIssue(owner, name, issue.issueId, form.title, form.content) - redirect("/%s/%s/issues/_data/%d".format(owner, name, issue.issueId)) + redirect(s"/${owner}/${name}/issues/_data/${issue.issueId}") } else Unauthorized } getOrElse NotFound }) post("/:owner/:repository/issue_comments/new", commentForm)(readableUsersOnly { (form, repository) => - handleComment(form.issueId, Some(form.content), repository) + handleComment(form.issueId, Some(form.content), repository)() map { id => + redirect(s"/${repository.owner}/${repository.name}/issues/${form.issueId}#comment-${id}") + } getOrElse NotFound }) post("/:owner/:repository/issue_comments/state", issueStateForm)(readableUsersOnly { (form, repository) => - handleComment(form.issueId, form.content, repository) + handleComment(form.issueId, form.content, repository)() map { id => + redirect(s"/${repository.owner}/${repository.name}/issues/${form.issueId}#comment-${id}") + } getOrElse NotFound }) ajaxPost("/:owner/:repository/issue_comments/edit/:id", commentForm)(readableUsersOnly { (form, repository) => @@ -142,7 +146,7 @@ getComment(owner, name, params("id")).map { comment => if(isEditable(owner, name, comment.commentedUserName)){ updateComment(comment.commentId, form.content) - redirect("/%s/%s/issue_comments/_data/%d".format(owner, name, comment.commentId)) + redirect(s"/${owner}/${name}/issue_comments/_data/${comment.commentId}") } else Unauthorized } getOrElse NotFound }) @@ -197,79 +201,81 @@ }) ajaxPost("/:owner/:repository/issues/:id/assign")(collaboratorsOnly { repository => - updateAssignedUserName(repository.owner, repository.name, params("id").toInt, - params.get("assignedUserName") filter (_.trim != "")) + updateAssignedUserName(repository.owner, repository.name, params("id").toInt, assignedUserName("assignedUserName")) Ok("updated") }) ajaxPost("/:owner/:repository/issues/:id/milestone")(collaboratorsOnly { repository => - updateMilestoneId(repository.owner, repository.name, params("id").toInt, - params.get("milestoneId") collect { case x if x.trim != "" => x.toInt }) - Ok("updated") + updateMilestoneId(repository.owner, repository.name, params("id").toInt, milestoneId("milestoneId")) + milestoneId("milestoneId").map { milestoneId => + getMilestonesWithIssueCount(repository.owner, repository.name) + .find(_._1.milestoneId == milestoneId).map { case (_, openCount, closeCount) => + issues.milestones.html.progress(openCount + closeCount, closeCount, false) + } getOrElse NotFound + } getOrElse Ok() }) post("/:owner/:repository/issues/batchedit/state")(collaboratorsOnly { repository => - val owner = repository.owner - val name = repository.name - val userName = context.loginAccount.get.userName + val action = params.get("value") - params.get("value") collect { - case s if s == "close" => (s.capitalize, Some(s), true) - case s if s == "reopen" => (s.capitalize, Some(s), false) - } map { case (content, action, closed) => - params("checked").split(',') foreach { issueId => - createComment(owner, name, userName, issueId.toInt, content, action) - updateClosed(owner, name, issueId.toInt, closed) - } - redirect("/%s/%s/issues".format(owner, name)) - } getOrElse NotFound + executeBatch(repository) { + handleComment(_, None, repository)( _ => action) + } }) post("/:owner/:repository/issues/batchedit/label")(collaboratorsOnly { repository => - val owner = repository.owner - val name = repository.name + val labelId = params("value").toInt - params.get("value").map(_.toInt) map { labelId => - params("checked").split(',') foreach { issueId => - getIssueLabel(owner, name, issueId.toInt, labelId) getOrElse { - registerIssueLabel(owner, name, issueId.toInt, labelId) - } + executeBatch(repository) { issueId => + getIssueLabel(repository.owner, repository.name, issueId, labelId) getOrElse { + registerIssueLabel(repository.owner, repository.name, issueId, labelId) } - redirect("/%s/%s/issues".format(owner, name)) - } getOrElse NotFound + } }) post("/:owner/:repository/issues/batchedit/assign")(collaboratorsOnly { repository => - params("checked").split(',') foreach { issueId => - updateAssignedUserName(repository.owner, repository.name, issueId.toInt, - params.get("value") filter (_.trim != "")) + val value = assignedUserName("value") + + executeBatch(repository) { + updateAssignedUserName(repository.owner, repository.name, _, value) } - redirect("/%s/%s/issues".format(repository.owner, repository.name)) }) post("/:owner/:repository/issues/batchedit/milestone")(collaboratorsOnly { repository => - params("checked").split(',') foreach { issueId => - updateMilestoneId(repository.owner, repository.name, issueId.toInt, - params.get("value") collect { case x if x.trim != "" => x.toInt }) + val value = milestoneId("value") + + executeBatch(repository) { + updateMilestoneId(repository.owner, repository.name, _, value) } - redirect("/%s/%s/issues".format(repository.owner, repository.name)) }) + val assignedUserName = (key: String) => params.get(key) filter (_.trim != "") + val milestoneId = (key: String) => params.get(key) collect { case x if x.trim != "" => x.toInt } + private def isEditable(owner: String, repository: String, author: String)(implicit context: app.Context): Boolean = hasWritePermission(owner, repository, context.loginAccount) || author == context.loginAccount.get.userName - private def handleComment(issueId: Int, content: Option[String], repository: RepositoryService.RepositoryInfo) = { + private def executeBatch(repository: RepositoryService.RepositoryInfo)(execute: Int => Unit) = { + params("checked").split(',') map(_.toInt) foreach execute + redirect(s"/${repository.owner}/${repository.name}/issues") + } + + /** + * @see [[https://github.com/takezoe/gitbucket/wiki/CommentAction]] + */ + private def handleComment(issueId: Int, content: Option[String], repository: RepositoryService.RepositoryInfo) + (getAction: model.Issue => Option[String] = + p1 => params.get("action").filter(_ => isEditable(p1.userName, p1.repositoryName, p1.openedUserName))) = { val owner = repository.owner val name = repository.name val userName = context.loginAccount.get.userName getIssue(owner, name, issueId.toString) map { issue => val (action, recordActivity) = - params.get("action") - .filter(_ => isEditable(owner, name, issue.openedUserName)) + getAction(issue) .collect { - case s if s == "close" => true -> (Some(s) -> Some(recordCloseIssueActivity _)) - case s if s == "reopen" => false -> (Some(s) -> Some(recordReopenIssueActivity _)) + case "close" => true -> (Some("close") -> Some(recordCloseIssueActivity _)) + case "reopen" => false -> (Some("reopen") -> Some(recordReopenIssueActivity _)) } .map { case (closed, t) => updateClosed(owner, name, issueId, closed) @@ -277,21 +283,26 @@ } .getOrElse(None -> None) - val commentId = createComment(owner, name, userName, issueId, content.getOrElse(action.get.capitalize), action) + val commentId = content + .map ( _ -> action.map( _ + "_comment" ).getOrElse("comment") ) + .getOrElse ( action.get.capitalize -> action.get ) + match { + case (content, action) => createComment(owner, name, userName, issueId, content, action) + } // record activity content foreach ( recordCommentIssueActivity(owner, name, userName, issueId, _) ) recordActivity foreach ( _ (owner, name, userName, issueId, issue.title) ) - redirect("/%s/%s/issues/%d#comment-%d".format(owner, name, issueId, commentId)) - } getOrElse NotFound + commentId + } } private def searchIssues(filter: String, repository: RepositoryService.RepositoryInfo) = { val owner = repository.owner val repoName = repository.name val userName = if(filter != "all") Some(params("userName")) else None - val sessionKey = "%s/%s/issues".format(owner, repoName) + val sessionKey = s"${owner}/${repoName}/issues" val page = try { val i = params.getOrElse("page", "1").toInt @@ -311,7 +322,7 @@ searchIssue(owner, repoName, condition, filter, userName, (page - 1) * IssueLimit, IssueLimit), page, (getCollaborators(owner, repoName) :+ owner).sorted, - getMilestones(owner, repoName).filter(_.closedDate.isEmpty), + getMilestones(owner, repoName), getLabels(owner, repoName), countIssue(owner, repoName, condition.copy(state = "open"), filter, userName), countIssue(owner, repoName, condition.copy(state = "closed"), filter, userName), diff --git a/src/main/scala/app/MilestonesController.scala b/src/main/scala/app/MilestonesController.scala index 55c60d0..f4ae4cb 100644 --- a/src/main/scala/app/MilestonesController.scala +++ b/src/main/scala/app/MilestonesController.scala @@ -3,7 +3,7 @@ import jp.sf.amateras.scalatra.forms._ import service._ -import util.{CollaboratorsAuthenticator, ReferrerAuthenticator, UsersAuthenticator} +import util.{CollaboratorsAuthenticator, ReferrerAuthenticator} class MilestonesController extends MilestonesControllerBase with MilestonesService with RepositoryService with AccountService diff --git a/src/main/scala/app/RepositorySettingsController.scala b/src/main/scala/app/RepositorySettingsController.scala index 08f6474..780a7f3 100644 --- a/src/main/scala/app/RepositorySettingsController.scala +++ b/src/main/scala/app/RepositorySettingsController.scala @@ -54,22 +54,19 @@ * Display the Collaborators page. */ get("/:owner/:repository/settings/collaborators")(ownerOnly { repository => - settings.html.collaborators(getCollaborators(repository.owner, repository.name), repository) - }) - - /** - * JSON API for collaborator completion. - */ - get("/:owner/:repository/settings/collaborators/proposals")(usersOnly { - contentType = formats("json") - org.json4s.jackson.Serialization.write(Map("options" -> getAllUsers.map(_.userName).toArray)) + settings.html.collaborators( + getCollaborators(repository.owner, repository.name), + getAccountByUserName(repository.owner).get.isGroupAccount, + repository) }) /** * Add the collaborator. */ post("/:owner/:repository/settings/collaborators/add", collaboratorForm)(ownerOnly { (form, repository) => - addCollaborator(repository.owner, repository.name, form.userName) + if(!getAccountByUserName(repository.owner).get.isGroupAccount){ + addCollaborator(repository.owner, repository.name, form.userName) + } redirect(s"/${repository.owner}/${repository.name}/settings/collaborators") }) @@ -77,7 +74,9 @@ * Add the collaborator. */ get("/:owner/:repository/settings/collaborators/remove")(ownerOnly { repository => - removeCollaborator(repository.owner, repository.name, params("name")) + if(!getAccountByUserName(repository.owner).get.isGroupAccount){ + removeCollaborator(repository.owner, repository.name, params("name")) + } redirect(s"/${repository.owner}/${repository.name}/settings/collaborators") }) diff --git a/src/main/scala/app/SearchController.scala b/src/main/scala/app/SearchController.scala new file mode 100644 index 0000000..38bf35a --- /dev/null +++ b/src/main/scala/app/SearchController.scala @@ -0,0 +1,51 @@ +package app + +import util._ +import service._ +import jp.sf.amateras.scalatra.forms._ + +class SearchController extends SearchControllerBase +with RepositoryService with AccountService with SystemSettingsService with ActivityService +with RepositorySearchService with IssuesService +with ReferrerAuthenticator + +trait SearchControllerBase extends ControllerBase { self: RepositoryService + with SystemSettingsService with ActivityService with RepositorySearchService + with ReferrerAuthenticator => + + val searchForm = mapping( + "query" -> trim(text(required)), + "owner" -> trim(text(required)), + "repository" -> trim(text(required)) + )(SearchForm.apply) + + case class SearchForm(query: String, owner: String, repository: String) + + post("/search", searchForm){ form => + redirect(s"${form.owner}/${form.repository}/search?q=${StringUtil.urlEncode(form.query)}") + } + + get("/:owner/:repository/search")(referrersOnly { repository => + val query = params("q").trim + val target = params.getOrElse("type", "code") + val page = try { + val i = params.getOrElse("page", "1").toInt + if(i <= 0) 1 else i + } catch { + case e: NumberFormatException => 1 + } + + target.toLowerCase match { + case "issue" => search.html.issues( + searchIssues(repository.owner, repository.name, query), + countFiles(repository.owner, repository.name, query), + query, page, repository) + + case _ => search.html.code( + searchFiles(repository.owner, repository.name, query), + countIssues(repository.owner, repository.name, query), + query, page, repository) + } + }) + +} diff --git a/src/main/scala/app/SignInController.scala b/src/main/scala/app/SignInController.scala index becc3b2..a94609a 100644 --- a/src/main/scala/app/SignInController.scala +++ b/src/main/scala/app/SignInController.scala @@ -24,20 +24,19 @@ } post("/signin", form){ form => - val account = getAccountByUserName(form.userName) - if(account.isEmpty || account.get.password != sha1(form.password)){ - redirect("/signin") - } else { - session.setAttribute("LOGIN_ACCOUNT", account.get) - updateLastLoginDate(account.get.userName) + getAccountByUserName(form.userName).collect { + case account if(!account.isGroupAccount && account.password == sha1(form.password)) => { + session.setAttribute("LOGIN_ACCOUNT", account) + updateLastLoginDate(account.userName) - session.get("REDIRECT").map { redirectUrl => - session.removeAttribute("REDIRECT") - redirect(redirectUrl.asInstanceOf[String]) - }.getOrElse { - redirect("/") + session.get("REDIRECT").map { redirectUrl => + session.removeAttribute("REDIRECT") + redirect(redirectUrl.asInstanceOf[String]) + }.getOrElse { + redirect("/") + } } - } + } getOrElse redirect("/signin") } get("/signout"){ diff --git a/src/main/scala/app/UserManagementController.scala b/src/main/scala/app/UserManagementController.scala index e5673d4..08f14d8 100644 --- a/src/main/scala/app/UserManagementController.scala +++ b/src/main/scala/app/UserManagementController.scala @@ -1,34 +1,38 @@ package app import service._ -import util.{FileUploadUtil, FileUtil, AdminAuthenticator} +import util.AdminAuthenticator import util.StringUtil._ import jp.sf.amateras.scalatra.forms._ -import org.apache.commons.io.FileUtils -import util.Directory._ -import scala.Some -class UserManagementController extends UserManagementControllerBase with AccountService with AdminAuthenticator +class UserManagementController extends UserManagementControllerBase + with AccountService with RepositoryService with AdminAuthenticator trait UserManagementControllerBase extends AccountManagementControllerBase { - self: AccountService with AdminAuthenticator => + self: AccountService with RepositoryService with AdminAuthenticator => - case class UserNewForm(userName: String, password: String, mailAddress: String, isAdmin: Boolean, + case class NewUserForm(userName: String, password: String, mailAddress: String, isAdmin: Boolean, url: Option[String], fileId: Option[String]) - case class UserEditForm(userName: String, password: Option[String], mailAddress: String, isAdmin: Boolean, + case class EditUserForm(userName: String, password: Option[String], mailAddress: String, isAdmin: Boolean, url: Option[String], fileId: Option[String], clearImage: Boolean) - val newForm = mapping( + case class NewGroupForm(groupName: String, url: Option[String], fileId: Option[String], + memberNames: Option[String]) + + case class EditGroupForm(groupName: String, url: Option[String], fileId: Option[String], + memberNames: Option[String], clearImage: Boolean) + + val newUserForm = mapping( "userName" -> trim(label("Username" , text(required, maxlength(100), identifier, uniqueUserName))), "password" -> trim(label("Password" , text(required, maxlength(20)))), "mailAddress" -> trim(label("Mail Address" , text(required, maxlength(100), uniqueMailAddress()))), "isAdmin" -> trim(label("User Type" , boolean())), "url" -> trim(label("URL" , optional(text(maxlength(200))))), "fileId" -> trim(label("File ID" , optional(text()))) - )(UserNewForm.apply) + )(NewUserForm.apply) - val editForm = mapping( + val editUserForm = mapping( "userName" -> trim(label("Username" , text(required, maxlength(100), identifier))), "password" -> trim(label("Password" , optional(text(maxlength(20))))), "mailAddress" -> trim(label("Mail Address" , text(required, maxlength(100), uniqueMailAddress("userName")))), @@ -36,28 +40,47 @@ "url" -> trim(label("URL" , optional(text(maxlength(200))))), "fileId" -> trim(label("File ID" , optional(text()))), "clearImage" -> trim(label("Clear image" , boolean())) - )(UserEditForm.apply) - + )(EditUserForm.apply) + + val newGroupForm = mapping( + "groupName" -> trim(label("Group name" , text(required, maxlength(100), identifier))), + "url" -> trim(label("URL" , optional(text(maxlength(200))))), + "fileId" -> trim(label("File ID" , optional(text()))), + "memberNames" -> trim(label("Member Names" , optional(text()))) + )(NewGroupForm.apply) + + val editGroupForm = mapping( + "groupName" -> trim(label("Group name" , text(required, maxlength(100), identifier))), + "url" -> trim(label("URL" , optional(text(maxlength(200))))), + "fileId" -> trim(label("File ID" , optional(text()))), + "memberNames" -> trim(label("Member Names" , optional(text()))), + "clearImage" -> trim(label("Clear image" , boolean())) + )(EditGroupForm.apply) + get("/admin/users")(adminOnly { - admin.users.html.list(getAllUsers()) + val users = getAllUsers() + val members = users.collect { case account if(account.isGroupAccount) => + account.userName -> getGroupMembers(account.userName) + }.toMap + admin.users.html.list(users, members) }) - get("/admin/users/_new")(adminOnly { - admin.users.html.edit(None) + get("/admin/users/_newuser")(adminOnly { + admin.users.html.user(None) }) - post("/admin/users/_new", newForm)(adminOnly { form => + post("/admin/users/_newuser", newUserForm)(adminOnly { form => createAccount(form.userName, sha1(form.password), form.mailAddress, form.isAdmin, form.url) updateImage(form.userName, form.fileId, false) redirect("/admin/users") }) - get("/admin/users/:userName/_edit")(adminOnly { + get("/admin/users/:userName/_edituser")(adminOnly { val userName = params("userName") - admin.users.html.edit(getAccountByUserName(userName)) + admin.users.html.user(getAccountByUserName(userName)) }) - post("/admin/users/:name/_edit", editForm)(adminOnly { form => + post("/admin/users/:name/_edituser", editUserForm)(adminOnly { form => val userName = params("userName") getAccountByUserName(userName).map { account => updateAccount(getAccountByUserName(userName).get.copy( @@ -71,5 +94,46 @@ } getOrElse NotFound }) - + + get("/admin/users/_newgroup")(adminOnly { + admin.users.html.group(None, Nil) + }) + + post("/admin/users/_newgroup", newGroupForm)(adminOnly { form => + createGroup(form.groupName, form.url) + updateGroupMembers(form.groupName, form.memberNames.map(_.split(",").toList).getOrElse(Nil)) + updateImage(form.groupName, form.fileId, false) + redirect("/admin/users") + }) + + get("/admin/users/:groupName/_editgroup")(adminOnly { + val groupName = params("groupName") + admin.users.html.group(getAccountByUserName(groupName), getGroupMembers(groupName)) + }) + + post("/admin/users/:groupName/_editgroup", editGroupForm)(adminOnly { form => + val groupName = params("groupName") + getAccountByUserName(groupName).map { account => + updateGroup(groupName, form.url) + + val memberNames = form.memberNames.map(_.split(",").toList).getOrElse(Nil) + updateGroupMembers(form.groupName, memberNames) + + getRepositoryNamesOfUser(form.groupName).foreach { repositoryName => + removeCollaborators(form.groupName, repositoryName) + memberNames.foreach { userName => + addCollaborator(form.groupName, repositoryName, userName) + } + } + + updateImage(form.groupName, form.fileId, form.clearImage) + redirect("/admin/users") + + } getOrElse NotFound + }) + + post("/admin/users/_usercheck")(adminOnly { + getAccountByUserName(params("userName")).isDefined + }) + } \ No newline at end of file diff --git a/src/main/scala/model/Account.scala b/src/main/scala/model/Account.scala index d0f1f59..86ff251 100644 --- a/src/main/scala/model/Account.scala +++ b/src/main/scala/model/Account.scala @@ -12,7 +12,8 @@ def updatedDate = column[java.util.Date]("UPDATED_DATE") def lastLoginDate = column[java.util.Date]("LAST_LOGIN_DATE") def image = column[String]("IMAGE") - def * = userName ~ mailAddress ~ password ~ isAdmin ~ url.? ~ registeredDate ~ updatedDate ~ lastLoginDate.? ~ image.? <> (Account, Account.unapply _) + def groupAccount = column[Boolean]("GROUP_ACCOUNT") + def * = userName ~ mailAddress ~ password ~ isAdmin ~ url.? ~ registeredDate ~ updatedDate ~ lastLoginDate.? ~ image.? ~ groupAccount <> (Account, Account.unapply _) } case class Account( @@ -24,5 +25,6 @@ registeredDate: java.util.Date, updatedDate: java.util.Date, lastLoginDate: Option[java.util.Date], - image: Option[String] + image: Option[String], + isGroupAccount: Boolean ) diff --git a/src/main/scala/model/GroupMembers.scala b/src/main/scala/model/GroupMembers.scala new file mode 100644 index 0000000..0bcd0af --- /dev/null +++ b/src/main/scala/model/GroupMembers.scala @@ -0,0 +1,14 @@ +package model + +import scala.slick.driver.H2Driver.simple._ + +object GroupMembers extends Table[GroupMember]("GROUP_MEMBER") { + def groupName = column[String]("GROUP_NAME", O PrimaryKey) + def userName = column[String]("USER_NAME", O PrimaryKey) + def * = groupName ~ userName <> (GroupMember, GroupMember.unapply _) +} + +case class GroupMember( + groupName: String, + userName: String +) \ No newline at end of file diff --git a/src/main/scala/model/IssueComment.scala b/src/main/scala/model/IssueComment.scala index 1084a33..f9fd983 100644 --- a/src/main/scala/model/IssueComment.scala +++ b/src/main/scala/model/IssueComment.scala @@ -9,9 +9,9 @@ def content = column[String]("CONTENT") def registeredDate = column[java.util.Date]("REGISTERED_DATE") def updatedDate = column[java.util.Date]("UPDATED_DATE") - def * = userName ~ repositoryName ~ issueId ~ commentId ~ action.? ~ commentedUserName ~ content ~ registeredDate ~ updatedDate <> (IssueComment, IssueComment.unapply _) + def * = userName ~ repositoryName ~ issueId ~ commentId ~ action ~ commentedUserName ~ content ~ registeredDate ~ updatedDate <> (IssueComment, IssueComment.unapply _) - def autoInc = userName ~ repositoryName ~ issueId ~ action.? ~ commentedUserName ~ content ~ registeredDate ~ updatedDate returning commentId + def autoInc = userName ~ repositoryName ~ issueId ~ action ~ commentedUserName ~ content ~ registeredDate ~ updatedDate returning commentId def byPrimaryKey(commentId: Int) = this.commentId is commentId.bind } @@ -20,7 +20,7 @@ repositoryName: String, issueId: Int, commentId: Int, - action: Option[String], + action: String, commentedUserName: String, content: String, registeredDate: java.util.Date, diff --git a/src/main/scala/model/package.scala b/src/main/scala/model/package.scala index 475da12..3280c35 100644 --- a/src/main/scala/model/package.scala +++ b/src/main/scala/model/package.scala @@ -1,5 +1,6 @@ package object model { - import scala.slick.lifted.MappedTypeMapper + import scala.slick.driver.BasicDriver.Implicit._ + import scala.slick.lifted.{Column, MappedTypeMapper} // java.util.Date TypeMapper implicit val dateTypeMapper = MappedTypeMapper.base[java.util.Date, java.sql.Timestamp]( @@ -7,6 +8,10 @@ t => new java.util.Date(t.getTime) ) + implicit class RichColumn(c1: Column[Boolean]){ + def &&(c2: => Column[Boolean], guard: => Boolean): Column[Boolean] = if(guard) c1 && c2 else c1 + } + /** * Returns system date. */ diff --git a/src/main/scala/service/AccountService.scala b/src/main/scala/service/AccountService.scala index 28223e1..3f828e3 100644 --- a/src/main/scala/service/AccountService.scala +++ b/src/main/scala/service/AccountService.scala @@ -24,7 +24,8 @@ registeredDate = currentDate, updatedDate = currentDate, lastLoginDate = None, - image = None) + image = None, + isGroupAccount = false) def updateAccount(account: Account): Unit = Accounts @@ -44,5 +45,42 @@ def updateLastLoginDate(userName: String): Unit = Accounts.filter(_.userName is userName.bind).map(_.lastLoginDate).update(currentDate) - + + def createGroup(groupName: String, url: Option[String]): Unit = + Accounts insert Account( + userName = groupName, + password = "", + mailAddress = groupName + "@devnull", + isAdmin = false, + url = url, + registeredDate = currentDate, + updatedDate = currentDate, + lastLoginDate = None, + image = None, + isGroupAccount = true) + + def updateGroup(groupName: String, url: Option[String]): Unit = + Accounts.filter(_.userName is groupName.bind).map(_.url.?).update(url) + + def updateGroupMembers(groupName: String, members: List[String]): Unit = { + Query(GroupMembers).filter(_.groupName is groupName.bind).delete + members.foreach { userName => + GroupMembers insert GroupMember (groupName, userName) + } + } + + def getGroupMembers(groupName: String): List[String] = + Query(GroupMembers) + .filter(_.groupName is groupName.bind) + .sortBy(_.userName) + .map(_.userName) + .list + + def getGroupsByUserName(userName: String): List[String] = + Query(GroupMembers) + .filter(_.userName is userName.bind) + .sortBy(_.groupName) + .map(_.groupName) + .list + } diff --git a/src/main/scala/service/IssuesService.scala b/src/main/scala/service/IssuesService.scala index 17f3c69..c0f90e0 100644 --- a/src/main/scala/service/IssuesService.scala +++ b/src/main/scala/service/IssuesService.scala @@ -6,8 +6,8 @@ import Q.interpolation import model._ -import util.StringUtil._ import util.Implicits._ +import util.StringUtil trait IssuesService { import IssuesService._ @@ -102,7 +102,10 @@ // get issues and comment count val issues = searchIssueQuery(owner, repository, condition, filter, userName) .leftJoin(Query(IssueComments) - .filter { _.byRepository(owner, repository) } + .filter { t => + (t.byRepository(owner, repository)) && + (t.action inSetBind Seq("comment", "close_comment", "reopen_comment")) + } .groupBy { _.issueId } .map { case (issueId, t) => issueId ~ t.length }).on((t1, t2) => t1.issueId is t2._1) .sortBy { case (t1, t2) => @@ -192,7 +195,7 @@ IssueLabels filter(_.byPrimaryKey(owner, repository, issueId, labelId)) delete def createComment(owner: String, repository: String, loginUser: String, - issueId: Int, content: String, action: Option[String]) = + issueId: Int, content: String, action: String) = IssueComments.autoInc insert ( owner, repository, @@ -234,10 +237,60 @@ } .update (closed, currentDate) + /** + * Search issues by keyword. + * + * @param owner the repository owner + * @param repository the repository name + * @param query the keywords separated by whitespace. + * @return issues with comment count and matched content of issue or comment + */ + def searchIssuesByKeyword(owner: String, repository: String, query: String): List[(Issue, Int, String)] = { + import scala.slick.driver.H2Driver.likeEncode + val keywords = StringUtil.splitWords(query.toLowerCase) + + // Search Issue + val issues = Query(Issues).filter { t => + keywords.map { keyword => + (t.title.toLowerCase like (s"%${likeEncode(keyword)}%", '^')) || + (t.content.toLowerCase like (s"%${likeEncode(keyword)}%", '^')) + } .reduceLeft(_ && _) + }.map { t => (t, 0, t.content.?) } + + // Search IssueComment + val comments = Query(IssueComments).innerJoin(Issues).on { case (t1, t2) => + t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) + }.filter { case (t1, t2) => + keywords.map { query => + t1.content.toLowerCase like (s"%${likeEncode(query)}%", '^') + }.reduceLeft(_ && _) + }.map { case (t1, t2) => (t2, t1.commentId, t1.content.?) } + + def getCommentCount(issue: Issue): Int = { + Query(IssueComments) + .filter { t => + t.byIssue(issue.userName, issue.repositoryName, issue.issueId) && + (t.action inSetBind Seq("comment", "close_comment", "reopen_comment")) + } + .map(_.issueId) + .list.length + } + + issues.union(comments).sortBy { case (issue, commentId, _) => + issue.issueId ~ commentId + }.list.splitWith { case ((issue1, _, _), (issue2, _, _)) => + issue1.issueId == issue2.issueId + }.map { result => + val (issue, _, content) = result.head + (issue, getCommentCount(issue) , content.getOrElse("")) + }.toList + } + } object IssuesService { import javax.servlet.http.HttpServletRequest + import util.StringUtil._ val IssueLimit = 30 @@ -279,4 +332,5 @@ param(request, "sort", Seq("created", "comments", "updated")).getOrElse("created"), param(request, "direction", Seq("asc", "desc")).getOrElse("desc")) } + } diff --git a/src/main/scala/service/RepositorySearchService.scala b/src/main/scala/service/RepositorySearchService.scala new file mode 100644 index 0000000..669a36b --- /dev/null +++ b/src/main/scala/service/RepositorySearchService.scala @@ -0,0 +1,121 @@ +package service + +import model.Issue +import util.{FileUtil, StringUtil, JGitUtil} +import util.Directory._ +import model.Issue +import org.eclipse.jgit.revwalk.RevWalk +import org.eclipse.jgit.treewalk.TreeWalk +import scala.collection.mutable.ListBuffer +import org.eclipse.jgit.lib.FileMode +import org.eclipse.jgit.api.Git + +trait RepositorySearchService { self: IssuesService => + import RepositorySearchService._ + + def countIssues(owner: String, repository: String, query: String): Int = + searchIssuesByKeyword(owner, repository, query).length + + def searchIssues(owner: String, repository: String, query: String): List[IssueSearchResult] = + searchIssuesByKeyword(owner, repository, query).map { case (issue, commentCount, content) => + IssueSearchResult( + issue.issueId, + issue.title, + issue.openedUserName, + issue.registeredDate, + commentCount, + getHighlightText(content, query)._1) + } + + def countFiles(owner: String, repository: String, query: String): Int = + JGitUtil.withGit(getRepositoryDir(owner, repository)){ git => + searchRepositoryFiles(git, query).length + } + + def searchFiles(owner: String, repository: String, query: String): List[FileSearchResult] = + JGitUtil.withGit(getRepositoryDir(owner, repository)){ git => + val files = searchRepositoryFiles(git, query) + val commits = JGitUtil.getLatestCommitFromPaths(git, files.toList.map(_._1), "HEAD") + files.map { case (path, text) => + val (highlightText, lineNumber) = getHighlightText(text, query) + FileSearchResult( + path, + commits(path).getCommitterIdent.getWhen, + highlightText, + lineNumber) + } + } + + private def searchRepositoryFiles(git: Git, query: String): List[(String, String)] = { + val revWalk = new RevWalk(git.getRepository) + val objectId = git.getRepository.resolve("HEAD") + val revCommit = revWalk.parseCommit(objectId) + val treeWalk = new TreeWalk(git.getRepository) + treeWalk.setRecursive(true) + treeWalk.addTree(revCommit.getTree) + + val keywords = StringUtil.splitWords(query.toLowerCase) + val list = new ListBuffer[(String, String)] + + while (treeWalk.next()) { + if(treeWalk.getFileMode(0) != FileMode.TREE){ + JGitUtil.getContent(git, treeWalk.getObjectId(0), false).foreach { bytes => + if(FileUtil.isText(bytes)){ + val text = new String(bytes, "UTF-8") + val lowerText = text.toLowerCase + val indices = keywords.map(lowerText.indexOf _) + if(!indices.exists(_ < 0)){ + list.append((treeWalk.getPathString, text)) + } + } + } + } + } + treeWalk.release + revWalk.release + + list.toList + } + +} + +object RepositorySearchService { + + val CodeLimit = 10 + val IssueLimit = 10 + + def getHighlightText(content: String, query: String): (String, Int) = { + val keywords = StringUtil.splitWords(query.toLowerCase) + val lowerText = content.toLowerCase + val indices = keywords.map(lowerText.indexOf _) + + if(!indices.exists(_ < 0)){ + val lineNumber = content.substring(0, indices.min).split("\n").size - 1 + val highlightText = StringUtil.escapeHtml(content.split("\n").drop(lineNumber).take(5).mkString("\n")) + .replaceAll("(?i)(" + keywords.map("\\Q" + _ + "\\E").mkString("|") + ")", + "$1") + (highlightText, lineNumber + 1) + } else { + (content.split("\n").take(5).mkString("\n"), 1) + } + } + + case class SearchResult( + files : List[(String, String)], + issues: List[(Issue, Int, String)]) + + case class IssueSearchResult( + issueId: Int, + title: String, + openedUserName: String, + registeredDate: java.util.Date, + commentCount: Int, + highlightText: String) + + case class FileSearchResult( + path: String, + lastModified: java.util.Date, + highlightText: String, + highlightLineNumber: Int) + +} \ No newline at end of file diff --git a/src/main/scala/service/RepositoryService.scala b/src/main/scala/service/RepositoryService.scala index 4ec1651..b9ce2e2 100644 --- a/src/main/scala/service/RepositoryService.scala +++ b/src/main/scala/service/RepositoryService.scala @@ -167,6 +167,15 @@ Collaborators.filter(_.byPrimaryKey(userName, repositoryName, collaboratorName)).delete /** + * Remove all collaborators from the repository. + * + * @param userName the user name of the repository owner + * @param repositoryName the repository name + */ + def removeCollaborators(userName: String, repositoryName: String): Unit = + Collaborators.filter(_.byRepository(userName, repositoryName)).delete + + /** * Returns the list of collaborators name which is sorted with ascending order. * * @param userName the user name of the repository owner diff --git a/src/main/scala/service/WikiService.scala b/src/main/scala/service/WikiService.scala index 5cfe5b5..bb2542b 100644 --- a/src/main/scala/service/WikiService.scala +++ b/src/main/scala/service/WikiService.scala @@ -6,7 +6,6 @@ import org.apache.commons.io.FileUtils import util.JGitUtil.DiffInfo import util.{Directory, JGitUtil} -import org.eclipse.jgit.lib.RepositoryBuilder import org.eclipse.jgit.treewalk.CanonicalTreeParser import java.util.concurrent.ConcurrentHashMap @@ -64,16 +63,16 @@ trait WikiService { import WikiService._ - def createWikiRepository(owner: model.Account, repository: String): Unit = { - lock(owner.userName, repository){ - val dir = Directory.getWikiRepositoryDir(owner.userName, repository) + def createWikiRepository(loginAccount: model.Account, owner: String, repository: String): Unit = { + lock(owner, repository){ + val dir = Directory.getWikiRepositoryDir(owner, repository) if(!dir.exists){ try { JGitUtil.initRepository(dir) - saveWikiPage(owner.userName, repository, "Home", "Home", "Welcome to the %s wiki!!".format(repository), owner, "Initial Commit") + saveWikiPage(owner, repository, "Home", "Home", s"Welcome to the ${repository} wiki!!", loginAccount, "Initial Commit") } finally { // once delete cloned repository because initial cloned repository does not have 'branch.master.merge' - FileUtils.deleteDirectory(Directory.getWikiWorkDir(owner.userName, repository)) + FileUtils.deleteDirectory(Directory.getWikiWorkDir(owner, repository)) } } } @@ -119,10 +118,12 @@ * Returns the list of wiki page names. */ def getWikiPageList(owner: String, repository: String): List[String] = { - JGitUtil.getFileList(Git.open(Directory.getWikiRepositoryDir(owner, repository)), "master", ".") - .filter(_.name.endsWith(".md")) - .map(_.name.replaceFirst("\\.md$", "")) - .sortBy(x => x) + JGitUtil.withGit(Directory.getWikiRepositoryDir(owner, repository)){ git => + JGitUtil.getFileList(git, "master", ".") + .filter(_.name.endsWith(".md")) + .map(_.name.replaceFirst("\\.md$", "")) + .sortBy(x => x) + } } /** @@ -189,12 +190,16 @@ private def cloneOrPullWorkingCopy(workDir: File, owner: String, repository: String): Unit = { if(!workDir.exists){ - Git.cloneRepository - .setURI(Directory.getWikiRepositoryDir(owner, repository).toURI.toString) - .setDirectory(workDir) - .call + val git = + Git.cloneRepository + .setURI(Directory.getWikiRepositoryDir(owner, repository).toURI.toString) + .setDirectory(workDir) + .call + git.getRepository.close // close .git resources. } else { - Git.open(workDir).pull.call + JGitUtil.withGit(workDir){ git => + git.pull.call + } } } diff --git a/src/main/scala/servlet/AutoUpdateListener.scala b/src/main/scala/servlet/AutoUpdateListener.scala index 4fe7397..601af9b 100644 --- a/src/main/scala/servlet/AutoUpdateListener.scala +++ b/src/main/scala/servlet/AutoUpdateListener.scala @@ -129,6 +129,7 @@ } catch { case ex: Throwable => { logger.error("Failed to schema update", ex) + ex.printStackTrace() conn.rollback() } } diff --git a/src/main/scala/servlet/BasicAuthenticationFilter.scala b/src/main/scala/servlet/BasicAuthenticationFilter.scala index a69c237..51d6618 100644 --- a/src/main/scala/servlet/BasicAuthenticationFilter.scala +++ b/src/main/scala/servlet/BasicAuthenticationFilter.scala @@ -32,7 +32,8 @@ getRepository(repositoryOwner, repositoryName.replaceFirst("\\.wiki", ""), "") match { case Some(repository) => { - if(!request.getRequestURI.endsWith("/git-receive-pack") && !repository.repository.isPrivate){ + if(!request.getRequestURI.endsWith("/git-receive-pack") && + !"service=git-receive-pack".equals(request.getQueryString) && !repository.repository.isPrivate){ chain.doFilter(req, wrappedResponse) } else { request.getHeader("Authorization") match { diff --git a/src/main/scala/servlet/GitRepositoryServlet.scala b/src/main/scala/servlet/GitRepositoryServlet.scala index 55ce0e9..94c432b 100644 --- a/src/main/scala/servlet/GitRepositoryServlet.scala +++ b/src/main/scala/servlet/GitRepositoryServlet.scala @@ -85,7 +85,7 @@ "(^|\\W)#(\\d+)(\\W|$)".r.findAllIn(commit.fullMessage).matchData.foreach { matchData => val issueId = matchData.group(2) if(getAccountByUserName(commit.committer).isDefined && getIssue(owner, repository, issueId).isDefined){ - createComment(owner, repository, commit.committer, issueId.toInt, commit.fullMessage, Some("commit")) + createComment(owner, repository, commit.committer, issueId.toInt, commit.fullMessage, "commit") } } Some(commit) diff --git a/src/main/scala/servlet/SessionCleanupListener.scala b/src/main/scala/servlet/SessionCleanupListener.scala index 86c8bcc..87ce1d1 100644 --- a/src/main/scala/servlet/SessionCleanupListener.scala +++ b/src/main/scala/servlet/SessionCleanupListener.scala @@ -1,17 +1,15 @@ package servlet -import util.FileUploadUtil import javax.servlet.http.{HttpSessionEvent, HttpSessionListener} +import app.FileUploadControllerBase /** * Removes session associated temporary files when session is destroyed. */ -class SessionCleanupListener extends HttpSessionListener { +class SessionCleanupListener extends HttpSessionListener with FileUploadControllerBase { def sessionCreated(se: HttpSessionEvent): Unit = {} - def sessionDestroyed(se: HttpSessionEvent): Unit = { - FileUploadUtil.removeTemporaryFiles()(se.getSession) - } + def sessionDestroyed(se: HttpSessionEvent): Unit = removeTemporaryFiles()(se.getSession) } diff --git a/src/main/scala/util/Directory.scala b/src/main/scala/util/Directory.scala index f2b7f4c..754d737 100644 --- a/src/main/scala/util/Directory.scala +++ b/src/main/scala/util/Directory.scala @@ -1,8 +1,6 @@ package util import java.io.File -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.lib.Ref /** * Provides directories used by GitBucket. diff --git a/src/main/scala/util/FileUploadUtil.scala b/src/main/scala/util/FileUploadUtil.scala deleted file mode 100644 index 6efacc1..0000000 --- a/src/main/scala/util/FileUploadUtil.scala +++ /dev/null @@ -1,33 +0,0 @@ -package util - -import java.text.SimpleDateFormat -import javax.servlet.http.HttpSession -import util.Directory._ -import org.apache.commons.io.FileUtils - -object FileUploadUtil { - - def generateFileId: String = - new SimpleDateFormat("yyyyMMddHHmmSSsss").format(new java.util.Date(System.currentTimeMillis)) - - def TemporaryDir(implicit session: HttpSession): java.io.File = - new java.io.File(GitBucketHome, s"tmp/_upload/${session.getId}") - - def getTemporaryFile(fileId: String)(implicit session: HttpSession): java.io.File = - new java.io.File(TemporaryDir, fileId) - -// def removeTemporaryFile(fileId: String)(implicit session: HttpSession): Unit = -// getTemporaryFile(fileId).delete() - - def removeTemporaryFiles()(implicit session: HttpSession): Unit = - FileUtils.deleteDirectory(TemporaryDir) - - def getUploadedFilename(fileId: String)(implicit session: HttpSession): Option[String] = { - val filename = Option(session.getAttribute("upload_" + fileId).asInstanceOf[String]) - if(filename.isDefined){ - session.removeAttribute("upload_" + fileId) - } - filename - } - -} diff --git a/src/main/scala/util/FileUtil.scala b/src/main/scala/util/FileUtil.scala index 01f8874..b56c7f4 100644 --- a/src/main/scala/util/FileUtil.scala +++ b/src/main/scala/util/FileUtil.scala @@ -1,6 +1,6 @@ package util -import org.apache.commons.io.{IOUtils, FileUtils, FilenameUtils} +import org.apache.commons.io.{IOUtils, FileUtils} import java.net.URLConnection import java.io.File import org.apache.commons.compress.archivers.zip.{ZipArchiveEntry, ZipArchiveOutputStream} diff --git a/src/main/scala/util/Implicits.scala b/src/main/scala/util/Implicits.scala index c475136..388f9f6 100644 --- a/src/main/scala/util/Implicits.scala +++ b/src/main/scala/util/Implicits.scala @@ -1,6 +1,5 @@ package util -import scala.slick.driver.H2Driver.simple._ import scala.util.matching.Regex /** @@ -25,11 +24,6 @@ } } - // TODO Should this implicit conversion move to model.Functions? - implicit class RichColumn(c1: Column[Boolean]){ - def &&(c2: => Column[Boolean], guard: => Boolean): Column[Boolean] = if(guard) c1 && c2 else c1 - } - implicit class RichString(value: String){ def replaceBy(regex: Regex)(replace: Regex.MatchData => Option[String]): String = { val sb = new StringBuilder() diff --git a/src/main/scala/util/JGitUtil.scala b/src/main/scala/util/JGitUtil.scala index af71638..46d64e0 100644 --- a/src/main/scala/util/JGitUtil.scala +++ b/src/main/scala/util/JGitUtil.scala @@ -53,14 +53,21 @@ * @param id the commit id * @param time the commit time * @param committer the committer name + * @param mailAddress the mail address of the committer * @param shortMessage the short message * @param fullMessage the full message * @param parents the list of parent commit id */ - case class CommitInfo(id: String, time: Date, committer: String, shortMessage: String, fullMessage: String, parents: List[String]){ + case class CommitInfo(id: String, time: Date, committer: String, mailAddress: String, + shortMessage: String, fullMessage: String, parents: List[String]){ def this(rev: org.eclipse.jgit.revwalk.RevCommit) = this( - rev.getName, rev.getCommitterIdent.getWhen, rev.getCommitterIdent.getName, rev.getShortMessage, rev.getFullMessage, + rev.getName, + rev.getCommitterIdent.getWhen, + rev.getCommitterIdent.getName, + rev.getCommitterIdent.getEmailAddress, + rev.getShortMessage, + rev.getFullMessage, rev.getParents().map(_.name).toList) val summary = { diff --git a/src/main/scala/util/StringUtil.scala b/src/main/scala/util/StringUtil.scala index ae6d868..d8ab997 100644 --- a/src/main/scala/util/StringUtil.scala +++ b/src/main/scala/util/StringUtil.scala @@ -20,4 +20,9 @@ def urlDecode(value: String): String = URLDecoder.decode(value, "UTF-8") + def splitWords(value: String): Array[String] = value.split("[ \\t ]+") + + def escapeHtml(value: String): String = + value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) + } diff --git a/src/main/scala/util/Validations.scala b/src/main/scala/util/Validations.scala index 1d42d99..dd383e3 100644 --- a/src/main/scala/util/Validations.scala +++ b/src/main/scala/util/Validations.scala @@ -1,7 +1,6 @@ package util import jp.sf.amateras.scalatra.forms._ -import scala.Some trait Validations { diff --git a/src/main/scala/view/AvatarImageProvider.scala b/src/main/scala/view/AvatarImageProvider.scala index 33ac699..bd1703d 100644 --- a/src/main/scala/view/AvatarImageProvider.scala +++ b/src/main/scala/view/AvatarImageProvider.scala @@ -10,16 +10,21 @@ * Returns <img> which displays the avatar icon. * Looks up Gravatar if avatar icon has not been configured in user settings. */ - protected def getAvatarImageHtml(userName: String, size: Int, tooltip: Boolean = false)(implicit context: app.Context): Html = { + protected def getAvatarImageHtml(userName: String, size: Int, + mailAddress: String = "", tooltip: Boolean = false)(implicit context: app.Context): Html = { val src = getAccountByUserName(userName).collect { case account if(account.image.isEmpty) => s"""http://www.gravatar.com/avatar/${StringUtil.md5(account.mailAddress)}?s=${size}""" } getOrElse { - s"""${context.path}/${userName}/_avatar""" + if(mailAddress.nonEmpty){ + s"""http://www.gravatar.com/avatar/${StringUtil.md5(mailAddress)}?s=${size}""" + } else { + s"""${context.path}/${userName}/_avatar""" + } } if(tooltip){ - Html(s"""""") + Html(s"""""") } else { - Html(s"""""") + Html(s"""""") } } diff --git a/src/main/scala/view/helpers.scala b/src/main/scala/view/helpers.scala index 6dd257b..615ffab 100644 --- a/src/main/scala/view/helpers.scala +++ b/src/main/scala/view/helpers.scala @@ -40,7 +40,10 @@ * Looks up Gravatar if avatar icon has not been configured in user settings. */ def avatar(userName: String, size: Int, tooltip: Boolean = false)(implicit context: app.Context): Html = - getAvatarImageHtml(userName, size, tooltip) + getAvatarImageHtml(userName, size, "", tooltip) + + def avatar(commit: util.JGitUtil.CommitInfo, size: Int)(implicit context: app.Context): Html = + getAvatarImageHtml(commit.committer, size, commit.mailAddress) /** * Converts commit id, issue id and username to the link. @@ -80,6 +83,7 @@ def assets(implicit context: app.Context): String = s"${context.path}/assets" + def isPast(date: Date): Boolean = System.currentTimeMillis > date.getTime /** * Implicit conversion to add mkHtml() to Seq[Html]. diff --git a/src/main/twirl/account/activity.scala.html b/src/main/twirl/account/activity.scala.html index 29b2982..1f2cefc 100644 --- a/src/main/twirl/account/activity.scala.html +++ b/src/main/twirl/account/activity.scala.html @@ -1,23 +1,6 @@ -@(account: model.Account, activities: List[model.Activity])(implicit context: app.Context) +@(account: model.Account, groupNames: List[String], activities: List[model.Activity])(implicit context: app.Context) @import context._ @import view.helpers._ -@html.main(account.userName){ -
-
-
-
- -
@account.userName
-
-
-
@account.url
-
Joined on @date(account.registeredDate)
-
-
-
- @tab(account, "activity") - @helper.html.activities(activities) -
-
-
+@main(account, groupNames, "activity"){ + @helper.html.activities(activities) } diff --git a/src/main/twirl/account/main.scala.html b/src/main/twirl/account/main.scala.html new file mode 100644 index 0000000..f571851 --- /dev/null +++ b/src/main/twirl/account/main.scala.html @@ -0,0 +1,48 @@ +@(account: model.Account, groupNames: List[String], active: String)(body: Html)(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.main(account.userName){ +
+
+
+
+ +
@account.userName
+
+
+ @if(account.url.isDefined){ +
@account.url
+ } +
Joined on @date(account.registeredDate)
+
+ @if(groupNames.nonEmpty){ +
+
Groups
+ @groupNames.map { groupName => + @avatar(groupName, 36, tooltip = true) + } +
+ } + +
+
+ + @body +
+
+
+} diff --git a/src/main/twirl/account/members.scala.html b/src/main/twirl/account/members.scala.html new file mode 100644 index 0000000..14d7c77 --- /dev/null +++ b/src/main/twirl/account/members.scala.html @@ -0,0 +1,16 @@ +@(account: model.Account, members: List[String])(implicit context: app.Context) +@import context._ +@import view.helpers._ +@main(account, Nil, "members"){ + @if(members.isEmpty){ + No members + } else { + @members.map { userName => +
+
+ @avatar(userName, 20) @userName +
+
+ } + } +} \ No newline at end of file diff --git a/src/main/twirl/account/repositories.scala.html b/src/main/twirl/account/repositories.scala.html index b0912d0..03f2c6e 100644 --- a/src/main/twirl/account/repositories.scala.html +++ b/src/main/twirl/account/repositories.scala.html @@ -1,45 +1,28 @@ -@(account: model.Account, repositories: List[service.RepositoryService.RepositoryInfo])(implicit context: app.Context) +@(account: model.Account, groupNames: List[String], repositories: List[service.RepositoryService.RepositoryInfo])(implicit context: app.Context) @import context._ @import view.helpers._ -@html.main(account.userName){ -
-
-
-
- -
@account.userName
-
-
-
@account.url
-
Joined on @date(account.registeredDate)
-
-
-
- @tab(account, "repositories") - @if(repositories.isEmpty){ - No repositories - } else { - @repositories.map { repository => -
-
- @repository.owner - / - @repository.name - @if(repository.repository.isPrivate){ - - } -
- @if(repository.repository.originUserName.isDefined){ - - } - @if(repository.repository.description.isDefined){ -
@repository.repository.description
- } -
Last updated: @datetime(repository.repository.lastActivityDate)
-
+@main(account, groupNames, "repositories"){ + @if(repositories.isEmpty){ + No repositories + } else { + @repositories.map { repository => +
+
+ @repository.owner + / + @repository.name + @if(repository.repository.isPrivate){ + } +
+ @if(repository.repository.originUserName.isDefined){ + } + @if(repository.repository.description.isDefined){ +
@repository.repository.description
+ } +
Last updated: @datetime(repository.repository.lastActivityDate)
-
-
+ } + } } diff --git a/src/main/twirl/account/tab.scala.html b/src/main/twirl/account/tab.scala.html deleted file mode 100644 index 2a12a0c..0000000 --- a/src/main/twirl/account/tab.scala.html +++ /dev/null @@ -1,14 +0,0 @@ -@(account: model.Account, active: String)(implicit context: app.Context) -@import context._ -@import view.helpers._ - diff --git a/src/main/twirl/admin/menu.scala.html b/src/main/twirl/admin/menu.scala.html index d9c7969..8e366a7 100644 --- a/src/main/twirl/admin/menu.scala.html +++ b/src/main/twirl/admin/menu.scala.html @@ -10,6 +10,9 @@ System Settings +
  • + H2 Console +
  • diff --git a/src/main/twirl/admin/users/edit.scala.html b/src/main/twirl/admin/users/edit.scala.html deleted file mode 100644 index b6d3213..0000000 --- a/src/main/twirl/admin/users/edit.scala.html +++ /dev/null @@ -1,55 +0,0 @@ -@(account: Option[model.Account])(implicit context: app.Context) -@import context._ -@html.main(if(account.isEmpty) "New User" else "Update User"){ - @admin.html.menu("users"){ -
    -
    -
    -
    - - - -
    -
    - - - -
    -
    - - - -
    -
    - - - -
    -
    - - - -
    -
    -
    -
    - - @helper.html.uploadavatar(account) -
    -
    -
    -
    - - Cancel -
    -
    - } -} \ No newline at end of file diff --git a/src/main/twirl/admin/users/group.scala.html b/src/main/twirl/admin/users/group.scala.html new file mode 100644 index 0000000..96a51ed --- /dev/null +++ b/src/main/twirl/admin/users/group.scala.html @@ -0,0 +1,121 @@ +@(account: Option[model.Account], members: List[String])(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.main(if(account.isEmpty) "New Group" else "Update Group"){ + @admin.html.menu("users"){ +
    +
    +
    +
    + + + +
    +
    + + + +
    +
    + + @helper.html.uploadavatar(account) +
    +
    +
    +
    + + + + + +
    + +
    +
    +
    +
    +
    + + Cancel +
    +
    + } +} + \ No newline at end of file diff --git a/src/main/twirl/admin/users/list.scala.html b/src/main/twirl/admin/users/list.scala.html index abe96f4..df75de3 100644 --- a/src/main/twirl/admin/users/list.scala.html +++ b/src/main/twirl/admin/users/list.scala.html @@ -1,30 +1,46 @@ -@(users: List[model.Account])(implicit context: app.Context) +@(users: List[model.Account], members: Map[String, List[String]])(implicit context: app.Context) @import context._ @import view.helpers._ @html.main("Manage Users"){ @admin.html.menu("users"){
    - New User + New User + New Group
    @users.map { account => diff --git a/src/main/twirl/admin/users/user.scala.html b/src/main/twirl/admin/users/user.scala.html new file mode 100644 index 0000000..98ae9ed --- /dev/null +++ b/src/main/twirl/admin/users/user.scala.html @@ -0,0 +1,56 @@ +@(account: Option[model.Account])(implicit context: app.Context) +@import context._ +@html.main(if(account.isEmpty) "New User" else "Update User"){ + @admin.html.menu("users"){ + +
    +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    +
    +
    + + @helper.html.uploadavatar(account) +
    +
    +
    +
    + + Cancel +
    + + } +} \ No newline at end of file diff --git a/src/main/twirl/helper/activities.scala.html b/src/main/twirl/helper/activities.scala.html index 6b1662c..70435f5 100644 --- a/src/main/twirl/helper/activities.scala.html +++ b/src/main/twirl/helper/activities.scala.html @@ -12,31 +12,33 @@ @activityMessage(activity.message) @activity.additionalInfo.map { additionalInfo => - @(activity.activityType match { - case "create_wiki" => { -
    Created {additionalInfo}.
    - } - case "edit_wiki" => { -
    Edited {additionalInfo}.
    - } - case "push" => { -
    - {additionalInfo.split("\n").reverse.take(4).zipWithIndex.map{ case (commit, i) => - if(i == 3){ -
    ...
    - } else { -
    - {commit.substring(0, 7)} - {commit.substring(41)} -
    - } - }} -
    - } - case _ => { -
    {additionalInfo}
    - } - }) + @if(additionalInfo.nonEmpty){ + @(activity.activityType match { + case "create_wiki" => { +
    Created {additionalInfo}.
    + } + case "edit_wiki" => { +
    Edited {additionalInfo}.
    + } + case "push" => { +
    + {additionalInfo.split("\n").reverse.take(4).zipWithIndex.map{ case (commit, i) => + if(i == 3){ +
    ...
    + } else { +
    + {commit.substring(0, 7)} + {commit.substring(41)} +
    + } + }} +
    + } + case _ => { +
    {additionalInfo}
    + } + }) + } } } diff --git a/src/main/twirl/helper/paginator.scala.html b/src/main/twirl/helper/paginator.scala.html index 4a37d81..0925004 100644 --- a/src/main/twirl/helper/paginator.scala.html +++ b/src/main/twirl/helper/paginator.scala.html @@ -1,32 +1,32 @@ @(page: Int, count: Int, limit: Int, width: Int, baseURL: String) -@defining(view.Pagination(page, count, service.IssuesService.IssueLimit, width)){ p => +@defining(view.Pagination(page, count, limit, width)){ p => @if(p.count > p.limit){ diff --git a/src/main/twirl/issues/create.scala.html b/src/main/twirl/issues/create.scala.html index fec4193..1d4a5da 100644 --- a/src/main/twirl/issues/create.scala.html +++ b/src/main/twirl/issues/create.scala.html @@ -5,7 +5,7 @@ repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context) @import context._ @import view.helpers._ -@html.main("New Issue - " + repository.owner + "/" + repository.name){ +@html.main(s"New Issue - ${repository.owner}/${repository.name}", Some(repository)){ @html.header("issues", repository) @tab("", repository) @@ -22,7 +22,6 @@ @helper.html.dropdown() {
  • Clear assignee
  • -
  • @collaborators.map { collaborator =>
  • @avatar(collaborator, 20) @collaborator
  • } @@ -34,9 +33,23 @@ @helper.html.dropdown() {
  • No milestone
  • -
  • @milestones.map { milestone => -
  • @milestone.title
  • +
  • + + @milestone.title +
    + @milestone.dueDate.map { dueDate => + @if(isPast(dueDate)){ + Due in @date(dueDate) + } else { + Due in @date(dueDate) + } + }.getOrElse { + No due date + } +
    +
    +
  • } } } @@ -91,7 +104,7 @@ }); $('a.milestone').click(function(){ - var title = $(this).text(); + var title = $(this).data('title'); var milestoneId = $(this).data('id'); $('a.milestone i.icon-ok').attr('class', 'icon-white'); diff --git a/src/main/twirl/issues/issue.scala.html b/src/main/twirl/issues/issue.scala.html index a12eafe..f51c6f8 100644 --- a/src/main/twirl/issues/issue.scala.html +++ b/src/main/twirl/issues/issue.scala.html @@ -2,13 +2,13 @@ comments: List[model.IssueComment], issueLabels: List[model.Label], collaborators: List[String], - milestones: List[model.Milestone], + milestones: List[(model.Milestone, Int, Int)], labels: List[model.Label], hasWritePermission: Boolean, repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context) @import context._ @import view.helpers._ -@html.main("%s - Issue #%d - %s/%s".format(issue.title, issue.issueId, repository.owner, repository.name)){ +@html.main(s"${issue.title} - Issue #${issue.issueId} - ${repository.owner}/${repository.name}", Some(repository)){ @html.header("issues", repository) @tab("issues", repository)
    - Edit + @if(account.isGroupAccount){ + Edit + } else { + Edit + }
    @avatar(account.userName, 20) @account.userName - @if(account.isAdmin){ - (Administrator) + @if(account.isGroupAccount){ + (Group) } else { - (Normal) + @if(account.isAdmin){ + (Administrator) + } else { + (Normal) + } + } + @if(account.isGroupAccount){ + @members(account.userName).map { userName => + @avatar(userName, 20, tooltip = true) + } }

    - @account.mailAddress + @if(!account.isGroupAccount){ + @account.mailAddress + } @account.url.map { url => @url } @@ -32,7 +48,9 @@
    Registered: @datetime(account.registeredDate) Updated: @datetime(account.updatedDate) - Last Login: @account.lastLoginDate.map(datetime) + @if(!account.isGroupAccount){ + Last Login: @account.lastLoginDate.map(datetime) + }
    - @avatar(latestCommit.committer, 20) + @avatar(latestCommit, 20) @latestCommit.committer @datetime(latestCommit.time) @link(latestCommit.summary, repository) diff --git a/src/main/twirl/repo/commit.scala.html b/src/main/twirl/repo/commit.scala.html index 60a872b..f0009a9 100644 --- a/src/main/twirl/repo/commit.scala.html +++ b/src/main/twirl/repo/commit.scala.html @@ -8,7 +8,7 @@ @import view.helpers._ @import util.Implicits._ @import org.eclipse.jgit.diff.DiffEntry.ChangeType -@html.main(commit.shortMessage){ +@html.main(commit.shortMessage, Some(repository)){ @html.header("code", repository) @tab(commitId, repository, "commits") @@ -43,7 +43,7 @@
    - @avatar(commit.committer, 20) + @avatar(commit, 20) @commit.committer @datetime(commit.time)
    diff --git a/src/main/twirl/repo/commits.scala.html b/src/main/twirl/repo/commits.scala.html index 4f3c86f..efdf3c9 100644 --- a/src/main/twirl/repo/commits.scala.html +++ b/src/main/twirl/repo/commits.scala.html @@ -6,7 +6,7 @@ hasNext: Boolean)(implicit context: app.Context) @import context._ @import view.helpers._ -@html.main(repository.owner+"/"+repository.name) { +@html.main(s"${repository.owner}/${repository.name}", Some(repository)) { @html.header("code", repository) @tab(branch, repository, if(pathList.isEmpty) "commits" else "files")
    @@ -38,7 +38,7 @@ Browse code
    -
    @avatar(commit.committer, 40)
    +
    @avatar(commit, 40)
    @link(commit.summary, repository) @if(commit.description.isDefined){ diff --git a/src/main/twirl/repo/files.scala.html b/src/main/twirl/repo/files.scala.html index 541ccdf..7eb2e15 100644 --- a/src/main/twirl/repo/files.scala.html +++ b/src/main/twirl/repo/files.scala.html @@ -6,7 +6,7 @@ readme: Option[String])(implicit context: app.Context) @import context._ @import view.helpers._ -@html.main(repository.owner + "/" + repository.name) { +@html.main(s"${repository.owner}/${repository.name}", Some(repository)) { @html.header("code", repository) @tab(branch, repository, "files")
    @@ -34,7 +34,7 @@
    - @avatar(latestCommit.committer, 20) + @avatar(latestCommit, 20) @latestCommit.committer @datetime(latestCommit.time)
    diff --git a/src/main/twirl/repo/guide.scala.html b/src/main/twirl/repo/guide.scala.html index a51b7c5..ad02313 100644 --- a/src/main/twirl/repo/guide.scala.html +++ b/src/main/twirl/repo/guide.scala.html @@ -1,7 +1,7 @@ @(repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context) @import context._ @import view.helpers._ -@html.main(repository.owner + "/" + repository.name) { +@html.main(s"${repository.owner}/${repository.name}", Some(repository)) { @html.header("code", repository)

    Create a new repository on the command line

    diff --git a/src/main/twirl/repo/tab.scala.html b/src/main/twirl/repo/tab.scala.html
    index 8a1d859..01b2ada 100644
    --- a/src/main/twirl/repo/tab.scala.html
    +++ b/src/main/twirl/repo/tab.scala.html
    @@ -30,9 +30,9 @@
       Commits
       Tags@if(repository.tags.length > 0){ @repository.tags.length}
       
  • -
    - HTTP +
    +
  • diff --git a/src/main/twirl/repo/tags.scala.html b/src/main/twirl/repo/tags.scala.html index c8a4331..78d108f 100644 --- a/src/main/twirl/repo/tags.scala.html +++ b/src/main/twirl/repo/tags.scala.html @@ -1,7 +1,7 @@ @(repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context) @import context._ @import view.helpers._ -@html.main(repository.owner + "/" + repository.name) { +@html.main(s"${repository.owner}/${repository.name}", Some(repository)) { @html.header("code", repository) @tab(repository.repository.defaultBranch, repository, "tags", true)

    Tags

    diff --git a/src/main/twirl/search/code.scala.html b/src/main/twirl/search/code.scala.html new file mode 100644 index 0000000..20b08a6 --- /dev/null +++ b/src/main/twirl/search/code.scala.html @@ -0,0 +1,26 @@ +@(files: List[service.RepositorySearchService.FileSearchResult], + issueCount: Int, + query: String, + page: Int, + repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context) +@import context._ +@import view.helpers._ +@import service.RepositorySearchService._ +@html.main("Search Results", Some(repository)){ + @menu("code", files.size, issueCount, query, repository){ + @if(files.isEmpty){ +

    We couldn't find any code matching '@query'

    + } else { +

    We've found @files.size code @plural(files.size, "result")

    + } + @files.drop((page - 1) * CodeLimit).take(CodeLimit).map { file => +
    +
    @file.path
    +
    Latest commit at @datetime(file.lastModified)
    +
    @Html(file.highlightText)
    +
    + } + @helper.html.paginator(page, files.size, CodeLimit, 10, + s"${url(repository)}/search?q=${urlEncode(query)}&type=code") + } +} \ No newline at end of file diff --git a/src/main/twirl/search/issues.scala.html b/src/main/twirl/search/issues.scala.html new file mode 100644 index 0000000..7cd0cbf --- /dev/null +++ b/src/main/twirl/search/issues.scala.html @@ -0,0 +1,35 @@ +@(issues: List[service.RepositorySearchService.IssueSearchResult], + fileCount: Int, + query: String, + page: Int, + repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context) +@import context._ +@import view.helpers._ +@import service.RepositorySearchService._ +@html.main("Search Results", Some(repository)){ + @menu("issue", fileCount, issues.size, query, repository){ + @if(issues.isEmpty){ +

    We couldn't find any code matching '@query'

    + } else { +

    We've found @issues.size code @plural(issues.size, "result")

    + } + @issues.drop((page - 1) * IssueLimit).take(IssueLimit).map { issue => +
    +
    #@issue.issueId
    +

    @issue.title

    + @if(issue.highlightText.nonEmpty){ +
    @Html(issue.highlightText)
    + } +
    + Opened by @issue.openedUserName + at @datetime(issue.registeredDate) + @if(issue.commentCount > 0){ +   @issue.commentCount @plural(issue.commentCount, "comment") + } +
    +
    + } + @helper.html.paginator(page, issues.size, IssueLimit, 10, + s"${url(repository)}/search?q=${urlEncode(query)}&type=issue") + } +} \ No newline at end of file diff --git a/src/main/twirl/search/menu.scala.html b/src/main/twirl/search/menu.scala.html new file mode 100644 index 0000000..d5f3441 --- /dev/null +++ b/src/main/twirl/search/menu.scala.html @@ -0,0 +1,37 @@ +@(active: String, fileCount: Int, issueCount: Int, query: String, + repository: service.RepositoryService.RepositoryInfo)(body: Html)(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.header("", repository) + diff --git a/src/main/twirl/settings/collaborators.scala.html b/src/main/twirl/settings/collaborators.scala.html index 4b7cf7c..acd869b 100644 --- a/src/main/twirl/settings/collaborators.scala.html +++ b/src/main/twirl/settings/collaborators.scala.html @@ -1,7 +1,9 @@ -@(collaborators: List[String], repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context) +@(collaborators: List[String], + isGroupRepository: Boolean, + repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context) @import context._ @import view.helpers._ -@html.main("Settings"){ +@html.main("Settings", Some(repository)){ @html.header("settings", repository) @menu("collaborators", repository){

    Manage Collaborators

    @@ -9,26 +11,32 @@ @collaborators.map { collaboratorName =>
  • @collaboratorName - (remove) + @if(!isGroupRepository){ + (remove) + }
  • } -
    -
    - -
    - - -
    + @if(!isGroupRepository){ +
    +
    + +
    + + +
    + } } } \ No newline at end of file diff --git a/src/main/twirl/settings/delete.scala.html b/src/main/twirl/settings/delete.scala.html index 09de0da..b78e071 100644 --- a/src/main/twirl/settings/delete.scala.html +++ b/src/main/twirl/settings/delete.scala.html @@ -1,7 +1,7 @@ @(repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context) @import context._ @import view.helpers._ -@html.main("Delete Repository"){ +@html.main("Delete Repository", Some(repository)){ @html.header("settings", repository) @menu("delete", repository){
    diff --git a/src/main/twirl/settings/options.scala.html b/src/main/twirl/settings/options.scala.html index a89b0ad..8967864 100644 --- a/src/main/twirl/settings/options.scala.html +++ b/src/main/twirl/settings/options.scala.html @@ -1,7 +1,7 @@ @(repository: service.RepositoryService.RepositoryInfo, info: Option[Any])(implicit context: app.Context) @import context._ @import view.helpers._ -@html.main("Settings"){ +@html.main("Settings", Some(repository)){ @html.header("settings", repository) @menu("options", repository){ @helper.html.information(info) @@ -23,24 +23,24 @@
    -
    - -
    -
    - -
    +
    + +
    +
    + +
    @* diff --git a/src/main/twirl/wiki/compare.scala.html b/src/main/twirl/wiki/compare.scala.html index 1f4561d..2d4db8c 100644 --- a/src/main/twirl/wiki/compare.scala.html +++ b/src/main/twirl/wiki/compare.scala.html @@ -4,7 +4,7 @@ @import context._ @import view.helpers._ @import org.eclipse.jgit.diff.DiffEntry.ChangeType -@html.main("Compare Revisions - " + repository.owner + "/" + repository.name){ +@html.main(s"Compare Revisions - ${repository.owner}/${repository.name}", Some(repository)){ @html.header("wiki", repository) @tab("history", repository)