// Copyright The Forgejo Authors. // SPDX-License-Identifier: MIT package markup import ( "bufio" "bytes" "html/template" "regexp" "slices" "strconv" "strings" "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/highlight" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/translation" "golang.org/x/net/html" "golang.org/x/net/html/atom" ) // filePreviewPattern matches "http://domain/org/repo/src/commit/COMMIT/filepath#L1-L2" var filePreviewPattern = regexp.MustCompile(`https?://((?:\S+/){3})src/commit/([0-9a-f]{4,64})/(\S+)#(L\d+(?:-L\d+)?)`) type FilePreview struct { fileContent []template.HTML subTitle template.HTML lineOffset int urlFull string filePath string start int end int isTruncated bool } func NewFilePreview(ctx *RenderContext, node *html.Node, locale translation.Locale) *FilePreview { if setting.FilePreviewMaxLines == 0 { // Feature is disabled return nil } preview := &FilePreview{} m := filePreviewPattern.FindStringSubmatchIndex(node.Data) if m == nil { return nil } // Ensure that every group has a match if slices.Contains(m, -1) { return nil } preview.urlFull = node.Data[m[0]:m[1]] // Ensure that we only use links to local repositories if !strings.HasPrefix(preview.urlFull, setting.AppURL+setting.AppSubURL) { return nil } projPath := strings.TrimSuffix(node.Data[m[2]:m[3]], "/") commitSha := node.Data[m[4]:m[5]] preview.filePath = node.Data[m[6]:m[7]] hash := node.Data[m[8]:m[9]] preview.start = m[0] preview.end = m[1] projPathSegments := strings.Split(projPath, "/") var language string fileBlob, err := DefaultProcessorHelper.GetRepoFileBlob( ctx.Ctx, projPathSegments[len(projPathSegments)-2], projPathSegments[len(projPathSegments)-1], commitSha, preview.filePath, &language, ) if err != nil { return nil } lineSpecs := strings.Split(hash, "-") commitLinkBuffer := new(bytes.Buffer) err = html.Render(commitLinkBuffer, createLink(node.Data[m[0]:m[5]], commitSha[0:7], "text black")) if err != nil { log.Error("failed to render commitLink: %v", err) } var startLine, endLine int if len(lineSpecs) == 1 { startLine, _ = strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L")) endLine = startLine preview.subTitle = locale.Tr( "markup.filepreview.line", startLine, template.HTML(commitLinkBuffer.String()), ) preview.lineOffset = startLine - 1 } else { startLine, _ = strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L")) endLine, _ = strconv.Atoi(strings.TrimPrefix(lineSpecs[1], "L")) preview.subTitle = locale.Tr( "markup.filepreview.lines", startLine, endLine, template.HTML(commitLinkBuffer.String()), ) preview.lineOffset = startLine - 1 } lineCount := endLine - (startLine - 1) if startLine < 1 || endLine < 1 || lineCount < 1 { return nil } if setting.FilePreviewMaxLines > 0 && lineCount > setting.FilePreviewMaxLines { preview.isTruncated = true lineCount = setting.FilePreviewMaxLines } dataRc, err := fileBlob.DataAsync() if err != nil { return nil } defer dataRc.Close() reader := bufio.NewReader(dataRc) // skip all lines until we find our startLine for i := 1; i < startLine; i++ { _, err := reader.ReadBytes('\n') if err != nil { return nil } } // capture the lines we're interested in lineBuffer := new(bytes.Buffer) for i := 0; i < lineCount; i++ { buf, err := reader.ReadBytes('\n') if err != nil { break } lineBuffer.Write(buf) } // highlight the file... fileContent, _, err := highlight.File(fileBlob.Name(), language, lineBuffer.Bytes()) if err != nil { log.Error("highlight.File failed, fallback to plain text: %v", err) fileContent = highlight.PlainText(lineBuffer.Bytes()) } preview.fileContent = fileContent return preview } func (p *FilePreview) CreateHTML(locale translation.Locale) *html.Node { table := &html.Node{ Type: html.ElementNode, Data: atom.Table.String(), Attr: []html.Attribute{{Key: "class", Val: "file-preview"}}, } tbody := &html.Node{ Type: html.ElementNode, Data: atom.Tbody.String(), } status := &charset.EscapeStatus{} statuses := make([]*charset.EscapeStatus, len(p.fileContent)) for i, line := range p.fileContent { statuses[i], p.fileContent[i] = charset.EscapeControlHTML(line, locale, charset.FileviewContext) status = status.Or(statuses[i]) } for idx, code := range p.fileContent { tr := &html.Node{ Type: html.ElementNode, Data: atom.Tr.String(), } lineNum := strconv.Itoa(p.lineOffset + idx + 1) tdLinesnum := &html.Node{ Type: html.ElementNode, Data: atom.Td.String(), Attr: []html.Attribute{ {Key: "class", Val: "lines-num"}, }, } spanLinesNum := &html.Node{ Type: html.ElementNode, Data: atom.Span.String(), Attr: []html.Attribute{ {Key: "data-line-number", Val: lineNum}, }, } tdLinesnum.AppendChild(spanLinesNum) tr.AppendChild(tdLinesnum) if status.Escaped { tdLinesEscape := &html.Node{ Type: html.ElementNode, Data: atom.Td.String(), Attr: []html.Attribute{ {Key: "class", Val: "lines-escape"}, }, } if statuses[idx].Escaped { btnTitle := "" if statuses[idx].HasInvisible { btnTitle += locale.TrString("repo.invisible_runes_line") + " " } if statuses[idx].HasAmbiguous { btnTitle += locale.TrString("repo.ambiguous_runes_line") } escapeBtn := &html.Node{ Type: html.ElementNode, Data: atom.Button.String(), Attr: []html.Attribute{ {Key: "class", Val: "toggle-escape-button btn interact-bg"}, {Key: "title", Val: btnTitle}, }, } tdLinesEscape.AppendChild(escapeBtn) } tr.AppendChild(tdLinesEscape) } tdCode := &html.Node{ Type: html.ElementNode, Data: atom.Td.String(), Attr: []html.Attribute{ {Key: "class", Val: "lines-code chroma"}, }, } codeInner := &html.Node{ Type: html.ElementNode, Data: atom.Code.String(), Attr: []html.Attribute{{Key: "class", Val: "code-inner"}}, } codeText := &html.Node{ Type: html.RawNode, Data: string(code), } codeInner.AppendChild(codeText) tdCode.AppendChild(codeInner) tr.AppendChild(tdCode) tbody.AppendChild(tr) } table.AppendChild(tbody) twrapper := &html.Node{ Type: html.ElementNode, Data: atom.Div.String(), Attr: []html.Attribute{{Key: "class", Val: "ui table"}}, } twrapper.AppendChild(table) header := &html.Node{ Type: html.ElementNode, Data: atom.Div.String(), Attr: []html.Attribute{{Key: "class", Val: "header"}}, } afilepath := &html.Node{ Type: html.ElementNode, Data: atom.A.String(), Attr: []html.Attribute{ {Key: "href", Val: p.urlFull}, {Key: "class", Val: "muted"}, }, } afilepath.AppendChild(&html.Node{ Type: html.TextNode, Data: p.filePath, }) header.AppendChild(afilepath) psubtitle := &html.Node{ Type: html.ElementNode, Data: atom.Span.String(), Attr: []html.Attribute{{Key: "class", Val: "text small grey"}}, } psubtitle.AppendChild(&html.Node{ Type: html.RawNode, Data: string(p.subTitle), }) header.AppendChild(psubtitle) node := &html.Node{ Type: html.ElementNode, Data: atom.Div.String(), Attr: []html.Attribute{{Key: "class", Val: "file-preview-box"}}, } node.AppendChild(header) if p.isTruncated { warning := &html.Node{ Type: html.ElementNode, Data: atom.Div.String(), Attr: []html.Attribute{{Key: "class", Val: "ui warning message tw-text-left"}}, } warning.AppendChild(&html.Node{ Type: html.TextNode, Data: locale.TrString("markup.filepreview.truncated"), }) node.AppendChild(warning) } node.AppendChild(twrapper) return node }