diff --git a/README.md b/README.md index 1e8d132..6883b24 100644 --- a/README.md +++ b/README.md @@ -6,20 +6,25 @@ The current version of GitBucket provides a basic features below: - Public / Private Git repository (http access only) -- Repository viewer (some advanced features are not implemented) +- Repository viewer (some advanced features such as online file editing are not implemented) +- Repository search (Code and Issues) - Wiki - Issues +- Fork / Pull request +- Mail notification - Activity timeline - User management (for Administrators) +- Group (like Organization in Github) +- LDAP integration +- Gravatar support Following features are not implemented, but we will make them in the future release! -- Fork and pull request -- Search +- File editing in repository viewer +- Comment for the changeset - Network graph - Statics - Watch / Star -- Team management (like Organization in Github) If you want to try the development version of GitBucket, see the documentation for developers at [Wiki](https://github.com/takezoe/gitbucket/wiki). @@ -32,29 +37,64 @@ The default administrator account is **root** and password is **root**. -To upgrade GitBucket, only replace gitbucket.war. +(Since 1.6) or you can start GitBucket by ```java -jar gitbucket.war``` without servlet container. In this case, GitBucket URL is **http://[hostname]:8080/**. You can specify following options. + +- --port=[NUMBER] +- --prefix=[CONTEXTPATH] + +To upgrade GitBucket, only replace gitbucket.war. All GitBucket data is stored in HOME/gitbucket. So if you want to back up GitBucket data, copy this directory to the other disk. + +For Installation on Windows Server with IIS see [this wiki page](https://github.com/takezoe/gitbucket/wiki/Installation-on-IIS-and-Helicontech-Zoo) Release Notes -------- -### 1.3 - xx 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.6 - 1 Oct 2013 +- Web hook +- Performance improvement for pull request +- Executable war file +- Specify suitable Content-Type for downloaded files in the repository viewer +- Fix some bugs + +### 1.5 - 4 Sep 2013 +- Fork and pull request +- LDAP authentication +- Mail notification +- Add an option to turn off the gravatar support +- Add the branch tab in the repository viewer +- Encoding auto detection for the file content in the repository viewer +- Add favicon, header logo and icons for the timeline +- Specify data directory via environment variable GITBUCKET_HOME +- Fix some bugs + +### 1.4 - 31 Jul 2013 +- Group management +- Repository search for code and issues +- Display user related issues on the dashboard +- Display participants avatar of issues on the issue page +- Performance improvement for repository viewer +- Alert by milestone due date +- H2 database administration console +- Fix some bugs + +### 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 +- Allow multi-byte characters as wiki page name +- Allow to create the empty repository +- Fix some bugs ### 1.2 - 09 Jul 2013 -- Added activity timeline. -- Bugfix for Git 1.8.1.5 or later. -- Allows multi-byte characters as label. -- Fixed some bugs. +- Add activity timeline +- Bugfix for Git 1.8.1.5 or later +- Allow multi-byte characters as label +- Fix some bugs ### 1.1 - 05 Jul 2013 -- Fixed some bugs. -- Upgrade to JGit 3.0. +- Fix some bugs +- Upgrade to JGit 3.0 ### 1.0 - 04 Jul 2013 -- This is a first public release. +- This is a first public release diff --git a/build.xml b/build.xml new file mode 100644 index 0000000..187359e --- /dev/null +++ b/build.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/embed-jetty/javax.servlet-3.0.0.v201112011016.jar b/embed-jetty/javax.servlet-3.0.0.v201112011016.jar new file mode 100644 index 0000000..b135409 --- /dev/null +++ b/embed-jetty/javax.servlet-3.0.0.v201112011016.jar Binary files differ diff --git a/embed-jetty/jetty-continuation-8.1.8.v20121106.jar b/embed-jetty/jetty-continuation-8.1.8.v20121106.jar new file mode 100644 index 0000000..1f3f59c --- /dev/null +++ b/embed-jetty/jetty-continuation-8.1.8.v20121106.jar Binary files differ diff --git a/embed-jetty/jetty-http-8.1.8.v20121106.jar b/embed-jetty/jetty-http-8.1.8.v20121106.jar new file mode 100644 index 0000000..80a2ba7 --- /dev/null +++ b/embed-jetty/jetty-http-8.1.8.v20121106.jar Binary files differ diff --git a/embed-jetty/jetty-io-8.1.8.v20121106.jar b/embed-jetty/jetty-io-8.1.8.v20121106.jar new file mode 100644 index 0000000..21d1d67 --- /dev/null +++ b/embed-jetty/jetty-io-8.1.8.v20121106.jar Binary files differ diff --git a/embed-jetty/jetty-security-8.1.8.v20121106.jar b/embed-jetty/jetty-security-8.1.8.v20121106.jar new file mode 100644 index 0000000..aac3f19 --- /dev/null +++ b/embed-jetty/jetty-security-8.1.8.v20121106.jar Binary files differ diff --git a/embed-jetty/jetty-server-8.1.8.v20121106.jar b/embed-jetty/jetty-server-8.1.8.v20121106.jar new file mode 100644 index 0000000..b21842e --- /dev/null +++ b/embed-jetty/jetty-server-8.1.8.v20121106.jar Binary files differ diff --git a/embed-jetty/jetty-servlet-8.1.8.v20121106.jar b/embed-jetty/jetty-servlet-8.1.8.v20121106.jar new file mode 100644 index 0000000..df9583f --- /dev/null +++ b/embed-jetty/jetty-servlet-8.1.8.v20121106.jar Binary files differ diff --git a/embed-jetty/jetty-util-8.1.8.v20121106.jar b/embed-jetty/jetty-util-8.1.8.v20121106.jar new file mode 100644 index 0000000..18c2270 --- /dev/null +++ b/embed-jetty/jetty-util-8.1.8.v20121106.jar Binary files differ diff --git a/embed-jetty/jetty-webapp-8.1.8.v20121106.jar b/embed-jetty/jetty-webapp-8.1.8.v20121106.jar new file mode 100644 index 0000000..23d18ab --- /dev/null +++ b/embed-jetty/jetty-webapp-8.1.8.v20121106.jar Binary files differ diff --git a/embed-jetty/jetty-xml-8.1.8.v20121106.jar b/embed-jetty/jetty-xml-8.1.8.v20121106.jar new file mode 100644 index 0000000..f8daf47 --- /dev/null +++ b/embed-jetty/jetty-xml-8.1.8.v20121106.jar Binary files differ diff --git a/etc/gitbucket.erd b/etc/gitbucket.erd new file mode 100644 index 0000000..74f5fb4 --- /dev/null +++ b/etc/gitbucket.erd @@ -0,0 +1,1745 @@ + + + + + + + 2 + + + + + + + + + + + 2 + + + + + + -1 + -1 + 33 + 18 + + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + -1 + -1 + 723 + 138 + + + + + + + + 2 + + + + + + + + + + + 2 + + + + + + -1 + -1 + 1182 + 339 + + + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + -1 + -1 + 1301 + 836 + + + + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + -1 + -1 + 684 + 858 + + + + + + + + + 2 + + + + + + + + + + + 2 + + + + + + -1 + -1 + 293 + 478 + + + + + + + + 2 + + + + + + + ISSUE_FK_1 + + + + + + + + 2 + + + + + + + + + + + 2 + + + + + + -1 + -1 + 875 + 677 + + + + + + + + 2 + + + + + + + MILESTONE_FK_1 + + + + + + + + + MILESTONE + Milestone + + + + USER_NAME + User Name + + VARCHAR + 文字列 + true + 12 + + 100 + true + true + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + true + true + + false + + + + MILESTONE_ID + Milestone ID + + INT + 整数 + false + 4 + + 10 + true + true + + true + + + + TITLE + Title + + VARCHAR + 文字列 + true + 12 + + 100 + true + false + + false + + + + DESCRIPTION + Description + + TEXT + 文字列 + true + 2005 + + + false + false + + false + + + + DUE_DATE + Due Date + + TIMESTAMP + 日時 + false + 93 + + 10 + false + false + + false + + + + CLOSED_DATE + Closed Date + + 10 + false + false + + false + + + + + + 255 + 255 + 206 + + + + ISSUE_FK_2 + + + + + + + + 2 + + + + + + + ISSUE_FK_2 + + + + USER_NAME + User Name + + VARCHAR + 文字列 + true + 12 + + 100 + true + true + + false + + + + OPENED_USER_NAME + Opened User Name + + 100 + true + false + + false + + + + + + + + + + + 2 + + + + + + + ISSUE_FK_2 + + + + + ASSIGNED_USER_NAME + Assinged User Name + + 100 + false + false + + false + + + + + + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + -1 + -1 + 18 + 776 + + + + + + + + + 2 + + + + + + + ISSUE_COMMENT_FK_2 + + + + + COMMENTED_USER_NAME + Commented User Name + + 100 + true + false + + false + + + + + + + + + + ISSUE_COMMENT + Issue Comment + + + + USER_NAME + User Name + + 100 + true + true + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + true + true + + false + + + + ISSUE_ID + Issue ID + + 10 + true + true + + false + + + + COMMENT_ID + Comment ID + + 10 + true + true + + true + + + + ACTION + Action + + VARCHAR + 文字列 + true + 12 + + 20 + true + false + Expand to VARCHAR(20) from VARCHAR(10) in 1.3 + false + + + + + CONTENT + Content + + TEXT + 文字列 + true + 2005 + + + true + false + + false + + + + REGISTERED_DATE + Registered Date + + TIMESTAMP + 日時 + false + 93 + + 10 + true + false + + false + + + + UPDATED_DATE + Updated Date + + 10 + true + false + + false + + + + + + 255 + 255 + 206 + + + + + ISSUE_COMMENT_FK_1 + + + + + + + ISSUE + Issue + + + + USER_NAME + User Name + + 100 + true + true + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + true + true + + false + + + + ISSUE_ID + Issue ID + + 10 + true + true + + false + + + + + MILESTONE_ID + Milestone ID + + 10 + false + false + + false + + + + + TITLE + Title + + + true + false + + false + + + + CONTENT + Content + + + true + false + + false + + + + REGISTERED_DATE + Registered Date + + 10 + true + false + + false + + + + UPDATED_DATE + Updated Date + + 10 + true + false + + false + + + + + + 255 + 255 + 206 + + + + ISSUE_LABEL_FK_2 + + + + + + + ISSUE_LABEL + Issue Label + + + + USER_NAME + User Name + + 100 + true + true + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + true + true + + false + + + + ISSUE_ID + Issue ID + + 10 + true + true + + false + + + + LABEL_ID + Label ID + + 10 + true + true + + false + + + + + + 255 + 255 + 206 + + + + + ISSUE_LABEL_FK_1 + + + + + + LABEL + Label + + + + USER_NAME + User Name + + 100 + false + true + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + false + true + + false + + + + LABEL_ID + Label ID + + INT + 整数 + false + 4 + + 10 + true + true + + true + + + + LABEL_NAME + Label Name + + 100 + true + false + + false + + + + COLOR + Color + + CHAR + 文字 + true + 1 + + 6 + true + false + + false + + + + + + 255 + 255 + 206 + + + + + LABEL_FK_1 + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + -1 + -1 + 481 + 361 + + + + + + + + ISSUE_ID + Issue ID + + + + USER_NAME + User Name + + 100 + false + false + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + false + false + + false + + + + ISSUE_ID + Issue ID + + 10 + true + false + + false + + + + + + 255 + 255 + 206 + + + + + ISSUE_ID_FK_1 + + + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + -1 + -1 + 1199 + 25 + + + + + + + + + 2 + + + + + + + ACTIVITY_FK_2 + + + + + ACTIVITY_USER_NAME + Activity User Name + + 100 + true + false + + false + + + + + + + + + + ACTIVITY + Activity + Since 1.2 + + + ACTIVITY_ID + Activity ID + + INT + 整数 + false + 4 + + 10 + true + true + + true + + + + USER_NAME + User Name + + 100 + true + false + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + true + false + + false + + + + + ACTIVITY_TYPE + Activity Type + + 100 + true + false + + false + + + + MESSAGE + Message + + + true + false + + false + + + + ADDITIONAL_INFO + Additional Information + + + false + false + + false + + + + ACTIVITY_DATE + Activity Date + + 10 + true + false + + false + + + + + + 255 + 255 + 206 + + + + + ACTIVITY_FK_1 + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + -1 + -1 + 1451 + 577 + + + + + + + + COMMIT_LOG + Commit Log + Since 1.2 + + + USER_NAME + User Name + + 100 + true + true + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + true + true + + false + + + + COMMIT_ID + Commit ID + + 40 + true + true + + false + + + + + + 255 + 255 + 206 + + + + + COMMIT_LOG_FK_1 + + + + + + REPOSITORY + Repository + + + + USER_NAME + User Name + + 100 + true + true + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + true + true + + false + + + + REPOSITORY_TYPE + Repository Type + + 10 + true + false + 0:Public 1:Private + false + 0 + + + DESCRIPTION + Description + + TEXT + 文字列 + true + 2005 + + + false + false + + false + + + + DEFAULT_BRANCH + Default Branch + + VARCHAR + 文字列 + true + 12 + + 100 + false + false + + false + + + + REGISTERED_DATE + Registered Date + + TIMESTAMP + 日時 + false + 93 + + 10 + true + false + + false + + + + UPDATED_DATE + Updated Date + + 10 + true + false + + false + + + + LAST_ACTIVITY_DATE + Last Activity Date + + 10 + true + false + + false + + + + + + IDX_PROJECT_1 + + UNIQUE + + + PROJECT_NAME + USER_ID + + + + + 255 + 255 + 206 + + + + PROJECT_ACCOUNT_FK_1 + + + + + REPOSITORY_NAME + Repository Name + + 100 + true + true + + false + + + + + + + + + + + COLLABORATORS + Collaborators + + + + USER_NAME + User Name + + 100 + true + true + + false + + + + + COLLABORATOR_NAME + Collaborator Name + + 100 + true + true + + false + + + + + + 255 + 255 + 206 + + + + + PROJECT_ACCOUNT_FK_2 + + + + + + + + + + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + -1 + -1 + 432 + 240 + + + + + + + + + 2 + + + + + + + GROUP_MEMBER_FK_2 + + + + + + + GROUP_MEMBER + Group Member + Since 1.4 + + + GROUP_NAME + Group Name + + 100 + true + true + + false + + + + USER_NAME + User Name + + 100 + true + true + + false + + + + + + 255 + 255 + 206 + + + + + GROUP_MEMBER_FK_1 + + + + + + + + + + + + ACCOUNT + Account + + + + + MAIL_ADDRESS + Mail Address + + VARCHAR + 文字列 + true + 12 + + 100 + true + false + + false + + + + PASSWORD + Password + + 40 + true + false + + false + + + + ADMINISTRATOR + Administrator + + BOOLEAN + 真偽値 + false + 16 + + 10 + true + false + + false + 0 + + + URL + URL + + 200 + false + false + + false + + + + REGISTERED_DATE + Registered Date + + 10 + true + false + + false + + + + UPDATED_DATE + Updated Date + + 10 + true + false + + false + + + + LAST_LOGIN_DATE + Last Login Date + + 10 + false + false + + false + + + + IMAGE + Image + + 100 + false + false + Since 1.3 + false + + + + GROUP_ACCOUNT + Group Account + + BOOLEAN + 真偽値 + false + 16 + + 10 + true + false + Since 1.4 + false + FALSE + + + + + IDX_ACCOUNT_1 + + UNIQUE + + + MAIL_ADDRESS + + + + + 255 + 255 + 206 + + + + + + + + + + + + + + + + + + + + 2 + + + + + + -1 + -1 + 410 + 860 + + + + + + ISSUE_OUTLINE_VIEW + Issue Outline View + Since 1.4 + + + USER_NAME + User Name + + 100 + false + false + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + false + false + + false + + + + ISSUE_ID + Issue ID + + INT + 整数 + false + 4 + + 10 + false + false + + false + + + + COMMENT_COUNT + Comment Count + + 10 + false + false + + false + + + + + + 210 + 232 + 249 + + + + + + H2 + false + + sun.jdbc.odbc.JdbcOdbc + + + + + + false + + \ No newline at end of file diff --git a/etc/icons.svg b/etc/icons.svg new file mode 100644 index 0000000..6943304 --- /dev/null +++ b/etc/icons.svg @@ -0,0 +1,751 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gitbucket.erd b/gitbucket.erd deleted file mode 100644 index 7b63794..0000000 --- a/gitbucket.erd +++ /dev/null @@ -1,1200 +0,0 @@ - - - - - - - 2 - - - - - - - - - - - 2 - - - - - - -1 - -1 - 37 - 36 - - - - - - - - - 2 - - - - - - - - - - 2 - - - - - - -1 - -1 - 751 - 47 - - - - - - - - 2 - - - - - - - - - - - 2 - - - - - - -1 - -1 - 882 - 239 - - - - - - - - - - 2 - - - - - - - - - - 2 - - - - - - -1 - -1 - 940 - 615 - - - - - - - - - - - 2 - - - - - - - - - - 2 - - - - - - -1 - -1 - 420 - 758 - - - - - - - - - 2 - - - - - - - - - - - 2 - - - - - - -1 - -1 - 307 - 356 - - - - - - - - 2 - - - - - - - ISSUE_FK_1 - - - - - - - - 2 - - - - - - - - - - - 2 - - - - - - -1 - -1 - 641 - 569 - - - - - - - - 2 - - - - - - - MILESTONE_FK_1 - - - - - - - - - MILESTONE - Milestone - - - - USER_NAME - User Name - - VARCHAR - 文字列 - true - 12 - - 100 - true - true - - false - - - - REPOSITORY_NAME - Repository Name - - 100 - true - true - - false - - - - MILESTONE_ID - Milestone ID - - INT - 整数 - false - 4 - - 10 - true - true - - true - - - - MILESTONE_NAME - Milestone Name - - 100 - true - false - - false - - - - - - 255 - 255 - 206 - - - - ISSUE_FK_2 - - - - - - - - 2 - - - - - - - ISSUE_FK_2 - - - - USER_NAME - User Name - - VARCHAR - 文字列 - true - 12 - - 100 - true - true - - false - - - - OPENED_USER_NAME - Opened User Name - - 100 - true - false - - false - - - - - - - - - - - - - 2 - - - - - - - - - - 2 - - - - - - -1 - -1 - 26 - 660 - - - - - - - - - 2 - - - - - - - ISSUE_COMMENT_FK_2 - - - - - COMMENTED_USER_NAME - Commented User Name - - 100 - true - false - - false - - - - - - - - - - ISSUE_COMMENT - Issue Comment - - - - USER_NAME - User Name - - 100 - true - true - - false - - - - REPOSITORY_NAME - Repository Name - - 100 - true - true - - false - - - - ISSUE_ID - Issue ID - - 10 - true - true - - false - - - - COMMENT_ID - Comment ID - - 10 - true - true - - true - - - - - CONTENT - Content - - TEXT - 文字列 - true - 2005 - - - true - false - - false - - - - REGISTERED_DATE - Registered Date - - TIMESTAMP - 日時 - false - 93 - - 10 - true - false - - false - - - - UPDATED_DATE - Updated Date - - 10 - true - false - - false - - - - - - 255 - 255 - 206 - - - - - ISSUE_COMMENT_FK_1 - - - - - - - ISSUE - Issue - - - - USER_NAME - User Name - - 100 - true - true - - false - - - - REPOSITORY_NAME - Repository Name - - 100 - true - true - - false - - - - ISSUE_ID - Issue ID - - 10 - true - true - - false - - - - - MILESTONE_ID - Milestone ID - - 10 - false - false - - false - - - - TITLE - Title - - - true - false - - false - - - - CONTENT - Content - - - true - false - - false - - - - REGISTERED_DATE - Registered Date - - 10 - true - false - - false - - - - UPDATED_DATE - Updated Date - - 10 - true - false - - false - - - - - - 255 - 255 - 206 - - - - ISSUE_LABEL_FK_2 - - - - - - - ISSUE_LABEL - Issue Label - - - - USER_NAME - User Name - - 100 - true - true - - false - - - - REPOSITORY_NAME - Repository Name - - 100 - true - true - - false - - - - ISSUE_ID - Issue ID - - 10 - true - true - - false - - - - LABEL_ID - Label ID - - 10 - true - true - - false - - - - - - 255 - 255 - 206 - - - - - ISSUE_LABEL_FK_1 - - - - - - LABEL - Label - - - - USER_NAME - User Name - - 100 - false - true - - false - - - - REPOSITORY_NAME - Repository Name - - 100 - false - true - - false - - - - LABEL_ID - Label ID - - INT - 整数 - false - 4 - - 10 - true - true - - true - - - - LABEL_NAME - Label Name - - 100 - true - false - - false - - - - COLOR - Color - - CHAR - 文字 - true - 1 - - 6 - true - false - - false - - - - - - 255 - 255 - 206 - - - - - LABEL_FK_1 - - - - - - - - 2 - - - - - - - - - - 2 - - - - - - -1 - -1 - 388 - 166 - - - - - - - - ISSUE_ID - Issue ID - - - - USER_NAME - User Name - - 100 - false - false - - false - - - - REPOSITORY_NAME - Repository Name - - 100 - false - false - - false - - - - ISSUE_ID - Issue ID - - 10 - true - false - - false - - - - - - 255 - 255 - 206 - - - - - ISSUE_ID_FK_1 - - - - - - - - REPOSITORY - Repository - - - - USER_NAME - User Name - - 100 - true - true - - false - - - - REPOSITORY_NAME - Repository Name - - 100 - true - true - - false - - - - REPOSITORY_TYPE - Repository Type - - 10 - true - false - 0:Public 1:Private - false - 0 - - - DESCRIPTION - Description - - TEXT - 文字列 - true - 2005 - - - false - false - - false - - - - DEFAULT_BRANCH - Default Branch - - VARCHAR - 文字列 - true - 12 - - 100 - false - false - - false - - - - REGISTERED_DATE - Registered Date - - TIMESTAMP - 日時 - false - 93 - - 10 - true - false - - false - - - - UPDATED_DATE - Updated Date - - 10 - true - false - - false - - - - LAST_ACTIVITY_DATE - Last Activity Date - - 10 - true - false - - false - - - - - - IDX_PROJECT_1 - - UNIQUE - - - PROJECT_NAME - USER_ID - - - - - 255 - 255 - 206 - - - - PROJECT_ACCOUNT_FK_1 - - - - - REPOSITORY_NAME - Repository Name - - 100 - true - true - - false - - - - - - - - - - - COLLABORATORS - Collaborators - - - - USER_NAME - User Name - - 100 - true - true - - false - - - - - COLLABORATOR_NAME - Collaborator Name - - 100 - true - true - - false - - - - - - 255 - 255 - 206 - - - - - PROJECT_ACCOUNT_FK_2 - - - - - - - - - - - - - ACCOUNT - Account - - - - - MAIL_ADDRESS - Mail Address - - VARCHAR - 文字列 - true - 12 - - 100 - true - false - - false - - - - PASSWORD - Password - - 20 - true - false - - false - - - - USER_TYPE - User Type - - INT - 整数 - false - 4 - - 10 - true - false - 0:Normal 1:Administrator - false - 0 - - - URL - URL - - 200 - false - false - - false - - - - REGISTERED_DATE - Registered Date - - 10 - true - false - - false - - - - UPDATED_DATE - Updated Date - - 10 - true - false - - false - - - - LAST_LOGIN_DATE - Last Login Date - - 10 - false - false - - false - - - - - - IDX_ACCOUNT_1 - - UNIQUE - - - MAIL_ADDRESS - - - - - 255 - 255 - 206 - - - - - - - - - - - - - - H2 - false - - sun.jdbc.odbc.JdbcOdbc - - - - - - false - - \ No newline at end of file diff --git a/lib/scalatra-forms_2.10-0.0.1.jar b/lib/scalatra-forms_2.10-0.0.1.jar deleted file mode 100644 index baea441..0000000 --- a/lib/scalatra-forms_2.10-0.0.1.jar +++ /dev/null Binary files differ diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..db255c2 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.12.3 \ No newline at end of file diff --git a/project/build.scala b/project/build.scala index 774c0dd..61db306 100644 --- a/project/build.scala +++ b/project/build.scala @@ -2,6 +2,7 @@ import Keys._ import org.scalatra.sbt._ import org.scalatra.sbt.PluginKeys._ +import sbt.ScalaVersion import twirl.sbt.TwirlPlugin._ import com.typesafe.sbteclipse.plugin.EclipsePlugin.EclipseKeys @@ -9,8 +10,8 @@ val Organization = "jp.sf.amateras" val Name = "gitbucket" val Version = "0.0.1" - val ScalaVersion = "2.10.1" - val ScalatraVersion = "2.2.0" + val ScalaVersion = "2.10.3" + val ScalatraVersion = "2.2.1" lazy val project = Project ( "gitbucket", @@ -20,24 +21,35 @@ name := Name, version := Version, scalaVersion := ScalaVersion, - resolvers += Classpaths.typesafeReleases, + resolvers ++= Seq( + Classpaths.typesafeReleases, + "amateras-repo" at "http://amateras.sourceforge.jp/mvn/" + ), + scalacOptions := Seq("-deprecation"), libraryDependencies ++= Seq( "org.eclipse.jgit" % "org.eclipse.jgit.http.server" % "3.0.0.201306101825-r", - "org.apache.commons" % "commons-io" % "1.3.2", "org.scalatra" %% "scalatra" % ScalatraVersion, "org.scalatra" %% "scalatra-specs2" % ScalatraVersion % "test", "org.scalatra" %% "scalatra-json" % ScalatraVersion, - "org.json4s" %% "json4s-jackson" % "3.2.4", + "org.json4s" %% "json4s-jackson" % "3.2.5", + "jp.sf.amateras" %% "scalatra-forms" % "0.0.2", "commons-io" % "commons-io" % "2.4", - "org.pegdown" % "pegdown" % "1.3.0", + "org.pegdown" % "pegdown" % "1.4.1", "org.apache.commons" % "commons-compress" % "1.5", + "org.apache.commons" % "commons-email" % "1.3.1", + "org.apache.httpcomponents" % "httpclient" % "4.3", "com.typesafe.slick" %% "slick" % "1.0.1", - "com.h2database" % "h2" % "1.3.171", - "ch.qos.logback" % "logback-classic" % "1.0.6" % "runtime", - "org.eclipse.jetty" % "jetty-webapp" % "8.1.8.v20121106" % "container", - "org.eclipse.jetty.orbit" % "javax.servlet" % "3.0.0.v201112011016" % "container;provided;test" artifacts (Artifact("javax.servlet", "jar", "jar")) + "com.novell.ldap" % "jldap" % "2009-10-07", + "com.h2database" % "h2" % "1.3.173", + "ch.qos.logback" % "logback-classic" % "1.0.13" % "runtime", + "org.eclipse.jetty" % "jetty-webapp" % "8.1.8.v20121106" % "container;provided", + "org.eclipse.jetty.orbit" % "javax.servlet" % "3.0.0.v201112011016" % "container;provided;test" artifacts (Artifact("javax.servlet", "jar", "jar")), + "junit" % "junit" % "4.11" % "test" ), - EclipseKeys.withSource := true + EclipseKeys.withSource := true, + javacOptions in compile ++= Seq("-target", "6", "-source", "6"), + testOptions in Test += Tests.Argument(TestFrameworks.Specs2, "junitxml", "console"), + packageOptions += Package.MainClass("JettyLauncher") ) ++ seq(Twirl.settings: _*) ) -} \ No newline at end of file +} diff --git a/project/plugins.sbt b/project/plugins.sbt index 3249832..15ac806 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,7 +1,9 @@ -addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "2.1.2") +addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "2.2.0") -addSbtPlugin("com.github.mpeltonen" % "sbt-idea" % "1.2.0") +addSbtPlugin("com.github.mpeltonen" % "sbt-idea" % "1.5.1") -addSbtPlugin("org.scalatra.sbt" % "scalatra-sbt" % "0.2.0") +addSbtPlugin("org.scalatra.sbt" % "scalatra-sbt" % "0.3.0") addSbtPlugin("io.spray" % "sbt-twirl" % "0.6.1") + +addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.1.2") diff --git a/sbt.sh b/sbt.sh new file mode 100755 index 0000000..dd2a62a --- /dev/null +++ b/sbt.sh @@ -0,0 +1 @@ +java -XX:+CMSClassUnloadingEnabled -XX:MaxPermSize=256m -Xmx512M -Xss2M -jar `dirname $0`/sbt-launch-0.12.3.jar "$@" diff --git a/src/main/java/JettyLauncher.java b/src/main/java/JettyLauncher.java new file mode 100644 index 0000000..356da21 --- /dev/null +++ b/src/main/java/JettyLauncher.java @@ -0,0 +1,53 @@ +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.nio.SelectChannelConnector; +import org.eclipse.jetty.webapp.WebAppContext; + +import java.net.URL; +import java.security.ProtectionDomain; + +public class JettyLauncher { + public static void main(String[] args) throws Exception { + String host = null; + int port = 8080; + String contextPath = "/"; + + for(String arg: args){ + if(arg.startsWith("--") && arg.contains("=")){ + String[] dim = arg.split("="); + if(dim.length >= 2){ + if(dim[0].equals("--host")){ + host = dim[1]; + } else if(dim[0].equals("--port")){ + port = Integer.parseInt(dim[1]); + } else if(dim[0].equals("--prefix")){ + contextPath = dim[1]; + } + } + } + } + + Server server = new Server(); + + SelectChannelConnector connector = new SelectChannelConnector(); + if(host != null){ + connector.setHost(host); + } + connector.setMaxIdleTime(1000 * 60 * 60); + connector.setSoLingerTime(-1); + connector.setPort(port); + server.addConnector(connector); + + WebAppContext context = new WebAppContext(); + ProtectionDomain domain = JettyLauncher.class.getProtectionDomain(); + URL location = domain.getCodeSource().getLocation(); + + context.setContextPath(contextPath); + context.setDescriptor(location.toExternalForm() + "/WEB-INF/web.xml"); + context.setServer(server); + context.setWar(location.toExternalForm()); + + server.setHandler(context); + server.start(); + server.join(); + } +} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 1fdfbec..fccd344 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -1,4 +1,17 @@ - + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/noimage.png b/src/main/resources/noimage.png index 39d1ab4..b338e16 100644 --- a/src/main/resources/noimage.png +++ b/src/main/resources/noimage.png Binary files differ diff --git a/src/main/resources/update/1_4.sql b/src/main/resources/update/1_4.sql new file mode 100644 index 0000000..2d3c492 --- /dev/null +++ b/src/main/resources/update/1_4.sql @@ -0,0 +1,24 @@ +CREATE TABLE GROUP_MEMBER( + GROUP_NAME VARCHAR(100) NOT NULL, + USER_NAME VARCHAR(100) NOT NULL +); + +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; + +CREATE OR REPLACE VIEW ISSUE_OUTLINE_VIEW AS + SELECT + A.USER_NAME, + A.REPOSITORY_NAME, + A.ISSUE_ID, + NVL(B.COMMENT_COUNT, 0) AS COMMENT_COUNT + FROM ISSUE A + LEFT OUTER JOIN ( + SELECT USER_NAME, REPOSITORY_NAME, ISSUE_ID, COUNT(COMMENT_ID) AS COMMENT_COUNT FROM ISSUE_COMMENT + WHERE ACTION IN ('comment', 'close_comment', 'reopen_comment') + GROUP BY USER_NAME, REPOSITORY_NAME, ISSUE_ID + ) B + ON (A.USER_NAME = B.USER_NAME AND A.REPOSITORY_NAME = B.REPOSITORY_NAME AND A.ISSUE_ID = B.ISSUE_ID); diff --git a/src/main/resources/update/1_5.sql b/src/main/resources/update/1_5.sql new file mode 100644 index 0000000..03fc1bf --- /dev/null +++ b/src/main/resources/update/1_5.sql @@ -0,0 +1,21 @@ +ALTER TABLE REPOSITORY ADD COLUMN ORIGIN_USER_NAME VARCHAR(100); +ALTER TABLE REPOSITORY ADD COLUMN ORIGIN_REPOSITORY_NAME VARCHAR(100); +ALTER TABLE REPOSITORY ADD COLUMN PARENT_USER_NAME VARCHAR(100); +ALTER TABLE REPOSITORY ADD COLUMN PARENT_REPOSITORY_NAME VARCHAR(100); + +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, + COMMIT_ID_FROM VARCHAR(40) NOT NULL, + COMMIT_ID_TO VARCHAR(40) 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 ISSUE ADD COLUMN PULL_REQUEST BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/main/resources/update/1_6.sql b/src/main/resources/update/1_6.sql new file mode 100644 index 0000000..43eb92d --- /dev/null +++ b/src/main/resources/update/1_6.sql @@ -0,0 +1,8 @@ +CREATE TABLE WEB_HOOK ( + USER_NAME VARCHAR(100) NOT NULL, + REPOSITORY_NAME VARCHAR(100) NOT NULL, + URL VARCHAR(200) NOT NULL +); + +ALTER TABLE WEB_HOOK ADD CONSTRAINT IDX_WEB_HOOK_PK PRIMARY KEY (USER_NAME, REPOSITORY_NAME, URL); +ALTER TABLE WEB_HOOK ADD CONSTRAINT IDX_WEB_HOOK_FK0 FOREIGN KEY (USER_NAME, REPOSITORY_NAME) REFERENCES REPOSITORY (USER_NAME, REPOSITORY_NAME); diff --git a/src/main/resources/update/1_7.sql b/src/main/resources/update/1_7.sql new file mode 100644 index 0000000..9005ff9 --- /dev/null +++ b/src/main/resources/update/1_7.sql @@ -0,0 +1,5 @@ +ALTER TABLE ACCOUNT ADD COLUMN FULL_NAME VARCHAR(100); + +UPDATE ACCOUNT SET FULL_NAME = USER_NAME WHERE FULL_NAME IS NULL; + +ALTER TABLE ACCOUNT ALTER COLUMN FULL_NAME SET NOT NULL; diff --git a/src/main/scala/ScalatraBootstrap.scala b/src/main/scala/ScalatraBootstrap.scala index f84c99c..874bbbd 100644 --- a/src/main/scala/ScalatraBootstrap.scala +++ b/src/main/scala/ScalatraBootstrap.scala @@ -5,8 +5,10 @@ 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 DashboardController, "/*") context.mount(new UserManagementController, "/*") context.mount(new SystemSettingsController, "/*") context.mount(new CreateRepositoryController, "/*") @@ -16,6 +18,7 @@ context.mount(new LabelsController, "/*") context.mount(new MilestonesController, "/*") context.mount(new IssuesController, "/*") + context.mount(new PullRequestsController, "/*") context.mount(new RepositorySettingsController, "/*") val dir = new java.io.File(_root_.util.Directory.GitBucketHome) diff --git a/src/main/scala/app/AccountController.scala b/src/main/scala/app/AccountController.scala index 68b2d53..6691bcc 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 @@ -16,15 +15,16 @@ self: SystemSettingsService with AccountService with RepositoryService with ActivityService with OneselfAuthenticator => - case class AccountNewForm(userName: String, password: String,mailAddress: String, + case class AccountNewForm(userName: String, password: String, fullName: String, mailAddress: String, url: Option[String], fileId: Option[String]) - case class AccountEditForm(password: Option[String], mailAddress: String, + case class AccountEditForm(password: Option[String], fullName: String, mailAddress: String, url: Option[String], fileId: Option[String], clearImage: Boolean) val newForm = mapping( "userName" -> trim(label("User name" , text(required, maxlength(100), identifier, uniqueUserName))), "password" -> trim(label("Password" , text(required, maxlength(20)))), + "fullName" -> trim(label("Full Name" , text(required, maxlength(100)))), "mailAddress" -> trim(label("Mail Address" , text(required, maxlength(100), uniqueMailAddress()))), "url" -> trim(label("URL" , optional(text(maxlength(200))))), "fileId" -> trim(label("File ID" , optional(text()))) @@ -32,6 +32,7 @@ val editForm = mapping( "password" -> trim(label("Password" , optional(text(maxlength(20))))), + "fullName" -> trim(label("Full Name" , text(required, maxlength(100)))), "mailAddress" -> trim(label("Mail Address" , text(required, maxlength(100), uniqueMailAddress("userName")))), "url" -> trim(label("URL" , optional(text(maxlength(200))))), "fileId" -> trim(label("File ID" , optional(text()))), @@ -43,12 +44,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(context.loginAccount, baseUrl, Some(userName))) } } getOrElse NotFound } @@ -74,6 +86,7 @@ getAccountByUserName(userName).map { account => updateAccount(account.copy( password = form.password.map(sha1).getOrElse(account.password), + fullName = form.fullName, mailAddress = form.mailAddress, url = form.url)) @@ -96,7 +109,7 @@ post("/register", newForm){ form => if(loadSystemSettings().allowAccountRegistration){ - createAccount(form.userName, sha1(form.password), form.mailAddress, false, form.url) + createAccount(form.userName, sha1(form.password), form.fullName, form.mailAddress, false, form.url) updateImage(form.userName, form.fileId, false) redirect("/signin") } else NotFound diff --git a/src/main/scala/app/ControllerBase.scala b/src/main/scala/app/ControllerBase.scala index b338f36..a3406cc 100644 --- a/src/main/scala/app/ControllerBase.scala +++ b/src/main/scala/app/ControllerBase.scala @@ -1,7 +1,9 @@ package app import _root_.util.Directory._ -import _root_.util.{FileUploadUtil, FileUtil, Validations} +import _root_.util.Implicits._ +import _root_.util.ControlUtil._ +import _root_.util.{FileUtil, Validations, Keys} import org.scalatra._ import org.scalatra.json._ import org.json4s._ @@ -10,7 +12,9 @@ import model.Account import scala.Some import service.AccountService -import javax.servlet.http.HttpServletRequest +import javax.servlet.http.{HttpServletResponse, HttpSession, HttpServletRequest} +import java.text.SimpleDateFormat +import javax.servlet.{FilterChain, ServletResponse, ServletRequest} /** * Provides generic features for controller implementations. @@ -20,73 +24,94 @@ implicit val jsonFormats = DefaultFormats + // Don't set content type via Accept header. + override def format(implicit request: HttpServletRequest) = "" + + override def doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) { + val httpRequest = request.asInstanceOf[HttpServletRequest] + val httpResponse = response.asInstanceOf[HttpServletResponse] + val context = request.getServletContext.getContextPath + val path = httpRequest.getRequestURI.substring(context.length) + + if(path.startsWith("/console/")){ + val account = httpRequest.getSession.getAttribute(Keys.Session.LoginAccount).asInstanceOf[Account] + if(account == null){ + // Redirect to login form + httpResponse.sendRedirect(context + "/signin?" + path) + } else if(account.isAdmin){ + // H2 Console (administrators only) + chain.doFilter(request, response) + } else { + // Redirect to dashboard + httpResponse.sendRedirect(context + "/") + } + } else if(path.startsWith("/git/")){ + // Git repository + chain.doFilter(request, response) + } else { + // Scalatra actions + super.doFilter(request, response, chain) + } + } + /** * Returns the context object for the request. */ implicit def context: Context = Context(servletContext.getContextPath, LoginAccount, currentURL, request) - private def currentURL: String = { - val queryString = request.getQueryString + private def currentURL: String = defining(request.getQueryString){ queryString => request.getRequestURI + (if(queryString != null) "?" + queryString else "") } - private def LoginAccount: Option[Account] = { - session.get("LOGIN_ACCOUNT") match { - case Some(x: Account) => Some(x) - case _ => None - } - } + private def LoginAccount: Option[Account] = session.getAs[Account](Keys.Session.LoginAccount) - def ajaxGet(path : String)(action : => Any) : Route = { + def ajaxGet(path : String)(action : => Any) : Route = super.get(path){ - request.setAttribute("AJAX", "true") + request.setAttribute(Keys.Request.Ajax, "true") action } - } - override def ajaxGet[T](path : String, form : MappingValueType[T])(action : T => Any) : Route = { + override def ajaxGet[T](path : String, form : MappingValueType[T])(action : T => Any) : Route = super.ajaxGet(path, form){ form => - request.setAttribute("AJAX", "true") + request.setAttribute(Keys.Request.Ajax, "true") action(form) } - } - def ajaxPost(path : String)(action : => Any) : Route = { + def ajaxPost(path : String)(action : => Any) : Route = super.post(path){ - request.setAttribute("AJAX", "true") + request.setAttribute(Keys.Request.Ajax, "true") action } - } - override def ajaxPost[T](path : String, form : MappingValueType[T])(action : T => Any) : Route = { + override def ajaxPost[T](path : String, form : MappingValueType[T])(action : T => Any) : Route = super.ajaxPost(path, form){ form => - request.setAttribute("AJAX", "true") + request.setAttribute(Keys.Request.Ajax, "true") action(form) } - } - protected def NotFound() = { - if(request.getAttribute("AJAX") == null){ - org.scalatra.NotFound(html.error("Not Found")) - } else { + protected def NotFound() = + if(request.hasAttribute(Keys.Request.Ajax)){ org.scalatra.NotFound() + } else { + org.scalatra.NotFound(html.error("Not Found")) } - } - protected def Unauthorized()(implicit context: app.Context) = { - if(request.getAttribute("AJAX") == null){ + protected def Unauthorized()(implicit context: app.Context) = + if(request.hasAttribute(Keys.Request.Ajax)){ + org.scalatra.Unauthorized() + } else { if(context.loginAccount.isDefined){ org.scalatra.Unauthorized(redirect("/")) } else { - org.scalatra.Unauthorized(redirect("/signin?" + currentURL)) + if(request.getMethod.toUpperCase == "POST"){ + org.scalatra.Unauthorized(redirect("/signin")) + } else { + org.scalatra.Unauthorized(redirect("/signin?redirect=" + currentURL)) + } } - } else { - org.scalatra.Unauthorized() } - } - protected def baseUrl = { - val url = request.getRequestURL.toString + protected def baseUrl = defining(request.getRequestURL.toString){ url => url.substring(0, url.length - (request.getRequestURI.length - request.getContextPath.length)) } @@ -97,28 +122,36 @@ */ case class Context(path: String, loginAccount: Option[Account], currentUrl: String, request: HttpServletRequest){ + def redirectUrl = if(request.getParameter("redirect") != null){ + request.getParameter("redirect") + } else { + currentUrl + } + /** * Get object from cache. * * If object has not been cached with the specified key then retrieves by given action. * Cached object are available during a request. */ - def cache[A](key: String)(action: => A): A = { - Option(request.getAttribute("cache." + key).asInstanceOf[A]).getOrElse { - val newObject = action - request.setAttribute("cache." + key, newObject) - newObject + def cache[A](key: String)(action: => A): A = + defining(Keys.Request.Cache(key)){ cacheKey => + Option(request.getAttribute(cacheKey).asInstanceOf[A]).getOrElse { + val newObject = action + request.setAttribute(cacheKey, newObject) + newObject + } } - } } /** * 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 = { + protected def updateImage(userName: String, fileId: Option[String], clearImage: Boolean): Unit = if(clearImage){ getAccountByUserName(userName).flatMap(_.image).map { image => new java.io.File(getUserUploadDir(userName), image).delete() @@ -126,26 +159,50 @@ } } 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)) } } - } protected def uniqueUserName: Constraint = new Constraint(){ - def validate(name: String, value: String): Option[String] = + override def validate(name: String, value: String): Option[String] = getAccountByUserName(value).map { _ => "User already exists." } } protected def uniqueMailAddress(paramName: String = ""): Constraint = new Constraint(){ - def validate(name: String, value: String): Option[String] = + override def validate(name: String, value: String, params: Map[String, String]): Option[String] = getAccountByMailAddress(value) .filter { x => if(paramName.isEmpty) true else Some(x.userName) != params.get(paramName) } .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] = + session.getAndRemove[String](Keys.Session.Upload(fileId)) + } \ No newline at end of file diff --git a/src/main/scala/app/CreateRepositoryController.scala b/src/main/scala/app/CreateRepositoryController.scala index e10e5bb..a3c52d0 100644 --- a/src/main/scala/app/CreateRepositoryController.scala +++ b/src/main/scala/app/CreateRepositoryController.scala @@ -1,107 +1,193 @@ package app import util.Directory._ -import util.{JGitUtil, UsersAuthenticator} +import util.ControlUtil._ +import util._ 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 - with UsersAuthenticator + with UsersAuthenticator with ReadableUsersAuthenticator /** * Creates new repository. */ trait CreateRepositoryControllerBase extends ControllerBase { - self: RepositoryService with WikiService with LabelsService with ActivityService - with UsersAuthenticator => + self: RepositoryService with AccountService with WikiService with LabelsService with ActivityService + with UsersAuthenticator with ReadableUsersAuthenticator => - 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) - val form = mapping( + 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())), "createReadme" -> trim(label("Create README" , boolean())) )(RepositoryCreationForm.apply) + val forkForm = mapping( + "owner" -> trim(label("Repository owner", text(required))), + "name" -> trim(label("Repository name", text(required))) + )(ForkRepositoryForm.apply) + /** * Show the new repository form. */ get("/new")(usersOnly { - html.newrepo() + html.newrepo(getGroupsByUserName(context.loginAccount.get.userName)) }) /** * Create new repository. */ - post("/new", form)(usersOnly { form => - val loginAccount = context.loginAccount.get - val loginUserName = loginAccount.userName + post("/new", newForm)(usersOnly { form => + LockUtil.lock(s"${form.owner}/${form.name}/create"){ + if(getRepository(form.owner, form.name, baseUrl).isEmpty){ + 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) + // Insert to the database at first + createRepository(form.name, form.owner, form.description, form.isPrivate) - // Insert default labels - createLabel(loginUserName, form.name, "bug", "fc2929") - createLabel(loginUserName, form.name, "duplicate", "cccccc") - createLabel(loginUserName, form.name, "enhancement", "84b6eb") - createLabel(loginUserName, form.name, "invalid", "e6e6e6") - createLabel(loginUserName, form.name, "question", "cc317c") - createLabel(loginUserName, form.name, "wontfix", "ffffff") + // Add collaborators for group repository + if(ownerAccount.isGroupAccount){ + getGroupMembers(form.owner).foreach { userName => + addCollaborator(form.owner, form.name, userName) + } + } - // Create the actual repository - val gitdir = getRepositoryDir(loginUserName, form.name) - JGitUtil.initRepository(gitdir) + // Insert default labels + insertDefaultLabels(form.owner, form.name) - if(form.createReadme){ - val tmpdir = getInitRepositoryDir(loginUserName, form.name) - try { - // Clone the repository - Git.cloneRepository.setURI(gitdir.toURI.toString).setDirectory(tmpdir).call + // Create the actual repository + val gitdir = getRepositoryDir(form.owner, form.name) + JGitUtil.initRepository(gitdir) - // Create README.md - FileUtils.writeStringToFile(new File(tmpdir, "README.md"), - if(form.description.nonEmpty){ - form.name + "\n" + - "===============\n" + - "\n" + - form.description.get - } else { - form.name + "\n" + - "===============\n" - }, "UTF-8") + if(form.createReadme){ + FileUtil.withTmpDir(getInitRepositoryDir(form.owner, form.name)){ tmpdir => + // Clone the repository + Git.cloneRepository.setURI(gitdir.toURI.toString).setDirectory(tmpdir).call - val git = Git.open(tmpdir) - git.add.addFilepattern("README.md").call - git.commit.setMessage("Initial commit").call - git.push.call + // Create README.md + FileUtils.writeStringToFile(new File(tmpdir, "README.md"), + if(form.description.nonEmpty){ + form.name + "\n" + + "===============\n" + + "\n" + + form.description.get + } else { + form.name + "\n" + + "===============\n" + }, "UTF-8") - } finally { - FileUtils.deleteDirectory(tmpdir) + val git = Git.open(tmpdir) + git.add.addFilepattern("README.md").call + git.commit + .setCommitter(new PersonIdent(loginAccount.fullName, loginAccount.mailAddress)) + .setMessage("Initial commit").call + git.push.call + } + } + + // Create Wiki repository + createWikiRepository(loginAccount, form.owner, form.name) + + // Record activity + recordCreateRepositoryActivity(form.owner, form.name, loginUserName) } + + // redirect to the repository + redirect(s"/${form.owner}/${form.name}") } - - // Create Wiki repository - createWikiRepository(loginAccount, form.name) - - // Record activity - recordCreateRepositoryActivity(loginUserName, form.name, loginUserName) - - // redirect to the repository - redirect(s"/${loginUserName}/${form.name}") }) - + + get("/:owner/:repository/fork")(readableUsersOnly { repository => + val loginAccount = context.loginAccount.get + val loginUserName = loginAccount.userName + + LockUtil.lock(s"${loginUserName}/${repository.name}/create"){ + if(getRepository(loginUserName, repository.name, baseUrl).isEmpty){ + // Insert to the database at first + val originUserName = repository.repository.originUserName.getOrElse(repository.owner) + val originRepositoryName = repository.repository.originRepositoryName.getOrElse(repository.name) + + createRepository( + repositoryName = repository.name, + userName = loginUserName, + description = repository.repository.description, + isPrivate = repository.repository.isPrivate, + originRepositoryName = Some(originRepositoryName), + originUserName = Some(originUserName), + parentRepositoryName = Some(repository.name), + parentUserName = Some(repository.owner) + ) + + // Insert default labels + insertDefaultLabels(loginUserName, repository.name) + + // clone repository actually + JGitUtil.cloneRepository( + getRepositoryDir(repository.owner, repository.name), + getRepositoryDir(loginUserName, repository.name)) + + // Create Wiki repository + JGitUtil.cloneRepository( + getWikiRepositoryDir(repository.owner, repository.name), + getWikiRepositoryDir(loginUserName, repository.name)) + + // insert commit id + using(Git.open(getRepositoryDir(loginUserName, repository.name))){ git => + JGitUtil.getRepositoryInfo(loginUserName, repository.name, baseUrl).branchList.foreach { branch => + JGitUtil.getCommitLog(git, branch) match { + case Right((commits, _)) => commits.foreach { commit => + if(!existsCommitId(loginUserName, repository.name, commit.id)){ + insertCommitId(loginUserName, repository.name, commit.id) + } + } + case Left(_) => ??? + } + } + } + + // Record activity + recordForkActivity(repository.owner, repository.name, loginUserName) + } + // redirect to the repository + redirect("/%s/%s".format(loginUserName, repository.name)) + } + }) + + private def insertDefaultLabels(userName: String, repositoryName: String): Unit = { + createLabel(userName, repositoryName, "bug", "fc2929") + createLabel(userName, repositoryName, "duplicate", "cccccc") + createLabel(userName, repositoryName, "enhancement", "84b6eb") + createLabel(userName, repositoryName, "invalid", "e6e6e6") + createLabel(userName, repositoryName, "question", "cc317c") + createLabel(userName, repositoryName, "wontfix", "ffffff") + } + + private def existsAccount: Constraint = new Constraint(){ + override 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.") + override def validate(name: String, value: String, params: Map[String, String]): Option[String] = + 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/DashboardController.scala b/src/main/scala/app/DashboardController.scala new file mode 100644 index 0000000..871dd34 --- /dev/null +++ b/src/main/scala/app/DashboardController.scala @@ -0,0 +1,109 @@ +package app + +import service._ +import util.{UsersAuthenticator, Keys} +import util.Implicits._ + +class DashboardController extends DashboardControllerBase + with IssuesService with PullRequestService with RepositoryService with AccountService + with UsersAuthenticator + +trait DashboardControllerBase extends ControllerBase { + self: IssuesService with PullRequestService with RepositoryService with UsersAuthenticator => + + get("/dashboard/issues/repos")(usersOnly { + searchIssues("all") + }) + + get("/dashboard/issues/assigned")(usersOnly { + searchIssues("assigned") + }) + + get("/dashboard/issues/created_by")(usersOnly { + searchIssues("created_by") + }) + + get("/dashboard/pulls")(usersOnly { + searchPullRequests("created_by", None) + }) + + get("/dashboard/pulls/owned")(usersOnly { + searchPullRequests("created_by", None) + }) + + get("/dashboard/pulls/public")(usersOnly { + searchPullRequests("not_created_by", None) + }) + + get("/dashboard/pulls/for/:owner/:repository")(usersOnly { + searchPullRequests("all", Some(params("owner") + "/" + params("repository"))) + }) + + private def searchIssues(filter: String) = { + import IssuesService._ + + // condition + val condition = session.putAndGet(Keys.Session.DashboardIssues, + if(request.hasQueryString) IssueSearchCondition(request) + else session.getAs[IssueSearchCondition](Keys.Session.DashboardIssues).getOrElse(IssueSearchCondition()) + ) + + val userName = context.loginAccount.get.userName + val repositories = getUserRepositories(userName, baseUrl).map(repo => repo.owner -> repo.name) + val filterUser = Map(filter -> userName) + val page = IssueSearchCondition.page(request) + // + dashboard.html.issues( + issues.html.listparts( + searchIssue(condition, filterUser, false, (page - 1) * IssueLimit, IssueLimit, repositories: _*), + page, + countIssue(condition.copy(state = "open"), filterUser, false, repositories: _*), + countIssue(condition.copy(state = "closed"), filterUser, false, repositories: _*), + condition), + countIssue(condition, Map.empty, false, repositories: _*), + countIssue(condition, Map("assigned" -> userName), false, repositories: _*), + countIssue(condition, Map("created_by" -> userName), false, repositories: _*), + countIssueGroupByRepository(condition, filterUser, false, repositories: _*), + condition, + filter) + + } + + private def searchPullRequests(filter: String, repository: Option[String]) = { + import IssuesService._ + import PullRequestService._ + + // condition + val condition = session.putAndGet(Keys.Session.DashboardPulls, { + if(request.hasQueryString) IssueSearchCondition(request) + else session.getAs[IssueSearchCondition](Keys.Session.DashboardPulls).getOrElse(IssueSearchCondition()) + }.copy(repo = repository)) + + val userName = context.loginAccount.get.userName + val repositories = getUserRepositories(userName, baseUrl).map(repo => repo.owner -> repo.name) + val filterUser = Map(filter -> userName) + val page = IssueSearchCondition.page(request) + + val counts = countIssueGroupByRepository( + IssueSearchCondition().copy(state = condition.state), Map.empty, true, repositories: _*) + + dashboard.html.pulls( + pulls.html.listparts( + searchIssue(condition, filterUser, true, (page - 1) * PullRequestLimit, PullRequestLimit, repositories: _*), + page, + countIssue(condition.copy(state = "open"), filterUser, true, repositories: _*), + countIssue(condition.copy(state = "closed"), filterUser, true, repositories: _*), + condition, + None, + false), + getPullRequestCountGroupByUser(condition.state == "closed", userName, None), + getRepositoryNamesOfUser(userName).map { RepoName => + (userName, RepoName, counts.collectFirst { case (_, RepoName, count) => count }.getOrElse(0)) + }.sortBy(_._3).reverse, + condition, + filter) + + } + + +} diff --git a/src/main/scala/app/FileUploadController.scala b/src/main/scala/app/FileUploadController.scala index 5d5ed3b..6ea5fa2 100644 --- a/src/main/scala/app/FileUploadController.scala +++ b/src/main/scala/app/FileUploadController.scala @@ -1,6 +1,7 @@ package app -import util.{FileUtil, FileUploadUtil} +import _root_.util.{Keys, FileUtil} +import util.ControlUtil._ import org.scalatra._ import org.scalatra.servlet.{MultipartConfig, FileUploadSupport} import org.apache.commons.io.FileUtils @@ -9,18 +10,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) - session += "upload_" + fileId -> file.name + case Some(file) if(FileUtil.isImage(file.name)) => defining(generateFileId){ fileId => + FileUtils.writeByteArrayToFile(getTemporaryFile(fileId), file.get) + session += Keys.Session.Upload(fileId) -> file.name Ok(fileId) } case None => BadRequest diff --git a/src/main/scala/app/IndexController.scala b/src/main/scala/app/IndexController.scala index c16873b..f827492 100644 --- a/src/main/scala/app/IndexController.scala +++ b/src/main/scala/app/IndexController.scala @@ -1,21 +1,38 @@ 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 html.index(getRecentActivities(), - getAccessibleRepositories(loginAccount, baseUrl), + getVisibleRepositories(loginAccount, baseUrl), loadSystemSettings(), - loginAccount.map{ account => getRepositoryNamesOfUser(account.userName) }.getOrElse(Nil) + loginAccount.map{ account => getUserRepositories(account.userName, baseUrl) }.getOrElse(Nil) ) } -} \ 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 09cc76b..98281e5 100644 --- a/src/main/scala/app/IssuesController.scala +++ b/src/main/scala/app/IssuesController.scala @@ -4,7 +4,9 @@ import service._ import IssuesService._ -import util.{CollaboratorsAuthenticator, ReferrerAuthenticator, ReadableUsersAuthenticator} +import util.{CollaboratorsAuthenticator, ReferrerAuthenticator, ReadableUsersAuthenticator, Notifier, Keys} +import util.Implicits._ +import util.ControlUtil._ import org.scalatra.Ok class IssuesController extends IssuesControllerBase @@ -57,98 +59,100 @@ }) get("/:owner/:repository/issues/:id")(referrersOnly { repository => - val owner = repository.owner - val name = repository.name - val issueId = params("id") - - getIssue(owner, name, issueId) map { - issues.html.issue( + defining(repository.owner, repository.name, params("id")){ case (owner, name, issueId) => + getIssue(owner, name, issueId) map { + issues.html.issue( _, getComments(owner, name, issueId.toInt), getIssueLabels(owner, name, issueId.toInt), (getCollaborators(owner, name) :+ owner).sorted, + getMilestonesWithIssueCount(owner, name), + getLabels(owner, name), + hasWritePermission(owner, name, context.loginAccount), + repository) + } getOrElse NotFound + } + }) + + get("/:owner/:repository/issues/new")(readableUsersOnly { repository => + defining(repository.owner, repository.name){ case (owner, name) => + issues.html.create( + (getCollaborators(owner, name) :+ owner).sorted, getMilestones(owner, name), getLabels(owner, name), hasWritePermission(owner, name, context.loginAccount), repository) - } getOrElse NotFound - }) - - get("/:owner/:repository/issues/new")(readableUsersOnly { repository => - val owner = repository.owner - val name = repository.name - - issues.html.create( - (getCollaborators(owner, name) :+ owner).sorted, - getMilestones(owner, name), - getLabels(owner, name), - hasWritePermission(owner, name, context.loginAccount), - repository) + } }) post("/:owner/:repository/issues/new", issueCreateForm)(readableUsersOnly { (form, repository) => - val owner = repository.owner - val name = repository.name - val writable = hasWritePermission(owner, name, context.loginAccount) - val userName = context.loginAccount.get.userName + defining(repository.owner, repository.name){ case (owner, name) => + val writable = hasWritePermission(owner, name, context.loginAccount) + val userName = context.loginAccount.get.userName - // insert issue - val issueId = createIssue(owner, name, userName, form.title, form.content, - if(writable) form.assignedUserName else None, - if(writable) form.milestoneId else None) + // insert issue + val issueId = createIssue(owner, name, userName, form.title, form.content, + if(writable) form.assignedUserName else None, + if(writable) form.milestoneId else None) - // insert labels - if(writable){ - form.labelNames.map { value => - val labels = getLabels(owner, name) - value.split(",").foreach { labelName => - labels.find(_.labelName == labelName).map { label => - registerIssueLabel(owner, name, issueId, label.labelId) + // insert labels + if(writable){ + form.labelNames.map { value => + val labels = getLabels(owner, name) + value.split(",").foreach { labelName => + labels.find(_.labelName == labelName).map { label => + registerIssueLabel(owner, name, issueId, label.labelId) + } } } } + + // record activity + recordCreateIssueActivity(owner, name, userName, issueId, form.title) + + // notifications + Notifier().toNotify(repository, issueId, form.content.getOrElse("")){ + Notifier.msgIssue(s"${baseUrl}/${owner}/${name}/issues/${issueId}") + } + + redirect(s"/${owner}/${name}/issues/${issueId}") } - - // record activity - recordCreateIssueActivity(owner, name, userName, issueId, form.title) - - redirect("/%s/%s/issues/%d".format(owner, name, issueId)) }) ajaxPost("/:owner/:repository/issues/edit/:id", issueEditForm)(readableUsersOnly { (form, repository) => - val owner = repository.owner - val name = repository.name - - 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)) - } else Unauthorized - } getOrElse NotFound + defining(repository.owner, repository.name){ case (owner, name) => + getIssue(owner, name, params("id")).map { issue => + if(isEditable(owner, name, issue.openedUserName)){ + updateIssue(owner, name, issue.issueId, form.title, form.content) + 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)() map { id => - redirect("/%s/%s/issues/%d#comment-%d".format(repository.owner, repository.name, form.issueId, id)) + handleComment(form.issueId, Some(form.content), repository)() map { case (issue, id) => + redirect(s"/${repository.owner}/${repository.name}/${ + if(issue.isPullRequest) "pull" else "issues"}/${form.issueId}#comment-${id}") } getOrElse NotFound }) post("/:owner/:repository/issue_comments/state", issueStateForm)(readableUsersOnly { (form, repository) => - handleComment(form.issueId, form.content, repository)() map { id => - redirect("/%s/%s/issues/%d#comment-%d".format(repository.owner, repository.name, form.issueId, id)) + handleComment(form.issueId, form.content, repository)() map { case (issue, id) => + redirect(s"/${repository.owner}/${repository.name}/${ + if(issue.isPullRequest) "pull" else "issues"}/${form.issueId}#comment-${id}") } getOrElse NotFound }) ajaxPost("/:owner/:repository/issue_comments/edit/:id", commentForm)(readableUsersOnly { (form, repository) => - val owner = repository.owner - val name = repository.name - - 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)) - } else Unauthorized - } getOrElse NotFound + defining(repository.owner, repository.name){ case (owner, name) => + getComment(owner, name, params("id")).map { comment => + if(isEditable(owner, name, comment.commentedUserName)){ + updateComment(comment.commentId, form.content) + redirect(s"/${owner}/${name}/issue_comments/_data/${comment.commentId}") + } else Unauthorized + } getOrElse NotFound + } }) ajaxGet("/:owner/:repository/issues/_data/:id")(readableUsersOnly { repository => @@ -187,17 +191,17 @@ }) ajaxPost("/:owner/:repository/issues/:id/label/new")(collaboratorsOnly { repository => - val issueId = params("id").toInt - - registerIssueLabel(repository.owner, repository.name, issueId, params("labelId").toInt) - issues.html.labellist(getIssueLabels(repository.owner, repository.name, issueId)) + defining(params("id").toInt){ issueId => + registerIssueLabel(repository.owner, repository.name, issueId, params("labelId").toInt) + issues.html.labellist(getIssueLabels(repository.owner, repository.name, issueId)) + } }) ajaxPost("/:owner/:repository/issues/:id/label/delete")(collaboratorsOnly { repository => - val issueId = params("id").toInt - - deleteIssueLabel(repository.owner, repository.name, issueId, params("labelId").toInt) - issues.html.labellist(getIssueLabels(repository.owner, repository.name, issueId)) + defining(params("id").toInt){ issueId => + deleteIssueLabel(repository.owner, repository.name, issueId, params("labelId").toInt) + issues.html.labellist(getIssueLabels(repository.owner, repository.name, issueId)) + } }) ajaxPost("/:owner/:repository/issues/:id/assign")(collaboratorsOnly { repository => @@ -207,128 +211,148 @@ ajaxPost("/:owner/:repository/issues/:id/milestone")(collaboratorsOnly { repository => updateMilestoneId(repository.owner, repository.name, params("id").toInt, milestoneId("milestoneId")) - Ok("updated") + 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 action = params.get("value") - - executeBatch(repository) { - handleComment(_, None, repository)( _ => action) - } - }) - - post("/:owner/:repository/issues/batchedit/label")(collaboratorsOnly { repository => - val labelId = params("value").toInt - - executeBatch(repository) { issueId => - getIssueLabel(repository.owner, repository.name, issueId, labelId) getOrElse { - registerIssueLabel(repository.owner, repository.name, issueId, labelId) + defining(params.get("value")){ action => + executeBatch(repository) { + handleComment(_, None, repository)( _ => action) } } }) - post("/:owner/:repository/issues/batchedit/assign")(collaboratorsOnly { repository => - val value = assignedUserName("value") + post("/:owner/:repository/issues/batchedit/label")(collaboratorsOnly { repository => + params("value").toIntOpt.map{ labelId => + executeBatch(repository) { issueId => + getIssueLabel(repository.owner, repository.name, issueId, labelId) getOrElse { + registerIssueLabel(repository.owner, repository.name, issueId, labelId) + } + } + } getOrElse NotFound + }) - executeBatch(repository) { - updateAssignedUserName(repository.owner, repository.name, _, value) + post("/:owner/:repository/issues/batchedit/assign")(collaboratorsOnly { repository => + defining(assignedUserName("value")){ value => + executeBatch(repository) { + updateAssignedUserName(repository.owner, repository.name, _, value) + } } }) post("/:owner/:repository/issues/batchedit/milestone")(collaboratorsOnly { repository => - val value = milestoneId("value") - - executeBatch(repository) { - updateMilestoneId(repository.owner, repository.name, _, value) + defining(milestoneId("value")){ value => + executeBatch(repository) { + updateMilestoneId(repository.owner, repository.name, _, value) + } } }) val assignedUserName = (key: String) => params.get(key) filter (_.trim != "") - val milestoneId = (key: String) => params.get(key) collect { case x if x.trim != "" => x.toInt } + val milestoneId: String => Option[Int] = (key: String) => params.get(key).flatMap(_.toIntOpt) 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 executeBatch(repository: RepositoryService.RepositoryInfo)(execute: Int => Unit) = { params("checked").split(',') map(_.toInt) foreach execute - redirect("/%s/%s/issues".format(repository.owner, repository.name)) + redirect(s"/${repository.owner}/${repository.name}/issues") } /** - * @see + * @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) = - getAction(issue) - .collect { - case "close" => true -> (Some("close") -> Some(recordCloseIssueActivity _)) - case "reopen" => false -> (Some("reopen") -> Some(recordReopenIssueActivity _)) + defining(repository.owner, repository.name){ case (owner, name) => + val userName = context.loginAccount.get.userName + + getIssue(owner, name, issueId.toString) map { issue => + val (action, recordActivity) = + getAction(issue) + .collect { + case "close" => true -> (Some("close") -> + Some(if(issue.isPullRequest) recordClosePullRequestActivity _ else recordCloseIssueActivity _)) + case "reopen" => false -> (Some("reopen") -> + Some(recordReopenIssueActivity _)) } - .map { case (closed, t) => + .map { case (closed, t) => updateClosed(owner, name, issueId, closed) t } - .getOrElse(None -> None) + .getOrElse(None -> None) - val commentId = content + 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) - } + 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) ) + // record activity + content foreach { + (if(issue.isPullRequest) recordCommentPullRequestActivity _ else recordCommentIssueActivity _) + (owner, name, userName, issueId, _) + } + recordActivity foreach ( _ (owner, name, userName, issueId, issue.title) ) - commentId + // notifications + Notifier() match { + case f => + content foreach { + f.toNotify(repository, issueId, _){ + Notifier.msgComment(s"${baseUrl}/${owner}/${name}/${ + if(issue.isPullRequest) "pull" else "issues"}/${issueId}#comment-${commentId}") + } + } + action foreach { + f.toNotify(repository, issueId, _){ + Notifier.msgStatus(s"${baseUrl}/${owner}/${name}/issues/${issueId}") + } + } + } + + issue -> 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) + defining(repository.owner, repository.name){ case (owner, repoName) => + val filterUser = Map(filter -> params.getOrElse("userName", "")) + val page = IssueSearchCondition.page(request) + val sessionKey = Keys.Session.Issues(owner, repoName) - val page = try { - val i = params.getOrElse("page", "1").toInt - if(i <= 0) 1 else i - } catch { - case e: NumberFormatException => 1 + // retrieve search condition + val condition = session.putAndGet(sessionKey, + if(request.hasQueryString) IssueSearchCondition(request) + else session.getAs[IssueSearchCondition](sessionKey).getOrElse(IssueSearchCondition()) + ) + + issues.html.list( + searchIssue(condition, filterUser, false, (page - 1) * IssueLimit, IssueLimit, owner -> repoName), + page, + (getCollaborators(owner, repoName) :+ owner).sorted, + getMilestones(owner, repoName), + getLabels(owner, repoName), + countIssue(condition.copy(state = "open"), filterUser, false, owner -> repoName), + countIssue(condition.copy(state = "closed"), filterUser, false, owner -> repoName), + countIssue(condition, Map.empty, false, owner -> repoName), + context.loginAccount.map(x => countIssue(condition, Map("assigned" -> x.userName), false, owner -> repoName)), + context.loginAccount.map(x => countIssue(condition, Map("created_by" -> x.userName), false, owner -> repoName)), + countIssueGroupByLabels(owner, repoName, condition, filterUser), + condition, + filter, + repository, + hasWritePermission(owner, repoName, context.loginAccount)) } - - // retrieve search condition - val condition = if(request.getQueryString == null){ - session.get(sessionKey).getOrElse(IssueSearchCondition()).asInstanceOf[IssueSearchCondition] - } else IssueSearchCondition(request) - - session.put(sessionKey, condition) - - issues.html.list( - searchIssue(owner, repoName, condition, filter, userName, (page - 1) * IssueLimit, IssueLimit), - page, - (getCollaborators(owner, repoName) :+ owner).sorted, - getMilestones(owner, repoName).filter(_.closedDate.isEmpty), - getLabels(owner, repoName), - countIssue(owner, repoName, condition.copy(state = "open"), filter, userName), - countIssue(owner, repoName, condition.copy(state = "closed"), filter, userName), - countIssue(owner, repoName, condition, "all", None), - context.loginAccount.map(x => countIssue(owner, repoName, condition, "assigned", Some(x.userName))), - context.loginAccount.map(x => countIssue(owner, repoName, condition, "created_by", Some(x.userName))), - countIssueGroupByLabels(owner, repoName, condition, filter, userName), - condition, - filter, - repository, - hasWritePermission(owner, repoName, context.loginAccount)) } } diff --git a/src/main/scala/app/LabelsController.scala b/src/main/scala/app/LabelsController.scala index e666d67..1366c4c 100644 --- a/src/main/scala/app/LabelsController.scala +++ b/src/main/scala/app/LabelsController.scala @@ -51,7 +51,7 @@ * Constraint for the identifier such as user name, repository name or page name. */ private def labelName: Constraint = new Constraint(){ - def validate(name: String, value: String): Option[String] = + override def validate(name: String, value: String): Option[String] = if(!value.matches("^[^,]+$")){ Some(s"${name} contains invalid character.") } else if(value.startsWith("_") || value.startsWith("-")){ diff --git a/src/main/scala/app/MilestonesController.scala b/src/main/scala/app/MilestonesController.scala index 55c60d0..234ca07 100644 --- a/src/main/scala/app/MilestonesController.scala +++ b/src/main/scala/app/MilestonesController.scala @@ -3,7 +3,8 @@ import jp.sf.amateras.scalatra.forms._ import service._ -import util.{CollaboratorsAuthenticator, ReferrerAuthenticator, UsersAuthenticator} +import util.{CollaboratorsAuthenticator, ReferrerAuthenticator} +import util.Implicits._ class MilestonesController extends MilestonesControllerBase with MilestonesService with RepositoryService with AccountService @@ -39,34 +40,44 @@ }) get("/:owner/:repository/issues/milestones/:milestoneId/edit")(collaboratorsOnly { repository => - issues.milestones.html.edit(getMilestone(repository.owner, repository.name, params("milestoneId").toInt), repository) + params("milestoneId").toIntOpt.map{ milestoneId => + issues.milestones.html.edit(getMilestone(repository.owner, repository.name, milestoneId), repository) + } getOrElse NotFound }) post("/:owner/:repository/issues/milestones/:milestoneId/edit", milestoneForm)(collaboratorsOnly { (form, repository) => - getMilestone(repository.owner, repository.name, params("milestoneId").toInt).map { milestone => - updateMilestone(milestone.copy(title = form.title, description = form.description, dueDate = form.dueDate)) - redirect(s"/${repository.owner}/${repository.name}/issues/milestones") + params("milestoneId").toIntOpt.flatMap{ milestoneId => + getMilestone(repository.owner, repository.name, milestoneId).map { milestone => + updateMilestone(milestone.copy(title = form.title, description = form.description, dueDate = form.dueDate)) + redirect(s"/${repository.owner}/${repository.name}/issues/milestones") + } } getOrElse NotFound }) get("/:owner/:repository/issues/milestones/:milestoneId/close")(collaboratorsOnly { repository => - getMilestone(repository.owner, repository.name, params("milestoneId").toInt).map { milestone => - closeMilestone(milestone) - redirect(s"/${repository.owner}/${repository.name}/issues/milestones") + params("milestoneId").toIntOpt.flatMap{ milestoneId => + getMilestone(repository.owner, repository.name, milestoneId).map { milestone => + closeMilestone(milestone) + redirect(s"/${repository.owner}/${repository.name}/issues/milestones") + } } getOrElse NotFound }) get("/:owner/:repository/issues/milestones/:milestoneId/open")(collaboratorsOnly { repository => - getMilestone(repository.owner, repository.name, params("milestoneId").toInt).map { milestone => - openMilestone(milestone) - redirect(s"/${repository.owner}/${repository.name}/issues/milestones") + params("milestoneId").toIntOpt.flatMap{ milestoneId => + getMilestone(repository.owner, repository.name, milestoneId).map { milestone => + openMilestone(milestone) + redirect(s"/${repository.owner}/${repository.name}/issues/milestones") + } } getOrElse NotFound }) get("/:owner/:repository/issues/milestones/:milestoneId/delete")(collaboratorsOnly { repository => - getMilestone(repository.owner, repository.name, params("milestoneId").toInt).map { milestone => - deleteMilestone(repository.owner, repository.name, milestone.milestoneId) - redirect(s"/${repository.owner}/${repository.name}/issues/milestones") + params("milestoneId").toIntOpt.flatMap{ milestoneId => + getMilestone(repository.owner, repository.name, milestoneId).map { milestone => + deleteMilestone(repository.owner, repository.name, milestone.milestoneId) + redirect(s"/${repository.owner}/${repository.name}/issues/milestones") + } } getOrElse NotFound }) diff --git a/src/main/scala/app/PullRequestsController.scala b/src/main/scala/app/PullRequestsController.scala new file mode 100644 index 0000000..c234bc6 --- /dev/null +++ b/src/main/scala/app/PullRequestsController.scala @@ -0,0 +1,417 @@ +package app + +import util.{LockUtil, CollaboratorsAuthenticator, JGitUtil, ReferrerAuthenticator, Notifier, Keys} +import util.Directory._ +import util.Implicits._ +import util.ControlUtil._ +import util.FileUtil._ +import service._ +import org.eclipse.jgit.api.Git +import jp.sf.amateras.scalatra.forms._ +import org.eclipse.jgit.transport.RefSpec +import org.apache.commons.io.FileUtils +import scala.collection.JavaConverters._ +import org.eclipse.jgit.lib.PersonIdent +import org.eclipse.jgit.api.MergeCommand.FastForwardMode +import service.IssuesService._ +import service.PullRequestService._ +import util.JGitUtil.DiffInfo +import service.RepositoryService.RepositoryTreeNode +import util.JGitUtil.CommitInfo + +class PullRequestsController extends PullRequestsControllerBase + with RepositoryService with AccountService with IssuesService with PullRequestService with MilestonesService with ActivityService + with ReferrerAuthenticator with CollaboratorsAuthenticator + +trait PullRequestsControllerBase extends ControllerBase { + self: RepositoryService with IssuesService with MilestonesService with ActivityService with PullRequestService + with ReferrerAuthenticator with CollaboratorsAuthenticator => + + val pullRequestForm = mapping( + "title" -> trim(label("Title" , text(required, maxlength(100)))), + "content" -> trim(label("Content", optional(text()))), + "targetUserName" -> trim(text(required, maxlength(100))), + "targetBranch" -> trim(text(required, maxlength(100))), + "requestUserName" -> trim(text(required, maxlength(100))), + "requestBranch" -> trim(text(required, maxlength(100))), + "commitIdFrom" -> trim(text(required, maxlength(40))), + "commitIdTo" -> trim(text(required, maxlength(40))) + )(PullRequestForm.apply) + + val mergeForm = mapping( + "message" -> trim(label("Message", text(required))) + )(MergeForm.apply) + + case class PullRequestForm( + title: String, + content: Option[String], + targetUserName: String, + targetBranch: String, + requestUserName: String, + requestBranch: String, + commitIdFrom: String, + commitIdTo: String) + + case class MergeForm(message: String) + + get("/:owner/:repository/pulls")(referrersOnly { repository => + searchPullRequests(None, repository) + }) + + get("/:owner/:repository/pulls/:userName")(referrersOnly { repository => + searchPullRequests(Some(params("userName")), repository) + }) + + get("/:owner/:repository/pull/:id")(referrersOnly { repository => + params("id").toIntOpt.flatMap{ issueId => + val owner = repository.owner + val name = repository.name + getPullRequest(owner, name, issueId) map { case(issue, pullreq) => + using(Git.open(getRepositoryDir(owner, name))){ git => + val (commits, diffs) = + getRequestCompareInfo(owner, name, pullreq.commitIdFrom, owner, name, pullreq.commitIdTo) + + pulls.html.pullreq( + issue, pullreq, + getComments(owner, name, issueId), + (getCollaborators(owner, name) :+ owner).sorted, + getMilestonesWithIssueCount(owner, name), + commits, + diffs, + hasWritePermission(owner, name, context.loginAccount), + repository) + } + } + } getOrElse NotFound + }) + + ajaxGet("/:owner/:repository/pull/:id/mergeguide")(collaboratorsOnly { repository => + params("id").toIntOpt.flatMap{ issueId => + val owner = repository.owner + val name = repository.name + getPullRequest(owner, name, issueId) map { case(issue, pullreq) => + pulls.html.mergeguide( + checkConflict(owner, name, pullreq.branch, owner, name, pullreq.requestBranch), + pullreq, + s"${baseUrl}${context.path}/git/${pullreq.requestUserName}/${pullreq.requestRepositoryName}.git") + } + } getOrElse NotFound + }) + + post("/:owner/:repository/pull/:id/merge", mergeForm)(collaboratorsOnly { (form, repository) => + params("id").toIntOpt.flatMap{ issueId => + val owner = repository.owner + val name = repository.name + LockUtil.lock(s"${owner}/${name}/merge"){ + getPullRequest(owner, name, issueId).map { case (issue, pullreq) => + val remote = getRepositoryDir(owner, name) + withTmpDir(new java.io.File(getTemporaryDir(owner, name), s"merge-${issueId}")){ tmpdir => + using(Git.cloneRepository.setDirectory(tmpdir).setURI(remote.toURI.toString).setBranch(pullreq.branch).call){ git => + + // mark issue as merged and close. + val loginAccount = context.loginAccount.get + createComment(owner, name, loginAccount.userName, issueId, form.message, "merge") + createComment(owner, name, loginAccount.userName, issueId, "Close", "close") + updateClosed(owner, name, issueId, true) + + // record activity + recordMergeActivity(owner, name, loginAccount.userName, issueId, form.message) + + // fetch pull request to temporary working repository + val pullRequestBranchName = s"gitbucket-pullrequest-${issueId}" + + git.fetch + .setRemote(getRepositoryDir(owner, name).toURI.toString) + .setRefSpecs(new RefSpec(s"refs/pull/${issueId}/head:refs/heads/${pullRequestBranchName}")).call + + // merge pull request + git.checkout.setName(pullreq.branch).call + + val result = git.merge + .include(git.getRepository.resolve(pullRequestBranchName)) + .setFastForward(FastForwardMode.NO_FF) + .setCommit(false) + .call + + if(result.getConflicts != null){ + throw new RuntimeException("This pull request can't merge automatically.") + } + + // merge commit + git.getRepository.writeMergeCommitMsg( + s"Merge pull request #${issueId} from ${pullreq.requestUserName}/${pullreq.requestRepositoryName}\n" + + form.message) + + git.commit + .setCommitter(new PersonIdent(loginAccount.fullName, loginAccount.mailAddress)) + .call + + // push + git.push.call + + val (commits, _) = getRequestCompareInfo(owner, name, pullreq.commitIdFrom, + pullreq.requestUserName, pullreq.requestRepositoryName, pullreq.commitIdTo) + + commits.flatten.foreach { commit => + if(!existsCommitId(owner, name, commit.id)){ + insertCommitId(owner, name, commit.id) + } + } + + // notifications + Notifier().toNotify(repository, issueId, "merge"){ + Notifier.msgStatus(s"${baseUrl}/${owner}/${name}/pull/${issueId}") + } + + redirect(s"/${owner}/${name}/pull/${issueId}") + } + } + } + } + } getOrElse NotFound + }) + + get("/:owner/:repository/compare")(referrersOnly { forkedRepository => + (forkedRepository.repository.originUserName, forkedRepository.repository.originRepositoryName) match { + case (Some(originUserName), Some(originRepositoryName)) => { + getRepository(originUserName, originRepositoryName, baseUrl).map { originRepository => + using( + Git.open(getRepositoryDir(originUserName, originRepositoryName)), + Git.open(getRepositoryDir(forkedRepository.owner, forkedRepository.name)) + ){ (oldGit, newGit) => + val oldBranch = JGitUtil.getDefaultBranch(oldGit, originRepository).get._2 + val newBranch = JGitUtil.getDefaultBranch(newGit, forkedRepository).get._2 + + redirect(s"${context.path}/${forkedRepository.owner}/${forkedRepository.name}/compare/${originUserName}:${oldBranch}...${newBranch}") + } + } getOrElse NotFound + } + case _ => { + using(Git.open(getRepositoryDir(forkedRepository.owner, forkedRepository.name))){ git => + JGitUtil.getDefaultBranch(git, forkedRepository).map { case (_, defaultBranch) => + redirect(s"${context.path}/${forkedRepository.owner}/${forkedRepository.name}/compare/${defaultBranch}...${defaultBranch}") + } getOrElse { + redirect(s"${context.path}/${forkedRepository.owner}/${forkedRepository.name}") + } + } + } + } + }) + + get("/:owner/:repository/compare/*...*")(referrersOnly { repository => + val Seq(origin, forked) = multiParams("splat") + val (originOwner, tmpOriginBranch) = parseCompareIdentifie(origin, repository.owner) + val (forkedOwner, tmpForkedBranch) = parseCompareIdentifie(forked, repository.owner) + + (getRepository(originOwner, repository.name, baseUrl), + getRepository(forkedOwner, repository.name, baseUrl)) match { + case (Some(originRepository), Some(forkedRepository)) => { + using( + Git.open(getRepositoryDir(originOwner, repository.name)), + Git.open(getRepositoryDir(forkedOwner, repository.name)) + ){ case (oldGit, newGit) => + val originBranch = JGitUtil.getDefaultBranch(oldGit, originRepository, tmpOriginBranch).get._2 + val forkedBranch = JGitUtil.getDefaultBranch(newGit, forkedRepository, tmpForkedBranch).get._2 + + val forkedId = getForkedCommitId(oldGit, newGit, + originOwner, repository.name, originBranch, + forkedOwner, repository.name, forkedBranch) + + val oldId = oldGit.getRepository.resolve(forkedId) + val newId = newGit.getRepository.resolve(forkedBranch) + + val (commits, diffs) = getRequestCompareInfo( + originOwner, repository.name, oldId.getName, + forkedOwner, repository.name, newId.getName) + + pulls.html.compare( + commits, + diffs, + repository.repository.originUserName.map { userName => + userName :: getForkedRepositories(userName, repository.name) + } getOrElse List(repository.owner), + originBranch, + forkedBranch, + oldId.getName, + newId.getName, + repository, + originRepository, + forkedRepository, + hasWritePermission(repository.owner, repository.name, context.loginAccount)) + } + } + case _ => NotFound + } + }) + + ajaxGet("/:owner/:repository/compare/*...*/mergecheck")(collaboratorsOnly { repository => + val Seq(origin, forked) = multiParams("splat") + val (originOwner, tmpOriginBranch) = parseCompareIdentifie(origin, repository.owner) + val (forkedOwner, tmpForkedBranch) = parseCompareIdentifie(forked, repository.owner) + + (getRepository(originOwner, repository.name, baseUrl), + getRepository(forkedOwner, repository.name, baseUrl)) match { + case (Some(originRepository), Some(forkedRepository)) => { + using( + Git.open(getRepositoryDir(originOwner, repository.name)), + Git.open(getRepositoryDir(forkedOwner, repository.name)) + ){ case (oldGit, newGit) => + val originBranch = JGitUtil.getDefaultBranch(oldGit, originRepository, tmpOriginBranch).get._2 + val forkedBranch = JGitUtil.getDefaultBranch(newGit, forkedRepository, tmpForkedBranch).get._2 + + pulls.html.mergecheck( + checkConflict(originOwner, repository.name, originBranch, forkedOwner, repository.name, forkedBranch)) + } + } + case _ => NotFound() + } + }) + + post("/:owner/:repository/pulls/new", pullRequestForm)(referrersOnly { (form, repository) => + val loginUserName = context.loginAccount.get.userName + + val issueId = createIssue( + owner = repository.owner, + repository = repository.name, + loginUser = loginUserName, + title = form.title, + content = form.content, + assignedUserName = None, + milestoneId = None, + isPullRequest = true) + + createPullRequest( + originUserName = repository.owner, + originRepositoryName = repository.name, + issueId = issueId, + originBranch = form.targetBranch, + requestUserName = form.requestUserName, + requestRepositoryName = repository.name, + requestBranch = form.requestBranch, + commitIdFrom = form.commitIdFrom, + commitIdTo = form.commitIdTo) + + // fetch requested branch + using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => + git.fetch + .setRemote(getRepositoryDir(form.requestUserName, repository.name).toURI.toString) + .setRefSpecs(new RefSpec(s"refs/heads/${form.requestBranch}:refs/pull/${issueId}/head")) + .call + } + + // record activity + recordPullRequestActivity(repository.owner, repository.name, loginUserName, issueId, form.title) + + // notifications + Notifier().toNotify(repository, issueId, form.content.getOrElse("")){ + Notifier.msgPullRequest(s"${baseUrl}/${repository.owner}/${repository.name}/pull/${issueId}") + } + + redirect(s"/${repository.owner}/${repository.name}/pull/${issueId}") + }) + + /** + * Checks whether conflict will be caused in merging. Returns true if conflict will be caused. + */ + private def checkConflict(userName: String, repositoryName: String, branch: String, + requestUserName: String, requestRepositoryName: String, requestBranch: String): Boolean = { + // TODO Are there more quick way? + LockUtil.lock(s"${userName}/${repositoryName}/merge-check"){ + val remote = getRepositoryDir(userName, repositoryName) + withTmpDir(new java.io.File(getTemporaryDir(userName, repositoryName), "merge-check")){ tmpdir => + using(Git.cloneRepository.setDirectory(tmpdir).setURI(remote.toURI.toString).setBranch(branch).call){ git => + + git.checkout.setName(branch).call + + git.fetch + .setRemote(getRepositoryDir(requestUserName, requestRepositoryName).toURI.toString) + .setRefSpecs(new RefSpec(s"refs/heads/${branch}:refs/heads/${requestBranch}")).call + + val result = git.merge + .include(git.getRepository.resolve("FETCH_HEAD")) + .setCommit(false).call + + result.getConflicts != null + } + } + } + } + + /** + * Parses branch identifier and extracts owner and branch name as tuple. + * + * - "owner:branch" to ("owner", "branch") + * - "branch" to ("defaultOwner", "branch") + */ + private def parseCompareIdentifie(value: String, defaultOwner: String): (String, String) = + if(value.contains(':')){ + val array = value.split(":") + (array(0), array(1)) + } else { + (defaultOwner, value) + } + + /** + * Extracts all repository names from [[service.RepositoryService.RepositoryTreeNode]] as flat list. + */ + private def getRepositoryNames(node: RepositoryTreeNode): List[String] = + node.owner :: node.children.map { child => getRepositoryNames(child) }.flatten + + /** + * Returns the identifier of the root commit (or latest merge commit) of the specified branch. + */ + private def getForkedCommitId(oldGit: Git, newGit: Git, userName: String, repositoryName: String, branch: String, + requestUserName: String, requestRepositoryName: String, requestBranch: String): String = + JGitUtil.getCommitLogs(newGit, requestBranch, true){ commit => + existsCommitId(userName, repositoryName, commit.getName) && + JGitUtil.getBranchesOfCommit(oldGit, commit.getName).contains(branch) + }.head.id + + private def getRequestCompareInfo(userName: String, repositoryName: String, branch: String, + requestUserName: String, requestRepositoryName: String, requestCommitId: String): (Seq[Seq[CommitInfo]], Seq[DiffInfo]) = { + + using( + Git.open(getRepositoryDir(userName, repositoryName)), + Git.open(getRepositoryDir(requestUserName, requestRepositoryName)) + ){ (oldGit, newGit) => + val oldId = oldGit.getRepository.resolve(branch) + val newId = newGit.getRepository.resolve(requestCommitId) + + val commits = newGit.log.addRange(oldId, newId).call.iterator.asScala.map { revCommit => + new CommitInfo(revCommit) + }.toList.splitWith{ (commit1, commit2) => + view.helpers.date(commit1.time) == view.helpers.date(commit2.time) + } + + val diffs = JGitUtil.getDiffs(newGit, oldId.getName, newId.getName, true) + + (commits, diffs) + } + } + + private def searchPullRequests(userName: Option[String], repository: RepositoryService.RepositoryInfo) = + defining(repository.owner, repository.name){ case (owner, repoName) => + val filterUser = userName.map { x => Map("created_by" -> x) } getOrElse Map("all" -> "") + val page = IssueSearchCondition.page(request) + val sessionKey = Keys.Session.Pulls(owner, repoName) + + // retrieve search condition + val condition = session.putAndGet(sessionKey, + if(request.hasQueryString) IssueSearchCondition(request) + else session.getAs[IssueSearchCondition](sessionKey).getOrElse(IssueSearchCondition()) + ) + + pulls.html.list( + searchIssue(condition, filterUser, true, (page - 1) * PullRequestLimit, PullRequestLimit, owner -> repoName), + getPullRequestCountGroupByUser(condition.state == "closed", owner, Some(repoName)), + userName, + page, + countIssue(condition.copy(state = "open" ), filterUser, true, owner -> repoName), + countIssue(condition.copy(state = "closed"), filterUser, true, owner -> repoName), + countIssue(condition, Map.empty, true, owner -> repoName), + condition, + repository, + hasWritePermission(owner, repoName, context.loginAccount)) + } + +} diff --git a/src/main/scala/app/RepositorySettingsController.scala b/src/main/scala/app/RepositorySettingsController.scala index 08f6474..a9285fd 100644 --- a/src/main/scala/app/RepositorySettingsController.scala +++ b/src/main/scala/app/RepositorySettingsController.scala @@ -6,13 +6,20 @@ import jp.sf.amateras.scalatra.forms._ import org.apache.commons.io.FileUtils import org.scalatra.FlashMapSupport +import service.WebHookService.WebHookPayload +import util.JGitUtil.CommitInfo +import util.ControlUtil._ +import org.eclipse.jgit.api.Git class RepositorySettingsController extends RepositorySettingsControllerBase - with RepositoryService with AccountService with OwnerAuthenticator with UsersAuthenticator + with RepositoryService with AccountService with WebHookService + with OwnerAuthenticator with UsersAuthenticator trait RepositorySettingsControllerBase extends ControllerBase with FlashMapSupport { - self: RepositoryService with AccountService with OwnerAuthenticator with UsersAuthenticator => + self: RepositoryService with AccountService with WebHookService + with OwnerAuthenticator with UsersAuthenticator => + // for repository options case class OptionsForm(description: Option[String], defaultBranch: String, isPrivate: Boolean) val optionsForm = mapping( @@ -20,13 +27,21 @@ "defaultBranch" -> trim(label("Default Branch" , text(required, maxlength(100)))), "isPrivate" -> trim(label("Repository Type", boolean())) )(OptionsForm.apply) - + + // for collaborator addition case class CollaboratorForm(userName: String) val collaboratorForm = mapping( "userName" -> trim(label("Username", text(required, collaborator))) )(CollaboratorForm.apply) + // for web hook url addition + case class WebHookForm(url: String) + + val webHookForm = mapping( + "url" -> trim(label("url", text(required, webHook))) + )(WebHookForm.apply) + /** * Redirect to the Options page. */ @@ -45,7 +60,15 @@ * Save the repository options. */ post("/:owner/:repository/settings/options", optionsForm)(ownerOnly { (form, repository) => - saveRepositoryOptions(repository.owner, repository.name, form.description, form.defaultBranch, form.isPrivate) + saveRepositoryOptions( + repository.owner, + repository.name, + form.description, + form.defaultBranch, + repository.repository.parentUserName.map { _ => + repository.repository.isPrivate + } getOrElse form.isPrivate + ) flash += "info" -> "Repository settings has been updated." redirect(s"/${repository.owner}/${repository.name}/settings/options") }) @@ -54,22 +77,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,11 +97,63 @@ * 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") }) /** + * Display the web hook page. + */ + get("/:owner/:repository/settings/hooks")(ownerOnly { repository => + settings.html.hooks(getWebHookURLs(repository.owner, repository.name), repository, flash.get("info")) + }) + + /** + * Add the web hook URL. + */ + post("/:owner/:repository/settings/hooks/add", webHookForm)(ownerOnly { (form, repository) => + addWebHookURL(repository.owner, repository.name, form.url) + redirect(s"/${repository.owner}/${repository.name}/settings/hooks") + }) + + /** + * Delete the web hook URL. + */ + get("/:owner/:repository/settings/hooks/delete")(ownerOnly { repository => + deleteWebHookURL(repository.owner, repository.name, params("url")) + redirect(s"/${repository.owner}/${repository.name}/settings/hooks") + }) + + /** + * Send the test request to registered web hook URLs. + */ + get("/:owner/:repository/settings/hooks/test")(ownerOnly { repository => + using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => + import scala.collection.JavaConverters._ + val commits = git.log + .add(git.getRepository.resolve(repository.repository.defaultBranch)) + .setMaxCount(3) + .call.iterator.asScala.map(new CommitInfo(_)) + + val webHookURLs = getWebHookURLs(repository.owner, repository.name) + if(webHookURLs.nonEmpty){ + callWebHook(repository.owner, repository.name, webHookURLs, + WebHookPayload( + git, + "refs/heads/" + repository.repository.defaultBranch, + repository, + commits.toList, + getAccountByUserName(repository.owner).get)) + } + + flash += "info" -> "Test payload deployed!" + } + redirect(s"/${repository.owner}/${repository.name}/settings/hooks") + }) + + /** * Display the delete repository page. */ get("/:owner/:repository/settings/delete")(ownerOnly { @@ -102,18 +174,24 @@ }) /** + * Provides duplication check for web hook url. + */ + private def webHook: Constraint = new Constraint(){ + override def validate(name: String, value: String): Option[String] = + getWebHookURLs(params("owner"), params("repository")).map(_.url).find(_ == value).map(_ => "URL had been registered already.") + } + + /** * Provides Constraint to validate the collaborator name. */ private def collaborator: Constraint = new Constraint(){ - def validate(name: String, value: String): Option[String] = { - val paths = request.getRequestURI.split("/") + override def validate(name: String, value: String): Option[String] = getAccountByUserName(value) match { case None => Some("User does not exist.") - case Some(x) if(x.userName == paths(1) || getCollaborators(paths(1), paths(2)).contains(x.userName)) + case Some(x) if(x.userName == params("owner") || getCollaborators(params("owner"), params("repository")).contains(x.userName)) => Some("User can access this repository already.") case _ => None } - } } } \ No newline at end of file diff --git a/src/main/scala/app/RepositoryViewerController.scala b/src/main/scala/app/RepositoryViewerController.scala index 43cb126..638478d 100644 --- a/src/main/scala/app/RepositoryViewerController.scala +++ b/src/main/scala/app/RepositoryViewerController.scala @@ -2,7 +2,8 @@ import util.Directory._ import util.Implicits._ -import _root_.util.{ReferrerAuthenticator, JGitUtil, FileUtil} +import util.ControlUtil._ +import _root_.util.{ReferrerAuthenticator, JGitUtil, FileUtil, StringUtil} import service._ import org.scalatra._ import java.io.File @@ -10,6 +11,7 @@ import org.eclipse.jgit.lib._ import org.apache.commons.io.FileUtils import org.eclipse.jgit.treewalk._ +import org.eclipse.jgit.api.errors.RefNotFoundException class RepositoryViewerController extends RepositoryViewerControllerBase with RepositoryService with AccountService with ReferrerAuthenticator @@ -38,48 +40,28 @@ }) /** - * Displays the file list of the repository root and the specified branch. - */ - get("/:owner/:repository/tree/:id")(referrersOnly { - fileList(_, params("id")) - }) - - /** * Displays the file list of the specified path and branch. */ - get("/:owner/:repository/tree/:id/*")(referrersOnly { - fileList(_, params("id"), multiParams("splat").head) - }) - - /** - * Displays the commit list of the specified branch. - */ - get("/:owner/:repository/commits/:branch")(referrersOnly { repository => - val branchName = params("branch") - val page = params.getOrElse("page", "1").toInt - JGitUtil.withGit(getRepositoryDir(repository.owner, repository.name)){ git => - JGitUtil.getCommitLog(git, branchName, page, 30) match { - case Right((logs, hasNext)) => - repo.html.commits(Nil, branchName, repository, logs.splitWith{ (commit1, commit2) => - view.helpers.date(commit1.time) == view.helpers.date(commit2.time) - }, page, hasNext) - case Left(_) => NotFound - } + get("/:owner/:repository/tree/*")(referrersOnly { repository => + val (id, path) = splitPath(repository, multiParams("splat").head) + if(path.isEmpty){ + fileList(repository, id) + } else { + fileList(repository, id, path) } }) /** * Displays the commit list of the specified resource. */ - get("/:owner/:repository/commits/:branch/*")(referrersOnly { repository => - val branchName = params("branch") - val path = multiParams("splat").head //.replaceFirst("^tree/.+?/", "") - val page = params.getOrElse("page", "1").toInt + get("/:owner/:repository/commits/*")(referrersOnly { repository => + val (branchName, path) = splitPath(repository, multiParams("splat").head) + val page = params.get("page").flatMap(_.toIntOpt).getOrElse(1) - JGitUtil.withGit(getRepositoryDir(repository.owner, repository.name)){ git => + using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => JGitUtil.getCommitLog(git, branchName, page, 30, path) match { case Right((logs, hasNext)) => - repo.html.commits(path.split("/").toList, branchName, repository, + repo.html.commits(if(path.isEmpty) Nil else path.split("/").toList, branchName, repository, logs.splitWith{ (commit1, commit2) => view.helpers.date(commit1.time) == view.helpers.date(commit2.time) }, page, hasNext) @@ -91,12 +73,11 @@ /** * Displays the file content of the specified branch or commit. */ - get("/:owner/:repository/blob/:id/*")(referrersOnly { repository => - val id = params("id") // branch name or commit id - val raw = params.get("raw").getOrElse("false").toBoolean - val path = multiParams("splat").head //.replaceFirst("^tree/.+?/", "") + get("/:owner/:repository/blob/*")(referrersOnly { repository => + val (id, path) = splitPath(repository, multiParams("splat").head) + val raw = params.get("raw").getOrElse("false").toBoolean - JGitUtil.withGit(getRepositoryDir(repository.owner, repository.name)){ git => + using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => val revCommit = JGitUtil.getRevCommitFromId(git, git.getRepository.resolve(id)) @scala.annotation.tailrec @@ -105,19 +86,18 @@ case true => getPathObjectId(path, walk) } - val treeWalk = new TreeWalk(git.getRepository) - val objectId = try { + val objectId = using(new TreeWalk(git.getRepository)){ treeWalk => treeWalk.addTree(revCommit.getTree) treeWalk.setRecursive(true) getPathObjectId(path, treeWalk) - } finally { - treeWalk.release } if(raw){ // Download - contentType = "application/octet-stream" - JGitUtil.getContent(git, objectId, false).get + defining(JGitUtil.getContent(git, objectId, false).get){ bytes => + contentType = FileUtil.getContentType(path, bytes) + bytes + } } else { // Viewer val large = FileUtil.isLarge(git.getRepository.getObjectDatabase.open(objectId).getSize) @@ -127,7 +107,7 @@ val content = if(viewer == "other"){ if(bytes.isDefined && FileUtil.isText(bytes.get)){ // text - JGitUtil.ContentInfo("text", bytes.map(new String(_, "UTF-8"))) + JGitUtil.ContentInfo("text", bytes.map(StringUtil.convertFromByteArray)) } else { // binary JGitUtil.ContentInfo("binary", None) @@ -148,22 +128,39 @@ get("/:owner/:repository/commit/:id")(referrersOnly { repository => val id = params("id") - JGitUtil.withGit(getRepositoryDir(repository.owner, repository.name)){ git => - val revCommit = JGitUtil.getRevCommitFromId(git, git.getRepository.resolve(id)) - - repo.html.commit(id, new JGitUtil.CommitInfo(revCommit), - JGitUtil.getBranchesOfCommit(git, revCommit.getName), JGitUtil.getTagsOfCommit(git, revCommit.getName), - repository, JGitUtil.getDiffs(git, id)) + using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => + defining(JGitUtil.getRevCommitFromId(git, git.getRepository.resolve(id))){ revCommit => + JGitUtil.getDiffs(git, id) match { case (diffs, oldCommitId) => + repo.html.commit(id, new JGitUtil.CommitInfo(revCommit), + JGitUtil.getBranchesOfCommit(git, revCommit.getName), + JGitUtil.getTagsOfCommit(git, revCommit.getName), + repository, diffs, oldCommitId) + } + } } }) /** + * Displays branches. + */ + get("/:owner/:repository/branches")(referrersOnly { repository => + using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => + // retrieve latest update date of each branch + val branchInfo = repository.branchList.map { branchName => + val revCommit = git.log.add(git.getRepository.resolve(branchName)).setMaxCount(1).call.iterator.next + (branchName, revCommit.getCommitterIdent.getWhen) + } + repo.html.branches(branchInfo, repository) + } + }) + + /** * Displays tags. */ get("/:owner/:repository/tags")(referrersOnly { repo.html.tags(_) }) - + /** * Download repository contents as an archive. */ @@ -180,11 +177,12 @@ // clone the repository val cloneDir = new File(workDir, revision) - JGitUtil.withGit(Git.cloneRepository + using(Git.cloneRepository .setURI(getRepositoryDir(repository.owner, repository.name).toURI.toString) .setDirectory(cloneDir) + .setBranch(revision) .call){ git => - + // checkout the specified revision git.checkout.setName(revision).call } @@ -202,7 +200,29 @@ BadRequest } }) + + get("/:owner/:repository/network/members")(referrersOnly { repository => + repo.html.forked( + getRepository( + repository.repository.originUserName.getOrElse(repository.owner), + repository.repository.originRepositoryName.getOrElse(repository.name), + baseUrl), + getForkedRepositories( + repository.repository.originUserName.getOrElse(repository.owner), + repository.repository.originRepositoryName.getOrElse(repository.name)), + repository) + }) + private def splitPath(repository: service.RepositoryService.RepositoryInfo, path: String): (String, String) = { + val id = repository.branchList.collectFirst { + case branch if(path == branch || path.startsWith(branch + "/")) => branch + } orElse repository.tags.collectFirst { + case tag if(path == tag.name || path.startsWith(tag.name + "/")) => tag.name + } orElse Some(path.split("/")(0)) get + + (id, path.substring(id.length).replaceFirst("^/", "")) + } + /** * Provides HTML of the file list. * @@ -215,26 +235,26 @@ if(repository.commitCount == 0){ repo.html.guide(repository) } else { - JGitUtil.withGit(getRepositoryDir(repository.owner, repository.name)){ git => + using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => val revisions = Seq(if(revstr.isEmpty) repository.repository.defaultBranch else revstr, repository.branchList.head) // get specified commit - revisions.map { rev => (git.getRepository.resolve(rev), rev)}.find(_._1 != null).map { case (objectId, revision) => - val revCommit = JGitUtil.getRevCommitFromId(git, objectId) - + JGitUtil.getDefaultBranch(git, repository, revstr).map { case (objectId, revision) => + defining(JGitUtil.getRevCommitFromId(git, objectId)){ revCommit => // get files - val files = JGitUtil.getFileList(git, revision, path) - // process README.md - val readme = files.find(_.name == "README.md").map { file => - new String(JGitUtil.getContent(Git.open(getRepositoryDir(repository.owner, repository.name)), file.id, true).get, "UTF-8") - } + val files = JGitUtil.getFileList(git, revision, path) + // process README.md + val readme = files.find(_.name == "README.md").map { file => + StringUtil.convertFromByteArray(JGitUtil.getContent(Git.open(getRepositoryDir(repository.owner, repository.name)), file.id, true).get) + } - repo.html.files(revision, repository, - if(path == ".") Nil else path.split("/").toList, // current path - new JGitUtil.CommitInfo(revCommit), // latest commit - files, readme) + repo.html.files(revision, repository, + if(path == ".") Nil else path.split("/").toList, // current path + new JGitUtil.CommitInfo(revCommit), // latest commit + files, readme) + } } getOrElse NotFound } } } -} \ No newline at end of file +} diff --git a/src/main/scala/app/SearchController.scala b/src/main/scala/app/SearchController.scala new file mode 100644 index 0000000..99ea743 --- /dev/null +++ b/src/main/scala/app/SearchController.scala @@ -0,0 +1,52 @@ +package app + +import util._ +import ControlUtil._ +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 => + defining(params("q").trim, params.getOrElse("type", "code")){ case (query, target) => + 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..7d4b2a9 100644 --- a/src/main/scala/app/SignInController.scala +++ b/src/main/scala/app/SignInController.scala @@ -1,8 +1,9 @@ package app import service._ -import util.StringUtil._ import jp.sf.amateras.scalatra.forms._ +import util.Implicits._ +import util.Keys class SignInController extends SignInControllerBase with SystemSettingsService with AccountService @@ -16,27 +17,17 @@ )(SignInForm.apply) get("/signin"){ - val queryString = request.getQueryString - if(queryString != null && queryString.startsWith("/")){ - session.setAttribute("REDIRECT", queryString) + val redirect = params.get("redirect") + if(redirect.isDefined && redirect.get.startsWith("/")){ + session.setAttribute(Keys.Session.Redirect, redirect.get) } html.signin(loadSystemSettings()) } 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) - - session.get("REDIRECT").map { redirectUrl => - session.removeAttribute("REDIRECT") - redirect(redirectUrl.asInstanceOf[String]) - }.getOrElse { - redirect("/") - } + authenticate(loadSystemSettings(), form.userName, form.password) match { + case Some(account) => signin(account) + case None => redirect("/signin") } } @@ -45,4 +36,22 @@ redirect("/") } + /** + * Set account information into HttpSession and redirect. + */ + private def signin(account: model.Account) = { + session.setAttribute(Keys.Session.LoginAccount, account) + updateLastLoginDate(account.userName) + + session.getAndRemove[String](Keys.Session.Redirect).map { redirectUrl => + if(redirectUrl.replaceFirst("/$", "") == request.getContextPath){ + redirect("/") + } else { + redirect(redirectUrl) + } + }.getOrElse { + redirect("/") + } + } + } \ No newline at end of file diff --git a/src/main/scala/app/SystemSettingsController.scala b/src/main/scala/app/SystemSettingsController.scala index 6416374..9466a83 100644 --- a/src/main/scala/app/SystemSettingsController.scala +++ b/src/main/scala/app/SystemSettingsController.scala @@ -12,11 +12,30 @@ trait SystemSettingsControllerBase extends ControllerBase with FlashMapSupport { self: SystemSettingsService with AccountService with AdminAuthenticator => - private case class SystemSettingsForm(allowAccountRegistration: Boolean) - private val form = mapping( - "allowAccountRegistration" -> trim(label("Account registration", boolean())) - )(SystemSettingsForm.apply) + "allowAccountRegistration" -> trim(label("Account registration", boolean())), + "gravatar" -> trim(label("Gravatar", boolean())), + "notification" -> trim(label("Notification", boolean())), + "smtp" -> optionalIfNotChecked("notification", mapping( + "host" -> trim(label("SMTP Host", text(required))), + "port" -> trim(label("SMTP Port", optional(number()))), + "user" -> trim(label("SMTP User", optional(text()))), + "password" -> trim(label("SMTP Password", optional(text()))), + "ssl" -> trim(label("Enable SSL", optional(boolean()))), + "fromAddress" -> trim(label("FROM Address", optional(text()))), + "fromName" -> trim(label("FROM Name", optional(text()))) + )(Smtp.apply)), + "ldapAuthentication" -> trim(label("LDAP", boolean())), + "ldap" -> optionalIfNotChecked("ldapAuthentication", mapping( + "host" -> trim(label("LDAP host", text(required))), + "port" -> trim(label("LDAP port", optional(number()))), + "bindDN" -> trim(label("Bind DN", optional(text()))), + "bindPassword" -> trim(label("Bind Password", optional(text()))), + "baseDN" -> trim(label("Base DN", text(required))), + "userNameAttribute" -> trim(label("User name attribute", text(required))), + "mailAttribute" -> trim(label("Mail address attribute", text(required))) + )(Ldap.apply)) + )(SystemSettings.apply) get("/admin/system")(adminOnly { @@ -24,7 +43,7 @@ }) post("/admin/system", form)(adminOnly { form => - saveSystemSettings(SystemSettings(form.allowAccountRegistration)) + saveSystemSettings(form) flash += "info" -> "System settings has been updated." redirect("/admin/system") }) diff --git a/src/main/scala/app/UserManagementController.scala b/src/main/scala/app/UserManagementController.scala index e5673d4..2a44cb9 100644 --- a/src/main/scala/app/UserManagementController.scala +++ b/src/main/scala/app/UserManagementController.scala @@ -1,67 +1,96 @@ package app import service._ -import util.{FileUploadUtil, FileUtil, AdminAuthenticator} +import util.AdminAuthenticator import util.StringUtil._ +import util.ControlUtil._ 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, fullName: 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], fullName: 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)))), + "fullName" -> trim(label("Full Name" , text(required, maxlength(100)))), "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))))), + "fullName" -> trim(label("Full Name" , text(required, maxlength(100)))), "mailAddress" -> trim(label("Mail Address" , text(required, maxlength(100), uniqueMailAddress("userName")))), "isAdmin" -> trim(label("User Type" , boolean())), "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, uniqueUserName))), + "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 => - createAccount(form.userName, sha1(form.password), form.mailAddress, form.isAdmin, form.url) + post("/admin/users/_newuser", newUserForm)(adminOnly { form => + createAccount(form.userName, sha1(form.password), form.fullName, 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( password = form.password.map(sha1).getOrElse(account.password), + fullName = form.fullName, mailAddress = form.mailAddress, isAdmin = form.isAdmin, url = form.url)) @@ -71,5 +100,46 @@ } getOrElse NotFound }) - -} \ No newline at end of file + + 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 { + defining(params("groupName")){ groupName => + admin.users.html.group(getAccountByUserName(groupName), getGroupMembers(groupName)) + } + }) + + post("/admin/users/:groupName/_editgroup", editGroupForm)(adminOnly { form => + defining(params("groupName"), form.memberNames.map(_.split(",").toList).getOrElse(Nil)){ case (groupName, memberNames) => + getAccountByUserName(groupName).map { account => + updateGroup(groupName, form.url) + 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 + }) + +} diff --git a/src/main/scala/app/WikiController.scala b/src/main/scala/app/WikiController.scala index 932f9e2..4d141de 100644 --- a/src/main/scala/app/WikiController.scala +++ b/src/main/scala/app/WikiController.scala @@ -1,32 +1,39 @@ package app import service._ -import util.{CollaboratorsAuthenticator, ReferrerAuthenticator, JGitUtil, StringUtil} +import util._ import util.Directory._ +import util.ControlUtil._ import jp.sf.amateras.scalatra.forms._ +import org.eclipse.jgit.api.Git +import org.scalatra.FlashMapSupport +import service.WikiService.WikiPageInfo +import scala.Some class WikiController extends WikiControllerBase with WikiService with RepositoryService with AccountService with ActivityService with CollaboratorsAuthenticator with ReferrerAuthenticator -trait WikiControllerBase extends ControllerBase { +trait WikiControllerBase extends ControllerBase with FlashMapSupport { self: WikiService with RepositoryService with ActivityService with CollaboratorsAuthenticator with ReferrerAuthenticator => - case class WikiPageEditForm(pageName: String, content: String, message: Option[String], currentPageName: String) + case class WikiPageEditForm(pageName: String, content: String, message: Option[String], currentPageName: String, id: String) val newForm = mapping( - "pageName" -> trim(label("Page name" , text(required, maxlength(40), pagename, unique))), - "content" -> trim(label("Content" , text(required))), - "message" -> trim(label("Message" , optional(text()))), - "currentPageName" -> trim(label("Current page name" , text())) + "pageName" -> trim(label("Page name" , text(required, maxlength(40), pagename, unique))), + "content" -> trim(label("Content" , text(required, conflictForNew))), + "message" -> trim(label("Message" , optional(text()))), + "currentPageName" -> trim(label("Current page name" , text())), + "id" -> trim(label("Latest commit id" , text())) )(WikiPageEditForm.apply) val editForm = mapping( - "pageName" -> trim(label("Page name" , text(required, maxlength(40), pagename))), - "content" -> trim(label("Content" , text(required))), - "message" -> trim(label("Message" , optional(text()))), - "currentPageName" -> trim(label("Current page name" , text(required))) + "pageName" -> trim(label("Page name" , text(required, maxlength(40), pagename))), + "content" -> trim(label("Content" , text(required, conflictForEdit))), + "message" -> trim(label("Message" , optional(text()))), + "currentPageName" -> trim(label("Current page name" , text(required))), + "id" -> trim(label("Latest commit id" , text(required))) )(WikiPageEditForm.apply) get("/:owner/:repository/wiki")(referrersOnly { repository => @@ -40,13 +47,13 @@ getWikiPage(repository.owner, repository.name, pageName).map { page => wiki.html.page(pageName, page, repository, hasWritePermission(repository.owner, repository.name, context.loginAccount)) - } getOrElse redirect(s"/${repository.owner}/${repository.name}/wiki/${pageName}/_edit") // TODO URLEncode + } getOrElse redirect(s"/${repository.owner}/${repository.name}/wiki/${StringUtil.urlEncode(pageName)}/_edit") }) get("/:owner/:repository/wiki/:page/_history")(referrersOnly { repository => val pageName = StringUtil.urlDecode(params("page")) - JGitUtil.withGit(getWikiRepositoryDir(repository.owner, repository.name)){ git => + using(Git.open(getWikiRepositoryDir(repository.owner, repository.name))){ git => JGitUtil.getCommitLog(git, "master", path = pageName + ".md") match { case Right((logs, hasNext)) => wiki.html.history(Some(pageName), logs, repository) case Left(_) => NotFound @@ -56,36 +63,60 @@ get("/:owner/:repository/wiki/:page/_compare/:commitId")(referrersOnly { repository => val pageName = StringUtil.urlDecode(params("page")) - val commitId = params("commitId").split("\\.\\.\\.") + val Array(from, to) = params("commitId").split("\\.\\.\\.") - JGitUtil.withGit(getWikiRepositoryDir(repository.owner, repository.name)){ git => - wiki.html.compare(Some(pageName), getWikiDiffs(git, commitId(0), commitId(1)), repository) + using(Git.open(getWikiRepositoryDir(repository.owner, repository.name))){ git => + wiki.html.compare(Some(pageName), from, to, JGitUtil.getDiffs(git, from, to, true), repository, + hasWritePermission(repository.owner, repository.name, context.loginAccount), flash.get("info")) } }) get("/:owner/:repository/wiki/_compare/:commitId")(referrersOnly { repository => - val commitId = params("commitId").split("\\.\\.\\.") + val Array(from, to) = params("commitId").split("\\.\\.\\.") - JGitUtil.withGit(getWikiRepositoryDir(repository.owner, repository.name)){ git => - wiki.html.compare(None, getWikiDiffs(git, commitId(0), commitId(1)), repository) + using(Git.open(getWikiRepositoryDir(repository.owner, repository.name))){ git => + wiki.html.compare(None, from, to, JGitUtil.getDiffs(git, from, to, true), repository, + hasWritePermission(repository.owner, repository.name, context.loginAccount), flash.get("info")) } }) - + + get("/:owner/:repository/wiki/:page/_revert/:commitId")(collaboratorsOnly { repository => + val pageName = StringUtil.urlDecode(params("page")) + val Array(from, to) = params("commitId").split("\\.\\.\\.") + + if(revertWikiPage(repository.owner, repository.name, from, to, context.loginAccount.get, Some(pageName))){ + redirect(s"/${repository.owner}/${repository.name}/wiki/${StringUtil.urlEncode(pageName)}") + } else { + flash += "info" -> "This patch was not able to be reversed." + redirect(s"/${repository.owner}/${repository.name}/wiki/${StringUtil.urlEncode(pageName)}/_compare/${from}...${to}") + } + }) + + get("/:owner/:repository/wiki/_revert/:commitId")(collaboratorsOnly { repository => + val Array(from, to) = params("commitId").split("\\.\\.\\.") + + if(revertWikiPage(repository.owner, repository.name, from, to, context.loginAccount.get, None)){ + redirect(s"/${repository.owner}/${repository.name}/wiki/}") + } else { + flash += "info" -> "This patch was not able to be reversed." + redirect(s"/${repository.owner}/${repository.name}/wiki/_compare/${from}...${to}") + } + }) + get("/:owner/:repository/wiki/:page/_edit")(collaboratorsOnly { repository => val pageName = StringUtil.urlDecode(params("page")) wiki.html.edit(pageName, getWikiPage(repository.owner, repository.name, pageName), repository) }) post("/:owner/:repository/wiki/_edit", editForm)(collaboratorsOnly { (form, repository) => - val loginAccount = context.loginAccount.get - - saveWikiPage(repository.owner, repository.name, form.currentPageName, form.pageName, - form.content, loginAccount, form.message.getOrElse("")) - - updateLastActivityDate(repository.owner, repository.name) - recordEditWikiPageActivity(repository.owner, repository.name, loginAccount.userName, form.pageName) - - redirect(s"/${repository.owner}/${repository.name}/wiki/${StringUtil.urlEncode(form.pageName)}") + defining(context.loginAccount.get){ loginAccount => + saveWikiPage(repository.owner, repository.name, form.currentPageName, form.pageName, + form.content, loginAccount, form.message.getOrElse(""), Some(form.id)).map { commitId => + updateLastActivityDate(repository.owner, repository.name) + recordEditWikiPageActivity(repository.owner, repository.name, loginAccount.userName, form.pageName, commitId) + } + redirect(s"/${repository.owner}/${repository.name}/wiki/${StringUtil.urlEncode(form.pageName)}") + } }) get("/:owner/:repository/wiki/_new")(collaboratorsOnly { @@ -93,24 +124,26 @@ }) post("/:owner/:repository/wiki/_new", newForm)(collaboratorsOnly { (form, repository) => - val loginAccount = context.loginAccount.get - - saveWikiPage(repository.owner, repository.name, form.currentPageName, form.pageName, - form.content, context.loginAccount.get, form.message.getOrElse("")) - - updateLastActivityDate(repository.owner, repository.name) - recordCreateWikiPageActivity(repository.owner, repository.name, loginAccount.userName, form.pageName) + defining(context.loginAccount.get){ loginAccount => + saveWikiPage(repository.owner, repository.name, form.currentPageName, form.pageName, + form.content, loginAccount, form.message.getOrElse(""), None) - redirect(s"/${repository.owner}/${repository.name}/wiki/${StringUtil.urlEncode(form.pageName)}") + updateLastActivityDate(repository.owner, repository.name) + recordCreateWikiPageActivity(repository.owner, repository.name, loginAccount.userName, form.pageName) + + redirect(s"/${repository.owner}/${repository.name}/wiki/${StringUtil.urlEncode(form.pageName)}") + } }) get("/:owner/:repository/wiki/:page/_delete")(collaboratorsOnly { repository => - val pageName = StringUtil.urlDecode(params("page")) - - deleteWikiPage(repository.owner, repository.name, pageName, context.loginAccount.get.userName, s"Delete ${pageName}") - updateLastActivityDate(repository.owner, repository.name) + val pageName = StringUtil.urlDecode(params("page")) - redirect(s"/${repository.owner}/${repository.name}/wiki") + defining(context.loginAccount.get){ loginAccount => + deleteWikiPage(repository.owner, repository.name, pageName, loginAccount.fullName, loginAccount.mailAddress, s"Destroyed ${pageName}") + updateLastActivityDate(repository.owner, repository.name) + + redirect(s"/${repository.owner}/${repository.name}/wiki") + } }) get("/:owner/:repository/wiki/_pages")(referrersOnly { repository => @@ -119,7 +152,7 @@ }) get("/:owner/:repository/wiki/_history")(referrersOnly { repository => - JGitUtil.withGit(getWikiRepositoryDir(repository.owner, repository.name)){ git => + using(Git.open(getWikiRepositoryDir(repository.owner, repository.name))){ git => JGitUtil.getCommitLog(git, "master") match { case Right((logs, hasNext)) => wiki.html.history(None, logs, repository) case Left(_) => NotFound @@ -128,19 +161,21 @@ }) get("/:owner/:repository/wiki/_blob/*")(referrersOnly { repository => - getFileContent(repository.owner, repository.name, multiParams("splat").head).map { content => - contentType = "application/octet-stream" - content + val path = multiParams("splat").head + + getFileContent(repository.owner, repository.name, path).map { bytes => + contentType = FileUtil.getContentType(path, bytes) + bytes } getOrElse NotFound }) private def unique: Constraint = new Constraint(){ - def validate(name: String, value: String): Option[String] = + override def validate(name: String, value: String, params: Map[String, String]): Option[String] = getWikiPageList(params("owner"), params("repository")).find(_ == value).map(_ => "Page already exists.") } private def pagename: Constraint = new Constraint(){ - def validate(name: String, value: String): Option[String] = + override def validate(name: String, value: String): Option[String] = if(value.exists("\\/:*?\"<>|".contains(_))){ Some(s"${name} contains invalid character.") } else if(value.startsWith("_") || value.startsWith("-")){ @@ -150,5 +185,22 @@ } } + private def conflictForNew: Constraint = new Constraint(){ + override def validate(name: String, value: String): Option[String] = { + optionIf(targetWikiPage.nonEmpty){ + Some("Someone has created the wiki since you started. Please reload this page and re-apply your changes.") + } + } + } -} \ No newline at end of file + private def conflictForEdit: Constraint = new Constraint(){ + override def validate(name: String, value: String): Option[String] = { + optionIf(targetWikiPage.map(_.id != params("id")).getOrElse(true)){ + Some("Someone has edited the wiki since you started. Please reload this page and re-apply your changes.") + } + } + } + + private def targetWikiPage = getWikiPage(params("owner"), params("repository"), params("pageName")) + +} diff --git a/src/main/scala/model/Account.scala b/src/main/scala/model/Account.scala index d0f1f59..4324d0f 100644 --- a/src/main/scala/model/Account.scala +++ b/src/main/scala/model/Account.scala @@ -4,6 +4,7 @@ object Accounts extends Table[Account]("ACCOUNT") { def userName = column[String]("USER_NAME", O PrimaryKey) + def fullName = column[String]("FULL_NAME") def mailAddress = column[String]("MAIL_ADDRESS") def password = column[String]("PASSWORD") def isAdmin = column[Boolean]("ADMINISTRATOR") @@ -12,11 +13,13 @@ 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 ~ fullName ~ mailAddress ~ password ~ isAdmin ~ url.? ~ registeredDate ~ updatedDate ~ lastLoginDate.? ~ image.? ~ groupAccount <> (Account, Account.unapply _) } case class Account( userName: String, + fullName: String, mailAddress: String, password: String, isAdmin: Boolean, @@ -24,5 +27,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/Issue.scala b/src/main/scala/model/Issue.scala index d134b8e..d5ce8a3 100644 --- a/src/main/scala/model/Issue.scala +++ b/src/main/scala/model/Issue.scala @@ -7,6 +7,11 @@ def byPrimaryKey(owner: String, repository: String) = byRepository(owner, repository) } +object IssueOutline extends Table[(String, String, Int, Int)]("ISSUE_OUTLINE_VIEW") with IssueTemplate { + def commentCount = column[Int]("COMMENT_COUNT") + def * = userName ~ repositoryName ~ issueId ~ commentCount +} + object Issues extends Table[Issue]("ISSUE") with IssueTemplate with MilestoneTemplate { def openedUserName = column[String]("OPENED_USER_NAME") def assignedUserName = column[String]("ASSIGNED_USER_NAME") @@ -15,7 +20,8 @@ def closed = column[Boolean]("CLOSED") def registeredDate = column[java.util.Date]("REGISTERED_DATE") def updatedDate = column[java.util.Date]("UPDATED_DATE") - def * = userName ~ repositoryName ~ issueId ~ openedUserName ~ milestoneId.? ~ assignedUserName.? ~ title ~ content.? ~ closed ~ registeredDate ~ updatedDate <> (Issue, Issue.unapply _) + def pullRequest = column[Boolean]("PULL_REQUEST") + def * = userName ~ repositoryName ~ issueId ~ openedUserName ~ milestoneId.? ~ assignedUserName.? ~ title ~ content.? ~ closed ~ registeredDate ~ updatedDate ~ pullRequest <> (Issue, Issue.unapply _) def byPrimaryKey(owner: String, repository: String, issueId: Int) = byIssue(owner, repository, issueId) } @@ -31,4 +37,5 @@ content: Option[String], closed: Boolean, registeredDate: java.util.Date, - updatedDate: java.util.Date) \ No newline at end of file + updatedDate: java.util.Date, + isPullRequest: Boolean) \ No newline at end of file diff --git a/src/main/scala/model/PullRequest.scala b/src/main/scala/model/PullRequest.scala new file mode 100644 index 0000000..0fb3205 --- /dev/null +++ b/src/main/scala/model/PullRequest.scala @@ -0,0 +1,28 @@ +package model + +import scala.slick.driver.H2Driver.simple._ + +object PullRequests extends Table[PullRequest]("PULL_REQUEST") with IssueTemplate { + def branch = column[String]("BRANCH") + def requestUserName = column[String]("REQUEST_USER_NAME") + def requestRepositoryName = column[String]("REQUEST_REPOSITORY_NAME") + def requestBranch = column[String]("REQUEST_BRANCH") + def commitIdFrom = column[String]("COMMIT_ID_FROM") + def commitIdTo = column[String]("COMMIT_ID_TO") + def * = userName ~ repositoryName ~ issueId ~ branch ~ requestUserName ~ requestRepositoryName ~ requestBranch ~ commitIdFrom ~ commitIdTo <> (PullRequest, PullRequest.unapply _) + + def byPrimaryKey(userName: String, repositoryName: String, issueId: Int) = byIssue(userName, repositoryName, issueId) + def byPrimaryKey(userName: Column[String], repositoryName: Column[String], issueId: Column[Int]) = byIssue(userName, repositoryName, issueId) +} + +case class PullRequest( + userName: String, + repositoryName: String, + issueId: Int, + branch: String, + requestUserName: String, + requestRepositoryName: String, + requestBranch: String, + commitIdFrom: String, + commitIdTo: String +) \ No newline at end of file diff --git a/src/main/scala/model/Repository.scala b/src/main/scala/model/Repository.scala index fcfb336..215b670 100644 --- a/src/main/scala/model/Repository.scala +++ b/src/main/scala/model/Repository.scala @@ -9,7 +9,11 @@ def registeredDate = column[java.util.Date]("REGISTERED_DATE") def updatedDate = column[java.util.Date]("UPDATED_DATE") def lastActivityDate = column[java.util.Date]("LAST_ACTIVITY_DATE") - def * = userName ~ repositoryName ~ isPrivate ~ description.? ~ defaultBranch ~ registeredDate ~ updatedDate ~ lastActivityDate <> (Repository, Repository.unapply _) + def originUserName = column[String]("ORIGIN_USER_NAME") + def originRepositoryName = column[String]("ORIGIN_REPOSITORY_NAME") + def parentUserName = column[String]("PARENT_USER_NAME") + def parentRepositoryName = column[String]("PARENT_REPOSITORY_NAME") + def * = userName ~ repositoryName ~ isPrivate ~ description.? ~ defaultBranch ~ registeredDate ~ updatedDate ~ lastActivityDate ~ originUserName.? ~ originRepositoryName.? ~ parentUserName.? ~ parentRepositoryName.? <> (Repository, Repository.unapply _) def byPrimaryKey(owner: String, repository: String) = byRepository(owner, repository) } @@ -22,5 +26,9 @@ defaultBranch: String, registeredDate: java.util.Date, updatedDate: java.util.Date, - lastActivityDate: java.util.Date + lastActivityDate: java.util.Date, + originUserName: Option[String], + originRepositoryName: Option[String], + parentUserName: Option[String], + parentRepositoryName: Option[String] ) diff --git a/src/main/scala/model/WebHook.scala b/src/main/scala/model/WebHook.scala new file mode 100644 index 0000000..63b0fac --- /dev/null +++ b/src/main/scala/model/WebHook.scala @@ -0,0 +1,16 @@ +package model + +import scala.slick.driver.H2Driver.simple._ + +object WebHooks extends Table[WebHook]("WEB_HOOK") with BasicTemplate { + def url = column[String]("URL") + def * = userName ~ repositoryName ~ url <> (WebHook, WebHook.unapply _) + + def byPrimaryKey(owner: String, repository: String, url: String) = byRepository(owner, repository) && (this.url is url.bind) +} + +case class WebHook( + userName: String, + repositoryName: String, + url: String +) 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..5d8a312 100644 --- a/src/main/scala/service/AccountService.scala +++ b/src/main/scala/service/AccountService.scala @@ -3,9 +3,54 @@ import model._ import scala.slick.driver.H2Driver.simple._ import Database.threadLocalSession +import service.SystemSettingsService.SystemSettings +import util.StringUtil._ +import model.GroupMember +import scala.Some +import model.Account +import util.LDAPUtil +import org.slf4j.LoggerFactory trait AccountService { + private val logger = LoggerFactory.getLogger(classOf[AccountService]) + + def authenticate(settings: SystemSettings, userName: String, password: String): Option[Account] = + if(settings.ldapAuthentication){ + ldapAuthentication(settings, userName, password) + } else { + defaultAuthentication(userName, password) + } + + /** + * Authenticate by internal database. + */ + private def defaultAuthentication(userName: String, password: String) = { + getAccountByUserName(userName).collect { + case account if(!account.isGroupAccount && account.password == sha1(password)) => Some(account) + } getOrElse None + } + + /** + * Authenticate by LDAP. + */ + private def ldapAuthentication(settings: SystemSettings, userName: String, password: String) = { + LDAPUtil.authenticate(settings.ldap.get, userName, password) match { + case Right(mailAddress) => { + // Create or update account by LDAP information + getAccountByUserName(userName) match { + case Some(x) => updateAccount(x.copy(mailAddress = mailAddress)) + case None => createAccount(userName, "", userName, mailAddress, false, None) + } + getAccountByUserName(userName) + } + case Left(errorMessage) => { + logger.info(s"LDAP Authentication Failed: ${errorMessage}") + defaultAuthentication(userName, password) + } + } + } + def getAccountByUserName(userName: String): Option[Account] = Query(Accounts) filter(_.userName is userName.bind) firstOption @@ -14,24 +59,27 @@ def getAllUsers(): List[Account] = Query(Accounts) sortBy(_.userName) list - def createAccount(userName: String, password: String, mailAddress: String, isAdmin: Boolean, url: Option[String]): Unit = + def createAccount(userName: String, password: String, fullName: String, mailAddress: String, isAdmin: Boolean, url: Option[String]): Unit = Accounts insert Account( userName = userName, password = password, + fullName = fullName, mailAddress = mailAddress, isAdmin = isAdmin, url = url, registeredDate = currentDate, updatedDate = currentDate, lastLoginDate = None, - image = None) + image = None, + isGroupAccount = false) def updateAccount(account: Account): Unit = Accounts .filter { a => a.userName is account.userName.bind } - .map { a => a.password ~ a.mailAddress ~ a.isAdmin ~ a.url.? ~ a.registeredDate ~ a.updatedDate ~ a.lastLoginDate.? } + .map { a => a.password ~ a.fullName ~ a.mailAddress ~ a.isAdmin ~ a.url.? ~ a.registeredDate ~ a.updatedDate ~ a.lastLoginDate.? } .update ( account.password, + account.fullName, account.mailAddress, account.isAdmin, account.url, @@ -44,5 +92,43 @@ 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 = "", + fullName = groupName, + 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/ActivityService.scala b/src/main/scala/service/ActivityService.scala index 0b7e261..b607488 100644 --- a/src/main/scala/service/ActivityService.scala +++ b/src/main/scala/service/ActivityService.scala @@ -6,23 +6,23 @@ trait ActivityService { - def getActivitiesByUser(activityUserName: String, isPublic: Boolean): List[Activity] = { - val q = Query(Activities) + def getActivitiesByUser(activityUserName: String, isPublic: Boolean): List[Activity] = + Activities .innerJoin(Repositories).on((t1, t2) => t1.byRepository(t2.userName, t2.repositoryName)) + .filter { case (t1, t2) => + if(isPublic){ + (t1.activityUserName is activityUserName.bind) && (t2.isPrivate is false.bind) + } else { + (t1.activityUserName is activityUserName.bind) + } + } + .sortBy { case (t1, t2) => t1.activityId desc } + .map { case (t1, t2) => t1 } + .take(30) + .list - (if(isPublic){ - q filter { case (t1, t2) => (t1.activityUserName is activityUserName.bind) && (t2.isPrivate is false.bind) } - } else { - q filter { case (t1, t2) => t1.activityUserName is activityUserName.bind } - }) - .sortBy { case (t1, t2) => t1.activityId desc } - .map { case (t1, t2) => t1 } - .take(30) - .list - } - def getRecentActivities(): List[Activity] = - Query(Activities) + Activities .innerJoin(Repositories).on((t1, t2) => t1.byRepository(t2.userName, t2.repositoryName)) .filter { case (t1, t2) => t2.isPrivate is false.bind } .sortBy { case (t1, t2) => t1.activityId desc } @@ -52,6 +52,13 @@ Some(title), currentDate) + def recordClosePullRequestActivity(userName: String, repositoryName: String, activityUserName: String, issueId: Int, title: String): Unit = + Activities.autoInc insert(userName, repositoryName, activityUserName, + "close_issue", + s"[user:${activityUserName}] closed pull request [pullreq:${userName}/${repositoryName}#${issueId}]", + Some(title), + currentDate) + def recordReopenIssueActivity(userName: String, repositoryName: String, activityUserName: String, issueId: Int, title: String): Unit = Activities.autoInc insert(userName, repositoryName, activityUserName, "reopen_issue", @@ -65,7 +72,14 @@ s"[user:${activityUserName}] commented on issue [issue:${userName}/${repositoryName}#${issueId}]", Some(cut(comment, 200)), currentDate) - + + def recordCommentPullRequestActivity(userName: String, repositoryName: String, activityUserName: String, issueId: Int, comment: String): Unit = + Activities.autoInc insert(userName, repositoryName, activityUserName, + "comment_issue", + s"[user:${activityUserName}] commented on pull request [pullreq:${userName}/${repositoryName}#${issueId}]", + Some(cut(comment, 200)), + currentDate) + def recordCreateWikiPageActivity(userName: String, repositoryName: String, activityUserName: String, pageName: String) = Activities.autoInc insert(userName, repositoryName, activityUserName, "create_wiki", @@ -73,11 +87,11 @@ Some(pageName), currentDate) - def recordEditWikiPageActivity(userName: String, repositoryName: String, activityUserName: String, pageName: String) = + def recordEditWikiPageActivity(userName: String, repositoryName: String, activityUserName: String, pageName: String, commitId: String) = Activities.autoInc insert(userName, repositoryName, activityUserName, "edit_wiki", s"[user:${activityUserName}] edited the [repo:${userName}/${repositoryName}] wiki", - Some(pageName), + Some(pageName + ":" + commitId), currentDate) def recordPushActivity(userName: String, repositoryName: String, activityUserName: String, @@ -98,15 +112,42 @@ def recordCreateBranchActivity(userName: String, repositoryName: String, activityUserName: String, branchName: String) = Activities.autoInc insert(userName, repositoryName, activityUserName, - "create_tag", + "create_branch", s"[user:${activityUserName}] created branch [tag:${userName}/${repositoryName}#${branchName}] at [repo:${userName}/${repositoryName}]", None, currentDate) - + + def recordForkActivity(userName: String, repositoryName: String, activityUserName: String) = + Activities.autoInc insert(userName, repositoryName, activityUserName, + "fork", + s"[user:${activityUserName}] forked [repo:${userName}/${repositoryName}] to [repo:${activityUserName}/${repositoryName}]", + None, + currentDate) + + def recordPullRequestActivity(userName: String, repositoryName: String, activityUserName: String, issueId: Int, title: String): Unit = + Activities.autoInc insert(userName, repositoryName, activityUserName, + "open_pullreq", + s"[user:${activityUserName}] opened pull request [pullreq:${userName}/${repositoryName}#${issueId}]", + Some(title), + currentDate) + + def recordMergeActivity(userName: String, repositoryName: String, activityUserName: String, issueId: Int, message: String): Unit = + Activities.autoInc insert(userName, repositoryName, activityUserName, + "merge_pullreq", + s"[user:${activityUserName}] merged pull request [pullreq:${userName}/${repositoryName}#${issueId}]", + Some(message), + currentDate) + def insertCommitId(userName: String, repositoryName: String, commitId: String) = { CommitLog insert (userName, repositoryName, commitId) } - + + def insertAllCommitIds(userName: String, repositoryName: String, commitIds: List[String]) = + CommitLog insertAll (commitIds.map(commitId => (userName, repositoryName, commitId)): _*) + + def getAllCommitIds(userName: String, repositoryName: String): List[String] = + Query(CommitLog).filter(_.byRepository(userName, repositoryName)).map(_.commitId).list + def existsCommitId(userName: String, repositoryName: String, commitId: String): Boolean = Query(CommitLog).filter(_.byPrimaryKey(userName, repositoryName, commitId)).firstOption.isDefined diff --git a/src/main/scala/service/IssuesService.scala b/src/main/scala/service/IssuesService.scala index aef6436..16fa972 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._ @@ -42,33 +42,28 @@ /** * Returns the count of the search result against issues. * - * @param owner the repository owner - * @param repository the repository name * @param condition the search condition - * @param filter the filter type ("all", "assigned" or "created_by") - * @param userName the filter user name required for "assigned" and "created_by" + * @param filterUser the filter user name (key is "all", "assigned" or "created_by", value is the user name) + * @param onlyPullRequest if true then counts only pull request, false then counts both of issue and pull request. + * @param repos Tuple of the repository owner and the repository name * @return the count of the search result */ - def countIssue(owner: String, repository: String, condition: IssueSearchCondition, filter: String, userName: Option[String]): Int = { - // TODO It must be _.length instead of map (_.issueId) list).length. - // But it does not work on Slick 1.0.1 (worked on Slick 1.0.0). - // https://github.com/slick/slick/issues/170 - (searchIssueQuery(owner, repository, condition, filter, userName) map (_.issueId) list).length - } + def countIssue(condition: IssueSearchCondition, filterUser: Map[String, String], onlyPullRequest: Boolean, + repos: (String, String)*): Int = + Query(searchIssueQuery(repos, condition, filterUser, onlyPullRequest).length).first /** * Returns the Map which contains issue count for each labels. * * @param owner the repository owner * @param repository the repository name * @param condition the search condition - * @param filter the filter type ("all", "assigned" or "created_by") - * @param userName the filter user name required for "assigned" and "created_by" - * @return the Map which contains issue count for each labels (key is label name, value is issue count), + * @param filterUser the filter user name (key is "all", "assigned" or "created_by", value is the user name) + * @return the Map which contains issue count for each labels (key is label name, value is issue count) */ def countIssueGroupByLabels(owner: String, repository: String, condition: IssueSearchCondition, - filter: String, userName: Option[String]): Map[String, Int] = { + filterUser: Map[String, String]): Map[String, Int] = { - searchIssueQuery(owner, repository, condition.copy(labels = Set.empty), filter, userName) + searchIssueQuery(Seq(owner -> repository), condition.copy(labels = Set.empty), filterUser, false) .innerJoin(IssueLabels).on { (t1, t2) => t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) } @@ -83,76 +78,100 @@ } .toMap } + /** + * Returns list which contains issue count for each repository. + * If the issue does not exist, its repository is not included in the result. + * + * @param condition the search condition + * @param filterUser the filter user name (key is "all", "assigned" or "created_by", value is the user name) + * @param onlyPullRequest if true then returns only pull request, false then returns both of issue and pull request. + * @param repos Tuple of the repository owner and the repository name + * @return list which contains issue count for each repository + */ + def countIssueGroupByRepository( + condition: IssueSearchCondition, filterUser: Map[String, String], onlyPullRequest: Boolean, + repos: (String, String)*): List[(String, String, Int)] = { + searchIssueQuery(repos, condition.copy(repo = None), filterUser, onlyPullRequest) + .groupBy { t => + t.userName ~ t.repositoryName + } + .map { case (repo, t) => + repo ~ t.length + } + .sortBy(_._3 desc) + .list + } /** * Returns the search result against issues. * - * @param owner the repository owner - * @param repository the repository name * @param condition the search condition - * @param filter the filter type ("all", "assigned" or "created_by") - * @param userName the filter user name required for "assigned" and "created_by" + * @param filterUser the filter user name (key is "all", "assigned", "created_by" or "not_created_by", value is the user name) + * @param onlyPullRequest if true then returns only pull request, false then returns both of issue and pull request. * @param offset the offset for pagination * @param limit the limit for pagination + * @param repos Tuple of the repository owner and the repository name * @return the search result (list of tuples which contain issue, labels and comment count) */ - def searchIssue(owner: String, repository: String, condition: IssueSearchCondition, - filter: String, userName: Option[String], offset: Int, limit: Int): List[(Issue, List[Label], Int)] = { + def searchIssue(condition: IssueSearchCondition, filterUser: Map[String, String], onlyPullRequest: Boolean, + offset: Int, limit: Int, repos: (String, String)*): List[(Issue, List[Label], Int)] = { - // get issues and comment count - val issues = searchIssueQuery(owner, repository, condition, filter, userName) - .leftJoin(Query(IssueComments) - .filter { t => - (t.byRepository(owner, repository)) && - (t.action inSetBind Seq("comment", "close_comment", "reopen_comment")) + // get issues and comment count and labels + searchIssueQuery(repos, condition, filterUser, onlyPullRequest) + .innerJoin(IssueOutline).on { (t1, t2) => t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) } + .leftJoin (IssueLabels) .on { case ((t1, t2), t3) => t1.byIssue(t3.userName, t3.repositoryName, t3.issueId) } + .leftJoin (Labels) .on { case (((t1, t2), t3), t4) => t3.byLabel(t4.userName, t4.repositoryName, t4.labelId) } + .map { case (((t1, t2), t3), t4) => + (t1, t2.commentCount, t4.labelId.?, t4.labelName.?, t4.color.?) } - .groupBy { _.issueId } - .map { case (issueId, t) => issueId ~ t.length }).on((t1, t2) => t1.issueId is t2._1) - .sortBy { case (t1, t2) => - (condition.sort match { - case "created" => t1.registeredDate - case "comments" => t2._2 - case "updated" => t1.updatedDate - }) match { - case sort => condition.direction match { - case "asc" => sort asc - case "desc" => sort desc + .sortBy(_._4) // labelName + .sortBy { case (t1, commentCount, _,_,_) => + (condition.sort match { + case "created" => t1.registeredDate + case "comments" => commentCount + case "updated" => t1.updatedDate + }) match { + case sort => condition.direction match { + case "asc" => sort asc + case "desc" => sort desc + } } } - } - .map { case (t1, t2) => (t1, t2._2.ifNull(0)) } - .drop(offset).take(limit) - .list - - // get labels - val labels = Query(IssueLabels) - .innerJoin(Labels).on { (t1, t2) => - t1.byLabel(t2.userName, t2.repositoryName, t2.labelId) - } - .filter { case (t1, t2) => - (t1.byRepository(owner, repository)) && - (t1.issueId inSetBind (issues.map(_._1.issueId))) - } - .sortBy { case (t1, t2) => t1.issueId ~ t2.labelName } - .map { case (t1, t2) => (t1.issueId, t2) } - .list - - issues.map { case (issue, commentCount) => - (issue, labels.collect { case (issueId, labels) if(issueId == issue.issueId) => labels }, commentCount) - } + .drop(offset).take(limit) + .list + .splitWith { (c1, c2) => + c1._1.userName == c2._1.userName && + c1._1.repositoryName == c2._1.repositoryName && + c1._1.issueId == c2._1.issueId + } + .map { issues => issues.head match { + case (issue, commentCount, _,_,_) => + (issue, + issues.flatMap { t => t._3.map ( + Label(issue.userName, issue.repositoryName, _, t._4.get, t._5.get) + )} toList, + commentCount) + }} toList } /** * Assembles query for conditional issue searching. */ - private def searchIssueQuery(owner: String, repository: String, condition: IssueSearchCondition, filter: String, userName: Option[String]) = + private def searchIssueQuery(repos: Seq[(String, String)], condition: IssueSearchCondition, + filterUser: Map[String, String], onlyPullRequest: Boolean) = Query(Issues) filter { t1 => - (t1.byRepository(owner, repository)) && + condition.repo + .map { _.split('/') match { case array => Seq(array(0) -> array(1)) } } + .getOrElse (repos) + .map { case (owner, repository) => t1.byRepository(owner, repository) } + .foldLeft[Column[Boolean]](false) ( _ || _ ) && (t1.closed is (condition.state == "closed").bind) && (t1.milestoneId is condition.milestoneId.get.get.bind, condition.milestoneId.flatten.isDefined) && (t1.milestoneId isNull, condition.milestoneId == Some(None)) && - (t1.assignedUserName is userName.get.bind, filter == "assigned") && - (t1.openedUserName is userName.get.bind, filter == "created_by") && + (t1.assignedUserName is filterUser("assigned").bind, filterUser.get("assigned").isDefined) && + (t1.openedUserName is filterUser("created_by").bind, filterUser.get("created_by").isDefined) && + (t1.openedUserName isNot filterUser("not_created_by").bind, filterUser.get("not_created_by").isDefined) && + (t1.pullRequest is true.bind, onlyPullRequest) && (IssueLabels filter { t2 => (t2.byIssue(t1.userName, t1.repositoryName, t1.issueId)) && (t2.labelId in @@ -164,7 +183,7 @@ } def createIssue(owner: String, repository: String, loginUser: String, title: String, content: Option[String], - assignedUserName: Option[String], milestoneId: Option[Int]) = + assignedUserName: Option[String], milestoneId: Option[Int], isPullRequest: Boolean = false) = // next id number sql"SELECT ISSUE_ID + 1 FROM ISSUE_ID WHERE USER_NAME = $owner AND REPOSITORY_NAME = $repository FOR UPDATE".as[Int] .firstOption.filter { id => @@ -179,7 +198,8 @@ content, false, currentDate, - currentDate) + currentDate, + isPullRequest) // increment issue id IssueId @@ -237,6 +257,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 = splitWords(query.toLowerCase) + + // Search Issue + val issues = Issues + .innerJoin(IssueOutline).on { case (t1, t2) => + t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) + } + .filter { case (t1, t2) => + keywords.map { keyword => + (t1.title.toLowerCase like (s"%${likeEncode(keyword)}%", '^')) || + (t1.content.toLowerCase like (s"%${likeEncode(keyword)}%", '^')) + } .reduceLeft(_ && _) + } + .map { case (t1, t2) => + (t1, 0, t1.content.?, t2.commentCount) + } + + // Search IssueComment + val comments = IssueComments + .innerJoin(Issues).on { case (t1, t2) => + t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) + } + .innerJoin(IssueOutline).on { case ((t1, t2), t3) => + t2.byIssue(t3.userName, t3.repositoryName, t3.issueId) + } + .filter { case ((t1, t2), t3) => + keywords.map { query => + t1.content.toLowerCase like (s"%${likeEncode(query)}%", '^') + }.reduceLeft(_ && _) + } + .map { case ((t1, t2), t3) => + (t2, t1.commentId, t1.content.?, t3.commentCount) + } + + issues.union(comments).sortBy { case (issue, commentId, _, _) => + issue.issueId ~ commentId + }.list.splitWith { case ((issue1, _, _, _), (issue2, _, _, _)) => + issue1.issueId == issue2.issueId + }.map { _.head match { + case (issue, _, content, commentCount) => (issue, commentCount, content.getOrElse("")) + } + }.toList + } + } object IssuesService { @@ -247,6 +321,7 @@ case class IssueSearchCondition( labels: Set[String] = Set.empty, milestoneId: Option[Option[Int]] = None, + repo: Option[String] = None, state: String = "open", sort: String = "created", direction: String = "desc"){ @@ -258,6 +333,7 @@ case Some(x) => x.toString case None => "none" })}, + repo.map("for=" + urlEncode(_)), Some("state=" + urlEncode(state)), Some("sort=" + urlEncode(sort)), Some("direction=" + urlEncode(direction))).flatten.mkString("&") @@ -274,12 +350,21 @@ def apply(request: HttpServletRequest): IssueSearchCondition = IssueSearchCondition( param(request, "labels").map(_.split(" ").toSet).getOrElse(Set.empty), - param(request, "milestone").map(_ match { + param(request, "milestone").map{ case "none" => None - case x => Some(x.toInt) - }), + case x => x.toIntOpt + }, + param(request, "for"), param(request, "state", Seq("open", "closed")).getOrElse("open"), param(request, "sort", Seq("created", "comments", "updated")).getOrElse("created"), param(request, "direction", Seq("asc", "desc")).getOrElse("desc")) + + def page(request: HttpServletRequest) = try { + val i = param(request, "page").getOrElse("1").toInt + if(i <= 0) 1 else i + } catch { + case e: NumberFormatException => 1 + } } + } diff --git a/src/main/scala/service/PullRequestService.scala b/src/main/scala/service/PullRequestService.scala new file mode 100644 index 0000000..5374b99 --- /dev/null +++ b/src/main/scala/service/PullRequestService.scala @@ -0,0 +1,54 @@ +package service + +import scala.slick.driver.H2Driver.simple._ +import Database.threadLocalSession +import model._ +import util.ControlUtil._ + +trait PullRequestService { self: IssuesService => + import PullRequestService._ + + def getPullRequest(owner: String, repository: String, issueId: Int): Option[(Issue, PullRequest)] = + getIssue(owner, repository, issueId.toString).flatMap{ issue => + Query(PullRequests).filter(_.byPrimaryKey(owner, repository, issueId)).firstOption.map{ + pullreq => (issue, pullreq) + } + } + + def getPullRequestCountGroupByUser(closed: Boolean, owner: String, repository: Option[String]): List[PullRequestCount] = + Query(PullRequests) + .innerJoin(Issues).on { (t1, t2) => t1.byPrimaryKey(t2.userName, t2.repositoryName, t2.issueId) } + .filter { case (t1, t2) => + (t2.closed is closed.bind) && + (t1.userName is owner.bind) && + (t1.repositoryName is repository.get.bind, repository.isDefined) + } + .groupBy { case (t1, t2) => t2.openedUserName } + .map { case (userName, t) => userName ~ t.length } + .sortBy(_._2 desc) + .list + .map { x => PullRequestCount(x._1, x._2) } + + def createPullRequest(originUserName: String, originRepositoryName: String, issueId: Int, + originBranch: String, requestUserName: String, requestRepositoryName: String, requestBranch: String, + commitIdFrom: String, commitIdTo: String): Unit = + PullRequests insert (PullRequest( + originUserName, + originRepositoryName, + issueId, + originBranch, + requestUserName, + requestRepositoryName, + requestBranch, + commitIdFrom, + commitIdTo)) + +} + +object PullRequestService { + + val PullRequestLimit = 25 + + case class PullRequestCount(userName: String, count: Int) + +} diff --git a/src/main/scala/service/RepositorySearchService.scala b/src/main/scala/service/RepositorySearchService.scala new file mode 100644 index 0000000..ac4f177 --- /dev/null +++ b/src/main/scala/service/RepositorySearchService.scala @@ -0,0 +1,126 @@ +package service + +import util.{FileUtil, StringUtil, JGitUtil} +import util.Directory._ +import util.ControlUtil._ +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 = + using(Git.open(getRepositoryDir(owner, repository))){ git => + if(JGitUtil.isEmpty(git)) 0 else searchRepositoryFiles(git, query).length + } + + def searchFiles(owner: String, repository: String, query: String): List[FileSearchResult] = + using(Git.open(getRepositoryDir(owner, repository))){ git => + if(JGitUtil.isEmpty(git)){ + Nil + } else { + 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 = StringUtil.convertFromByteArray(bytes) + 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) + +} diff --git a/src/main/scala/service/RepositoryService.scala b/src/main/scala/service/RepositoryService.scala index 153aa95..a443b81 100644 --- a/src/main/scala/service/RepositoryService.scala +++ b/src/main/scala/service/RepositoryService.scala @@ -15,19 +15,27 @@ * @param userName the user name of the repository owner * @param description the repository description * @param isPrivate the repository type (private is true, otherwise false) + * @param originRepositoryName specify for the forked repository. (default is None) + * @param originUserName specify for the forked repository. (default is None) */ - def createRepository(repositoryName: String, userName: String, description: Option[String], isPrivate: Boolean): Unit = { + def createRepository(repositoryName: String, userName: String, description: Option[String], isPrivate: Boolean, + originRepositoryName: Option[String] = None, originUserName: Option[String] = None, + parentRepositoryName: Option[String] = None, parentUserName: Option[String] = None): Unit = { Repositories insert Repository( - userName = userName, - repositoryName = repositoryName, - isPrivate = isPrivate, - description = description, - defaultBranch = "master", - registeredDate = currentDate, - updatedDate = currentDate, - lastActivityDate = currentDate) - + userName = userName, + repositoryName = repositoryName, + isPrivate = isPrivate, + description = description, + defaultBranch = "master", + registeredDate = currentDate, + updatedDate = currentDate, + lastActivityDate = currentDate, + originUserName = originUserName, + originRepositoryName = originRepositoryName, + parentUserName = parentUserName, + parentRepositoryName = parentRepositoryName) + IssueId insert (userName, repositoryName, 0) } @@ -39,8 +47,10 @@ Labels .filter(_.byRepository(userName, repositoryName)).delete IssueComments .filter(_.byRepository(userName, repositoryName)).delete Issues .filter(_.byRepository(userName, repositoryName)).delete + PullRequests .filter(_.byRepository(userName, repositoryName)).delete IssueId .filter(_.byRepository(userName, repositoryName)).delete Milestones .filter(_.byRepository(userName, repositoryName)).delete + WebHooks .filter(_.byRepository(userName, repositoryName)).delete Repositories .filter(_.byRepository(userName, repositoryName)).delete } @@ -54,39 +64,6 @@ Query(Repositories) filter(_.userName is userName.bind) map (_.repositoryName) list /** - * Returns the list of specified user's repositories information. - * - * @param userName the user name - * @param baseUrl the base url of this application - * @param loginUserName the logged in user name - * @return the list of repository information which is sorted in descending order of lastActivityDate. - */ - def getVisibleRepositories(userName: String, baseUrl: String, loginUserName: Option[String]): List[RepositoryInfo] = { - val q1 = Repositories - .filter { t => t.userName is userName.bind } - .map { r => r } - - val q2 = Collaborators - .innerJoin(Repositories).on((t1, t2) => t1.byRepository(t2.userName, t2.repositoryName)) - .filter{ case (t1, t2) => t1.collaboratorName is userName.bind} - .map { case (t1, t2) => t2 } - - def visibleFor(t: Repositories.type, loginUserName: Option[String]) = { - loginUserName match { - case Some(x) => (t.isPrivate is false.bind) || ( - (t.isPrivate is true.bind) && ((t.userName is x.bind) || (Collaborators.filter { c => - c.byRepository(t.userName, t.repositoryName) && (c.collaboratorName is x.bind) - }.exists))) - case None => (t.isPrivate is false.bind) - } - } - - q1.union(q2).filter(visibleFor(_, loginUserName)).sortBy(_.lastActivityDate desc).list map { repository => - new RepositoryInfo(JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl), repository) - } - } - - /** * Returns the specified repository information. * * @param userName the user name of the repository owner @@ -96,34 +73,69 @@ */ def getRepository(userName: String, repositoryName: String, baseUrl: String): Option[RepositoryInfo] = { (Query(Repositories) filter { t => t.byRepository(userName, repositoryName) } firstOption) map { repository => - new RepositoryInfo(JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl), repository) + // for getting issue count and pull request count + val issues = Query(Issues).filter { t => + t.byRepository(repository.userName, repository.repositoryName) && (t.closed is false.bind) + }.map(_.pullRequest).list + + new RepositoryInfo( + JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl), + repository, + issues.size, + issues.filter(_ == true).size, + getForkedCount( + repository.originUserName.getOrElse(repository.userName), + repository.originRepositoryName.getOrElse(repository.repositoryName) + )) + } + } + + def getUserRepositories(userName: String, baseUrl: String): List[RepositoryInfo] = { + Query(Repositories).filter { t1 => + (t1.userName is userName.bind) || + (Query(Collaborators).filter { t2 => t2.byRepository(t1.userName, t1.repositoryName) && (t2.collaboratorName is userName.bind)} exists) + }.sortBy(_.lastActivityDate desc).list.map{ repository => + new RepositoryInfo( + JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl), + repository, + getForkedCount( + repository.originUserName.getOrElse(repository.userName), + repository.originRepositoryName.getOrElse(repository.repositoryName) + )) } } /** - * Returns the list of accessible repositories information for the specified account user. - * - * @param account the account + * Returns the list of visible repositories for the specified user. + * If repositoryUserName is given then filters results by repository owner. + * + * @param loginAccount the logged in account * @param baseUrl the base url of this application - * @return the repository informations which is sorted in descending order of lastActivityDate. + * @param repositoryUserName the repository owner (if None then returns all repositories which are visible for logged in user) + * @return the repository information which is sorted in descending order of lastActivityDate. */ - def getAccessibleRepositories(account: Option[Account], baseUrl: String): List[RepositoryInfo] = { - - def newRepositoryInfo(repository: Repository): RepositoryInfo = { - new RepositoryInfo(JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl), repository) - } - - (account match { + def getVisibleRepositories(loginAccount: Option[Account], baseUrl: String, repositoryUserName: Option[String] = None): List[RepositoryInfo] = { + (loginAccount match { // for Administrators case Some(x) if(x.isAdmin) => Query(Repositories) // for Normal Users case Some(x) if(!x.isAdmin) => Query(Repositories) filter { t => (t.isPrivate is false.bind) || - (Query(Collaborators).filter(t2 => t2.byRepository(t.userName, t.repositoryName) && (t2.collaboratorName is x.userName.bind)) exists) + (Query(Collaborators).filter { t2 => t2.byRepository(t.userName, t.repositoryName) && (t2.collaboratorName is x.userName.bind)} exists) } // for Guests case None => Query(Repositories) filter(_.isPrivate is false.bind) - }).sortBy(_.lastActivityDate desc).list.map(newRepositoryInfo _) + }).filter { t => + repositoryUserName.map { userName => t.userName is userName.bind } getOrElse ConstColumn.TRUE + }.sortBy(_.lastActivityDate desc).list.map{ repository => + new RepositoryInfo( + JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl), + repository, + getForkedCount( + repository.originUserName.getOrElse(repository.userName), + repository.originRepositoryName.getOrElse(repository.repositoryName) + )) + } } /** @@ -162,6 +174,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 @@ -180,17 +201,39 @@ } } + private def getForkedCount(userName: String, repositoryName: String): Int = + Query(Repositories.filter { t => + (t.originUserName is userName.bind) && (t.originRepositoryName is repositoryName.bind) + }.length).first + + + def getForkedRepositories(userName: String, repositoryName: String): List[String] = + Query(Repositories).filter { t => + (t.originUserName is userName.bind) && (t.originRepositoryName is repositoryName.bind) + } + .sortBy(_.userName asc).map(_.userName).list + } object RepositoryService { case class RepositoryInfo(owner: String, name: String, url: String, repository: Repository, - commitCount: Int, branchList: List[String], tags: List[util.JGitUtil.TagInfo]){ + issueCount: Int, pullCount: Int, commitCount: Int, forkedCount: Int, + branchList: List[String], tags: List[util.JGitUtil.TagInfo]){ - def this(repo: JGitUtil.RepositoryInfo, model: Repository) = { - this(repo.owner, repo.name, repo.url, model, repo.commitCount, repo.branchList, repo.tags) - } + /** + * Creates instance with issue count and pull request count. + */ + def this(repo: JGitUtil.RepositoryInfo, model: Repository, issueCount: Int, pullCount: Int, forkedCount: Int) = + this(repo.owner, repo.name, repo.url, model, issueCount, pullCount, repo.commitCount, forkedCount, repo.branchList, repo.tags) + /** + * Creates instance without issue count and pull request count. + */ + def this(repo: JGitUtil.RepositoryInfo, model: Repository, forkedCount: Int) = + this(repo.owner, repo.name, repo.url, model, 0, 0, repo.commitCount, forkedCount, repo.branchList, repo.tags) } + case class RepositoryTreeNode(owner: String, name: String, children: List[RepositoryTreeNode]) + } \ No newline at end of file diff --git a/src/main/scala/service/RequestCache.scala b/src/main/scala/service/RequestCache.scala index 059e91c..0f0b0f0 100644 --- a/src/main/scala/service/RequestCache.scala +++ b/src/main/scala/service/RequestCache.scala @@ -1,6 +1,7 @@ package service import model._ +import service.SystemSettingsService.SystemSettings /** * This service is used for a view helper mainly. @@ -10,6 +11,11 @@ */ trait RequestCache { + def getSystemSettings()(implicit context: app.Context): SystemSettings = + context.cache("system_settings"){ + new SystemSettingsService {}.loadSystemSettings() + } + def getIssue(userName: String, repositoryName: String, issueId: String)(implicit context: app.Context): Option[Issue] = { context.cache(s"issue.${userName}/${repositoryName}#${issueId}"){ new IssuesService {}.getIssue(userName, repositoryName, issueId) diff --git a/src/main/scala/service/SystemSettingsService.scala b/src/main/scala/service/SystemSettingsService.scala index b425b34..5ed9eb3 100644 --- a/src/main/scala/service/SystemSettingsService.scala +++ b/src/main/scala/service/SystemSettingsService.scala @@ -1,40 +1,152 @@ -package service - -import util.Directory._ -import SystemSettingsService._ - -trait SystemSettingsService { - - def saveSystemSettings(settings: SystemSettings): Unit = { - val props = new java.util.Properties() - props.setProperty(AllowAccountRegistration, settings.allowAccountRegistration.toString) - props.store(new java.io.FileOutputStream(GitBucketConf), null) - } - - - def loadSystemSettings(): SystemSettings = { - val props = new java.util.Properties() - if(GitBucketConf.exists){ - props.load(new java.io.FileInputStream(GitBucketConf)) - } - SystemSettings(getBoolean(props, "allow_account_registration")) - } - -} - -object SystemSettingsService { - - case class SystemSettings(allowAccountRegistration: Boolean) - - private val AllowAccountRegistration = "allow_account_registration" - - private def getBoolean(props: java.util.Properties, key: String, default: Boolean = false): Boolean = { - val value = props.getProperty(key) - if(value == null || value.isEmpty){ - default - } else { - value.toBoolean - } - } - -} +package service + +import util.Directory._ +import util.ControlUtil._ +import SystemSettingsService._ + +trait SystemSettingsService { + + def saveSystemSettings(settings: SystemSettings): Unit = { + defining(new java.util.Properties()){ props => + props.setProperty(AllowAccountRegistration, settings.allowAccountRegistration.toString) + props.setProperty(Gravatar, settings.gravatar.toString) + props.setProperty(Notification, settings.notification.toString) + if(settings.notification) { + settings.smtp.foreach { smtp => + props.setProperty(SmtpHost, smtp.host) + smtp.port.foreach(x => props.setProperty(SmtpPort, x.toString)) + smtp.user.foreach(props.setProperty(SmtpUser, _)) + smtp.password.foreach(props.setProperty(SmtpPassword, _)) + smtp.ssl.foreach(x => props.setProperty(SmtpSsl, x.toString)) + smtp.fromAddress.foreach(props.setProperty(SmtpFromAddress, _)) + smtp.fromName.foreach(props.setProperty(SmtpFromName, _)) + } + } + props.setProperty(LdapAuthentication, settings.ldapAuthentication.toString) + if(settings.ldapAuthentication){ + settings.ldap.map { ldap => + props.setProperty(LdapHost, ldap.host) + ldap.port.foreach(x => props.setProperty(LdapPort, x.toString)) + ldap.bindDN.foreach(x => props.setProperty(LdapBindDN, x)) + ldap.bindPassword.foreach(x => props.setProperty(LdapBindPassword, x)) + props.setProperty(LdapBaseDN, ldap.baseDN) + props.setProperty(LdapUserNameAttribute, ldap.userNameAttribute) + props.setProperty(LdapMailAddressAttribute, ldap.mailAttribute) + } + } + props.store(new java.io.FileOutputStream(GitBucketConf), null) + } + } + + + def loadSystemSettings(): SystemSettings = { + defining(new java.util.Properties()){ props => + if(GitBucketConf.exists){ + props.load(new java.io.FileInputStream(GitBucketConf)) + } + SystemSettings( + getValue(props, AllowAccountRegistration, false), + getValue(props, Gravatar, true), + getValue(props, Notification, false), + if(getValue(props, Notification, false)){ + Some(Smtp( + getValue(props, SmtpHost, ""), + getOptionValue(props, SmtpPort, Some(DefaultSmtpPort)), + getOptionValue(props, SmtpUser, None), + getOptionValue(props, SmtpPassword, None), + getOptionValue[Boolean](props, SmtpSsl, None), + getOptionValue(props, SmtpFromAddress, None), + getOptionValue(props, SmtpFromName, None))) + } else { + None + }, + getValue(props, LdapAuthentication, false), + if(getValue(props, LdapAuthentication, false)){ + Some(Ldap( + getValue(props, LdapHost, ""), + getOptionValue(props, LdapPort, Some(DefaultLdapPort)), + getOptionValue(props, LdapBindDN, None), + getOptionValue(props, LdapBindPassword, None), + getValue(props, LdapBaseDN, ""), + getValue(props, LdapUserNameAttribute, ""), + getValue(props, LdapMailAddressAttribute, ""))) + } else { + None + } + ) + } + } + +} + +object SystemSettingsService { + import scala.reflect.ClassTag + + case class SystemSettings( + allowAccountRegistration: Boolean, + gravatar: Boolean, + notification: Boolean, + smtp: Option[Smtp], + ldapAuthentication: Boolean, + ldap: Option[Ldap]) + + case class Ldap( + host: String, + port: Option[Int], + bindDN: Option[String], + bindPassword: Option[String], + baseDN: String, + userNameAttribute: String, + mailAttribute: String) + + case class Smtp( + host: String, + port: Option[Int], + user: Option[String], + password: Option[String], + ssl: Option[Boolean], + fromAddress: Option[String], + fromName: Option[String]) + + val DefaultSmtpPort = 25 + val DefaultLdapPort = 389 + + private val AllowAccountRegistration = "allow_account_registration" + private val Gravatar = "gravatar" + private val Notification = "notification" + private val SmtpHost = "smtp.host" + private val SmtpPort = "smtp.port" + private val SmtpUser = "smtp.user" + private val SmtpPassword = "smtp.password" + private val SmtpSsl = "smtp.ssl" + private val SmtpFromAddress = "smtp.from_address" + private val SmtpFromName = "smtp.from_name" + private val LdapAuthentication = "ldap_authentication" + private val LdapHost = "ldap.host" + private val LdapPort = "ldap.port" + private val LdapBindDN = "ldap.bindDN" + private val LdapBindPassword = "ldap.bind_password" + private val LdapBaseDN = "ldap.baseDN" + private val LdapUserNameAttribute = "ldap.username_attribute" + private val LdapMailAddressAttribute = "ldap.mail_attribute" + + private def getValue[A: ClassTag](props: java.util.Properties, key: String, default: A): A = + defining(props.getProperty(key)){ value => + if(value == null || value.isEmpty) default + else convertType(value).asInstanceOf[A] + } + + private def getOptionValue[A: ClassTag](props: java.util.Properties, key: String, default: Option[A]): Option[A] = + defining(props.getProperty(key)){ value => + if(value == null || value.isEmpty) default + else Some(convertType(value)).asInstanceOf[Option[A]] + } + + private def convertType[A: ClassTag](value: String) = + defining(implicitly[ClassTag[A]].runtimeClass){ c => + if(c == classOf[Boolean]) value.toBoolean + else if(c == classOf[Int]) value.toInt + else value + } + +} diff --git a/src/main/scala/service/WebHookService.scala b/src/main/scala/service/WebHookService.scala new file mode 100644 index 0000000..7ddaa11 --- /dev/null +++ b/src/main/scala/service/WebHookService.scala @@ -0,0 +1,143 @@ +package service + +import scala.slick.driver.H2Driver.simple._ +import Database.threadLocalSession + +import model._ +import org.slf4j.LoggerFactory +import service.RepositoryService.RepositoryInfo +import util.JGitUtil +import org.eclipse.jgit.diff.DiffEntry +import util.JGitUtil.CommitInfo +import org.eclipse.jgit.api.Git +import org.apache.http.message.BasicNameValuePair +import org.apache.http.client.entity.UrlEncodedFormEntity +import org.apache.http.protocol.HTTP +import org.apache.http.NameValuePair + +trait WebHookService { + import WebHookService._ + + private val logger = LoggerFactory.getLogger(classOf[WebHookService]) + + def getWebHookURLs(owner: String, repository: String): List[WebHook] = + Query(WebHooks).filter(_.byRepository(owner, repository)).sortBy(_.url).list + + def addWebHookURL(owner: String, repository: String, url :String): Unit = + WebHooks.insert(WebHook(owner, repository, url)) + + def deleteWebHookURL(owner: String, repository: String, url :String): Unit = + Query(WebHooks).filter(_.byPrimaryKey(owner, repository, url)).delete + + def callWebHook(owner: String, repository: String, webHookURLs: List[WebHook], payload: WebHookPayload): Unit = { + import org.json4s._ + import org.json4s.jackson.Serialization + import org.json4s.jackson.Serialization.{read, write} + import org.apache.http.client.methods.HttpPost + import org.apache.http.impl.client.HttpClientBuilder + import scala.concurrent._ + import ExecutionContext.Implicits.global + + logger.debug("start callWebHook") + implicit val formats = Serialization.formats(NoTypeHints) + + if(webHookURLs.nonEmpty){ + val json = write(payload) + val httpClient = HttpClientBuilder.create.build + + webHookURLs.foreach { webHookUrl => + val f = future { + logger.debug(s"start web hook invocation for ${webHookUrl}") + val httpPost = new HttpPost(webHookUrl.url) + + val params: java.util.List[NameValuePair] = new java.util.ArrayList() + params.add(new BasicNameValuePair("payload", json)) + httpPost.setEntity(new UrlEncodedFormEntity(params, "UTF-8")) + + httpClient.execute(httpPost) + httpPost.releaseConnection() + logger.debug(s"end web hook invocation for ${webHookUrl}") + } + f.onSuccess { + case s => logger.debug(s"Success: web hook request to ${webHookUrl.url}") + } + f.onFailure { + case t => logger.error(s"Failed: web hook request to ${webHookUrl.url}", t) + } + } + } + logger.debug("end callWebHook") + } + +} + +object WebHookService { + + case class WebHookPayload( + ref: String, + commits: List[WebHookCommit], + repository: WebHookRepository) + + object WebHookPayload { + def apply(git: Git, refName: String, repositoryInfo: RepositoryInfo, + commits: List[CommitInfo], repositoryOwner: Account): WebHookPayload = + WebHookPayload( + refName, + commits.map { commit => + val diffs = JGitUtil.getDiffs(git, commit.id, false) + val commitUrl = repositoryInfo.url.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/commit/" + commit.id + + WebHookCommit( + id = commit.id, + message = commit.fullMessage, + timestamp = commit.time.toString, + url = commitUrl, + added = diffs._1.collect { case x if(x.changeType == DiffEntry.ChangeType.ADD) => x.newPath }, + removed = diffs._1.collect { case x if(x.changeType == DiffEntry.ChangeType.DELETE) => x.oldPath }, + modified = diffs._1.collect { case x if(x.changeType != DiffEntry.ChangeType.ADD && + x.changeType != DiffEntry.ChangeType.DELETE) => x.newPath }, + author = WebHookUser( + name = commit.committer, + email = commit.mailAddress + ) + ) + }.toList, + WebHookRepository( + name = repositoryInfo.name, + url = repositoryInfo.url, + description = repositoryInfo.repository.description.getOrElse(""), + watchers = 0, + forks = repositoryInfo.forkedCount, + `private` = repositoryInfo.repository.isPrivate, + owner = WebHookUser( + name = repositoryOwner.userName, + email = repositoryOwner.mailAddress + ) + ) + ) + } + + case class WebHookCommit( + id: String, + message: String, + timestamp: String, + url: String, + added: List[String], + removed: List[String], + modified: List[String], + author: WebHookUser) + + case class WebHookRepository( + name: String, + url: String, + description: String, + watchers: Int, + forks: Int, + `private`: Boolean, + owner: WebHookUser) + + case class WebHookUser( + name: String, + email: String) + +} diff --git a/src/main/scala/service/WikiService.scala b/src/main/scala/service/WikiService.scala index 8c02515..bce71e4 100644 --- a/src/main/scala/service/WikiService.scala +++ b/src/main/scala/service/WikiService.scala @@ -4,11 +4,11 @@ import java.util.Date import org.eclipse.jgit.api.Git import org.apache.commons.io.FileUtils -import util.JGitUtil.DiffInfo -import util.{Directory, JGitUtil} -import org.eclipse.jgit.lib.RepositoryBuilder +import util.{StringUtil, Directory, JGitUtil, LockUtil} +import util.ControlUtil._ import org.eclipse.jgit.treewalk.CanonicalTreeParser -import java.util.concurrent.ConcurrentHashMap +import org.eclipse.jgit.diff.DiffFormatter +import org.eclipse.jgit.api.errors.PatchApplyException object WikiService { @@ -19,8 +19,9 @@ * @param content the page content * @param committer the last committer * @param time the last modified time + * @param id the latest commit id */ - case class WikiPageInfo(name: String, content: String, committer: String, time: Date) + case class WikiPageInfo(name: String, content: String, committer: String, time: Date, id: String) /** * The model for wiki page history. @@ -32,65 +33,35 @@ */ case class WikiPageHistoryInfo(name: String, committer: String, message: String, date: Date) - /** - * lock objects - */ - private val locks = new ConcurrentHashMap[String, AnyRef]() - - /** - * Returns the lock object for the specified repository. - */ - private def getLockObject(owner: String, repository: String): AnyRef = synchronized { - val key = owner + "/" + repository - if(!locks.containsKey(key)){ - locks.put(key, new AnyRef()) - } - locks.get(key) - } - - /** - * Synchronizes a given function which modifies the working copy of the wiki repository. - * - * @param owner the repository owner - * @param repository the repository name - * @param f the function which modifies the working copy of the wiki repository - * @tparam T the return type of the given function - * @return the result of the given function - */ - def lock[T](owner: String, repository: String)(f: => T): T = getLockObject(owner, repository).synchronized(f) - } trait WikiService { import WikiService._ - def createWikiRepository(owner: model.Account, repository: String): Unit = { - lock(owner.userName, repository){ - val dir = Directory.getWikiRepositoryDir(owner.userName, repository) - if(!dir.exists){ - try { - JGitUtil.initRepository(dir) - saveWikiPage(owner.userName, repository, "Home", "Home", s"Welcome to the ${repository} wiki!!", owner, "Initial Commit") - } finally { - // once delete cloned repository because initial cloned repository does not have 'branch.master.merge' - FileUtils.deleteDirectory(Directory.getWikiWorkDir(owner.userName, repository)) + def createWikiRepository(loginAccount: model.Account, owner: String, repository: String): Unit = + LockUtil.lock(s"${owner}/${repository}/wiki"){ + defining(Directory.getWikiRepositoryDir(owner, repository)){ dir => + if(!dir.exists){ + try { + JGitUtil.initRepository(dir) + saveWikiPage(owner, repository, "Home", "Home", s"Welcome to the ${repository} wiki!!", loginAccount, "Initial Commit", None) + } finally { + // once delete cloned repository because initial cloned repository does not have 'branch.master.merge' + FileUtils.deleteDirectory(Directory.getWikiWorkDir(owner, repository)) + } } } } - } - + /** * Returns the wiki page. */ def getWikiPage(owner: String, repository: String, pageName: String): Option[WikiPageInfo] = { - JGitUtil.withGit(Directory.getWikiRepositoryDir(owner, repository)){ git => - try { + using(Git.open(Directory.getWikiRepositoryDir(owner, repository))){ git => + optionIf(!JGitUtil.isEmpty(git)){ JGitUtil.getFileList(git, "master", ".").find(_.name == pageName + ".md").map { file => - WikiPageInfo(file.name, new String(git.getRepository.open(file.id).getBytes, "UTF-8"), file.committer, file.time) + WikiPageInfo(file.name, new String(git.getRepository.open(file.id).getBytes, "UTF-8"), file.committer, file.time, file.commitId) } - } catch { - // TODO no commit, but it should not judge by exception. - case e: NullPointerException => None } } } @@ -98,9 +69,9 @@ /** * Returns the content of the specified file. */ - def getFileContent(owner: String, repository: String, path: String): Option[Array[Byte]] = { - JGitUtil.withGit(Directory.getWikiRepositoryDir(owner, repository)){ git => - try { + def getFileContent(owner: String, repository: String, path: String): Option[Array[Byte]] = + using(Git.open(Directory.getWikiRepositoryDir(owner, repository))){ git => + optionIf(!JGitUtil.isEmpty(git)){ val index = path.lastIndexOf('/') val parentPath = if(index < 0) "." else path.substring(0, index) val fileName = if(index < 0) path else path.substring(index + 1) @@ -108,57 +79,119 @@ JGitUtil.getFileList(git, "master", parentPath).find(_.name == fileName).map { file => git.getRepository.open(file.id).getBytes } - } catch { - // TODO no commit, but it should not judge by exception. - case e: NullPointerException => None } } - } /** * 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) + using(Git.open(Directory.getWikiRepositoryDir(owner, repository))){ git => + JGitUtil.getFileList(git, "master", ".") + .filter(_.name.endsWith(".md")) + .map(_.name.replaceFirst("\\.md$", "")) + .sortBy(x => x) + } } - + + /** + * Reverts specified changes. + */ + def revertWikiPage(owner: String, repository: String, from: String, to: String, + committer: model.Account, pageName: Option[String]): Boolean = { + LockUtil.lock(s"${owner}/${repository}/wiki"){ + defining(Directory.getWikiWorkDir(owner, repository)){ workDir => + // clone working copy + cloneOrPullWorkingCopy(workDir, owner, repository) + + using(Git.open(workDir)){ git => + val reader = git.getRepository.newObjectReader + val oldTreeIter = new CanonicalTreeParser + oldTreeIter.reset(reader, git.getRepository.resolve(from + "^{tree}")) + + val newTreeIter = new CanonicalTreeParser + newTreeIter.reset(reader, git.getRepository.resolve(to + "^{tree}")) + + import scala.collection.JavaConverters._ + val diffs = git.diff.setNewTree(oldTreeIter).setOldTree(newTreeIter).call.asScala.filter { diff => + pageName match { + case Some(x) => diff.getNewPath == x + ".md" + case None => true + } + } + + val patch = using(new java.io.ByteArrayOutputStream()){ out => + val formatter = new DiffFormatter(out) + formatter.setRepository(git.getRepository) + formatter.format(diffs.asJava) + new String(out.toByteArray, "UTF-8") + } + + try { + git.apply.setPatch(new java.io.ByteArrayInputStream(patch.getBytes("UTF-8"))).call + git.add.addFilepattern(".").call + git.commit.setCommitter(committer.fullName, committer.mailAddress).setMessage(pageName match { + case Some(x) => s"Revert ${from} ... ${to} on ${x}" + case None => s"Revert ${from} ... ${to}" + }).call + git.push.call + true + } catch { + case ex: PatchApplyException => false + } + } + } + } + } + + /** * Save the wiki page. */ def saveWikiPage(owner: String, repository: String, currentPageName: String, newPageName: String, - content: String, committer: model.Account, message: String): Unit = { + content: String, committer: model.Account, message: String, currentId: Option[String]): Option[String] = { - lock(owner, repository){ - // clone working copy - val workDir = Directory.getWikiWorkDir(owner, repository) - cloneOrPullWorkingCopy(workDir, owner, repository) + LockUtil.lock(s"${owner}/${repository}/wiki"){ + defining(Directory.getWikiWorkDir(owner, repository)){ workDir => + // clone working copy + cloneOrPullWorkingCopy(workDir, owner, repository) - // write as file - JGitUtil.withGit(workDir){ git => - val file = new File(workDir, newPageName + ".md") - val added = if(!file.exists || FileUtils.readFileToString(file, "UTF-8") != content){ - FileUtils.writeStringToFile(file, content, "UTF-8") - git.add.addFilepattern(file.getName).call - true - } else { - false - } + // write as file + using(Git.open(workDir)){ git => + defining(new File(workDir, newPageName + ".md")){ file => + // new page + val created = !file.exists - // delete file - val deleted = if(currentPageName != "" && currentPageName != newPageName){ - git.rm.addFilepattern(currentPageName + ".md").call - true - } else { - false - } + // created or updated + val added = executeIf(!file.exists || FileUtils.readFileToString(file, "UTF-8") != content){ + FileUtils.writeStringToFile(file, content, "UTF-8") + git.add.addFilepattern(file.getName).call + } - // commit and push - if(added || deleted){ - git.commit.setCommitter(committer.userName, committer.mailAddress).setMessage(message).call - git.push.call + // delete file + val deleted = executeIf(currentPageName != "" && currentPageName != newPageName){ + git.rm.addFilepattern(currentPageName + ".md").call + } + + // commit and push + optionIf(added || deleted){ + defining(git.commit.setCommitter(committer.fullName, committer.mailAddress) + .setMessage(if(message.trim.length == 0){ + if(deleted){ + s"Rename ${currentPageName} to ${newPageName}" + } else if(created){ + s"Created ${newPageName}" + } else { + s"Updated ${newPageName}" + } + } else { + message + }).call){ commit => + git.push.call + Some(commit.getName) + } + } + } } } } @@ -167,56 +200,38 @@ /** * Delete the wiki page. */ - def deleteWikiPage(owner: String, repository: String, pageName: String, committer: String, message: String): Unit = { - lock(owner, repository){ - // clone working copy - val workDir = Directory.getWikiWorkDir(owner, repository) - cloneOrPullWorkingCopy(workDir, owner, repository) + def deleteWikiPage(owner: String, repository: String, pageName: String, + committer: String, mailAddress: String, message: String): Unit = { + LockUtil.lock(s"${owner}/${repository}/wiki"){ + defining(Directory.getWikiWorkDir(owner, repository)){ workDir => + // clone working copy + cloneOrPullWorkingCopy(workDir, owner, repository) - // delete file - new File(workDir, pageName + ".md").delete - - JGitUtil.withGit(workDir){ git => - git.rm.addFilepattern(pageName + ".md").call - - // commit and push - // TODO committer's mail address - git.commit.setAuthor(committer, committer + "@devnull").setMessage(message).call - git.push.call + // delete file + new File(workDir, pageName + ".md").delete + + using(Git.open(workDir)){ git => + git.rm.addFilepattern(pageName + ".md").call + + // commit and push + git.commit.setCommitter(committer, mailAddress).setMessage(message).call + git.push.call + } } } } - /** - * Returns differences between specified commits. - */ - def getWikiDiffs(git: Git, commitId1: String, commitId2: String): List[DiffInfo] = { - // get diff between specified commit and its previous commit - val reader = git.getRepository.newObjectReader - - val oldTreeIter = new CanonicalTreeParser - oldTreeIter.reset(reader, git.getRepository.resolve(commitId1 + "^{tree}")) - - val newTreeIter = new CanonicalTreeParser - newTreeIter.reset(reader, git.getRepository.resolve(commitId2 + "^{tree}")) - - import scala.collection.JavaConverters._ - git.diff.setNewTree(newTreeIter).setOldTree(oldTreeIter).call.asScala.map { diff => - DiffInfo(diff.getChangeType, diff.getOldPath, diff.getNewPath, - JGitUtil.getContent(git, diff.getOldId.toObjectId, false).map(new String(_, "UTF-8")), - JGitUtil.getContent(git, diff.getNewId.toObjectId, false).map(new String(_, "UTF-8"))) - }.toList - } - 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 - } else { - Git.open(workDir).pull.call + .getRepository + .close + } else using(Git.open(workDir)){ git => + git.pull.call } } -} \ No newline at end of file +} diff --git a/src/main/scala/servlet/AutoUpdateListener.scala b/src/main/scala/servlet/AutoUpdateListener.scala index 4614230..1f78018 100644 --- a/src/main/scala/servlet/AutoUpdateListener.scala +++ b/src/main/scala/servlet/AutoUpdateListener.scala @@ -1,12 +1,14 @@ package servlet import java.io.File -import java.sql.Connection +import java.sql.{DriverManager, Connection} import org.apache.commons.io.FileUtils -import javax.servlet.ServletContextEvent +import javax.servlet.{ServletContext, ServletContextListener, ServletContextEvent} import org.apache.commons.io.IOUtils import org.slf4j.LoggerFactory -import util.Directory +import util.Directory._ +import util.ControlUtil._ +import org.eclipse.jgit.api.Git object AutoUpdate { @@ -26,15 +28,14 @@ */ def update(conn: Connection): Unit = { val sqlPath = s"update/${majorVersion}_${minorVersion}.sql" - val in = Thread.currentThread.getContextClassLoader.getResourceAsStream(sqlPath) - if(in != null){ - val sql = IOUtils.toString(in, "UTF-8") - val stmt = conn.createStatement() - try { - logger.debug(sqlPath + "=" + sql) - stmt.executeUpdate(sql) - } finally { - stmt.close() + + using(Thread.currentThread.getContextClassLoader.getResourceAsStream(sqlPath)){ in => + if(in != null){ + val sql = IOUtils.toString(in, "UTF-8") + using(conn.createStatement()){ stmt => + logger.debug(sqlPath + "=" + sql) + stmt.executeUpdate(sql) + } } } } @@ -49,26 +50,32 @@ * The history of versions. A head of this sequence is the current BitBucket version. */ val versions = Seq( + Version(1, 7), + Version(1, 6), + Version(1, 5), + Version(1, 4), new Version(1, 3){ override def update(conn: Connection): Unit = { super.update(conn) // Fix wiki repository configuration - val rs = conn.createStatement.executeQuery("SELECT USER_NAME, REPOSITORY_NAME FROM REPOSITORY") - while(rs.next){ - val wikidir = Directory.getWikiRepositoryDir(rs.getString("USER_NAME"), rs.getString("REPOSITORY_NAME")) - val repository = org.eclipse.jgit.api.Git.open(wikidir).getRepository - val config = repository.getConfig - if(!config.getBoolean("http", "receivepack", false)){ - config.setBoolean("http", null, "receivepack", true) - config.save + using(conn.createStatement.executeQuery("SELECT USER_NAME, REPOSITORY_NAME FROM REPOSITORY")){ rs => + while(rs.next){ + using(Git.open(getWikiRepositoryDir(rs.getString("USER_NAME"), rs.getString("REPOSITORY_NAME")))){ git => + defining(git.getRepository.getConfig){ config => + if(!config.getBoolean("http", "receivepack", false)){ + config.setBoolean("http", null, "receivepack", true) + config.save + } + } + } } - repository.close } } }, Version(1, 2), Version(1, 1), - Version(1, 0) + Version(1, 0), + Version(0, 0) ) /** @@ -79,7 +86,7 @@ /** * The version file (GITBUCKET_HOME/version). */ - val versionFile = new File(Directory.GitBucketHome, "version") + val versionFile = new File(GitBucketHome, "version") /** * Returns the current version from the version file. @@ -103,35 +110,50 @@ } /** - * Start H2 database and update schema automatically. + * Update database schema automatically in the context initializing. */ -class AutoUpdateListener extends org.h2.server.web.DbStarter { +class AutoUpdateListener extends ServletContextListener { import AutoUpdate._ private val logger = LoggerFactory.getLogger(classOf[AutoUpdateListener]) override def contextInitialized(event: ServletContextEvent): Unit = { - super.contextInitialized(event) - logger.debug("H2 started") - + org.h2.Driver.load() + event.getServletContext.setInitParameter("db.url", s"jdbc:h2:${DatabaseHome}") + logger.debug("Start schema update") - val conn = getConnection() - try { - val currentVersion = getCurrentVersion() - if(currentVersion == headVersion){ - logger.debug("No update") - } else { - versions.takeWhile(_ != currentVersion).reverse.foreach(_.update(conn)) - FileUtils.writeStringToFile(versionFile, headVersion.versionString, "UTF-8") - conn.commit() - logger.debug("Updated from " + currentVersion.versionString + " to " + headVersion.versionString) - } - } catch { - case ex: Throwable => { - logger.error("Failed to schema update", ex) - conn.rollback() + defining(getConnection(event.getServletContext)){ conn => + try { + defining(getCurrentVersion()){ currentVersion => + if(currentVersion == headVersion){ + logger.debug("No update") + } else if(!versions.contains(currentVersion)){ + logger.warn(s"Skip migration because ${currentVersion.versionString} is illegal version.") + } else { + versions.takeWhile(_ != currentVersion).reverse.foreach(_.update(conn)) + FileUtils.writeStringToFile(versionFile, headVersion.versionString, "UTF-8") + conn.commit() + logger.debug(s"Updated from ${currentVersion.versionString} to ${headVersion.versionString}") + } + } + } catch { + case ex: Throwable => { + logger.error("Failed to schema update", ex) + ex.printStackTrace() + conn.rollback() + } } } logger.debug("End schema update") } - -} \ No newline at end of file + + def contextDestroyed(sce: ServletContextEvent): Unit = { + // Nothing to do. + } + + private def getConnection(servletContext: ServletContext): Connection = + DriverManager.getConnection( + servletContext.getInitParameter("db.url"), + servletContext.getInitParameter("db.user"), + servletContext.getInitParameter("db.password")) + +} diff --git a/src/main/scala/servlet/BasicAuthenticationFilter.scala b/src/main/scala/servlet/BasicAuthenticationFilter.scala index a69c237..16c516c 100644 --- a/src/main/scala/servlet/BasicAuthenticationFilter.scala +++ b/src/main/scala/servlet/BasicAuthenticationFilter.scala @@ -2,14 +2,16 @@ import javax.servlet._ import javax.servlet.http._ -import util.StringUtil._ -import service.{AccountService, RepositoryService} +import service.{SystemSettingsService, AccountService, RepositoryService} import org.slf4j.LoggerFactory +import util.Implicits._ +import util.ControlUtil._ +import util.Keys /** * Provides BASIC Authentication for [[servlet.GitRepositoryServlet]]. */ -class BasicAuthenticationFilter extends Filter with RepositoryService with AccountService { +class BasicAuthenticationFilter extends Filter with RepositoryService with AccountService with SystemSettingsService { private val logger = LoggerFactory.getLogger(classOf[BasicAuthenticationFilter]) @@ -26,28 +28,30 @@ } try { - val paths = request.getRequestURI.substring(request.getContextPath.length).split("/") - val repositoryOwner = paths(2) - val repositoryName = paths(3).replaceFirst("\\.git$", "") - - getRepository(repositoryOwner, repositoryName.replaceFirst("\\.wiki", ""), "") match { - case Some(repository) => { - if(!request.getRequestURI.endsWith("/git-receive-pack") && !repository.repository.isPrivate){ - chain.doFilter(req, wrappedResponse) - } else { - request.getHeader("Authorization") match { - case null => requireAuth(response) - case auth => decodeAuthHeader(auth).split(":") match { - case Array(username, password) if(isWritableUser(username, password, repository)) => { - request.setAttribute("USER_NAME", username) - chain.doFilter(req, wrappedResponse) + defining(request.paths){ case Array(_, repositoryOwner, repositoryName, _*) => + getRepository(repositoryOwner, repositoryName.replaceFirst("\\.wiki\\.git$|\\.git$", ""), "") match { + case Some(repository) => { + 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 { + case null => requireAuth(response) + case auth => decodeAuthHeader(auth).split(":") match { + case Array(username, password) if(isWritableUser(username, password, repository)) => { + request.setAttribute(Keys.Request.UserName, username) + chain.doFilter(req, wrappedResponse) + } + case _ => requireAuth(response) } - case _ => requireAuth(response) } } } + case None => { + logger.debug(s"Repository ${repositoryOwner}/${repositoryName} is not found.") + response.sendError(HttpServletResponse.SC_NOT_FOUND) + } } - case None => response.sendError(HttpServletResponse.SC_NOT_FOUND) } } catch { case ex: Exception => { @@ -57,12 +61,12 @@ } } - private def isWritableUser(username: String, password: String, repository: RepositoryService.RepositoryInfo): Boolean = { - getAccountByUserName(username).map { account => - account.password == sha1(password) && hasWritePermission(repository.owner, repository.name, Some(account)) - } getOrElse false - } - + private def isWritableUser(username: String, password: String, repository: RepositoryService.RepositoryInfo): Boolean = + authenticate(loadSystemSettings(), username, password) match { + case Some(account) => hasWritePermission(repository.owner, repository.name, Some(account)) + case None => false + } + private def requireAuth(response: HttpServletResponse): Unit = { response.setHeader("WWW-Authenticate", "BASIC realm=\"GitBucket\"") response.sendError(HttpServletResponse.SC_UNAUTHORIZED) diff --git a/src/main/scala/servlet/GitRepositoryServlet.scala b/src/main/scala/servlet/GitRepositoryServlet.scala index 94c432b..e16f48c 100644 --- a/src/main/scala/servlet/GitRepositoryServlet.scala +++ b/src/main/scala/servlet/GitRepositoryServlet.scala @@ -9,8 +9,13 @@ import javax.servlet.ServletConfig import javax.servlet.ServletContext import javax.servlet.http.HttpServletRequest -import util.{JGitUtil, Directory} +import util.{Keys, JGitUtil, Directory} +import util.ControlUtil._ +import util.Implicits._ import service._ +import WebHookService._ +import org.eclipse.jgit.api.Git +import util.JGitUtil.CommitInfo /** * Provides Git repository via HTTP. @@ -24,7 +29,7 @@ override def init(config: ServletConfig): Unit = { setReceivePackFactory(new GitBucketReceivePackFactory()) - + // TODO are there any other ways...? super.init(new ServletConfig(){ def getInitParameter(name: String): String = name match { @@ -33,12 +38,14 @@ case name => config.getInitParameter(name) } def getInitParameterNames(): java.util.Enumeration[String] = { - config.getInitParameterNames + config.getInitParameterNames } - + def getServletContext(): ServletContext = config.getServletContext def getServletName(): String = config.getServletName }); + + super.init(config) } } @@ -49,68 +56,102 @@ override def create(request: HttpServletRequest, db: Repository): ReceivePack = { val receivePack = new ReceivePack(db) - val userName = request.getAttribute("USER_NAME").asInstanceOf[String] + val userName = request.getAttribute(Keys.Request.UserName).asInstanceOf[String] logger.debug("requestURI: " + request.getRequestURI) logger.debug("userName:" + userName) - val paths = request.getRequestURI.substring(request.getContextPath.length).split("/") - val owner = paths(2) - val repository = paths(3).replaceFirst("\\.git$", "") - - logger.debug("repository:" + owner + "/" + repository) + defining(request.paths){ paths => + val owner = paths(1) + val repository = paths(2).replaceFirst("\\.git$", "") + val baseURL = request.getRequestURL.toString.replaceFirst("/git/.*", "") - receivePack.setPostReceiveHook(new CommitLogHook(owner, repository, userName)) - receivePack + logger.debug("repository:" + owner + "/" + repository) + logger.debug("baseURL:" + baseURL) + + receivePack.setPostReceiveHook(new CommitLogHook(owner, repository, userName, baseURL)) + receivePack + } } } import scala.collection.JavaConverters._ -class CommitLogHook(owner: String, repository: String, userName: String) extends PostReceiveHook - with RepositoryService with AccountService with IssuesService with ActivityService { +class CommitLogHook(owner: String, repository: String, userName: String, baseURL: String) extends PostReceiveHook + with RepositoryService with AccountService with IssuesService with ActivityService with WebHookService { private val logger = LoggerFactory.getLogger(classOf[CommitLogHook]) def onPostReceive(receivePack: ReceivePack, commands: java.util.Collection[ReceiveCommand]): Unit = { - JGitUtil.withGit(Directory.getRepositoryDir(owner, repository)) { git => + using(Git.open(Directory.getRepositoryDir(owner, repository))) { git => commands.asScala.foreach { command => val commits = JGitUtil.getCommitLog(git, command.getOldId.name, command.getNewId.name) val refName = command.getRefName.split("/") - - // apply issue comment - val newCommits = commits.flatMap { commit => - if(!existsCommitId(owner, repository, commit.id)){ - insertCommitId(owner, repository, commit.id) - "(^|\\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, "commit") - } + val branchName = refName.drop(2).mkString("/") + + // Extract new commit and apply issue comment + val newCommits = if(commits.size > 1000){ + val existIds = getAllCommitIds(owner, repository) + commits.flatMap { commit => + optionIf(!existIds.contains(commit.id)){ + createIssueComment(commit) + Some(commit) } - Some(commit) - } else None - }.toList - + } + } else { + commits.flatMap { commit => + optionIf(!existsCommitId(owner, repository, commit.id)){ + createIssueComment(commit) + Some(commit) + } + } + } + + // batch insert all new commit id + insertAllCommitIds(owner, repository, newCommits.map(_.id)) + // record activity if(refName(1) == "heads"){ command.getType match { case ReceiveCommand.Type.CREATE => { - recordCreateBranchActivity(owner, repository, userName, refName(2)) - recordPushActivity(owner, repository, userName, refName(2), newCommits) + recordCreateBranchActivity(owner, repository, userName, branchName) + recordPushActivity(owner, repository, userName, branchName, newCommits) } - case ReceiveCommand.Type.UPDATE => recordPushActivity(owner, repository, userName, refName(2), newCommits) + case ReceiveCommand.Type.UPDATE => recordPushActivity(owner, repository, userName, branchName, newCommits) case _ => } } else if(refName(1) == "tags"){ command.getType match { - case ReceiveCommand.Type.CREATE => recordCreateTagActivity(owner, repository, userName, refName(2), newCommits) + case ReceiveCommand.Type.CREATE => recordCreateTagActivity(owner, repository, userName, branchName, newCommits) case _ => } } + + // call web hook + val webHookURLs = getWebHookURLs(owner, repository) + if(webHookURLs.nonEmpty){ + val payload = WebHookPayload( + git, + command.getRefName, + getRepository(owner, repository, baseURL).get, + newCommits, + getAccountByUserName(owner).get) + + callWebHook(owner, repository, webHookURLs, payload) + } } } // update repository last modified time. updateLastActivityDate(owner, repository) } + + private def createIssueComment(commit: CommitInfo) = { + "(^|\\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, "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/servlet/TransactionFilter.scala b/src/main/scala/servlet/TransactionFilter.scala index 5a26734..12363ad 100644 --- a/src/main/scala/servlet/TransactionFilter.scala +++ b/src/main/scala/servlet/TransactionFilter.scala @@ -3,7 +3,6 @@ import javax.servlet._ import org.slf4j.LoggerFactory import javax.servlet.http.HttpServletRequest -import scala.slick.session.Database /** * Controls the transaction with the open session in view pattern. @@ -21,15 +20,19 @@ // assets don't need transaction chain.doFilter(req, res) } else { - val context = req.getServletContext - Database.forURL(context.getInitParameter("db.url"), - context.getInitParameter("db.user"), - context.getInitParameter("db.password")) withTransaction { - logger.debug("TODO begin transaction") + Database(req.getServletContext) withTransaction { + logger.debug("begin transaction") chain.doFilter(req, res) - logger.debug("TODO end transaction") + logger.debug("end transaction") } } } - -} \ No newline at end of file + +} + +object Database { + def apply(context: ServletContext): scala.slick.session.Database = + scala.slick.session.Database.forURL(context.getInitParameter("db.url"), + context.getInitParameter("db.user"), + context.getInitParameter("db.password")) +} diff --git a/src/main/scala/util/Authenticator.scala b/src/main/scala/util/Authenticator.scala index b22a0d7..c524713 100644 --- a/src/main/scala/util/Authenticator.scala +++ b/src/main/scala/util/Authenticator.scala @@ -3,6 +3,8 @@ import app.ControllerBase import service._ import RepositoryService.RepositoryInfo +import util.Implicits._ +import util.ControlUtil._ /** * Allows only oneself and administrators. @@ -13,11 +15,12 @@ private def authenticate(action: => Any) = { { - val paths = request.getRequestURI.substring(request.getContextPath.length).split("/") - context.loginAccount match { - case Some(x) if(x.isAdmin) => action - case Some(x) if(paths(1) == x.userName) => action - case _ => Unauthorized() + defining(request.paths){ paths => + context.loginAccount match { + case Some(x) if(x.isAdmin) => action + case Some(x) if(paths(0) == x.userName) => action + case _ => Unauthorized() + } } } } @@ -32,14 +35,15 @@ private def authenticate(action: (RepositoryInfo) => Any) = { { - val paths = request.getRequestURI.substring(request.getContextPath.length).split("/") - getRepository(paths(1), paths(2), baseUrl).map { repository => - context.loginAccount match { - case Some(x) if(x.isAdmin) => action(repository) - case Some(x) if(repository.owner == x.userName) => action(repository) - case _ => Unauthorized() - } - } getOrElse NotFound() + defining(request.paths){ paths => + getRepository(paths(0), paths(1), baseUrl).map { repository => + context.loginAccount match { + case Some(x) if(x.isAdmin) => action(repository) + case Some(x) if(repository.owner == x.userName) => action(repository) + case _ => Unauthorized() + } + } getOrElse NotFound() + } } } } @@ -87,15 +91,16 @@ private def authenticate(action: (RepositoryInfo) => Any) = { { - val paths = request.getRequestURI.substring(request.getContextPath.length).split("/") - getRepository(paths(1), paths(2), baseUrl).map { repository => - context.loginAccount match { - case Some(x) if(x.isAdmin) => action(repository) - case Some(x) if(paths(1) == x.userName) => action(repository) - case Some(x) if(getCollaborators(paths(1), paths(2)).contains(x.userName)) => action(repository) - case _ => Unauthorized() - } - } getOrElse NotFound() + defining(request.paths){ paths => + getRepository(paths(0), paths(1), baseUrl).map { repository => + context.loginAccount match { + case Some(x) if(x.isAdmin) => action(repository) + case Some(x) if(paths(0) == x.userName) => action(repository) + case Some(x) if(getCollaborators(paths(0), paths(1)).contains(x.userName)) => action(repository) + case _ => Unauthorized() + } + } getOrElse NotFound() + } } } } @@ -109,19 +114,20 @@ private def authenticate(action: (RepositoryInfo) => Any) = { { - val paths = request.getRequestURI.substring(request.getContextPath.length).split("/") - getRepository(paths(1), paths(2), baseUrl).map { repository => - if(!repository.repository.isPrivate){ - action(repository) - } else { - context.loginAccount match { - case Some(x) if(x.isAdmin) => action(repository) - case Some(x) if(paths(1) == x.userName) => action(repository) - case Some(x) if(getCollaborators(paths(1), paths(2)).contains(x.userName)) => action(repository) - case _ => Unauthorized() + defining(request.paths){ paths => + getRepository(paths(0), paths(1), baseUrl).map { repository => + if(!repository.repository.isPrivate){ + action(repository) + } else { + context.loginAccount match { + case Some(x) if(x.isAdmin) => action(repository) + case Some(x) if(paths(0) == x.userName) => action(repository) + case Some(x) if(getCollaborators(paths(0), paths(1)).contains(x.userName)) => action(repository) + case _ => Unauthorized() + } } - } - } getOrElse NotFound() + } getOrElse NotFound() + } } } } @@ -135,16 +141,17 @@ private def authenticate(action: (RepositoryInfo) => Any) = { { - val paths = request.getRequestURI.substring(request.getContextPath.length).split("/") - getRepository(paths(1), paths(2), baseUrl).map { repository => - context.loginAccount match { - case Some(x) if(x.isAdmin) => action(repository) - case Some(x) if(!repository.repository.isPrivate) => action(repository) - case Some(x) if(paths(1) == x.userName) => action(repository) - case Some(x) if(getCollaborators(paths(1), paths(2)).contains(x.userName)) => action(repository) - case _ => Unauthorized() - } - } getOrElse NotFound() + defining(request.paths){ paths => + getRepository(paths(0), paths(1), baseUrl).map { repository => + context.loginAccount match { + case Some(x) if(x.isAdmin) => action(repository) + case Some(x) if(!repository.repository.isPrivate) => action(repository) + case Some(x) if(paths(0) == x.userName) => action(repository) + case Some(x) if(getCollaborators(paths(0), paths(1)).contains(x.userName)) => action(repository) + case _ => Unauthorized() + } + } getOrElse NotFound() + } } } } diff --git a/src/main/scala/util/ControlUtil.scala b/src/main/scala/util/ControlUtil.scala new file mode 100644 index 0000000..c7b4310 --- /dev/null +++ b/src/main/scala/util/ControlUtil.scala @@ -0,0 +1,48 @@ +package util + +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.revwalk.RevWalk +import org.eclipse.jgit.treewalk.TreeWalk + +/** + * Provides control facilities. + */ +object ControlUtil { + + def defining[A, B](value: A)(f: A => B): B = f(value) + + def using[A <% { def close(): Unit }, B](resource: A)(f: A => B): B = + try f(resource) finally { + if(resource != null){ + try { + resource.close() + } catch { + case e: Throwable => // ignore + } + } + } + + def using[T](git: Git)(f: Git => T): T = + try f(git) finally git.getRepository.close + + def using[T](git1: Git, git2: Git)(f: (Git, Git) => T): T = + try f(git1, git2) finally { + git1.getRepository.close + git2.getRepository.close + } + + def using[T](revWalk: RevWalk)(f: RevWalk => T): T = + try f(revWalk) finally revWalk.release() + + def using[T](treeWalk: TreeWalk)(f: TreeWalk => T): T = + try f(treeWalk) finally treeWalk.release() + + def executeIf(condition: => Boolean)(action: => Unit): Boolean = + if(condition){ + action + true + } else false + + def optionIf[T](condition: => Boolean)(action: => Option[T]): Option[T] = + if(condition) action else None +} diff --git a/src/main/scala/util/Directory.scala b/src/main/scala/util/Directory.scala index f2b7f4c..5409962 100644 --- a/src/main/scala/util/Directory.scala +++ b/src/main/scala/util/Directory.scala @@ -1,34 +1,38 @@ package util import java.io.File -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.lib.Ref +import util.ControlUtil._ /** * Provides directories used by GitBucket. */ object Directory { - val GitBucketHome = new File(System.getProperty("user.home"), "gitbucket").getAbsolutePath + val GitBucketHome = (scala.util.Properties.envOrNone("GITBUCKET_HOME") match { + case Some(env) => new File(env) + case None => new File(System.getProperty("user.home"), "gitbucket") + }).getAbsolutePath val GitBucketConf = new File(GitBucketHome, "gitbucket.conf") val RepositoryHome = s"${GitBucketHome}/repositories" + + val DatabaseHome = s"${GitBucketHome}/data" /** * Repository names of the specified user. */ - def getRepositories(owner: String): List[String] = { - val dir = new File(s"${RepositoryHome}/${owner}") - if(dir.exists){ - dir.listFiles.filter { file => - file.isDirectory && !file.getName.endsWith(".wiki.git") - }.map(_.getName.replaceFirst("\\.git$", "")).toList - } else { - Nil + def getRepositories(owner: String): List[String] = + defining(new File(s"${RepositoryHome}/${owner}")){ dir => + if(dir.exists){ + dir.listFiles.filter { file => + file.isDirectory && !file.getName.endsWith(".wiki.git") + }.map(_.getName.replaceFirst("\\.git$", "")).toList + } else { + Nil + } } - } - + /** * Substance directory of the repository. */ 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..1386a05 100644 --- a/src/main/scala/util/FileUtil.scala +++ b/src/main/scala/util/FileUtil.scala @@ -1,22 +1,31 @@ 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} +import util.ControlUtil._ object FileUtil { - def getMimeType(name: String): String = { - val fileNameMap = URLConnection.getFileNameMap() - val mimeType = fileNameMap.getContentTypeFor(name) - if(mimeType == null){ - "application/octeat-stream" - } else { - mimeType + def getMimeType(name: String): String = + defining(URLConnection.getFileNameMap()){ fileNameMap => + fileNameMap.getContentTypeFor(name) match { + case null => "application/octet-stream" + case mimeType => mimeType + } + } + + def getContentType(name: String, bytes: Array[Byte]): String = { + defining(getMimeType(name)){ mimeType => + if(mimeType == "application/octet-stream" && isText(bytes)){ + "text/plain" + } else { + mimeType + } } } - + def isImage(name: String): Boolean = getMimeType(name).startsWith("image/") def isLarge(size: Long): Boolean = (size > 1024 * 1000) @@ -36,21 +45,29 @@ } } - val out = new ZipArchiveOutputStream(dest) - try { + using(new ZipArchiveOutputStream(dest)){ out => addDirectoryToZip(out, dir, dir.getName) - } finally { - IOUtils.closeQuietly(out) } } - def getExtension(name: String): String = { - val index = name.lastIndexOf('.') - if(index >= 0){ - name.substring(index + 1) - } else { - "" - } + def getFileName(path: String): String = defining(path.lastIndexOf('/')){ i => + if(i >= 0) path.substring(i + 1) else path } -} \ No newline at end of file + def getExtension(name: String): String = + name.lastIndexOf('.') match { + case i if(i >= 0) => name.substring(i + 1) + case _ => "" + } + + def withTmpDir[A](dir: File)(action: File => A): A = { + if(dir.exists()){ + FileUtils.deleteDirectory(dir) + } + try{ + action(dir) + }finally{ + FileUtils.deleteDirectory(dir) + } + } +} diff --git a/src/main/scala/util/Implicits.scala b/src/main/scala/util/Implicits.scala index c475136..a9e648c 100644 --- a/src/main/scala/util/Implicits.scala +++ b/src/main/scala/util/Implicits.scala @@ -1,7 +1,7 @@ package util -import scala.slick.driver.H2Driver.simple._ import scala.util.matching.Regex +import javax.servlet.http.{HttpSession, HttpServletRequest} /** * Provides some usable implicit conversions. @@ -13,7 +13,7 @@ def splitWith(condition: (A, A) => Boolean): Seq[Seq[A]] = split(seq)(condition) @scala.annotation.tailrec - private def split[A](list: Seq[A], result: Seq[Seq[A]] = Nil)(condition: (A, A) => Boolean): Seq[Seq[A]] = { + private def split[A](list: Seq[A], result: Seq[Seq[A]] = Nil)(condition: (A, A) => Boolean): Seq[Seq[A]] = list match { case x :: xs => { xs.span(condition(x, _)) match { @@ -22,12 +22,6 @@ } case Nil => result } - } - } - - // 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){ @@ -47,6 +41,38 @@ } sb.toString } + + def toIntOpt: Option[Int] = try { + Option(Integer.parseInt(value)) + } catch { + case e: NumberFormatException => None + } } -} \ No newline at end of file + implicit class RichRequest(request: HttpServletRequest){ + + def paths: Array[String] = request.getRequestURI.substring(request.getContextPath.length + 1).split("/") + + def hasQueryString: Boolean = request.getQueryString != null + + def hasAttribute(name: String): Boolean = request.getAttribute(name) != null + + } + + implicit class RichSession(session: HttpSession){ + + def putAndGet[T](key: String, value: T): T = { + session.setAttribute(key, value) + value + } + + def getAndRemove[T](key: String): Option[T] = { + val value = session.getAttribute(key).asInstanceOf[T] + if(value == null){ + session.removeAttribute(key) + } + Option(value) + } + } + +} diff --git a/src/main/scala/util/JGitUtil.scala b/src/main/scala/util/JGitUtil.scala index 4f9091c..c282e9a 100644 --- a/src/main/scala/util/JGitUtil.scala +++ b/src/main/scala/util/JGitUtil.scala @@ -2,19 +2,19 @@ import org.eclipse.jgit.api.Git import util.Directory._ +import util.StringUtil._ +import util.ControlUtil._ import scala.collection.JavaConverters._ -import javax.servlet.ServletContext import org.eclipse.jgit.lib._ import org.eclipse.jgit.revwalk._ import org.eclipse.jgit.revwalk.filter._ import org.eclipse.jgit.treewalk._ import org.eclipse.jgit.treewalk.filter._ -import org.eclipse.jgit.diff._ import org.eclipse.jgit.diff.DiffEntry.ChangeType -import org.eclipse.jgit.util.io.DisabledOutputStream import org.eclipse.jgit.errors.MissingObjectException import java.util.Date import org.eclipse.jgit.api.errors.NoHeadException +import service.RepositoryService /** * Provides complex JGit operations. @@ -71,29 +71,17 @@ rev.getFullMessage, rev.getParents().map(_.name).toList) - val summary = { - val i = fullMessage.trim.indexOf("\n") - val firstLine = if(i >= 0){ - fullMessage.trim.substring(0, i).trim - } else { - fullMessage - } - if(firstLine.length > shortMessage.length){ - shortMessage - } else { - firstLine + val summary = defining(fullMessage.trim.indexOf("\n")){ i => + defining(if(i >= 0) fullMessage.trim.substring(0, i).trim else fullMessage){ firstLine => + if(firstLine.length > shortMessage.length) shortMessage else firstLine } } - val description = { - val i = fullMessage.trim.indexOf("\n") - if(i >= 0){ + val description = defining(fullMessage.trim.indexOf("\n")){ i => + optionIf(i >= 0){ Some(fullMessage.trim.substring(i).trim) - } else { - None } } - } case class DiffInfo(changeType: ChangeType, oldPath: String, newPath: String, oldContent: Option[String], newContent: Option[String]) @@ -116,33 +104,18 @@ case class TagInfo(name: String, time: Date, id: String) /** - * Use this method to use the Git object. - * Repository resources are released certainly after processing. - */ - def withGit[T](dir: java.io.File)(f: Git => T): T = withGit(Git.open(dir))(f) - - /** - * Use this method to use the Git object. - * Repository resources are released certainly after processing. - */ - def withGit[T](git: Git)(f: Git => T): T = { - try { - f(git) - } finally { - git.getRepository.close - } - } - - /** - * Returns RevCommit from the commit id. + * Returns RevCommit from the commit or tag id. * * @param git the Git object - * @param commitId the ObjectId of the commit - * @return the RevCommit for the specified commit + * @param objectId the ObjectId of the commit or tag + * @return the RevCommit for the specified commit or tag */ - def getRevCommitFromId(git: Git, commitId: ObjectId): RevCommit = { + def getRevCommitFromId(git: Git, objectId: ObjectId): RevCommit = { val revWalk = new RevWalk(git.getRepository) - val revCommit = revWalk.parseCommit(commitId) + val revCommit = revWalk.parseAny(objectId) match { + case r: RevTag => revWalk.parseCommit(r.getObject) + case _ => revWalk.parseCommit(objectId) + } revWalk.dispose revCommit } @@ -151,15 +124,10 @@ * Returns the repository information. It contains branch names and tag names. */ def getRepositoryInfo(owner: String, repository: String, baseUrl: String): RepositoryInfo = { - withGit(getRepositoryDir(owner, repository)){ git => + using(Git.open(getRepositoryDir(owner, repository))){ git => try { // get commit count - val i = git.log.all.call.iterator - var commitCount = 0 - while(i.hasNext && commitCount <= 1000){ - i.next - commitCount = commitCount + 1 - } + val commitCount = git.log.all.call.iterator.asScala.map(_ => 1).take(1000).sum RepositoryInfo( owner, repository, s"${baseUrl}/git/${owner}/${repository}.git", @@ -193,45 +161,43 @@ * @return HTML of the file list */ def getFileList(git: Git, revision: String, path: String = "."): List[FileInfo] = { - val revWalk = new RevWalk(git.getRepository) - val objectId = git.getRepository.resolve(revision) - val revCommit = revWalk.parseCommit(objectId) - - val treeWalk = new TreeWalk(git.getRepository) - treeWalk.addTree(revCommit.getTree) - if(path != "."){ - treeWalk.setRecursive(true) - treeWalk.setFilter(new TreeFilter(){ - - var stopRecursive = false - - def include(walker: TreeWalk): Boolean = { - val targetPath = walker.getPathString - if((path + "/").startsWith(targetPath)){ - true - } else if(targetPath.startsWith(path + "/") && targetPath.substring(path.length + 1).indexOf("/") < 0){ - stopRecursive = true - treeWalk.setRecursive(false) - true - } else { - false - } - } - - def shouldBeRecursive(): Boolean = !stopRecursive - - override def clone: TreeFilter = return this - }) - } - val list = new scala.collection.mutable.ListBuffer[(ObjectId, FileMode, String, String)] - - while (treeWalk.next()) { - list.append((treeWalk.getObjectId(0), treeWalk.getFileMode(0), treeWalk.getPathString, treeWalk.getNameString)) + + using(new RevWalk(git.getRepository)){ revWalk => + val objectId = git.getRepository.resolve(revision) + val revCommit = revWalk.parseCommit(objectId) + + using(new TreeWalk(git.getRepository)){ treeWalk => + treeWalk.addTree(revCommit.getTree) + if(path != "."){ + treeWalk.setRecursive(true) + treeWalk.setFilter(new TreeFilter(){ + + var stopRecursive = false + + def include(walker: TreeWalk): Boolean = { + val targetPath = walker.getPathString + if((path + "/").startsWith(targetPath)){ + true + } else if(targetPath.startsWith(path + "/") && targetPath.substring(path.length + 1).indexOf("/") < 0){ + stopRecursive = true + treeWalk.setRecursive(false) + true + } else { + false + } + } + + def shouldBeRecursive(): Boolean = !stopRecursive + + override def clone: TreeFilter = return this + }) + } + while (treeWalk.next()) { + list.append((treeWalk.getObjectId(0), treeWalk.getFileMode(0), treeWalk.getPathString, treeWalk.getNameString)) + } + } } - - treeWalk.release - revWalk.dispose val commits = getLatestCommitFromPaths(git, list.toList.map(_._3), revision) list.map { case (objectId, fileMode, path, name) => @@ -261,7 +227,7 @@ * @param page the page number (1-) * @param limit the number of commit info per page. 0 (default) means unlimited. * @param path filters by this path. default is no filter. - * @return a tuple of the commit list and whether has next + * @return a tuple of the commit list and whether has next, or the error message */ def getCommitLog(git: Git, revision: String, page: Int = 1, limit: Int = 0, path: String = ""): Either[String, (List[CommitInfo], Boolean)] = { val fixedPage = if(page <= 0) 1 else page @@ -276,27 +242,48 @@ case _ => (logs, i.hasNext) } - val revWalk = new RevWalk(git.getRepository) - val objectId = git.getRepository.resolve(revision) - if(objectId == null){ - Left(s"${revision} can't be resolved.") - } else { - revWalk.markStart(revWalk.parseCommit(objectId)) - if(path.nonEmpty){ - revWalk.setRevFilter(new RevFilter(){ - def include(walk: RevWalk, commit: RevCommit): Boolean = { - getDiffs(git, commit.getName, false).find(_.newPath == path).nonEmpty + using(new RevWalk(git.getRepository)){ revWalk => + defining(git.getRepository.resolve(revision)){ objectId => + if(objectId == null){ + Left(s"${revision} can't be resolved.") + } else { + revWalk.markStart(revWalk.parseCommit(objectId)) + if(path.nonEmpty){ + revWalk.setRevFilter(new RevFilter(){ + def include(walk: RevWalk, commit: RevCommit): Boolean = { + getDiffs(git, commit.getName, false)._1.find(_.newPath == path).nonEmpty + } + override def clone(): RevFilter = this + }) } - override def clone(): RevFilter = this - }) + Right(getCommitLog(revWalk.iterator, 0, Nil)) + } } - - val commits = getCommitLog(revWalk.iterator, 0, Nil) - revWalk.release - - Right(commits) } } + + def getCommitLogs(git: Git, begin: String, includesLastCommit: Boolean = false) + (endCondition: RevCommit => Boolean): List[CommitInfo] = { + @scala.annotation.tailrec + def getCommitLog(i: java.util.Iterator[RevCommit], logs: List[CommitInfo]): List[CommitInfo] = + i.hasNext match { + case true => { + val revCommit = i.next + if(endCondition(revCommit)){ + if(includesLastCommit) logs :+ new CommitInfo(revCommit) else logs + } else { + getCommitLog(i, logs :+ new CommitInfo(revCommit)) + } + } + case false => logs + } + + using(new RevWalk(git.getRepository)){ revWalk => + revWalk.markStart(revWalk.parseCommit(git.getRepository.resolve(begin))) + getCommitLog(revWalk.iterator, Nil).reverse + } + } + /** * Returns the commit list between two revisions. @@ -306,30 +293,9 @@ * @param to the to revision * @return the commit list */ - def getCommitLog(git: Git, from: String, to: String): List[CommitInfo] = { - @scala.annotation.tailrec - def getCommitLog(i: java.util.Iterator[RevCommit], logs: List[CommitInfo]): List[CommitInfo] = - i.hasNext match { - case true => { - val revCommit = i.next - if(revCommit.name == from){ - logs - } else { - getCommitLog(i, logs :+ new CommitInfo(revCommit)) - } - } - case false => logs - } - - val revWalk = new RevWalk(git.getRepository) - revWalk.markStart(revWalk.parseCommit(git.getRepository.resolve(to))) - - val commits = getCommitLog(revWalk.iterator, Nil) - revWalk.release - - commits.reverse - } - + // TODO swap parameters 'from' and 'to'!? + def getCommitLog(git: Git, from: String, to: String): List[CommitInfo] = + getCommitLogs(git, to)(_.getName == from) /** * Returns the latest RevCommit of the specified path. @@ -351,51 +317,11 @@ * @return the list of latest commit */ def getLatestCommitFromPaths(git: Git, paths: List[String], revision: String): Map[String, RevCommit] = { - - val map = new scala.collection.mutable.HashMap[String, RevCommit] - - val revWalk = new RevWalk(git.getRepository) - revWalk.markStart(revWalk.parseCommit(git.getRepository.resolve(revision))) - //revWalk.sort(RevSort.REVERSE); - val i = revWalk.iterator - - while(i.hasNext && map.size != paths.length){ - val commit = i.next - if(commit.getParentCount == 0){ - // Initial commit - val treeWalk = new TreeWalk(git.getRepository) - treeWalk.reset() - treeWalk.setRecursive(true) - treeWalk.addTree(commit.getTree) - while (treeWalk.next) { - paths.foreach { path => - if(treeWalk.getPathString.startsWith(path) && !map.contains(path)){ - map.put(path, commit) - } - } - } - treeWalk.release - } else { - (0 to commit.getParentCount - 1).foreach { i => - val parent = revWalk.parseCommit(commit.getParent(i).getId()) - val df = new DiffFormatter(DisabledOutputStream.INSTANCE) - df.setRepository(git.getRepository) - df.setDiffComparator(RawTextComparator.DEFAULT) - df.setDetectRenames(true) - val diffs = df.scan(parent.getTree(), commit.getTree) - diffs.asScala.foreach { diff => - paths.foreach { path => - if(diff.getChangeType != ChangeType.DELETE && diff.getNewPath.startsWith(path) && !map.contains(path)){ - map.put(path, commit) - } - } - } - } - } - - revWalk.release - } - map.toMap + val start = getRevCommitFromId(git, git.getRepository.resolve(revision)) + paths.map { path => + val commit = git.log.add(start).addPath(path).setMaxCount(1).call.iterator.next + (path, commit) + }.toMap } /** @@ -411,126 +337,131 @@ if(large == false && FileUtil.isLarge(loader.getSize)){ None } else { - val db = git.getRepository.getObjectDatabase - try { + using(git.getRepository.getObjectDatabase){ db => Some(db.open(id).getBytes) - } finally { - db.close } } } catch { case e: MissingObjectException => None } - - def getDiffs(git: Git, id: String, fetchContent: Boolean = true): List[DiffInfo] = { + + /** + * Returns the tuple of diff of the given commit and the previous commit id. + */ + def getDiffs(git: Git, id: String, fetchContent: Boolean = true): (List[DiffInfo], Option[String]) = { @scala.annotation.tailrec def getCommitLog(i: java.util.Iterator[RevCommit], logs: List[RevCommit]): List[RevCommit] = i.hasNext match { case true if(logs.size < 2) => getCommitLog(i, logs :+ i.next) case _ => logs } - - val revWalk = new RevWalk(git.getRepository) - revWalk.markStart(revWalk.parseCommit(git.getRepository.resolve(id))) - - val commits = getCommitLog(revWalk.iterator, Nil) - revWalk.release - - val revCommit = commits(0) - - if(commits.length >= 2){ - // not initial commit - val oldCommit = commits(1) - - // get diff between specified commit and its previous commit - val reader = git.getRepository.newObjectReader - - val oldTreeIter = new CanonicalTreeParser - oldTreeIter.reset(reader, git.getRepository.resolve(oldCommit.name + "^{tree}")) - - val newTreeIter = new CanonicalTreeParser - newTreeIter.reset(reader, git.getRepository.resolve(id + "^{tree}")) - - import scala.collection.JavaConverters._ - git.diff.setNewTree(newTreeIter).setOldTree(oldTreeIter).call.asScala.map { diff => - if(!fetchContent || FileUtil.isImage(diff.getOldPath) || FileUtil.isImage(diff.getNewPath)){ - DiffInfo(diff.getChangeType, diff.getOldPath, diff.getNewPath, None, None) - } else { - DiffInfo(diff.getChangeType, diff.getOldPath, diff.getNewPath, - JGitUtil.getContent(git, diff.getOldId.toObjectId, false).filter(FileUtil.isText).map(new String(_, "UTF-8")), - JGitUtil.getContent(git, diff.getNewId.toObjectId, false).filter(FileUtil.isText).map(new String(_, "UTF-8"))) + + using(new RevWalk(git.getRepository)){ revWalk => + revWalk.markStart(revWalk.parseCommit(git.getRepository.resolve(id))) + val commits = getCommitLog(revWalk.iterator, Nil) + val revCommit = commits(0) + + if(commits.length >= 2){ + // not initial commit + val oldCommit = commits(1) + (getDiffs(git, oldCommit.getName, id, fetchContent), Some(oldCommit.getName)) + + } else { + // initial commit + using(new TreeWalk(git.getRepository)){ treeWalk => + treeWalk.addTree(revCommit.getTree) + val buffer = new scala.collection.mutable.ListBuffer[DiffInfo]() + while(treeWalk.next){ + buffer.append((if(!fetchContent){ + DiffInfo(ChangeType.ADD, null, treeWalk.getPathString, None, None) + } else { + DiffInfo(ChangeType.ADD, null, treeWalk.getPathString, None, + JGitUtil.getContent(git, treeWalk.getObjectId(0), false).filter(FileUtil.isText).map(convertFromByteArray)) + })) + } + (buffer.toList, None) } - }.toList - } else { - // initial commit - val walk = new TreeWalk(git.getRepository) - walk.addTree(revCommit.getTree) - val buffer = new scala.collection.mutable.ListBuffer[DiffInfo]() - while(walk.next){ - buffer.append((if(!fetchContent){ - DiffInfo(ChangeType.ADD, null, walk.getPathString, None, None) - } else { - DiffInfo(ChangeType.ADD, null, walk.getPathString, None, - JGitUtil.getContent(git, walk.getObjectId(0), false).filter(FileUtil.isText).map(new String(_, "UTF-8"))) - })) } - walk.release - buffer.toList } } + def getDiffs(git: Git, from: String, to: String, fetchContent: Boolean): List[DiffInfo] = { + val reader = git.getRepository.newObjectReader + val oldTreeIter = new CanonicalTreeParser + oldTreeIter.reset(reader, git.getRepository.resolve(from + "^{tree}")) + + val newTreeIter = new CanonicalTreeParser + newTreeIter.reset(reader, git.getRepository.resolve(to + "^{tree}")) + + import scala.collection.JavaConverters._ + git.diff.setNewTree(newTreeIter).setOldTree(oldTreeIter).call.asScala.map { diff => + if(!fetchContent || FileUtil.isImage(diff.getOldPath) || FileUtil.isImage(diff.getNewPath)){ + DiffInfo(diff.getChangeType, diff.getOldPath, diff.getNewPath, None, None) + } else { + DiffInfo(diff.getChangeType, diff.getOldPath, diff.getNewPath, + JGitUtil.getContent(git, diff.getOldId.toObjectId, false).filter(FileUtil.isText).map(convertFromByteArray), + JGitUtil.getContent(git, diff.getNewId.toObjectId, false).filter(FileUtil.isText).map(convertFromByteArray)) + } + }.toList + } + + /** * Returns the list of branch names of the specified commit. */ - def getBranchesOfCommit(git: Git, commitId: String): List[String] = { - val walk = new org.eclipse.jgit.revwalk.RevWalk(git.getRepository) - try { - val commit = walk.parseCommit(git.getRepository.resolve(commitId + "^0")) - - git.getRepository.getAllRefs.entrySet.asScala.filter { e => - (e.getKey.startsWith(Constants.R_HEADS) && walk.isMergedInto(commit, walk.parseCommit(e.getValue.getObjectId))) - }.map { e => - e.getValue.getName.substring(org.eclipse.jgit.lib.Constants.R_HEADS.length) - }.toList.sorted - - } finally { - walk.release + def getBranchesOfCommit(git: Git, commitId: String): List[String] = + using(new RevWalk(git.getRepository)){ revWalk => + defining(revWalk.parseCommit(git.getRepository.resolve(commitId + "^0"))){ commit => + git.getRepository.getAllRefs.entrySet.asScala.filter { e => + (e.getKey.startsWith(Constants.R_HEADS) && revWalk.isMergedInto(commit, revWalk.parseCommit(e.getValue.getObjectId))) + }.map { e => + e.getValue.getName.substring(org.eclipse.jgit.lib.Constants.R_HEADS.length) + }.toList.sorted + } } - } /** * Returns the list of tags of the specified commit. */ - def getTagsOfCommit(git: Git, commitId: String): List[String] = { - val walk = new org.eclipse.jgit.revwalk.RevWalk(git.getRepository) - try { - val commit = walk.parseCommit(git.getRepository.resolve(commitId + "^0")) - - git.getRepository.getAllRefs.entrySet.asScala.filter { e => - (e.getKey.startsWith(Constants.R_TAGS) && walk.isMergedInto(commit, walk.parseCommit(e.getValue.getObjectId))) - }.map { e => - e.getValue.getName.substring(org.eclipse.jgit.lib.Constants.R_TAGS.length) - }.toList.sorted.reverse - - } finally { - walk.release + def getTagsOfCommit(git: Git, commitId: String): List[String] = + using(new RevWalk(git.getRepository)){ revWalk => + defining(revWalk.parseCommit(git.getRepository.resolve(commitId + "^0"))){ commit => + git.getRepository.getAllRefs.entrySet.asScala.filter { e => + (e.getKey.startsWith(Constants.R_TAGS) && revWalk.isMergedInto(commit, revWalk.parseCommit(e.getValue.getObjectId))) + }.map { e => + e.getValue.getName.substring(org.eclipse.jgit.lib.Constants.R_TAGS.length) + }.toList.sorted.reverse + } } - } - def initRepository(dir: java.io.File): Unit = { - val repository = new RepositoryBuilder().setGitDir(dir).setBare.build - try { + def initRepository(dir: java.io.File): Unit = + using(new RepositoryBuilder().setGitDir(dir).setBare.build){ repository => repository.create setReceivePack(repository) - } finally { - repository.close } + + def cloneRepository(from: java.io.File, to: java.io.File): Unit = + using(Git.cloneRepository.setURI(from.toURI.toString).setDirectory(to).setBare(true).call){ git => + setReceivePack(git.getRepository) + } + + def isEmpty(git: Git): Boolean = git.getRepository.resolve(Constants.HEAD) == null + + private def setReceivePack(repository: org.eclipse.jgit.lib.Repository): Unit = + defining(repository.getConfig){ config => + config.setBoolean("http", null, "receivepack", true) + config.save + } + + def getDefaultBranch(git: Git, repository: RepositoryService.RepositoryInfo, + revstr: String = ""): Option[(ObjectId, String)] = { + Seq( + Some(if(revstr.isEmpty) repository.repository.defaultBranch else revstr), + repository.branchList.headOption + ).flatMap { + case Some(rev) => Some((git.getRepository.resolve(rev), rev)) + case None => None + }.find(_._1 != null) } - private def setReceivePack(repository: org.eclipse.jgit.lib.Repository): Unit = { - val config = repository.getConfig - config.setBoolean("http", null, "receivepack", true) - config.save - } - -} \ No newline at end of file +} diff --git a/src/main/scala/util/Keys.scala b/src/main/scala/util/Keys.scala new file mode 100644 index 0000000..4aabe35 --- /dev/null +++ b/src/main/scala/util/Keys.scala @@ -0,0 +1,72 @@ +package util + +/** + * Define key strings for request attributes, session attributes or flash attributes. + */ +object Keys { + + /** + * Define session keys. + */ + object Session { + + /** + * Session key for the logged in account information. + */ + val LoginAccount = "LOGIN_ACCOUNT" + + /** + * Session key for the redirect URL. + */ + val Redirect = "REDIRECT" + + /** + * Session key for the issue search condition in dashboard. + */ + val DashboardIssues = "dashboard/issues" + + /** + * Session key for the pull request search condition in dashboard. + */ + val DashboardPulls = "dashboard/pulls" + + /** + * Generate session key for the issue search condition. + */ + def Issues(owner: String, name: String) = s"${owner}/${name}/issues" + + /** + * Generate session key for the pull request search condition. + */ + def Pulls(owner: String, name: String) = s"${owner}/${name}/pulls" + + /** + * Generate session key for the upload filename. + */ + def Upload(fileId: String) = s"upload_${fileId}" + + } + + /** + * Define request keys. + */ + object Request { + + /** + * Request key for the Ajax request flag. + */ + val Ajax = "AJAX" + + /** + * Request key for the username which is used during Git repository access. + */ + val UserName = "USER_NAME" + + /** + * Generate request key for the request cache. + */ + def Cache(key: String) = s"cache.${key}" + + } + +} diff --git a/src/main/scala/util/LDAPUtil.scala b/src/main/scala/util/LDAPUtil.scala new file mode 100644 index 0000000..4c6347c --- /dev/null +++ b/src/main/scala/util/LDAPUtil.scala @@ -0,0 +1,104 @@ +package util + +import util.ControlUtil._ +import service.SystemSettingsService +import com.novell.ldap._ +import service.SystemSettingsService.Ldap +import scala.annotation.tailrec + +/** + * Utility for LDAP authentication. + */ +object LDAPUtil { + + private val LDAP_VERSION: Int = 3 + + /** + * Try authentication by LDAP using given configuration. + * Returns Right(mailAddress) if authentication is successful, otherwise Left(errorMessage). + */ + def authenticate(ldapSettings: Ldap, userName: String, password: String): Either[String, String] = { + bind( + ldapSettings.host, + ldapSettings.port.getOrElse(SystemSettingsService.DefaultLdapPort), + ldapSettings.bindDN.getOrElse(""), + ldapSettings.bindPassword.getOrElse("") + ) match { + case Some(conn) => { + withConnection(conn) { conn => + findUser(conn, userName, ldapSettings.baseDN, ldapSettings.userNameAttribute) match { + case Some(userDN) => userAuthentication(ldapSettings, userDN, password) + case None => Left("User does not exist.") + } + } + } + case None => Left("System LDAP authentication failed.") + } + } + + private def userAuthentication(ldapSettings: Ldap, userDN: String, password: String): Either[String, String] = { + bind( + ldapSettings.host, + ldapSettings.port.getOrElse(SystemSettingsService.DefaultLdapPort), + userDN, + password + ) match { + case Some(conn) => { + withConnection(conn) { conn => + findMailAddress(conn, userDN, ldapSettings.mailAttribute) match { + case Some(mailAddress) => Right(mailAddress) + case None => Left("Can't find mail address.") + } + } + } + case None => Left("User LDAP Authentication Failed.") + } + } + + private def bind(host: String, port: Int, dn: String, password: String): Option[LDAPConnection] = { + val conn: LDAPConnection = new LDAPConnection + try { + conn.connect(host, port) + conn.bind(LDAP_VERSION, dn, password.getBytes) + Some(conn) + } catch { + case e: Exception => { + if (conn.isConnected) conn.disconnect() + None + } + } + } + + private def withConnection[T](conn: LDAPConnection)(f: LDAPConnection => T): T = { + try { + f(conn) + } finally { + conn.disconnect() + } + } + + private def findUser(conn: LDAPConnection, userName: String, baseDN: String, userNameAttribute: String): Option[String] = { + @tailrec + def getEntries(results: LDAPSearchResults, entries: List[Option[LDAPEntry]] = Nil): List[LDAPEntry] = { + if(results.hasMore){ + getEntries(results, entries :+ (try { + Option(results.next) + } catch { + case ex: LDAPReferralException => None // NOTE(tanacasino): Referral follow is off. so ignores it.(for AD) + })) + } else { + entries.flatten + } + } + getEntries(conn.search(baseDN, LDAPConnection.SCOPE_SUB, userNameAttribute + "=" + userName, null, false)).collectFirst { + case x => x.getDN + } + } + + private def findMailAddress(conn: LDAPConnection, userDN: String, mailAttribute: String): Option[String] = + defining(conn.search(userDN, LDAPConnection.SCOPE_BASE, null, Array[String](mailAttribute), false)){ results => + optionIf (results.hasMore) { + Option(results.next.getAttribute(mailAttribute)).map(_.getStringValue) + } + } +} diff --git a/src/main/scala/util/LockUtil.scala b/src/main/scala/util/LockUtil.scala new file mode 100644 index 0000000..267b28b --- /dev/null +++ b/src/main/scala/util/LockUtil.scala @@ -0,0 +1,36 @@ +package util + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.locks.{ReentrantLock, Lock} +import util.ControlUtil._ + +object LockUtil { + + /** + * lock objects + */ + private val locks = new ConcurrentHashMap[String, Lock]() + + /** + * Returns the lock object for the specified repository. + */ + private def getLockObject(key: String): Lock = synchronized { + if(!locks.containsKey(key)){ + locks.put(key, new ReentrantLock()) + } + locks.get(key) + } + + /** + * Synchronizes a given function which modifies the working copy of the wiki repository. + */ + def lock[T](key: String)(f: => T): T = defining(getLockObject(key)){ lock => + try { + lock.lock() + f + } finally { + lock.unlock() + } + } + +} diff --git a/src/main/scala/util/Notifier.scala b/src/main/scala/util/Notifier.scala new file mode 100644 index 0000000..4fc4652 --- /dev/null +++ b/src/main/scala/util/Notifier.scala @@ -0,0 +1,111 @@ +package util + +import scala.concurrent._ +import ExecutionContext.Implicits.global +import org.apache.commons.mail.{DefaultAuthenticator, HtmlEmail} +import org.slf4j.LoggerFactory + +import app.Context +import service.{AccountService, RepositoryService, IssuesService, SystemSettingsService} +import servlet.Database +import SystemSettingsService.Smtp + +trait Notifier extends RepositoryService with AccountService with IssuesService { + def toNotify(r: RepositoryService.RepositoryInfo, issueId: Int, content: String) + (msg: String => String)(implicit context: Context): Unit + + protected def recipients(issue: model.Issue)(notify: String => Unit)(implicit context: Context) = + ( + // individual repository's owner + issue.userName :: + // collaborators + getCollaborators(issue.userName, issue.repositoryName) ::: + // participants + issue.openedUserName :: + getComments(issue.userName, issue.repositoryName, issue.issueId).map(_.commentedUserName) + ) + .distinct + .withFilter ( _ != context.loginAccount.get.userName ) // the operation in person is excluded + .foreach ( getAccountByUserName(_) filterNot (_.isGroupAccount) foreach (x => notify(x.mailAddress)) ) + +} + +object Notifier { + // TODO We want to be able to switch to mock. + def apply(): Notifier = new SystemSettingsService {}.loadSystemSettings match { + case settings if settings.notification => new Mailer(settings.smtp.get) + case _ => new MockMailer + } + + def msgIssue(url: String) = (content: String) => s""" + |${content}
+ |--
+ |View it on GitBucket + """.stripMargin + + def msgPullRequest(url: String) = (content: String) => s""" + |${content}
+ |View, comment on, or merge it at:
+ |${url} + """.stripMargin + + def msgComment(url: String) = (content: String) => s""" + |${content}
+ |--
+ |View it on GitBucket + """.stripMargin + + def msgStatus(url: String) = (content: String) => s""" + |${content} #${url split('/') last} + """.stripMargin +} + +class Mailer(private val smtp: Smtp) extends Notifier { + private val logger = LoggerFactory.getLogger(classOf[Mailer]) + + def toNotify(r: RepositoryService.RepositoryInfo, issueId: Int, content: String) + (msg: String => String)(implicit context: Context) = { + val database = Database(context.request.getServletContext) + + val f = future { + val email = new HtmlEmail + email.setHostName(smtp.host) + email.setSmtpPort(smtp.port.get) + smtp.user.foreach { user => + email.setAuthenticator(new DefaultAuthenticator(user, smtp.password.getOrElse(""))) + } + smtp.ssl.foreach { ssl => + email.setSSLOnConnect(ssl) + } + smtp.fromAddress + .map (_ -> smtp.fromName.orNull) + .orElse (Some("notifications@gitbucket.com" -> context.loginAccount.get.userName)) + .foreach { case (address, name) => + email.setFrom(address, name) + } + email.setHtmlMsg(msg(view.Markdown.toHtml(content, r, false, true))) + + // TODO Can we use the Database Session in other than Transaction Filter? + database withSession { + getIssue(r.owner, r.name, issueId.toString) foreach { issue => + email.setSubject(s"[${r.name}] ${issue.title} (#${issueId})") + recipients(issue) { + email.getToAddresses.clear + email.addTo(_).send + } + } + } + "Notifications Successful." + } + f onSuccess { + case s => logger.debug(s) + } + f onFailure { + case t => logger.error("Notifications Failed.", t) + } + } +} +class MockMailer extends Notifier { + def toNotify(r: RepositoryService.RepositoryInfo, issueId: Int, content: String) + (msg: String => String)(implicit context: Context): Unit = {} +} \ No newline at end of file diff --git a/src/main/scala/util/StringUtil.scala b/src/main/scala/util/StringUtil.scala index ae6d868..61c5c18 100644 --- a/src/main/scala/util/StringUtil.scala +++ b/src/main/scala/util/StringUtil.scala @@ -1,14 +1,16 @@ package util import java.net.{URLDecoder, URLEncoder} +import org.mozilla.universalchardet.UniversalDetector +import util.ControlUtil._ object StringUtil { - def sha1(value: String): String = { - val md = java.security.MessageDigest.getInstance("SHA-1") - md.update(value.getBytes) - md.digest.map(b => "%02x".format(b)).mkString - } + def sha1(value: String): String = + defining(java.security.MessageDigest.getInstance("SHA-1")){ md => + md.update(value.getBytes) + md.digest.map(b => "%02x".format(b)).mkString + } def md5(value: String): String = { val md = java.security.MessageDigest.getInstance("MD5") @@ -20,4 +22,20 @@ 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("\"", """) + + def convertFromByteArray(content: Array[Byte]): String = new String(content, detectEncoding(content)) + + def detectEncoding(content: Array[Byte]): String = + defining(new UniversalDetector(null)){ detector => + detector.handleData(content, 0, content.length) + detector.dataEnd() + detector.getDetectedCharset match { + case null => "UTF-8" + case e => e + } + } } diff --git a/src/main/scala/util/Validations.scala b/src/main/scala/util/Validations.scala index 1d42d99..3b71280 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 { @@ -9,7 +8,7 @@ * Constraint for the identifier such as user name, repository name or page name. */ def identifier: Constraint = new Constraint(){ - def validate(name: String, value: String): Option[String] = + override def validate(name: String, value: String): Option[String] = if(!value.matches("^[a-zA-Z0-9\\-_]+$")){ Some(s"${name} contains invalid character.") } else if(value.startsWith("_") || value.startsWith("-")){ @@ -26,10 +25,7 @@ */ def date(constraints: Constraint*): SingleValueType[java.util.Date] = new SingleValueType[java.util.Date]((pattern("\\d{4}-\\d{2}-\\d{2}") +: constraints): _*){ - def convert(value: String): java.util.Date = { - val formatter = new java.text.SimpleDateFormat("yyyy-MM-dd") - formatter.parse(value) - } + def convert(value: String): java.util.Date = new java.text.SimpleDateFormat("yyyy-MM-dd").parse(value) } } diff --git a/src/main/scala/view/AvatarImageProvider.scala b/src/main/scala/view/AvatarImageProvider.scala index c2c7fa2..c5ae04e 100644 --- a/src/main/scala/view/AvatarImageProvider.scala +++ b/src/main/scala/view/AvatarImageProvider.scala @@ -12,17 +12,23 @@ */ 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}""" + + val src = getAccountByUserName(userName).map { account => + if(account.image.isEmpty && getSystemSettings().gravatar){ + s"""http://www.gravatar.com/avatar/${StringUtil.md5(account.mailAddress)}?s=${size}""" + } else { + s"""${context.path}/${userName}/_avatar""" + } } getOrElse { - if(mailAddress.nonEmpty){ + if(mailAddress.nonEmpty && getSystemSettings().gravatar){ 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"""""") } diff --git a/src/main/scala/view/Markdown.scala b/src/main/scala/view/Markdown.scala index 32cd513..90da6da 100644 --- a/src/main/scala/view/Markdown.scala +++ b/src/main/scala/view/Markdown.scala @@ -1,12 +1,14 @@ package view import util.StringUtil +import util.ControlUtil._ +import util.Directory._ import org.parboiled.common.StringUtils import org.pegdown._ import org.pegdown.ast._ import org.pegdown.LinkRenderer.Rendering import scala.collection.JavaConverters._ -import service.RequestCache +import service.{RequestCache, WikiService} object Markdown { @@ -29,7 +31,7 @@ } class GitBucketLinkRender(context: app.Context, repository: service.RepositoryService.RepositoryInfo, - enableWikiLink: Boolean) extends LinkRenderer { + enableWikiLink: Boolean) extends LinkRenderer with WikiService { override def render(node: WikiLinkNode): Rendering = { if(enableWikiLink){ try { @@ -40,8 +42,14 @@ } else { (text, text) } + val url = repository.url.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/wiki/" + StringUtil.urlEncode(page) - new Rendering(url, label) + + if(getWikiPage(repository.owner, repository.name, page).isDefined){ + new Rendering(url, label) + } else { + new Rendering(url, label).withAttribute("class", "absent") + } } catch { case e: java.io.UnsupportedEncodingException => throw new IllegalStateException } @@ -52,7 +60,7 @@ } class GitBucketVerbatimSerializer extends VerbatimSerializer { - def serialize(node: VerbatimNode, printer: Printer) { + def serialize(node: VerbatimNode, printer: Printer): Unit = { printer.println.print(" length){ + value.substring(0, length) + "..." + } else { + value + } + + import scala.util.matching.Regex + import scala.util.matching.Regex._ + implicit class RegexReplaceString(s: String) { + def replaceAll(pattern: String, replacer: (Match) => String): String = { + pattern.r.replaceAllIn(s, replacer) + } + } + def activityMessage(message: String)(implicit context: app.Context): Html = Html(message .replaceAll("\\[issue:([^\\s]+?)/([^\\s]+?)#((\\d+))\\]" , s"""$$1/$$2#$$3""") + .replaceAll("\\[pullreq:([^\\s]+?)/([^\\s]+?)#((\\d+))\\]" , s"""$$1/$$2#$$3""") .replaceAll("\\[repo:([^\\s]+?)/([^\\s]+?)\\]" , s"""$$1/$$2""") - .replaceAll("\\[branch:([^\\s]+?)/([^\\s]+?)#([^\\s]+?)\\]", s"""$$3""") - .replaceAll("\\[tag:([^\\s]+?)/([^\\s]+?)#([^\\s]+?)\\]" , s"""$$3""") + .replaceAll("\\[branch:([^\\s]+?)/([^\\s]+?)#([^\\s]+?)\\]", (m: Match) => s"""${m.group(3)}""") + .replaceAll("\\[tag:([^\\s]+?)/([^\\s]+?)#([^\\s]+?)\\]" , (m: Match) => s"""${m.group(3)}""") .replaceAll("\\[user:([^\\s]+?)\\]" , s"""$$1""") ) + /** + * URL encode except '/'. + */ + def encodeRefName(value: String): String = StringUtil.urlEncode(value).replace("%2F", "/") + def urlEncode(value: String): String = StringUtil.urlEncode(value) def urlEncode(value: Option[String]): String = value.map(urlEncode).getOrElse("") @@ -73,14 +93,12 @@ /** * Generates the url to the account page. */ - def url(userName: String)(implicit context: app.Context): String = - s"${context.path}/${userName}" + def url(userName: String)(implicit context: app.Context): String = s"${context.path}/${userName}" /** * Returns the url to the root of assets. */ - def assets(implicit context: app.Context): String = - s"${context.path}/assets" + def assets(implicit context: app.Context): String = s"${context.path}/assets" /** * Generates the link to the account page. @@ -91,6 +109,8 @@ } getOrElse Html(userName) } + 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
-
-
- -
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/edit.scala.html b/src/main/twirl/account/edit.scala.html index 2eeb22a..8d8ca9a 100644 --- a/src/main/twirl/account/edit.scala.html +++ b/src/main/twirl/account/edit.scala.html @@ -13,34 +13,43 @@
@if(account.isEmpty){
- +
} + @if(account.map(_.password.nonEmpty).getOrElse(true)){ +
+ + + +
+ }
- - - + + +
- +
- +
- + @helper.html.uploadavatar(account)
diff --git a/src/main/twirl/account/main.scala.html b/src/main/twirl/account/main.scala.html new file mode 100644 index 0000000..0b6cc6e --- /dev/null +++ b/src/main/twirl/account/main.scala.html @@ -0,0 +1,49 @@ +@(account: model.Account, groupNames: List[String], active: String)(body: Html)(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.main(account.userName){ +
+
+
+
+ + + +
+
+ @if(account.url.isDefined){ + + } +
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 9a2ce34..f9037f5 100644 --- a/src/main/twirl/account/repositories.scala.html +++ b/src/main/twirl/account/repositories.scala.html @@ -1,42 +1,26 @@ -@(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
-
-
- -
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.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.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/system.scala.html b/src/main/twirl/admin/system.scala.html index 75a07b6..361138d 100644 --- a/src/main/twirl/admin/system.scala.html +++ b/src/main/twirl/admin/system.scala.html @@ -8,17 +8,151 @@
    System Settings
    - + + + +
    -
    + + + +
    + +
    + +
    + + + +
    + +
    + +
    +
    +
    + +
    + + +
    +
    +
    + +
    + + +
    +
    +
    + +
    + + +
    +
    +
    + +
    + + +
    +
    +
    + +
    + + +
    +
    +
    + +
    + + +
    +
    +
    + +
    + + +
    +
    +
    + + + +
    + +
    + +
    +
    +
    + +
    + + +
    +
    +
    + +
    + + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    @@ -26,4 +160,15 @@
    } -} \ No newline at end of file +} + \ No newline at end of file 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..240622c --- /dev/null +++ b/src/main/twirl/admin/users/group.scala.html @@ -0,0 +1,116 @@ +@(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) +
    +
    +
    +
    + + + @helper.html.account("memberName", 200) + + +
    + +
    +
    +
    +
    +
    + + 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..b49d710 --- /dev/null +++ b/src/main/twirl/admin/users/user.scala.html @@ -0,0 +1,74 @@ +@(account: Option[model.Account])(implicit context: app.Context) +@import context._ +@html.main(if(account.isEmpty) "New User" else "Update User"){ + @admin.html.menu("users"){ + +
    +
    +
    + +
    + +
    + +
    + @if(account.map(_.password.nonEmpty).getOrElse(true)){ +
    + +
    + +
    + +
    + } +
    + +
    + +
    + +
    +
    + +
    + +
    + +
    +
    + + + +
    +
    + +
    + +
    + +
    +
    +
    +
    + + @helper.html.uploadavatar(account) +
    +
    +
    +
    + + Cancel +
    + + } +} diff --git a/src/main/twirl/dashboard/issues.scala.html b/src/main/twirl/dashboard/issues.scala.html new file mode 100644 index 0000000..9ebdede --- /dev/null +++ b/src/main/twirl/dashboard/issues.scala.html @@ -0,0 +1,48 @@ +@(listparts: twirl.api.Html, + allCount: Int, + assignedCount: Int, + createdByCount: Int, + repositories: List[(String, String, Int)], + condition: service.IssuesService.IssueSearchCondition, + filter: String)(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.main("Your Issues"){ +@dashboard.html.tab("issues") +
    + + @listparts +
    +} diff --git a/src/main/twirl/dashboard/pulls.scala.html b/src/main/twirl/dashboard/pulls.scala.html new file mode 100644 index 0000000..4ffc18f --- /dev/null +++ b/src/main/twirl/dashboard/pulls.scala.html @@ -0,0 +1,40 @@ +@(listparts: twirl.api.Html, + counts: List[service.PullRequestService.PullRequestCount], + repositories: List[(String, String, Int)], + condition: service.IssuesService.IssueSearchCondition, + filter: String)(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.main("Your Issues"){ +@dashboard.html.tab("pulls") +
    + + @listparts +
    +} diff --git a/src/main/twirl/dashboard/tab.scala.html b/src/main/twirl/dashboard/tab.scala.html new file mode 100644 index 0000000..21e4d06 --- /dev/null +++ b/src/main/twirl/dashboard/tab.scala.html @@ -0,0 +1,9 @@ +@(active: String = "")(implicit context: app.Context) +@import context._ + diff --git a/src/main/twirl/header.scala.html b/src/main/twirl/header.scala.html index 45b939d..b132711 100644 --- a/src/main/twirl/header.scala.html +++ b/src/main/twirl/header.scala.html @@ -1,10 +1,30 @@ @(active: String, repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context) @import context._ @import view.helpers._ + +@if(repository.commitCount > 0){ +
    + +
    +}
    - @repository.owner / @repository.name @if(repository.repository.isPrivate){ - + + } + @if(!repository.repository.isPrivate){ + + } + @repository.owner / @repository.name + + @defining(repository.repository){ x => + @if(repository.repository.originRepositoryName.isDefined){ + + } }
    - 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) + }
    @@ -14,16 +34,28 @@ + + @if(loginAccount.isDefined && (loginAccount.get.isAdmin || loginAccount.get.userName == repository.owner)){ } - + diff --git a/src/main/twirl/helper/activities.scala.html b/src/main/twirl/helper/activities.scala.html index 6b1662c..15c8cdc 100644 --- a/src/main/twirl/helper/activities.scala.html +++ b/src/main/twirl/helper/activities.scala.html @@ -1,43 +1,96 @@ @(activities: List[model.Activity])(implicit context: app.Context) @import context._ @import view.helpers._ + @if(activities.isEmpty){ No activity } else { @activities.map { activity =>
    -
    @datetime(activity.activityDate)
    -
    - @avatar(activity.activityUserName, 16) - @activityMessage(activity.message) -
    - @activity.additionalInfo.map { additionalInfo => - @(activity.activityType match { - case "create_wiki" => { - - } - case "edit_wiki" => { - - } - case "push" => { -
    - {additionalInfo.split("\n").reverse.take(4).zipWithIndex.map{ case (commit, i) => - if(i == 3){ -
    ...
    - } else { + @(activity.activityType match { + case "open_issue" => detailActivity(activity, "activity-issue.png") + case "comment_issue" => detailActivity(activity, "activity-comment.png") + case "close_issue" => detailActivity(activity, "activity-issue-close.png") + case "reopen_issue" => detailActivity(activity, "activity-issue-reopen.png") + case "open_pullreq" => detailActivity(activity, "activity-merge.png") + case "merge_pullreq" => detailActivity(activity, "activity-merge.png") + case "create_repository" => simpleActivity(activity, "activity-create-repository.png") + case "create_branch" => simpleActivity(activity, "activity-branch.png") + case "create_tag" => simpleActivity(activity, "activity-tag.png") + case "fork" => simpleActivity(activity, "activity-fork.png") + case "push" => customActivity(activity, "activity-commit.png"){ +
    + {activity.additionalInfo.get.split("\n").reverse.take(4).zipWithIndex.map{ case (commit, i) => + if(i == 3){ +
    ...
    + } else { + if(commit.nonEmpty){
    - {commit.substring(0, 7)} - {commit.substring(41)} + {commit.substring(0, 7)} + {commit.substring(41)}
    } - }} -
    + } + }} +
    + } + case "create_wiki" => customActivity(activity, "activity-wiki.png"){ + + } + case "edit_wiki" => customActivity(activity, "activity-wiki.png"){ + activity.additionalInfo.get.split(":") match { + case Array(pageName, commitId) => + + case Array(pageName) => +
    + Edited {pageName}. +
    } - case _ => { -
    {additionalInfo}
    - } - }) - } + } + })
    } } + +@detailActivity(activity: model.Activity, image: String) = { +
    +
    +
    @datetime(activity.activityDate)
    +
    + @avatar(activity.activityUserName, 16) + @activityMessage(activity.message) +
    + @activity.additionalInfo.map { additionalInfo => +
    @additionalInfo
    + } +
    +} + +@customActivity(activity: model.Activity, image: String)(additionalInfo: Any) = { +
    +
    +
    @datetime(activity.activityDate)
    +
    + @avatar(activity.activityUserName, 16) + @activityMessage(activity.message) +
    + @additionalInfo +
    +} + +@simpleActivity(activity: model.Activity, image: String) = { +
    +
    +
    + @avatar(activity.activityUserName, 16) + @activityMessage(activity.message) + @datetime(activity.activityDate) +
    +
    +} + diff --git a/src/main/twirl/helper/copy.scala.html b/src/main/twirl/helper/copy.scala.html new file mode 100644 index 0000000..1cba6e6 --- /dev/null +++ b/src/main/twirl/helper/copy.scala.html @@ -0,0 +1,36 @@ +@(id: String, value: String)(html: Html) +
    + @html + +
    + \ No newline at end of file diff --git a/src/main/twirl/helper/diff.scala.html b/src/main/twirl/helper/diff.scala.html index 0360230..9e517ab 100644 --- a/src/main/twirl/helper/diff.scala.html +++ b/src/main/twirl/helper/diff.scala.html @@ -1,7 +1,39 @@ -@(diffs: Seq[util.JGitUtil.DiffInfo], repository: service.RepositoryService.RepositoryInfo, commitId: Option[String])(implicit context: app.Context) +@(diffs: Seq[util.JGitUtil.DiffInfo], + repository: service.RepositoryService.RepositoryInfo, + newCommitId: Option[String], + oldCommitId: Option[String], + showIndex: Boolean)(implicit context: app.Context) @import context._ @import view.helpers._ @import org.eclipse.jgit.diff.DiffEntry.ChangeType +@if(showIndex){ +
    +
    + +
    + Showing @diffs.size changed @plural(diffs.size, "file") +
    + +} @diffs.zipWithIndex.map { case (diff, i) => @@ -9,17 +41,27 @@ @@ -90,6 +132,17 @@ } $(function(){ + @if(showIndex){ + $('#toggle-file-list').click(function(){ + $('#commit-file-list').toggle(); + if($(this).val() == 'Show file list'){ + $(this).val('Hide file list'); + } else { + $(this).val('Show file list'); + } + }); + } + @diffs.zipWithIndex.map { case (diff, i) => @if(diff.newContent != None || diff.oldContent != None){ if($('#oldText-@i').length > 0){ diff --git a/src/main/twirl/helper/dropdown.scala.html b/src/main/twirl/helper/dropdown.scala.html index 6c86e96..f7479a8 100644 --- a/src/main/twirl/helper/dropdown.scala.html +++ b/src/main/twirl/helper/dropdown.scala.html @@ -1,10 +1,13 @@ -@(buttonValue: String = "")(body: Html) -
    - 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/helper/preview.scala.html b/src/main/twirl/helper/preview.scala.html index 9c1a607..cc6504b 100644 --- a/src/main/twirl/helper/preview.scala.html +++ b/src/main/twirl/helper/preview.scala.html @@ -1,5 +1,5 @@ @(repository: service.RepositoryService.RepositoryInfo, content: String, enableWikiLink: Boolean, enableRefsLink: Boolean, - style: String = "", placeholder: String = "Leave a comment")(implicit context: app.Context) + style: String = "", placeholder: String = "Leave a comment", elastic: Boolean = false)(implicit context: app.Context) @import context._ @import view.helpers._
    @@ -27,6 +27,10 @@ +}
    @if(diff.changeType == ChangeType.COPY || diff.changeType == ChangeType.RENAME){ @diff.oldPath -> @diff.newPath + @if(newCommitId.isDefined){ + + } } @if(diff.changeType == ChangeType.ADD || diff.changeType == ChangeType.MODIFY){ @diff.newPath + @if(newCommitId.isDefined){ + + } } @if(diff.changeType == ChangeType.DELETE){ @diff.oldPath - } - @if(commitId.isDefined){ - + @if(oldCommitId.isDefined){ + + } }