mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-26 06:33:42 +09:00
Comment excerpts in activity feeds previously used either the first 200 display characters of a comment or, for review comments, its first physical line. That excerpt is rendered as Markdown in the feed, so if the excerpt began with a leading blank lines or structural markdown syntax, the excerpt would render as empty or produce broken output. For example, a review comment beginning with a code fence stored only the opening fence, which rendered as an empty code block. This commit instead renders feed excerpts as prose only, dropping code, math, tables, images and HTML, which also fixes already stored excerpts. New excerpts start at the first prose line, and review comments get the same excerpt as issue comments. This produces meaningful excerpts in more cases while preserving their original Markdown. --- For a comment that contains the following: ```` ``` some code ``` hello ```` This previously rendered as: <img width="816" height="118" alt="Screenshot 2026-09-08 at 5 06 47 PM" src="https://github.com/user-attachments/assets/9d125363-72de-46bf-b47a-961245a79c5f" /> And now renders as: <img width="807" height="89" alt="Screenshot 2026-09-08 at 5 08 30 PM" src="https://github.com/user-attachments/assets/d2dcdc6d-d7a9-437f-8e9c-b845fe99fa5e" /> --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: bircni <bircni@icloud.com>
370 lines
11 KiB
Go
370 lines
11 KiB
Go
// Copyright 2024 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package markup
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dev/modules/htmlutil"
|
|
"gitea.dev/modules/markup/common"
|
|
"gitea.dev/modules/markup/internal"
|
|
"gitea.dev/modules/public"
|
|
"gitea.dev/modules/setting"
|
|
"gitea.dev/modules/typesniffer"
|
|
"gitea.dev/modules/util"
|
|
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
type RenderMetaMode string
|
|
|
|
const (
|
|
RenderMetaAsDetails RenderMetaMode = "details" // default
|
|
RenderMetaAsNone RenderMetaMode = "none"
|
|
RenderMetaAsTable RenderMetaMode = "table"
|
|
)
|
|
|
|
var RenderBehaviorForTesting struct {
|
|
// Gitea will emit some additional attributes for various purposes, these attributes don't affect rendering.
|
|
// But there are too many hard-coded test cases, to avoid changing all of them again and again, we can disable emitting these internal attributes.
|
|
DisableAdditionalAttributes bool
|
|
}
|
|
|
|
type WebThemeInterface interface {
|
|
PublicAssetURI() string
|
|
}
|
|
|
|
type StandalonePageOptions struct {
|
|
CurrentWebTheme WebThemeInterface
|
|
RenderQueryString string
|
|
}
|
|
|
|
type RenderOptions struct {
|
|
UseAbsoluteLink bool
|
|
FeedExcerpt bool
|
|
|
|
// relative path from tree root of the branch
|
|
RelativePath string
|
|
|
|
// eg: "orgmode", "asciicast", "console"
|
|
// for file mode, it could be left as empty, and will be detected by file extension in RelativePath
|
|
MarkupType string
|
|
|
|
// user&repo, format&style®exp (for external issue pattern), teams&org (for mention)
|
|
// RefTypeNameSubURL (for iframe&asciicast)
|
|
// markupAllowShortIssuePattern
|
|
// markdownNewLineHardBreak
|
|
Metas map[string]string
|
|
|
|
// used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page
|
|
StandalonePageOptions *StandalonePageOptions
|
|
|
|
// EnableHeadingIDGeneration controls whether to auto-generate IDs for HTML headings without id attribute.
|
|
// This should be enabled for repository files and wiki pages, but disabled for comments to avoid duplicate IDs.
|
|
EnableHeadingIDGeneration bool
|
|
}
|
|
|
|
type TocShowInSectionType string
|
|
|
|
const (
|
|
TocShowInSidebar TocShowInSectionType = "sidebar"
|
|
TocShowInMain TocShowInSectionType = "main"
|
|
)
|
|
|
|
type TocHeadingItem struct {
|
|
HeadingLevel int
|
|
AnchorID string
|
|
InnerText string
|
|
}
|
|
|
|
// RenderContext represents a render context
|
|
type RenderContext struct {
|
|
ctx context.Context
|
|
|
|
// the context might be used by the "render" function, but it might also be used by "postProcess" function
|
|
usedByRender bool
|
|
|
|
TocShowInSection TocShowInSectionType
|
|
TocHeadingItems []*TocHeadingItem
|
|
|
|
RenderHelper RenderHelper
|
|
RenderOptions RenderOptions
|
|
RenderInternal internal.RenderInternal
|
|
}
|
|
|
|
func (ctx *RenderContext) Deadline() (deadline time.Time, ok bool) {
|
|
return ctx.ctx.Deadline()
|
|
}
|
|
|
|
func (ctx *RenderContext) Done() <-chan struct{} {
|
|
return ctx.ctx.Done()
|
|
}
|
|
|
|
func (ctx *RenderContext) Err() error {
|
|
return ctx.ctx.Err()
|
|
}
|
|
|
|
func (ctx *RenderContext) Value(key any) any {
|
|
return ctx.ctx.Value(key)
|
|
}
|
|
|
|
var _ context.Context = (*RenderContext)(nil)
|
|
|
|
func NewRenderContext(ctx context.Context) *RenderContext {
|
|
return &RenderContext{ctx: ctx, RenderHelper: &SimpleRenderHelper{}}
|
|
}
|
|
|
|
func (ctx *RenderContext) WithMarkupType(typ string) *RenderContext {
|
|
ctx.RenderOptions.MarkupType = typ
|
|
return ctx
|
|
}
|
|
|
|
func (ctx *RenderContext) WithRelativePath(path string) *RenderContext {
|
|
ctx.RenderOptions.RelativePath = path
|
|
return ctx
|
|
}
|
|
|
|
func (ctx *RenderContext) WithMetas(metas map[string]string) *RenderContext {
|
|
ctx.RenderOptions.Metas = metas
|
|
return ctx
|
|
}
|
|
|
|
func (ctx *RenderContext) WithStandalonePage(opts StandalonePageOptions) *RenderContext {
|
|
ctx.RenderOptions.StandalonePageOptions = &opts
|
|
return ctx
|
|
}
|
|
|
|
func (ctx *RenderContext) WithEnableHeadingIDGeneration(v bool) *RenderContext {
|
|
ctx.RenderOptions.EnableHeadingIDGeneration = v
|
|
return ctx
|
|
}
|
|
|
|
func (ctx *RenderContext) WithUseAbsoluteLink(v bool) *RenderContext {
|
|
ctx.RenderOptions.UseAbsoluteLink = v
|
|
return ctx
|
|
}
|
|
|
|
func (ctx *RenderContext) WithHelper(helper RenderHelper) *RenderContext {
|
|
ctx.RenderHelper = helper
|
|
return ctx
|
|
}
|
|
|
|
func (ctx *RenderContext) DetectMarkupRenderer(prefetchBuf []byte) Renderer {
|
|
if ctx.RenderOptions.MarkupType == "" && ctx.RenderOptions.RelativePath != "" {
|
|
var sniffedType typesniffer.SniffedType
|
|
if len(prefetchBuf) > 0 {
|
|
sniffedType = typesniffer.DetectContentType(prefetchBuf)
|
|
}
|
|
ctx.RenderOptions.MarkupType = DetectRendererTypeByPrefetch(ctx.RenderOptions.RelativePath, sniffedType, prefetchBuf)
|
|
}
|
|
return renderers[ctx.RenderOptions.MarkupType]
|
|
}
|
|
|
|
func (ctx *RenderContext) DetectMarkupRendererByReader(in io.Reader) (Renderer, io.Reader, error) {
|
|
prefetchBuf := make([]byte, 512)
|
|
n, err := util.ReadAtMost(in, prefetchBuf)
|
|
if err != nil && err != io.EOF {
|
|
return nil, nil, err
|
|
}
|
|
prefetchBuf = prefetchBuf[:n]
|
|
renderer := ctx.DetectMarkupRenderer(prefetchBuf)
|
|
if renderer == nil {
|
|
return nil, nil, util.NewInvalidArgumentErrorf("unable to find a render")
|
|
}
|
|
return renderer, io.MultiReader(bytes.NewReader(prefetchBuf), in), nil
|
|
}
|
|
|
|
func RendererNeedPostProcess(renderer Renderer) bool {
|
|
if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Render renders markup file to HTML with all specific handling stuff.
|
|
func Render(rctx *RenderContext, origInput io.Reader, output io.Writer) error {
|
|
renderer, input, err := rctx.DetectMarkupRendererByReader(origInput)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return RenderWithRenderer(rctx, renderer, input, output)
|
|
}
|
|
|
|
func RenderIFrame(ctx *RenderContext, opts *ExternalRendererOptions, output io.Writer) error {
|
|
ownerName, repoName := ctx.RenderOptions.Metas["user"], ctx.RenderOptions.Metas["repo"]
|
|
refSubURL := ctx.RenderOptions.Metas["RefTypeNameSubURL"]
|
|
if ownerName == "" || repoName == "" || refSubURL == "" {
|
|
setting.PanicInDevOrTesting("RenderIFrame requires user, repo and RefTypeNameSubURL metas")
|
|
return errors.New("RenderIFrame requires user, repo and RefTypeNameSubURL metas")
|
|
}
|
|
src := fmt.Sprintf("%s/%s/%s/render/%s/%s", setting.AppSubURL,
|
|
url.PathEscape(ownerName),
|
|
url.PathEscape(repoName),
|
|
ctx.RenderOptions.Metas["RefTypeNameSubURL"],
|
|
util.PathEscapeSegments(ctx.RenderOptions.RelativePath),
|
|
)
|
|
|
|
// The render response should always have correct "sandbox" limits (no same-origin),
|
|
// otherwise the "render link" direct access can still cause XSS without iframe.
|
|
// So here we do not need to set sandbox attribute on the iframe.
|
|
_, err := htmlutil.HTMLPrintf(output, `<iframe data-src="%s" data-global-init="initExternalRenderIframe" class="external-render-iframe"></iframe>`, src)
|
|
return err
|
|
}
|
|
|
|
func pipes() (io.ReadCloser, io.WriteCloser, func()) {
|
|
pr, pw := io.Pipe()
|
|
return pr, pw, func() {
|
|
_ = pr.Close()
|
|
_ = pw.Close()
|
|
}
|
|
}
|
|
|
|
func GetExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions, _ bool) {
|
|
if externalRender, ok := renderer.(ExternalRenderer); ok {
|
|
return externalRender.GetExternalRendererOptions(), true
|
|
}
|
|
return ret, false
|
|
}
|
|
|
|
func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
|
|
var extraHeadHTML template.HTML
|
|
if extOpts, ok := GetExternalRendererOptions(renderer); ok && extOpts.DisplayInIframe {
|
|
if ctx.RenderOptions.StandalonePageOptions == nil {
|
|
// for an external "DisplayInIFrame" render, it could only output its content in a standalone page
|
|
// otherwise, a <iframe> should be outputted to embed the external rendered page
|
|
return RenderIFrame(ctx, &extOpts, output)
|
|
}
|
|
// else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS
|
|
extraScriptSrc := public.AssetURI("web_src/js/external-render-helper.ts")
|
|
extraLinkHref := ctx.RenderOptions.StandalonePageOptions.CurrentWebTheme.PublicAssetURI()
|
|
// "<script>" must go before "<link>", to make Golang's http.DetectContentType() can still recognize the content as "text/html"
|
|
// DO NOT use "type=module", the script must run as early as possible, to set up the environment in the iframe
|
|
extraHeadHTML = htmlutil.HTMLFormat(
|
|
`<script nonce crossorigin src="%s" id="gitea-external-render-helper" data-render-query-string="%s"></script>`+
|
|
`<link rel="stylesheet" href="%s">`,
|
|
extraScriptSrc, ctx.RenderOptions.StandalonePageOptions.RenderQueryString,
|
|
extraLinkHref,
|
|
)
|
|
}
|
|
|
|
ctx.usedByRender = true
|
|
if ctx.RenderHelper != nil {
|
|
defer ctx.RenderHelper.CleanUp()
|
|
}
|
|
|
|
finalProcessor := ctx.RenderInternal.Init(output, extraHeadHTML)
|
|
defer finalProcessor.Close()
|
|
|
|
// input -> (pw1=pr1) -> renderer -> (pw2=pr2) -> SanitizeReader -> finalProcessor -> output
|
|
// no sanitizer: input -> (pw1=pr1) -> renderer -> pw2(finalProcessor) -> output
|
|
pr1, pw1, close1 := pipes()
|
|
defer close1()
|
|
|
|
eg, _ := errgroup.WithContext(ctx)
|
|
var pw2 io.WriteCloser = util.NopCloser{Writer: finalProcessor}
|
|
|
|
if r, ok := renderer.(ExternalRenderer); !ok || !r.GetExternalRendererOptions().SanitizerDisabled {
|
|
var pr2 io.ReadCloser
|
|
var close2 func()
|
|
pr2, pw2, close2 = pipes()
|
|
defer close2()
|
|
eg.Go(func() error {
|
|
defer pr2.Close()
|
|
return SanitizeReader(pr2, renderer.Name(), finalProcessor)
|
|
})
|
|
}
|
|
|
|
eg.Go(func() (err error) {
|
|
if RendererNeedPostProcess(renderer) {
|
|
err = PostProcessDefault(ctx, pr1, pw2)
|
|
} else {
|
|
_, err = io.Copy(pw2, pr1)
|
|
}
|
|
_, _ = pr1.Close(), pw2.Close()
|
|
return err
|
|
})
|
|
|
|
if err := renderer.Render(ctx, input, pw1); err != nil {
|
|
return err
|
|
}
|
|
_ = pw1.Close()
|
|
|
|
return eg.Wait()
|
|
}
|
|
|
|
// Init initializes the render global variables
|
|
func Init(renderHelpFuncs *RenderHelperFuncs) {
|
|
DefaultRenderHelperFuncs = renderHelpFuncs
|
|
common.InitLinkURLSchemes(setting.Markdown.CustomURLSchemes)
|
|
|
|
// since setting maybe changed extensions, this will reload all renderer extensions mapping
|
|
fileNameRenderers = make(map[string]Renderer)
|
|
for _, renderer := range renderers {
|
|
for _, pattern := range renderer.FileNamePatterns() {
|
|
fileNameRenderers[pattern] = renderer
|
|
}
|
|
}
|
|
|
|
RefreshFileNamePatterns()
|
|
}
|
|
|
|
func ComposeSimpleDocumentMetas() map[string]string {
|
|
// TODO: there is no separate config option for "simple document" rendering, so temporarily use the same config as "repo file"
|
|
return map[string]string{"markdownNewLineHardBreak": strconv.FormatBool(setting.Markdown.RenderOptionsRepoFile.NewLineHardBreak)}
|
|
}
|
|
|
|
type TestRenderHelper struct {
|
|
ctx *RenderContext
|
|
BaseLink string
|
|
}
|
|
|
|
func (r *TestRenderHelper) CleanUp() {}
|
|
|
|
func (r *TestRenderHelper) IsCommitIDExisting(commitID string) bool {
|
|
return strings.HasPrefix(commitID, "65f1bf2") //|| strings.HasPrefix(commitID, "88fc37a")
|
|
}
|
|
|
|
func (r *TestRenderHelper) ResolveLink(link, preferLinkType string) string {
|
|
linkType, link := ParseRenderedLink(link, preferLinkType)
|
|
switch linkType {
|
|
case LinkTypeRoot:
|
|
return r.ctx.ResolveLinkRoot(link)
|
|
default:
|
|
return r.ctx.ResolveLinkRelative(r.BaseLink, "", link)
|
|
}
|
|
}
|
|
|
|
var _ RenderHelper = (*TestRenderHelper)(nil)
|
|
|
|
// NewTestRenderContext is a helper function to create a RenderContext for testing purpose
|
|
// It accepts string (BaseLink), map[string]string (Metas)
|
|
func NewTestRenderContext(baseLinkOrMetas ...any) *RenderContext {
|
|
if !setting.IsInTesting {
|
|
panic("NewTestRenderContext should only be used in testing")
|
|
}
|
|
helper := &TestRenderHelper{}
|
|
ctx := NewRenderContext(context.Background()).WithHelper(helper)
|
|
helper.ctx = ctx
|
|
for _, v := range baseLinkOrMetas {
|
|
switch v := v.(type) {
|
|
case string:
|
|
helper.BaseLink = v
|
|
case map[string]string:
|
|
ctx = ctx.WithMetas(v)
|
|
default:
|
|
panic(fmt.Sprintf("unknown type %T", v))
|
|
}
|
|
}
|
|
return ctx
|
|
}
|