Merge branch 'main' into feature/enhanced-workflow-runs-api

This commit is contained in:
Brice Ruth 2025-06-30 13:08:41 -05:00 committed by GitHub
commit 0a17b7aa64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
81 changed files with 955 additions and 753 deletions

View File

@ -178,6 +178,15 @@ func WithTx(parentCtx context.Context, f func(ctx context.Context) error) error
return txWithNoCheck(parentCtx, f)
}
// WithTx2 is similar to WithTx, but it has two return values: result and error.
func WithTx2[T any](parentCtx context.Context, f func(ctx context.Context) (T, error)) (ret T, errRet error) {
errRet = WithTx(parentCtx, func(ctx context.Context) (errInner error) {
ret, errInner = f(ctx)
return errInner
})
return ret, errRet
}
func txWithNoCheck(parentCtx context.Context, f func(ctx context.Context) error) error {
sess := xormEngine.NewSession()
defer sess.Close()

View File

@ -22,17 +22,22 @@ func (b *Blob) Name() string {
return b.name
}
// GetBlobContent Gets the limited content of the blob as raw text
func (b *Blob) GetBlobContent(limit int64) (string, error) {
// GetBlobBytes Gets the limited content of the blob
func (b *Blob) GetBlobBytes(limit int64) ([]byte, error) {
if limit <= 0 {
return "", nil
return nil, nil
}
dataRc, err := b.DataAsync()
if err != nil {
return "", err
return nil, err
}
defer dataRc.Close()
buf, err := util.ReadWithLimit(dataRc, int(limit))
return util.ReadWithLimit(dataRc, int(limit))
}
// GetBlobContent Gets the limited content of the blob as raw text
func (b *Blob) GetBlobContent(limit int64) (string, error) {
buf, err := b.GetBlobBytes(limit)
return string(buf), err
}
@ -99,11 +104,9 @@ loop:
// GuessContentType guesses the content type of the blob.
func (b *Blob) GuessContentType() (typesniffer.SniffedType, error) {
r, err := b.DataAsync()
buf, err := b.GetBlobBytes(typesniffer.SniffContentSize)
if err != nil {
return typesniffer.SniffedType{}, err
}
defer r.Close()
return typesniffer.DetectContentTypeFromReader(r)
return typesniffer.DetectContentType(buf), nil
}

View File

@ -6,13 +6,14 @@ package console
import (
"bytes"
"io"
"path"
"unicode/utf8"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/util"
trend "github.com/buildkite/terminal-to-html/v3"
"github.com/go-enry/go-enry/v2"
)
func init() {
@ -22,6 +23,8 @@ func init() {
// Renderer implements markup.Renderer
type Renderer struct{}
var _ markup.RendererContentDetector = (*Renderer)(nil)
// Name implements markup.Renderer
func (Renderer) Name() string {
return "console"
@ -40,15 +43,36 @@ func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
}
// CanRender implements markup.RendererContentDetector
func (Renderer) CanRender(filename string, input io.Reader) bool {
buf, err := io.ReadAll(input)
if err != nil {
func (Renderer) CanRender(filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte) bool {
if !sniffedType.IsTextPlain() {
return false
}
if enry.GetLanguage(path.Base(filename), buf) != enry.OtherLanguage {
s := util.UnsafeBytesToString(prefetchBuf)
rs := []rune(s)
cnt := 0
firstErrPos := -1
isCtrlSep := func(p int) bool {
return p < len(rs) && (rs[p] == ';' || rs[p] == 'm')
}
for i, c := range rs {
if c == 0 {
return false
}
if c == '\x1b' {
match := i+1 < len(rs) && rs[i+1] == '['
if match && (isCtrlSep(i+2) || isCtrlSep(i+3) || isCtrlSep(i+4) || isCtrlSep(i+5)) {
cnt++
}
}
if c == utf8.RuneError && firstErrPos == -1 {
firstErrPos = i
}
}
if firstErrPos != -1 && firstErrPos != len(rs)-1 {
return false
}
return bytes.ContainsRune(buf, '\x1b')
return cnt >= 2 // only render it as console output if there are at least two escape sequences
}
// Render renders terminal colors to HTML with all specific handling stuff.

View File

@ -8,23 +8,39 @@ import (
"testing"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/typesniffer"
"github.com/stretchr/testify/assert"
)
func TestRenderConsole(t *testing.T) {
var render Renderer
kases := map[string]string{
"\x1b[37m\x1b[40mnpm\x1b[0m \x1b[0m\x1b[32minfo\x1b[0m \x1b[0m\x1b[35mit worked if it ends with\x1b[0m ok": "<span class=\"term-fg37 term-bg40\">npm</span> <span class=\"term-fg32\">info</span> <span class=\"term-fg35\">it worked if it ends with</span> ok",
cases := []struct {
input string
expected string
}{
{"\x1b[37m\x1b[40mnpm\x1b[0m \x1b[0m\x1b[32minfo\x1b[0m \x1b[0m\x1b[35mit worked if it ends with\x1b[0m ok", `<span class="term-fg37 term-bg40">npm</span> <span class="term-fg32">info</span> <span class="term-fg35">it worked if it ends with</span> ok`},
{"\x1b[1;2m \x1b[123m 啊", `<span class="term-fg2"> 啊</span>`},
{"\x1b[1;2m \x1b[123m \xef", `<span class="term-fg2"> <20></span>`},
{"\x1b[1;2m \x1b[123m \xef \xef", ``},
{"\x1b[12", ``},
{"\x1b[1", ``},
{"\x1b[FOO\x1b[", ``},
{"\x1b[mFOO\x1b[m", `FOO`},
}
for k, v := range kases {
var render Renderer
for i, c := range cases {
var buf strings.Builder
canRender := render.CanRender("test", strings.NewReader(k))
assert.True(t, canRender)
st := typesniffer.DetectContentType([]byte(c.input))
canRender := render.CanRender("test", st, []byte(c.input))
if c.expected == "" {
assert.False(t, canRender, "case %d: expected not to render", i)
continue
}
err := render.Render(markup.NewRenderContext(t.Context()), strings.NewReader(k), &buf)
assert.True(t, canRender)
err := render.Render(markup.NewRenderContext(t.Context()), strings.NewReader(c.input), &buf)
assert.NoError(t, err)
assert.Equal(t, v, buf.String())
assert.Equal(t, c.expected, buf.String())
}
}

View File

@ -4,12 +4,12 @@
package markup
import (
"bytes"
"io"
"path"
"strings"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/typesniffer"
)
// Renderer defines an interface for rendering markup file to HTML
@ -37,7 +37,7 @@ type ExternalRenderer interface {
// RendererContentDetector detects if the content can be rendered
// by specified renderer
type RendererContentDetector interface {
CanRender(filename string, input io.Reader) bool
CanRender(filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte) bool
}
var (
@ -60,13 +60,9 @@ func GetRendererByFileName(filename string) Renderer {
}
// DetectRendererType detects the markup type of the content
func DetectRendererType(filename string, input io.Reader) string {
buf, err := io.ReadAll(input)
if err != nil {
return ""
}
func DetectRendererType(filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte) string {
for _, renderer := range renderers {
if detector, ok := renderer.(RendererContentDetector); ok && detector.CanRender(filename, bytes.NewReader(buf)) {
if detector, ok := renderer.(RendererContentDetector); ok && detector.CanRender(filename, sniffedType, prefetchBuf) {
return renderer.Name()
}
}

View File

@ -8,8 +8,11 @@ import "time"
// CreateUserOption create user options
type CreateUserOption struct {
SourceID int64 `json:"source_id"`
SourceID int64 `json:"source_id"`
// identifier of the user, provided by the external authenticator (if configured)
// default: empty
LoginName string `json:"login_name"`
// username of the user
// required: true
Username string `json:"username" binding:"Required;Username;MaxSize(40)"`
FullName string `json:"full_name" binding:"MaxSize(100)"`
@ -32,6 +35,8 @@ type CreateUserOption struct {
type EditUserOption struct {
// required: true
SourceID int64 `json:"source_id"`
// identifier of the user, provided by the external authenticator (if configured)
// default: empty
// required: true
LoginName string `json:"login_name" binding:"Required"`
// swagger:strfmt email

View File

@ -71,7 +71,8 @@ type PayloadUser struct {
// Full name of the commit author
Name string `json:"name"`
// swagger:strfmt email
Email string `json:"email"`
Email string `json:"email"`
// username of the user
UserName string `json:"username"`
}

View File

@ -14,7 +14,7 @@ type AddTimeOption struct {
Time int64 `json:"time" binding:"Required"`
// swagger:strfmt date-time
Created time.Time `json:"created"`
// User who spent the time (optional)
// username of the user who spent the time working on the issue (optional)
User string `json:"user_name"`
}
@ -26,7 +26,8 @@ type TrackedTime struct {
// Time in seconds
Time int64 `json:"time"`
// deprecated (only for backwards compatibility)
UserID int64 `json:"user_id"`
UserID int64 `json:"user_id"`
// username of the user
UserName string `json:"user_name"`
// deprecated (only for backwards compatibility)
IssueID int64 `json:"issue_id"`

View File

@ -15,6 +15,7 @@ type Organization struct {
Location string `json:"location"`
Visibility string `json:"visibility"`
RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access"`
// username of the organization
// deprecated
UserName string `json:"username"`
}
@ -30,6 +31,7 @@ type OrganizationPermissions struct {
// CreateOrgOption options for creating an organization
type CreateOrgOption struct {
// username of the organization
// required: true
UserName string `json:"username" binding:"Required;Username;MaxSize(40)"`
FullName string `json:"full_name" binding:"MaxSize(100)"`

View File

@ -15,9 +15,9 @@ import (
type User struct {
// the user's id
ID int64 `json:"id"`
// the user's username
// login of the user, same as `username`
UserName string `json:"login"`
// the user's authentication sign-in name.
// identifier of the user, provided by the external authenticator (if configured)
// default: empty
LoginName string `json:"login_name"`
// The ID of the user's Authentication Source

View File

@ -11,6 +11,7 @@ type Email struct {
Verified bool `json:"verified"`
Primary bool `json:"primary"`
UserID int64 `json:"user_id"`
// username of the user
UserName string `json:"username"`
}

View File

@ -6,18 +6,14 @@ package typesniffer
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"net/http"
"regexp"
"slices"
"strings"
"code.gitea.io/gitea/modules/util"
"sync"
)
// Use at most this many bytes to determine Content Type.
const sniffLen = 1024
const SniffContentSize = 1024
const (
MimeTypeImageSvg = "image/svg+xml"
@ -26,22 +22,30 @@ const (
MimeTypeApplicationOctetStream = "application/octet-stream"
)
var (
svgComment = regexp.MustCompile(`(?s)<!--.*?-->`)
svgTagRegex = regexp.MustCompile(`(?si)\A\s*(?:(<!DOCTYPE\s+svg([\s:]+.*?>|>))\s*)*<svg\b`)
svgTagInXMLRegex = regexp.MustCompile(`(?si)\A<\?xml\b.*?\?>\s*(?:(<!DOCTYPE\s+svg([\s:]+.*?>|>))\s*)*<svg\b`)
)
var globalVars = sync.OnceValue(func() (ret struct {
svgComment, svgTagRegex, svgTagInXMLRegex *regexp.Regexp
},
) {
ret.svgComment = regexp.MustCompile(`(?s)<!--.*?-->`)
ret.svgTagRegex = regexp.MustCompile(`(?si)\A\s*(?:(<!DOCTYPE\s+svg([\s:]+.*?>|>))\s*)*<svg\b`)
ret.svgTagInXMLRegex = regexp.MustCompile(`(?si)\A<\?xml\b.*?\?>\s*(?:(<!DOCTYPE\s+svg([\s:]+.*?>|>))\s*)*<svg\b`)
return ret
})
// SniffedType contains information about a blobs type.
// SniffedType contains information about a blob's type.
type SniffedType struct {
contentType string
}
// IsText etects if content format is plain text.
// IsText detects if the content format is text family, including text/plain, text/html, text/css, etc.
func (ct SniffedType) IsText() bool {
return strings.Contains(ct.contentType, "text/")
}
func (ct SniffedType) IsTextPlain() bool {
return strings.Contains(ct.contentType, "text/plain")
}
// IsImage detects if data is an image format
func (ct SniffedType) IsImage() bool {
return strings.Contains(ct.contentType, "image/")
@ -57,12 +61,12 @@ func (ct SniffedType) IsPDF() bool {
return strings.Contains(ct.contentType, "application/pdf")
}
// IsVideo detects if data is an video format
// IsVideo detects if data is a video format
func (ct SniffedType) IsVideo() bool {
return strings.Contains(ct.contentType, "video/")
}
// IsAudio detects if data is an video format
// IsAudio detects if data is a video format
func (ct SniffedType) IsAudio() bool {
return strings.Contains(ct.contentType, "audio/")
}
@ -103,33 +107,34 @@ func detectFileTypeBox(data []byte) (brands []string, found bool) {
return brands, true
}
// DetectContentType extends http.DetectContentType with more content types. Defaults to text/unknown if input is empty.
// DetectContentType extends http.DetectContentType with more content types. Defaults to text/plain if input is empty.
func DetectContentType(data []byte) SniffedType {
if len(data) == 0 {
return SniffedType{"text/unknown"}
return SniffedType{"text/plain"}
}
ct := http.DetectContentType(data)
if len(data) > sniffLen {
data = data[:sniffLen]
if len(data) > SniffContentSize {
data = data[:SniffContentSize]
}
vars := globalVars()
// SVG is unsupported by http.DetectContentType, https://github.com/golang/go/issues/15888
detectByHTML := strings.Contains(ct, "text/plain") || strings.Contains(ct, "text/html")
detectByXML := strings.Contains(ct, "text/xml")
if detectByHTML || detectByXML {
dataProcessed := svgComment.ReplaceAll(data, nil)
dataProcessed := vars.svgComment.ReplaceAll(data, nil)
dataProcessed = bytes.TrimSpace(dataProcessed)
if detectByHTML && svgTagRegex.Match(dataProcessed) ||
detectByXML && svgTagInXMLRegex.Match(dataProcessed) {
if detectByHTML && vars.svgTagRegex.Match(dataProcessed) ||
detectByXML && vars.svgTagInXMLRegex.Match(dataProcessed) {
ct = MimeTypeImageSvg
}
}
if strings.HasPrefix(ct, "audio/") && bytes.HasPrefix(data, []byte("ID3")) {
// The MP3 detection is quite inaccurate, any content with "ID3" prefix will result in "audio/mpeg".
// So remove the "ID3" prefix and detect again, if result is text, then it must be text content.
// So remove the "ID3" prefix and detect again, then if the result is "text", it must be text content.
// This works especially because audio files contain many unprintable/invalid characters like `0x00`
ct2 := http.DetectContentType(data[3:])
if strings.HasPrefix(ct2, "text/") {
@ -155,15 +160,3 @@ func DetectContentType(data []byte) SniffedType {
}
return SniffedType{ct}
}
// DetectContentTypeFromReader guesses the content type contained in the reader.
func DetectContentTypeFromReader(r io.Reader) (SniffedType, error) {
buf := make([]byte, sniffLen)
n, err := util.ReadAtMost(r, buf)
if err != nil {
return SniffedType{}, fmt.Errorf("DetectContentTypeFromReader io error: %w", err)
}
buf = buf[:n]
return DetectContentType(buf), nil
}

View File

@ -4,7 +4,6 @@
package typesniffer
import (
"bytes"
"encoding/base64"
"encoding/hex"
"strings"
@ -17,7 +16,7 @@ func TestDetectContentTypeLongerThanSniffLen(t *testing.T) {
// Pre-condition: Shorter than sniffLen detects SVG.
assert.Equal(t, "image/svg+xml", DetectContentType([]byte(`<!-- Comment --><svg></svg>`)).contentType)
// Longer than sniffLen detects something else.
assert.NotEqual(t, "image/svg+xml", DetectContentType([]byte(`<!-- `+strings.Repeat("x", sniffLen)+` --><svg></svg>`)).contentType)
assert.NotEqual(t, "image/svg+xml", DetectContentType([]byte(`<!-- `+strings.Repeat("x", SniffContentSize)+` --><svg></svg>`)).contentType)
}
func TestIsTextFile(t *testing.T) {
@ -116,22 +115,13 @@ func TestIsAudio(t *testing.T) {
assert.True(t, DetectContentType([]byte("ID3Toy\n====\t* hi 🌞, ..."+"🌛"[0:2])).IsText()) // test ID3 tag with incomplete UTF8 char
}
func TestDetectContentTypeFromReader(t *testing.T) {
mp3, _ := base64.StdEncoding.DecodeString("SUQzBAAAAAABAFRYWFgAAAASAAADbWFqb3JfYnJhbmQAbXA0MgBUWFhYAAAAEQAAA21pbm9yX3Zl")
st, err := DetectContentTypeFromReader(bytes.NewReader(mp3))
assert.NoError(t, err)
assert.True(t, st.IsAudio())
}
func TestDetectContentTypeOgg(t *testing.T) {
oggAudio, _ := hex.DecodeString("4f67675300020000000000000000352f0000000000007dc39163011e01766f72626973000000000244ac0000000000000071020000000000b8014f6767530000")
st, err := DetectContentTypeFromReader(bytes.NewReader(oggAudio))
assert.NoError(t, err)
st := DetectContentType(oggAudio)
assert.True(t, st.IsAudio())
oggVideo, _ := hex.DecodeString("4f676753000200000000000000007d9747ef000000009b59daf3012a807468656f7261030201001e00110001e000010e00020000001e00000001000001000001")
st, err = DetectContentTypeFromReader(bytes.NewReader(oggVideo))
assert.NoError(t, err)
st = DetectContentType(oggVideo)
assert.True(t, st.IsVideo())
}

View File

@ -1917,7 +1917,6 @@ pulls.cmd_instruction_checkout_title=Checkout
pulls.cmd_instruction_checkout_desc=Z vašeho repositáře projektu se podívejte na novou větev a vyzkoušejte změny.
pulls.cmd_instruction_merge_title=Sloučit
pulls.cmd_instruction_merge_desc=Slučte změny a aktualizujte je na Gitea.
pulls.cmd_instruction_merge_warning=Varování: Tato operace nemůže sloučit požadavek na natažení, protože „autodetekce manuálních sloučení“ nebyla povolena
pulls.clear_merge_message=Vymazat zprávu o sloučení
pulls.clear_merge_message_hint=Vymazání zprávy o sloučení odstraní pouze obsah zprávy a ponechá generované přídavky gitu jako "Co-AuthoreBy …".

View File

@ -1953,7 +1953,6 @@ pulls.cmd_instruction_checkout_title=Checkout
pulls.cmd_instruction_checkout_desc=Wechsle auf einen neuen Branch in deinem lokalen Repository und teste die Änderungen.
pulls.cmd_instruction_merge_title=Mergen
pulls.cmd_instruction_merge_desc=Die Änderungen mergen und auf Gitea aktualisieren.
pulls.cmd_instruction_merge_warning=Warnung: Dieser Vorgang kann den Pull-Request nicht mergen, da "manueller Merge" nicht aktiviert wurde
pulls.clear_merge_message=Merge-Nachricht löschen
pulls.clear_merge_message_hint=Das Löschen der Merge-Nachricht wird nur den Inhalt der Commit-Nachricht entfernen und generierte Git-Trailer wie "Co-Authored-By …" erhalten.

View File

@ -1969,7 +1969,7 @@ pulls.cmd_instruction_checkout_title = Checkout
pulls.cmd_instruction_checkout_desc = From your project repository, check out a new branch and test the changes.
pulls.cmd_instruction_merge_title = Merge
pulls.cmd_instruction_merge_desc = Merge the changes and update on Gitea.
pulls.cmd_instruction_merge_warning = Warning: This operation can not merge pull request because "autodetect manual merge" was not enable
pulls.cmd_instruction_merge_warning = Warning: This operation cannot merge pull request because "autodetect manual merge" is not enabled.
pulls.clear_merge_message = Clear merge message
pulls.clear_merge_message_hint = Clearing the merge message will only remove the commit message content and keep generated git trailers such as "Co-Authored-By …".

View File

@ -1969,7 +1969,6 @@ pulls.cmd_instruction_checkout_title=Basculer
pulls.cmd_instruction_checkout_desc=Depuis votre dépôt, basculer sur une nouvelle branche et tester des modifications.
pulls.cmd_instruction_merge_title=Fusionner
pulls.cmd_instruction_merge_desc=Fusionner les modifications et mettre à jour sur Gitea.
pulls.cmd_instruction_merge_warning=Attention : cette opération ne peut pas fusionner la demande dajout car la « détection automatique de fusion manuelle » na pas été activée
pulls.clear_merge_message=Effacer le message de fusion
pulls.clear_merge_message_hint=Effacer le message de fusion ne supprimera que le message de la révision, mais pas les pieds de révision générés tels que "Co-Authored-By:".

View File

@ -1969,7 +1969,6 @@ pulls.cmd_instruction_checkout_title=Seiceáil
pulls.cmd_instruction_checkout_desc=Ó stór tionscadail, seiceáil brainse nua agus déan tástáil ar na hathruithe.
pulls.cmd_instruction_merge_title=Cumaisc
pulls.cmd_instruction_merge_desc=Cumaisc na hathruithe agus nuashonrú ar Gitea.
pulls.cmd_instruction_merge_warning=Rabhadh: Ní féidir leis an oibríocht seo an t-iarratas tarraingthe a chumasc toisc nach raibh "cumasc láimhe uathoibríoch" cumasaithe
pulls.clear_merge_message=Glan an teachtaireacht chumaisc
pulls.clear_merge_message_hint=Má imrítear an teachtaireacht chumaisc ní bhainfear ach ábhar na teachtaireachta tiomanta agus coimeádfar leantóirí git ginte ar nós "Co-Authored-By …".

View File

@ -1960,7 +1960,6 @@ pulls.cmd_instruction_checkout_title=チェックアウト
pulls.cmd_instruction_checkout_desc=プロジェクトリポジトリから新しいブランチをチェックアウトし、変更内容をテストします。
pulls.cmd_instruction_merge_title=マージ
pulls.cmd_instruction_merge_desc=変更内容をマージして、Giteaに反映します。
pulls.cmd_instruction_merge_warning=警告: 「手動マージの自動検出」が有効ではないため、この操作ではプルリクエストをマージできません
pulls.clear_merge_message=マージメッセージをクリア
pulls.clear_merge_message_hint=マージメッセージのクリアは、コミットメッセージの除去だけを行います。 生成されたGitトレーラー("Co-Authored-By …" 等)はそのまま残ります。

View File

@ -1969,7 +1969,6 @@ pulls.cmd_instruction_checkout_title=Checkout
pulls.cmd_instruction_checkout_desc=A partir do seu repositório, crie um novo ramo e teste nele as modificações.
pulls.cmd_instruction_merge_title=Integrar
pulls.cmd_instruction_merge_desc=Integrar as modificações e enviar para o Gitea.
pulls.cmd_instruction_merge_warning=Aviso: Esta operação não pode executar pedidos de integração porque "auto-identificar integração manual" não estava habilitado
pulls.clear_merge_message=Apagar mensagem de integração
pulls.clear_merge_message_hint=Apagar a mensagem de integração apenas remove o conteúdo da mensagem de cometimento e mantém os rodapés do git, tais como "Co-Autorado-Por …".
@ -2384,6 +2383,7 @@ settings.event_repository=Repositório
settings.event_repository_desc=Repositório criado ou eliminado.
settings.event_header_issue=Eventos da questão
settings.event_issues=Questões
settings.event_issues_desc=Questão aberta, fechada, reaberta, editada ou eliminada.
settings.event_issue_assign=Questão atribuída
settings.event_issue_assign_desc=Encarregado atribuído ou retirado à questão.
settings.event_issue_label=Questão com rótulo
@ -2394,6 +2394,7 @@ settings.event_issue_comment=Comentário da questão
settings.event_issue_comment_desc=Comentário da questão criado, editado ou eliminado.
settings.event_header_pull_request=Eventos de pedidos de integração
settings.event_pull_request=Pedido de integração
settings.event_pull_request_desc=Pedido de integração aberto, fechado, reaberto, editado ou eliminado.
settings.event_pull_request_assign=Encarregado atribuído ao pedido de integração
settings.event_pull_request_assign_desc=Encarregado atribuído ou retirado ao pedido de integração.
settings.event_pull_request_label=Rótulo atribuído ao pedido de integração

View File

@ -2256,6 +2256,7 @@ settings.event_repository=Репозиторій
settings.event_repository_desc=Репозиторій створений або видалено.
settings.event_header_issue=Події задачі
settings.event_issues=Задачі
settings.event_issues_desc=Задачу відкрито, закрито, повторно відкрито, відредаговано або видалено.
settings.event_issue_assign=Задачу призначено
settings.event_issue_assign_desc=Задачу призначено або скасовано.
settings.event_issue_label_desc=Мітки задачі оновлено або видалено.
@ -2263,6 +2264,7 @@ settings.event_issue_comment=Коментар задачі
settings.event_issue_comment_desc=Коментар задачі створено, видалено чи відредаговано.
settings.event_header_pull_request=Події запиту злиття
settings.event_pull_request=Запити на злиття
settings.event_pull_request_desc=Запит на злиття відкрито, закрито, повторно відкрито, відредаговано або видалено.
settings.event_pull_request_assign=Запит на злиття призначено
settings.event_pull_request_assign_desc=Запит на злиття призначено або скасовано.
settings.event_pull_request_label=Запиту на злиття призначена мітка

View File

@ -1955,7 +1955,6 @@ pulls.cmd_instruction_checkout_title=检出
pulls.cmd_instruction_checkout_desc=从您的仓库中检出一个新的分支并测试变更。
pulls.cmd_instruction_merge_title=合并
pulls.cmd_instruction_merge_desc=合并变更并更新到 Gitea 上
pulls.cmd_instruction_merge_warning=警告:此操作不能合并该合并请求,因为「自动检测手动合并」未启用
pulls.clear_merge_message=清除合并信息
pulls.clear_merge_message_hint=清除合并消息只会删除提交消息内容,并保留生成的 Git 附加内容如「Co-Authored-By…」。

View File

@ -1901,7 +1901,6 @@ pulls.cmd_instruction_checkout_title=檢出
pulls.cmd_instruction_checkout_desc=從您的專案儲存庫中,檢出一個新分支並測試變更。
pulls.cmd_instruction_merge_title=合併
pulls.cmd_instruction_merge_desc=合併變更並在 Gitea 上更新。
pulls.cmd_instruction_merge_warning=警告:此操作無法合併合併請求,因為未啟用「自動檢測手動合併」
pulls.clear_merge_message=清除合併訊息
pulls.clear_merge_message_hint=清除合併訊息將僅移除提交訊息內容,留下產生的 git 結尾如「Co-Authored-By …」。

51
package-lock.json generated
View File

@ -40,6 +40,7 @@
"minimatch": "10.0.2",
"monaco-editor": "0.52.2",
"monaco-editor-webpack-plugin": "7.1.0",
"online-3d-viewer": "0.16.0",
"pdfobject": "2.3.1",
"perfect-debounce": "1.0.0",
"postcss": "8.5.5",
@ -2026,6 +2027,16 @@
"vue": "^3.2.29"
}
},
"node_modules/@simonwep/pickr": {
"version": "1.9.0",
"resolved": "https://registry.npmmirror.com/@simonwep/pickr/-/pickr-1.9.0.tgz",
"integrity": "sha512-oEYvv15PyfZzjoAzvXYt3UyNGwzsrpFxLaZKzkOSd0WYBVwLd19iJerePDONxC1iF6+DpcswPdLIM2KzCJuYFg==",
"license": "MIT",
"dependencies": {
"core-js": "3.32.2",
"nanopop": "2.3.0"
}
},
"node_modules/@stoplight/better-ajv-errors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@stoplight/better-ajv-errors/-/better-ajv-errors-1.0.3.tgz",
@ -5337,6 +5348,17 @@
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.32.2",
"resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.32.2.tgz",
"integrity": "sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-js-compat": {
"version": "3.43.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.43.0.tgz",
@ -7721,6 +7743,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@ -10285,6 +10313,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/nanopop": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/nanopop/-/nanopop-2.3.0.tgz",
"integrity": "sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw==",
"license": "MIT"
},
"node_modules/napi-postinstall": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.4.tgz",
@ -10525,6 +10559,17 @@
"wrappy": "1"
}
},
"node_modules/online-3d-viewer": {
"version": "0.16.0",
"resolved": "https://registry.npmmirror.com/online-3d-viewer/-/online-3d-viewer-0.16.0.tgz",
"integrity": "sha512-Mcmo41TM3K+svlMDRH8ySKSY2e8s7Sssdb5U9LV3gkFKVWGGuS304Vk5gqxopAJbE72DpsC67Ve3YNtcAuROwQ==",
"license": "MIT",
"dependencies": {
"@simonwep/pickr": "1.9.0",
"fflate": "0.8.2",
"three": "0.176.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -13193,6 +13238,12 @@
"node": ">=0.8"
}
},
"node_modules/three": {
"version": "0.176.0",
"resolved": "https://registry.npmmirror.com/three/-/three-0.176.0.tgz",
"integrity": "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==",
"license": "MIT"
},
"node_modules/throttle-debounce": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",

View File

@ -39,6 +39,7 @@
"minimatch": "10.0.2",
"monaco-editor": "0.52.2",
"monaco-editor-webpack-plugin": "7.1.0",
"online-3d-viewer": "0.16.0",
"pdfobject": "2.3.1",
"perfect-debounce": "1.0.0",
"postcss": "8.5.5",

View File

@ -467,7 +467,9 @@ func CommonRoutes() *web.Router {
g.MatchPath("HEAD", "/<group:*>/repodata/<filename>", rpm.CheckRepositoryFileExistence)
g.MatchPath("GET", "/<group:*>/repodata/<filename>", rpm.GetRepositoryFile)
g.MatchPath("PUT", "/<group:*>/upload", reqPackageAccess(perm.AccessModeWrite), rpm.UploadPackageFile)
// this URL pattern is only used internally in the RPM index, it is generated by us, the filename part is not really used (can be anything)
g.MatchPath("HEAD,GET", "/<group:*>/package/<name>/<version>/<architecture>", rpm.DownloadPackageFile)
g.MatchPath("HEAD,GET", "/<group:*>/package/<name>/<version>/<architecture>/<filename>", rpm.DownloadPackageFile)
g.MatchPath("DELETE", "/<group:*>/package/<name>/<version>/<architecture>", reqPackageAccess(perm.AccessModeWrite), rpm.DeletePackageFile)
}, reqPackageAccess(perm.AccessModeRead))

View File

@ -21,7 +21,7 @@ func (a *Auth) Name() string {
}
// Verify extracts the user from the Bearer token
// If it's an anonymous session a ghost user is returned
// If it's an anonymous session, a ghost user is returned
func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) {
packageMeta, err := packages.ParseAuthorizationRequest(req)
if err != nil {

View File

@ -95,15 +95,13 @@ func containerGlobalLockKey(piOwnerID int64, piName, usage string) string {
}
func getOrCreateUploadVersion(ctx context.Context, pi *packages_service.PackageInfo) (*packages_model.PackageVersion, error) {
var uploadVersion *packages_model.PackageVersion
releaser, err := globallock.Lock(ctx, containerGlobalLockKey(pi.Owner.ID, pi.Name, "package"))
if err != nil {
return nil, err
}
defer releaser()
err = db.WithTx(ctx, func(ctx context.Context) error {
return db.WithTx2(ctx, func(ctx context.Context) (*packages_model.PackageVersion, error) {
created := true
p := &packages_model.Package{
OwnerID: pi.Owner.ID,
@ -115,7 +113,7 @@ func getOrCreateUploadVersion(ctx context.Context, pi *packages_service.PackageI
if p, err = packages_model.TryInsertPackage(ctx, p); err != nil {
if !errors.Is(err, packages_model.ErrDuplicatePackage) {
log.Error("Error inserting package: %v", err)
return err
return nil, err
}
created = false
}
@ -123,7 +121,7 @@ func getOrCreateUploadVersion(ctx context.Context, pi *packages_service.PackageI
if created {
if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypePackage, p.ID, container_module.PropertyRepository, strings.ToLower(pi.Owner.LowerName+"/"+pi.Name)); err != nil {
log.Error("Error setting package property: %v", err)
return err
return nil, err
}
}
@ -138,16 +136,11 @@ func getOrCreateUploadVersion(ctx context.Context, pi *packages_service.PackageI
if pv, err = packages_model.GetOrInsertVersion(ctx, pv); err != nil {
if !errors.Is(err, packages_model.ErrDuplicatePackageVersion) {
log.Error("Error inserting package: %v", err)
return err
return nil, err
}
}
uploadVersion = pv
return nil
return pv, nil
})
return uploadVersion, err
}
func createFileForBlob(ctx context.Context, pv *packages_model.PackageVersion, pb *packages_model.PackageBlob) error {

View File

@ -13,6 +13,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
auth_model "code.gitea.io/gitea/models/auth"
packages_model "code.gitea.io/gitea/models/packages"
@ -39,10 +40,14 @@ import (
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests
const maxManifestSize = 10 * 1024 * 1024
var (
imageNamePattern = regexp.MustCompile(`\A[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*\z`)
referencePattern = regexp.MustCompile(`\A[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}\z`)
)
var globalVars = sync.OnceValue(func() (ret struct {
imageNamePattern, referencePattern *regexp.Regexp
},
) {
ret.imageNamePattern = regexp.MustCompile(`\A[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*\z`)
ret.referencePattern = regexp.MustCompile(`\A[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}\z`)
return ret
})
type containerHeaders struct {
Status int
@ -84,9 +89,7 @@ func jsonResponse(ctx *context.Context, status int, obj any) {
Status: status,
ContentType: "application/json",
})
if err := json.NewEncoder(ctx.Resp).Encode(obj); err != nil {
log.Error("JSON encode: %v", err)
}
_ = json.NewEncoder(ctx.Resp).Encode(obj) // ignore network errors
}
func apiError(ctx *context.Context, status int, err error) {
@ -134,7 +137,7 @@ func ReqContainerAccess(ctx *context.Context) {
// VerifyImageName is a middleware which checks if the image name is allowed
func VerifyImageName(ctx *context.Context) {
if !imageNamePattern.MatchString(ctx.PathParam("image")) {
if !globalVars().imageNamePattern.MatchString(ctx.PathParam("image")) {
apiErrorDefined(ctx, errNameInvalid)
}
}
@ -216,7 +219,7 @@ func GetRepositoryList(ctx *context.Context) {
if len(repositories) == n {
v := url.Values{}
if n > 0 {
v.Add("n", strconv.Itoa(n))
v.Add("n", strconv.Itoa(n)) // FIXME: "n" can't be zero here, the logic is inconsistent with GetTagsList
}
v.Add("last", repositories[len(repositories)-1])
@ -565,7 +568,7 @@ func PutManifest(ctx *context.Context) {
IsTagged: digest.Digest(reference).Validate() != nil,
}
if mci.IsTagged && !referencePattern.MatchString(reference) {
if mci.IsTagged && !globalVars().referencePattern.MatchString(reference) {
apiErrorDefined(ctx, errManifestInvalid.WithMessage("Tag is invalid"))
return
}
@ -618,7 +621,7 @@ func getBlobSearchOptionsFromContext(ctx *context.Context) (*container_model.Blo
reference := ctx.PathParam("reference")
if d := digest.Digest(reference); d.Validate() == nil {
opts.Digest = string(d)
} else if referencePattern.MatchString(reference) {
} else if globalVars().referencePattern.MatchString(reference) {
opts.Tag = reference
opts.OnlyLead = true
} else {
@ -782,7 +785,8 @@ func GetTagsList(ctx *context.Context) {
})
}
// FIXME: Workaround to be removed in v1.20
// FIXME: Workaround to be removed in v1.20.
// Update maybe we should never really remote it, as long as there is legacy data?
// https://github.com/go-gitea/gitea/issues/19586
func workaroundGetContainerBlob(ctx *context.Context, opts *container_model.BlobSearchOptions) (*packages_model.PackageFileDescriptor, error) {
blob, err := container_model.GetContainerBlob(ctx, opts)

View File

@ -46,11 +46,9 @@ func processManifest(ctx context.Context, mci *manifestCreationInfo, buf *packag
if err := json.NewDecoder(buf).Decode(&index); err != nil {
return "", err
}
if index.SchemaVersion != 2 {
return "", errUnsupported.WithMessage("Schema version is not supported")
}
if _, err := buf.Seek(0, io.SeekStart); err != nil {
return "", err
}
@ -77,24 +75,41 @@ func processManifest(ctx context.Context, mci *manifestCreationInfo, buf *packag
return "", errManifestInvalid
}
func processOciImageManifest(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (string, error) {
manifestDigest := ""
type processManifestTxRet struct {
pv *packages_model.PackageVersion
pb *packages_model.PackageBlob
created bool
digest string
}
err := func() error {
manifest, configDescriptor, metadata, err := container_service.ParseManifestMetadata(ctx, buf, mci.Owner.ID, mci.Image)
if err != nil {
return err
}
if _, err = buf.Seek(0, io.SeekStart); err != nil {
return err
func handleCreateManifestResult(ctx context.Context, err error, mci *manifestCreationInfo, contentStore *packages_module.ContentStore, txRet *processManifestTxRet) (string, error) {
if err != nil && txRet.created && txRet.pb != nil {
if err := contentStore.Delete(packages_module.BlobHash256Key(txRet.pb.HashSHA256)); err != nil {
log.Error("Error deleting package blob from content store: %v", err)
}
return "", err
}
pd, err := packages_model.GetPackageDescriptor(ctx, txRet.pv)
if err != nil {
log.Error("Error getting package descriptor: %v", err) // ignore this error
} else {
notify_service.PackageCreate(ctx, mci.Creator, pd)
}
return txRet.digest, nil
}
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
func processOciImageManifest(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (manifestDigest string, errRet error) {
manifest, configDescriptor, metadata, err := container_service.ParseManifestMetadata(ctx, buf, mci.Owner.ID, mci.Image)
if err != nil {
return "", err
}
if _, err = buf.Seek(0, io.SeekStart); err != nil {
return "", err
}
contentStore := packages_module.NewContentStore()
var txRet processManifestTxRet
err = db.WithTx(ctx, func(ctx context.Context) (err error) {
blobReferences := make([]*blobReference, 0, 1+len(manifest.Layers))
blobReferences = append(blobReferences, &blobReference{
Digest: manifest.Config.Digest,
@ -127,7 +142,7 @@ func processOciImageManifest(ctx context.Context, mci *manifestCreationInfo, buf
}
uploadVersion, err := packages_model.GetInternalVersionByNameAndVersion(ctx, mci.Owner.ID, packages_model.TypeContainer, mci.Image, container_module.UploadVersion)
if err != nil && err != packages_model.ErrPackageNotExist {
if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) {
return err
}
@ -136,61 +151,26 @@ func processOciImageManifest(ctx context.Context, mci *manifestCreationInfo, buf
return err
}
}
txRet.pv = pv
txRet.pb, txRet.created, txRet.digest, err = createManifestBlob(ctx, contentStore, mci, pv, buf)
return err
})
pb, created, digest, err := createManifestBlob(ctx, mci, pv, buf)
removeBlob := false
defer func() {
if removeBlob {
contentStore := packages_module.NewContentStore()
if err := contentStore.Delete(packages_module.BlobHash256Key(pb.HashSHA256)); err != nil {
log.Error("Error deleting package blob from content store: %v", err)
}
}
}()
if err != nil {
removeBlob = created
return err
}
return handleCreateManifestResult(ctx, err, mci, contentStore, &txRet)
}
if err := committer.Commit(); err != nil {
removeBlob = created
return err
}
if err := notifyPackageCreate(ctx, mci.Creator, pv); err != nil {
return err
}
manifestDigest = digest
return nil
}()
if err != nil {
func processOciImageIndex(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (manifestDigest string, errRet error) {
var index oci.Index
if err := json.NewDecoder(buf).Decode(&index); err != nil {
return "", err
}
if _, err := buf.Seek(0, io.SeekStart); err != nil {
return "", err
}
return manifestDigest, nil
}
func processOciImageIndex(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (string, error) {
manifestDigest := ""
err := func() error {
var index oci.Index
if err := json.NewDecoder(buf).Decode(&index); err != nil {
return err
}
if _, err := buf.Seek(0, io.SeekStart); err != nil {
return err
}
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
contentStore := packages_module.NewContentStore()
var txRet processManifestTxRet
err := db.WithTx(ctx, func(ctx context.Context) (err error) {
metadata := &container_module.Metadata{
Type: container_module.TypeOCI,
Manifests: make([]*container_module.Manifest, 0, len(index.Manifests)),
@ -241,50 +221,12 @@ func processOciImageIndex(ctx context.Context, mci *manifestCreationInfo, buf *p
return err
}
pb, created, digest, err := createManifestBlob(ctx, mci, pv, buf)
removeBlob := false
defer func() {
if removeBlob {
contentStore := packages_module.NewContentStore()
if err := contentStore.Delete(packages_module.BlobHash256Key(pb.HashSHA256)); err != nil {
log.Error("Error deleting package blob from content store: %v", err)
}
}
}()
if err != nil {
removeBlob = created
return err
}
if err := committer.Commit(); err != nil {
removeBlob = created
return err
}
if err := notifyPackageCreate(ctx, mci.Creator, pv); err != nil {
return err
}
manifestDigest = digest
return nil
}()
if err != nil {
return "", err
}
return manifestDigest, nil
}
func notifyPackageCreate(ctx context.Context, doer *user_model.User, pv *packages_model.PackageVersion) error {
pd, err := packages_model.GetPackageDescriptor(ctx, pv)
if err != nil {
txRet.pv = pv
txRet.pb, txRet.created, txRet.digest, err = createManifestBlob(ctx, contentStore, mci, pv, buf)
return err
}
})
notify_service.PackageCreate(ctx, doer, pd)
return nil
return handleCreateManifestResult(ctx, err, mci, contentStore, &txRet)
}
func createPackageAndVersion(ctx context.Context, mci *manifestCreationInfo, metadata *container_module.Metadata) (*packages_model.PackageVersion, error) {
@ -437,7 +379,7 @@ func createFileFromBlobReference(ctx context.Context, pv, uploadVersion *package
return pf, nil
}
func createManifestBlob(ctx context.Context, mci *manifestCreationInfo, pv *packages_model.PackageVersion, buf *packages_module.HashedBuffer) (*packages_model.PackageBlob, bool, string, error) {
func createManifestBlob(ctx context.Context, contentStore *packages_module.ContentStore, mci *manifestCreationInfo, pv *packages_model.PackageVersion, buf *packages_module.HashedBuffer) (_ *packages_model.PackageBlob, created bool, manifestDigest string, _ error) {
pb, exists, err := packages_model.GetOrInsertBlob(ctx, packages_service.NewPackageBlob(buf))
if err != nil {
log.Error("Error inserting package blob: %v", err)
@ -446,21 +388,20 @@ func createManifestBlob(ctx context.Context, mci *manifestCreationInfo, pv *pack
// FIXME: Workaround to be removed in v1.20
// https://github.com/go-gitea/gitea/issues/19586
if exists {
err = packages_module.NewContentStore().Has(packages_module.BlobHash256Key(pb.HashSHA256))
err = contentStore.Has(packages_module.BlobHash256Key(pb.HashSHA256))
if err != nil && (errors.Is(err, util.ErrNotExist) || errors.Is(err, os.ErrNotExist)) {
log.Debug("Package registry inconsistent: blob %s does not exist on file system", pb.HashSHA256)
exists = false
}
}
if !exists {
contentStore := packages_module.NewContentStore()
if err := contentStore.Save(packages_module.BlobHash256Key(pb.HashSHA256), buf, buf.Size()); err != nil {
log.Error("Error saving package blob in content store: %v", err)
return nil, false, "", err
}
}
manifestDigest := digestFromHashSummer(buf)
manifestDigest = digestFromHashSummer(buf)
pf, err := createFileFromBlobReference(ctx, pv, nil, &blobReference{
Digest: digest.Digest(manifestDigest),
MediaType: mci.MediaType,

View File

@ -29,7 +29,7 @@ func CreateOrg(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of the user that will own the created organization
// description: username of the user who will own the created organization
// type: string
// required: true
// - name: organization

View File

@ -22,7 +22,7 @@ func CreateRepo(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of the user. This user will own the created repository
// description: username of the user who will own the created repository
// type: string
// required: true
// - name: repository

View File

@ -175,7 +175,7 @@ func EditUser(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user to edit
// description: username of the user whose data is to be edited
// type: string
// required: true
// - name: body
@ -272,7 +272,7 @@ func DeleteUser(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user to delete
// description: username of the user to delete
// type: string
// required: true
// - name: purge
@ -328,7 +328,7 @@ func CreatePublicKey(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of the user
// description: username of the user who is to receive a public key
// type: string
// required: true
// - name: key
@ -358,7 +358,7 @@ func DeleteUserPublicKey(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user
// description: username of the user whose public key is to be deleted
// type: string
// required: true
// - name: id
@ -405,7 +405,7 @@ func SearchUsers(ctx *context.APIContext) {
// format: int64
// - name: login_name
// in: query
// description: user's login name to search for
// description: identifier of the user, provided by the external authenticator
// type: string
// - name: page
// in: query
@ -456,7 +456,7 @@ func RenameUser(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: existing username of user
// description: current username of the user
// type: string
// required: true
// - name: body

View File

@ -22,7 +22,7 @@ func ListUserBadges(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user
// description: username of the user whose badges are to be listed
// type: string
// required: true
// responses:
@ -53,7 +53,7 @@ func AddUserBadges(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user
// description: username of the user to whom a badge is to be added
// type: string
// required: true
// - name: body
@ -87,7 +87,7 @@ func DeleteUserBadges(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user
// description: username of the user whose badge is to be deleted
// type: string
// required: true
// - name: body

View File

@ -47,7 +47,7 @@ func CheckUserBlock(ctx *context.APIContext) {
// required: true
// - name: username
// in: path
// description: user to check
// description: username of the user to check
// type: string
// required: true
// responses:
@ -71,7 +71,7 @@ func BlockUser(ctx *context.APIContext) {
// required: true
// - name: username
// in: path
// description: user to block
// description: username of the user to block
// type: string
// required: true
// - name: note
@ -101,7 +101,7 @@ func UnblockUser(ctx *context.APIContext) {
// required: true
// - name: username
// in: path
// description: user to unblock
// description: username of the user to unblock
// type: string
// required: true
// responses:

View File

@ -133,7 +133,7 @@ func IsMember(ctx *context.APIContext) {
// required: true
// - name: username
// in: path
// description: username of the user
// description: username of the user to check for an organization membership
// type: string
// required: true
// responses:
@ -186,7 +186,7 @@ func IsPublicMember(ctx *context.APIContext) {
// required: true
// - name: username
// in: path
// description: username of the user
// description: username of the user to check for a public organization membership
// type: string
// required: true
// responses:
@ -240,7 +240,7 @@ func PublicizeMember(ctx *context.APIContext) {
// required: true
// - name: username
// in: path
// description: username of the user
// description: username of the user whose membership is to be publicized
// type: string
// required: true
// responses:
@ -282,7 +282,7 @@ func ConcealMember(ctx *context.APIContext) {
// required: true
// - name: username
// in: path
// description: username of the user
// description: username of the user whose membership is to be concealed
// type: string
// required: true
// responses:
@ -324,7 +324,7 @@ func DeleteMember(ctx *context.APIContext) {
// required: true
// - name: username
// in: path
// description: username of the user
// description: username of the user to remove from the organization
// type: string
// required: true
// responses:

View File

@ -82,7 +82,7 @@ func ListUserOrgs(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user
// description: username of the user whose organizations are to be listed
// type: string
// required: true
// - name: page
@ -112,7 +112,7 @@ func GetUserOrgsPermissions(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user
// description: username of the user whose permissions are to be obtained
// type: string
// required: true
// - name: org

View File

@ -426,7 +426,7 @@ func GetTeamMember(ctx *context.APIContext) {
// required: true
// - name: username
// in: path
// description: username of the member to list
// description: username of the user whose data is to be listed
// type: string
// required: true
// responses:
@ -467,7 +467,7 @@ func AddTeamMember(ctx *context.APIContext) {
// required: true
// - name: username
// in: path
// description: username of the user to add
// description: username of the user to add to a team
// type: string
// required: true
// responses:
@ -509,7 +509,7 @@ func RemoveTeamMember(ctx *context.APIContext) {
// required: true
// - name: username
// in: path
// description: username of the user to remove
// description: username of the user to remove from a team
// type: string
// required: true
// responses:

View File

@ -93,7 +93,7 @@ func IsCollaborator(ctx *context.APIContext) {
// required: true
// - name: collaborator
// in: path
// description: username of the collaborator
// description: username of the user to check for being a collaborator
// type: string
// required: true
// responses:
@ -145,7 +145,7 @@ func AddOrUpdateCollaborator(ctx *context.APIContext) {
// required: true
// - name: collaborator
// in: path
// description: username of the collaborator to add
// description: username of the user to add or update as a collaborator
// type: string
// required: true
// - name: body
@ -264,7 +264,7 @@ func GetRepoPermissions(ctx *context.APIContext) {
// required: true
// - name: collaborator
// in: path
// description: username of the collaborator
// description: username of the collaborator whose permissions are to be obtained
// type: string
// required: true
// responses:

View File

@ -43,7 +43,7 @@ func AddIssueSubscription(ctx *context.APIContext) {
// required: true
// - name: user
// in: path
// description: user to subscribe
// description: username of the user to subscribe the issue to
// type: string
// required: true
// responses:
@ -87,7 +87,7 @@ func DelIssueSubscription(ctx *context.APIContext) {
// required: true
// - name: user
// in: path
// description: user witch unsubscribe
// description: username of the user to unsubscribe from an issue
// type: string
// required: true
// responses:

View File

@ -405,7 +405,7 @@ func ListTrackedTimesByUser(ctx *context.APIContext) {
// required: true
// - name: user
// in: path
// description: username of user
// description: username of the user whose tracked times are to be listed
// type: string
// required: true
// responses:

View File

@ -30,7 +30,7 @@ func ListAccessTokens(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user
// description: username of to user whose access tokens are to be listed
// type: string
// required: true
// - name: page
@ -83,7 +83,7 @@ func CreateAccessToken(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user
// description: username of the user whose token is to be created
// required: true
// type: string
// - name: body
@ -149,7 +149,7 @@ func DeleteAccessToken(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user
// description: username of the user whose token is to be deleted
// type: string
// required: true
// - name: token

View File

@ -37,7 +37,7 @@ func CheckUserBlock(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: user to check
// description: username of the user to check
// type: string
// required: true
// responses:
@ -56,7 +56,7 @@ func BlockUser(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: user to block
// description: username of the user to block
// type: string
// required: true
// - name: note
@ -81,7 +81,7 @@ func UnblockUser(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: user to unblock
// description: username of the user to unblock
// type: string
// required: true
// responses:

View File

@ -67,7 +67,7 @@ func ListFollowers(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user
// description: username of the user whose followers are to be listed
// type: string
// required: true
// - name: page
@ -131,7 +131,7 @@ func ListFollowing(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user
// description: username of the user whose followed users are to be listed
// type: string
// required: true
// - name: page
@ -167,7 +167,7 @@ func CheckMyFollowing(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of followed user
// description: username of the user to check for authenticated followers
// type: string
// required: true
// responses:
@ -187,12 +187,12 @@ func CheckFollowing(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of following user
// description: username of the following user
// type: string
// required: true
// - name: target
// in: path
// description: username of followed user
// description: username of the followed user
// type: string
// required: true
// responses:
@ -216,7 +216,7 @@ func Follow(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user to follow
// description: username of the user to follow
// type: string
// required: true
// responses:
@ -246,7 +246,7 @@ func Unfollow(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user to unfollow
// description: username of the user to unfollow
// type: string
// required: true
// responses:

View File

@ -53,7 +53,7 @@ func ListGPGKeys(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user
// description: username of the user whose GPG key list is to be obtained
// type: string
// required: true
// - name: page

View File

@ -136,7 +136,7 @@ func ListPublicKeys(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user
// description: username of the user whose public keys are to be listed
// type: string
// required: true
// - name: fingerprint

View File

@ -62,7 +62,7 @@ func ListUserRepos(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user
// description: username of the user whose owned repos are to be listed
// type: string
// required: true
// - name: page

View File

@ -50,7 +50,7 @@ func GetStarredRepos(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user
// description: username of the user whose starred repos are to be listed
// type: string
// required: true
// - name: page

View File

@ -110,7 +110,7 @@ func GetInfo(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user to get
// description: username of the user whose data is to be listed
// type: string
// required: true
// responses:
@ -151,7 +151,7 @@ func GetUserHeatmapData(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user to get
// description: username of the user whose heatmap is to be obtained
// type: string
// required: true
// responses:
@ -177,7 +177,7 @@ func ListUserActivityFeeds(ctx *context.APIContext) {
// parameters:
// - name: username
// in: path
// description: username of user
// description: username of the user whose activity feeds are to be listed
// type: string
// required: true
// - name: only-performed-by

View File

@ -49,7 +49,7 @@ func GetWatchedRepos(ctx *context.APIContext) {
// - name: username
// type: string
// in: path
// description: username of the user
// description: username of the user whose watched repos are to be listed
// required: true
// - name: page
// in: query

View File

@ -244,7 +244,7 @@ func editFileOpenExisting(ctx *context.Context) (prefetch []byte, dataRc io.Read
return nil, nil, nil
}
if fInfo.isLFSFile {
if fInfo.isLFSFile() {
lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath)
if err != nil {
_ = dataRc.Close()
@ -298,7 +298,7 @@ func EditFile(ctx *context.Context) {
ctx.Data["FileSize"] = fInfo.fileSize
// Only some file types are editable online as text.
if fInfo.isLFSFile {
if fInfo.isLFSFile() {
ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_lfs_files")
} else if !fInfo.st.IsRepresentableAsText() {
ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_non_text_files")

View File

@ -267,8 +267,10 @@ func LFSFileGet(ctx *context.Context) {
buf = buf[:n]
st := typesniffer.DetectContentType(buf)
// FIXME: there is no IsPlainText set, but template uses it
ctx.Data["IsTextFile"] = st.IsText()
ctx.Data["FileSize"] = meta.Size
// FIXME: the last field is the URL-base64-encoded filename, it should not be "direct"
ctx.Data["RawFileLink"] = fmt.Sprintf("%s%s/%s.git/info/lfs/objects/%s/%s", setting.AppURL, url.PathEscape(ctx.Repo.Repository.OwnerName), url.PathEscape(ctx.Repo.Repository.Name), url.PathEscape(meta.Oid), "direct")
switch {
case st.IsRepresentableAsText():
@ -309,8 +311,6 @@ func LFSFileGet(ctx *context.Context) {
}
ctx.Data["LineNums"] = gotemplate.HTML(output.String())
case st.IsPDF():
ctx.Data["IsPDFFile"] = true
case st.IsVideo():
ctx.Data["IsVideoFile"] = true
case st.IsAudio():

View File

@ -59,60 +59,63 @@ const (
)
type fileInfo struct {
isTextFile bool
isLFSFile bool
fileSize int64
lfsMeta *lfs.Pointer
st typesniffer.SniffedType
fileSize int64
lfsMeta *lfs.Pointer
st typesniffer.SniffedType
}
func getFileReader(ctx gocontext.Context, repoID int64, blob *git.Blob) ([]byte, io.ReadCloser, *fileInfo, error) {
dataRc, err := blob.DataAsync()
func (fi *fileInfo) isLFSFile() bool {
return fi.lfsMeta != nil && fi.lfsMeta.Oid != ""
}
func getFileReader(ctx gocontext.Context, repoID int64, blob *git.Blob) (buf []byte, dataRc io.ReadCloser, fi *fileInfo, err error) {
dataRc, err = blob.DataAsync()
if err != nil {
return nil, nil, nil, err
}
buf := make([]byte, 1024)
const prefetchSize = lfs.MetaFileMaxSize
buf = make([]byte, prefetchSize)
n, _ := util.ReadAtMost(dataRc, buf)
buf = buf[:n]
st := typesniffer.DetectContentType(buf)
isTextFile := st.IsText()
fi = &fileInfo{fileSize: blob.Size(), st: typesniffer.DetectContentType(buf)}
// FIXME: what happens when README file is an image?
if !isTextFile || !setting.LFS.StartServer {
return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil
if !fi.st.IsText() || !setting.LFS.StartServer {
return buf, dataRc, fi, nil
}
pointer, _ := lfs.ReadPointerFromBuffer(buf)
if !pointer.IsValid() { // fallback to plain file
return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil
if !pointer.IsValid() { // fallback to a plain file
return buf, dataRc, fi, nil
}
meta, err := git_model.GetLFSMetaObjectByOid(ctx, repoID, pointer.Oid)
if err != nil { // fallback to plain file
if err != nil { // fallback to a plain file
log.Warn("Unable to access LFS pointer %s in repo %d: %v", pointer.Oid, repoID, err)
return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil
return buf, dataRc, fi, nil
}
dataRc.Close()
// close the old dataRc and open the real LFS target
_ = dataRc.Close()
dataRc, err = lfs.ReadMetaObject(pointer)
if err != nil {
return nil, nil, nil, err
}
buf = make([]byte, 1024)
buf = make([]byte, prefetchSize)
n, err = util.ReadAtMost(dataRc, buf)
if err != nil {
dataRc.Close()
return nil, nil, nil, err
_ = dataRc.Close()
return nil, nil, fi, err
}
buf = buf[:n]
st = typesniffer.DetectContentType(buf)
return buf, dataRc, &fileInfo{st.IsText(), true, meta.Size, &meta.Pointer, st}, nil
fi.st = typesniffer.DetectContentType(buf)
fi.fileSize = blob.Size()
fi.lfsMeta = &meta.Pointer
return buf, dataRc, fi, nil
}
func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool {

View File

@ -23,6 +23,7 @@ import (
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
issue_service "code.gitea.io/gitea/services/issue"
@ -40,7 +41,128 @@ func prepareLatestCommitInfo(ctx *context.Context) bool {
return loadLatestCommitData(ctx, commit)
}
func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) {
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 handleFileViewRenderMarkup(ctx *context.Context, filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte, utf8Reader io.Reader) bool {
markupType := markup.DetectMarkupTypeByFileName(filename)
if markupType == "" {
markupType = markup.DetectRendererType(filename, sniffedType, prefetchBuf)
}
if markupType == "" {
return false
}
ctx.Data["HasSourceRenderedToggle"] = true
if ctx.FormString("display") == "source" {
return false
}
ctx.Data["MarkupType"] = markupType
metas := ctx.Repo.Repository.ComposeRepoFileMetas(ctx)
metas["RefTypeNameSubURL"] = ctx.Repo.RefTypeNameSubURL()
rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{
CurrentRefPath: ctx.Repo.RefTypeNameSubURL(),
CurrentTreePath: path.Dir(ctx.Repo.TreePath),
}).
WithMarkupType(markupType).
WithRelativePath(ctx.Repo.TreePath).
WithMetas(metas)
var err error
ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, utf8Reader)
if err != nil {
ctx.ServerError("Render", err)
return true
}
// to prevent iframe from loading third-party url
ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'")
return true
}
func handleFileViewRenderSource(ctx *context.Context, filename string, attrs *attribute.Attributes, fInfo *fileInfo, utf8Reader io.Reader) bool {
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, err := highlight.File(filename, language, buf)
ctx.Data["LexerName"] = lexerName
if err != nil {
log.Error("highlight.File failed, fallback to plain text: %v", err)
fileContent = highlight.PlainText(buf)
}
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 prepareFileView(ctx *context.Context, entry *git.TreeEntry) {
ctx.Data["IsViewFile"] = true
ctx.Data["HideRepoInfo"] = true
@ -86,11 +208,8 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) {
}
}
isDisplayingSource := ctx.FormString("display") == "source"
isDisplayingRendered := !isDisplayingSource
// Don't call any other repository functions depends on git.Repository until the dataRc closed to
// avoid create unnecessary temporary cat file.
// 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)
@ -98,207 +217,62 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) {
}
defer dataRc.Close()
if fInfo.isLFSFile {
if fInfo.isLFSFile() {
ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/media/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
}
isRepresentableAsText := fInfo.st.IsRepresentableAsText()
if !isRepresentableAsText {
// If we can't show plain text, always try to render.
isDisplayingSource = false
isDisplayingRendered = true
if !prepareFileViewEditorButtons(ctx) {
return
}
ctx.Data["IsLFSFile"] = fInfo.isLFSFile
ctx.Data["IsLFSFile"] = fInfo.isLFSFile()
ctx.Data["FileSize"] = fInfo.fileSize
ctx.Data["IsTextFile"] = fInfo.isTextFile
ctx.Data["IsRepresentableAsText"] = isRepresentableAsText
ctx.Data["IsDisplayingSource"] = isDisplayingSource
ctx.Data["IsDisplayingRendered"] = isDisplayingRendered
ctx.Data["IsRepresentableAsText"] = fInfo.st.IsRepresentableAsText()
ctx.Data["IsExecutable"] = entry.IsExecutable()
ctx.Data["CanCopyContent"] = fInfo.st.IsRepresentableAsText() || fInfo.st.IsImage()
isTextSource := fInfo.isTextFile || isDisplayingSource
ctx.Data["IsTextSource"] = isTextSource
if isTextSource {
ctx.Data["CanCopyContent"] = true
}
// Check LFS Lock
lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath)
ctx.Data["LFSLock"] = lfsLock
if err != nil {
ctx.ServerError("GetTreePathLock", err)
attrs, ok := prepareFileViewLfsAttrs(ctx)
if !ok {
return
}
if lfsLock != nil {
u, err := user_model.GetUserByID(ctx, lfsLock.OwnerID)
if err != nil {
ctx.ServerError("GetTreePathLock", err)
return
}
ctx.Data["LFSLockOwner"] = u.Name
ctx.Data["LFSLockOwnerHomeLink"] = u.HomeLink()
ctx.Data["LFSLockHint"] = ctx.Tr("repo.editor.this_file_locked")
}
// read all needed attributes which will be used later
// there should be no performance different between reading 2 or 4 here
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
}
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()
}
// 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
utf8Reader := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{})
switch {
case isRepresentableAsText:
if fInfo.fileSize >= setting.UI.MaxDisplayFileSize {
ctx.Data["IsFileTooLarge"] = true
break
}
if fInfo.st.IsSvgImage() {
ctx.Data["IsImageFile"] = true
ctx.Data["CanCopyContent"] = true
ctx.Data["HasSourceRenderedToggle"] = true
}
rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{})
shouldRenderSource := ctx.FormString("display") == "source"
readmeExist := util.IsReadmeFileName(blob.Name())
ctx.Data["ReadmeExist"] = readmeExist
markupType := markup.DetectMarkupTypeByFileName(blob.Name())
if markupType == "" {
markupType = markup.DetectRendererType(blob.Name(), bytes.NewReader(buf))
}
if markupType != "" {
ctx.Data["HasSourceRenderedToggle"] = true
}
if markupType != "" && !shouldRenderSource {
ctx.Data["IsMarkup"] = true
ctx.Data["MarkupType"] = markupType
metas := ctx.Repo.Repository.ComposeRepoFileMetas(ctx)
metas["RefTypeNameSubURL"] = ctx.Repo.RefTypeNameSubURL()
rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{
CurrentRefPath: ctx.Repo.RefTypeNameSubURL(),
CurrentTreePath: path.Dir(ctx.Repo.TreePath),
}).
WithMarkupType(markupType).
WithRelativePath(ctx.Repo.TreePath).
WithMetas(metas)
ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd)
if err != nil {
ctx.ServerError("Render", err)
return
}
// to prevent iframe load third-party url
ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'")
} else {
buf, _ := io.ReadAll(rd)
// 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, err := highlight.File(blob.Name(), language, buf)
ctx.Data["LexerName"] = lexerName
if err != nil {
log.Error("highlight.File failed, fallback to plain text: %v", err)
fileContent = highlight.PlainText(buf)
}
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
}
case fInfo.st.IsPDF():
ctx.Data["IsPDFFile"] = true
case fInfo.fileSize >= setting.UI.MaxDisplayFileSize:
ctx.Data["IsFileTooLarge"] = true
case handleFileViewRenderMarkup(ctx, entry.Name(), fInfo.st, buf, utf8Reader):
// it also sets ctx.Data["FileContent"] and more
ctx.Data["IsMarkup"] = true
case handleFileViewRenderSource(ctx, entry.Name(), attrs, fInfo, utf8Reader):
// 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
case fInfo.st.IsImage() && (setting.UI.SVG.Enabled || !fInfo.st.IsSvgImage()):
ctx.Data["IsImageFile"] = true
ctx.Data["CanCopyContent"] = true
default:
if fInfo.fileSize >= setting.UI.MaxDisplayFileSize {
ctx.Data["IsFileTooLarge"] = true
break
}
// TODO: this logic duplicates with "isRepresentableAsText=true", it is not the same as "LFSFileGet" in "lfs.go"
// It is used by "external renders", markupRender will execute external programs to get rendered content.
if markupType := markup.DetectMarkupTypeByFileName(blob.Name()); markupType != "" {
rd := io.MultiReader(bytes.NewReader(buf), dataRc)
ctx.Data["IsMarkup"] = true
ctx.Data["MarkupType"] = markupType
rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{
CurrentRefPath: ctx.Repo.RefTypeNameSubURL(),
CurrentTreePath: path.Dir(ctx.Repo.TreePath),
}).
WithMarkupType(markupType).
WithRelativePath(ctx.Repo.TreePath)
ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd)
if err != nil {
ctx.ServerError("Render", err)
return
}
}
// unable to render anything, show the "view raw" or let frontend handle it
}
ctx.Data["IsVendored"], ctx.Data["IsGenerated"] = attrs.GetVendored().Value(), attrs.GetGenerated().Value()
if fInfo.st.IsImage() && !fInfo.st.IsSvgImage() {
img, _, err := image.DecodeConfig(bytes.NewReader(buf))
if err == nil {
// There are Image formats go can't decode
// Instead of throwing an error in that case, we show the size only when we can decode
ctx.Data["ImageSize"] = fmt.Sprintf("%dx%dpx", img.Width, img.Height)
}
}
prepareToRenderButtons(ctx, lfsLock)
}
func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) {
func prepareFileViewEditorButtons(ctx *context.Context) bool {
// archived or mirror repository, the buttons should not be shown
if !ctx.Repo.Repository.CanEnableEditor() {
return
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
return true
}
if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) {
@ -306,7 +280,24 @@ func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) {
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
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
@ -315,4 +306,5 @@ func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) {
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
}

View File

@ -339,7 +339,7 @@ func prepareToRenderDirOrFile(entry *git.TreeEntry) func(ctx *context.Context) {
if entry.IsDir() {
prepareToRenderDirectory(ctx)
} else {
prepareToRenderFile(ctx, entry)
prepareFileView(ctx, entry)
}
}
}

View File

@ -161,24 +161,23 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil
}
defer dataRc.Close()
ctx.Data["FileIsText"] = fInfo.isTextFile
ctx.Data["FileIsText"] = fInfo.st.IsText()
ctx.Data["FileTreePath"] = path.Join(ctx.Repo.TreePath, subfolder, readmeFile.Name())
ctx.Data["FileSize"] = fInfo.fileSize
ctx.Data["IsLFSFile"] = fInfo.isLFSFile
ctx.Data["IsLFSFile"] = fInfo.isLFSFile()
if fInfo.isLFSFile {
if fInfo.isLFSFile() {
filenameBase64 := base64.RawURLEncoding.EncodeToString([]byte(readmeFile.Name()))
ctx.Data["RawFileLink"] = fmt.Sprintf("%s.git/info/lfs/objects/%s/%s", ctx.Repo.Repository.Link(), url.PathEscape(fInfo.lfsMeta.Oid), url.PathEscape(filenameBase64))
}
if !fInfo.isTextFile {
if !fInfo.st.IsText() {
return
}
if fInfo.fileSize >= setting.UI.MaxDisplayFileSize {
// Pretend that this is a normal text file to display 'This file is too large to be shown'
ctx.Data["IsFileTooLarge"] = true
ctx.Data["IsTextFile"] = true
return
}
@ -212,7 +211,7 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil
ctx.Data["EscapeStatus"], ctx.Data["FileContent"] = charset.EscapeControlHTML(template.HTML(contentEscaped), ctx.Locale)
}
if !fInfo.isLFSFile && ctx.Repo.Repository.CanEnableEditor() {
if !fInfo.isLFSFile() && ctx.Repo.Repository.CanEnableEditor() {
ctx.Data["CanEditReadmeFile"] = true
}
}

View File

@ -203,9 +203,6 @@ func ViewPackageVersion(ctx *context.Context) {
}
ctx.Data["PackageRegistryHost"] = registryHostURL.Host
var pvs []*packages_model.PackageVersion
pvsTotal := int64(0)
switch pd.Package.Type {
case packages_model.TypeAlpine:
branches := make(container.Set[string])
@ -296,12 +293,16 @@ func ViewPackageVersion(ctx *context.Context) {
}
}
ctx.Data["ContainerImageMetadata"] = imageMetadata
}
var pvs []*packages_model.PackageVersion
var pvsTotal int64
if pd.Package.Type == packages_model.TypeContainer {
pvs, pvsTotal, err = container_model.SearchImageTags(ctx, &container_model.ImageTagsSearchOptions{
Paginator: db.NewAbsoluteListOptions(0, 5),
PackageID: pd.Package.ID,
IsTagged: true,
})
default:
} else {
pvs, pvsTotal, err = packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
Paginator: db.NewAbsoluteListOptions(0, 5),
PackageID: pd.Package.ID,
@ -312,7 +313,6 @@ func ViewPackageVersion(ctx *context.Context) {
ctx.ServerError("", err)
return
}
ctx.Data["LatestVersions"] = pvs
ctx.Data["TotalVersionCount"] = pvsTotal

View File

@ -48,7 +48,7 @@ func ParseManifestMetadata(ctx context.Context, rd io.Reader, ownerID int64, ima
configDescriptor, err := container_service.GetContainerBlob(ctx, &container_service.BlobSearchOptions{
OwnerID: ownerID,
Image: imageName,
Digest: string(manifest.Config.Digest),
Digest: manifest.Config.Digest.String(),
})
if err != nil {
return nil, nil, nil, err

View File

@ -82,6 +82,8 @@
</table>
{{end}}{{/* end if .IsFileTooLarge */}}
<div class="code-line-menu tippy-target">
{{/*FIXME: the "HasSourceRenderedToggle" is never set on blame page, it should mean "whether the file is renderable".
If the file is renderable, then it must has the "display=source" parameter to make sure the file view page shows the source code, then line number works. */}}
{{if $.Permission.CanRead ctx.Consts.RepoUnitTypeIssues}}
<a class="item ref-in-new-issue" role="menuitem" data-url-issue-new="{{.RepoLink}}/issues/new" data-url-param-body-link="{{.Repository.Link}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}" rel="nofollow noindex">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</a>
{{end}}

View File

@ -5,7 +5,7 @@
{{range $i, $v := .TreeNames}}
<div class="breadcrumb-divider">/</div>
{{if eq $i $l}}
<input id="file-name" maxlength="255" value="{{$v}}" placeholder="{{ctx.Locale.Tr (Iif $.PageIsUpload "repo.editor.add_subdir" "repo.editor.name_your_file")}}" data-editorconfig="{{$.EditorconfigJson}}" required autofocus>
<input id="file-name" maxlength="255" value="{{$v}}" placeholder="{{ctx.Locale.Tr (Iif $.PageIsUpload "repo.editor.add_subdir" "repo.editor.name_your_file")}}" data-editorconfig="{{$.EditorconfigJson}}" {{Iif $.PageIsUpload "" "required"}} autofocus>
<span data-tooltip-content="{{ctx.Locale.Tr "repo.editor.filename_help"}}">{{svg "octicon-info"}}</span>
{{else}}
<span class="section"><a href="{{$.BranchLink}}/{{index $.TreePaths $i | PathEscapeSegments}}">{{$v}}</a></span>

View File

@ -22,7 +22,7 @@
<span class="label-filter-exclude-info">{{ctx.Locale.Tr "repo.issues.filter_label_exclude"}}</span>
<div class="divider"></div>
<a class="item label-filter-query-default" href="{{QueryBuild $queryLink "labels" NIL}}">{{ctx.Locale.Tr "repo.issues.filter_label_no_select"}}</a>
<a class="item label-filter-query-not-set" href="{{QueryBuild $queryLink "labels" 0}}">{{ctx.Locale.Tr "repo.issues.filter_label_select_no_label"}}</a>
<a class="item label-filter-query-not-set" href="{{QueryBuild $queryLink "labels" "0"}}">{{ctx.Locale.Tr "repo.issues.filter_label_select_no_label"}}</a>
{{/* The logic here is not the same as the label selector in the issue sidebar.
The one in the issue sidebar renders "repo labels | divider | org labels".
Maybe the logic should be updated to be consistent.*/}}

View File

@ -15,7 +15,7 @@
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_milestone"}}">
</div>
<div class="divider"></div>
<a class="{{if not $.MilestoneID}}active selected {{end}}item" href="{{QueryBuild $queryLink "milestone" 0}}">{{ctx.Locale.Tr "repo.issues.filter_milestone_all"}}</a>
<a class="{{if not $.MilestoneID}}active selected {{end}}item" href="{{QueryBuild $queryLink "milestone" NIL}}">{{ctx.Locale.Tr "repo.issues.filter_milestone_all"}}</a>
<a class="{{if $.MilestoneID}}{{if eq $.MilestoneID -1}}active selected {{end}}{{end}}item" href="{{QueryBuild $queryLink "milestone" -1}}">{{ctx.Locale.Tr "repo.issues.filter_milestone_none"}}</a>
{{if .OpenMilestones}}
<div class="divider"></div>

View File

@ -30,8 +30,6 @@
<audio controls src="{{$.RawFileLink}}">
<strong>{{ctx.Locale.Tr "repo.audio_not_supported_in_browser"}}</strong>
</audio>
{{else if .IsPDFFile}}
<div class="pdf-content is-loading" data-global-init="initPdfViewer" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "diff.view_file"}}"></div>
{{else}}
<a href="{{$.RawFileLink}}" rel="nofollow" class="tw-p-4">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
{{end}}

View File

@ -5,9 +5,7 @@
{{template "base/alert" .}}
{{template "repo/release_tag_header" .}}
<h4 class="ui top attached header">
<div class="five wide column tw-flex tw-items-center">
{{.TagCount}} {{ctx.Locale.Tr "repo.release.tags"}}
</div>
{{.TagCount}} {{ctx.Locale.Tr "repo.release.tags"}}
</h4>
{{$canReadReleases := $.Permission.CanRead ctx.Consts.RepoUnitTypeReleases}}
<div class="ui attached segment">
@ -15,53 +13,49 @@
{{template "shared/search/combo" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.tag_kind") "Tooltip" (ctx.Locale.Tr "search.tag_tooltip")}}
</form>
</div>
<div class="ui attached table segment">
<div class="ui attached segment tw-p-0">
{{if .Releases}}
<table class="ui very basic striped fixed table single line" id="tags-table">
<tbody class="tag-list">
{{range $idx, $release := .Releases}}
<tr>
<td class="tag-list-row">
<h3 class="tag-list-row-title tw-mb-2">
{{if $canReadReleases}}
<a class="tag-list-row-link tw-flex tw-items-center" href="{{$.RepoLink}}/releases/tag/{{.TagName | PathEscapeSegments}}" rel="nofollow">{{.TagName}}</a>
{{else}}
<a class="tag-list-row-link tw-flex tw-items-center" href="{{$.RepoLink}}/src/tag/{{.TagName | PathEscapeSegments}}" rel="nofollow">{{.TagName}}</a>
{{end}}
</h3>
<div class="download tw-flex tw-items-center">
{{if $.Permission.CanRead ctx.Consts.RepoUnitTypeCode}}
{{if .CreatedUnix}}
<span class="tw-mr-2">{{svg "octicon-clock" 16 "tw-mr-1"}}{{DateUtils.TimeSince .CreatedUnix}}</span>
{{end}}
<div class="ui divided list" id="tags-table">
{{range $idx, $release := .Releases}}
<div class="item tag-list-row tw-p-4">
<h3 class="tag-list-row-title tw-mb-2">
{{if $canReadReleases}}
<a class="tag-list-row-link" href="{{$.RepoLink}}/releases/tag/{{.TagName | PathEscapeSegments}}" rel="nofollow">{{.TagName}}</a>
{{else}}
<a class="tag-list-row-link" href="{{$.RepoLink}}/src/tag/{{.TagName | PathEscapeSegments}}" rel="nofollow">{{.TagName}}</a>
{{end}}
</h3>
<div class="flex-text-block muted-links tw-gap-4 tw-flex-wrap">
{{if $.Permission.CanRead ctx.Consts.RepoUnitTypeCode}}
{{if .CreatedUnix}}
<span class="flex-text-inline">{{svg "octicon-clock"}}{{DateUtils.TimeSince .CreatedUnix}}</span>
{{end}}
<a class="tw-mr-2 tw-font-mono muted" href="{{$.RepoLink}}/src/commit/{{.Sha1}}" rel="nofollow">{{svg "octicon-git-commit" 16 "tw-mr-1"}}{{ShortSha .Sha1}}</a>
<a class="flex-text-inline tw-font-mono" href="{{$.RepoLink}}/src/commit/{{.Sha1}}" rel="nofollow">{{svg "octicon-git-commit"}}{{ShortSha .Sha1}}</a>
{{if not $.DisableDownloadSourceArchives}}
<a class="archive-link tw-mr-2 muted" href="{{$.RepoLink}}/archive/{{.TagName | PathEscapeSegments}}.zip" rel="nofollow">{{svg "octicon-file-zip" 16 "tw-mr-1"}}ZIP</a>
<a class="archive-link tw-mr-2 muted" href="{{$.RepoLink}}/archive/{{.TagName | PathEscapeSegments}}.tar.gz" rel="nofollow">{{svg "octicon-file-zip" 16 "tw-mr-1"}}TAR.GZ</a>
{{end}}
{{if not $.DisableDownloadSourceArchives}}
<a class="archive-link flex-text-inline" href="{{$.RepoLink}}/archive/{{.TagName | PathEscapeSegments}}.zip" rel="nofollow">{{svg "octicon-file-zip"}}ZIP</a>
<a class="archive-link flex-text-inline" href="{{$.RepoLink}}/archive/{{.TagName | PathEscapeSegments}}.tar.gz" rel="nofollow">{{svg "octicon-file-zip"}}TAR.GZ</a>
{{end}}
{{if (and $canReadReleases $.CanCreateRelease $release.IsTag)}}
<a class="tw-mr-2 muted" href="{{$.RepoLink}}/releases/new?tag={{.TagName}}">{{svg "octicon-tag" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.release.new_release"}}</a>
{{end}}
{{if (and $canReadReleases $.CanCreateRelease $release.IsTag)}}
<a class="flex-text-inline" href="{{$.RepoLink}}/releases/new?tag={{.TagName}}">{{svg "octicon-tag"}}{{ctx.Locale.Tr "repo.release.new_release"}}</a>
{{end}}
{{if (and ($.Permission.CanWrite ctx.Consts.RepoUnitTypeCode) $release.IsTag)}}
<a class="ui delete-button tw-mr-2 muted" data-url="{{$.RepoLink}}/tags/delete" data-id="{{.ID}}">
{{svg "octicon-trash" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.release.delete_tag"}}
</a>
{{end}}
{{if (and ($.Permission.CanWrite ctx.Consts.RepoUnitTypeCode) $release.IsTag)}}
<a class="flex-text-inline link-action" data-url="{{$.RepoLink}}/tags/delete?id={{.ID}}" data-modal-confirm="#confirm-delete-tag-modal">
{{svg "octicon-trash"}}{{ctx.Locale.Tr "repo.release.delete_tag"}}
</a>
{{end}}
{{if and $canReadReleases (not $release.IsTag)}}
<a class="tw-mr-2 muted" href="{{$.RepoLink}}/releases/tag/{{.TagName | PathEscapeSegments}}">{{svg "octicon-tag" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.release.detail"}}</a>
{{end}}
{{end}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
{{if and $canReadReleases (not $release.IsTag)}}
<a class="flex-text-inline" href="{{$.RepoLink}}/releases/tag/{{.TagName | PathEscapeSegments}}">{{svg "octicon-tag"}}{{ctx.Locale.Tr "repo.release.detail"}}</a>
{{end}}
{{end}}
</div>
</div>
{{end}}
</div>
{{else}}
{{if .NumTags}}
<p class="tw-p-4">{{ctx.Locale.Tr "no_results_found"}}</p>
@ -73,9 +67,8 @@
</div>
{{if $.Permission.CanWrite ctx.Consts.RepoUnitTypeCode}}
<div class="ui g-modal-confirm delete modal">
<div id="confirm-delete-tag-modal" class="ui small modal">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "repo.release.delete_tag"}}
</div>
<div class="content">

View File

@ -1,4 +1,6 @@
<div {{if .ReadmeInList}}id="readme" {{end}}class="{{TabSizeClass .Editorconfig .FileTreePath}} non-diff-file-content">
<div {{if .ReadmeInList}}id="readme"{{end}} class="{{TabSizeClass .Editorconfig .FileTreePath}} non-diff-file-content"
data-global-init="initRepoFileView" data-raw-file-link="{{.RawFileLink}}">
{{- if .FileError}}
<div class="ui error message">
<div class="text left tw-whitespace-pre">{{.FileError}}</div>
@ -32,13 +34,14 @@
{{template "repo/file_info" .}}
{{end}}
</div>
<div class="file-header-right file-actions tw-flex tw-items-center tw-flex-wrap">
{{if .HasSourceRenderedToggle}}
<div class="ui compact icon buttons">
<a href="?display=source" class="ui mini basic button {{if .IsDisplayingSource}}active{{end}}" data-tooltip-content="{{ctx.Locale.Tr "repo.file_view_source"}}">{{svg "octicon-code" 15}}</a>
<a href="{{$.Link}}" class="ui mini basic button {{if .IsDisplayingRendered}}active{{end}}" data-tooltip-content="{{ctx.Locale.Tr "repo.file_view_rendered"}}">{{svg "octicon-file" 15}}</a>
</div>
{{end}}
<div class="file-header-right file-actions flex-text-block tw-flex-wrap">
{{/* this componment is also controlled by frontend plugin renders */}}
<div class="ui compact icon buttons file-view-toggle-buttons {{Iif .HasSourceRenderedToggle "" "tw-hidden"}}">
{{if .IsRepresentableAsText}}
<a href="?display=source" class="ui mini basic button file-view-toggle-source {{if .IsDisplayingSource}}active{{end}}" data-tooltip-content="{{ctx.Locale.Tr "repo.file_view_source"}}">{{svg "octicon-code" 15}}</a>
{{end}}
<a href="?display=rendered" class="ui mini basic button file-view-toggle-rendered {{if not .IsDisplayingSource}}active{{end}}" data-tooltip-content="{{ctx.Locale.Tr "repo.file_view_rendered"}}">{{svg "octicon-file" 15}}</a>
</div>
{{if not .ReadmeInList}}
<div class="ui buttons tw-mr-1">
<a class="ui mini basic button" href="{{$.RawFileLink}}">{{ctx.Locale.Tr "repo.file_raw"}}</a>
@ -55,7 +58,10 @@
{{end}}
</div>
<a download class="btn-octicon" data-tooltip-content="{{ctx.Locale.Tr "repo.download_file"}}" href="{{$.RawFileLink}}">{{svg "octicon-download"}}</a>
<a class="btn-octicon {{if not .CanCopyContent}} disabled{{end}}" data-global-click="onCopyContentButtonClick" {{if or .IsImageFile (and .HasSourceRenderedToggle (not .IsDisplayingSource))}} data-link="{{$.RawFileLink}}"{{end}} data-tooltip-content="{{if .CanCopyContent}}{{ctx.Locale.Tr "copy_content"}}{{else}}{{ctx.Locale.Tr "copy_type_unsupported"}}{{end}}">{{svg "octicon-copy"}}</a>
<a class="btn-octicon {{if not .CanCopyContent}}disabled{{end}}" data-global-click="onCopyContentButtonClick"
{{if not .IsDisplayingSource}}data-raw-file-link="{{$.RawFileLink}}"{{end}}
data-tooltip-content="{{if .CanCopyContent}}{{ctx.Locale.Tr "copy_content"}}{{else}}{{ctx.Locale.Tr "copy_type_unsupported"}}{{end}}"
>{{svg "octicon-copy"}}</a>
{{if .EnableFeed}}
<a class="btn-octicon" href="{{$.RepoLink}}/rss/{{$.RefTypeNameSubURL}}/{{PathEscapeSegments .TreePath}}" data-tooltip-content="{{ctx.Locale.Tr "rss_feed"}}">
{{svg "octicon-rss"}}
@ -82,20 +88,36 @@
{{end}}
</div>
</h4>
<div class="ui bottom attached table unstackable segment">
{{if not (or .IsMarkup .IsRenderedHTML)}}
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus "root" $}}
{{if not .IsMarkup}}
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}}
{{end}}
<div class="file-view{{if .IsMarkup}} markup {{.MarkupType}}{{else if .IsPlainText}} plain-text{{else if .IsTextSource}} code-view{{end}}">
<div class="file-view {{if .IsMarkup}}markup {{.MarkupType}}{{else if .IsPlainText}}plain-text{{else if .IsDisplayingSource}}code-view{{end}}">
{{if .IsFileTooLarge}}
{{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}}
{{else if not .FileSize}}
{{template "shared/fileisempty"}}
{{else if .IsMarkup}}
{{if .FileContent}}{{.FileContent}}{{end}}
{{.FileContent}}
{{else if .IsPlainText}}
<pre>{{if .FileContent}}{{.FileContent}}{{end}}</pre>
{{else if not .IsTextSource}}
{{else if .FileContent}}
<table>
<tbody>
{{range $idx, $code := .FileContent}}
{{$line := Eval $idx "+" 1}}
<tr>
<td id="L{{$line}}" class="lines-num"><span id="L{{$line}}" data-line-number="{{$line}}"></span></td>
{{if $.EscapeStatus.Escaped}}
<td class="lines-escape">{{if (index $.LineEscapeStatus $idx).Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{if (index $.LineEscapeStatus $idx).HasInvisible}}{{ctx.Locale.Tr "repo.invisible_runes_line"}} {{end}}{{if (index $.LineEscapeStatus $idx).HasAmbiguous}}{{ctx.Locale.Tr "repo.ambiguous_runes_line"}}{{end}}"></button>{{end}}</td>
{{end}}
<td rel="L{{$line}}" class="lines-code chroma"><code class="code-inner">{{$code}}</code></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="view-raw">
{{if .IsImageFile}}
<img alt="{{$.RawFileLink}}" src="{{$.RawFileLink}}">
@ -107,35 +129,23 @@
<audio controls src="{{$.RawFileLink}}">
<strong>{{ctx.Locale.Tr "repo.audio_not_supported_in_browser"}}</strong>
</audio>
{{else if .IsPDFFile}}
<div class="pdf-content is-loading" data-global-init="initPdfViewer" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "repo.diff.view_file"}}"></div>
{{else}}
<a href="{{$.RawFileLink}}" rel="nofollow" class="tw-p-4">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
<div class="file-view-render-container">
<div class="file-view-raw-prompt tw-p-4">
<a href="{{$.RawFileLink}}" rel="nofollow">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
</div>
</div>
{{end}}
</div>
{{else if .FileSize}}
<table>
<tbody>
{{range $idx, $code := .FileContent}}
{{$line := Eval $idx "+" 1}}
<tr>
<td id="L{{$line}}" class="lines-num"><span id="L{{$line}}" data-line-number="{{$line}}"></span></td>
{{if $.EscapeStatus.Escaped}}
<td class="lines-escape">{{if (index $.LineEscapeStatus $idx).Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{if (index $.LineEscapeStatus $idx).HasInvisible}}{{ctx.Locale.Tr "repo.invisible_runes_line"}} {{end}}{{if (index $.LineEscapeStatus $idx).HasAmbiguous}}{{ctx.Locale.Tr "repo.ambiguous_runes_line"}}{{end}}"></button>{{end}}</td>
{{end}}
<td rel="L{{$line}}" class="lines-code chroma"><code class="code-inner">{{$code}}</code></td>
</tr>
{{end}}
</tbody>
</table>
<div class="code-line-menu tippy-target">
{{if $.Permission.CanRead ctx.Consts.RepoUnitTypeIssues}}
<a class="item ref-in-new-issue" role="menuitem" data-url-issue-new="{{.RepoLink}}/issues/new" data-url-param-body-link="{{.Repository.Link}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}" rel="nofollow noindex">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</a>
{{end}}
<a class="item view_git_blame" role="menuitem" href="{{.Repository.Link}}/blame/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}">{{ctx.Locale.Tr "repo.view_git_blame"}}</a>
<a class="item copy-line-permalink" role="menuitem" data-url="{{.Repository.Link}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}">{{ctx.Locale.Tr "repo.file_copy_permalink"}}</a>
</div>
{{end}}
</div>
<div class="code-line-menu tippy-target">
{{if $.Permission.CanRead ctx.Consts.RepoUnitTypeIssues}}
<a class="item ref-in-new-issue" role="menuitem" data-url-issue-new="{{.RepoLink}}/issues/new" data-url-param-body-link="{{.Repository.Link}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}" rel="nofollow noindex">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</a>
{{end}}
<a class="item view_git_blame" role="menuitem" href="{{.Repository.Link}}/blame/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}">{{ctx.Locale.Tr "repo.view_git_blame"}}</a>
<a class="item copy-line-permalink" role="menuitem" data-url="{{.Repository.Link}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}">{{ctx.Locale.Tr "repo.file_copy_permalink"}}</a>
</div>
</div>
</div>

View File

@ -766,7 +766,7 @@
},
{
"type": "string",
"description": "user's login name to search for",
"description": "identifier of the user, provided by the external authenticator",
"name": "login_name",
"in": "query"
},
@ -842,7 +842,7 @@
"parameters": [
{
"type": "string",
"description": "username of user to delete",
"description": "username of the user to delete",
"name": "username",
"in": "path",
"required": true
@ -884,7 +884,7 @@
"parameters": [
{
"type": "string",
"description": "username of user to edit",
"description": "username of the user whose data is to be edited",
"name": "username",
"in": "path",
"required": true
@ -926,7 +926,7 @@
"parameters": [
{
"type": "string",
"description": "username of user",
"description": "username of the user whose badges are to be listed",
"name": "username",
"in": "path",
"required": true
@ -956,7 +956,7 @@
"parameters": [
{
"type": "string",
"description": "username of user",
"description": "username of the user to whom a badge is to be added",
"name": "username",
"in": "path",
"required": true
@ -990,7 +990,7 @@
"parameters": [
{
"type": "string",
"description": "username of user",
"description": "username of the user whose badge is to be deleted",
"name": "username",
"in": "path",
"required": true
@ -1032,7 +1032,7 @@
"parameters": [
{
"type": "string",
"description": "username of the user",
"description": "username of the user who is to receive a public key",
"name": "username",
"in": "path",
"required": true
@ -1071,7 +1071,7 @@
"parameters": [
{
"type": "string",
"description": "username of user",
"description": "username of the user whose public key is to be deleted",
"name": "username",
"in": "path",
"required": true
@ -1114,7 +1114,7 @@
"parameters": [
{
"type": "string",
"description": "username of the user that will own the created organization",
"description": "username of the user who will own the created organization",
"name": "username",
"in": "path",
"required": true
@ -1154,7 +1154,7 @@
"parameters": [
{
"type": "string",
"description": "existing username of user",
"description": "current username of the user",
"name": "username",
"in": "path",
"required": true
@ -1197,7 +1197,7 @@
"parameters": [
{
"type": "string",
"description": "username of the user. This user will own the created repository",
"description": "username of the user who will own the created repository",
"name": "username",
"in": "path",
"required": true
@ -2716,7 +2716,7 @@
},
{
"type": "string",
"description": "user to check",
"description": "username of the user to check",
"name": "username",
"in": "path",
"required": true
@ -2747,7 +2747,7 @@
},
{
"type": "string",
"description": "user to block",
"description": "username of the user to block",
"name": "username",
"in": "path",
"required": true
@ -2787,7 +2787,7 @@
},
{
"type": "string",
"description": "user to unblock",
"description": "username of the user to unblock",
"name": "username",
"in": "path",
"required": true
@ -3258,7 +3258,7 @@
},
{
"type": "string",
"description": "username of the user",
"description": "username of the user to check for an organization membership",
"name": "username",
"in": "path",
"required": true
@ -3295,7 +3295,7 @@
},
{
"type": "string",
"description": "username of the user",
"description": "username of the user to remove from the organization",
"name": "username",
"in": "path",
"required": true
@ -3369,7 +3369,7 @@
},
{
"type": "string",
"description": "username of the user",
"description": "username of the user to check for a public organization membership",
"name": "username",
"in": "path",
"required": true
@ -3403,7 +3403,7 @@
},
{
"type": "string",
"description": "username of the user",
"description": "username of the user whose membership is to be publicized",
"name": "username",
"in": "path",
"required": true
@ -3440,7 +3440,7 @@
},
{
"type": "string",
"description": "username of the user",
"description": "username of the user whose membership is to be concealed",
"name": "username",
"in": "path",
"required": true
@ -7046,7 +7046,7 @@
},
{
"type": "string",
"description": "username of the collaborator",
"description": "username of the user to check for being a collaborator",
"name": "collaborator",
"in": "path",
"required": true
@ -7090,7 +7090,7 @@
},
{
"type": "string",
"description": "username of the collaborator to add",
"description": "username of the user to add or update as a collaborator",
"name": "collaborator",
"in": "path",
"required": true
@ -7190,7 +7190,7 @@
},
{
"type": "string",
"description": "username of the collaborator",
"description": "username of the collaborator whose permissions are to be obtained",
"name": "collaborator",
"in": "path",
"required": true
@ -12067,7 +12067,7 @@
},
{
"type": "string",
"description": "user to subscribe",
"description": "username of the user to subscribe the issue to",
"name": "user",
"in": "path",
"required": true
@ -12125,7 +12125,7 @@
},
{
"type": "string",
"description": "user witch unsubscribe",
"description": "username of the user to unsubscribe from an issue",
"name": "user",
"in": "path",
"required": true
@ -16937,7 +16937,7 @@
},
{
"type": "string",
"description": "username of user",
"description": "username of the user whose tracked times are to be listed",
"name": "user",
"in": "path",
"required": true
@ -17960,7 +17960,7 @@
},
{
"type": "string",
"description": "username of the member to list",
"description": "username of the user whose data is to be listed",
"name": "username",
"in": "path",
"required": true
@ -17995,7 +17995,7 @@
},
{
"type": "string",
"description": "username of the user to add",
"description": "username of the user to add to a team",
"name": "username",
"in": "path",
"required": true
@ -18033,7 +18033,7 @@
},
{
"type": "string",
"description": "username of the user to remove",
"description": "username of the user to remove from a team",
"name": "username",
"in": "path",
"required": true
@ -19012,7 +19012,7 @@
"parameters": [
{
"type": "string",
"description": "user to check",
"description": "username of the user to check",
"name": "username",
"in": "path",
"required": true
@ -19036,7 +19036,7 @@
"parameters": [
{
"type": "string",
"description": "user to block",
"description": "username of the user to block",
"name": "username",
"in": "path",
"required": true
@ -19069,7 +19069,7 @@
"parameters": [
{
"type": "string",
"description": "user to unblock",
"description": "username of the user to unblock",
"name": "username",
"in": "path",
"required": true
@ -19231,7 +19231,7 @@
"parameters": [
{
"type": "string",
"description": "username of followed user",
"description": "username of the user to check for authenticated followers",
"name": "username",
"in": "path",
"required": true
@ -19255,7 +19255,7 @@
"parameters": [
{
"type": "string",
"description": "username of user to follow",
"description": "username of the user to follow",
"name": "username",
"in": "path",
"required": true
@ -19282,7 +19282,7 @@
"parameters": [
{
"type": "string",
"description": "username of user to unfollow",
"description": "username of the user to unfollow",
"name": "username",
"in": "path",
"required": true
@ -20236,7 +20236,7 @@
"parameters": [
{
"type": "string",
"description": "username of user to get",
"description": "username of the user whose data is to be listed",
"name": "username",
"in": "path",
"required": true
@ -20265,7 +20265,7 @@
"parameters": [
{
"type": "string",
"description": "username of user",
"description": "username of the user whose activity feeds are to be listed",
"name": "username",
"in": "path",
"required": true
@ -20319,7 +20319,7 @@
"parameters": [
{
"type": "string",
"description": "username of user",
"description": "username of the user whose followers are to be listed",
"name": "username",
"in": "path",
"required": true
@ -20360,7 +20360,7 @@
"parameters": [
{
"type": "string",
"description": "username of user",
"description": "username of the user whose followed users are to be listed",
"name": "username",
"in": "path",
"required": true
@ -20398,14 +20398,14 @@
"parameters": [
{
"type": "string",
"description": "username of following user",
"description": "username of the following user",
"name": "username",
"in": "path",
"required": true
},
{
"type": "string",
"description": "username of followed user",
"description": "username of the followed user",
"name": "target",
"in": "path",
"required": true
@ -20434,7 +20434,7 @@
"parameters": [
{
"type": "string",
"description": "username of user",
"description": "username of the user whose GPG key list is to be obtained",
"name": "username",
"in": "path",
"required": true
@ -20475,7 +20475,7 @@
"parameters": [
{
"type": "string",
"description": "username of user to get",
"description": "username of the user whose heatmap is to be obtained",
"name": "username",
"in": "path",
"required": true
@ -20504,7 +20504,7 @@
"parameters": [
{
"type": "string",
"description": "username of user",
"description": "username of the user whose public keys are to be listed",
"name": "username",
"in": "path",
"required": true
@ -20551,7 +20551,7 @@
"parameters": [
{
"type": "string",
"description": "username of user",
"description": "username of the user whose organizations are to be listed",
"name": "username",
"in": "path",
"required": true
@ -20592,7 +20592,7 @@
"parameters": [
{
"type": "string",
"description": "username of user",
"description": "username of the user whose permissions are to be obtained",
"name": "username",
"in": "path",
"required": true
@ -20631,7 +20631,7 @@
"parameters": [
{
"type": "string",
"description": "username of user",
"description": "username of the user whose owned repos are to be listed",
"name": "username",
"in": "path",
"required": true
@ -20672,7 +20672,7 @@
"parameters": [
{
"type": "string",
"description": "username of user",
"description": "username of the user whose starred repos are to be listed",
"name": "username",
"in": "path",
"required": true
@ -20716,7 +20716,7 @@
"parameters": [
{
"type": "string",
"description": "username of the user",
"description": "username of the user whose watched repos are to be listed",
"name": "username",
"in": "path",
"required": true
@ -20757,7 +20757,7 @@
"parameters": [
{
"type": "string",
"description": "username of user",
"description": "username of to user whose access tokens are to be listed",
"name": "username",
"in": "path",
"required": true
@ -20799,7 +20799,7 @@
"parameters": [
{
"type": "string",
"description": "username of user",
"description": "username of the user whose token is to be created",
"name": "username",
"in": "path",
"required": true
@ -20838,7 +20838,7 @@
"parameters": [
{
"type": "string",
"description": "username of user",
"description": "username of the user whose token is to be deleted",
"name": "username",
"in": "path",
"required": true
@ -21639,7 +21639,7 @@
"x-go-name": "Time"
},
"user_name": {
"description": "User who spent the time (optional)",
"description": "username of the user who spent the time working on the issue (optional)",
"type": "string",
"x-go-name": "User"
}
@ -23174,6 +23174,7 @@
"x-go-name": "RepoAdminChangeTeamAccess"
},
"username": {
"description": "username of the organization",
"type": "string",
"x-go-name": "UserName"
},
@ -23624,7 +23625,9 @@
"x-go-name": "FullName"
},
"login_name": {
"description": "identifier of the user, provided by the external authenticator (if configured)",
"type": "string",
"default": "empty",
"x-go-name": "LoginName"
},
"must_change_password": {
@ -23649,6 +23652,7 @@
"x-go-name": "SourceID"
},
"username": {
"description": "username of the user",
"type": "string",
"x-go-name": "Username"
},
@ -24634,7 +24638,9 @@
"x-go-name": "Location"
},
"login_name": {
"description": "identifier of the user, provided by the external authenticator (if configured)",
"type": "string",
"default": "empty",
"x-go-name": "LoginName"
},
"max_repo_creation": {
@ -24693,6 +24699,7 @@
"x-go-name": "UserID"
},
"username": {
"description": "username of the user",
"type": "string",
"x-go-name": "UserName"
},
@ -26417,7 +26424,7 @@
"x-go-name": "RepoAdminChangeTeamAccess"
},
"username": {
"description": "deprecated",
"description": "username of the organization\ndeprecated",
"type": "string",
"x-go-name": "UserName"
},
@ -26661,6 +26668,7 @@
"x-go-name": "Name"
},
"username": {
"description": "username of the user",
"type": "string",
"x-go-name": "UserName"
}
@ -28124,6 +28132,7 @@
"x-go-name": "UserID"
},
"user_name": {
"description": "username of the user",
"type": "string",
"x-go-name": "UserName"
}
@ -28365,12 +28374,12 @@
"x-go-name": "Location"
},
"login": {
"description": "the user's username",
"description": "login of the user, same as `username`",
"type": "string",
"x-go-name": "UserName"
},
"login_name": {
"description": "the user's authentication sign-in name.",
"description": "identifier of the user, provided by the external authenticator (if configured)",
"type": "string",
"default": "empty",
"x-go-name": "LoginName"

View File

@ -157,9 +157,14 @@ gpgkey=%sapi/packages/%s/rpm/repository.key`,
t.Run("Download", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// download the package without the file name
req := NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture))
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, content, resp.Body.Bytes())
// download the package with a file name (it can be anything)
req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s/any-file-name", groupURL, packageName, packageVersion, packageArchitecture))
resp = MakeRequest(t, req, http.StatusOK)
assert.Equal(t, content, resp.Body.Bytes())
})
@ -447,7 +452,8 @@ gpgkey=%sapi/packages/%s/rpm/repository.key`,
pub, err := openpgp.ReadArmoredKeyRing(gpgResp.Body)
require.NoError(t, err)
req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture))
rpmFileName := fmt.Sprintf("%s-%s.%s.rpm", packageName, packageVersion, packageArchitecture)
req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture, rpmFileName))
resp := MakeRequest(t, req, http.StatusOK)
_, sigs, err := rpmutils.Verify(resp.Body, pub)

View File

@ -68,14 +68,15 @@ func TestLFSRender(t *testing.T) {
req := NewRequest(t, "GET", "/user2/lfs/src/branch/master/crypt.bin")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body).doc
doc := NewHTMLParser(t, resp.Body)
fileInfo := doc.Find("div.file-info-entry").First().Text()
assert.Contains(t, fileInfo, "LFS")
rawLink, exists := doc.Find("div.file-view > div.view-raw > a").Attr("href")
assert.True(t, exists, "Download link should render instead of content because this is a binary file")
assert.Equal(t, "/user2/lfs/media/branch/master/crypt.bin", rawLink, "The download link should use the proper /media link because it's in LFS")
// find new file view container
fileViewContainer := doc.Find("[data-global-init=initRepoFileView]")
assert.Equal(t, "/user2/lfs/media/branch/master/crypt.bin", fileViewContainer.AttrOr("data-raw-file-link", ""))
AssertHTMLElement(t, doc, ".view-raw > .file-view-render-container > .file-view-raw-prompt", 1)
})
// check that a directory with a README file shows its text

View File

@ -52,8 +52,7 @@ form.single-button-form.is-loading .button {
}
.markup pre.is-loading,
.editor-loading.is-loading,
.pdf-content.is-loading {
.editor-loading.is-loading {
height: var(--height-loading);
}

View File

@ -183,42 +183,6 @@ td .commit-summary {
cursor: default;
}
.view-raw {
display: flex;
justify-content: center;
align-items: center;
}
.view-raw > * {
max-width: 100%;
}
.view-raw audio,
.view-raw video,
.view-raw img {
margin: 1rem 0;
border-radius: 0;
object-fit: contain;
}
.view-raw img[src$=".svg" i] {
max-height: 600px !important;
max-width: 600px !important;
}
.pdf-content {
width: 100%;
height: 600px;
border: none !important;
display: flex;
align-items: center;
justify-content: center;
}
.pdf-content .pdf-fallback-button {
margin: 50px auto;
}
.repository.file.list .non-diff-file-content .plain-text {
padding: 1em 2em;
}
@ -241,10 +205,6 @@ td .commit-summary {
padding: 0 !important;
}
.non-diff-file-content .pdfobject {
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
.repo-editor-header {
width: 100%;
}

View File

@ -60,3 +60,33 @@
.file-view.code-view .ui.button.code-line-button:hover {
background: var(--color-secondary);
}
.view-raw {
display: flex;
justify-content: center;
}
.view-raw > * {
max-width: 100%;
}
.view-raw audio,
.view-raw video,
.view-raw img {
margin: 1rem;
border-radius: 0;
object-fit: contain;
}
.view-raw img[src$=".svg" i] {
max-height: 600px !important;
max-width: 600px !important;
}
.file-view-render-container {
width: 100%;
}
.file-view-render-container :last-child {
border-radius: 0 0 var(--border-radius) var(--border-radius); /* to match the "ui segment" bottom radius */
}

View File

@ -91,10 +91,6 @@
border-bottom: none;
}
#tags-table .tag-list-row {
padding: 8px 12px;
}
#tags-table .tag-list-row-title {
font-size: 18px;
font-weight: var(--font-weight-normal);

View File

@ -1,7 +1,7 @@
import {request} from '../modules/fetch.ts';
import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
import {addDelegatedEventListener, submitEventSubmitter} from '../utils/dom.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {addDelegatedEventListener, createElementFromHTML, submitEventSubmitter} from '../utils/dom.ts';
import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts';
import type {RequestOpts} from '../types.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
@ -111,28 +111,44 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, formSubmitt
async function onLinkActionClick(el: HTMLElement, e: Event) {
// A "link-action" can post AJAX request to its "data-url"
// Then the browser is redirected to: the "redirect" in response, or "data-redirect" attribute, or current URL by reloading.
// If the "link-action" has "data-modal-confirm" attribute, a confirm modal dialog will be shown before taking action.
// If the "link-action" has "data-modal-confirm" attribute, a "confirm modal dialog" will be shown before taking action.
// Attribute "data-modal-confirm" can be a modal element by "#the-modal-id", or a string content for the modal dialog.
e.preventDefault();
const url = el.getAttribute('data-url');
const doRequest = async () => {
if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but A doesn't have disabled attribute
if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but "A" doesn't have the "disabled" attribute
await fetchActionDoRequest(el, url, {method: el.getAttribute('data-link-action-method') || 'POST'});
if ('disabled' in el) el.disabled = false;
};
const modalConfirmContent = el.getAttribute('data-modal-confirm') ||
el.getAttribute('data-modal-confirm-content') || '';
if (!modalConfirmContent) {
let elModal: HTMLElement | null = null;
const dataModalConfirm = el.getAttribute('data-modal-confirm') || '';
if (dataModalConfirm.startsWith('#')) {
// eslint-disable-next-line unicorn/prefer-query-selector
elModal = document.getElementById(dataModalConfirm.substring(1));
if (elModal) {
elModal = createElementFromHTML(elModal.outerHTML);
elModal.removeAttribute('id');
}
}
if (!elModal) {
const modalConfirmContent = dataModalConfirm || el.getAttribute('data-modal-confirm-content') || '';
if (modalConfirmContent) {
const isRisky = el.classList.contains('red') || el.classList.contains('negative');
elModal = createConfirmModal({
header: el.getAttribute('data-modal-confirm-header') || '',
content: modalConfirmContent,
confirmButtonColor: isRisky ? 'red' : 'primary',
});
}
}
if (!elModal) {
await doRequest();
return;
}
const isRisky = el.classList.contains('red') || el.classList.contains('negative');
if (await confirmModal({
header: el.getAttribute('data-modal-confirm-header') || '',
content: modalConfirmContent,
confirmButtonColor: isRisky ? 'red' : 'primary',
})) {
if (await confirmModal(elModal)) {
await doRequest();
}
}

View File

@ -5,20 +5,29 @@ import {fomanticQuery} from '../../modules/fomantic/base.ts';
const {i18n} = window.config;
export function confirmModal({header = '', content = '', confirmButtonColor = 'primary'} = {}): Promise<boolean> {
type ConfirmModalOptions = {
header?: string;
content?: string;
confirmButtonColor?: 'primary' | 'red' | 'green' | 'blue';
}
export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLElement {
const headerHtml = header ? `<div class="header">${htmlEscape(header)}</div>` : '';
return createElementFromHTML(`
<div class="ui g-modal-confirm modal">
${headerHtml}
<div class="content">${htmlEscape(content)}</div>
<div class="actions">
<button class="ui cancel button">${svg('octicon-x')} ${htmlEscape(i18n.modal_cancel)}</button>
<button class="ui ${confirmButtonColor} ok button">${svg('octicon-check')} ${htmlEscape(i18n.modal_confirm)}</button>
</div>
</div>
`);
}
export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<boolean> {
if (!(modal instanceof HTMLElement)) modal = createConfirmModal(modal);
return new Promise((resolve) => {
const headerHtml = header ? `<div class="header">${htmlEscape(header)}</div>` : '';
const modal = createElementFromHTML(`
<div class="ui g-modal-confirm modal">
${headerHtml}
<div class="content">${htmlEscape(content)}</div>
<div class="actions">
<button class="ui cancel button">${svg('octicon-x')} ${htmlEscape(i18n.modal_cancel)}</button>
<button class="ui ${confirmButtonColor} ok button">${svg('octicon-check')} ${htmlEscape(i18n.modal_confirm)}</button>
</div>
</div>
`);
document.body.append(modal);
const $modal = fomanticQuery(modal);
$modal.modal({
onApprove() {

View File

@ -9,17 +9,17 @@ const {i18n} = window.config;
export function initCopyContent() {
registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => {
if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return;
let content;
let isRasterImage = false;
const link = btn.getAttribute('data-link');
const rawFileLink = btn.getAttribute('data-raw-file-link');
// when data-link is present, we perform a fetch. this is either because
// the text to copy is not in the DOM, or it is an image which should be
let content, isRasterImage = false;
// when "data-raw-link" is present, we perform a fetch. this is either because
// the text to copy is not in the DOM, or it is an image that should be
// fetched to copy in full resolution
if (link) {
if (rawFileLink) {
btn.classList.add('is-loading', 'loading-icon-2px');
try {
const res = await GET(link, {credentials: 'include', redirect: 'follow'});
const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'});
const contentType = res.headers.get('content-type');
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {

View File

@ -0,0 +1,76 @@
import type {FileRenderPlugin} from '../render/plugin.ts';
import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts';
import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {createElementFromHTML, showElem, toggleClass} from '../utils/dom.ts';
import {htmlEscape} from 'escape-goat';
import {basename} from '../utils.ts';
const plugins: FileRenderPlugin[] = [];
function initPluginsOnce(): void {
if (plugins.length) return;
plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer());
}
function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null {
return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null;
}
function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLElement | null): void {
const toggleButtons = elFileView.querySelector('.file-view-toggle-buttons');
showElem(toggleButtons);
const displayingRendered = Boolean(renderContainer);
toggleClass(toggleButtons.querySelectorAll('.file-view-toggle-source'), 'active', !displayingRendered); // it may not exist
toggleClass(toggleButtons.querySelector('.file-view-toggle-rendered'), 'active', displayingRendered);
// TODO: if there is only one button, hide it?
}
async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) {
const elViewRawPrompt = container.querySelector('.file-view-raw-prompt');
if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container');
let rendered = false, errorMsg = '';
try {
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType);
if (plugin) {
container.classList.add('is-loading');
container.setAttribute('data-render-name', plugin.name); // not used yet
await plugin.render(container, rawFileLink);
rendered = true;
}
} catch (e) {
errorMsg = `${e}`;
} finally {
container.classList.remove('is-loading');
}
if (rendered) {
elViewRawPrompt.remove();
return;
}
// remove all children from the container, and only show the raw file link
container.replaceChildren(elViewRawPrompt);
if (errorMsg) {
const elErrorMessage = createElementFromHTML(htmlEscape`<div class="ui error message">${errorMsg}</div>`);
elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage);
}
}
export function initRepoFileView(): void {
registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => {
initPluginsOnce();
const rawFileLink = elFileView.getAttribute('data-raw-file-link');
const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet
// TODO: we should also provide the prefetched file head bytes to let the plugin decide whether to render or not
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType);
if (!plugin) return;
const renderContainer = elFileView.querySelector<HTMLElement>('.file-view-render-container');
showRenderRawFileButton(elFileView, renderContainer);
// maybe in the future multiple plugins can render the same file, so we should not assume only one plugin will render it
if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType);
});
}

View File

@ -19,7 +19,7 @@ import {initRepoIssueContentHistory} from './features/repo-issue-content.ts';
import {initStopwatch} from './features/stopwatch.ts';
import {initFindFileInRepo} from './features/repo-findfile.ts';
import {initMarkupContent} from './markup/content.ts';
import {initPdfViewer} from './render/pdf.ts';
import {initRepoFileView} from './features/file-view.ts';
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
@ -159,10 +159,11 @@ onDomReady(() => {
initUserAuthWebAuthnRegister,
initUserSettings,
initRepoDiffView,
initPdfViewer,
initColorPickers,
initOAuth2SettingsDisableCheckbox,
initRepoFileView,
]);
// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.

View File

@ -1,17 +0,0 @@
import {htmlEscape} from 'escape-goat';
import {registerGlobalInitFunc} from '../modules/observer.ts';
export async function initPdfViewer() {
registerGlobalInitFunc('initPdfViewer', async (el: HTMLInputElement) => {
const pdfobject = await import(/* webpackChunkName: "pdfobject" */'pdfobject');
const src = el.getAttribute('data-src');
const fallbackText = el.getAttribute('data-fallback-button-text');
pdfobject.embed(src, el, {
fallbackLink: htmlEscape`
<a role="button" class="ui basic button pdf-fallback-button" href="[url]">${fallbackText}</a>
`,
});
el.classList.remove('is-loading');
});
}

View File

@ -0,0 +1,10 @@
export type FileRenderPlugin = {
// unique plugin name
name: string;
// test if plugin can handle a specified file
canHandle: (filename: string, mimeType: string) => boolean;
// render file content
render: (container: HTMLElement, fileUrl: string, options?: any) => Promise<void>;
}

View File

@ -0,0 +1,60 @@
import type {FileRenderPlugin} from '../plugin.ts';
import {extname} from '../../utils.ts';
// support common 3D model file formats, use online-3d-viewer library for rendering
// eslint-disable-next-line multiline-comment-style
/* a simple text STL file example:
solid SimpleTriangle
facet normal 0 0 1
outer loop
vertex 0 0 0
vertex 1 0 0
vertex 0 1 0
endloop
endfacet
endsolid SimpleTriangle
*/
export function newRenderPlugin3DViewer(): FileRenderPlugin {
// Some extensions are text-based formats:
// .3mf .amf .brep: XML
// .fbx: XML or BINARY
// .dae .gltf: JSON
// .ifc, .igs, .iges, .stp, .step are: TEXT
// .stl .ply: TEXT or BINARY
// .obj .off .wrl: TEXT
// So we need to be able to render when the file is recognized as plaintext file by backend.
//
// It needs more logic to make it overall right (render a text 3D model automatically):
// we need to distinguish the ambiguous filename extensions.
// For example: "*.obj, *.off, *.step" might be or not be a 3D model file.
// So when it is a text file, we can't assume that "we only render it by 3D plugin",
// otherwise the end users would be impossible to view its real content when the file is not a 3D model.
const SUPPORTED_EXTENSIONS = [
'.3dm', '.3ds', '.3mf', '.amf', '.bim', '.brep',
'.dae', '.fbx', '.fcstd', '.glb', '.gltf',
'.ifc', '.igs', '.iges', '.stp', '.step',
'.stl', '.obj', '.off', '.ply', '.wrl',
];
return {
name: '3d-model-viewer',
canHandle(filename: string, _mimeType: string): boolean {
const ext = extname(filename).toLowerCase();
return SUPPORTED_EXTENSIONS.includes(ext);
},
async render(container: HTMLElement, fileUrl: string): Promise<void> {
// TODO: height and/or max-height?
const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer');
const viewer = new OV.EmbeddedViewer(container, {
backgroundColor: new OV.RGBAColor(59, 68, 76, 0),
defaultColor: new OV.RGBColor(65, 131, 196),
edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1),
});
viewer.LoadModelFromUrlList([fileUrl]);
},
};
}

View File

@ -0,0 +1,20 @@
import type {FileRenderPlugin} from '../plugin.ts';
export function newRenderPluginPdfViewer(): FileRenderPlugin {
return {
name: 'pdf-viewer',
canHandle(filename: string, _mimeType: string): boolean {
return filename.toLowerCase().endsWith('.pdf');
},
async render(container: HTMLElement, fileUrl: string): Promise<void> {
const PDFObject = await import(/* webpackChunkName: "pdfobject" */'pdfobject');
// TODO: the PDFObject library does not support dynamic height adjustment,
container.style.height = `${window.innerHeight - 100}px`;
if (!PDFObject.default.embed(fileUrl, container)) {
throw new Error('Unable to render the PDF file');
}
},
};
}