Browse Source

Restructure markup & markdown to prepare for multiple markup language… (#2411)

* restructure markup & markdown to prepare for multiple markup languages support

* adjust some functions between markdown and markup

* fix tests

* improve the comments
Lunny Xiao 2 years ago
parent
commit
52e11b24bf

+ 2 - 2
models/issue_comment.go

@@ -16,7 +16,7 @@ import (
16 16
 	api "code.gitea.io/sdk/gitea"
17 17
 
18 18
 	"code.gitea.io/gitea/modules/log"
19
-	"code.gitea.io/gitea/modules/markdown"
19
+	"code.gitea.io/gitea/modules/markup"
20 20
 )
21 21
 
22 22
 // CommentType defines whether a comment is just a simple comment, an action (like close) or a reference.
@@ -272,7 +272,7 @@ func (c *Comment) LoadAssignees() error {
272 272
 // MailParticipants sends new comment emails to repository watchers
273 273
 // and mentioned people.
274 274
 func (c *Comment) MailParticipants(e Engine, opType ActionType, issue *Issue) (err error) {
275
-	mentions := markdown.FindAllMentions(c.Content)
275
+	mentions := markup.FindAllMentions(c.Content)
276 276
 	if err = UpdateIssueMentions(e, c.IssueID, mentions); err != nil {
277 277
 		return fmt.Errorf("UpdateIssueMentions [%d]: %v", c.IssueID, err)
278 278
 	}

+ 2 - 2
models/issue_mail.go

@@ -10,7 +10,7 @@ import (
10 10
 	"github.com/Unknwon/com"
11 11
 
12 12
 	"code.gitea.io/gitea/modules/log"
13
-	"code.gitea.io/gitea/modules/markdown"
13
+	"code.gitea.io/gitea/modules/markup"
14 14
 	"code.gitea.io/gitea/modules/setting"
15 15
 )
16 16
 
@@ -104,7 +104,7 @@ func (issue *Issue) MailParticipants() (err error) {
104 104
 }
105 105
 
106 106
 func (issue *Issue) mailParticipants(e Engine) (err error) {
107
-	mentions := markdown.FindAllMentions(issue.Content)
107
+	mentions := markup.FindAllMentions(issue.Content)
108 108
 	if err = UpdateIssueMentions(e, issue.ID, mentions); err != nil {
109 109
 		return fmt.Errorf("UpdateIssueMentions [%d]: %v", issue.ID, err)
110 110
 	}

+ 2 - 1
models/mail.go

@@ -14,6 +14,7 @@ import (
14 14
 	"code.gitea.io/gitea/modules/log"
15 15
 	"code.gitea.io/gitea/modules/mailer"
16 16
 	"code.gitea.io/gitea/modules/markdown"
17
+	"code.gitea.io/gitea/modules/markup"
17 18
 	"code.gitea.io/gitea/modules/setting"
18 19
 	"gopkg.in/gomail.v2"
19 20
 	"gopkg.in/macaron.v1"
@@ -150,7 +151,7 @@ func composeTplData(subject, body, link string) map[string]interface{} {
150 151
 
151 152
 func composeIssueCommentMessage(issue *Issue, doer *User, comment *Comment, tplName base.TplName, tos []string, info string) *mailer.Message {
152 153
 	subject := issue.mailSubject()
153
-	body := string(markdown.RenderString(issue.Content, issue.Repo.HTMLURL(), issue.Repo.ComposeMetas()))
154
+	body := string(markup.RenderByType(markdown.MarkupName, []byte(issue.Content), issue.Repo.HTMLURL(), issue.Repo.ComposeMetas()))
154 155
 
155 156
 	data := make(map[string]interface{}, 10)
156 157
 	if comment != nil {

+ 2 - 2
models/migrations/v16.go

@@ -8,7 +8,7 @@ import (
8 8
 	"fmt"
9 9
 	"time"
10 10
 
11
-	"code.gitea.io/gitea/modules/markdown"
11
+	"code.gitea.io/gitea/modules/markup"
12 12
 
13 13
 	"github.com/go-xorm/xorm"
14 14
 )
@@ -101,7 +101,7 @@ func addUnitsToTables(x *xorm.Engine) error {
101 101
 				config["ExternalTrackerURL"] = repo.ExternalTrackerURL
102 102
 				config["ExternalTrackerFormat"] = repo.ExternalTrackerFormat
103 103
 				if len(repo.ExternalTrackerStyle) == 0 {
104
-					repo.ExternalTrackerStyle = markdown.IssueNameStyleNumeric
104
+					repo.ExternalTrackerStyle = markup.IssueNameStyleNumeric
105 105
 				}
106 106
 				config["ExternalTrackerStyle"] = repo.ExternalTrackerStyle
107 107
 			case V16UnitTypeExternalWiki:

+ 5 - 5
models/repo.go

@@ -22,7 +22,7 @@ import (
22 22
 
23 23
 	"code.gitea.io/git"
24 24
 	"code.gitea.io/gitea/modules/log"
25
-	"code.gitea.io/gitea/modules/markdown"
25
+	"code.gitea.io/gitea/modules/markup"
26 26
 	"code.gitea.io/gitea/modules/options"
27 27
 	"code.gitea.io/gitea/modules/process"
28 28
 	"code.gitea.io/gitea/modules/setting"
@@ -480,10 +480,10 @@ func (repo *Repository) ComposeMetas() map[string]string {
480 480
 			"repo":   repo.Name,
481 481
 		}
482 482
 		switch unit.ExternalTrackerConfig().ExternalTrackerStyle {
483
-		case markdown.IssueNameStyleAlphanumeric:
484
-			repo.ExternalMetas["style"] = markdown.IssueNameStyleAlphanumeric
483
+		case markup.IssueNameStyleAlphanumeric:
484
+			repo.ExternalMetas["style"] = markup.IssueNameStyleAlphanumeric
485 485
 		default:
486
-			repo.ExternalMetas["style"] = markdown.IssueNameStyleNumeric
486
+			repo.ExternalMetas["style"] = markup.IssueNameStyleNumeric
487 487
 		}
488 488
 
489 489
 	}
@@ -708,7 +708,7 @@ func (repo *Repository) DescriptionHTML() template.HTML {
708 708
 	sanitize := func(s string) string {
709 709
 		return fmt.Sprintf(`<a href="%[1]s" target="_blank" rel="noopener">%[1]s</a>`, s)
710 710
 	}
711
-	return template.HTML(descPattern.ReplaceAllStringFunc(markdown.Sanitize(repo.Description), sanitize))
711
+	return template.HTML(descPattern.ReplaceAllStringFunc(markup.Sanitize(repo.Description), sanitize))
712 712
 }
713 713
 
714 714
 // LocalCopyPath returns the local repository copy path

+ 6 - 6
models/repo_test.go

@@ -8,7 +8,7 @@ import (
8 8
 	"path"
9 9
 	"testing"
10 10
 
11
-	"code.gitea.io/gitea/modules/markdown"
11
+	"code.gitea.io/gitea/modules/markup"
12 12
 	"code.gitea.io/gitea/modules/setting"
13 13
 
14 14
 	"github.com/Unknwon/com"
@@ -39,13 +39,13 @@ func TestRepo(t *testing.T) {
39 39
 		assert.Equal(t, "https://someurl.com/{user}/{repo}/{issue}", metas["format"])
40 40
 	}
41 41
 
42
-	testSuccess(markdown.IssueNameStyleNumeric)
42
+	testSuccess(markup.IssueNameStyleNumeric)
43 43
 
44
-	externalTracker.ExternalTrackerConfig().ExternalTrackerStyle = markdown.IssueNameStyleAlphanumeric
45
-	testSuccess(markdown.IssueNameStyleAlphanumeric)
44
+	externalTracker.ExternalTrackerConfig().ExternalTrackerStyle = markup.IssueNameStyleAlphanumeric
45
+	testSuccess(markup.IssueNameStyleAlphanumeric)
46 46
 
47
-	externalTracker.ExternalTrackerConfig().ExternalTrackerStyle = markdown.IssueNameStyleNumeric
48
-	testSuccess(markdown.IssueNameStyleNumeric)
47
+	externalTracker.ExternalTrackerConfig().ExternalTrackerStyle = markup.IssueNameStyleNumeric
48
+	testSuccess(markup.IssueNameStyleNumeric)
49 49
 }
50 50
 
51 51
 func TestGetRepositoryCount(t *testing.T) {

+ 28 - 546
modules/markdown/markdown.go

@@ -6,107 +6,14 @@ package markdown
6 6
 
7 7
 import (
8 8
 	"bytes"
9
-	"fmt"
10
-	"io"
11
-	"net/url"
12
-	"path"
13
-	"path/filepath"
14
-	"regexp"
15 9
 	"strings"
16 10
 
17
-	"code.gitea.io/gitea/modules/base"
18
-	"code.gitea.io/gitea/modules/log"
19 11
 	"code.gitea.io/gitea/modules/markup"
20 12
 	"code.gitea.io/gitea/modules/setting"
21 13
 
22
-	"github.com/Unknwon/com"
23 14
 	"github.com/russross/blackfriday"
24
-	"golang.org/x/net/html"
25 15
 )
26 16
 
27
-// Issue name styles
28
-const (
29
-	IssueNameStyleNumeric      = "numeric"
30
-	IssueNameStyleAlphanumeric = "alphanumeric"
31
-)
32
-
33
-// IsMarkdownFile reports whether name looks like a Markdown file
34
-// based on its extension.
35
-func IsMarkdownFile(name string) bool {
36
-	extension := strings.ToLower(filepath.Ext(name))
37
-	for _, ext := range setting.Markdown.FileExtensions {
38
-		if strings.ToLower(ext) == extension {
39
-			return true
40
-		}
41
-	}
42
-	return false
43
-}
44
-
45
-var (
46
-	// NOTE: All below regex matching do not perform any extra validation.
47
-	// Thus a link is produced even if the user does not exist, the issue does not exist, the commit does not exist, etc.
48
-	// While fast, this is also incorrect and lead to false positives.
49
-
50
-	// MentionPattern matches string that mentions someone, e.g. @Unknwon
51
-	MentionPattern = regexp.MustCompile(`(\s|^|\W)@[0-9a-zA-Z-_\.]+`)
52
-
53
-	// IssueNumericPattern matches string that references to a numeric issue, e.g. #1287
54
-	IssueNumericPattern = regexp.MustCompile(`( |^|\()#[0-9]+\b`)
55
-	// IssueAlphanumericPattern matches string that references to an alphanumeric issue, e.g. ABC-1234
56
-	IssueAlphanumericPattern = regexp.MustCompile(`( |^|\()[A-Z]{1,10}-[1-9][0-9]*\b`)
57
-	// CrossReferenceIssueNumericPattern matches string that references a numeric issue in a different repository
58
-	// e.g. gogits/gogs#12345
59
-	CrossReferenceIssueNumericPattern = regexp.MustCompile(`( |^)[0-9a-zA-Z]+/[0-9a-zA-Z]+#[0-9]+\b`)
60
-
61
-	// Sha1CurrentPattern matches string that represents a commit SHA, e.g. d8a994ef243349f321568f9e36d5c3f444b99cae
62
-	// Although SHA1 hashes are 40 chars long, the regex matches the hash from 7 to 40 chars in length
63
-	// so that abbreviated hash links can be used as well. This matches git and github useability.
64
-	Sha1CurrentPattern = regexp.MustCompile(`(?:^|\s|\()([0-9a-f]{7,40})\b`)
65
-
66
-	// ShortLinkPattern matches short but difficult to parse [[name|link|arg=test]] syntax
67
-	ShortLinkPattern = regexp.MustCompile(`(\[\[.*?\]\]\w*)`)
68
-
69
-	// AnySHA1Pattern allows to split url containing SHA into parts
70
-	AnySHA1Pattern = regexp.MustCompile(`(http\S*)://(\S+)/(\S+)/(\S+)/(\S+)/([0-9a-f]{40})(?:/?([^#\s]+)?(?:#(\S+))?)?`)
71
-
72
-	validLinksPattern = regexp.MustCompile(`^[a-z][\w-]+://`)
73
-)
74
-
75
-// regexp for full links to issues/pulls
76
-var issueFullPattern *regexp.Regexp
77
-
78
-// InitMarkdown initialize regexps for markdown parsing
79
-func InitMarkdown() {
80
-	getIssueFullPattern()
81
-}
82
-
83
-func getIssueFullPattern() *regexp.Regexp {
84
-	if issueFullPattern == nil {
85
-		appURL := setting.AppURL
86
-		if len(appURL) > 0 && appURL[len(appURL)-1] != '/' {
87
-			appURL += "/"
88
-		}
89
-		issueFullPattern = regexp.MustCompile(appURL +
90
-			`\w+/\w+/(?:issues|pulls)/((?:\w{1,10}-)?[1-9][0-9]*)([\?|#]\S+.(\S+)?)?\b`)
91
-	}
92
-	return issueFullPattern
93
-}
94
-
95
-// isLink reports whether link fits valid format.
96
-func isLink(link []byte) bool {
97
-	return validLinksPattern.Match(link)
98
-}
99
-
100
-// FindAllMentions matches mention patterns in given content
101
-// and returns a list of found user names without @ prefix.
102
-func FindAllMentions(content string) []string {
103
-	mentions := MentionPattern.FindAllString(content, -1)
104
-	for i := range mentions {
105
-		mentions[i] = mentions[i][strings.Index(mentions[i], "@")+1:] // Strip @ character
106
-	}
107
-	return mentions
108
-}
109
-
110 17
 // Renderer is a extended version of underlying render object.
111 18
 type Renderer struct {
112 19
 	blackfriday.Renderer
@@ -116,13 +23,13 @@ type Renderer struct {
116 23
 
117 24
 // Link defines how formal links should be processed to produce corresponding HTML elements.
118 25
 func (r *Renderer) Link(out *bytes.Buffer, link []byte, title []byte, content []byte) {
119
-	if len(link) > 0 && !isLink(link) {
26
+	if len(link) > 0 && !markup.IsLink(link) {
120 27
 		if link[0] != '#' {
121 28
 			lnk := string(link)
122 29
 			if r.isWikiMarkdown {
123
-				lnk = URLJoin("wiki", lnk)
30
+				lnk = markup.URLJoin("wiki", lnk)
124 31
 			}
125
-			mLink := URLJoin(r.urlPrefix, lnk)
32
+			mLink := markup.URLJoin(r.urlPrefix, lnk)
126 33
 			link = []byte(mLink)
127 34
 		}
128 35
 	}
@@ -190,11 +97,11 @@ var (
190 97
 func (r *Renderer) Image(out *bytes.Buffer, link []byte, title []byte, alt []byte) {
191 98
 	prefix := r.urlPrefix
192 99
 	if r.isWikiMarkdown {
193
-		prefix = URLJoin(prefix, "wiki", "src")
100
+		prefix = markup.URLJoin(prefix, "wiki", "src")
194 101
 	}
195 102
 	prefix = strings.Replace(prefix, "/src/", "/raw/", 1)
196 103
 	if len(link) > 0 {
197
-		if isLink(link) {
104
+		if markup.IsLink(link) {
198 105
 			// External link with .svg suffix usually means CI status.
199 106
 			// TODO: define a keyword to allow non-svg images render as external link.
200 107
 			if bytes.HasSuffix(link, svgSuffix) || bytes.Contains(link, svgSuffixWithMark) {
@@ -203,7 +110,7 @@ func (r *Renderer) Image(out *bytes.Buffer, link []byte, title []byte, alt []byt
203 110
 			}
204 111
 		} else {
205 112
 			lnk := string(link)
206
-			lnk = URLJoin(prefix, lnk)
113
+			lnk = markup.URLJoin(prefix, lnk)
207 114
 			lnk = strings.Replace(lnk, " ", "+", -1)
208 115
 			link = []byte(lnk)
209 116
 		}
@@ -216,351 +123,6 @@ func (r *Renderer) Image(out *bytes.Buffer, link []byte, title []byte, alt []byt
216 123
 	out.WriteString("</a>")
217 124
 }
218 125
 
219
-// cutoutVerbosePrefix cutouts URL prefix including sub-path to
220
-// return a clean unified string of request URL path.
221
-func cutoutVerbosePrefix(prefix string) string {
222
-	if len(prefix) == 0 || prefix[0] != '/' {
223
-		return prefix
224
-	}
225
-	count := 0
226
-	for i := 0; i < len(prefix); i++ {
227
-		if prefix[i] == '/' {
228
-			count++
229
-		}
230
-		if count >= 3+setting.AppSubURLDepth {
231
-			return prefix[:i]
232
-		}
233
-	}
234
-	return prefix
235
-}
236
-
237
-// URLJoin joins url components, like path.Join, but preserving contents
238
-func URLJoin(base string, elems ...string) string {
239
-	u, err := url.Parse(base)
240
-	if err != nil {
241
-		log.Error(4, "URLJoin: Invalid base URL %s", base)
242
-		return ""
243
-	}
244
-	joinArgs := make([]string, 0, len(elems)+1)
245
-	joinArgs = append(joinArgs, u.Path)
246
-	joinArgs = append(joinArgs, elems...)
247
-	u.Path = path.Join(joinArgs...)
248
-	return u.String()
249
-}
250
-
251
-// RenderIssueIndexPattern renders issue indexes to corresponding links.
252
-func RenderIssueIndexPattern(rawBytes []byte, urlPrefix string, metas map[string]string) []byte {
253
-	urlPrefix = cutoutVerbosePrefix(urlPrefix)
254
-
255
-	pattern := IssueNumericPattern
256
-	if metas["style"] == IssueNameStyleAlphanumeric {
257
-		pattern = IssueAlphanumericPattern
258
-	}
259
-
260
-	ms := pattern.FindAll(rawBytes, -1)
261
-	for _, m := range ms {
262
-		if m[0] == ' ' || m[0] == '(' {
263
-			m = m[1:] // ignore leading space or opening parentheses
264
-		}
265
-		var link string
266
-		if metas == nil {
267
-			link = fmt.Sprintf(`<a href="%s">%s</a>`, URLJoin(urlPrefix, "issues", string(m[1:])), m)
268
-		} else {
269
-			// Support for external issue tracker
270
-			if metas["style"] == IssueNameStyleAlphanumeric {
271
-				metas["index"] = string(m)
272
-			} else {
273
-				metas["index"] = string(m[1:])
274
-			}
275
-			link = fmt.Sprintf(`<a href="%s">%s</a>`, com.Expand(metas["format"], metas), m)
276
-		}
277
-		rawBytes = bytes.Replace(rawBytes, m, []byte(link), 1)
278
-	}
279
-	return rawBytes
280
-}
281
-
282
-// IsSameDomain checks if given url string has the same hostname as current Gitea instance
283
-func IsSameDomain(s string) bool {
284
-	if strings.HasPrefix(s, "/") {
285
-		return true
286
-	}
287
-	if uapp, err := url.Parse(setting.AppURL); err == nil {
288
-		if u, err := url.Parse(s); err == nil {
289
-			return u.Host == uapp.Host
290
-		}
291
-		return false
292
-	}
293
-	return false
294
-}
295
-
296
-// renderFullSha1Pattern renders SHA containing URLs
297
-func renderFullSha1Pattern(rawBytes []byte, urlPrefix string) []byte {
298
-	ms := AnySHA1Pattern.FindAllSubmatch(rawBytes, -1)
299
-	for _, m := range ms {
300
-		all := m[0]
301
-		protocol := string(m[1])
302
-		paths := string(m[2])
303
-		path := protocol + "://" + paths
304
-		author := string(m[3])
305
-		repoName := string(m[4])
306
-		path = URLJoin(path, author, repoName)
307
-		ltype := "src"
308
-		itemType := m[5]
309
-		if IsSameDomain(paths) {
310
-			ltype = string(itemType)
311
-		} else if string(itemType) == "commit" {
312
-			ltype = "commit"
313
-		}
314
-		sha := m[6]
315
-		var subtree string
316
-		if len(m) > 7 && len(m[7]) > 0 {
317
-			subtree = string(m[7])
318
-		}
319
-		var line []byte
320
-		if len(m) > 8 && len(m[8]) > 0 {
321
-			line = m[8]
322
-		}
323
-		urlSuffix := ""
324
-		text := base.ShortSha(string(sha))
325
-		if subtree != "" {
326
-			urlSuffix = "/" + subtree
327
-			text += urlSuffix
328
-		}
329
-		if line != nil {
330
-			value := string(line)
331
-			urlSuffix += "#"
332
-			urlSuffix += value
333
-			text += " ("
334
-			text += value
335
-			text += ")"
336
-		}
337
-		rawBytes = bytes.Replace(rawBytes, all, []byte(fmt.Sprintf(
338
-			`<a href="%s">%s</a>`, URLJoin(path, ltype, string(sha))+urlSuffix, text)), -1)
339
-	}
340
-	return rawBytes
341
-}
342
-
343
-// RenderFullIssuePattern renders issues-like URLs
344
-func RenderFullIssuePattern(rawBytes []byte) []byte {
345
-	ms := getIssueFullPattern().FindAllSubmatch(rawBytes, -1)
346
-	for _, m := range ms {
347
-		all := m[0]
348
-		id := string(m[1])
349
-		text := "#" + id
350
-		// TODO if m[2] is not nil, then link is to a comment,
351
-		// and we should indicate that in the text somehow
352
-		rawBytes = bytes.Replace(rawBytes, all, []byte(fmt.Sprintf(
353
-			`<a href="%s">%s</a>`, string(all), text)), -1)
354
-	}
355
-	return rawBytes
356
-}
357
-
358
-func firstIndexOfByte(sl []byte, target byte) int {
359
-	for i := 0; i < len(sl); i++ {
360
-		if sl[i] == target {
361
-			return i
362
-		}
363
-	}
364
-	return -1
365
-}
366
-
367
-func lastIndexOfByte(sl []byte, target byte) int {
368
-	for i := len(sl) - 1; i >= 0; i-- {
369
-		if sl[i] == target {
370
-			return i
371
-		}
372
-	}
373
-	return -1
374
-}
375
-
376
-// RenderShortLinks processes [[syntax]]
377
-//
378
-// noLink flag disables making link tags when set to true
379
-// so this function just replaces the whole [[...]] with the content text
380
-//
381
-// isWikiMarkdown is a flag to choose linking url prefix
382
-func RenderShortLinks(rawBytes []byte, urlPrefix string, noLink bool, isWikiMarkdown bool) []byte {
383
-	ms := ShortLinkPattern.FindAll(rawBytes, -1)
384
-	for _, m := range ms {
385
-		orig := bytes.TrimSpace(m)
386
-		m = orig[2:]
387
-		tailPos := lastIndexOfByte(m, ']') + 1
388
-		tail := []byte{}
389
-		if tailPos < len(m) {
390
-			tail = m[tailPos:]
391
-			m = m[:tailPos-1]
392
-		}
393
-		m = m[:len(m)-2]
394
-		props := map[string]string{}
395
-
396
-		// MediaWiki uses [[link|text]], while GitHub uses [[text|link]]
397
-		// It makes page handling terrible, but we prefer GitHub syntax
398
-		// And fall back to MediaWiki only when it is obvious from the look
399
-		// Of text and link contents
400
-		sl := bytes.Split(m, []byte("|"))
401
-		for _, v := range sl {
402
-			switch bytes.Count(v, []byte("=")) {
403
-
404
-			// Piped args without = sign, these are mandatory arguments
405
-			case 0:
406
-				{
407
-					sv := string(v)
408
-					if props["name"] == "" {
409
-						if isLink(v) {
410
-							// If we clearly see it is a link, we save it so
411
-
412
-							// But first we need to ensure, that if both mandatory args provided
413
-							// look like links, we stick to GitHub syntax
414
-							if props["link"] != "" {
415
-								props["name"] = props["link"]
416
-							}
417
-
418
-							props["link"] = strings.TrimSpace(sv)
419
-						} else {
420
-							props["name"] = sv
421
-						}
422
-					} else {
423
-						props["link"] = strings.TrimSpace(sv)
424
-					}
425
-				}
426
-
427
-			// Piped args with = sign, these are optional arguments
428
-			case 1:
429
-				{
430
-					sep := firstIndexOfByte(v, '=')
431
-					key, val := string(v[:sep]), html.UnescapeString(string(v[sep+1:]))
432
-					lastCharIndex := len(val) - 1
433
-					if (val[0] == '"' || val[0] == '\'') && (val[lastCharIndex] == '"' || val[lastCharIndex] == '\'') {
434
-						val = val[1:lastCharIndex]
435
-					}
436
-					props[key] = val
437
-				}
438
-			}
439
-		}
440
-
441
-		var name string
442
-		var link string
443
-		if props["link"] != "" {
444
-			link = props["link"]
445
-		} else if props["name"] != "" {
446
-			link = props["name"]
447
-		}
448
-		if props["title"] != "" {
449
-			name = props["title"]
450
-		} else if props["name"] != "" {
451
-			name = props["name"]
452
-		} else {
453
-			name = link
454
-		}
455
-
456
-		name += string(tail)
457
-		image := false
458
-		ext := filepath.Ext(string(link))
459
-		if ext != "" {
460
-			switch ext {
461
-			case ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".gif", ".bmp", ".ico", ".svg":
462
-				{
463
-					image = true
464
-				}
465
-			}
466
-		}
467
-		absoluteLink := isLink([]byte(link))
468
-		if !absoluteLink {
469
-			link = strings.Replace(link, " ", "+", -1)
470
-		}
471
-		if image {
472
-			if !absoluteLink {
473
-				if IsSameDomain(urlPrefix) {
474
-					urlPrefix = strings.Replace(urlPrefix, "/src/", "/raw/", 1)
475
-				}
476
-				if isWikiMarkdown {
477
-					link = URLJoin("wiki", "raw", link)
478
-				}
479
-				link = URLJoin(urlPrefix, link)
480
-			}
481
-			title := props["title"]
482
-			if title == "" {
483
-				title = props["alt"]
484
-			}
485
-			if title == "" {
486
-				title = path.Base(string(name))
487
-			}
488
-			alt := props["alt"]
489
-			if alt == "" {
490
-				alt = name
491
-			}
492
-			if alt != "" {
493
-				alt = `alt="` + alt + `"`
494
-			}
495
-			name = fmt.Sprintf(`<img src="%s" %s title="%s" />`, link, alt, title)
496
-		} else if !absoluteLink {
497
-			if isWikiMarkdown {
498
-				link = URLJoin("wiki", link)
499
-			}
500
-			link = URLJoin(urlPrefix, link)
501
-		}
502
-		if noLink {
503
-			rawBytes = bytes.Replace(rawBytes, orig, []byte(name), -1)
504
-		} else {
505
-			rawBytes = bytes.Replace(rawBytes, orig,
506
-				[]byte(fmt.Sprintf(`<a href="%s">%s</a>`, link, name)), -1)
507
-		}
508
-	}
509
-	return rawBytes
510
-}
511
-
512
-// RenderCrossReferenceIssueIndexPattern renders issue indexes from other repositories to corresponding links.
513
-func RenderCrossReferenceIssueIndexPattern(rawBytes []byte, urlPrefix string, metas map[string]string) []byte {
514
-	ms := CrossReferenceIssueNumericPattern.FindAll(rawBytes, -1)
515
-	for _, m := range ms {
516
-		if m[0] == ' ' || m[0] == '(' {
517
-			m = m[1:] // ignore leading space or opening parentheses
518
-		}
519
-
520
-		repo := string(bytes.Split(m, []byte("#"))[0])
521
-		issue := string(bytes.Split(m, []byte("#"))[1])
522
-
523
-		link := fmt.Sprintf(`<a href="%s">%s</a>`, URLJoin(setting.AppURL, repo, "issues", issue), m)
524
-		rawBytes = bytes.Replace(rawBytes, m, []byte(link), 1)
525
-	}
526
-	return rawBytes
527
-}
528
-
529
-// renderSha1CurrentPattern renders SHA1 strings to corresponding links that assumes in the same repository.
530
-func renderSha1CurrentPattern(rawBytes []byte, urlPrefix string) []byte {
531
-	ms := Sha1CurrentPattern.FindAllSubmatch(rawBytes, -1)
532
-	for _, m := range ms {
533
-		hash := m[1]
534
-		// The regex does not lie, it matches the hash pattern.
535
-		// However, a regex cannot know if a hash actually exists or not.
536
-		// We could assume that a SHA1 hash should probably contain alphas AND numerics
537
-		// but that is not always the case.
538
-		// Although unlikely, deadbeef and 1234567 are valid short forms of SHA1 hash
539
-		// as used by git and github for linking and thus we have to do similar.
540
-		rawBytes = bytes.Replace(rawBytes, hash, []byte(fmt.Sprintf(
541
-			`<a href="%s">%s</a>`, URLJoin(urlPrefix, "commit", string(hash)), base.ShortSha(string(hash)))), -1)
542
-	}
543
-	return rawBytes
544
-}
545
-
546
-// RenderSpecialLink renders mentions, indexes and SHA1 strings to corresponding links.
547
-func RenderSpecialLink(rawBytes []byte, urlPrefix string, metas map[string]string, isWikiMarkdown bool) []byte {
548
-	ms := MentionPattern.FindAll(rawBytes, -1)
549
-	for _, m := range ms {
550
-		m = m[bytes.Index(m, []byte("@")):]
551
-		rawBytes = bytes.Replace(rawBytes, m,
552
-			[]byte(fmt.Sprintf(`<a href="%s">%s</a>`, URLJoin(setting.AppURL, string(m[1:])), m)), -1)
553
-	}
554
-
555
-	rawBytes = RenderFullIssuePattern(rawBytes)
556
-	rawBytes = RenderShortLinks(rawBytes, urlPrefix, false, isWikiMarkdown)
557
-	rawBytes = RenderIssueIndexPattern(rawBytes, urlPrefix, metas)
558
-	rawBytes = RenderCrossReferenceIssueIndexPattern(rawBytes, urlPrefix, metas)
559
-	rawBytes = renderFullSha1Pattern(rawBytes, urlPrefix)
560
-	rawBytes = renderSha1CurrentPattern(rawBytes, urlPrefix)
561
-	return rawBytes
562
-}
563
-
564 126
 // RenderRaw renders Markdown to HTML without handling special links.
565 127
 func RenderRaw(body []byte, urlPrefix string, wikiMarkdown bool) []byte {
566 128
 	htmlFlags := 0
@@ -589,107 +151,6 @@ func RenderRaw(body []byte, urlPrefix string, wikiMarkdown bool) []byte {
589 151
 }
590 152
 
591 153
 var (
592
-	leftAngleBracket  = []byte("</")
593
-	rightAngleBracket = []byte(">")
594
-)
595
-
596
-var noEndTags = []string{"img", "input", "br", "hr"}
597
-
598
-// PostProcess treats different types of HTML differently,
599
-// and only renders special links for plain text blocks.
600
-func PostProcess(rawHTML []byte, urlPrefix string, metas map[string]string, isWikiMarkdown bool) []byte {
601
-	startTags := make([]string, 0, 5)
602
-	var buf bytes.Buffer
603
-	tokenizer := html.NewTokenizer(bytes.NewReader(rawHTML))
604
-
605
-OUTER_LOOP:
606
-	for html.ErrorToken != tokenizer.Next() {
607
-		token := tokenizer.Token()
608
-		switch token.Type {
609
-		case html.TextToken:
610
-			buf.Write(RenderSpecialLink([]byte(token.String()), urlPrefix, metas, isWikiMarkdown))
611
-
612
-		case html.StartTagToken:
613
-			buf.WriteString(token.String())
614
-			tagName := token.Data
615
-			// If this is an excluded tag, we skip processing all output until a close tag is encountered.
616
-			if strings.EqualFold("a", tagName) || strings.EqualFold("code", tagName) || strings.EqualFold("pre", tagName) {
617
-				stackNum := 1
618
-				for html.ErrorToken != tokenizer.Next() {
619
-					token = tokenizer.Token()
620
-
621
-					// Copy the token to the output verbatim
622
-					buf.Write(RenderShortLinks([]byte(token.String()), urlPrefix, true, isWikiMarkdown))
623
-
624
-					if token.Type == html.StartTagToken && !com.IsSliceContainsStr(noEndTags, token.Data) {
625
-						stackNum++
626
-					}
627
-
628
-					// If this is the close tag to the outer-most, we are done
629
-					if token.Type == html.EndTagToken {
630
-						stackNum--
631
-
632
-						if stackNum <= 0 && strings.EqualFold(tagName, token.Data) {
633
-							break
634
-						}
635
-					}
636
-				}
637
-				continue OUTER_LOOP
638
-			}
639
-
640
-			if !com.IsSliceContainsStr(noEndTags, tagName) {
641
-				startTags = append(startTags, tagName)
642
-			}
643
-
644
-		case html.EndTagToken:
645
-			if len(startTags) == 0 {
646
-				buf.WriteString(token.String())
647
-				break
648
-			}
649
-
650
-			buf.Write(leftAngleBracket)
651
-			buf.WriteString(startTags[len(startTags)-1])
652
-			buf.Write(rightAngleBracket)
653
-			startTags = startTags[:len(startTags)-1]
654
-		default:
655
-			buf.WriteString(token.String())
656
-		}
657
-	}
658
-
659
-	if io.EOF == tokenizer.Err() {
660
-		return buf.Bytes()
661
-	}
662
-
663
-	// If we are not at the end of the input, then some other parsing error has occurred,
664
-	// so return the input verbatim.
665
-	return rawHTML
666
-}
667
-
668
-// Render renders Markdown to HTML with all specific handling stuff.
669
-func render(rawBytes []byte, urlPrefix string, metas map[string]string, isWikiMarkdown bool) []byte {
670
-	urlPrefix = strings.Replace(urlPrefix, " ", "+", -1)
671
-	result := RenderRaw(rawBytes, urlPrefix, isWikiMarkdown)
672
-	result = PostProcess(result, urlPrefix, metas, isWikiMarkdown)
673
-	result = SanitizeBytes(result)
674
-	return result
675
-}
676
-
677
-// Render renders Markdown to HTML with all specific handling stuff.
678
-func Render(rawBytes []byte, urlPrefix string, metas map[string]string) []byte {
679
-	return render(rawBytes, urlPrefix, metas, false)
680
-}
681
-
682
-// RenderString renders Markdown to HTML with special links and returns string type.
683
-func RenderString(raw, urlPrefix string, metas map[string]string) string {
684
-	return string(render([]byte(raw), urlPrefix, metas, false))
685
-}
686
-
687
-// RenderWiki renders markdown wiki page to HTML and return HTML string
688
-func RenderWiki(rawBytes []byte, urlPrefix string, metas map[string]string) string {
689
-	return string(render(rawBytes, urlPrefix, metas, true))
690
-}
691
-
692
-var (
693 154
 	// MarkupName describes markup's name
694 155
 	MarkupName = "markdown"
695 156
 )
@@ -714,5 +175,26 @@ func (Parser) Extensions() []string {
714 175
 
715 176
 // Render implements markup.Parser
716 177
 func (Parser) Render(rawBytes []byte, urlPrefix string, metas map[string]string, isWiki bool) []byte {
717
-	return render(rawBytes, urlPrefix, metas, isWiki)
178
+	return RenderRaw(rawBytes, urlPrefix, isWiki)
179
+}
180
+
181
+// Render renders Markdown to HTML with all specific handling stuff.
182
+func Render(rawBytes []byte, urlPrefix string, metas map[string]string) []byte {
183
+	return markup.Render("a.md", rawBytes, urlPrefix, metas)
184
+}
185
+
186
+// RenderString renders Markdown to HTML with special links and returns string type.
187
+func RenderString(raw, urlPrefix string, metas map[string]string) string {
188
+	return markup.RenderString("a.md", raw, urlPrefix, metas)
189
+}
190
+
191
+// RenderWiki renders markdown wiki page to HTML and return HTML string
192
+func RenderWiki(rawBytes []byte, urlPrefix string, metas map[string]string) string {
193
+	return markup.RenderWiki("a.md", rawBytes, urlPrefix, metas)
194
+}
195
+
196
+// IsMarkdownFile reports whether name looks like a Markdown file
197
+// based on its extension.
198
+func IsMarkdownFile(name string) bool {
199
+	return markup.IsMarkupFile(name, MarkupName)
718 200
 }

+ 84 - 482
modules/markdown/markdown_test.go

@@ -7,12 +7,13 @@ package markdown_test
7 7
 import (
8 8
 	"fmt"
9 9
 	"strconv"
10
-	"testing"
11
-
12 10
 	"strings"
11
+	"testing"
13 12
 
14 13
 	. "code.gitea.io/gitea/modules/markdown"
14
+	"code.gitea.io/gitea/modules/markup"
15 15
 	"code.gitea.io/gitea/modules/setting"
16
+
16 17
 	"github.com/stretchr/testify/assert"
17 18
 )
18 19
 
@@ -24,24 +25,24 @@ var numericMetas = map[string]string{
24 25
 	"format": "https://someurl.com/{user}/{repo}/{index}",
25 26
 	"user":   "someUser",
26 27
 	"repo":   "someRepo",
27
-	"style":  IssueNameStyleNumeric,
28
+	"style":  markup.IssueNameStyleNumeric,
28 29
 }
29 30
 
30 31
 var alphanumericMetas = map[string]string{
31 32
 	"format": "https://someurl.com/{user}/{repo}/{index}",
32 33
 	"user":   "someUser",
33 34
 	"repo":   "someRepo",
34
-	"style":  IssueNameStyleAlphanumeric,
35
+	"style":  markup.IssueNameStyleAlphanumeric,
35 36
 }
36 37
 
37 38
 // numericLink an HTML to a numeric-style issue
38 39
 func numericIssueLink(baseURL string, index int) string {
39
-	return link(URLJoin(baseURL, strconv.Itoa(index)), fmt.Sprintf("#%d", index))
40
+	return link(markup.URLJoin(baseURL, strconv.Itoa(index)), fmt.Sprintf("#%d", index))
40 41
 }
41 42
 
42 43
 // alphanumLink an HTML link to an alphanumeric-style issue
43 44
 func alphanumIssueLink(baseURL string, name string) string {
44
-	return link(URLJoin(baseURL, name), name)
45
+	return link(markup.URLJoin(baseURL, name), name)
45 46
 }
46 47
 
47 48
 // urlContentsLink an HTML link whose contents is the target URL
@@ -56,175 +57,7 @@ func link(href, contents string) string {
56 57
 
57 58
 func testRenderIssueIndexPattern(t *testing.T, input, expected string, metas map[string]string) {
58 59
 	assert.Equal(t, expected,
59
-		string(RenderIssueIndexPattern([]byte(input), AppSubURL, metas)))
60
-}
61
-
62
-func TestURLJoin(t *testing.T) {
63
-	type test struct {
64
-		Expected string
65
-		Base     string
66
-		Elements []string
67
-	}
68
-	newTest := func(expected, base string, elements ...string) test {
69
-		return test{Expected: expected, Base: base, Elements: elements}
70
-	}
71
-	for _, test := range []test{
72
-		newTest("https://try.gitea.io/a/b/c",
73
-			"https://try.gitea.io", "a/b", "c"),
74
-		newTest("https://try.gitea.io/a/b/c",
75
-			"https://try.gitea.io/", "/a/b/", "/c/"),
76
-		newTest("https://try.gitea.io/a/c",
77
-			"https://try.gitea.io/", "/a/./b/", "../c/"),
78
-		newTest("a/b/c",
79
-			"a", "b/c/"),
80
-		newTest("a/b/d",
81
-			"a/", "b/c/", "/../d/"),
82
-	} {
83
-		assert.Equal(t, test.Expected, URLJoin(test.Base, test.Elements...))
84
-	}
85
-}
86
-
87
-func TestRender_IssueIndexPattern(t *testing.T) {
88
-	// numeric: render inputs without valid mentions
89
-	test := func(s string) {
90
-		testRenderIssueIndexPattern(t, s, s, nil)
91
-		testRenderIssueIndexPattern(t, s, s, numericMetas)
92
-	}
93
-
94
-	// should not render anything when there are no mentions
95
-	test("")
96
-	test("this is a test")
97
-	test("test 123 123 1234")
98
-	test("#")
99
-	test("# # #")
100
-	test("# 123")
101
-	test("#abcd")
102
-	test("##1234")
103
-	test("test#1234")
104
-	test("#1234test")
105
-	test(" test #1234test")
106
-
107
-	// should not render issue mention without leading space
108
-	test("test#54321 issue")
109
-
110
-	// should not render issue mention without trailing space
111
-	test("test #54321issue")
112
-}
113
-
114
-func TestRender_IssueIndexPattern2(t *testing.T) {
115
-	setting.AppURL = AppURL
116
-	setting.AppSubURL = AppSubURL
117
-
118
-	// numeric: render inputs with valid mentions
119
-	test := func(s, expectedFmt string, indices ...int) {
120
-		links := make([]interface{}, len(indices))
121
-		for i, index := range indices {
122
-			links[i] = numericIssueLink(URLJoin(setting.AppSubURL, "issues"), index)
123
-		}
124
-		expectedNil := fmt.Sprintf(expectedFmt, links...)
125
-		testRenderIssueIndexPattern(t, s, expectedNil, nil)
126
-
127
-		for i, index := range indices {
128
-			links[i] = numericIssueLink("https://someurl.com/someUser/someRepo/", index)
129
-		}
130
-		expectedNum := fmt.Sprintf(expectedFmt, links...)
131
-		testRenderIssueIndexPattern(t, s, expectedNum, numericMetas)
132
-	}
133
-
134
-	// should render freestanding mentions
135
-	test("#1234 test", "%s test", 1234)
136
-	test("test #8 issue", "test %s issue", 8)
137
-	test("test issue #1234", "test issue %s", 1234)
138
-
139
-	// should render mentions in parentheses
140
-	test("(#54321 issue)", "(%s issue)", 54321)
141
-	test("test (#9801 extra) issue", "test (%s extra) issue", 9801)
142
-	test("test (#1)", "test (%s)", 1)
143
-
144
-	// should render multiple issue mentions in the same line
145
-	test("#54321 #1243", "%s %s", 54321, 1243)
146
-	test("wow (#54321 #1243)", "wow (%s %s)", 54321, 1243)
147
-	test("(#4)(#5)", "(%s)(%s)", 4, 5)
148
-	test("#1 (#4321) test", "%s (%s) test", 1, 4321)
149
-}
150
-
151
-func TestRender_IssueIndexPattern3(t *testing.T) {
152
-	setting.AppURL = AppURL
153
-	setting.AppSubURL = AppSubURL
154
-
155
-	// alphanumeric: render inputs without valid mentions
156
-	test := func(s string) {
157
-		testRenderIssueIndexPattern(t, s, s, alphanumericMetas)
158
-	}
159
-	test("")
160
-	test("this is a test")
161
-	test("test 123 123 1234")
162
-	test("#")
163
-	test("##1234")
164
-	test("# 123")
165
-	test("#abcd")
166
-	test("test #123")
167
-	test("abc-1234")         // issue prefix must be capital
168
-	test("ABc-1234")         // issue prefix must be _all_ capital
169
-	test("ABCDEFGHIJK-1234") // the limit is 10 characters in the prefix
170
-	test("ABC1234")          // dash is required
171
-	test("test ABC- test")   // number is required
172
-	test("test -1234 test")  // prefix is required
173
-	test("testABC-123 test") // leading space is required
174
-	test("test ABC-123test") // trailing space is required
175
-	test("ABC-0123")         // no leading zero
176
-}
177
-
178
-func TestRender_IssueIndexPattern4(t *testing.T) {
179
-	setting.AppURL = AppURL
180
-	setting.AppSubURL = AppSubURL
181
-
182
-	// alphanumeric: render inputs with valid mentions
183
-	test := func(s, expectedFmt string, names ...string) {
184
-		links := make([]interface{}, len(names))
185
-		for i, name := range names {
186
-			links[i] = alphanumIssueLink("https://someurl.com/someUser/someRepo/", name)
187
-		}
188
-		expected := fmt.Sprintf(expectedFmt, links...)
189
-		testRenderIssueIndexPattern(t, s, expected, alphanumericMetas)
190
-	}
191
-	test("OTT-1234 test", "%s test", "OTT-1234")
192
-	test("test T-12 issue", "test %s issue", "T-12")
193
-	test("test issue ABCDEFGHIJ-1234567890", "test issue %s", "ABCDEFGHIJ-1234567890")
194
-}
195
-
196
-func TestRender_AutoLink(t *testing.T) {
197
-	setting.AppURL = AppURL
198
-	setting.AppSubURL = AppSubURL
199
-
200
-	test := func(input, expected string) {
201
-		buffer := RenderSpecialLink([]byte(input), setting.AppSubURL, nil, false)
202
-		assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
203
-		buffer = RenderSpecialLink([]byte(input), setting.AppSubURL, nil, true)
204
-		assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
205
-	}
206
-
207
-	// render valid issue URLs
208
-	test(URLJoin(setting.AppSubURL, "issues", "3333"),
209
-		numericIssueLink(URLJoin(setting.AppSubURL, "issues"), 3333))
210
-
211
-	// render external issue URLs
212
-	for _, externalURL := range []string{
213
-		"http://1111/2222/ssss-issues/3333?param=blah&blahh=333",
214
-		"http://test.com/issues/33333",
215
-		"https://issues/333"} {
216
-		test(externalURL, externalURL)
217
-	}
218
-
219
-	// render valid commit URLs
220
-	tmp := URLJoin(AppSubURL, "commit", "d8a994ef243349f321568f9e36d5c3f444b99cae")
221
-	test(tmp, "<a href=\""+tmp+"\">d8a994ef24</a>")
222
-	tmp += "#diff-2"
223
-	test(tmp, "<a href=\""+tmp+"\">d8a994ef24 (diff-2)</a>")
224
-
225
-	// render other commit URLs
226
-	tmp = "https://external-link.gogs.io/gogs/gogs/commit/d8a994ef243349f321568f9e36d5c3f444b99cae#diff-2"
227
-	test(tmp, "<a href=\""+tmp+"\">d8a994ef24 (diff-2)</a>")
60
+		string(markup.RenderIssueIndexPattern([]byte(input), AppSubURL, metas)))
228 61
 }
229 62
 
230 63
 func TestRender_StandardLinks(t *testing.T) {
@@ -241,8 +74,8 @@ func TestRender_StandardLinks(t *testing.T) {
241 74
 	googleRendered := `<p><a href="https://google.com/" rel="nofollow">https://google.com/</a></p>`
242 75
 	test("<https://google.com/>", googleRendered, googleRendered)
243 76
 
244
-	lnk := URLJoin(AppSubURL, "WikiPage")
245
-	lnkWiki := URLJoin(AppSubURL, "wiki", "WikiPage")
77
+	lnk := markup.URLJoin(AppSubURL, "WikiPage")
78
+	lnkWiki := markup.URLJoin(AppSubURL, "wiki", "WikiPage")
246 79
 	test("[WikiPage](WikiPage)",
247 80
 		`<p><a href="`+lnk+`" rel="nofollow">WikiPage</a></p>`,
248 81
 		`<p><a href="`+lnkWiki+`" rel="nofollow">WikiPage</a></p>`)
@@ -251,7 +84,7 @@ func TestRender_StandardLinks(t *testing.T) {
251 84
 func TestRender_ShortLinks(t *testing.T) {
252 85
 	setting.AppURL = AppURL
253 86
 	setting.AppSubURL = AppSubURL
254
-	tree := URLJoin(AppSubURL, "src", "master")
87
+	tree := markup.URLJoin(AppSubURL, "src", "master")
255 88
 
256 89
 	test := func(input, expected, expectedWiki string) {
257 90
 		buffer := RenderString(input, tree, nil)
@@ -260,13 +93,13 @@ func TestRender_ShortLinks(t *testing.T) {
260 93
 		assert.Equal(t, strings.TrimSpace(expectedWiki), strings.TrimSpace(string(buffer)))
261 94
 	}
262 95
 
263
-	rawtree := URLJoin(AppSubURL, "raw", "master")
264
-	url := URLJoin(tree, "Link")
265
-	otherUrl := URLJoin(tree, "OtherLink")
266
-	imgurl := URLJoin(rawtree, "Link.jpg")
267
-	urlWiki := URLJoin(AppSubURL, "wiki", "Link")
268
-	otherUrlWiki := URLJoin(AppSubURL, "wiki", "OtherLink")
269
-	imgurlWiki := URLJoin(AppSubURL, "wiki", "raw", "Link.jpg")
96
+	rawtree := markup.URLJoin(AppSubURL, "raw", "master")
97
+	url := markup.URLJoin(tree, "Link")
98
+	otherUrl := markup.URLJoin(tree, "OtherLink")
99
+	imgurl := markup.URLJoin(rawtree, "Link.jpg")
100
+	urlWiki := markup.URLJoin(AppSubURL, "wiki", "Link")
101
+	otherUrlWiki := markup.URLJoin(AppSubURL, "wiki", "OtherLink")
102
+	imgurlWiki := markup.URLJoin(AppSubURL, "wiki", "raw", "Link.jpg")
270 103
 	favicon := "http://google.com/favicon.ico"
271 104
 
272 105
 	test(
@@ -311,27 +144,26 @@ func TestRender_ShortLinks(t *testing.T) {
311 144
 		`<p><a href="`+urlWiki+`" rel="nofollow">Link</a> <a href="`+otherUrlWiki+`" rel="nofollow">OtherLink</a></p>`)
312 145
 }
313 146
 
314
-func TestRender_Commits(t *testing.T) {
315
-	setting.AppURL = AppURL
316
-	setting.AppSubURL = AppSubURL
317
-
318
-	test := func(input, expected string) {
319
-		buffer := RenderString(input, setting.AppSubURL, nil)
320
-		assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
147
+func TestMisc_IsMarkdownFile(t *testing.T) {
148
+	setting.Markdown.FileExtensions = []string{".md", ".markdown", ".mdown", ".mkd"}
149
+	trueTestCases := []string{
150
+		"test.md",
151
+		"wow.MARKDOWN",
152
+		"LOL.mDoWn",
153
+	}
154
+	falseTestCases := []string{
155
+		"test",
156
+		"abcdefg",
157
+		"abcdefghijklmnopqrstuvwxyz",
158
+		"test.md.test",
321 159
 	}
322 160
 
323
-	var sha = "b6dd6210eaebc915fd5be5579c58cce4da2e2579"
324
-	var commit = URLJoin(AppSubURL, "commit", sha)
325
-	var subtree = URLJoin(commit, "src")
326
-	var tree = strings.Replace(subtree, "/commit/", "/tree/", -1)
327
-	var src = strings.Replace(subtree, "/commit/", "/src/", -1)
328
-
329
-	test(sha, `<p><a href="`+commit+`" rel="nofollow">b6dd6210ea</a></p>`)
330
-	test(sha[:7], `<p><a href="`+commit[:len(commit)-(40-7)]+`" rel="nofollow">b6dd621</a></p>`)
331
-	test(sha[:39], `<p><a href="`+commit[:len(commit)-(40-39)]+`" rel="nofollow">b6dd6210ea</a></p>`)
332
-	test(commit, `<p><a href="`+commit+`" rel="nofollow">b6dd6210ea</a></p>`)
333
-	test(tree, `<p><a href="`+src+`" rel="nofollow">b6dd6210ea/src</a></p>`)
334
-	test("commit "+sha, `<p>commit <a href="`+commit+`" rel="nofollow">b6dd6210ea</a></p>`)
161
+	for _, testCase := range trueTestCases {
162
+		assert.True(t, IsMarkdownFile(testCase))
163
+	}
164
+	for _, testCase := range falseTestCases {
165
+		assert.False(t, IsMarkdownFile(testCase))
166
+	}
335 167
 }
336 168
 
337 169
 func TestRender_Images(t *testing.T) {
@@ -345,7 +177,7 @@ func TestRender_Images(t *testing.T) {
345 177
 
346 178
 	url := "../../.images/src/02/train.jpg"
347 179
 	title := "Train"
348
-	result := URLJoin(AppSubURL, url)
180
+	result := markup.URLJoin(AppSubURL, url)
349 181
 
350 182
 	test(
351 183
 		"!["+title+"]("+url+")",
@@ -356,143 +188,6 @@ func TestRender_Images(t *testing.T) {
356 188
 		`<p><a href="`+result+`" rel="nofollow"><img src="`+result+`" alt="`+title+`" title="`+title+`"/></a></p>`)
357 189
 }
358 190
 
359
-func TestRender_CrossReferences(t *testing.T) {
360
-	setting.AppURL = AppURL
361
-	setting.AppSubURL = AppSubURL
362
-
363
-	test := func(input, expected string) {
364
-		buffer := RenderString(input, setting.AppSubURL, nil)
365
-		assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
366
-	}
367
-
368
-	test(
369
-		"gogits/gogs#12345",
370
-		`<p><a href="`+URLJoin(AppURL, "gogits", "gogs", "issues", "12345")+`" rel="nofollow">gogits/gogs#12345</a></p>`)
371
-}
372
-
373
-func TestRender_FullIssueURLs(t *testing.T) {
374
-	setting.AppURL = AppURL
375
-	setting.AppSubURL = AppSubURL
376
-
377
-	test := func(input, expected string) {
378
-		result := RenderFullIssuePattern([]byte(input))
379
-		assert.Equal(t, expected, string(result))
380
-	}
381
-	test("Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6",
382
-		"Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6")
383
-	test("Look here http://localhost:3000/person/repo/issues/4",
384
-		`Look here <a href="http://localhost:3000/person/repo/issues/4">#4</a>`)
385
-	test("http://localhost:3000/person/repo/issues/4#issuecomment-1234",
386
-		`<a href="http://localhost:3000/person/repo/issues/4#issuecomment-1234">#4</a>`)
387
-}
388
-
389
-func TestRegExp_MentionPattern(t *testing.T) {
390
-	trueTestCases := []string{
391
-		"@Unknwon",
392
-		"@ANT_123",
393
-		"@xxx-DiN0-z-A..uru..s-xxx",
394
-		"   @lol   ",
395
-		" @Te/st",
396
-	}
397
-	falseTestCases := []string{
398
-		"@ 0",
399
-		"@ ",
400
-		"@",
401
-		"",
402
-		"ABC",
403
-	}
404
-
405
-	for _, testCase := range trueTestCases {
406
-		res := MentionPattern.MatchString(testCase)
407
-		if !res {
408
-			println()
409
-			println(testCase)
410
-		}
411
-		assert.True(t, res)
412
-	}
413
-	for _, testCase := range falseTestCases {
414
-		res := MentionPattern.MatchString(testCase)
415
-		if res {
416
-			println()
417
-			println(testCase)
418
-		}
419
-		assert.False(t, res)
420
-	}
421
-}
422
-
423
-func TestRegExp_IssueNumericPattern(t *testing.T) {
424
-	trueTestCases := []string{
425
-		"#1234",
426
-		"#0",
427
-		"#1234567890987654321",
428
-	}
429
-	falseTestCases := []string{
430
-		"# 1234",
431
-		"# 0",
432
-		"# ",
433
-		"#",
434
-		"#ABC",
435
-		"#1A2B",
436
-		"",
437
-		"ABC",
438
-	}
439
-
440
-	for _, testCase := range trueTestCases {
441
-		assert.True(t, IssueNumericPattern.MatchString(testCase))
442
-	}
443
-	for _, testCase := range falseTestCases {
444
-		assert.False(t, IssueNumericPattern.MatchString(testCase))
445
-	}
446
-}
447
-
448
-func TestRegExp_IssueAlphanumericPattern(t *testing.T) {
449
-	trueTestCases := []string{
450
-		"ABC-1234",
451
-		"A-1",
452
-		"RC-80",
453
-		"ABCDEFGHIJ-1234567890987654321234567890",
454
-	}
455
-	falseTestCases := []string{
456
-		"RC-08",
457
-		"PR-0",
458
-		"ABCDEFGHIJK-1",
459
-		"PR_1",
460
-		"",
461
-		"#ABC",
462
-		"",
463
-		"ABC",
464
-		"GG-",
465
-		"rm-1",
466
-	}
467
-
468
-	for _, testCase := range trueTestCases {
469
-		assert.True(t, IssueAlphanumericPattern.MatchString(testCase))
470
-	}
471
-	for _, testCase := range falseTestCases {
472
-		assert.False(t, IssueAlphanumericPattern.MatchString(testCase))
473
-	}
474
-}
475
-
476
-func TestRegExp_Sha1CurrentPattern(t *testing.T) {
477
-	trueTestCases := []string{
478
-		"d8a994ef243349f321568f9e36d5c3f444b99cae",
479
-		"abcdefabcdefabcdefabcdefabcdefabcdefabcd",
480
-	}
481
-	falseTestCases := []string{
482
-		"test",
483
-		"abcdefg",
484
-		"abcdefghijklmnopqrstuvwxyzabcdefghijklmn",
485
-		"abcdefghijklmnopqrstuvwxyzabcdefghijklmO",
486
-	}
487
-
488
-	for _, testCase := range trueTestCases {
489
-		assert.True(t, Sha1CurrentPattern.MatchString(testCase))
490
-	}
491
-	for _, testCase := range falseTestCases {
492
-		assert.False(t, Sha1CurrentPattern.MatchString(testCase))
493
-	}
494
-}
495
-
496 191
 func TestRegExp_ShortLinkPattern(t *testing.T) {
497 192
 	trueTestCases := []string{
498 193
 		"[[stuff]]",
@@ -510,139 +205,13 @@ func TestRegExp_ShortLinkPattern(t *testing.T) {
510 205
 	}
511 206
 
512 207
 	for _, testCase := range trueTestCases {
513
-		assert.True(t, ShortLinkPattern.MatchString(testCase))
208
+		assert.True(t, markup.ShortLinkPattern.MatchString(testCase))
514 209
 	}
515 210
 	for _, testCase := range falseTestCases {
516
-		assert.False(t, ShortLinkPattern.MatchString(testCase))
211
+		assert.False(t, markup.ShortLinkPattern.MatchString(testCase))
517 212
 	}
518 213
 }
519 214
 
520
-func TestRegExp_AnySHA1Pattern(t *testing.T) {
521
-	testCases := map[string][]string{
522
-		"https://github.com/jquery/jquery/blob/a644101ed04d0beacea864ce805e0c4f86ba1cd1/test/unit/event.js#L2703": {
523
-			"https",
524
-			"github.com",
525
-			"jquery",
526
-			"jquery",
527
-			"blob",
528
-			"a644101ed04d0beacea864ce805e0c4f86ba1cd1",
529
-			"test/unit/event.js",
530
-			"L2703",
531
-		},
532
-		"https://github.com/jquery/jquery/blob/a644101ed04d0beacea864ce805e0c4f86ba1cd1/test/unit/event.js": {
533
-			"https",
534
-			"github.com",
535
-			"jquery",
536
-			"jquery",
537
-			"blob",
538
-			"a644101ed04d0beacea864ce805e0c4f86ba1cd1",
539
-			"test/unit/event.js",
540
-			"",
541
-		},
542
-		"https://github.com/jquery/jquery/commit/0705be475092aede1eddae01319ec931fb9c65fc": {
543
-			"https",
544
-			"github.com",
545
-			"jquery",
546
-			"jquery",
547
-			"commit",
548
-			"0705be475092aede1eddae01319ec931fb9c65fc",
549
-			"",
550
-			"",
551
-		},
552
-		"https://github.com/jquery/jquery/tree/0705be475092aede1eddae01319ec931fb9c65fc/src": {
553
-			"https",
554
-			"github.com",
555
-			"jquery",
556
-			"jquery",
557
-			"tree",
558
-			"0705be475092aede1eddae01319ec931fb9c65fc",
559
-			"src",
560
-			"",
561
-		},
562
-		"https://try.gogs.io/gogs/gogs/commit/d8a994ef243349f321568f9e36d5c3f444b99cae#diff-2": {
563
-			"https",
564
-			"try.gogs.io",
565
-			"gogs",
566
-			"gogs",
567
-			"commit",
568
-			"d8a994ef243349f321568f9e36d5c3f444b99cae",
569
-			"",
570
-			"diff-2",
571
-		},
572
-	}
573
-
574
-	for k, v := range testCases {
575
-		assert.Equal(t, AnySHA1Pattern.FindStringSubmatch(k)[1:], v)
576
-	}
577
-}
578
-
579
-func TestMisc_IsMarkdownFile(t *testing.T) {
580
-	setting.Markdown.FileExtensions = []string{".md", ".markdown", ".mdown", ".mkd"}
581
-	trueTestCases := []string{
582
-		"test.md",
583
-		"wow.MARKDOWN",
584
-		"LOL.mDoWn",
585
-	}
586
-	falseTestCases := []string{
587
-		"test",
588
-		"abcdefg",
589
-		"abcdefghijklmnopqrstuvwxyz",
590
-		"test.md.test",
591
-	}
592
-
593
-	for _, testCase := range trueTestCases {
594
-		assert.True(t, IsMarkdownFile(testCase))
595
-	}
596
-	for _, testCase := range falseTestCases {
597
-		assert.False(t, IsMarkdownFile(testCase))
598
-	}
599
-}
600
-
601
-func TestMisc_IsSameDomain(t *testing.T) {
602
-	setting.AppURL = AppURL
603
-	setting.AppSubURL = AppSubURL
604
-
605
-	var sha = "b6dd6210eaebc915fd5be5579c58cce4da2e2579"
606
-	var commit = URLJoin(AppSubURL, "commit", sha)
607
-
608
-	assert.True(t, IsSameDomain(commit))
609
-	assert.False(t, IsSameDomain("http://google.com/ncr"))
610
-	assert.False(t, IsSameDomain("favicon.ico"))
611
-}
612
-
613
-// Test cases without ambiguous links
614
-var sameCases = []string{
615
-	// dear imgui wiki markdown extract: special wiki syntax
616
-	`Wiki! Enjoy :)
617
-- [[Links, Language bindings, Engine bindings|Links]]
618
-- [[Tips]]
619
-
620
-Ideas and codes
621
-
622
-- Bezier widget (by @r-lyeh) ` + AppURL + `ocornut/imgui/issues/786
623
-- Node graph editors https://github.com/ocornut/imgui/issues/306
624
-- [[Memory Editor|memory_editor_example]]
625
-- [[Plot var helper|plot_var_example]]`,
626
-	// wine-staging wiki home extract: tables, special wiki syntax, images
627
-	`## What is Wine Staging?
628
-**Wine Staging** on website [wine-staging.com](http://wine-staging.com).
629
-
630
-## Quick Links
631
-Here are some links to the most important topics. You can find the full list of pages at the sidebar.
632
-
633
-| [[images/icon-install.png]]    | [[Installation]]                                         |
634
-|--------------------------------|----------------------------------------------------------|
635
-| [[images/icon-usage.png]]      | [[Usage]]                                                |
636
-`,
637
-	// libgdx wiki page: inline images with special syntax
638
-	`[Excelsior JET](http://www.excelsiorjet.com/) allows you to create native executables for Windows, Linux and Mac OS X.
639
-
640
-1. [Package your libGDX application](https://github.com/libgdx/libgdx/wiki/Gradle-on-the-Commandline#packaging-for-the-desktop)
641
-[[images/1.png]]
642
-2. Perform a test run by hitting the Run! button.
643
-[[images/2.png]]`,
644
-}
645
-
646 215
 func testAnswers(baseURLContent, baseURLImages string) []string {
647 216
 	return []string{
648 217
 		`<p>Wiki! Enjoy :)</p>
@@ -697,24 +266,41 @@ func testAnswers(baseURLContent, baseURLImages string) []string {
697 266
 	}
698 267
 }
699 268
 
700
-func TestTotal_RenderString(t *testing.T) {
701
-	answers := testAnswers(URLJoin(AppSubURL, "src", "master/"), URLJoin(AppSubURL, "raw", "master/"))
269
+// Test cases without ambiguous links
270
+var sameCases = []string{
271
+	// dear imgui wiki markdown extract: special wiki syntax
272
+	`Wiki! Enjoy :)
273
+- [[Links, Language bindings, Engine bindings|Links]]
274
+- [[Tips]]
702 275
 
703
-	for i := 0; i < len(sameCases); i++ {
704
-		line := RenderString(sameCases[i], URLJoin(AppSubURL, "src", "master/"), nil)
705
-		assert.Equal(t, answers[i], line)
706
-	}
276
+Ideas and codes
707 277
 
708
-	testCases := []string{}
278
+- Bezier widget (by @r-lyeh) ` + AppURL + `ocornut/imgui/issues/786
279
+- Node graph editors https://github.com/ocornut/imgui/issues/306
280
+- [[Memory Editor|memory_editor_example]]
281
+- [[Plot var helper|plot_var_example]]`,
282
+	// wine-staging wiki home extract: tables, special wiki syntax, images
283
+	`## What is Wine Staging?
284
+**Wine Staging** on website [wine-staging.com](http://wine-staging.com).
709 285
 
710
-	for i := 0; i < len(testCases); i += 2 {
711
-		line := RenderString(testCases[i], AppSubURL, nil)
712
-		assert.Equal(t, testCases[i+1], line)
713
-	}
286
+## Quick Links
287
+Here are some links to the most important topics. You can find the full list of pages at the sidebar.
288
+
289
+| [[images/icon-install.png]]    | [[Installation]]                                         |
290
+|--------------------------------|----------------------------------------------------------|
291
+| [[images/icon-usage.png]]      | [[Usage]]                                                |
292
+`,
293
+	// libgdx wiki page: inline images with special syntax
294
+	`[Excelsior JET](http://www.excelsiorjet.com/) allows you to create native executables for Windows, Linux and Mac OS X.
295
+
296
+1. [Package your libGDX application](https://github.com/libgdx/libgdx/wiki/Gradle-on-the-Commandline#packaging-for-the-desktop)
297
+[[images/1.png]]
298
+2. Perform a test run by hitting the Run! button.
299
+[[images/2.png]]`,
714 300
 }
715 301
 
716 302
 func TestTotal_RenderWiki(t *testing.T) {
717
-	answers := testAnswers(URLJoin(AppSubURL, "wiki/"), URLJoin(AppSubURL, "wiki", "raw/"))
303
+	answers := testAnswers(markup.URLJoin(AppSubURL, "wiki/"), markup.URLJoin(AppSubURL, "wiki", "raw/"))
718 304
 
719 305
 	for i := 0; i < len(sameCases); i++ {
720 306
 		line := RenderWiki([]byte(sameCases[i]), AppSubURL, nil)
@@ -739,3 +325,19 @@ func TestTotal_RenderWiki(t *testing.T) {
739 325
 		assert.Equal(t, testCases[i+1], line)
740 326
 	}
741 327
 }
328
+
329
+func TestTotal_RenderString(t *testing.T) {
330
+	answers := testAnswers(markup.URLJoin(AppSubURL, "src", "master/"), markup.URLJoin(AppSubURL, "raw", "master/"))
331
+
332
+	for i := 0; i < len(sameCases); i++ {
333
+		line := RenderString(sameCases[i], markup.URLJoin(AppSubURL, "src", "master/"), nil)
334
+		assert.Equal(t, answers[i], line)
335
+	}
336
+
337
+	testCases := []string{}
338
+
339
+	for i := 0; i < len(testCases); i += 2 {
340
+		line := RenderString(testCases[i], AppSubURL, nil)
341
+		assert.Equal(t, testCases[i+1], line)
342
+	}
343
+}

+ 517 - 0
modules/markup/html.go

@@ -0,0 +1,517 @@
1
+// Copyright 2017 The Gitea Authors. All rights reserved.
2
+// Use of this source code is governed by a MIT-style
3
+// license that can be found in the LICENSE file.
4
+
5
+package markup
6
+
7
+import (
8
+	"bytes"
9
+	"fmt"
10
+	"io"
11
+	"net/url"
12
+	"path"
13
+	"path/filepath"
14
+	"regexp"
15
+	"strings"
16
+
17
+	"code.gitea.io/gitea/modules/base"
18
+	"code.gitea.io/gitea/modules/log"
19
+	"code.gitea.io/gitea/modules/setting"
20
+
21
+	"github.com/Unknwon/com"
22
+	"golang.org/x/net/html"
23
+)
24
+
25
+// Issue name styles
26
+const (
27
+	IssueNameStyleNumeric      = "numeric"
28
+	IssueNameStyleAlphanumeric = "alphanumeric"
29
+)
30
+
31
+var (
32
+	// NOTE: All below regex matching do not perform any extra validation.
33
+	// Thus a link is produced even if the linked entity does not exist.
34
+	// While fast, this is also incorrect and lead to false positives.
35
+	// TODO: fix invalid linking issue
36
+
37
+	// MentionPattern matches string that mentions someone, e.g. @Unknwon
38
+	MentionPattern = regexp.MustCompile(`(\s|^|\W)@[0-9a-zA-Z-_\.]+`)
39
+
40
+	// IssueNumericPattern matches string that references to a numeric issue, e.g. #1287
41
+	IssueNumericPattern = regexp.MustCompile(`( |^|\()#[0-9]+\b`)
42
+	// IssueAlphanumericPattern matches string that references to an alphanumeric issue, e.g. ABC-1234
43
+	IssueAlphanumericPattern = regexp.MustCompile(`( |^|\()[A-Z]{1,10}-[1-9][0-9]*\b`)
44
+	// CrossReferenceIssueNumericPattern matches string that references a numeric issue in a different repository
45
+	// e.g. gogits/gogs#12345
46
+	CrossReferenceIssueNumericPattern = regexp.MustCompile(`( |^)[0-9a-zA-Z]+/[0-9a-zA-Z]+#[0-9]+\b`)
47
+
48
+	// Sha1CurrentPattern matches string that represents a commit SHA, e.g. d8a994ef243349f321568f9e36d5c3f444b99cae
49
+	// Although SHA1 hashes are 40 chars long, the regex matches the hash from 7 to 40 chars in length
50
+	// so that abbreviated hash links can be used as well. This matches git and github useability.
51
+	Sha1CurrentPattern = regexp.MustCompile(`(?:^|\s|\()([0-9a-f]{7,40})\b`)
52
+
53
+	// ShortLinkPattern matches short but difficult to parse [[name|link|arg=test]] syntax
54
+	ShortLinkPattern = regexp.MustCompile(`(\[\[.*?\]\]\w*)`)
55
+
56
+	// AnySHA1Pattern allows to split url containing SHA into parts
57
+	AnySHA1Pattern = regexp.MustCompile(`(http\S*)://(\S+)/(\S+)/(\S+)/(\S+)/([0-9a-f]{40})(?:/?([^#\s]+)?(?:#(\S+))?)?`)
58
+
59
+	validLinksPattern = regexp.MustCompile(`^[a-z][\w-]+://`)
60
+)
61
+
62
+// regexp for full links to issues/pulls
63
+var issueFullPattern *regexp.Regexp
64
+
65
+// IsLink reports whether link fits valid format.
66
+func IsLink(link []byte) bool {
67
+	return isLink(link)
68
+}
69
+
70
+// isLink reports whether link fits valid format.
71
+func isLink(link []byte) bool {
72
+	return validLinksPattern.Match(link)
73
+}
74
+
75
+func getIssueFullPattern() *regexp.Regexp {
76
+	if issueFullPattern == nil {
77
+		appURL := setting.AppURL
78
+		if len(appURL) > 0 && appURL[len(appURL)-1] != '/' {
79
+			appURL += "/"
80
+		}
81
+		issueFullPattern = regexp.MustCompile(appURL +
82
+			`\w+/\w+/(?:issues|pulls)/((?:\w{1,10}-)?[1-9][0-9]*)([\?|#]\S+.(\S+)?)?\b`)
83
+	}
84
+	return issueFullPattern
85
+}
86
+
87
+// FindAllMentions matches mention patterns in given content
88
+// and returns a list of found user names without @ prefix.
89
+func FindAllMentions(content string) []string {
90
+	mentions := MentionPattern.FindAllString(content, -1)
91
+	for i := range mentions {
92
+		mentions[i] = mentions[i][strings.Index(mentions[i], "@")+1:] // Strip @ character
93
+	}
94
+	return mentions
95
+}
96
+
97
+// cutoutVerbosePrefix cutouts URL prefix including sub-path to
98
+// return a clean unified string of request URL path.
99
+func cutoutVerbosePrefix(prefix string) string {
100
+	if len(prefix) == 0 || prefix[0] != '/' {
101
+		return prefix
102
+	}
103
+	count := 0
104
+	for i := 0; i < len(prefix); i++ {
105
+		if prefix[i] == '/' {
106
+			count++
107
+		}
108
+		if count >= 3+setting.AppSubURLDepth {
109
+			return prefix[:i]
110
+		}
111
+	}
112
+	return prefix
113
+}
114
+
115
+// URLJoin joins url components, like path.Join, but preserving contents
116
+func URLJoin(base string, elems ...string) string {
117
+	u, err := url.Parse(base)
118
+	if err != nil {
119
+		log.Error(4, "URLJoin: Invalid base URL %s", base)
120
+		return ""
121
+	}
122
+	joinArgs := make([]string, 0, len(elems)+1)
123
+	joinArgs = append(joinArgs, u.Path)
124
+	joinArgs = append(joinArgs, elems...)
125
+	u.Path = path.Join(joinArgs...)
126
+	return u.String()
127
+}
128
+
129
+// RenderIssueIndexPattern renders issue indexes to corresponding links.
130
+func RenderIssueIndexPattern(rawBytes []byte, urlPrefix string, metas map[string]string) []byte {
131
+	urlPrefix = cutoutVerbosePrefix(urlPrefix)
132
+
133
+	pattern := IssueNumericPattern
134
+	if metas["style"] == IssueNameStyleAlphanumeric {
135
+		pattern = IssueAlphanumericPattern
136
+	}
137
+
138
+	ms := pattern.FindAll(rawBytes, -1)
139
+	for _, m := range ms {
140
+		if m[0] == ' ' || m[0] == '(' {
141
+			m = m[1:] // ignore leading space or opening parentheses
142
+		}
143
+		var link string
144
+		if metas == nil {
145
+			link = fmt.Sprintf(`<a href="%s">%s</a>`, URLJoin(urlPrefix, "issues", string(m[1:])), m)
146
+		} else {
147
+			// Support for external issue tracker
148
+			if metas["style"] == IssueNameStyleAlphanumeric {
149
+				metas["index"] = string(m)
150
+			} else {
151
+				metas["index"] = string(m[1:])
152
+			}
153
+			link = fmt.Sprintf(`<a href="%s">%s</a>`, com.Expand(metas["format"], metas), m)
154
+		}
155
+		rawBytes = bytes.Replace(rawBytes, m, []byte(link), 1)
156
+	}
157
+	return rawBytes
158
+}
159
+
160
+// IsSameDomain checks if given url string has the same hostname as current Gitea instance
161
+func IsSameDomain(s string) bool {
162
+	if strings.HasPrefix(s, "/") {
163
+		return true
164
+	}
165
+	if uapp, err := url.Parse(setting.AppURL); err == nil {
166
+		if u, err := url.Parse(s); err == nil {
167
+			return u.Host == uapp.Host
168
+		}
169
+		return false
170
+	}
171
+	return false
172
+}
173
+
174
+// renderFullSha1Pattern renders SHA containing URLs
175
+func renderFullSha1Pattern(rawBytes []byte, urlPrefix string) []byte {
176
+	ms := AnySHA1Pattern.FindAllSubmatch(rawBytes, -1)
177
+	for _, m := range ms {
178
+		all := m[0]
179
+		protocol := string(m[1])
180
+		paths := string(m[2])
181
+		path := protocol + "://" + paths
182
+		author := string(m[3])
183
+		repoName := string(m[4])
184
+		path = URLJoin(path, author, repoName)
185
+		ltype := "src"
186
+		itemType := m[5]
187
+		if IsSameDomain(paths) {
188
+			ltype = string(itemType)
189
+		} else if string(itemType) == "commit" {
190
+			ltype = "commit"
191
+		}
192
+		sha := m[6]
193
+		var subtree string
194
+		if len(m) > 7 && len(m[7]) > 0 {
195
+			subtree = string(m[7])
196
+		}
197
+		var line []byte
198
+		if len(m) > 8 && len(m[8]) > 0 {
199
+			line = m[8]
200
+		}
201
+		urlSuffix := ""
202
+		text := base.ShortSha(string(sha))
203
+		if subtree != "" {
204
+			urlSuffix = "/" + subtree
205
+			text += urlSuffix
206
+		}
207
+		if line != nil {
208
+			value := string(line)
209
+			urlSuffix += "#"
210
+			urlSuffix += value
211
+			text += " ("
212
+			text += value
213
+			text += ")"
214
+		}
215
+		rawBytes = bytes.Replace(rawBytes, all, []byte(fmt.Sprintf(
216
+			`<a href="%s">%s</a>`, URLJoin(path, ltype, string(sha))+urlSuffix, text)), -1)
217
+	}
218
+	return rawBytes
219
+}
220
+
221
+// RenderFullIssuePattern renders issues-like URLs
222
+func RenderFullIssuePattern(rawBytes []byte) []byte {
223
+	ms := getIssueFullPattern().FindAllSubmatch(rawBytes, -1)
224
+	for _, m := range ms {
225
+		all := m[0]
226
+		id := string(m[1])
227
+		text := "#" + id
228
+		// TODO if m[2] is not nil, then link is to a comment,
229
+		// and we should indicate that in the text somehow
230
+		rawBytes = bytes.Replace(rawBytes, all, []byte(fmt.Sprintf(
231
+			`<a href="%s">%s</a>`, string(all), text)), -1)
232
+	}
233
+	return rawBytes
234
+}
235
+
236
+func firstIndexOfByte(sl []byte, target byte) int {
237
+	for i := 0; i < len(sl); i++ {
238
+		if sl[i] == target {
239
+			return i
240
+		}
241
+	}
242
+	return -1
243
+}
244
+
245
+func lastIndexOfByte(sl []byte, target byte) int {
246
+	for i := len(sl) - 1; i >= 0; i-- {
247
+		if sl[i] == target {
248
+			return i
249
+		}
250
+	}
251
+	return -1
252
+}
253
+
254
+// RenderShortLinks processes [[syntax]]
255
+//
256
+// noLink flag disables making link tags when set to true
257
+// so this function just replaces the whole [[...]] with the content text
258
+//
259
+// isWikiMarkdown is a flag to choose linking url prefix
260
+func RenderShortLinks(rawBytes []byte, urlPrefix string, noLink bool, isWikiMarkdown bool) []byte {
261
+	ms := ShortLinkPattern.FindAll(rawBytes, -1)
262
+	for _, m := range ms {
263
+		orig := bytes.TrimSpace(m)
264
+		m = orig[2:]
265
+		tailPos := lastIndexOfByte(m, ']') + 1
266
+		tail := []byte{}
267
+		if tailPos < len(m) {
268
+			tail = m[tailPos:]
269
+			m = m[:tailPos-1]
270
+		}
271
+		m = m[:len(m)-2]
272
+		props := map[string]string{}
273
+
274
+		// MediaWiki uses [[link|text]], while GitHub uses [[text|link]]
275
+		// It makes page handling terrible, but we prefer GitHub syntax
276
+		// And fall back to MediaWiki only when it is obvious from the look
277
+		// Of text and link contents
278
+		sl := bytes.Split(m, []byte("|"))
279
+		for _, v := range sl {
280
+			switch bytes.Count(v, []byte("=")) {
281
+
282
+			// Piped args without = sign, these are mandatory arguments
283
+			case 0:
284
+				{
285
+					sv := string(v)
286
+					if props["name"] == "" {
287
+						if isLink(v) {
288
+							// If we clearly see it is a link, we save it so
289
+
290
+							// But first we need to ensure, that if both mandatory args provided
291
+							// look like links, we stick to GitHub syntax
292
+							if props["link"] != "" {
293
+								props["name"] = props["link"]
294
+							}
295
+
296
+							props["link"] = strings.TrimSpace(sv)
297
+						} else {
298
+							props["name"] = sv
299
+						}
300
+					} else {
301
+						props["link"] = strings.TrimSpace(sv)
302
+					}
303
+				}
304
+
305
+			// Piped args with = sign, these are optional arguments
306
+			case 1:
307
+				{
308
+					sep := firstIndexOfByte(v, '=')
309
+					key, val := string(v[:sep]), html.UnescapeString(string(v[sep+1:]))
310
+					lastCharIndex := len(val) - 1
311
+					if (val[0] == '"' || val[0] == '\'') && (val[lastCharIndex] == '"' || val[lastCharIndex] == '\'') {
312
+						val = val[1:lastCharIndex]
313
+					}
314
+					props[key] = val
315
+				}
316
+			}
317
+		}
318
+
319
+		var name string
320
+		var link string
321
+		if props["link"] != "" {
322
+			link = props["link"]
323
+		} else if props["name"] != "" {
324
+			link = props["name"]
325
+		}
326
+		if props["title"] != "" {
327
+			name = props["title"]
328
+		} else if props["name"] != "" {
329
+			name = props["name"]
330
+		} else {
331
+			name = link
332
+		}
333
+
334
+		name += string(tail)
335
+		image := false
336
+		ext := filepath.Ext(string(link))
337
+		if ext != "" {
338
+			switch ext {
339
+			case ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".gif", ".bmp", ".ico", ".svg":
340
+				{
341
+					image = true
342
+				}
343
+			}
344
+		}
345
+		absoluteLink := isLink([]byte(link))
346
+		if !absoluteLink {
347
+			link = strings.Replace(link, " ", "+", -1)
348
+		}
349
+		if image {
350
+			if !absoluteLink {
351
+				if IsSameDomain(urlPrefix) {
352
+					urlPrefix = strings.Replace(urlPrefix, "/src/", "/raw/", 1)
353
+				}
354
+				if isWikiMarkdown {
355
+					link = URLJoin("wiki", "raw", link)
356
+				}
357
+				link = URLJoin(urlPrefix, link)
358
+			}
359
+			title := props["title"]
360
+			if title == "" {
361
+				title = props["alt"]
362
+			}
363
+			if title == "" {
364
+				title = path.Base(string(name))
365
+			}
366
+			alt := props["alt"]
367
+			if alt == "" {
368
+				alt = name
369
+			}
370
+			if alt != "" {
371
+				alt = `alt="` + alt + `"`
372
+			}
373
+			name = fmt.Sprintf(`<img src="%s" %s title="%s" />`, link, alt, title)
374
+		} else if !absoluteLink {
375
+			if isWikiMarkdown {
376
+				link = URLJoin("wiki", link)
377
+			}
378
+			link = URLJoin(urlPrefix, link)
379
+		}
380
+		if noLink {
381
+			rawBytes = bytes.Replace(rawBytes, orig, []byte(name), -1)
382
+		} else {
383
+			rawBytes = bytes.Replace(rawBytes, orig,
384
+				[]byte(fmt.Sprintf(`<a href="%s">%s</a>`, link, name)), -1)
385
+		}
386
+	}
387
+	return rawBytes
388
+}
389
+
390
+// RenderCrossReferenceIssueIndexPattern renders issue indexes from other repositories to corresponding links.
391
+func RenderCrossReferenceIssueIndexPattern(rawBytes []byte, urlPrefix string, metas map[string]string) []byte {
392
+	ms := CrossReferenceIssueNumericPattern.FindAll(rawBytes, -1)
393
+	for _, m := range ms {
394
+		if m[0] == ' ' || m[0] == '(' {
395
+			m = m[1:] // ignore leading space or opening parentheses
396
+		}
397
+
398
+		repo := string(bytes.Split(m, []byte("#"))[0])
399
+		issue := string(bytes.Split(m, []byte("#"))[1])
400
+
401
+		link := fmt.Sprintf(`<a href="%s">%s</a>`, URLJoin(setting.AppURL, repo, "issues", issue), m)
402
+		rawBytes = bytes.Replace(rawBytes, m, []byte(link), 1)
403
+	}
404
+	return rawBytes
405
+}
406
+
407
+// renderSha1CurrentPattern renders SHA1 strings to corresponding links that assumes in the same repository.
408
+func renderSha1CurrentPattern(rawBytes []byte, urlPrefix string) []byte {
409
+	ms := Sha1CurrentPattern.FindAllSubmatch(rawBytes, -1)
410
+	for _, m := range ms {
411
+		hash := m[1]
412
+		// The regex does not lie, it matches the hash pattern.
413
+		// However, a regex cannot know if a hash actually exists or not.
414
+		// We could assume that a SHA1 hash should probably contain alphas AND numerics
415
+		// but that is not always the case.
416
+		// Although unlikely, deadbeef and 1234567 are valid short forms of SHA1 hash
417
+		// as used by git and github for linking and thus we have to do similar.
418
+		rawBytes = bytes.Replace(rawBytes, hash, []byte(fmt.Sprintf(
419
+			`<a href="%s">%s</a>`, URLJoin(urlPrefix, "commit", string(hash)), base.ShortSha(string(hash)))), -1)
420
+	}
421
+	return rawBytes
422
+}
423
+
424
+// RenderSpecialLink renders mentions, indexes and SHA1 strings to corresponding links.
425
+func RenderSpecialLink(rawBytes []byte, urlPrefix string, metas map[string]string, isWikiMarkdown bool) []byte {
426
+	ms := MentionPattern.FindAll(rawBytes, -1)
427
+	for _, m := range ms {
428
+		m = m[bytes.Index(m, []byte("@")):]
429
+		rawBytes = bytes.Replace(rawBytes, m,
430
+			[]byte(fmt.Sprintf(`<a href="%s">%s</a>`, URLJoin(setting.AppURL, string(m[1:])), m)), -1)
431
+	}
432
+
433
+	rawBytes = RenderFullIssuePattern(rawBytes)
434
+	rawBytes = RenderShortLinks(rawBytes, urlPrefix, false, isWikiMarkdown)
435
+	rawBytes = RenderIssueIndexPattern(rawBytes, urlPrefix, metas)
436
+	rawBytes = RenderCrossReferenceIssueIndexPattern(rawBytes, urlPrefix, metas)
437
+	rawBytes = renderFullSha1Pattern(rawBytes, urlPrefix)
438
+	rawBytes = renderSha1CurrentPattern(rawBytes, urlPrefix)
439
+	return rawBytes
440
+}
441
+
442
+var (
443
+	leftAngleBracket  = []byte("</")
444
+	rightAngleBracket = []byte(">")
445
+)
446
+
447
+var noEndTags = []string{"img", "input", "br", "hr"}
448
+
449
+// PostProcess treats different types of HTML differently,
450
+// and only renders special links for plain text blocks.
451
+func PostProcess(rawHTML []byte, urlPrefix string, metas map[string]string, isWikiMarkdown bool) []byte {
452
+	startTags := make([]string, 0, 5)
453
+	var buf bytes.Buffer
454
+	tokenizer := html.NewTokenizer(bytes.NewReader(rawHTML))
455
+
456
+OUTER_LOOP:
457
+	for html.ErrorToken != tokenizer.Next() {
458
+		token := tokenizer.Token()
459
+		switch token.Type {
460
+		case html.TextToken:
461
+			buf.Write(RenderSpecialLink([]byte(token.String()), urlPrefix, metas, isWikiMarkdown))
462
+
463
+		case html.StartTagToken:
464
+			buf.WriteString(token.String())
465
+			tagName := token.Data
466
+			// If this is an excluded tag, we skip processing all output until a close tag is encountered.
467
+			if strings.EqualFold("a", tagName) || strings.EqualFold("code", tagName) || strings.EqualFold("pre", tagName) {
468
+				stackNum := 1
469
+				for html.ErrorToken != tokenizer.Next() {
470
+					token = tokenizer.Token()
471
+
472
+					// Copy the token to the output verbatim
473
+					buf.Write(RenderShortLinks([]byte(token.String()), urlPrefix, true, isWikiMarkdown))
474
+
475
+					if token.Type == html.StartTagToken && !com.IsSliceContainsStr(noEndTags, token.Data) {
476
+						stackNum++
477
+					}
478
+
479
+					// If this is the close tag to the outer-most, we are done
480
+					if token.Type == html.EndTagToken {
481
+						stackNum--
482
+
483
+						if stackNum <= 0 && strings.EqualFold(tagName, token.Data) {
484
+							break
485
+						}
486
+					}
487
+				}
488
+				continue OUTER_LOOP
489
+			}
490
+
491
+			if !com.IsSliceContainsStr(noEndTags, tagName) {
492
+				startTags = append(startTags, tagName)
493
+			}
494
+
495
+		case html.EndTagToken:
496
+			if len(startTags) == 0 {
497
+				buf.WriteString(token.String())
498
+				break
499
+			}
500
+
501
+			buf.Write(leftAngleBracket)
502
+			buf.WriteString(startTags[len(startTags)-1])
503
+			buf.Write(rightAngleBracket)
504
+			startTags = startTags[:len(startTags)-1]
505
+		default:
506
+			buf.WriteString(token.String())
507
+		}
508
+	}
509
+
510
+	if io.EOF == tokenizer.Err() {
511
+		return buf.Bytes()
512
+	}
513
+
514
+	// If we are not at the end of the input, then some other parsing error has occurred,
515
+	// so return the input verbatim.
516
+	return rawHTML
517
+}

+ 460 - 0
modules/markup/html_test.go

@@ -0,0 +1,460 @@
1
+// Copyright 2017 The Gitea Authors. All rights reserved.
2
+// Use of this source code is governed by a MIT-style
3
+// license that can be found in the LICENSE file.
4
+
5
+package markup_test
6
+
7
+import (
8
+	"fmt"
9
+	"strconv"
10
+	"strings"
11
+	"testing"
12
+
13
+	_ "code.gitea.io/gitea/modules/markdown"
14
+	. "code.gitea.io/gitea/modules/markup"
15
+	"code.gitea.io/gitea/modules/setting"
16
+
17
+	"github.com/stretchr/testify/assert"
18
+)
19
+
20
+const AppURL = "http://localhost:3000/"
21
+const Repo = "gogits/gogs"
22
+const AppSubURL = AppURL + Repo + "/"
23
+
24
+var numericMetas = map[string]string{
25
+	"format": "https://someurl.com/{user}/{repo}/{index}",
26
+	"user":   "someUser",
27
+	"repo":   "someRepo",
28
+	"style":  IssueNameStyleNumeric,
29
+}
30
+
31
+var alphanumericMetas = map[string]string{
32
+	"format": "https://someurl.com/{user}/{repo}/{index}",
33
+	"user":   "someUser",
34
+	"repo":   "someRepo",
35
+	"style":  IssueNameStyleAlphanumeric,
36
+}
37
+
38
+// numericLink an HTML to a numeric-style issue
39
+func numericIssueLink(baseURL string, index int) string {
40
+	return link(URLJoin(baseURL, strconv.Itoa(index)), fmt.Sprintf("#%d", index))
41
+}
42
+
43
+// alphanumLink an HTML link to an alphanumeric-style issue
44
+func alphanumIssueLink(baseURL string, name string) string {
45
+	return link(URLJoin(baseURL, name), name)
46
+}
47
+
48
+// urlContentsLink an HTML link whose contents is the target URL
49
+func urlContentsLink(href string) string {
50
+	return link(href, href)
51
+}
52
+
53
+// link an HTML link
54
+func link(href, contents string) string {
55
+	return fmt.Sprintf("<a href=\"%s\">%s</a>", href, contents)
56
+}
57
+
58
+func testRenderIssueIndexPattern(t *testing.T, input, expected string, metas map[string]string) {
59
+	assert.Equal(t, expected,
60
+		string(RenderIssueIndexPattern([]byte(input), AppSubURL, metas)))
61
+}
62
+
63
+func TestURLJoin(t *testing.T) {
64
+	type test struct {
65
+		Expected string
66
+		Base     string
67
+		Elements []string
68
+	}
69
+	newTest := func(expected, base string, elements ...string) test {
70
+		return test{Expected: expected, Base: base, Elements: elements}
71
+	}
72
+	for _, test := range []test{
73
+		newTest("https://try.gitea.io/a/b/c",
74
+			"https://try.gitea.io", "a/b", "c"),
75
+		newTest("https://try.gitea.io/a/b/c",
76
+			"https://try.gitea.io/", "/a/b/", "/c/"),
77
+		newTest("https://try.gitea.io/a/c",
78
+			"https://try.gitea.io/", "/a/./b/", "../c/"),
79
+		newTest("a/b/c",
80
+			"a", "b/c/"),
81
+		newTest("a/b/d",
82
+			"a/", "b/c/", "/../d/"),
83
+	} {
84
+		assert.Equal(t, test.Expected, URLJoin(test.Base, test.Elements...))
85
+	}
86
+}
87
+
88
+func TestRender_IssueIndexPattern(t *testing.T) {
89
+	// numeric: render inputs without valid mentions
90
+	test := func(s string) {
91
+		testRenderIssueIndexPattern(t, s, s, nil)
92
+		testRenderIssueIndexPattern(t, s, s, numericMetas)
93
+	}
94
+
95
+	// should not render anything when there are no mentions
96
+	test("")
97
+	test("this is a test")
98
+	test("test 123 123 1234")
99
+	test("#")
100
+	test("# # #")
101
+	test("# 123")
102
+	test("#abcd")
103
+	test("##1234")
104
+	test("test#1234")
105
+	test("#1234test")
106
+	test(" test #1234test")
107
+
108
+	// should not render issue mention without leading space
109
+	test("test#54321 issue")
110
+
111
+	// should not render issue mention without trailing space
112
+	test("test #54321issue")
113
+}
114
+
115
+func TestRender_IssueIndexPattern2(t *testing.T) {
116
+	setting.AppURL = AppURL
117
+	setting.AppSubURL = AppSubURL
118
+
119
+	// numeric: render inputs with valid mentions
120
+	test := func(s, expectedFmt string, indices ...int) {
121
+		links := make([]interface{}, len(indices))
122
+		for i, index := range indices {
123
+			links[i] = numericIssueLink(URLJoin(setting.AppSubURL, "issues"), index)
124
+		}
125
+		expectedNil := fmt.Sprintf(expectedFmt, links...)
126
+		testRenderIssueIndexPattern(t, s, expectedNil, nil)
127
+
128
+		for i, index := range indices {
129
+			links[i] = numericIssueLink("https://someurl.com/someUser/someRepo/", index)
130
+		}
131
+		expectedNum := fmt.Sprintf(expectedFmt, links...)
132
+		testRenderIssueIndexPattern(t, s, expectedNum, numericMetas)
133
+	}
134
+
135
+	// should render freestanding mentions
136
+	test("#1234 test", "%s test", 1234)
137
+	test("test #8 issue", "test %s issue", 8)
138
+	test("test issue #1234", "test issue %s", 1234)
139
+
140
+	// should render mentions in parentheses
141
+	test("(#54321 issue)", "(%s issue)", 54321)
142
+	test("test (#9801 extra) issue", "test (%s extra) issue", 9801)
143
+	test("test (#1)", "test (%s)", 1)
144
+
145
+	// should render multiple issue mentions in the same line
146
+	test("#54321 #1243", "%s %s", 54321, 1243)
147
+	test("wow (#54321 #1243)", "wow (%s %s)", 54321, 1243)
148
+	test("(#4)(#5)", "(%s)(%s)", 4, 5)
149
+	test("#1 (#4321) test", "%s (%s) test", 1, 4321)
150
+}
151
+
152
+func TestRender_IssueIndexPattern3(t *testing.T) {
153
+	setting.AppURL = AppURL
154
+	setting.AppSubURL = AppSubURL
155
+
156
+	// alphanumeric: render inputs without valid mentions
157
+	test := func(s string) {
158
+		testRenderIssueIndexPattern(t, s, s, alphanumericMetas)
159
+	}
160
+	test("")
161
+	test("this is a test")
162
+	test("test 123 123 1234")
163
+	test("#")
164
+	test("##1234")
165
+	test("# 123")
166
+	test("#abcd")
167
+	test("test #123")
168
+	test("abc-1234")         // issue prefix must be capital
169
+	test("ABc-1234")         // issue prefix must be _all_ capital
170
+	test("ABCDEFGHIJK-1234") // the limit is 10 characters in the prefix
171
+	test("ABC1234")          // dash is required
172
+	test("test ABC- test")   // number is required
173
+	test("test -1234 test")  // prefix is required
174
+	test("testABC-123 test") // leading space is required
175
+	test("test ABC-123test") // trailing space is required
176
+	test("ABC-0123")         // no leading zero
177
+}
178
+
179
+func TestRender_IssueIndexPattern4(t *testing.T) {
180
+	setting.AppURL = AppURL
181
+	setting.AppSubURL = AppSubURL
182
+
183
+	// alphanumeric: render inputs with valid mentions
184
+	test := func(s, expectedFmt string, names ...string) {
185
+		links := make([]interface{}, len(names))
186
+		for i, name := range names {
187
+			links[i] = alphanumIssueLink("https://someurl.com/someUser/someRepo/", name)
188
+		}
189
+		expected := fmt.Sprintf(expectedFmt, links...)
190
+		testRenderIssueIndexPattern(t, s, expected, alphanumericMetas)
191
+	}
192
+	test("OTT-1234 test", "%s test", "OTT-1234")
193
+	test("test T-12 issue", "test %s issue", "T-12")
194
+	test("test issue ABCDEFGHIJ-1234567890", "test issue %s", "ABCDEFGHIJ-1234567890")
195
+}
196
+
197
+func TestRender_AutoLink(t *testing.T) {
198
+	setting.AppURL = AppURL
199
+	setting.AppSubURL = AppSubURL
200
+
201
+	test := func(input, expected string) {
202
+		buffer := RenderSpecialLink([]byte(input), setting.AppSubURL, nil, false)
203
+		assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
204
+		buffer = RenderSpecialLink([]byte(input), setting.AppSubURL, nil, true)
205
+		assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
206
+	}
207
+
208
+	// render valid issue URLs
209
+	test(URLJoin(setting.AppSubURL, "issues", "3333"),
210
+		numericIssueLink(URLJoin(setting.AppSubURL, "issues"), 3333))
211
+
212
+	// render external issue URLs
213
+	for _, externalURL := range []string{
214
+		"http://1111/2222/ssss-issues/3333?param=blah&blahh=333",
215
+		"http://test.com/issues/33333",
216
+		"https://issues/333"} {
217
+		test(externalURL, externalURL)
218
+	}
219
+
220
+	// render valid commit URLs
221
+	tmp := URLJoin(AppSubURL, "commit", "d8a994ef243349f321568f9e36d5c3f444b99cae")
222
+	test(tmp, "<a href=\""+tmp+"\">d8a994ef24</a>")
223
+	tmp += "#diff-2"
224
+	test(tmp, "<a href=\""+tmp+"\">d8a994ef24 (diff-2)</a>")
225
+
226
+	// render other commit URLs
227
+	tmp = "https://external-link.gogs.io/gogs/gogs/commit/d8a994ef243349f321568f9e36d5c3f444b99cae#diff-2"
228
+	test(tmp, "<a href=\""+tmp+"\">d8a994ef24 (diff-2)</a>")
229
+}
230
+
231
+func TestRender_Commits(t *testing.T) {
232
+	setting.AppURL = AppURL
233
+	setting.AppSubURL = AppSubURL
234
+
235
+	test := func(input, expected string) {
236
+		buffer := RenderString(".md", input, setting.AppSubURL, nil)
237
+		assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
238
+	}
239
+
240
+	var sha = "b6dd6210eaebc915fd5be5579c58cce4da2e2579"
241
+	var commit = URLJoin(AppSubURL, "commit", sha)
242
+	var subtree = URLJoin(commit, "src")
243
+	var tree = strings.Replace(subtree, "/commit/", "/tree/", -1)
244
+	var src = strings.Replace(subtree, "/commit/", "/src/", -1)
245
+
246
+	test(sha, `<p><a href="`+commit+`" rel="nofollow">b6dd6210ea</a></p>`)
247
+	test(sha[:7], `<p><a href="`+commit[:len(commit)-(40-7)]+`" rel="nofollow">b6dd621</a></p>`)
248
+	test(sha[:39], `<p><a href="`+commit[:len(commit)-(40-39)]+`" rel="nofollow">b6dd6210ea</a></p>`)
249
+	test(commit, `<p><a href="`+commit+`" rel="nofollow">b6dd6210ea</a></p>`)
250
+	test(tree, `<p><a href="`+src+`" rel="nofollow">b6dd6210ea/src</a></p>`)
251
+	test("commit "+sha, `<p>commit <a href="`+commit+`" rel="nofollow">b6dd6210ea</a></p>`)
252
+}
253
+
254
+func TestRender_CrossReferences(t *testing.T) {
255
+	setting.AppURL = AppURL
256
+	setting.AppSubURL = AppSubURL
257
+
258
+	test := func(input, expected string) {
259
+		buffer := RenderString("a.md", input, setting.AppSubURL, nil)
260
+		assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
261
+	}
262
+
263
+	test(
264
+		"gogits/gogs#12345",
265
+		`<p><a href="`+URLJoin(AppURL, "gogits", "gogs", "issues", "12345")+`" rel="nofollow">gogits/gogs#12345</a></p>`)
266
+}
267
+
268
+func TestRender_FullIssueURLs(t *testing.T) {
269
+	setting.AppURL = AppURL
270
+	setting.AppSubURL = AppSubURL
271
+
272
+	test := func(input, expected string) {
273
+		result := RenderFullIssuePattern([]byte(input))
274
+		assert.Equal(t, expected, string(result))
275
+	}
276
+	test("Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6",
277
+		"Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6")
278
+	test("Look here http://localhost:3000/person/repo/issues/4",
279
+		`Look here <a href="http://localhost:3000/person/repo/issues/4">#4</a>`)
280
+	test("http://localhost:3000/person/repo/issues/4#issuecomment-1234",
281
+		`<a href="http://localhost:3000/person/repo/issues/4#issuecomment-1234">#4</a>`)
282
+}
283
+
284
+func TestRegExp_MentionPattern(t *testing.T) {
285
+	trueTestCases := []string{
286
+		"@Unknwon",
287
+		"@ANT_123",
288
+		"@xxx-DiN0-z-A..uru..s-xxx",
289
+		"   @lol   ",
290
+		" @Te/st",
291
+	}
292
+	falseTestCases := []string{
293
+		"@ 0",
294
+		"@ ",
295
+		"@",
296
+		"",
297
+		"ABC",
298
+	}
299
+
300
+	for _, testCase := range trueTestCases {
301
+		res := MentionPattern.MatchString(testCase)
302
+		if !res {
303
+			println()
304
+			println(testCase)
305
+		}
306
+		assert.True(t, res)
307
+	}
308
+	for _, testCase := range falseTestCases {
309
+		res := MentionPattern.MatchString(testCase)
310
+		if res {
311
+			println()
312
+			println(testCase)
313
+		}
314
+		assert.False(t, res)
315
+	}
316
+}
317
+
318
+func TestRegExp_IssueNumericPattern(t *testing.T) {
319
+	trueTestCases := []string{
320
+		"#1234",
321
+		"#0",
322
+		"#1234567890987654321",
323
+	}
324
+	falseTestCases := []string{
325
+		"# 1234",
326
+		"# 0",
327
+		"# ",
328
+		"#",
329
+		"#ABC",
330
+		"#1A2B",
331
+		"",
332
+		"ABC",
333
+	}
334
+
335
+	for _, testCase := range trueTestCases {
336
+		assert.True(t, IssueNumericPattern.MatchString(testCase))
337
+	}
338
+	for _, testCase := range falseTestCases {
339
+		assert.False(t, IssueNumericPattern.MatchString(testCase))
340
+	}
341
+}
342
+
343
+func TestRegExp_IssueAlphanumericPattern(t *testing.T) {
344
+	trueTestCases := []string{
345
+		"ABC-1234",
346
+		"A-1",
347
+		"RC-80",
348
+		"ABCDEFGHIJ-1234567890987654321234567890",
349
+	}
350
+	falseTestCases := []string{
351
+		"RC-08",
352
+		"PR-0",
353
+		"ABCDEFGHIJK-1",
354
+		"PR_1",
355
+		"",
356
+		"#ABC",
357
+		"",
358
+		"ABC",
359
+		"GG-",
360
+		"rm-1",
361
+	}
362
+
363
+	for _, testCase := range trueTestCases {
364
+		assert.True(t, IssueAlphanumericPattern.MatchString(testCase))
365
+	}
366
+	for _, testCase := range falseTestCases {
367
+		assert.False(t, IssueAlphanumericPattern.MatchString(testCase))
368
+	}
369
+}
370
+
371
+func TestRegExp_Sha1CurrentPattern(t *testing.T) {
372
+	trueTestCases := []string{
373
+		"d8a994ef243349f321568f9e36d5c3f444b99cae",
374
+		"abcdefabcdefabcdefabcdefabcdefabcdefabcd",
375
+	}
376
+	falseTestCases := []string{
377
+		"test",
378
+		"abcdefg",
379
+		"abcdefghijklmnopqrstuvwxyzabcdefghijklmn",
380
+		"abcdefghijklmnopqrstuvwxyzabcdefghijklmO",
381
+	}
382
+
383
+	for _, testCase := range trueTestCases {
384
+		assert.True(t, Sha1CurrentPattern.MatchString(testCase))
385
+	}
386
+	for _, testCase := range falseTestCases {
387
+		assert.False(t, Sha1CurrentPattern.MatchString(testCase))
388
+	}
389
+}
390
+
391
+func TestRegExp_AnySHA1Pattern(t *testing.T) {
392
+	testCases := map[string][]string{
393
+		"https://github.com/jquery/jquery/blob/a644101ed04d0beacea864ce805e0c4f86ba1cd1/test/unit/event.js#L2703": {
394
+			"https",
395
+			"github.com",
396
+			"jquery",
397
+			"jquery",
398
+			"blob",
399
+			"a644101ed04d0beacea864ce805e0c4f86ba1cd1",
400
+			"test/unit/event.js",
401
+			"L2703",
402
+		},
403
+		"https://github.com/jquery/jquery/blob/a644101ed04d0beacea864ce805e0c4f86ba1cd1/test/unit/event.js": {
404
+			"https",
405
+			"github.com",
406
+			"jquery",
407
+			"jquery",
408
+			"blob",
409
+			"a644101ed04d0beacea864ce805e0c4f86ba1cd1",
410
+			"test/unit/event.js",
411
+			"",
412
+		},
413
+		"https://github.com/jquery/jquery/commit/0705be475092aede1eddae01319ec931fb9c65fc": {
414
+			"https",
415
+			"github.com",
416
+			"jquery",
417
+			"jquery",
418
+			"commit",
419
+			"0705be475092aede1eddae01319ec931fb9c65fc",
420
+			"",
421
+			"",
422
+		},
423
+		"https://github.com/jquery/jquery/tree/0705be475092aede1eddae01319ec931fb9c65fc/src": {
424
+			"https",
425
+			"github.com",
426
+			"jquery",
427
+			"jquery",
428
+			"tree",
429
+			"0705be475092aede1eddae01319ec931fb9c65fc",
430
+			"src",
431
+			"",
432
+		},
433
+		"https://try.gogs.io/gogs/gogs/commit/d8a994ef243349f321568f9e36d5c3f444b99cae#diff-2": {
434
+			"https",
435
+			"try.gogs.io",
436
+			"gogs",
437
+			"gogs",
438
+			"commit",
439
+			"d8a994ef243349f321568f9e36d5c3f444b99cae",
440
+			"",
441
+			"diff-2",
442
+		},
443
+	}
444
+
445
+	for k, v := range testCases {
446
+		assert.Equal(t, AnySHA1Pattern.FindStringSubmatch(k)[1:], v)
447
+	}
448
+}
449
+
450
+func TestMisc_IsSameDomain(t *testing.T) {
451
+	setting.AppURL = AppURL
452
+	setting.AppSubURL = AppSubURL
453
+
454
+	var sha = "b6dd6210eaebc915fd5be5579c58cce4da2e2579"
455
+	var commit = URLJoin(AppSubURL, "commit", sha)
456
+
457
+	assert.True(t, IsSameDomain(commit))
458
+	assert.False(t, IsSameDomain("http://google.com/ncr"))
459
+	assert.False(t, IsSameDomain("favicon.ico"))
460
+}

+ 57 - 23
modules/markup/markup.go

@@ -9,6 +9,12 @@ import (
9 9
 	"strings"
10 10
 )
11 11
 
12
+// Init initialize regexps for markdown parsing
13
+func Init() {
14
+	getIssueFullPattern()
15
+	NewSanitizer()
16
+}
17
+
12 18
 // Parser defines an interface for parsering markup file to HTML
13 19
 type Parser interface {
14 20
 	Name() string // markup format name
@@ -17,66 +23,94 @@ type Parser interface {
17 23
 }
18 24
 
19 25
 var (
20
-	parsers = make(map[string]Parser)
26
+	extParsers = make(map[string]Parser)
27
+	parsers    = make(map[string]Parser)
21 28
 )
22 29
 
23 30
 // RegisterParser registers a new markup file parser
24 31
 func RegisterParser(parser Parser) {
32
+	parsers[parser.Name()] = parser
25 33
 	for _, ext := range parser.Extensions() {
26
-		parsers[strings.ToLower(ext)] = parser
34
+		extParsers[strings.ToLower(ext)] = parser
27 35
 	}
28 36
 }
29 37
 
38
+// GetParserByFileName get parser by filename
39
+func GetParserByFileName(filename string) Parser {
40
+	extension := strings.ToLower(filepath.Ext(filename))
41
+	return extParsers[extension]
42
+}
43
+
44
+// GetParserByType returns a parser according type
45
+func GetParserByType(tp string) Parser {
46
+	return parsers[tp]
47
+}
48
+
30 49
 // Render renders markup file to HTML with all specific handling stuff.
31 50
 func Render(filename string, rawBytes []byte, urlPrefix string, metas map[string]string) []byte {
32
-	return render(filename, rawBytes, urlPrefix, metas, false)
51
+	return renderFile(filename, rawBytes, urlPrefix, metas, false)
33 52
 }
34 53
 
35
-func render(filename string, rawBytes []byte, urlPrefix string, metas map[string]string, isWiki bool) []byte {
36
-	extension := strings.ToLower(filepath.Ext(filename))
37
-	if parser, ok := parsers[extension]; ok {
38
-		return parser.Render(rawBytes, urlPrefix, metas, isWiki)
39
-	}
40
-	return nil
54
+// RenderByType renders markup to HTML with special links and returns string type.
55
+func RenderByType(tp string, rawBytes []byte, urlPrefix string, metas map[string]string) []byte {
56
+	return renderByType(tp, rawBytes, urlPrefix, metas, false)
41 57
 }
42 58
 
43 59
 // RenderString renders Markdown to HTML with special links and returns string type.
44 60
 func RenderString(filename string, raw, urlPrefix string, metas map[string]string) string {
45
-	return string(render(filename, []byte(raw), urlPrefix, metas, false))
61
+	return string(renderFile(filename, []byte(raw), urlPrefix, metas, false))
46 62
 }
47 63
 
48 64
 // RenderWiki renders markdown wiki page to HTML and return HTML string
49 65
 func RenderWiki(filename string, rawBytes []byte, urlPrefix string, metas map[string]string) string {
50
-	return string(render(filename, rawBytes, urlPrefix, metas, true))
66
+	return string(renderFile(filename, rawBytes, urlPrefix, metas, true))
67
+}
68
+
69
+func render(parser Parser, rawBytes []byte, urlPrefix string, metas map[string]string, isWiki bool) []byte {
70
+	urlPrefix = strings.Replace(urlPrefix, " ", "+", -1)
71
+	result := parser.Render(rawBytes, urlPrefix, metas, isWiki)
72
+	result = PostProcess(result, urlPrefix, metas, isWiki)
73
+	return SanitizeBytes(result)
74
+}
75
+
76
+func renderByType(tp string, rawBytes []byte, urlPrefix string, metas map[string]string, isWiki bool) []byte {
77
+	if parser, ok := parsers[tp]; ok {
78
+		return render(parser, rawBytes, urlPrefix, metas, isWiki)
79
+	}
80
+	return nil
81
+}
82
+
83
+func renderFile(filename string, rawBytes []byte, urlPrefix string, metas map[string]string, isWiki bool) []byte {
84
+	extension := strings.ToLower(filepath.Ext(filename))
85
+	if parser, ok := extParsers[extension]; ok {
86
+		return render(parser, rawBytes, urlPrefix, metas, isWiki)
87
+	}
88
+	return nil
51 89
 }
52 90
 
53 91
 // Type returns if markup format via the filename
54 92
 func Type(filename string) string {
55
-	extension := strings.ToLower(filepath.Ext(filename))
56
-	if parser, ok := parsers[extension]; ok {
93
+	if parser := GetParserByFileName(filename); parser != nil {
57 94
 		return parser.Name()
58 95
 	}
59 96
 	return ""
60 97
 }
61 98
 
62
-// ReadmeFileType reports whether name looks like a README file
63
-// based on its name and find the parser via its ext name
64
-func ReadmeFileType(name string) (string, bool) {
65
-	if IsReadmeFile(name) {
66
-		return Type(name), true
99
+// IsMarkupFile reports whether file is a markup type file
100
+func IsMarkupFile(name, markup string) bool {
101
+	if parser := GetParserByFileName(name); parser != nil {
102
+		return parser.Name() == markup
67 103
 	}
68
-	return "", false
104
+	return false
69 105
 }
70 106
 
71 107
 // IsReadmeFile reports whether name looks like a README file
72 108
 // based on its name.
73 109
 func IsReadmeFile(name string) bool {
110
+	name = strings.ToLower(name)
74 111
 	if len(name) < 6 {
75 112
 		return false
76
-	}
77
-
78
-	name = strings.ToLower(name)
79
-	if len(name) == 6 {
113
+	} else if len(name) == 6 {
80 114
 		return name == "readme"
81 115
 	}
82 116
 	return name[:7] == "readme."

+ 4 - 1
modules/markup/markup_test.go

@@ -2,11 +2,14 @@
2 2
 // Use of this source code is governed by a MIT-style
3 3
 // license that can be found in the LICENSE file.
4 4
 
5
-package markup
5
+package markup_test
6 6
 
7 7
 import (
8 8
 	"testing"
9 9
 
10
+	_ "code.gitea.io/gitea/modules/markdown"
11
+	. "code.gitea.io/gitea/modules/markup"
12
+
10 13
 	"github.com/stretchr/testify/assert"
11 14
 )
12 15
 

+ 1 - 1
modules/markdown/sanitizer.go

@@ -3,7 +3,7 @@
3 3
 // Use of this source code is governed by a MIT-style
4 4
 // license that can be found in the LICENSE file.
5 5
 
6
-package markdown
6
+package markup
7 7
 
8 8
 import (
9 9
 	"regexp"

+ 1 - 1
modules/markdown/sanitizer_test.go

@@ -3,7 +3,7 @@
3 3
 // Use of this source code is governed by a MIT-style
4 4
 // license that can be found in the LICENSE file.
5 5
 
6
-package markdown
6
+package markup
7 7
 
8 8
 import (
9 9
 	"testing"

+ 3 - 3
modules/templates/helper.go

@@ -24,7 +24,7 @@ import (
24 24
 	"code.gitea.io/gitea/models"
25 25
 	"code.gitea.io/gitea/modules/base"
26 26
 	"code.gitea.io/gitea/modules/log"
27
-	"code.gitea.io/gitea/modules/markdown"
27
+	"code.gitea.io/gitea/modules/markup"
28 28
 	"code.gitea.io/gitea/modules/setting"
29 29
 )
30 30
 
@@ -173,7 +173,7 @@ func SafeJS(raw string) template.JS {
173 173
 
174 174
 // Str2html render Markdown text to HTML
175 175
 func Str2html(raw string) template.HTML {
176
-	return template.HTML(markdown.Sanitize(raw))
176
+	return template.HTML(markup.Sanitize(raw))
177 177
 }
178 178
 
179 179
 // List traversings the list
@@ -253,7 +253,7 @@ func ReplaceLeft(s, old, new string) string {
253 253
 // RenderCommitMessage renders commit message with XSS-safe and special links.
254 254
 func RenderCommitMessage(full bool, msg, urlPrefix string, metas map[string]string) template.HTML {
255 255
 	cleanMsg := template.HTMLEscapeString(msg)
256
-	fullMessage := string(markdown.RenderIssueIndexPattern([]byte(cleanMsg), urlPrefix, metas))
256
+	fullMessage := string(markup.RenderIssueIndexPattern([]byte(cleanMsg), urlPrefix, metas))
257 257
 	msgLines := strings.Split(strings.TrimSpace(fullMessage), "\n")
258 258
 	numLines := len(msgLines)
259 259
 	if numLines == 0 {

+ 2 - 1
routers/api/v1/misc/markdown.go

@@ -9,6 +9,7 @@ import (
9 9
 
10 10
 	"code.gitea.io/gitea/modules/context"
11 11
 	"code.gitea.io/gitea/modules/markdown"
12
+	"code.gitea.io/gitea/modules/markup"
12 13
 	"code.gitea.io/gitea/modules/setting"
13 14
 )
14 15
 
@@ -39,7 +40,7 @@ func Markdown(ctx *context.APIContext, form api.MarkdownOption) {
39 40
 	switch form.Mode {
40 41
 	case "gfm":
41 42
 		md := []byte(form.Text)
42
-		context := markdown.URLJoin(setting.AppURL, form.Context)
43
+		context := markup.URLJoin(setting.AppURL, form.Context)
43 44
 		if form.Wiki {
44 45
 			ctx.Write([]byte(markdown.RenderWiki(md, context, nil)))
45 46
 		} else {

+ 8 - 10
routers/api/v1/misc/markdown_test.go

@@ -1,23 +1,21 @@
1 1
 package misc
2 2
 
3 3
 import (
4
+	"io/ioutil"
4 5
 	"net/http"
5 6
 	"net/http/httptest"
6
-	"testing"
7
-
8
-	macaron "gopkg.in/macaron.v1"
9
-
10 7
 	"net/url"
11
-
12
-	"io/ioutil"
13 8
 	"strings"
9
+	"testing"
14 10
 
15 11
 	"code.gitea.io/gitea/modules/context"
16
-	"code.gitea.io/gitea/modules/markdown"
12
+	"code.gitea.io/gitea/modules/markup"
17 13
 	"code.gitea.io/gitea/modules/setting"
18 14
 	api "code.gitea.io/sdk/gitea"
15
+
19 16
 	"github.com/go-macaron/inject"
20 17
 	"github.com/stretchr/testify/assert"
18
+	macaron "gopkg.in/macaron.v1"
21 19
 )
22 20
 
23 21
 const AppURL = "http://localhost:3000/"
@@ -55,7 +53,7 @@ func TestAPI_RenderGFM(t *testing.T) {
55 53
 		Context: Repo,
56 54
 		Wiki:    true,
57 55
 	}
58
-	requrl, _ := url.Parse(markdown.URLJoin(AppURL, "api", "v1", "markdown"))
56
+	requrl, _ := url.Parse(markup.URLJoin(AppURL, "api", "v1", "markdown"))
59 57
 	req := &http.Request{
60 58
 		Method: "POST",
61 59
 		URL:    requrl,
@@ -149,7 +147,7 @@ func TestAPI_RenderSimple(t *testing.T) {
149 147
 		Text:    "",
150 148
 		Context: Repo,
151 149
 	}
152
-	requrl, _ := url.Parse(markdown.URLJoin(AppURL, "api", "v1", "markdown"))
150
+	requrl, _ := url.Parse(markup.URLJoin(AppURL, "api", "v1", "markdown"))
153