diff --git a/CHANGELOG.md b/CHANGELOG.md index cfefea3a22..c11e75ced2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ This changelog goes through all the changes that have been made in each release without substantial changes to our git log; to see the highlights of what has been added to each release, please refer to the [blog](https://blog.gitea.io). +## [1.10.2](https://github.com/go-gitea/gitea/releases/tag/v1.10.2) - 2020-01-02 +* BUGFIXES + * Allow only specific Columns to be updated on Issue via API (#9539) (#9580) + * Add ErrReactionAlreadyExist error (#9550) (#9564) + * Fix bug when migrate from API (#8631) (#9563) + * Use default avatar for ghost user (#9536) (#9537) + * Fix repository issues pagination bug when there are more than one label filter (#9512) (#9528) + * Fix deleted branch not removed when push the branch again (#9516) (#9524) + * Fix missing repository status when migrating repository via API (#9511) + * Trigger webhook when deleting a branch after merging a PR (#9510) + * Fix paging on /repos/{owner}/{repo}/git/trees/{sha} API endpoint (#9482) + * Fix NewCommitStatus (#9434) (#9435) + * Use OriginalURL instead of CloneAddr in migration logging (#9418) (#9420) + * Fix Slack webhook payload title generation to work with Mattermost (#9404) + * DefaultBranch needs to be prefixed by BranchPrefix (#9356) (#9359) + * Fix issue indexer not triggered when migrating a repository (#9333) + * Fix bug that release attachment files not deleted when deleting repository (#9322) (#9329) + * Fix migration releases (#9319) (#9326) (#9328) + * Fix File Edit: Author/Committer interchanged (#9297) (#9300) + ## [1.10.1](https://github.com/go-gitea/gitea/releases/tag/v1.10.1) - 2019-12-05 * BUGFIXES * Fix max length check and limit in multiple repo forms (#9148) (#9204) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a0134974ac..2172aeec24 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,7 @@ - [Code review](#code-review) - [Styleguide](#styleguide) - [Design guideline](#design-guideline) + - [API v1](#api-v1) - [Developer Certificate of Origin (DCO)](#developer-certificate-of-origin-dco) - [Release Cycle](#release-cycle) - [Maintainers](#maintainers) @@ -177,6 +178,43 @@ To maintain understandable code and avoid circular dependencies it is important - **templates:** Golang templates for generating the html output. - **vendor:** External code that Gitea depends on. +## API v1 + +The API is documented by [swagger](http://try.gitea.io/api/swagger) and is based on [GitHub API v3](https://developer.github.com/v3/). +Thus, Gitea´s API should use the same endpoints and fields as GitHub´s API as far as possible, unless there are good reasons to deviate. +If Gitea provides functionality that GitHub does not, a new endpoint can be created. +If information is provided by Gitea that is not provided by the GitHub API, a new field can be used that doesn't collide with any GitHub fields. + +Updating an existing API should not remove existing fields unless there is a really good reason to do so. +The same applies to status responses. If you notice a problem, feel free to leave a comment in the code for future refactoring to APIv2 (which is currently not planned). + +All expected results (errors, success, fail messages) should be documented +([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/repo/issue.go#L319-L327)). + +All JSON input types must be defined as a struct in `models/structs/` +([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/modules/structs/issue.go#L76-L91)) +and referenced in +[routers/api/v1/swagger/options.go](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/swagger/options.go). +They can then be used like the following: +([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/repo/issue.go#L318)). + +All JSON responses must be defined as a struct in `models/structs/` +([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/modules/structs/issue.go#L36-L68)) +and referenced in its category in `routers/api/v1/swagger/` +([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/swagger/issue.go#L11-L16)) +They can be used like the following: +([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/repo/issue.go#L277-L279)) + +In general, HTTP methods are chosen as follows: + * **GET** endpoints return requested object and status **OK (200)** + * **DELETE** endpoints return status **No Content (204)** + * **POST** endpoints return status **Created (201)**, used to **create** new objects (e.g. a User) + * **PUT** endpoints return status **No Content (204)**, used to **add/assign** existing Obejcts (e.g. User) to something (e.g. Org-Team) + * **PATCH** endpoints return changed object and status **OK (200)**, used to **edit/change** an existing object + + +An endpoint which changes/edits an object expects all fields to be optional (except ones to identify the object, which are required). + ## Developer Certificate of Origin (DCO) diff --git a/custom/conf/app.ini.sample b/custom/conf/app.ini.sample index b86ace3b94..ca63322b31 100644 --- a/custom/conf/app.ini.sample +++ b/custom/conf/app.ini.sample @@ -76,6 +76,16 @@ WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP] CLOSE_KEYWORDS=close,closes,closed,fix,fixes,fixed,resolve,resolves,resolved ; List of keywords used in Pull Request comments to automatically reopen a related issue REOPEN_KEYWORDS=reopen,reopens,reopened +; In the default merge message for squash commits include at most this many commits +DEFAULT_MERGE_MESSAGE_COMMITS_LIMIT=50 +; In the default merge message for squash commits limit the size of the commit messages to this +DEFAULT_MERGE_MESSAGE_SIZE=5120 +; In the default merge message for squash commits walk all commits to include all authors in the Co-authored-by otherwise just use those in the limited list +DEFAULT_MERGE_MESSAGE_ALL_AUTHORS=false +; In default merge messages limit the number of approvers listed as Reviewed-by: to this many +DEFAULT_MERGE_MESSAGE_MAX_APPROVERS=10 +; In default merge messages only include approvers who are official +DEFAULT_MERGE_MESSAGE_OFFICIAL_APPROVERS_ONLY=true [repository.issue] ; List of reasons why a Pull Request or Issue can be locked diff --git a/docs/config.yaml b/docs/config.yaml index a0a349a780..cbfcd49e6e 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -18,7 +18,7 @@ params: description: Git with a cup of tea author: The Gitea Authors website: https://docs.gitea.io - version: 1.10.1 + version: 1.10.2 outputs: home: diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md index b2cb81c5cd..0457eb38e3 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -77,6 +77,11 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`. keywords used in Pull Request comments to automatically close a related issue - `REOPEN_KEYWORDS`: **reopen**, **reopens**, **reopened**: List of keywords used in Pull Request comments to automatically reopen a related issue +- `DEFAULT_MERGE_MESSAGE_COMMITS_LIMIT`: **50**: In the default merge message for squash commits include at most this many commits. Set to `-1` to include all commits +- `DEFAULT_MERGE_MESSAGE_SIZE`: **5120**: In the default merge message for squash commits limit the size of the commit messages. Set to `-1` to have no limit. +- `DEFAULT_MERGE_MESSAGE_ALL_AUTHORS`: **false**: In the default merge message for squash commits walk all commits to include all authors in the Co-authored-by otherwise just use those in the limited list +- `DEFAULT_MERGE_MESSAGE_MAX_APPROVERS`: **10**: In default merge messages limit the number of approvers listed as `Reviewed-by:`. Set to `-1` to include all. +- `DEFAULT_MERGE_MESSAGE_OFFICIAL_APPROVERS_ONLY`: **true**: In default merge messages only include approvers who are officially allowed to review. ### Repository - Issue (`repository.issue`) diff --git a/docs/content/doc/advanced/customizing-gitea.en-us.md b/docs/content/doc/advanced/customizing-gitea.en-us.md index 8db022772d..7ae9b74fa2 100644 --- a/docs/content/doc/advanced/customizing-gitea.en-us.md +++ b/docs/content/doc/advanced/customizing-gitea.en-us.md @@ -80,10 +80,10 @@ Dont forget to restart your gitea to apply the changes. ### Adding links and tabs -If all you want is to add extra links to the top navigation bar, or extra tabs to the repository view, you can put them in `extra_links.tmpl` and `extra_tabs.tmpl` inside your `custom/templates/custom/` directory. +If all you want is to add extra links to the top navigation bar or footer, or extra tabs to the repository view, you can put them in `extra_links.tmpl` (links added to the navbar), `extra_links_footer.tmpl` (links added to the left side of footer), and `extra_tabs.tmpl` inside your `custom/templates/custom/` directory. For instance, let's say you are in Germany and must add the famously legally-required "Impressum"/about page, listing who is responsible for the site's content: -just place it under your "custom/public/" directory (for instance `custom/public/impressum.html`) and put a link to it in `custom/templates/custom/extra_links.tmpl`. +just place it under your "custom/public/" directory (for instance `custom/public/impressum.html`) and put a link to it in either `custom/templates/custom/extra_links.tmpl` or `custom/templates/custom/extra_links_footer.tmpl`. To match the current style, the link should have the class name "item", and you can use `{{AppSubUrl}}` to get the base URL: `Impressum` diff --git a/go.mod b/go.mod index 2f81c9b16a..5d22b82745 100644 --- a/go.mod +++ b/go.mod @@ -79,11 +79,9 @@ require ( github.com/prometheus/procfs v0.0.4 // indirect github.com/quasoft/websspi v1.0.0 github.com/remyoudompheng/bigfft v0.0.0-20190321074620-2f0d2b0e0001 // indirect - github.com/russross/blackfriday/v2 v2.0.1 github.com/satori/go.uuid v1.2.0 github.com/sergi/go-diff v1.0.0 github.com/shurcooL/httpfs v0.0.0-20190527155220-6a4d4a70508b // indirect - github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect github.com/shurcooL/vfsgen v0.0.0-20181202132449-6a9ea43bcacd github.com/steveyen/gtreap v0.0.0-20150807155958-0abe01ef9be2 // indirect github.com/stretchr/testify v1.4.0 @@ -95,6 +93,7 @@ require ( github.com/unknwon/paginater v0.0.0-20151104151617-7748a72e0141 github.com/urfave/cli v1.20.0 github.com/yohcop/openid-go v0.0.0-20160914080427-2c050d2dae53 + github.com/yuin/goldmark v1.1.19 go.etcd.io/bbolt v1.3.3 // indirect golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876 golang.org/x/net v0.0.0-20191101175033-0deb6923b6d9 diff --git a/go.sum b/go.sum index a6f65167f5..247630d47d 100644 --- a/go.sum +++ b/go.sum @@ -462,16 +462,12 @@ github.com/remyoudompheng/bigfft v0.0.0-20190321074620-2f0d2b0e0001/go.mod h1:qq github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= -github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/shurcooL/httpfs v0.0.0-20190527155220-6a4d4a70508b h1:4kg1wyftSKxLtnPAvcRWakIPpokB9w780/KwrNLnfPA= github.com/shurcooL/httpfs v0.0.0-20190527155220-6a4d4a70508b/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= -github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/vfsgen v0.0.0-20181202132449-6a9ea43bcacd h1:ug7PpSOB5RBPK1Kg6qskGBoP3Vnj/aNYFTznWvlkGo0= github.com/shurcooL/vfsgen v0.0.0-20181202132449-6a9ea43bcacd/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726/go.mod h1:3yhqj7WBBfRhbBlzyOC3gUxftwsU0u8gqevxwIHQpMw= @@ -550,6 +546,8 @@ github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/yohcop/openid-go v0.0.0-20160914080427-2c050d2dae53 h1:HsIQ6yAjfjQ3IxPGrTusxp6Qxn92gNVq2x5CbvQvx3w= github.com/yohcop/openid-go v0.0.0-20160914080427-2c050d2dae53/go.mod h1:f6elajwZV+xceiaqgRL090YzLEDGSbqr3poGL3ZgXYo= +github.com/yuin/goldmark v1.1.19 h1:0s2/60x0XsFCXHeFut+F3azDVAAyIMyUfJRbRexiTYs= +github.com/yuin/goldmark v1.1.19/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= diff --git a/integrations/api_issue_reaction_test.go b/integrations/api_issue_reaction_test.go index c474f7bad2..1906b8d090 100644 --- a/integrations/api_issue_reaction_test.go +++ b/integrations/api_issue_reaction_test.go @@ -47,7 +47,7 @@ func TestAPIIssuesReactions(t *testing.T) { Reaction: "rocket", }) resp = session.MakeRequest(t, req, http.StatusCreated) - var apiNewReaction api.ReactionResponse + var apiNewReaction api.Reaction DecodeJSON(t, resp, &apiNewReaction) //Add existing reaction @@ -56,10 +56,10 @@ func TestAPIIssuesReactions(t *testing.T) { //Get end result of reaction list of issue #1 req = NewRequestf(t, "GET", urlStr) resp = session.MakeRequest(t, req, http.StatusOK) - var apiReactions []*api.ReactionResponse + var apiReactions []*api.Reaction DecodeJSON(t, resp, &apiReactions) - expectResponse := make(map[int]api.ReactionResponse) - expectResponse[0] = api.ReactionResponse{ + expectResponse := make(map[int]api.Reaction) + expectResponse[0] = api.Reaction{ User: user2.APIFormat(), Reaction: "eyes", Created: time.Unix(1573248003, 0), @@ -107,7 +107,7 @@ func TestAPICommentReactions(t *testing.T) { Reaction: "+1", }) resp = session.MakeRequest(t, req, http.StatusCreated) - var apiNewReaction api.ReactionResponse + var apiNewReaction api.Reaction DecodeJSON(t, resp, &apiNewReaction) //Add existing reaction @@ -116,15 +116,15 @@ func TestAPICommentReactions(t *testing.T) { //Get end result of reaction list of issue #1 req = NewRequestf(t, "GET", urlStr) resp = session.MakeRequest(t, req, http.StatusOK) - var apiReactions []*api.ReactionResponse + var apiReactions []*api.Reaction DecodeJSON(t, resp, &apiReactions) - expectResponse := make(map[int]api.ReactionResponse) - expectResponse[0] = api.ReactionResponse{ + expectResponse := make(map[int]api.Reaction) + expectResponse[0] = api.Reaction{ User: user2.APIFormat(), Reaction: "laugh", Created: time.Unix(1573248004, 0), } - expectResponse[1] = api.ReactionResponse{ + expectResponse[1] = api.Reaction{ User: user1.APIFormat(), Reaction: "laugh", Created: time.Unix(1573248005, 0), diff --git a/integrations/api_issue_test.go b/integrations/api_issue_test.go index f412f5af08..382fe606bf 100644 --- a/integrations/api_issue_test.go +++ b/integrations/api_issue_test.go @@ -62,3 +62,61 @@ func TestAPICreateIssue(t *testing.T) { Title: title, }) } + +func TestAPIEditIssue(t *testing.T) { + defer prepareTestEnv(t)() + + issueBefore := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 10}).(*models.Issue) + repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: issueBefore.RepoID}).(*models.Repository) + owner := models.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User) + assert.NoError(t, issueBefore.LoadAttributes()) + assert.Equal(t, int64(1019307200), int64(issueBefore.DeadlineUnix)) + assert.Equal(t, api.StateOpen, issueBefore.State()) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session) + + // update values of issue + issueState := "closed" + removeDeadline := true + milestone := int64(4) + body := "new content!" + title := "new title from api set" + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d?token=%s", owner.Name, repo.Name, issueBefore.Index, token) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + State: &issueState, + RemoveDeadline: &removeDeadline, + Milestone: &milestone, + Body: &body, + Title: title, + + // ToDo change more + }) + resp := session.MakeRequest(t, req, http.StatusCreated) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + + issueAfter := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 10}).(*models.Issue) + + // check deleted user + assert.Equal(t, int64(500), issueAfter.PosterID) + assert.NoError(t, issueAfter.LoadAttributes()) + assert.Equal(t, int64(-1), issueAfter.PosterID) + assert.Equal(t, int64(-1), issueBefore.PosterID) + assert.Equal(t, int64(-1), apiIssue.Poster.ID) + + // API response + assert.Equal(t, api.StateClosed, apiIssue.State) + assert.Equal(t, milestone, apiIssue.Milestone.ID) + assert.Equal(t, body, apiIssue.Body) + assert.True(t, apiIssue.Deadline == nil) + assert.Equal(t, title, apiIssue.Title) + + // in database + assert.Equal(t, api.StateClosed, issueAfter.State()) + assert.Equal(t, milestone, issueAfter.MilestoneID) + assert.Equal(t, int64(0), int64(issueAfter.DeadlineUnix)) + assert.Equal(t, body, issueAfter.Content) + assert.Equal(t, title, issueAfter.Title) +} diff --git a/integrations/attachement_test.go b/integrations/attachement_test.go deleted file mode 100644 index 8d709a376e..0000000000 --- a/integrations/attachement_test.go +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// Use of this source code is governed by a MIT-style -// license that can be found in the LICENSE file. - -package integrations - -import ( - "bytes" - "image" - "image/png" - "io" - "mime/multipart" - "net/http" - "testing" - - "code.gitea.io/gitea/modules/test" - "github.com/stretchr/testify/assert" -) - -func generateImg() bytes.Buffer { - // Generate image - myImage := image.NewRGBA(image.Rect(0, 0, 32, 32)) - var buff bytes.Buffer - png.Encode(&buff, myImage) - return buff -} - -func createAttachment(t *testing.T, session *TestSession, repoURL, filename string, buff bytes.Buffer, expectedStatus int) string { - body := &bytes.Buffer{} - - //Setup multi-part - writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("file", filename) - assert.NoError(t, err) - _, err = io.Copy(part, &buff) - assert.NoError(t, err) - err = writer.Close() - assert.NoError(t, err) - - csrf := GetCSRF(t, session, repoURL) - - req := NewRequestWithBody(t, "POST", "/attachments", body) - req.Header.Add("X-Csrf-Token", csrf) - req.Header.Add("Content-Type", writer.FormDataContentType()) - resp := session.MakeRequest(t, req, expectedStatus) - - if expectedStatus != http.StatusOK { - return "" - } - var obj map[string]string - DecodeJSON(t, resp, &obj) - return obj["uuid"] -} - -func TestCreateAnonymousAttachment(t *testing.T) { - prepareTestEnv(t) - session := emptyTestSession(t) - createAttachment(t, session, "user2/repo1", "image.png", generateImg(), http.StatusFound) -} - -func TestCreateIssueAttachement(t *testing.T) { - prepareTestEnv(t) - const repoURL = "user2/repo1" - session := loginUser(t, "user2") - uuid := createAttachment(t, session, repoURL, "image.png", generateImg(), http.StatusOK) - - req := NewRequest(t, "GET", repoURL+"/issues/new") - resp := session.MakeRequest(t, req, http.StatusOK) - htmlDoc := NewHTMLParser(t, resp.Body) - - link, exists := htmlDoc.doc.Find("form").Attr("action") - assert.True(t, exists, "The template has changed") - - postData := map[string]string{ - "_csrf": htmlDoc.GetCSRF(), - "title": "New Issue With Attachement", - "content": "some content", - "files[0]": uuid, - } - - req = NewRequestWithValues(t, "POST", link, postData) - resp = session.MakeRequest(t, req, http.StatusFound) - test.RedirectURL(resp) // check that redirect URL exists - - //Validate that attachement is available - req = NewRequest(t, "GET", "/attachments/"+uuid) - session.MakeRequest(t, req, http.StatusOK) -} diff --git a/integrations/attachment_test.go b/integrations/attachment_test.go new file mode 100644 index 0000000000..746256df95 --- /dev/null +++ b/integrations/attachment_test.go @@ -0,0 +1,137 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "bytes" + "image" + "image/png" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "os" + "path" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func generateImg() bytes.Buffer { + // Generate image + myImage := image.NewRGBA(image.Rect(0, 0, 32, 32)) + var buff bytes.Buffer + png.Encode(&buff, myImage) + return buff +} + +func createAttachment(t *testing.T, session *TestSession, repoURL, filename string, buff bytes.Buffer, expectedStatus int) string { + body := &bytes.Buffer{} + + //Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("file", filename) + assert.NoError(t, err) + _, err = io.Copy(part, &buff) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) + + csrf := GetCSRF(t, session, repoURL) + + req := NewRequestWithBody(t, "POST", "/attachments", body) + req.Header.Add("X-Csrf-Token", csrf) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, expectedStatus) + + if expectedStatus != http.StatusOK { + return "" + } + var obj map[string]string + DecodeJSON(t, resp, &obj) + return obj["uuid"] +} + +func TestCreateAnonymousAttachment(t *testing.T) { + prepareTestEnv(t) + session := emptyTestSession(t) + createAttachment(t, session, "user2/repo1", "image.png", generateImg(), http.StatusFound) +} + +func TestCreateIssueAttachment(t *testing.T) { + prepareTestEnv(t) + const repoURL = "user2/repo1" + session := loginUser(t, "user2") + uuid := createAttachment(t, session, repoURL, "image.png", generateImg(), http.StatusOK) + + req := NewRequest(t, "GET", repoURL+"/issues/new") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + link, exists := htmlDoc.doc.Find("form").Attr("action") + assert.True(t, exists, "The template has changed") + + postData := map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "title": "New Issue With Attachment", + "content": "some content", + "files": uuid, + } + + req = NewRequestWithValues(t, "POST", link, postData) + resp = session.MakeRequest(t, req, http.StatusFound) + test.RedirectURL(resp) // check that redirect URL exists + + //Validate that attachment is available + req = NewRequest(t, "GET", "/attachments/"+uuid) + session.MakeRequest(t, req, http.StatusOK) +} + +func TestGetAttachment(t *testing.T) { + prepareTestEnv(t) + adminSession := loginUser(t, "user1") + user2Session := loginUser(t, "user2") + user8Session := loginUser(t, "user8") + emptySession := emptyTestSession(t) + testCases := []struct { + name string + uuid string + createFile bool + session *TestSession + want int + }{ + {"LinkedIssueUUID", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", true, user2Session, http.StatusOK}, + {"LinkedCommentUUID", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a17", true, user2Session, http.StatusOK}, + {"linked_release_uuid", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a19", true, user2Session, http.StatusOK}, + {"NotExistingUUID", "b0eebc99-9c0b-4ef8-bb6d-6bb9bd380a18", false, user2Session, http.StatusNotFound}, + {"FileMissing", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a18", false, user2Session, http.StatusInternalServerError}, + {"NotLinked", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a20", true, user2Session, http.StatusNotFound}, + {"NotLinkedAccessibleByUploader", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a20", true, user8Session, http.StatusOK}, + {"PublicByNonLogged", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", true, emptySession, http.StatusOK}, + {"PrivateByNonLogged", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, emptySession, http.StatusNotFound}, + {"PrivateAccessibleByAdmin", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, adminSession, http.StatusOK}, + {"PrivateAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user2Session, http.StatusOK}, + {"RepoNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user8Session, http.StatusNotFound}, + {"OrgNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a21", true, user8Session, http.StatusNotFound}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + //Write empty file to be available for response + if tc.createFile { + localPath := models.AttachmentLocalPath(tc.uuid) + err := os.MkdirAll(path.Dir(localPath), os.ModePerm) + assert.NoError(t, err) + err = ioutil.WriteFile(localPath, []byte("hello world"), 0644) + assert.NoError(t, err) + } + //Actual test + req := NewRequest(t, "GET", "/attachments/"+tc.uuid) + tc.session.MakeRequest(t, req, tc.want) + }) + } +} diff --git a/models/attachment.go b/models/attachment.go index 487ddd4ad5..6cfa6cb64e 100644 --- a/models/attachment.go +++ b/models/attachment.go @@ -71,6 +71,26 @@ func (a *Attachment) DownloadURL() string { return fmt.Sprintf("%sattachments/%s", setting.AppURL, a.UUID) } +// LinkedRepository returns the linked repo if any +func (a *Attachment) LinkedRepository() (*Repository, UnitType, error) { + if a.IssueID != 0 { + iss, err := GetIssueByID(a.IssueID) + if err != nil { + return nil, UnitTypeIssues, err + } + repo, err := GetRepositoryByID(iss.RepoID) + return repo, UnitTypeIssues, err + } else if a.ReleaseID != 0 { + rel, err := GetReleaseByID(a.ReleaseID) + if err != nil { + return nil, UnitTypeReleases, err + } + repo, err := GetRepositoryByID(rel.RepoID) + return repo, UnitTypeReleases, err + } + return nil, -1, nil +} + // NewAttachment creates a new attachment object. func NewAttachment(attach *Attachment, buf []byte, file io.Reader) (_ *Attachment, err error) { attach.UUID = gouuid.NewV4().String() diff --git a/models/attachment_test.go b/models/attachment_test.go index f38a5beeee..ddb6abad32 100644 --- a/models/attachment_test.go +++ b/models/attachment_test.go @@ -61,7 +61,7 @@ func TestGetByCommentOrIssueID(t *testing.T) { // count of attachments from issue ID attachments, err := GetAttachmentsByIssueID(1) assert.NoError(t, err) - assert.Equal(t, 2, len(attachments)) + assert.Equal(t, 1, len(attachments)) attachments, err = GetAttachmentsByCommentID(1) assert.NoError(t, err) @@ -73,7 +73,7 @@ func TestDeleteAttachments(t *testing.T) { count, err := DeleteAttachmentsByIssue(4, false) assert.NoError(t, err) - assert.Equal(t, 1, count) + assert.Equal(t, 2, count) count, err = DeleteAttachmentsByComment(2, false) assert.NoError(t, err) @@ -128,3 +128,31 @@ func TestGetAttachmentsByUUIDs(t *testing.T) { assert.Equal(t, int64(1), attachList[0].IssueID) assert.Equal(t, int64(5), attachList[1].IssueID) } + +func TestLinkedRepository(t *testing.T) { + assert.NoError(t, PrepareTestDatabase()) + testCases := []struct { + name string + attachID int64 + expectedRepo *Repository + expectedUnitType UnitType + }{ + {"LinkedIssue", 1, &Repository{ID: 1}, UnitTypeIssues}, + {"LinkedComment", 3, &Repository{ID: 1}, UnitTypeIssues}, + {"LinkedRelease", 9, &Repository{ID: 1}, UnitTypeReleases}, + {"Notlinked", 10, nil, -1}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + attach, err := GetAttachmentByID(tc.attachID) + assert.NoError(t, err) + repo, unitType, err := attach.LinkedRepository() + assert.NoError(t, err) + if tc.expectedRepo != nil { + assert.Equal(t, tc.expectedRepo.ID, repo.ID) + } + assert.Equal(t, tc.expectedUnitType, unitType) + + }) + } +} diff --git a/models/branches.go b/models/branches.go index 21b23c75d9..385817e4f9 100644 --- a/models/branches.go +++ b/models/branches.go @@ -44,6 +44,7 @@ type ProtectedBranch struct { ApprovalsWhitelistUserIDs []int64 `xorm:"JSON TEXT"` ApprovalsWhitelistTeamIDs []int64 `xorm:"JSON TEXT"` RequiredApprovals int64 `xorm:"NOT NULL DEFAULT 0"` + BlockOnRejectedReviews bool `xorm:"NOT NULL DEFAULT false"` CreatedUnix timeutil.TimeStamp `xorm:"created"` UpdatedUnix timeutil.TimeStamp `xorm:"updated"` } @@ -166,6 +167,23 @@ func (protectBranch *ProtectedBranch) GetGrantedApprovalsCount(pr *PullRequest) return approvals } +// MergeBlockedByRejectedReview returns true if merge is blocked by rejected reviews +func (protectBranch *ProtectedBranch) MergeBlockedByRejectedReview(pr *PullRequest) bool { + if !protectBranch.BlockOnRejectedReviews { + return false + } + rejectExist, err := x.Where("issue_id = ?", pr.IssueID). + And("type = ?", ReviewTypeReject). + And("official = ?", true). + Exist(new(Review)) + if err != nil { + log.Error("MergeBlockedByRejectedReview: %v", err) + return true + } + + return rejectExist +} + // GetProtectedBranchByRepoID getting protected branch by repo ID func GetProtectedBranchByRepoID(repoID int64) ([]*ProtectedBranch, error) { protectedBranches := make([]*ProtectedBranch, 0) @@ -340,7 +358,7 @@ func (repo *Repository) IsProtectedBranchForMerging(pr *PullRequest, branchName if err != nil { return true, err } else if has { - return !protectedBranch.CanUserMerge(doer.ID) || !protectedBranch.HasEnoughApprovals(pr), nil + return !protectedBranch.CanUserMerge(doer.ID) || !protectedBranch.HasEnoughApprovals(pr) || protectedBranch.MergeBlockedByRejectedReview(pr), nil } return false, nil diff --git a/models/error.go b/models/error.go index 396d7594c8..f0d5699aad 100644 --- a/models/error.go +++ b/models/error.go @@ -1201,6 +1201,21 @@ func (err ErrForbiddenIssueReaction) Error() string { return fmt.Sprintf("'%s' is not an allowed reaction", err.Reaction) } +// ErrReactionAlreadyExist is used when a existing reaction was try to created +type ErrReactionAlreadyExist struct { + Reaction string +} + +// IsErrReactionAlreadyExist checks if an error is a ErrReactionAlreadyExist. +func IsErrReactionAlreadyExist(err error) bool { + _, ok := err.(ErrReactionAlreadyExist) + return ok +} + +func (err ErrReactionAlreadyExist) Error() string { + return fmt.Sprintf("reaction '%s' already exists", err.Reaction) +} + // __________ .__ .__ __________ __ // \______ \__ __| | | |\______ \ ____ ________ __ ____ _______/ |_ // | ___/ | \ | | | | _// __ \/ ____/ | \_/ __ \ / ___/\ __\ diff --git a/models/fixtures/attachment.yml b/models/fixtures/attachment.yml index 289d4d0efd..2606d52b47 100644 --- a/models/fixtures/attachment.yml +++ b/models/fixtures/attachment.yml @@ -10,7 +10,7 @@ - id: 2 uuid: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12 - issue_id: 1 + issue_id: 4 comment_id: 0 name: attach2 download_count: 1 @@ -81,6 +81,15 @@ - id: 10 uuid: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a20 + uploader_id: 8 name: attach1 download_count: 0 - created_unix: 946684800 \ No newline at end of file + created_unix: 946684800 + +- + id: 11 + uuid: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a21 + release_id: 2 + name: attach1 + download_count: 0 + created_unix: 946684800 diff --git a/models/fixtures/issue.yml b/models/fixtures/issue.yml index 6b57268a7a..ecee7499f6 100644 --- a/models/fixtures/issue.yml +++ b/models/fixtures/issue.yml @@ -108,4 +108,17 @@ is_closed: false is_pull: true created_unix: 946684820 - updated_unix: 978307180 \ No newline at end of file + updated_unix: 978307180 + +- + id: 10 + repo_id: 42 + index: 1 + poster_id: 500 + name: issue from deleted account + content: content from deleted account + is_closed: false + is_pull: false + created_unix: 946684830 + updated_unix: 999307200 + deadline_unix: 1019307200 diff --git a/models/fixtures/milestone.yml b/models/fixtures/milestone.yml index 15f422fc3b..a9ecb4ee6a 100644 --- a/models/fixtures/milestone.yml +++ b/models/fixtures/milestone.yml @@ -21,3 +21,11 @@ content: content3 is_closed: true num_issues: 0 + +- + id: 4 + repo_id: 42 + name: milestone of repo42 + content: content random + is_closed: false + num_issues: 0 diff --git a/models/fixtures/release.yml b/models/fixtures/release.yml index db9a6b503d..f95eb048be 100644 --- a/models/fixtures/release.yml +++ b/models/fixtures/release.yml @@ -11,4 +11,19 @@ is_draft: false is_prerelease: false is_tag: false - created_unix: 946684800 \ No newline at end of file + created_unix: 946684800 + +- + id: 2 + repo_id: 40 + publisher_id: 2 + tag_name: "v1.1" + lower_tag_name: "v1.1" + target: "master" + title: "testing-release" + sha1: "65f1bf27bc3bf70f64657658635e66094edbcb4d" + num_commits: 10 + is_draft: false + is_prerelease: false + is_tag: false + created_unix: 946684800 diff --git a/models/fixtures/repo_unit.yml b/models/fixtures/repo_unit.yml index f7522d3031..5ced38b003 100644 --- a/models/fixtures/repo_unit.yml +++ b/models/fixtures/repo_unit.yml @@ -472,4 +472,10 @@ repo_id: 48 type: 7 config: "{\"ExternalTrackerURL\":\"https://tracker.com\",\"ExternalTrackerFormat\":\"https://tracker.com/{user}/{repo}/issues/{index}\",\"ExternalTrackerStyle\":\"alphanumeric\"}" + created_unix: 946684810 +- + id: 69 + repo_id: 2 + type: 2 + config: "{}" created_unix: 946684810 \ No newline at end of file diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml index feec0b5faf..c7f4d4d109 100644 --- a/models/fixtures/repository.yml +++ b/models/fixtures/repository.yml @@ -547,7 +547,8 @@ is_private: false num_stars: 0 num_forks: 0 - num_issues: 0 + num_issues: 1 + num_milestones: 1 is_mirror: false - @@ -588,7 +589,7 @@ is_mirror: false status: 0 -- +- id: 46 owner_id: 26 lower_name: repo_external_tracker @@ -600,7 +601,7 @@ is_mirror: false status: 0 -- +- id: 47 owner_id: 26 lower_name: repo_external_tracker_numeric @@ -612,7 +613,7 @@ is_mirror: false status: 0 -- +- id: 48 owner_id: 26 lower_name: repo_external_tracker_alpha diff --git a/models/issue.go b/models/issue.go index 75f7bd818a..485be4baef 100644 --- a/models/issue.go +++ b/models/issue.go @@ -1,4 +1,5 @@ // Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. @@ -239,6 +240,16 @@ func (issue *Issue) loadReactions(e Engine) (err error) { return nil } +func (issue *Issue) loadMilestone(e Engine) (err error) { + if issue.Milestone == nil && issue.MilestoneID > 0 { + issue.Milestone, err = getMilestoneByRepoID(e, issue.RepoID, issue.MilestoneID) + if err != nil && !IsErrMilestoneNotExist(err) { + return fmt.Errorf("getMilestoneByRepoID [repo_id: %d, milestone_id: %d]: %v", issue.RepoID, issue.MilestoneID, err) + } + } + return nil +} + func (issue *Issue) loadAttributes(e Engine) (err error) { if err = issue.loadRepo(e); err != nil { return @@ -252,11 +263,8 @@ func (issue *Issue) loadAttributes(e Engine) (err error) { return } - if issue.Milestone == nil && issue.MilestoneID > 0 { - issue.Milestone, err = getMilestoneByRepoID(e, issue.RepoID, issue.MilestoneID) - if err != nil && !IsErrMilestoneNotExist(err) { - return fmt.Errorf("getMilestoneByRepoID [repo_id: %d, milestone_id: %d]: %v", issue.RepoID, issue.MilestoneID, err) - } + if err = issue.loadMilestone(e); err != nil { + return } if err = issue.loadAssignees(e); err != nil { @@ -296,6 +304,11 @@ func (issue *Issue) LoadAttributes() error { return issue.loadAttributes(x) } +// LoadMilestone load milestone of this issue. +func (issue *Issue) LoadMilestone() error { + return issue.loadMilestone(x) +} + // GetIsRead load the `IsRead` field of the issue func (issue *Issue) GetIsRead(userID int64) error { issueUser := &IssueUser{IssueID: issue.ID, UID: userID} @@ -1568,27 +1581,25 @@ func SearchIssueIDsByKeyword(kw string, repoIDs []int64, limit, start int) (int6 return total, ids, nil } -func updateIssue(e Engine, issue *Issue) error { - _, err := e.ID(issue.ID).AllCols().Update(issue) - if err != nil { - return err - } - return nil -} - -// UpdateIssue updates all fields of given issue. -func UpdateIssue(issue *Issue) error { +// UpdateIssueByAPI updates all allowed fields of given issue. +func UpdateIssueByAPI(issue *Issue) error { sess := x.NewSession() defer sess.Close() if err := sess.Begin(); err != nil { return err } - if err := updateIssue(sess, issue); err != nil { + + if _, err := sess.ID(issue.ID).Cols( + "name", "is_closed", "content", "milestone_id", "priority", + "deadline_unix", "updated_unix", "closed_unix", "is_locked"). + Update(issue); err != nil { return err } + if err := issue.loadPoster(sess); err != nil { return err } + if err := issue.addCrossReferences(sess, issue.Poster, true); err != nil { return err } diff --git a/models/issue_reaction.go b/models/issue_reaction.go index 6896eeeafc..d421ab44e9 100644 --- a/models/issue_reaction.go +++ b/models/issue_reaction.go @@ -30,6 +30,8 @@ type Reaction struct { type FindReactionsOptions struct { IssueID int64 CommentID int64 + UserID int64 + Reaction string } func (opts *FindReactionsOptions) toConds() builder.Cond { @@ -46,6 +48,12 @@ func (opts *FindReactionsOptions) toConds() builder.Cond { } else if opts.CommentID == -1 { cond = cond.And(builder.Eq{"reaction.comment_id": 0}) } + if opts.UserID > 0 { + cond = cond.And(builder.Eq{"reaction.user_id": opts.UserID}) + } + if opts.Reaction != "" { + cond = cond.And(builder.Eq{"reaction.type": opts.Reaction}) + } return cond } @@ -80,9 +88,25 @@ func createReaction(e *xorm.Session, opts *ReactionOptions) (*Reaction, error) { UserID: opts.Doer.ID, IssueID: opts.Issue.ID, } + findOpts := FindReactionsOptions{ + IssueID: opts.Issue.ID, + CommentID: -1, // reaction to issue only + Reaction: opts.Type, + UserID: opts.Doer.ID, + } if opts.Comment != nil { reaction.CommentID = opts.Comment.ID + findOpts.CommentID = opts.Comment.ID } + + existingR, err := findReactions(e, findOpts) + if err != nil { + return nil, err + } + if len(existingR) > 0 { + return existingR[0], ErrReactionAlreadyExist{Reaction: opts.Type} + } + if _, err := e.Insert(reaction); err != nil { return nil, err } @@ -99,23 +123,23 @@ type ReactionOptions struct { } // CreateReaction creates reaction for issue or comment. -func CreateReaction(opts *ReactionOptions) (reaction *Reaction, err error) { +func CreateReaction(opts *ReactionOptions) (*Reaction, error) { if !setting.UI.ReactionsMap[opts.Type] { return nil, ErrForbiddenIssueReaction{opts.Type} } sess := x.NewSession() defer sess.Close() - if err = sess.Begin(); err != nil { + if err := sess.Begin(); err != nil { return nil, err } - reaction, err = createReaction(sess, opts) + reaction, err := createReaction(sess, opts) if err != nil { - return nil, err + return reaction, err } - if err = sess.Commit(); err != nil { + if err := sess.Commit(); err != nil { return nil, err } return reaction, nil diff --git a/models/issue_reaction_test.go b/models/issue_reaction_test.go index 1189b389e9..723a6be536 100644 --- a/models/issue_reaction_test.go +++ b/models/issue_reaction_test.go @@ -50,9 +50,10 @@ func TestIssueAddDuplicateReaction(t *testing.T) { Type: "heart", }) assert.Error(t, err) - assert.Nil(t, reaction) + assert.Equal(t, ErrReactionAlreadyExist{Reaction: "heart"}, err) - AssertExistsAndLoadBean(t, &Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1.ID}) + existingR := AssertExistsAndLoadBean(t, &Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1.ID}).(*Reaction) + assert.Equal(t, existingR.ID, reaction.ID) } func TestIssueDeleteReaction(t *testing.T) { @@ -129,7 +130,6 @@ func TestIssueCommentDeleteReaction(t *testing.T) { user2 := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User) user3 := AssertExistsAndLoadBean(t, &User{ID: 3}).(*User) user4 := AssertExistsAndLoadBean(t, &User{ID: 4}).(*User) - ghost := NewGhostUser() issue1 := AssertExistsAndLoadBean(t, &Issue{ID: 1}).(*Issue) @@ -139,14 +139,13 @@ func TestIssueCommentDeleteReaction(t *testing.T) { addReaction(t, user2, issue1, comment1, "heart") addReaction(t, user3, issue1, comment1, "heart") addReaction(t, user4, issue1, comment1, "+1") - addReaction(t, ghost, issue1, comment1, "heart") err := comment1.LoadReactions() assert.NoError(t, err) - assert.Len(t, comment1.Reactions, 5) + assert.Len(t, comment1.Reactions, 4) reactions := comment1.Reactions.GroupByType() - assert.Len(t, reactions["heart"], 4) + assert.Len(t, reactions["heart"], 3) assert.Len(t, reactions["+1"], 1) } @@ -160,7 +159,7 @@ func TestIssueCommentReactionCount(t *testing.T) { comment1 := AssertExistsAndLoadBean(t, &Comment{ID: 1}).(*Comment) addReaction(t, user1, issue1, comment1, "heart") - DeleteCommentReaction(user1, issue1, comment1, "heart") + assert.NoError(t, DeleteCommentReaction(user1, issue1, comment1, "heart")) AssertNotExistsBean(t, &Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1.ID, CommentID: comment1.ID}) } diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index e8bb3f16d4..73c9bc1138 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -288,6 +288,8 @@ var migrations = []Migration{ NewMigration("add user_id prefix to existing user avatar name", renameExistingUserAvatarName), // v116 -> v117 NewMigration("Extend TrackedTimes", extendTrackedTimes), + // v117 -> v118 + NewMigration("Add block on rejected reviews branch protection", addBlockOnRejectedReviews), } // Migrate database to current version diff --git a/models/migrations/v117.go b/models/migrations/v117.go new file mode 100644 index 0000000000..662d6c7b46 --- /dev/null +++ b/models/migrations/v117.go @@ -0,0 +1,17 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "xorm.io/xorm" +) + +func addBlockOnRejectedReviews(x *xorm.Engine) error { + type ProtectedBranch struct { + BlockOnRejectedReviews bool `xorm:"NOT NULL DEFAULT false"` + } + + return x.Sync2(new(ProtectedBranch)) +} diff --git a/models/pull.go b/models/pull.go index ba9c575775..9a8777aca3 100644 --- a/models/pull.go +++ b/models/pull.go @@ -7,6 +7,7 @@ package models import ( "fmt" + "io" "strings" "code.gitea.io/gitea/modules/git" @@ -177,6 +178,206 @@ func (pr *PullRequest) GetDefaultMergeMessage() string { return fmt.Sprintf("Merge branch '%s' of %s/%s into %s", pr.HeadBranch, pr.MustHeadUserName(), pr.HeadRepo.Name, pr.BaseBranch) } +// GetCommitMessages returns the commit messages between head and merge base (if there is one) +func (pr *PullRequest) GetCommitMessages() string { + if err := pr.LoadIssue(); err != nil { + log.Error("Cannot load issue %d for PR id %d: Error: %v", pr.IssueID, pr.ID, err) + return "" + } + + if err := pr.Issue.LoadPoster(); err != nil { + log.Error("Cannot load poster %d for pr id %d, index %d Error: %v", pr.Issue.PosterID, pr.ID, pr.Index, err) + return "" + } + + if pr.HeadRepo == nil { + var err error + pr.HeadRepo, err = GetRepositoryByID(pr.HeadRepoID) + if err != nil { + log.Error("GetRepositoryById[%d]: %v", pr.HeadRepoID, err) + return "" + } + } + + gitRepo, err := git.OpenRepository(pr.HeadRepo.RepoPath()) + if err != nil { + log.Error("Unable to open head repository: Error: %v", err) + return "" + } + defer gitRepo.Close() + + headCommit, err := gitRepo.GetBranchCommit(pr.HeadBranch) + if err != nil { + log.Error("Unable to get head commit: %s Error: %v", pr.HeadBranch, err) + return "" + } + + mergeBase, err := gitRepo.GetCommit(pr.MergeBase) + if err != nil { + log.Error("Unable to get merge base commit: %s Error: %v", pr.MergeBase, err) + return "" + } + + limit := setting.Repository.PullRequest.DefaultMergeMessageCommitsLimit + + list, err := gitRepo.CommitsBetweenLimit(headCommit, mergeBase, limit, 0) + if err != nil { + log.Error("Unable to get commits between: %s %s Error: %v", pr.HeadBranch, pr.MergeBase, err) + return "" + } + + maxSize := setting.Repository.PullRequest.DefaultMergeMessageSize + + posterSig := pr.Issue.Poster.NewGitSig().String() + + authorsMap := map[string]bool{} + authors := make([]string, 0, list.Len()) + stringBuilder := strings.Builder{} + element := list.Front() + for element != nil { + commit := element.Value.(*git.Commit) + + if maxSize < 0 || stringBuilder.Len() < maxSize { + toWrite := []byte(commit.CommitMessage) + if len(toWrite) > maxSize-stringBuilder.Len() && maxSize > -1 { + toWrite = append(toWrite[:maxSize-stringBuilder.Len()], "..."...) + } + if _, err := stringBuilder.Write(toWrite); err != nil { + log.Error("Unable to write commit message Error: %v", err) + return "" + } + + if _, err := stringBuilder.WriteRune('\n'); err != nil { + log.Error("Unable to write commit message Error: %v", err) + return "" + } + } + + authorString := commit.Author.String() + if !authorsMap[authorString] && authorString != posterSig { + authors = append(authors, authorString) + authorsMap[authorString] = true + } + element = element.Next() + } + + // Consider collecting the remaining authors + if limit >= 0 && setting.Repository.PullRequest.DefaultMergeMessageAllAuthors { + skip := limit + limit = 30 + for { + list, err := gitRepo.CommitsBetweenLimit(headCommit, mergeBase, limit, skip) + if err != nil { + log.Error("Unable to get commits between: %s %s Error: %v", pr.HeadBranch, pr.MergeBase, err) + return "" + + } + if list.Len() == 0 { + break + } + element := list.Front() + for element != nil { + commit := element.Value.(*git.Commit) + + authorString := commit.Author.String() + if !authorsMap[authorString] && authorString != posterSig { + authors = append(authors, authorString) + authorsMap[authorString] = true + } + element = element.Next() + } + + } + } + + if len(authors) > 0 { + if _, err := stringBuilder.WriteRune('\n'); err != nil { + log.Error("Unable to write to string builder Error: %v", err) + return "" + } + } + + for _, author := range authors { + if _, err := stringBuilder.Write([]byte("Co-authored-by: ")); err != nil { + log.Error("Unable to write to string builder Error: %v", err) + return "" + } + if _, err := stringBuilder.Write([]byte(author)); err != nil { + log.Error("Unable to write to string builder Error: %v", err) + return "" + } + if _, err := stringBuilder.WriteRune('\n'); err != nil { + log.Error("Unable to write to string builder Error: %v", err) + return "" + } + } + + return stringBuilder.String() +} + +// GetApprovers returns the approvers of the pull request +func (pr *PullRequest) GetApprovers() string { + + stringBuilder := strings.Builder{} + if err := pr.getReviewedByLines(&stringBuilder); err != nil { + log.Error("Unable to getReviewedByLines: Error: %v", err) + return "" + } + + return stringBuilder.String() +} + +func (pr *PullRequest) getReviewedByLines(writer io.Writer) error { + maxReviewers := setting.Repository.PullRequest.DefaultMergeMessageMaxApprovers + + if maxReviewers == 0 { + return nil + } + + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + + // Note: This doesn't page as we only expect a very limited number of reviews + reviews, err := findReviews(sess, FindReviewOptions{ + Type: ReviewTypeApprove, + IssueID: pr.IssueID, + OfficialOnly: setting.Repository.PullRequest.DefaultMergeMessageOfficialApproversOnly, + }) + if err != nil { + log.Error("Unable to FindReviews for PR ID %d: %v", pr.ID, err) + return err + } + + reviewersWritten := 0 + + for _, review := range reviews { + if maxReviewers > 0 && reviewersWritten > maxReviewers { + break + } + + if err := review.loadReviewer(sess); err != nil && !IsErrUserNotExist(err) { + log.Error("Unable to LoadReviewer[%d] for PR ID %d : %v", review.ReviewerID, pr.ID, err) + return err + } else if review.Reviewer == nil { + continue + } + if _, err := writer.Write([]byte("Reviewed-by: ")); err != nil { + return err + } + if _, err := writer.Write([]byte(review.Reviewer.NewGitSig().String())); err != nil { + return err + } + if _, err := writer.Write([]byte{'\n'}); err != nil { + return err + } + reviewersWritten++ + } + return sess.Commit() +} + // GetDefaultSquashMessage returns default message used when squash and merging pull request func (pr *PullRequest) GetDefaultSquashMessage() string { if err := pr.LoadIssue(); err != nil { diff --git a/models/review.go b/models/review.go index 493959e78e..bc7dfbcd14 100644 --- a/models/review.go +++ b/models/review.go @@ -125,9 +125,10 @@ func GetReviewByID(id int64) (*Review, error) { // FindReviewOptions represent possible filters to find reviews type FindReviewOptions struct { - Type ReviewType - IssueID int64 - ReviewerID int64 + Type ReviewType + IssueID int64 + ReviewerID int64 + OfficialOnly bool } func (opts *FindReviewOptions) toCond() builder.Cond { @@ -141,6 +142,9 @@ func (opts *FindReviewOptions) toCond() builder.Cond { if opts.Type != ReviewTypeUnknown { cond = cond.And(builder.Eq{"type": opts.Type}) } + if opts.OfficialOnly { + cond = cond.And(builder.Eq{"official": true}) + } return cond } diff --git a/models/user.go b/models/user.go index e832c2ed51..a8f2c6fd22 100644 --- a/models/user.go +++ b/models/user.go @@ -791,6 +791,14 @@ func NewGhostUser() *User { } } +// IsGhost check if user is fake user for a deleted account +func (u *User) IsGhost() bool { + if u == nil { + return false + } + return u.ID == -1 && u.Name == "Ghost" +} + var ( reservedUsernames = []string{ "attachments", diff --git a/modules/auth/repo_form.go b/modules/auth/repo_form.go index 26ed2bb692..c87549af92 100644 --- a/modules/auth/repo_form.go +++ b/modules/auth/repo_form.go @@ -171,6 +171,7 @@ type ProtectBranchForm struct { EnableApprovalsWhitelist bool ApprovalsWhitelistUsers string ApprovalsWhitelistTeams string + BlockOnRejectedReviews bool } // Validate validates the fields diff --git a/modules/git/repo_commit.go b/modules/git/repo_commit.go index 5808c7600e..8762b63e2e 100644 --- a/modules/git/repo_commit.go +++ b/modules/git/repo_commit.go @@ -315,7 +315,28 @@ func (repo *Repository) FilesCountBetween(startCommitID, endCommitID string) (in // CommitsBetween returns a list that contains commits between [last, before). func (repo *Repository) CommitsBetween(last *Commit, before *Commit) (*list.List, error) { - stdout, err := NewCommand("rev-list", before.ID.String()+"..."+last.ID.String()).RunInDirBytes(repo.Path) + var stdout []byte + var err error + if before == nil { + stdout, err = NewCommand("rev-list", before.ID.String()).RunInDirBytes(repo.Path) + } else { + stdout, err = NewCommand("rev-list", before.ID.String()+"..."+last.ID.String()).RunInDirBytes(repo.Path) + } + if err != nil { + return nil, err + } + return repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout)) +} + +// CommitsBetweenLimit returns a list that contains at most limit commits skipping the first skip commits between [last, before) +func (repo *Repository) CommitsBetweenLimit(last *Commit, before *Commit, limit, skip int) (*list.List, error) { + var stdout []byte + var err error + if before == nil { + stdout, err = NewCommand("rev-list", "--max-count", strconv.Itoa(limit), "--skip", strconv.Itoa(skip), last.ID.String()).RunInDirBytes(repo.Path) + } else { + stdout, err = NewCommand("rev-list", "--max-count", strconv.Itoa(limit), "--skip", strconv.Itoa(skip), before.ID.String()+"..."+last.ID.String()).RunInDirBytes(repo.Path) + } if err != nil { return nil, err } @@ -328,6 +349,9 @@ func (repo *Repository) CommitsBetweenIDs(last, before string) (*list.List, erro if err != nil { return nil, err } + if before == "" { + return repo.CommitsBetween(lastCommit, nil) + } beforeCommit, err := repo.GetCommit(before) if err != nil { return nil, err diff --git a/modules/markup/common/footnote.go b/modules/markup/common/footnote.go new file mode 100644 index 0000000000..ad4cd7f2e1 --- /dev/null +++ b/modules/markup/common/footnote.go @@ -0,0 +1,507 @@ +// Copyright 2019 Yusuke Inuzuka +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// Most of what follows is a subtly changed version of github.com/yuin/goldmark/extension/footnote.go + +package common + +import ( + "bytes" + "fmt" + "os" + "strconv" + "unicode" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// CleanValue will clean a value to make it safe to be an id +// This function is quite different from the original goldmark function +// and more closely matches the output from the shurcooL sanitizer +// In particular Unicode letters and numbers are a lot more than a-zA-Z0-9... +func CleanValue(value []byte) []byte { + value = bytes.TrimSpace(value) + rs := bytes.Runes(value) + result := make([]rune, 0, len(rs)) + needsDash := false + for _, r := range rs { + switch { + case unicode.IsLetter(r) || unicode.IsNumber(r): + if needsDash && len(result) > 0 { + result = append(result, '-') + } + needsDash = false + result = append(result, unicode.ToLower(r)) + default: + needsDash = true + } + } + return []byte(string(result)) +} + +// Most of what follows is a subtly changed version of github.com/yuin/goldmark/extension/footnote.go + +// A FootnoteLink struct represents a link to a footnote of Markdown +// (PHP Markdown Extra) text. +type FootnoteLink struct { + ast.BaseInline + Index int + Name []byte +} + +// Dump implements Node.Dump. +func (n *FootnoteLink) Dump(source []byte, level int) { + m := map[string]string{} + m["Index"] = fmt.Sprintf("%v", n.Index) + m["Name"] = fmt.Sprintf("%v", n.Name) + ast.DumpHelper(n, source, level, m, nil) +} + +// KindFootnoteLink is a NodeKind of the FootnoteLink node. +var KindFootnoteLink = ast.NewNodeKind("GiteaFootnoteLink") + +// Kind implements Node.Kind. +func (n *FootnoteLink) Kind() ast.NodeKind { + return KindFootnoteLink +} + +// NewFootnoteLink returns a new FootnoteLink node. +func NewFootnoteLink(index int, name []byte) *FootnoteLink { + return &FootnoteLink{ + Index: index, + Name: name, + } +} + +// A FootnoteBackLink struct represents a link to a footnote of Markdown +// (PHP Markdown Extra) text. +type FootnoteBackLink struct { + ast.BaseInline + Index int + Name []byte +} + +// Dump implements Node.Dump. +func (n *FootnoteBackLink) Dump(source []byte, level int) { + m := map[string]string{} + m["Index"] = fmt.Sprintf("%v", n.Index) + m["Name"] = fmt.Sprintf("%v", n.Name) + ast.DumpHelper(n, source, level, m, nil) +} + +// KindFootnoteBackLink is a NodeKind of the FootnoteBackLink node. +var KindFootnoteBackLink = ast.NewNodeKind("GiteaFootnoteBackLink") + +// Kind implements Node.Kind. +func (n *FootnoteBackLink) Kind() ast.NodeKind { + return KindFootnoteBackLink +} + +// NewFootnoteBackLink returns a new FootnoteBackLink node. +func NewFootnoteBackLink(index int, name []byte) *FootnoteBackLink { + return &FootnoteBackLink{ + Index: index, + Name: name, + } +} + +// A Footnote struct represents a footnote of Markdown +// (PHP Markdown Extra) text. +type Footnote struct { + ast.BaseBlock + Ref []byte + Index int + Name []byte +} + +// Dump implements Node.Dump. +func (n *Footnote) Dump(source []byte, level int) { + m := map[string]string{} + m["Index"] = fmt.Sprintf("%v", n.Index) + m["Ref"] = fmt.Sprintf("%s", n.Ref) + m["Name"] = fmt.Sprintf("%v", n.Name) + ast.DumpHelper(n, source, level, m, nil) +} + +// KindFootnote is a NodeKind of the Footnote node. +var KindFootnote = ast.NewNodeKind("GiteaFootnote") + +// Kind implements Node.Kind. +func (n *Footnote) Kind() ast.NodeKind { + return KindFootnote +} + +// NewFootnote returns a new Footnote node. +func NewFootnote(ref []byte) *Footnote { + return &Footnote{ + Ref: ref, + Index: -1, + Name: ref, + } +} + +// A FootnoteList struct represents footnotes of Markdown +// (PHP Markdown Extra) text. +type FootnoteList struct { + ast.BaseBlock + Count int +} + +// Dump implements Node.Dump. +func (n *FootnoteList) Dump(source []byte, level int) { + m := map[string]string{} + m["Count"] = fmt.Sprintf("%v", n.Count) + ast.DumpHelper(n, source, level, m, nil) +} + +// KindFootnoteList is a NodeKind of the FootnoteList node. +var KindFootnoteList = ast.NewNodeKind("GiteaFootnoteList") + +// Kind implements Node.Kind. +func (n *FootnoteList) Kind() ast.NodeKind { + return KindFootnoteList +} + +// NewFootnoteList returns a new FootnoteList node. +func NewFootnoteList() *FootnoteList { + return &FootnoteList{ + Count: 0, + } +} + +var footnoteListKey = parser.NewContextKey() + +type footnoteBlockParser struct { +} + +var defaultFootnoteBlockParser = &footnoteBlockParser{} + +// NewFootnoteBlockParser returns a new parser.BlockParser that can parse +// footnotes of the Markdown(PHP Markdown Extra) text. +func NewFootnoteBlockParser() parser.BlockParser { + return defaultFootnoteBlockParser +} + +func (b *footnoteBlockParser) Trigger() []byte { + return []byte{'['} +} + +func (b *footnoteBlockParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) { + line, segment := reader.PeekLine() + pos := pc.BlockOffset() + if pos < 0 || line[pos] != '[' { + return nil, parser.NoChildren + } + pos++ + if pos > len(line)-1 || line[pos] != '^' { + return nil, parser.NoChildren + } + open := pos + 1 + closes := 0 + closure := util.FindClosure(line[pos+1:], '[', ']', false, false) + closes = pos + 1 + closure + next := closes + 1 + if closure > -1 { + if next >= len(line) || line[next] != ':' { + return nil, parser.NoChildren + } + } else { + return nil, parser.NoChildren + } + padding := segment.Padding + label := reader.Value(text.NewSegment(segment.Start+open-padding, segment.Start+closes-padding)) + if util.IsBlank(label) { + return nil, parser.NoChildren + } + item := NewFootnote(label) + + pos = next + 1 - padding + if pos >= len(line) { + reader.Advance(pos) + return item, parser.NoChildren + } + reader.AdvanceAndSetPadding(pos, padding) + return item, parser.HasChildren +} + +func (b *footnoteBlockParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { + line, _ := reader.PeekLine() + if util.IsBlank(line) { + return parser.Continue | parser.HasChildren + } + childpos, padding := util.IndentPosition(line, reader.LineOffset(), 4) + if childpos < 0 { + return parser.Close + } + reader.AdvanceAndSetPadding(childpos, padding) + return parser.Continue | parser.HasChildren +} + +func (b *footnoteBlockParser) Close(node ast.Node, reader text.Reader, pc parser.Context) { + var list *FootnoteList + if tlist := pc.Get(footnoteListKey); tlist != nil { + list = tlist.(*FootnoteList) + } else { + list = NewFootnoteList() + pc.Set(footnoteListKey, list) + node.Parent().InsertBefore(node.Parent(), node, list) + } + node.Parent().RemoveChild(node.Parent(), node) + list.AppendChild(list, node) +} + +func (b *footnoteBlockParser) CanInterruptParagraph() bool { + return true +} + +func (b *footnoteBlockParser) CanAcceptIndentedLine() bool { + return false +} + +type footnoteParser struct { +} + +var defaultFootnoteParser = &footnoteParser{} + +// NewFootnoteParser returns a new parser.InlineParser that can parse +// footnote links of the Markdown(PHP Markdown Extra) text. +func NewFootnoteParser() parser.InlineParser { + return defaultFootnoteParser +} + +func (s *footnoteParser) Trigger() []byte { + // footnote syntax probably conflict with the image syntax. + // So we need trigger this parser with '!'. + return []byte{'!', '['} +} + +func (s *footnoteParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + line, segment := block.PeekLine() + pos := 1 + if len(line) > 0 && line[0] == '!' { + pos++ + } + if pos >= len(line) || line[pos] != '^' { + return nil + } + pos++ + if pos >= len(line) { + return nil + } + open := pos + closure := util.FindClosure(line[pos:], '[', ']', false, false) + if closure < 0 { + return nil + } + closes := pos + closure + value := block.Value(text.NewSegment(segment.Start+open, segment.Start+closes)) + block.Advance(closes + 1) + + var list *FootnoteList + if tlist := pc.Get(footnoteListKey); tlist != nil { + list = tlist.(*FootnoteList) + } + if list == nil { + return nil + } + index := 0 + name := []byte{} + for def := list.FirstChild(); def != nil; def = def.NextSibling() { + d := def.(*Footnote) + if bytes.Equal(d.Ref, value) { + if d.Index < 0 { + list.Count++ + d.Index = list.Count + val := CleanValue(d.Name) + if len(val) == 0 { + val = []byte(strconv.Itoa(d.Index)) + } + d.Name = pc.IDs().Generate(val, KindFootnote) + } + index = d.Index + name = d.Name + break + } + } + if index == 0 { + return nil + } + + return NewFootnoteLink(index, name) +} + +type footnoteASTTransformer struct { +} + +var defaultFootnoteASTTransformer = &footnoteASTTransformer{} + +// NewFootnoteASTTransformer returns a new parser.ASTTransformer that +// insert a footnote list to the last of the document. +func NewFootnoteASTTransformer() parser.ASTTransformer { + return defaultFootnoteASTTransformer +} + +func (a *footnoteASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { + var list *FootnoteList + if tlist := pc.Get(footnoteListKey); tlist != nil { + list = tlist.(*FootnoteList) + } else { + return + } + pc.Set(footnoteListKey, nil) + for footnote := list.FirstChild(); footnote != nil; { + var container ast.Node = footnote + next := footnote.NextSibling() + if fc := container.LastChild(); fc != nil && ast.IsParagraph(fc) { + container = fc + } + footnoteNode := footnote.(*Footnote) + index := footnoteNode.Index + name := footnoteNode.Name + if index < 0 { + list.RemoveChild(list, footnote) + } else { + container.AppendChild(container, NewFootnoteBackLink(index, name)) + } + footnote = next + } + list.SortChildren(func(n1, n2 ast.Node) int { + if n1.(*Footnote).Index < n2.(*Footnote).Index { + return -1 + } + return 1 + }) + if list.Count <= 0 { + list.Parent().RemoveChild(list.Parent(), list) + return + } + + node.AppendChild(node, list) +} + +// FootnoteHTMLRenderer is a renderer.NodeRenderer implementation that +// renders FootnoteLink nodes. +type FootnoteHTMLRenderer struct { + html.Config +} + +// NewFootnoteHTMLRenderer returns a new FootnoteHTMLRenderer. +func NewFootnoteHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { + r := &FootnoteHTMLRenderer{ + Config: html.NewConfig(), + } + for _, opt := range opts { + opt.SetHTMLOption(&r.Config) + } + return r +} + +// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. +func (r *FootnoteHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(KindFootnoteLink, r.renderFootnoteLink) + reg.Register(KindFootnoteBackLink, r.renderFootnoteBackLink) + reg.Register(KindFootnote, r.renderFootnote) + reg.Register(KindFootnoteList, r.renderFootnoteList) +} + +func (r *FootnoteHTMLRenderer) renderFootnoteLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + n := node.(*FootnoteLink) + n.Dump(source, 0) + is := strconv.Itoa(n.Index) + _, _ = w.WriteString(``) + _, _ = w.WriteString(is) + _, _ = w.WriteString(``) + } + return ast.WalkContinue, nil +} + +func (r *FootnoteHTMLRenderer) renderFootnoteBackLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + n := node.(*FootnoteBackLink) + fmt.Fprintf(os.Stdout, "source:\n%s\n", string(n.Text(source))) + _, _ = w.WriteString(` `) + _, _ = w.WriteString("↩︎") + _, _ = w.WriteString(``) + } + return ast.WalkContinue, nil +} + +func (r *FootnoteHTMLRenderer) renderFootnote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + n := node.(*Footnote) + if entering { + fmt.Fprintf(os.Stdout, "source:\n%s\n", string(n.Text(source))) + _, _ = w.WriteString(`
  • \n") + } else { + _, _ = w.WriteString("
  • \n") + } + return ast.WalkContinue, nil +} + +func (r *FootnoteHTMLRenderer) renderFootnoteList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + tag := "div" + if entering { + _, _ = w.WriteString("<") + _, _ = w.WriteString(tag) + _, _ = w.WriteString(` class="footnotes" role="doc-endnotes"`) + if node.Attributes() != nil { + html.RenderAttributes(w, node, html.GlobalAttributeFilter) + } + _ = w.WriteByte('>') + if r.Config.XHTML { + _, _ = w.WriteString("\n
    \n") + } else { + _, _ = w.WriteString("\n
    \n") + } + _, _ = w.WriteString("
      \n") + } else { + _, _ = w.WriteString("
    \n") + _, _ = w.WriteString("\n") + } + return ast.WalkContinue, nil +} + +type footnoteExtension struct{} + +// FootnoteExtension represents the Gitea Footnote +var FootnoteExtension = &footnoteExtension{} + +// Extend extends the markdown converter with the Gitea Footnote parser +func (e *footnoteExtension) Extend(m goldmark.Markdown) { + m.Parser().AddOptions( + parser.WithBlockParsers( + util.Prioritized(NewFootnoteBlockParser(), 999), + ), + parser.WithInlineParsers( + util.Prioritized(NewFootnoteParser(), 101), + ), + parser.WithASTTransformers( + util.Prioritized(NewFootnoteASTTransformer(), 999), + ), + ) + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(NewFootnoteHTMLRenderer(), 500), + )) +} diff --git a/modules/markup/common/html.go b/modules/markup/common/html.go new file mode 100644 index 0000000000..3a47686f1e --- /dev/null +++ b/modules/markup/common/html.go @@ -0,0 +1,19 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package common + +import ( + "mvdan.cc/xurls/v2" +) + +var ( + // NOTE: All below regex matching do not perform any extra validation. + // Thus a link is produced even if the linked entity does not exist. + // While fast, this is also incorrect and lead to false positives. + // TODO: fix invalid linking issue + + // LinkRegex is a regexp matching a valid link + LinkRegex, _ = xurls.StrictMatchingScheme("https?://") +) diff --git a/modules/markup/common/linkify.go b/modules/markup/common/linkify.go new file mode 100644 index 0000000000..6ae70fba34 --- /dev/null +++ b/modules/markup/common/linkify.go @@ -0,0 +1,156 @@ +// Copyright 2019 Yusuke Inuzuka +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// Most of this file is a subtly changed version of github.com/yuin/goldmark/extension/linkify.go + +package common + +import ( + "bytes" + "regexp" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +var wwwURLRegxp = regexp.MustCompile(`^www\.[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}((?:/|[#?])[-a-zA-Z0-9@:%_\+.~#!?&//=\(\);,'">\^{}\[\]` + "`" + `]*)?`) + +type linkifyParser struct { +} + +var defaultLinkifyParser = &linkifyParser{} + +// NewLinkifyParser return a new InlineParser can parse +// text that seems like a URL. +func NewLinkifyParser() parser.InlineParser { + return defaultLinkifyParser +} + +func (s *linkifyParser) Trigger() []byte { + // ' ' indicates any white spaces and a line head + return []byte{' ', '*', '_', '~', '('} +} + +var protoHTTP = []byte("http:") +var protoHTTPS = []byte("https:") +var protoFTP = []byte("ftp:") +var domainWWW = []byte("www.") + +func (s *linkifyParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + if pc.IsInLinkLabel() { + return nil + } + line, segment := block.PeekLine() + consumes := 0 + start := segment.Start + c := line[0] + // advance if current position is not a line head. + if c == ' ' || c == '*' || c == '_' || c == '~' || c == '(' { + consumes++ + start++ + line = line[1:] + } + + var m []int + var protocol []byte + var typ ast.AutoLinkType = ast.AutoLinkURL + if bytes.HasPrefix(line, protoHTTP) || bytes.HasPrefix(line, protoHTTPS) || bytes.HasPrefix(line, protoFTP) { + m = LinkRegex.FindSubmatchIndex(line) + } + if m == nil && bytes.HasPrefix(line, domainWWW) { + m = wwwURLRegxp.FindSubmatchIndex(line) + protocol = []byte("http") + } + if m != nil { + lastChar := line[m[1]-1] + if lastChar == '.' { + m[1]-- + } else if lastChar == ')' { + closing := 0 + for i := m[1] - 1; i >= m[0]; i-- { + if line[i] == ')' { + closing++ + } else if line[i] == '(' { + closing-- + } + } + if closing > 0 { + m[1] -= closing + } + } else if lastChar == ';' { + i := m[1] - 2 + for ; i >= m[0]; i-- { + if util.IsAlphaNumeric(line[i]) { + continue + } + break + } + if i != m[1]-2 { + if line[i] == '&' { + m[1] -= m[1] - i + } + } + } + } + if m == nil { + if len(line) > 0 && util.IsPunct(line[0]) { + return nil + } + typ = ast.AutoLinkEmail + stop := util.FindEmailIndex(line) + if stop < 0 { + return nil + } + at := bytes.IndexByte(line, '@') + m = []int{0, stop, at, stop - 1} + if m == nil || bytes.IndexByte(line[m[2]:m[3]], '.') < 0 { + return nil + } + lastChar := line[m[1]-1] + if lastChar == '.' { + m[1]-- + } + if m[1] < len(line) { + nextChar := line[m[1]] + if nextChar == '-' || nextChar == '_' { + return nil + } + } + } + if m == nil { + return nil + } + if consumes != 0 { + s := segment.WithStop(segment.Start + 1) + ast.MergeOrAppendTextSegment(parent, s) + } + consumes += m[1] + block.Advance(consumes) + n := ast.NewTextSegment(text.NewSegment(start, start+m[1])) + link := ast.NewAutoLink(typ, n) + link.Protocol = protocol + return link +} + +func (s *linkifyParser) CloseBlock(parent ast.Node, pc parser.Context) { + // nothing to do +} + +type linkify struct { +} + +// Linkify is an extension that allow you to parse text that seems like a URL. +var Linkify = &linkify{} + +func (e *linkify) Extend(m goldmark.Markdown) { + m.Parser().AddOptions( + parser.WithInlineParsers( + util.Prioritized(NewLinkifyParser(), 999), + ), + ) +} diff --git a/modules/markup/html.go b/modules/markup/html.go index b10da40fc1..2c6773bce4 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup/common" "code.gitea.io/gitea/modules/references" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -57,8 +58,6 @@ var ( // https://html.spec.whatwg.org/multipage/input.html#e-mail-state-(type%3Demail) emailRegex = regexp.MustCompile("(?:\\s|^|\\(|\\[)([a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9]{2,}(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+)(?:\\s|$|\\)|\\]|\\.(\\s|$))") - linkRegex, _ = xurls.StrictMatchingScheme("https?://") - // blackfriday extensions create IDs like fn:user-content-footnote blackfridayExtRegex = regexp.MustCompile(`[^:]*:user-content-`) ) @@ -118,7 +117,7 @@ func CustomLinkURLSchemes(schemes []string) { } withAuth = append(withAuth, s) } - linkRegex, _ = xurls.StrictMatchingScheme(strings.Join(withAuth, "|")) + common.LinkRegex, _ = xurls.StrictMatchingScheme(strings.Join(withAuth, "|")) } // IsSameDomain checks if given url string has the same hostname as current Gitea instance @@ -509,6 +508,12 @@ func shortLinkProcessorFull(ctx *postProcessCtx, node *html.Node, noLink bool) { (strings.HasPrefix(val, "‘") && strings.HasSuffix(val, "’")) { const lenQuote = len("‘") val = val[lenQuote : len(val)-lenQuote] + } else if (strings.HasPrefix(val, "\"") && strings.HasSuffix(val, "\"")) || + (strings.HasPrefix(val, "'") && strings.HasSuffix(val, "'")) { + val = val[1 : len(val)-1] + } else if strings.HasPrefix(val, "'") && strings.HasSuffix(val, "’") { + const lenQuote = len("‘") + val = val[1 : len(val)-lenQuote] } props[key] = val } @@ -803,7 +808,7 @@ func emailAddressProcessor(ctx *postProcessCtx, node *html.Node) { // linkProcessor creates links for any HTTP or HTTPS URL not captured by // markdown. func linkProcessor(ctx *postProcessCtx, node *html.Node) { - m := linkRegex.FindStringIndex(node.Data) + m := common.LinkRegex.FindStringIndex(node.Data) if m == nil { return } @@ -832,7 +837,7 @@ func genDefaultLinkProcessor(defaultLink string) processor { // descriptionLinkProcessor creates links for DescriptionHTML func descriptionLinkProcessor(ctx *postProcessCtx, node *html.Node) { - m := linkRegex.FindStringIndex(node.Data) + m := common.LinkRegex.FindStringIndex(node.Data) if m == nil { return } diff --git a/modules/markup/html_test.go b/modules/markup/html_test.go index 07747e97e1..91ef320b40 100644 --- a/modules/markup/html_test.go +++ b/modules/markup/html_test.go @@ -323,6 +323,6 @@ func TestRender_ShortLinks(t *testing.T) { `

    `) test( "

    [[foobar]]

    ", - `

    [[foobar]]

    `, - `

    [[foobar]]

    `) + `

    [[foobar]]

    `, + `

    [[foobar]]

    `) } diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go new file mode 100644 index 0000000000..2a2a9dce6a --- /dev/null +++ b/modules/markup/markdown/goldmark.go @@ -0,0 +1,178 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package markdown + +import ( + "bytes" + "fmt" + "strings" + + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/common" + giteautil "code.gitea.io/gitea/modules/util" + + "github.com/yuin/goldmark/ast" + east "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +var byteMailto = []byte("mailto:") + +// GiteaASTTransformer is a default transformer of the goldmark tree. +type GiteaASTTransformer struct{} + +// Transform transforms the given AST tree. +func (g *GiteaASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { + _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + switch v := n.(type) { + case *ast.Image: + // Images need two things: + // + // 1. Their src needs to munged to be a real value + // 2. If they're not wrapped with a link they need a link wrapper + + // Check if the destination is a real link + link := v.Destination + if len(link) > 0 && !markup.IsLink(link) { + prefix := pc.Get(urlPrefixKey).(string) + if pc.Get(isWikiKey).(bool) { + prefix = giteautil.URLJoin(prefix, "wiki", "raw") + } + prefix = strings.Replace(prefix, "/src/", "/media/", 1) + + lnk := string(link) + lnk = giteautil.URLJoin(prefix, lnk) + lnk = strings.Replace(lnk, " ", "+", -1) + link = []byte(lnk) + } + v.Destination = link + + parent := n.Parent() + // Create a link around image only if parent is not already a link + if _, ok := parent.(*ast.Link); !ok && parent != nil { + wrap := ast.NewLink() + wrap.Destination = link + wrap.Title = v.Title + parent.ReplaceChild(parent, n, wrap) + wrap.AppendChild(wrap, n) + } + case *ast.Link: + // Links need their href to munged to be a real value + link := v.Destination + if len(link) > 0 && !markup.IsLink(link) && + link[0] != '#' && !bytes.HasPrefix(link, byteMailto) { + // special case: this is not a link, a hash link or a mailto:, so it's a + // relative URL + lnk := string(link) + if pc.Get(isWikiKey).(bool) { + lnk = giteautil.URLJoin("wiki", lnk) + } + link = []byte(giteautil.URLJoin(pc.Get(urlPrefixKey).(string), lnk)) + } + v.Destination = link + } + return ast.WalkContinue, nil + }) +} + +type prefixedIDs struct { + values map[string]bool +} + +// Generate generates a new element id. +func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte { + dft := []byte("id") + if kind == ast.KindHeading { + dft = []byte("heading") + } + return p.GenerateWithDefault(value, dft) +} + +// Generate generates a new element id. +func (p *prefixedIDs) GenerateWithDefault(value []byte, dft []byte) []byte { + result := common.CleanValue(value) + if len(result) == 0 { + result = dft + } + if !bytes.HasPrefix(result, []byte("user-content-")) { + result = append([]byte("user-content-"), result...) + } + if _, ok := p.values[util.BytesToReadOnlyString(result)]; !ok { + p.values[util.BytesToReadOnlyString(result)] = true + return result + } + for i := 1; ; i++ { + newResult := fmt.Sprintf("%s-%d", result, i) + if _, ok := p.values[newResult]; !ok { + p.values[newResult] = true + return []byte(newResult) + } + } +} + +// Put puts a given element id to the used ids table. +func (p *prefixedIDs) Put(value []byte) { + p.values[util.BytesToReadOnlyString(value)] = true +} + +func newPrefixedIDs() *prefixedIDs { + return &prefixedIDs{ + values: map[string]bool{}, + } +} + +// NewTaskCheckBoxHTMLRenderer creates a TaskCheckBoxHTMLRenderer to render tasklists +// in the gitea form. +func NewTaskCheckBoxHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { + r := &TaskCheckBoxHTMLRenderer{ + Config: html.NewConfig(), + } + for _, opt := range opts { + opt.SetHTMLOption(&r.Config) + } + return r +} + +// TaskCheckBoxHTMLRenderer is a renderer.NodeRenderer implementation that +// renders checkboxes in list items. +// Overrides the default goldmark one to present the gitea format +type TaskCheckBoxHTMLRenderer struct { + html.Config +} + +// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. +func (r *TaskCheckBoxHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox) +} + +func (r *TaskCheckBoxHTMLRenderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + n := node.(*east.TaskCheckBox) + + end := ">" + if r.XHTML { + end = " />" + } + var err error + if n.IsChecked { + _, err = w.WriteString(``) + } else { + _, err = w.WriteString(``) + } + if err != nil { + return ast.WalkStop, err + } + return ast.WalkContinue, nil +} diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go index f1e44a8fbc..5230fca4dc 100644 --- a/modules/markup/markdown/markdown.go +++ b/modules/markup/markdown/markdown.go @@ -7,161 +7,83 @@ package markdown import ( "bytes" - "io" - "strings" + "sync" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/common" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" + giteautil "code.gitea.io/gitea/modules/util" - "github.com/russross/blackfriday/v2" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/util" ) -// Renderer is a extended version of underlying render object. -type Renderer struct { - blackfriday.Renderer - URLPrefix string - IsWiki bool +var converter goldmark.Markdown +var once = sync.Once{} + +var urlPrefixKey = parser.NewContextKey() +var isWikiKey = parser.NewContextKey() + +// NewGiteaParseContext creates a parser.Context with the gitea context set +func NewGiteaParseContext(urlPrefix string, isWiki bool) parser.Context { + pc := parser.NewContext(parser.WithIDs(newPrefixedIDs())) + pc.Set(urlPrefixKey, urlPrefix) + pc.Set(isWikiKey, isWiki) + return pc } -var byteMailto = []byte("mailto:") - -var htmlEscaper = [256][]byte{ - '&': []byte("&"), - '<': []byte("<"), - '>': []byte(">"), - '"': []byte("""), -} - -func escapeHTML(w io.Writer, s []byte) { - var start, end int - for end < len(s) { - escSeq := htmlEscaper[s[end]] - if escSeq != nil { - _, _ = w.Write(s[start:end]) - _, _ = w.Write(escSeq) - start = end + 1 - } - end++ - } - if start < len(s) && end <= len(s) { - _, _ = w.Write(s[start:end]) - } -} - -// RenderNode is a default renderer of a single node of a syntax tree. For -// block nodes it will be called twice: first time with entering=true, second -// time with entering=false, so that it could know when it's working on an open -// tag and when on close. It writes the result to w. -// -// The return value is a way to tell the calling walker to adjust its walk -// pattern: e.g. it can terminate the traversal by returning Terminate. Or it -// can ask the walker to skip a subtree of this node by returning SkipChildren. -// The typical behavior is to return GoToNext, which asks for the usual -// traversal to the next node. -func (r *Renderer) RenderNode(w io.Writer, node *blackfriday.Node, entering bool) blackfriday.WalkStatus { - switch node.Type { - case blackfriday.Image: - prefix := r.URLPrefix - if r.IsWiki { - prefix = util.URLJoin(prefix, "wiki", "raw") - } - prefix = strings.Replace(prefix, "/src/", "/media/", 1) - link := node.LinkData.Destination - if len(link) > 0 && !markup.IsLink(link) { - lnk := string(link) - lnk = util.URLJoin(prefix, lnk) - lnk = strings.Replace(lnk, " ", "+", -1) - link = []byte(lnk) - } - node.LinkData.Destination = link - // Render link around image only if parent is not link already - if node.Parent != nil && node.Parent.Type != blackfriday.Link { - if entering { - _, _ = w.Write([]byte(``)) - return r.Renderer.RenderNode(w, node, entering) - } - s := r.Renderer.RenderNode(w, node, entering) - _, _ = w.Write([]byte(``)) - return s - } - return r.Renderer.RenderNode(w, node, entering) - case blackfriday.Link: - // special case: this is not a link, a hash link or a mailto:, so it's a - // relative URL - link := node.LinkData.Destination - if len(link) > 0 && !markup.IsLink(link) && - link[0] != '#' && !bytes.HasPrefix(link, byteMailto) && - node.LinkData.Footnote == nil { - lnk := string(link) - if r.IsWiki { - lnk = util.URLJoin("wiki", lnk) - } - link = []byte(util.URLJoin(r.URLPrefix, lnk)) - } - node.LinkData.Destination = link - return r.Renderer.RenderNode(w, node, entering) - case blackfriday.Text: - isListItem := false - for n := node.Parent; n != nil; n = n.Parent { - if n.Type == blackfriday.Item { - isListItem = true - break - } - } - if isListItem { - text := node.Literal - switch { - case bytes.HasPrefix(text, []byte("[ ] ")): - _, _ = w.Write([]byte(``)) - text = text[3:] - case bytes.HasPrefix(text, []byte("[x] ")): - _, _ = w.Write([]byte(``)) - text = text[3:] - } - node.Literal = text - } - } - return r.Renderer.RenderNode(w, node, entering) -} - -const ( - blackfridayExtensions = 0 | - blackfriday.NoIntraEmphasis | - blackfriday.Tables | - blackfriday.FencedCode | - blackfriday.Strikethrough | - blackfriday.NoEmptyLineBeforeBlock | - blackfriday.DefinitionLists | - blackfriday.Footnotes | - blackfriday.HeadingIDs | - blackfriday.AutoHeadingIDs - blackfridayHTMLFlags = 0 | - blackfriday.Smartypants -) - // RenderRaw renders Markdown to HTML without handling special links. func RenderRaw(body []byte, urlPrefix string, wikiMarkdown bool) []byte { - renderer := &Renderer{ - Renderer: blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{ - Flags: blackfridayHTMLFlags, - FootnoteAnchorPrefix: "user-content-", - HeadingIDPrefix: "user-content-", - }), - URLPrefix: urlPrefix, - IsWiki: wikiMarkdown, + once.Do(func() { + converter = goldmark.New( + goldmark.WithExtensions(extension.Table, + extension.Strikethrough, + extension.TaskList, + extension.DefinitionList, + common.FootnoteExtension, + extension.NewTypographer( + extension.WithTypographicSubstitutions(extension.TypographicSubstitutions{ + extension.EnDash: nil, + extension.EmDash: nil, + }), + ), + ), + goldmark.WithParserOptions( + parser.WithAttribute(), + parser.WithAutoHeadingID(), + parser.WithASTTransformers( + util.Prioritized(&GiteaASTTransformer{}, 10000), + ), + ), + goldmark.WithRendererOptions( + html.WithUnsafe(), + ), + ) + + // Override the original Tasklist renderer! + converter.Renderer().AddOptions( + renderer.WithNodeRenderers( + util.Prioritized(NewTaskCheckBoxHTMLRenderer(), 1000), + ), + ) + + if setting.Markdown.EnableHardLineBreak { + converter.Renderer().AddOptions(html.WithHardWraps()) + } + }) + + pc := NewGiteaParseContext(urlPrefix, wikiMarkdown) + var buf bytes.Buffer + if err := converter.Convert(giteautil.NormalizeEOL(body), &buf, parser.WithContext(pc)); err != nil { + log.Error("Unable to render: %v", err) } - exts := blackfridayExtensions - if setting.Markdown.EnableHardLineBreak { - exts |= blackfriday.HardLineBreak - } - - // Need to normalize EOL to UNIX LF to have consistent results in rendering - body = blackfriday.Run(util.NormalizeEOL(body), blackfriday.WithRenderer(renderer), blackfriday.WithExtensions(exts)) - return markup.SanitizeBytes(body) + return markup.SanitizeReader(&buf).Bytes() } var ( @@ -174,8 +96,7 @@ func init() { } // Parser implements markup.Parser -type Parser struct { -} +type Parser struct{} // Name implements markup.Parser func (Parser) Name() string { diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go index e3156a657b..53772ee441 100644 --- a/modules/markup/markdown/markdown_test.go +++ b/modules/markup/markdown/markdown_test.go @@ -98,16 +98,12 @@ func TestRender_Images(t *testing.T) { func testAnswers(baseURLContent, baseURLImages string) []string { return []string{ `

    Wiki! Enjoy :)

    - -

    See commit 65f1bf27bc

    -

    Ideas and codes

    - `, `

    What is Wine Staging?

    -

    Wine Staging on website wine-staging.com.

    - -

    Here are some links to the most important topics. You can find the full list of pages at the sidebar.

    - @@ -131,7 +123,6 @@ func testAnswers(baseURLContent, baseURLImages string) []string { - @@ -141,20 +132,15 @@ func testAnswers(baseURLContent, baseURLImages string) []string {
    Installation
    images/icon-usage.png
    `, `

    Excelsior JET allows you to create native executables for Windows, Linux and Mac OS X.

    -
    1. Package your libGDX application images/1.png
    2. Perform a test run by hitting the Run! button. images/2.png
    -

    More tests

    -

    (from https://www.markdownguide.org/extended-syntax/)

    -

    Definition list

    -
    First Term
    This is the definition of the first term.
    @@ -162,27 +148,21 @@ func testAnswers(baseURLContent, baseURLImages string) []string {
    This is one definition of the second term.
    This is another definition of the second term.
    -

    Footnotes

    -

    Here is a simple footnote,1 and here is a longer one.2

    -
    -
    -
      -
    1. This is the first footnote.
    2. - -
    3. Here is one with multiple paragraphs and code.

      - +
    4. +

      This is the first footnote. ↩︎

      +
    5. +
    6. +

      Here is one with multiple paragraphs and code.

      Indent paragraphs to include them in the footnote.

      -

      { my code }

      - -

      Add as many paragraphs as you like.

    7. +

      Add as many paragraphs as you like. ↩︎

      +
    -
    `, } @@ -299,15 +279,15 @@ func TestRender_RenderParagraphs(t *testing.T) { test := func(t *testing.T, str string, cnt int) { unix := []byte(str) res := string(RenderRaw(unix, "", false)) - assert.Equal(t, strings.Count(res, "