a845b744b7
HTML files now render in an iframe served from S3 (tinqs-git-preview bucket) instead of Gitea's raw endpoint which forces text/plain. SWR flow: first request uploads blob to S3 synchronously, subsequent requests redirect to presigned S3 URL instantly. When the blob SHA changes (new commit), the stale version is served immediately while the new version uploads in the background. Security: iframe uses sandbox="allow-scripts" only (no allow-same-origin). S3 is a different origin from git.arikigame.com, so even if JS runs in the iframe it cannot access Gitea session cookies or API tokens. Config: [html_preview] section in app.ini, disabled by default. Release pipeline auto-adds config on first deploy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
337 lines
12 KiB
Go
337 lines
12 KiB
Go
// Copyright 2024 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package repo
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
"io"
|
|
"path"
|
|
"strings"
|
|
|
|
git_model "code.gitea.io/gitea/models/git"
|
|
issue_model "code.gitea.io/gitea/models/issues"
|
|
"code.gitea.io/gitea/models/renderhelper"
|
|
user_model "code.gitea.io/gitea/models/user"
|
|
"code.gitea.io/gitea/modules/actions"
|
|
"code.gitea.io/gitea/modules/charset"
|
|
"code.gitea.io/gitea/modules/git"
|
|
"code.gitea.io/gitea/modules/git/attribute"
|
|
"code.gitea.io/gitea/modules/highlight"
|
|
"code.gitea.io/gitea/modules/log"
|
|
"code.gitea.io/gitea/modules/markup"
|
|
"code.gitea.io/gitea/modules/setting"
|
|
"code.gitea.io/gitea/modules/util"
|
|
"code.gitea.io/gitea/services/context"
|
|
issue_service "code.gitea.io/gitea/services/issue"
|
|
)
|
|
|
|
func prepareLatestCommitInfo(ctx *context.Context) bool {
|
|
commit, err := ctx.Repo.Commit.GetCommitByPath(ctx.Repo.TreePath)
|
|
if err != nil {
|
|
ctx.ServerError("GetCommitByPath", err)
|
|
return false
|
|
}
|
|
|
|
return loadLatestCommitData(ctx, commit)
|
|
}
|
|
|
|
func prepareFileViewLfsAttrs(ctx *context.Context) (*attribute.Attributes, bool) {
|
|
attrsMap, err := attribute.CheckAttributes(ctx, ctx.Repo.GitRepo, ctx.Repo.CommitID, attribute.CheckAttributeOpts{
|
|
Filenames: []string{ctx.Repo.TreePath},
|
|
Attributes: []string{attribute.LinguistGenerated, attribute.LinguistVendored, attribute.LinguistLanguage, attribute.GitlabLanguage},
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("attribute.CheckAttributes", err)
|
|
return nil, false
|
|
}
|
|
attrs := attrsMap[ctx.Repo.TreePath]
|
|
if attrs == nil {
|
|
// this case shouldn't happen, just in case.
|
|
setting.PanicInDevOrTesting("no attributes found for %s", ctx.Repo.TreePath)
|
|
attrs = attribute.NewAttributes()
|
|
}
|
|
ctx.Data["IsVendored"], ctx.Data["IsGenerated"] = attrs.GetVendored().Value(), attrs.GetGenerated().Value()
|
|
return attrs, true
|
|
}
|
|
|
|
func isHTMLTreePath(treePath string) bool {
|
|
lower := strings.ToLower(treePath)
|
|
return strings.HasSuffix(lower, ".html") || strings.HasSuffix(lower, ".htm")
|
|
}
|
|
|
|
func handleFileViewRenderMarkup(ctx *context.Context, prefetchBuf []byte, utf8Reader io.Reader) bool {
|
|
if isHTMLTreePath(ctx.Repo.TreePath) {
|
|
return false // HTML uses dedicated preview handler, not the markup engine
|
|
}
|
|
rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{
|
|
CurrentRefPath: ctx.Repo.RefTypeNameSubURL(),
|
|
CurrentTreePath: path.Dir(ctx.Repo.TreePath),
|
|
}).WithRelativePath(ctx.Repo.TreePath)
|
|
|
|
renderer := rctx.DetectMarkupRenderer(prefetchBuf)
|
|
if renderer == nil {
|
|
return false // not supported markup
|
|
}
|
|
metas := ctx.Repo.Repository.ComposeRepoFileMetas(ctx)
|
|
metas["RefTypeNameSubURL"] = ctx.Repo.RefTypeNameSubURL()
|
|
rctx.WithMetas(metas)
|
|
|
|
ctx.Data["HasSourceRenderedToggle"] = true
|
|
|
|
if ctx.FormString("display") == "source" {
|
|
return false
|
|
}
|
|
|
|
var err error
|
|
ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRenderToHTML(ctx, rctx, renderer, utf8Reader)
|
|
if err != nil {
|
|
ctx.ServerError("Render", err)
|
|
return true
|
|
}
|
|
|
|
opts, ok := markup.GetExternalRendererOptions(renderer)
|
|
usingIframe := ok && opts.DisplayInIframe
|
|
ctx.Data["MarkupType"] = rctx.RenderOptions.MarkupType
|
|
ctx.Data["RenderAsMarkup"] = util.Iif(usingIframe, "markup-iframe", "markup-inplace")
|
|
return true
|
|
}
|
|
|
|
func handleFileViewRenderSource(ctx *context.Context, attrs *attribute.Attributes, fInfo *fileInfo, utf8Reader io.Reader) bool {
|
|
if isHTMLTreePath(ctx.Repo.TreePath) && ctx.FormString("display") != "source" {
|
|
return false // default HTML files to iframe preview, not line-numbered source
|
|
}
|
|
filename := ctx.Repo.TreePath
|
|
if ctx.FormString("display") == "rendered" || !fInfo.st.IsRepresentableAsText() {
|
|
return false
|
|
}
|
|
|
|
if !fInfo.st.IsText() {
|
|
if ctx.FormString("display") == "" {
|
|
// not text but representable as text, e.g. SVG
|
|
// since there is no "display" is specified, let other renders to handle
|
|
return false
|
|
}
|
|
ctx.Data["HasSourceRenderedToggle"] = true
|
|
}
|
|
|
|
buf, _ := io.ReadAll(utf8Reader)
|
|
// The Open Group Base Specification: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html
|
|
// empty: 0 lines; "a": 1 incomplete-line; "a\n": 1 line; "a\nb": 1 line, 1 incomplete-line;
|
|
// Gitea uses the definition (like most modern editors):
|
|
// empty: 0 lines; "a": 1 line; "a\n": 2 lines; "a\nb": 2 lines;
|
|
// When rendering, the last empty line is not rendered in UI, while the line-number is still counted, to tell users that the file contains a trailing EOL.
|
|
// To make the UI more consistent, it could use an icon mark to indicate that there is no trailing EOL, and show line-number as the rendered lines.
|
|
// This NumLines is only used for the display on the UI: "xxx lines"
|
|
if len(buf) == 0 {
|
|
ctx.Data["NumLines"] = 0
|
|
} else {
|
|
ctx.Data["NumLines"] = bytes.Count(buf, []byte{'\n'}) + 1
|
|
}
|
|
|
|
language := attrs.GetLanguage().Value()
|
|
fileContent, lexerName := highlight.RenderFullFile(filename, language, buf)
|
|
ctx.Data["LexerName"] = lexerName
|
|
status := &charset.EscapeStatus{}
|
|
statuses := make([]*charset.EscapeStatus, len(fileContent))
|
|
for i, line := range fileContent {
|
|
statuses[i], fileContent[i] = charset.EscapeControlHTML(line, ctx.Locale)
|
|
status = status.Or(statuses[i])
|
|
}
|
|
ctx.Data["EscapeStatus"] = status
|
|
ctx.Data["FileContent"] = fileContent
|
|
ctx.Data["LineEscapeStatus"] = statuses
|
|
return true
|
|
}
|
|
|
|
func handleFileViewRenderImage(ctx *context.Context, fInfo *fileInfo, prefetchBuf []byte) bool {
|
|
if !fInfo.st.IsImage() {
|
|
return false
|
|
}
|
|
if fInfo.st.IsSvgImage() && !setting.UI.SVG.Enabled {
|
|
return false
|
|
}
|
|
if fInfo.st.IsSvgImage() {
|
|
ctx.Data["HasSourceRenderedToggle"] = true
|
|
} else {
|
|
img, _, err := image.DecodeConfig(bytes.NewReader(prefetchBuf))
|
|
if err == nil { // ignore the error for the formats that are not supported by image.DecodeConfig
|
|
ctx.Data["ImageSize"] = fmt.Sprintf("%dx%dpx", img.Width, img.Height)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func handleFileViewRenderHTML(ctx *context.Context, fInfo *fileInfo) bool {
|
|
if !isHTMLTreePath(ctx.Repo.TreePath) {
|
|
return false
|
|
}
|
|
// Allow toggling to source view
|
|
if ctx.FormString("display") == "source" {
|
|
return false
|
|
}
|
|
ctx.Data["IsHTMLFile"] = true
|
|
return true
|
|
}
|
|
|
|
func prepareFileView(ctx *context.Context, entry *git.TreeEntry) {
|
|
ctx.Data["IsViewFile"] = true
|
|
ctx.Data["HideRepoInfo"] = true
|
|
|
|
if !prepareLatestCommitInfo(ctx) {
|
|
return
|
|
}
|
|
|
|
blob := entry.Blob()
|
|
|
|
ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+ctx.Repo.TreePath, ctx.Repo.RefFullName.ShortName())
|
|
ctx.Data["FileIsSymlink"] = entry.IsLink()
|
|
ctx.Data["FileTreePath"] = ctx.Repo.TreePath
|
|
ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
|
|
if isHTMLTreePath(ctx.Repo.TreePath) {
|
|
ctx.Data["IsHTMLFile"] = true
|
|
ctx.Data["HTMLPreviewEnabled"] = setting.HTMLPreview.Enabled
|
|
ctx.Data["HTMLPreviewLink"] = ctx.Repo.RepoLink + "/preview/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
|
|
}
|
|
|
|
if ctx.Repo.TreePath == ".editorconfig" {
|
|
_, editorconfigWarning, editorconfigErr := ctx.Repo.GetEditorconfig(ctx.Repo.Commit)
|
|
if editorconfigWarning != nil {
|
|
ctx.Data["FileWarning"] = strings.TrimSpace(editorconfigWarning.Error())
|
|
}
|
|
if editorconfigErr != nil {
|
|
ctx.Data["FileError"] = strings.TrimSpace(editorconfigErr.Error())
|
|
}
|
|
} else if issue_service.IsTemplateConfig(ctx.Repo.TreePath) {
|
|
_, issueConfigErr := issue_service.GetTemplateConfig(ctx.Repo.GitRepo, ctx.Repo.TreePath, ctx.Repo.Commit)
|
|
if issueConfigErr != nil {
|
|
ctx.Data["FileError"] = strings.TrimSpace(issueConfigErr.Error())
|
|
}
|
|
} else if actions.IsWorkflow(ctx.Repo.TreePath) {
|
|
content, err := actions.GetContentFromEntry(entry)
|
|
if err != nil {
|
|
log.Error("actions.GetContentFromEntry: %v", err)
|
|
}
|
|
if workFlowErr := actions.ValidateWorkflowContent(content); workFlowErr != nil {
|
|
ctx.Data["FileError"] = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", workFlowErr.Error())
|
|
}
|
|
} else if issue_service.IsCodeOwnerFile(ctx.Repo.TreePath) {
|
|
if data, err := blob.GetBlobContent(setting.UI.MaxDisplayFileSize); err == nil {
|
|
_, warnings := issue_model.GetCodeOwnersFromContent(ctx, data)
|
|
if len(warnings) > 0 {
|
|
ctx.Data["FileWarning"] = strings.Join(warnings, "\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Don't call any other repository functions depends on git.Repository until the dataRc closed to
|
|
// avoid creating an unnecessary temporary cat file.
|
|
buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, blob)
|
|
if err != nil {
|
|
ctx.ServerError("getFileReader", err)
|
|
return
|
|
}
|
|
defer dataRc.Close()
|
|
|
|
if fInfo.isLFSFile() {
|
|
ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/media/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
|
|
}
|
|
|
|
if !prepareFileViewEditorButtons(ctx) {
|
|
return
|
|
}
|
|
|
|
ctx.Data["IsLFSFile"] = fInfo.isLFSFile()
|
|
ctx.Data["FileSize"] = fInfo.blobOrLfsSize
|
|
ctx.Data["IsRepresentableAsText"] = fInfo.st.IsRepresentableAsText()
|
|
ctx.Data["IsExecutable"] = entry.IsExecutable()
|
|
ctx.Data["CanCopyContent"] = fInfo.st.IsRepresentableAsText() || fInfo.st.IsImage()
|
|
|
|
attrs, ok := prepareFileViewLfsAttrs(ctx)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// TODO: in the future maybe we need more accurate flags, for example:
|
|
// * IsRepresentableAsText: some files are text, some are not
|
|
// * IsRenderableXxx: some files are rendered by backend "markup" engine, some are rendered by frontend (pdf, 3d)
|
|
// * DefaultViewMode: when there is no "display" query parameter, which view mode should be used by default, source or rendered
|
|
|
|
contentReader := io.MultiReader(bytes.NewReader(buf), dataRc)
|
|
if fInfo.st.IsRepresentableAsText() {
|
|
contentReader = charset.ToUTF8WithFallbackReader(contentReader, charset.ConvertOpts{})
|
|
}
|
|
switch {
|
|
case fInfo.blobOrLfsSize >= setting.UI.MaxDisplayFileSize:
|
|
ctx.Data["IsFileTooLarge"] = true
|
|
case handleFileViewRenderHTML(ctx, fInfo):
|
|
// Clear any template data that might have been set by previous handlers/init
|
|
// to ensure the template renders the iframe, not source code.
|
|
delete(ctx.Data, "FileContent")
|
|
delete(ctx.Data, "RenderAsMarkup")
|
|
delete(ctx.Data, "IsPlainText")
|
|
delete(ctx.Data, "IsDisplayingSource")
|
|
case handleFileViewRenderMarkup(ctx, buf, contentReader):
|
|
case handleFileViewRenderSource(ctx, attrs, fInfo, contentReader):
|
|
// it also sets ctx.Data["FileContent"] and more
|
|
ctx.Data["IsDisplayingSource"] = true
|
|
case handleFileViewRenderImage(ctx, fInfo, buf):
|
|
ctx.Data["IsImageFile"] = true
|
|
case fInfo.st.IsVideo():
|
|
ctx.Data["IsVideoFile"] = true
|
|
case fInfo.st.IsAudio():
|
|
ctx.Data["IsAudioFile"] = true
|
|
default:
|
|
// unable to render anything, show the "view raw" or let frontend handle it
|
|
}
|
|
}
|
|
|
|
func prepareFileViewEditorButtons(ctx *context.Context) bool {
|
|
// archived or mirror repository, the buttons should not be shown
|
|
if !ctx.Repo.Repository.CanEnableEditor() {
|
|
return true
|
|
}
|
|
|
|
// The buttons should not be shown if it's not a branch
|
|
if !ctx.Repo.RefFullName.IsBranch() {
|
|
ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch")
|
|
ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch")
|
|
return true
|
|
}
|
|
|
|
if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) {
|
|
ctx.Data["CanEditFile"] = true
|
|
ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit")
|
|
ctx.Data["CanDeleteFile"] = true
|
|
ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access")
|
|
return true
|
|
}
|
|
|
|
lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath)
|
|
ctx.Data["LFSLock"] = lfsLock
|
|
if err != nil {
|
|
ctx.ServerError("GetTreePathLock", err)
|
|
return false
|
|
}
|
|
if lfsLock != nil {
|
|
u, err := user_model.GetUserByID(ctx, lfsLock.OwnerID)
|
|
if err != nil {
|
|
ctx.ServerError("GetTreePathLock", err)
|
|
return false
|
|
}
|
|
ctx.Data["LFSLockOwner"] = u.Name
|
|
ctx.Data["LFSLockOwnerHomeLink"] = u.HomeLink()
|
|
ctx.Data["LFSLockHint"] = ctx.Tr("repo.editor.this_file_locked")
|
|
}
|
|
|
|
// it's a lfs file and the user is not the owner of the lock
|
|
isLFSLocked := lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID
|
|
ctx.Data["CanEditFile"] = !isLFSLocked
|
|
ctx.Data["EditFileTooltip"] = util.Iif(isLFSLocked, ctx.Tr("repo.editor.this_file_locked"), ctx.Tr("repo.editor.edit_this_file"))
|
|
ctx.Data["CanDeleteFile"] = !isLFSLocked
|
|
ctx.Data["DeleteFileTooltip"] = util.Iif(isLFSLocked, ctx.Tr("repo.editor.this_file_locked"), ctx.Tr("repo.editor.delete_this_file"))
|
|
return true
|
|
}
|