fix: Handle the merge conflicts

This commit is contained in:
Pascal Zimmermann committed 2026-05-19 16:45:28 +02:00
commit 3f27d054f8
1148 files changed
+59125 -15301

No files matched your search

+4 -5
View File
@@ -11,15 +11,14 @@ import (
"image/color"
"image/png"
_ "image/gif" // for processing gif images
_ "image/jpeg" // for processing jpeg images
"code.gitea.io/gitea/modules/avatar/identicon"
"code.gitea.io/gitea/modules/setting"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp" // for processing webp images
_ "image/gif" // for processing gif images
_ "image/jpeg" // for processing jpeg images
"golang.org/x/image/draw"
)
// DefaultAvatarSize is the target CSS pixel size for avatar generation. It is
+2 -1
View File
@@ -4,6 +4,7 @@
package dump
import (
"archive/zip"
"context"
"errors"
"fmt"
@@ -85,7 +86,7 @@ func NewDumper(ctx context.Context, format string, output io.Writer) (*Dumper, e
var comp archives.ArchiverAsync
switch format {
case "zip":
comp = archives.Zip{}
comp = archives.Zip{Compression: zip.Deflate}
case "tar":
comp = archives.Tar{}
case "tar.sz":
-7
View File
@@ -7,7 +7,6 @@ import (
"bytes"
"fmt"
"io"
"strings"
"time"
"code.gitea.io/gitea/modules/json"
@@ -110,9 +109,3 @@ func (e *Event) WriteTo(w io.Writer) (int64, error) {
return sum, err
}
func (e *Event) String() string {
buf := new(strings.Builder)
_, _ = e.WriteTo(buf)
return buf.String()
}
+1 -6
View File
@@ -65,10 +65,5 @@ func NewJwtSecretWithBase64() ([]byte, string) {
// NewSecretKey generate a new value intended to be used by SECRET_KEY.
func NewSecretKey() (string, error) {
secretKey, err := util.CryptoRandomString(64)
if err != nil {
return "", err
}
return secretKey, nil
return util.CryptoRandomString(64), nil
}
+8
View File
@@ -7,8 +7,10 @@ import (
"context"
"os"
"path/filepath"
"strings"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
@@ -39,6 +41,9 @@ func (b *catFileBatchCommand) getBatch() *catFileBatchCommunicator {
}
func (b *catFileBatchCommand) QueryContent(obj string) (*CatFileObject, BufferedReader, error) {
if strings.Contains(obj, "\n") {
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
}
_, err := b.getBatch().reqWriter.Write([]byte("contents " + obj + "\n"))
if err != nil {
return nil, nil, err
@@ -51,6 +56,9 @@ func (b *catFileBatchCommand) QueryContent(obj string) (*CatFileObject, Buffered
}
func (b *catFileBatchCommand) QueryInfo(obj string) (*CatFileObject, error) {
if strings.Contains(obj, "\n") {
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
}
_, err := b.getBatch().reqWriter.Write([]byte("info " + obj + "\n"))
if err != nil {
return nil, err
+8
View File
@@ -8,8 +8,10 @@ import (
"io"
"os"
"path/filepath"
"strings"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
@@ -50,6 +52,9 @@ func (b *catFileBatchLegacy) getBatchCheck() *catFileBatchCommunicator {
}
func (b *catFileBatchLegacy) QueryContent(obj string) (*CatFileObject, BufferedReader, error) {
if strings.Contains(obj, "\n") {
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
}
_, err := io.WriteString(b.getBatchContent().reqWriter, obj+"\n")
if err != nil {
return nil, nil, err
@@ -62,6 +67,9 @@ func (b *catFileBatchLegacy) QueryContent(obj string) (*CatFileObject, BufferedR
}
func (b *catFileBatchLegacy) QueryInfo(obj string) (*CatFileObject, error) {
if strings.Contains(obj, "\n") {
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
}
_, err := io.WriteString(b.getBatchCheck().reqWriter, obj+"\n")
if err != nil {
return nil, err
+38 -68
View File
@@ -10,6 +10,7 @@ import (
"errors"
"io"
"math"
"slices"
"strconv"
"strings"
"sync/atomic"
@@ -172,77 +173,46 @@ headerLoop:
return id, DiscardFull(rd, size-n+1)
}
// git tree files are a list:
// <mode-in-ascii> SP <fname> NUL <binary Hash>
//
// Unfortunately this 20-byte notation is somewhat in conflict to all other git tools
// Therefore we need some method to convert these binary hashes to hex hashes
// ParseCatFileTreeLine reads an entry from a tree in a cat-file --batch stream
// This carefully avoids allocations - except where fnameBuf is too small.
// It is recommended therefore to pass in an fnameBuf large enough to avoid almost all allocations
//
// Each line is composed of:
// <mode-in-ascii-dropping-initial-zeros> SP <fname> NUL <binary HASH>
//
// We don't attempt to convert the raw HASH to save a lot of time
func ParseCatFileTreeLine(objectFormat ObjectFormat, rd BufferedReader, modeBuf, fnameBuf, shaBuf []byte) (mode, fname, sha []byte, n int, err error) {
var readBytes []byte
// Read the Mode & fname
readBytes, err = rd.ReadSlice('\x00')
if err != nil {
return mode, fname, sha, n, err
}
idx := bytes.IndexByte(readBytes, ' ')
if idx < 0 {
log.Debug("missing space in readBytes ParseCatFileTreeLine: %s", readBytes)
return mode, fname, sha, n, &ErrNotExist{}
}
n += idx + 1
copy(modeBuf, readBytes[:idx])
if len(modeBuf) >= idx {
modeBuf = modeBuf[:idx]
} else {
modeBuf = append(modeBuf, readBytes[len(modeBuf):idx]...)
}
mode = modeBuf
readBytes = readBytes[idx+1:]
// Deal with the fname
copy(fnameBuf, readBytes)
if len(fnameBuf) > len(readBytes) {
fnameBuf = fnameBuf[:len(readBytes)]
} else {
fnameBuf = append(fnameBuf, readBytes[len(fnameBuf):]...)
}
for err == bufio.ErrBufferFull {
readBytes, err = rd.ReadSlice('\x00')
fnameBuf = append(fnameBuf, readBytes...)
}
n += len(fnameBuf)
if err != nil {
return mode, fname, sha, n, err
}
fnameBuf = fnameBuf[:len(fnameBuf)-1]
fname = fnameBuf
// Deal with the binary hash
idx = 0
length := objectFormat.FullLength() / 2
for idx < length {
var read int
read, err = rd.Read(shaBuf[idx:length])
n += read
if err != nil {
return mode, fname, sha, n, err
// Each entry is composed of:
// <mode-in-ascii-dropping-initial-zeros> SP <name> NUL <binary-hash>
func ParseCatFileTreeLine(objectFormat ObjectFormat, rd BufferedReader) (mode EntryMode, name string, objID ObjectID, n int, err error) {
// use the in-buffer memory as much as possible to avoid extra allocations
bufBytes, err := rd.ReadSlice('\x00')
const maxEntryInfoBytes = 1024 * 1024
if errors.Is(err, bufio.ErrBufferFull) {
bufBytes = slices.Clone(bufBytes)
for len(bufBytes) < maxEntryInfoBytes && errors.Is(err, bufio.ErrBufferFull) {
var tmp []byte
tmp, err = rd.ReadSlice('\x00')
bufBytes = append(bufBytes, tmp...)
}
idx += read
}
sha = shaBuf
return mode, fname, sha, n, err
if err != nil {
return mode, name, objID, len(bufBytes), err
}
idx := bytes.IndexByte(bufBytes, ' ')
if idx < 0 {
return mode, name, objID, len(bufBytes), errors.New("invalid CatFileTreeLine output")
}
mode = ParseEntryMode(util.UnsafeBytesToString(bufBytes[:idx]))
name = string(bufBytes[idx+1 : len(bufBytes)-1]) // trim the NUL terminator, it needs a copy because the bufBytes will be reused by the reader
if mode == EntryModeNoEntry {
return mode, name, objID, len(bufBytes), errors.New("invalid entry mode: " + string(bufBytes[:idx]))
}
switch objectFormat {
case Sha1ObjectFormat:
objID = &Sha1Hash{}
case Sha256ObjectFormat:
objID = &Sha256Hash{}
default:
panic("unsupported object format: " + objectFormat.Name())
}
readIDLen, err := io.ReadFull(rd, objID.RawValue())
return mode, name, objID, len(bufBytes) + readIDLen, err
}
func DiscardFull(rd BufferedReader, discard int64) error {
+2 -2
View File
@@ -192,13 +192,13 @@ func RunGitTests(m interface{ Run() int }) {
func runGitTests(m interface{ Run() int }) int {
gitHomePath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("git-home")
if err != nil {
testlogger.Panicf("unable to create temp dir: %s", err.Error())
return testlogger.MainErrorf("unable to create temp dir: %v", err)
}
defer cleanup()
setting.Git.HomePath = gitHomePath
if err = InitFull(); err != nil {
testlogger.Panicf("failed to call Init: %s", err.Error())
return testlogger.MainErrorf("failed to call Init: %v", err)
}
return m.Run()
}
+2 -4
View File
@@ -57,14 +57,12 @@ type Command struct {
}
func logArgSanitize(arg string) string {
if strings.Contains(arg, "://") && strings.Contains(arg, "@") {
return util.SanitizeCredentialURLs(arg)
} else if filepath.IsAbs(arg) {
if filepath.IsAbs(arg) {
base := filepath.Base(arg)
dir := filepath.Dir(arg)
return ".../" + filepath.Join(filepath.Base(dir), base)
}
return arg
return util.SanitizeCredentialURLs(arg)
}
func (c *Command) LogString() string {
+5 -2
View File
@@ -24,7 +24,7 @@ func testMain(m *testing.M) int {
// "setting.Git.HomePath" is initialized in "git" package but really used in "gitcmd" package
gitHomePath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("git-home")
if err != nil {
testlogger.Panicf("failed to create temp dir: %v", err)
return testlogger.MainErrorf("failed to create temp dir: %v", err)
}
defer cleanup()
@@ -109,7 +109,10 @@ func TestCommandString(t *testing.T) {
assert.Equal(t, cmd.prog+` a "-m msg" "it's a test" "say \"hello\""`, cmd.LogString())
cmd = NewCommand("url: https://a:b@c/", "/root/dir-a/dir-b")
assert.Equal(t, cmd.prog+` "url: https://sanitized-credential@c/" .../dir-a/dir-b`, cmd.LogString())
assert.Equal(t, cmd.prog+` "url: https://(masked)@c/" .../dir-a/dir-b`, cmd.LogString())
cmd = NewCommand("url: a:b@c/", "/root/dir-a/dir-b")
assert.Equal(t, cmd.prog+` "url: (masked)@c/" .../dir-a/dir-b`, cmd.LogString())
}
func TestRunStdError(t *testing.T) {
+8
View File
@@ -56,6 +56,14 @@ func StderrHasPrefix(err error, prefix string) bool {
return strings.HasPrefix(stderr, prefix)
}
func StderrContains(err error, sub string) bool {
stderr, ok := ErrorAsStderr(err)
if !ok {
return false
}
return strings.Contains(stderr, sub)
}
func IsErrorExitCode(err error, code int) bool {
var exitError *exec.ExitError
if errors.As(err, &exitError) {
+4 -26
View File
@@ -5,10 +5,7 @@ package git
import (
"bytes"
"fmt"
"io"
"code.gitea.io/gitea/modules/log"
)
// ParseTreeEntries parses the output of a `git ls-tree -l` command.
@@ -47,14 +44,11 @@ func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) {
}
func catBatchParseTreeEntries(objectFormat ObjectFormat, ptree *Tree, rd BufferedReader, sz int64) ([]*TreeEntry, error) {
fnameBuf := make([]byte, 4096)
modeBuf := make([]byte, 40)
shaBuf := make([]byte, objectFormat.FullLength())
entries := make([]*TreeEntry, 0, 10)
loop:
for sz > 0 {
mode, fname, sha, count, err := ParseCatFileTreeLine(objectFormat, rd, modeBuf, fnameBuf, shaBuf)
mode, fname, objID, count, err := ParseCatFileTreeLine(objectFormat, rd)
if err != nil {
if err == io.EOF {
break loop
@@ -64,25 +58,9 @@ loop:
sz -= int64(count)
entry := new(TreeEntry)
entry.ptree = ptree
switch string(mode) {
case "100644":
entry.entryMode = EntryModeBlob
case "100755":
entry.entryMode = EntryModeExec
case "120000":
entry.entryMode = EntryModeSymlink
case "160000":
entry.entryMode = EntryModeCommit
case "40000", "40755": // git uses 40000 for tree object, but some users may get 40755 for unknown reasons
entry.entryMode = EntryModeTree
default:
log.Debug("Unknown mode: %v", string(mode))
return nil, fmt.Errorf("unknown mode: %v", string(mode))
}
entry.ID = objectFormat.MustID(sha)
entry.name = string(fname)
entry.entryMode = mode
entry.ID = objID
entry.name = fname
entries = append(entries, entry)
}
if _, err := rd.Discard(1); err != nil {
+31
View File
@@ -4,6 +4,9 @@
package git
import (
"bufio"
"io"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -100,3 +103,31 @@ func TestParseTreeEntriesInvalid(t *testing.T) {
assert.Error(t, err)
assert.Empty(t, entries)
}
func TestParseCatFileTreeLine(t *testing.T) {
input := "100644 looooooooooooooooooooooooong-file-name.txt\x0012345678901234567890"
input += "40755 some-directory\x00abcdefg123abcdefg123"
var readCount int
buf := bufio.NewReaderSize(strings.NewReader(input), 20) // NewReaderSize has a limit: min buffer size = 16
mode, name, objID, n, err := ParseCatFileTreeLine(Sha1ObjectFormat, buf)
readCount += n
assert.NoError(t, err)
assert.Equal(t, EntryModeBlob, mode)
assert.Equal(t, "looooooooooooooooooooooooong-file-name.txt", name)
assert.Equal(t, "12345678901234567890", string(objID.RawValue()))
mode, name, objID, n, err = ParseCatFileTreeLine(Sha1ObjectFormat, buf)
readCount += n
assert.NoError(t, err)
assert.Equal(t, EntryModeTree, mode)
assert.Equal(t, "some-directory", name)
assert.Equal(t, "abcdefg123abcdefg123", string(objID.RawValue()))
assert.Equal(t, len(input), readCount)
_, _, _, n, err = ParseCatFileTreeLine(Sha1ObjectFormat, buf)
assert.ErrorIs(t, err, io.EOF)
assert.Zero(t, n)
}
+7 -12
View File
@@ -8,7 +8,6 @@ package pipeline
import (
"bufio"
"bytes"
"encoding/hex"
"io"
"sort"
"strings"
@@ -46,10 +45,6 @@ func findLFSFileFunc(repo *git.Repository, objectID git.ObjectID, revListReader
trees := []string{}
paths := []string{}
fnameBuf := make([]byte, 4096)
modeBuf := make([]byte, 40)
workingShaBuf := make([]byte, objectID.Type().FullLength()/2)
for scan.Scan() {
// Get the next commit ID
commitID := scan.Text()
@@ -93,23 +88,23 @@ func findLFSFileFunc(repo *git.Repository, objectID git.ObjectID, revListReader
case "tree":
var n int64
for n < info.Size {
mode, fname, binObjectID, count, err := git.ParseCatFileTreeLine(objectID.Type(), batchReader, modeBuf, fnameBuf, workingShaBuf)
mode, fname, shaID, count, err := git.ParseCatFileTreeLine(objectID.Type(), batchReader)
if err != nil {
return nil, err
}
n += int64(count)
if bytes.Equal(binObjectID, objectID.RawValue()) {
if bytes.Equal(shaID.RawValue(), objectID.RawValue()) {
result := LFSResult{
Name: curPath + string(fname),
Name: curPath + fname,
SHA: curCommit.ID.String(),
Summary: strings.Split(strings.TrimSpace(curCommit.CommitMessage), "\n")[0],
When: curCommit.Author.When,
ParentHashes: curCommit.Parents,
}
resultsMap[curCommit.ID.String()+":"+curPath+string(fname)] = &result
} else if string(mode) == git.EntryModeTree.String() {
trees = append(trees, hex.EncodeToString(binObjectID))
paths = append(paths, curPath+string(fname)+"/")
resultsMap[curCommit.ID.String()+":"+curPath+fname] = &result
} else if mode == git.EntryModeTree {
trees = append(trees, shaID.String())
paths = append(paths, curPath+fname+"/")
}
}
if _, err := batchReader.Discard(1); err != nil {
+2 -1
View File
@@ -66,9 +66,10 @@ func ParseEntryMode(mode string) EntryMode {
return EntryModeSymlink
case "160000":
return EntryModeCommit
case "040000":
case "040000", "40000": // leading-zero is optional
return EntryModeTree
default:
// if the faster path didn't work, try parsing the mode as an integer and masking off the file type bits
// git uses 040000 for tree object, but some users may get 040755 from non-standard git implementations
m, _ := strconv.ParseInt(mode, 8, 32)
modeInt := EntryMode(m)
+2
View File
@@ -46,7 +46,9 @@ func TestParseEntryMode(t *testing.T) {
{"160755", EntryModeCommit},
{"040000", EntryModeTree},
{"40000", EntryModeTree},
{"040755", EntryModeTree},
{"40755", EntryModeTree},
{"777777", EntryModeNoEntry}, // invalid mode
}
-17
View File
@@ -44,23 +44,6 @@ func CommitsCount(ctx context.Context, repo Repository, opts CommitsCountOptions
return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64)
}
// CommitsCountBetween return numbers of commits between two commits
func CommitsCountBetween(ctx context.Context, repo Repository, start, end string) (int64, error) {
count, err := CommitsCount(ctx, repo, CommitsCountOptions{
Revision: []string{start + ".." + end},
})
if err != nil && strings.Contains(err.Error(), "no merge base") {
// future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
// previously it would return the results of git rev-list before last so let's try that...
return CommitsCount(ctx, repo, CommitsCountOptions{
Revision: []string{start, end},
})
}
return count, err
}
// FileCommitsCount return the number of files at a revision
func FileCommitsCount(ctx context.Context, repo Repository, revision, file string) (int64, error) {
return CommitsCount(ctx, repo,
+32
View File
@@ -4,9 +4,15 @@
package gitrepo
import (
"path/filepath"
"strings"
"testing"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockRepository struct {
@@ -17,6 +23,32 @@ func (r *mockRepository) RelativePath() string {
return r.path
}
func TestMergeBaseNoCommonHistory(t *testing.T) {
repoDir := filepath.Join(t.TempDir(), "repo.git")
require.NoError(t, gitcmd.NewCommand("init").AddDynamicArguments(repoDir).Run(t.Context()))
_, _, runErr := gitcmd.NewCommand("fast-import").WithDir(repoDir).WithStdinBytes([]byte(strings.TrimSpace(`
commit refs/heads/branch1
committer User <user@example.com> 1714310400 +0000
data 12
First commit
M 100644 inline file1.txt
data 12
Hello from 1
commit refs/heads/branch2
committer User <user@example.com> 1714310400 +0000
data 13
Second commit
M 100644 inline file2.txt
data 12
Hello from 2
`))).RunStdString(t.Context())
require.NoError(t, runErr)
mergeBase, err := MergeBase(t.Context(), &mockRepository{path: repoDir}, "branch1", "branch2")
assert.Empty(t, mergeBase)
assert.ErrorIs(t, err, util.ErrNotExist)
}
func TestRepoGetDivergingCommits(t *testing.T) {
repo := &mockRepository{path: "repo1_bare"}
do, err := GetDivergingCommits(t.Context(), repo, "master", "branch2")
-10
View File
@@ -5,21 +5,11 @@ package gitrepo
import (
"context"
"strings"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/globallock"
)
func GitConfigGet(ctx context.Context, repo Repository, key string) (string, error) {
result, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("config", "--get").
AddDynamicArguments(key))
if err != nil {
return "", err
}
return strings.TrimSpace(result), nil
}
func getRepoConfigLockKey(repoStoragePath string) string {
return "repo-config:" + repoStoragePath
}
+5 -1
View File
@@ -9,13 +9,17 @@ import (
"strings"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/util"
)
// MergeBase checks and returns merge base of two commits.
func MergeBase(ctx context.Context, repo Repository, baseCommitID, headCommitID string) (string, error) {
mergeBase, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("merge-base").
mergeBase, stderr, err := RunCmdString(ctx, repo, gitcmd.NewCommand("merge-base").
AddDashesAndList(baseCommitID, headCommitID))
if err != nil {
if gitcmd.IsErrorExitCode(err, 1) && strings.TrimSpace(stderr) == "" {
return "", util.NewNotExistErrorf("merge-base for %s and %s doesn't exist", baseCommitID, headCommitID)
}
return "", fmt.Errorf("get merge-base of %s and %s failed: %w", baseCommitID, headCommitID, err)
}
return strings.TrimSpace(mergeBase), nil
+1
View File
@@ -1,5 +1,6 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// This code is heavily inspired by the archived gofacebook/gracenet/net.go handler
//go:build windows
+1
View File
@@ -58,6 +58,7 @@ var chromaLexers = sync.OnceValue(func() (ret struct {
".inc": "PHP", // ObjectPascal, POVRay, SourcePawn, PHTML
".m": "Objective-C", // Matlab, Mathematica, Mason
".mc": "Mason", // MonkeyC
".mod": "AMPL", // Modula-2
".network": "SYSTEMD", // INI
".php": "PHP", // PHTML
".php3": "PHP", // PHTML
-5
View File
@@ -78,11 +78,6 @@ func (hl *HostMatchList) AppendBuiltin(builtin string) {
hl.builtins = append(hl.builtins, builtin)
}
// AppendPattern appends more pattern to match
func (hl *HostMatchList) AppendPattern(pattern string) {
hl.patterns = append(hl.patterns, pattern)
}
// IsEmpty checks if the checklist is empty
func (hl *HostMatchList) IsEmpty() bool {
return hl == nil || (len(hl.builtins) == 0 && len(hl.patterns) == 0 && len(hl.ipNets) == 0)
+37 -14
View File
@@ -37,6 +37,42 @@ type ServeHeaderOptions struct {
LastModified time.Time
}
const (
// Disable JS execution on the same origin, since we serve the file from the same origin as Gitea server.
// This rule can be relaxed in the future as long as it is properly sandboxed.
// "style-src" is for SVG inline styles (from Display SVG files as images instead of text #14101)
serveHeaderCspDefault = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
// No sandbox attribute for PDF as it breaks rendering in at least Safari.
// This should generally be safe as scripts inside PDF can not escape the PDF document.
// See https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion.
// HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context
serveHeaderCspPdf = "default-src 'none'; style-src 'unsafe-inline'"
// For audios and videos, actually it doesn't really need CSP (just like Gitea <= 1.25)
serveHeaderCspAudioVideo = ""
)
func serveSetHeaderContentRelated(w http.ResponseWriter, contentType string) {
header := w.Header()
contentType = util.IfZero(contentType, typesniffer.MimeTypeApplicationOctetStream)
header.Set("Content-Type", contentType)
header.Set("X-Content-Type-Options", "nosniff")
csp := serveHeaderCspDefault
if strings.HasPrefix(contentType, "application/pdf") {
csp = serveHeaderCspPdf
}
if strings.HasPrefix(contentType, "video/") || strings.HasPrefix(contentType, "audio/") {
csp = serveHeaderCspAudioVideo
}
if csp != "" {
header.Set("Content-Security-Policy", csp)
} else {
header.Del("Content-Security-Policy")
}
}
// ServeSetHeaders sets necessary content serve headers
func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) {
header := w.Header()
@@ -46,24 +82,11 @@ func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) {
w.Header().Add(gzhttp.HeaderNoCompression, "1")
}
contentType := util.IfZero(opts.ContentType, typesniffer.MimeTypeApplicationOctetStream)
header.Set("Content-Type", contentType)
header.Set("X-Content-Type-Options", "nosniff")
serveSetHeaderContentRelated(w, opts.ContentType)
if opts.ContentLength != nil {
header.Set("Content-Length", strconv.FormatInt(*opts.ContentLength, 10))
}
// Disable script execution of HTML/SVG files, since we serve the file from the same origin as Gitea server
header.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
if strings.Contains(contentType, "application/pdf") {
// no sandbox attribute for PDF as it breaks rendering in at least safari. this
// should generally be safe as scripts inside PDF can not escape the PDF document
// see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion
// HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context
header.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
}
if opts.Filename != "" && opts.ContentDisposition != "" {
header.Set("Content-Disposition", encodeContentDisposition(opts.ContentDisposition, path.Base(opts.Filename)))
header.Set("Access-Control-Expose-Headers", "Content-Disposition")
+27
View File
@@ -12,6 +12,8 @@ import (
"strings"
"testing"
"code.gitea.io/gitea/modules/typesniffer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -106,3 +108,28 @@ func TestServeUserContentByFile(t *testing.T) {
test(t, http.StatusPartialContent, data[1:])
})
}
func TestServeSetHeaderContentRelated(t *testing.T) {
cases := []struct {
contentType string
csp string
}{
{"", serveHeaderCspDefault},
{"any", serveHeaderCspDefault},
{"application/pdf", serveHeaderCspPdf},
{"application/pdf; other", serveHeaderCspPdf},
{"audio/mp4", serveHeaderCspAudioVideo},
{"video/ogg; other", serveHeaderCspAudioVideo},
{typesniffer.MimeTypeImageSvg, serveHeaderCspDefault},
}
for _, c := range cases {
w := httptest.NewRecorder()
serveSetHeaderContentRelated(w, c.contentType)
csp := w.Header().Get("Content-Security-Policy")
assert.Equal(t, c.csp, csp, "content-type: %s", c.contentType)
assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options")) // it should always be there
}
// make sure sandboxed
require.Contains(t, serveHeaderCspDefault, "; sandbox")
}
@@ -18,8 +18,7 @@ import (
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/indexer"
"code.gitea.io/gitea/modules/indexer/code/internal"
indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
inner_elasticsearch "code.gitea.io/gitea/modules/indexer/internal/elasticsearch"
es "code.gitea.io/gitea/modules/indexer/internal/elasticsearch"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
@@ -28,23 +27,15 @@ import (
"code.gitea.io/gitea/modules/util"
"github.com/go-enry/go-enry/v2"
"github.com/olivere/elastic/v7"
)
const (
esRepoIndexerLatestVersion = 3
// multi-match-types, currently only 2 types are used
// Reference: https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-dsl-multi-match-query.html#multi-match-types
esMultiMatchTypeBestFields = "best_fields"
esMultiMatchTypePhrasePrefix = "phrase_prefix"
)
const esRepoIndexerLatestVersion = 3
var _ internal.Indexer = &Indexer{}
// Indexer implements Indexer interface
type Indexer struct {
inner *inner_elasticsearch.Indexer
indexer_internal.Indexer // do not composite inner_elasticsearch.Indexer directly to avoid exposing too much
*es.Indexer
}
func (b *Indexer) SupportedSearchModes() []indexer.SearchMode {
@@ -53,12 +44,7 @@ func (b *Indexer) SupportedSearchModes() []indexer.SearchMode {
// NewIndexer creates a new elasticsearch indexer
func NewIndexer(url, indexerName string) *Indexer {
inner := inner_elasticsearch.NewIndexer(url, indexerName, esRepoIndexerLatestVersion, defaultMapping)
indexer := &Indexer{
inner: inner,
Indexer: inner,
}
return indexer
return &Indexer{Indexer: es.NewIndexer(url, indexerName, esRepoIndexerLatestVersion, defaultMapping)}
}
const (
@@ -138,7 +124,7 @@ const (
}`
)
func (b *Indexer) addUpdate(ctx context.Context, catFileBatch git.CatFileBatch, sha string, update internal.FileUpdate, repo *repo_model.Repository) ([]elastic.BulkableRequest, error) {
func (b *Indexer) addUpdate(ctx context.Context, catFileBatch git.CatFileBatch, sha string, update internal.FileUpdate, repo *repo_model.Repository) ([]es.BulkOp, error) {
// Ignore vendored files in code search
if setting.Indexer.ExcludeVendored && analyze.IsVendor(update.Filename) {
return nil, nil
@@ -157,8 +143,9 @@ func (b *Indexer) addUpdate(ctx context.Context, catFileBatch git.CatFileBatch,
}
}
id := internal.FilenameIndexerID(repo.ID, update.Filename)
if size > setting.Indexer.MaxIndexerFileSize {
return []elastic.BulkableRequest{b.addDelete(update.Filename, repo)}, nil
return []es.BulkOp{es.DeleteOp(id)}, nil
}
info, batchReader, err := catFileBatch.QueryContent(update.BlobSha)
@@ -177,33 +164,24 @@ func (b *Indexer) addUpdate(ctx context.Context, catFileBatch git.CatFileBatch,
if _, err = batchReader.Discard(1); err != nil {
return nil, err
}
id := internal.FilenameIndexerID(repo.ID, update.Filename)
return []elastic.BulkableRequest{
elastic.NewBulkIndexRequest().
Index(b.inner.VersionedIndexName()).
Id(id).
Doc(map[string]any{
"repo_id": repo.ID,
"filename": update.Filename,
"content": string(charset.ToUTF8DropErrors(fileContents)),
"commit_id": sha,
"language": analyze.GetCodeLanguage(update.Filename, fileContents),
"updated_at": timeutil.TimeStampNow(),
}),
}, nil
return []es.BulkOp{es.IndexOp(id, map[string]any{
"repo_id": repo.ID,
"filename": update.Filename,
"content": string(charset.ToUTF8DropErrors(fileContents)),
"commit_id": sha,
"language": analyze.GetCodeLanguage(update.Filename, fileContents),
"updated_at": timeutil.TimeStampNow(),
})}, nil
}
func (b *Indexer) addDelete(filename string, repo *repo_model.Repository) elastic.BulkableRequest {
id := internal.FilenameIndexerID(repo.ID, filename)
return elastic.NewBulkDeleteRequest().
Index(b.inner.VersionedIndexName()).
Id(id)
func (b *Indexer) addDelete(filename string, repo *repo_model.Repository) es.BulkOp {
return es.DeleteOp(internal.FilenameIndexerID(repo.ID, filename))
}
// Index will save the index data
func (b *Indexer) Index(ctx context.Context, repo *repo_model.Repository, sha string, changes *internal.RepoChanges) error {
reqs := make([]elastic.BulkableRequest, 0)
ops := make([]es.BulkOp, 0)
if len(changes.Updates) > 0 {
batch, err := gitrepo.NewBatch(ctx, repo)
if err != nil {
@@ -212,29 +190,25 @@ func (b *Indexer) Index(ctx context.Context, repo *repo_model.Repository, sha st
defer batch.Close()
for _, update := range changes.Updates {
updateReqs, err := b.addUpdate(ctx, batch, sha, update, repo)
updateOps, err := b.addUpdate(ctx, batch, sha, update, repo)
if err != nil {
return err
}
if len(updateReqs) > 0 {
reqs = append(reqs, updateReqs...)
if len(updateOps) > 0 {
ops = append(ops, updateOps...)
}
}
}
for _, filename := range changes.RemovedFilenames {
reqs = append(reqs, b.addDelete(filename, repo))
ops = append(ops, b.addDelete(filename, repo))
}
if len(reqs) > 0 {
if len(ops) > 0 {
esBatchSize := 50
for i := 0; i < len(reqs); i += esBatchSize {
_, err := b.inner.Client.Bulk().
Index(b.inner.VersionedIndexName()).
Add(reqs[i:min(i+esBatchSize, len(reqs))]...).
Do(ctx)
if err != nil {
for i := 0; i < len(ops); i += esBatchSize {
if err := b.Bulk(ctx, ops[i:min(i+esBatchSize, len(ops))]); err != nil {
return err
}
}
@@ -246,33 +220,21 @@ func (b *Indexer) Index(ctx context.Context, repo *repo_model.Repository, sha st
func (b *Indexer) Delete(ctx context.Context, repoID int64) error {
if err := b.doDelete(ctx, repoID); err != nil {
// Maybe there is a conflict during the delete operation, so we should retry after a refresh
log.Warn("Deletion of entries of repo %v within index %v was erroneous. Trying to refresh index before trying again", repoID, b.inner.VersionedIndexName(), err)
if err := b.refreshIndex(ctx); err != nil {
log.Warn("Deletion of entries of repo %v within index %v was erroneous: %v. Trying to refresh index before trying again", repoID, b.VersionedIndexName(), err)
if err := b.Refresh(ctx); err != nil {
return err
}
if err := b.doDelete(ctx, repoID); err != nil {
log.Error("Could not delete entries of repo %v within index %v", repoID, b.inner.VersionedIndexName())
log.Error("Could not delete entries of repo %v within index %v", repoID, b.VersionedIndexName())
return err
}
}
return nil
}
func (b *Indexer) refreshIndex(ctx context.Context) error {
if _, err := b.inner.Client.Refresh(b.inner.VersionedIndexName()).Do(ctx); err != nil {
log.Error("Error while trying to refresh index %v", b.inner.VersionedIndexName(), err)
return err
}
return nil
}
// Delete entries by repoId
func (b *Indexer) doDelete(ctx context.Context, repoID int64) error {
_, err := b.inner.Client.DeleteByQuery(b.inner.VersionedIndexName()).
Query(elastic.NewTermsQuery("repo_id", repoID)).
Do(ctx)
return err
return b.DeleteByQuery(ctx, es.TermsQuery("repo_id", repoID))
}
// contentMatchIndexPos find words positions for start and the following end on content. It will
@@ -291,10 +253,10 @@ func contentMatchIndexPos(content, start, end string) (int, int) {
return startIdx, (startIdx + len(start) + endIdx + len(end)) - 9 // remove the length <em></em> since we give Content the original data
}
func convertResult(searchResult *elastic.SearchResult, kw string, pageSize int) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
func convertResult(searchResult *es.SearchResponse, kw string, pageSize int) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
hits := make([]*internal.SearchResult, 0, pageSize)
for _, hit := range searchResult.Hits.Hits {
repoID, fileName := internal.ParseIndexerID(hit.Id)
for _, hit := range searchResult.Hits {
repoID, fileName := internal.ParseIndexerID(hit.ID)
res := make(map[string]any)
if err := json.Unmarshal(hit.Source, &res); err != nil {
return 0, nil, nil, err
@@ -333,111 +295,111 @@ func convertResult(searchResult *elastic.SearchResult, kw string, pageSize int)
})
}
return searchResult.TotalHits(), hits, extractAggs(searchResult), nil
return searchResult.Total, hits, extractAggs(searchResult), nil
}
func extractAggs(searchResult *elastic.SearchResult) []*internal.SearchResultLanguages {
var searchResultLanguages []*internal.SearchResultLanguages
agg, found := searchResult.Aggregations.Terms("language")
if found {
searchResultLanguages = make([]*internal.SearchResultLanguages, 0, 10)
for _, bucket := range agg.Buckets {
searchResultLanguages = append(searchResultLanguages, &internal.SearchResultLanguages{
Language: bucket.Key.(string),
Color: enry.GetColor(bucket.Key.(string)),
Count: int(bucket.DocCount),
})
func extractAggs(searchResult *es.SearchResponse) []*internal.SearchResultLanguages {
buckets, found := searchResult.Aggregations["language"]
if !found {
return nil
}
searchResultLanguages := make([]*internal.SearchResultLanguages, 0, 10)
for _, bucket := range buckets {
// language is mapped as keyword so the key is always a string; if the
// mapping ever changes, skip rather than emit an empty-language bucket.
key, ok := bucket.Key.(string)
if !ok {
continue
}
searchResultLanguages = append(searchResultLanguages, &internal.SearchResultLanguages{
Language: key,
Color: enry.GetColor(key),
Count: int(bucket.DocCount),
})
}
return searchResultLanguages
}
// Search searches for codes and language stats by given conditions.
func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
var contentQuery elastic.Query
searchMode := util.IfZero(opts.SearchMode, b.SupportedSearchModes()[0].ModeValue)
contentQuery := es.Query(es.NewMultiMatchQuery(opts.Keyword, "content").Type(es.MultiMatchTypeBestFields).Operator("and"))
if searchMode == indexer.SearchModeExact {
// 1.21 used NewMultiMatchQuery().Type(esMultiMatchTypePhrasePrefix), but later releases changed to NewMatchPhraseQuery
contentQuery = elastic.NewMatchPhraseQuery("content", opts.Keyword)
} else /* words */ {
contentQuery = elastic.NewMultiMatchQuery("content", opts.Keyword).Type(esMultiMatchTypeBestFields).Operator("and")
contentQuery = es.MatchPhraseQuery("content", opts.Keyword)
}
kwQuery := elastic.NewBoolQuery().Should(
kwQuery := es.NewBoolQuery().Should(
contentQuery,
elastic.NewMultiMatchQuery(opts.Keyword, "filename^10").Type(esMultiMatchTypePhrasePrefix),
es.NewMultiMatchQuery(opts.Keyword, "filename^10").Type(es.MultiMatchTypePhrasePrefix),
)
query := elastic.NewBoolQuery()
query = query.Must(kwQuery)
query := es.NewBoolQuery().Must(kwQuery)
if len(opts.RepoIDs) > 0 {
repoStrs := make([]any, 0, len(opts.RepoIDs))
for _, repoID := range opts.RepoIDs {
repoStrs = append(repoStrs, repoID)
}
repoQuery := elastic.NewTermsQuery("repo_id", repoStrs...)
query = query.Must(repoQuery)
query.Must(es.TermsQuery("repo_id", es.ToAnySlice(opts.RepoIDs)...))
}
var (
start, pageSize = opts.GetSkipTake()
kw = "<em>" + opts.Keyword + "</em>"
aggregation = elastic.NewTermsAggregation().Field("language").Size(10).OrderByCountDesc()
)
start, pageSize := opts.GetSkipTake()
kw := "<em>" + opts.Keyword + "</em>"
languageAggs := map[string]any{
"language": map[string]any{
"terms": map[string]any{
"field": "language",
"size": 10,
"order": map[string]any{"_count": "desc"},
},
},
}
// number_of_fragments=0 returns the full highlighted content (no fragmentation).
highlight := map[string]any{
"fields": map[string]any{
"content": map[string]any{},
"filename": map[string]any{},
},
"number_of_fragments": 0,
"type": "fvh",
}
sort := []es.SortField{
{Field: "_score", Desc: true},
{Field: "updated_at", Desc: false},
}
if len(opts.Language) == 0 {
searchResult, err := b.inner.Client.Search().
Index(b.inner.VersionedIndexName()).
Aggregation("language", aggregation).
Query(query).
Highlight(
elastic.NewHighlight().
Field("content").
Field("filename").
NumOfFragments(0). // return all highlighting content on fragments
HighlighterType("fvh"),
).
Sort("_score", false).
Sort("updated_at", true).
From(start).Size(pageSize).
Do(ctx)
resp, err := b.Indexer.Search(ctx, es.SearchRequest{
Query: query,
Sort: sort,
From: start,
Size: pageSize,
TrackTotal: true,
Aggregations: languageAggs,
Highlight: highlight,
})
if err != nil {
return 0, nil, nil, err
}
return convertResult(searchResult, kw, pageSize)
return convertResult(resp, kw, pageSize)
}
langQuery := elastic.NewMatchQuery("language", opts.Language)
countResult, err := b.inner.Client.Search().
Index(b.inner.VersionedIndexName()).
Aggregation("language", aggregation).
Query(query).
Size(0). // We only need stats information
Do(ctx)
countResp, err := b.Indexer.Search(ctx, es.SearchRequest{
Query: query,
Size: 0, // stats only
TrackTotal: true,
Aggregations: languageAggs,
})
if err != nil {
return 0, nil, nil, err
}
query = query.Must(langQuery)
searchResult, err := b.inner.Client.Search().
Index(b.inner.VersionedIndexName()).
Query(query).
Highlight(
elastic.NewHighlight().
Field("content").
Field("filename").
NumOfFragments(0). // return all highlighting content on fragments
HighlighterType("fvh"),
).
Sort("_score", false).
Sort("updated_at", true).
From(start).Size(pageSize).
Do(ctx)
query.Must(es.MatchQuery("language", opts.Language))
resp, err := b.Indexer.Search(ctx, es.SearchRequest{
Query: query,
Sort: sort,
From: start,
Size: pageSize,
TrackTotal: true,
Highlight: highlight,
})
if err != nil {
return 0, nil, nil, err
}
total, hits, _, err := convertResult(searchResult, kw, pageSize)
return total, hits, extractAggs(countResult), err
total, hits, _, err := convertResult(resp, kw, pageSize)
return total, hits, extractAggs(countResp), err
}
+11
View File
@@ -8,6 +8,7 @@ import (
"os"
"slices"
"testing"
"time"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
@@ -39,6 +40,16 @@ func TestMain(m *testing.M) {
func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
t.Run(name, func(t *testing.T) {
assert.NoError(t, setupRepositoryIndexes(t.Context(), indexer))
// Wait for the index to catch up: ES/OpenSearch make writes visible
// only after a refresh (default interval: 1s). Bleve is synchronous
// and passes on the first iteration.
require.Eventually(t, func() bool {
total, _, _, err := indexer.Search(t.Context(), &internal.SearchOptions{
Keyword: "Description",
Paginator: &db.ListOptions{Page: 1, PageSize: 1},
})
return err == nil && total > 0
}, 10*time.Second, 100*time.Millisecond, "index did not become searchable")
keywords := []struct {
RepoIDs []int64
+352 -37
View File
@@ -4,52 +4,80 @@
package elasticsearch
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"code.gitea.io/gitea/modules/indexer/internal"
"github.com/olivere/elastic/v7"
"code.gitea.io/gitea/modules/json"
)
var _ internal.Indexer = &Indexer{}
// Indexer represents a basic elasticsearch indexer implementation
// Indexer is a narrow wrapper around an Elasticsearch/OpenSearch cluster.
// It targets the REST subset shared by Elasticsearch 7/8/9 and OpenSearch 3.
type Indexer struct {
Client *elastic.Client
client *http.Client
base string // base URL with trailing slash, no userinfo
user string
pass string
url string
indexName string
version int
mapping string
}
func NewIndexer(url, indexName string, version int, mapping string) *Indexer {
// NewIndexer builds an Indexer. The connection is opened by Init.
func NewIndexer(rawURL, indexName string, version int, mapping string) *Indexer {
return &Indexer{
url: url,
base: rawURL,
indexName: indexName,
version: version,
mapping: mapping,
}
}
// Init initializes the indexer
// Init connects and creates the versioned index if missing, returning true if it already existed.
func (i *Indexer) Init(ctx context.Context) (bool, error) {
if i == nil {
return false, errors.New("cannot init nil indexer")
}
if i.Client != nil {
return false, errors.New("indexer is already initialized")
}
client, err := i.initClient()
parsed, err := url.Parse(i.base)
if err != nil {
return false, err
return false, fmt.Errorf("parse elasticsearch url: %w", err)
}
if parsed.User != nil {
i.user = parsed.User.Username()
i.pass, _ = parsed.User.Password()
parsed.User = nil
}
base := parsed.String()
if !strings.HasSuffix(base, "/") {
base += "/"
}
i.base = base
// No client-level Timeout: bulk/_delete_by_query can legitimately run for
// minutes on large repos. Per-request deadlines come from the caller's ctx;
// transport-level timeouts cover stalled connects/handshakes/headers so a
// half-open server cannot wedge the indexer indefinitely.
i.client = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
IdleConnTimeout: 90 * time.Second,
MaxIdleConns: 100,
},
}
i.Client = client
exists, err := i.Client.IndexExists(i.VersionedIndexName()).Do(ctx)
exists, err := i.indexExists(ctx, i.VersionedIndexName())
if err != nil {
return false, err
}
@@ -61,34 +89,321 @@ func (i *Indexer) Init(ctx context.Context) (bool, error) {
return false, err
}
return exists, nil
return false, nil
}
// Ping checks if the indexer is available
// Ping returns an error when the cluster is unusable (status != green/yellow).
func (i *Indexer) Ping(ctx context.Context) error {
if i == nil {
return errors.New("cannot ping nil indexer")
var body struct {
Status string `json:"status"`
}
if i.Client == nil {
return errors.New("indexer is not initialized")
}
resp, err := i.Client.ClusterHealth().Do(ctx)
if err != nil {
if err := i.doJSON(ctx, http.MethodGet, "_cluster/health", nil, &body); err != nil {
return err
}
if resp.Status != "green" && resp.Status != "yellow" {
// It's healthy if the status is green, and it's available if the status is yellow,
// see https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-health.html
return fmt.Errorf("status of elasticsearch cluster is %s", resp.Status)
// Healthy = green; usable = yellow. Red is unusable.
// https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-health.html
if body.Status != "green" && body.Status != "yellow" {
return fmt.Errorf("status of elasticsearch cluster is %s", body.Status)
}
return nil
}
// Close closes the indexer
// Close releases idle HTTP connections held by the client.
func (i *Indexer) Close() {
if i == nil {
if i == nil || i.client == nil {
return
}
i.Client = nil
i.client.CloseIdleConnections()
i.client = nil
}
// Bulk submits index/delete ops. Returns the first item-level failure, if any.
func (i *Indexer) Bulk(ctx context.Context, ops []BulkOp) error {
if len(ops) == 0 {
return nil
}
index := i.VersionedIndexName()
var buf bytes.Buffer
buf.Grow(len(ops) * 256)
for _, op := range ops {
meta := map[string]any{op.action: map[string]any{"_index": index, "_id": op.id}}
if err := writeJSONLine(&buf, meta); err != nil {
return err
}
if op.action == bulkActionIndex {
if err := writeJSONLine(&buf, op.doc); err != nil {
return err
}
}
}
res, err := i.do(ctx, http.MethodPost, urlPath(index, "_bulk"), "application/x-ndjson", bytes.NewReader(buf.Bytes()))
if err != nil {
return err
}
defer drainAndClose(res)
var body struct {
Errors bool `json:"errors"`
Items []map[string]struct {
Status int `json:"status"`
Error json.Value `json:"error"`
} `json:"items"`
}
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
return err
}
if !body.Errors {
return nil
}
return firstBulkError(body.Items)
}
// firstBulkError returns the first item-level failure in a bulk response.
// Each items entry is a single-key map ({"index": {...}} or {"delete": {...}}).
// Delete-of-missing (404) is idempotent and not reported.
func firstBulkError(items []map[string]struct {
Status int `json:"status"`
Error json.Value `json:"error"`
},
) error {
for _, item := range items {
for action, result := range item {
if action == bulkActionDelete && result.Status == http.StatusNotFound {
continue
}
if result.Status >= 300 {
return fmt.Errorf("bulk %s failed (status %d): %s", action, result.Status, string(result.Error))
}
}
}
return nil
}
// Index writes a single document.
func (i *Indexer) Index(ctx context.Context, id string, doc any) error {
body, err := json.Marshal(doc)
if err != nil {
return err
}
return i.doJSON(ctx, http.MethodPut, urlPath(i.VersionedIndexName(), "_doc", id), bytes.NewReader(body), nil)
}
// Delete removes a single document by id. Missing ids are not an error.
func (i *Indexer) Delete(ctx context.Context, id string) error {
res, err := i.do(ctx, http.MethodDelete, urlPath(i.VersionedIndexName(), "_doc", id), "", nil, http.StatusNotFound)
if err != nil {
return err
}
drainAndClose(res)
return nil
}
// DeleteByQuery removes every document matching the query.
func (i *Indexer) DeleteByQuery(ctx context.Context, query Query) error {
body, err := json.Marshal(map[string]any{"query": query.querySource()})
if err != nil {
return err
}
return i.doJSON(ctx, http.MethodPost, urlPath(i.VersionedIndexName(), "_delete_by_query"), bytes.NewReader(body), nil)
}
// Refresh forces a refresh so recent writes are searchable.
func (i *Indexer) Refresh(ctx context.Context) error {
return i.doJSON(ctx, http.MethodPost, urlPath(i.VersionedIndexName(), "_refresh"), nil, nil)
}
// Search runs a search request and decodes the reply.
func (i *Indexer) Search(ctx context.Context, req SearchRequest) (*SearchResponse, error) {
body := map[string]any{}
if req.Query != nil {
body["query"] = req.Query.querySource()
}
if len(req.Sort) > 0 {
sorts := make([]map[string]any, len(req.Sort))
for idx, s := range req.Sort {
sorts[idx] = s.source()
}
body["sort"] = sorts
}
if req.From > 0 {
body["from"] = req.From
}
body["size"] = req.Size
if len(req.Aggregations) > 0 {
body["aggs"] = req.Aggregations
}
if len(req.Highlight) > 0 {
body["highlight"] = req.Highlight
}
payload, err := json.Marshal(body)
if err != nil {
return nil, err
}
// Default track_total_hits is 10000 (capped count); send it explicitly so
// callers can choose between exact totals (true) and skipping counting (false).
path := urlPath(i.VersionedIndexName(), "_search") + "?track_total_hits=" + strconv.FormatBool(req.TrackTotal)
res, err := i.do(ctx, http.MethodPost, path, "application/json", bytes.NewReader(payload))
if err != nil {
return nil, err
}
defer drainAndClose(res)
return decodeSearchResponse(res.Body)
}
func (i *Indexer) indexExists(ctx context.Context, name string) (bool, error) {
res, err := i.do(ctx, http.MethodHead, urlPath(name), "", nil, http.StatusNotFound)
if err != nil {
return false, err
}
drainAndClose(res)
return res.StatusCode == http.StatusOK, nil
}
func (i *Indexer) createIndex(ctx context.Context) error {
var body struct {
Acknowledged bool `json:"acknowledged"`
}
if err := i.doJSON(ctx, http.MethodPut, urlPath(i.VersionedIndexName()), bytes.NewBufferString(i.mapping), &body); err != nil {
return fmt.Errorf("create index %s: %w", i.VersionedIndexName(), err)
}
if !body.Acknowledged {
return fmt.Errorf("create index %s not acknowledged", i.VersionedIndexName())
}
i.checkOldIndexes(ctx)
return nil
}
// do sends a request and returns the response. Status >= 300 is turned into
// an error unless the status appears in okStatus. The caller closes Body.
func (i *Indexer) do(ctx context.Context, method, path, contentType string, body io.Reader, okStatus ...int) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, i.base+path, body)
if err != nil {
return nil, err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if i.user != "" || i.pass != "" {
req.SetBasicAuth(i.user, i.pass)
}
res, err := i.client.Do(req)
if err != nil {
return nil, err
}
if res.StatusCode >= 300 && !slices.Contains(okStatus, res.StatusCode) {
msg := readErrBody(res)
res.Body.Close()
return nil, fmt.Errorf("%s %s: %s", method, path, msg)
}
return res, nil
}
// doJSON sends a request with a JSON body and, when out is non-nil, decodes
// the JSON response into it.
func (i *Indexer) doJSON(ctx context.Context, method, path string, body io.Reader, out any) error {
contentType := ""
if body != nil {
contentType = "application/json"
}
res, err := i.do(ctx, method, path, contentType, body)
if err != nil {
return err
}
defer drainAndClose(res)
if out == nil {
return nil
}
return json.NewDecoder(res.Body).Decode(out)
}
// drainAndClose discards any unread response body before closing so the
// underlying TCP connection can be reused for keep-alive.
func drainAndClose(res *http.Response) {
_, _ = io.Copy(io.Discard, res.Body)
res.Body.Close()
}
func writeJSONLine(buf *bytes.Buffer, v any) error {
enc, err := json.Marshal(v)
if err != nil {
return err
}
buf.Write(enc)
buf.WriteByte('\n')
return nil
}
// readErrBody reads up to 4 KiB of an error response and drains the rest so
// the underlying connection can be reused (keep-alive needs Body fully read).
func readErrBody(res *http.Response) string {
const limit = 4 << 10
b, _ := io.ReadAll(io.LimitReader(res.Body, limit))
_, _ = io.Copy(io.Discard, res.Body)
return fmt.Sprintf("status %d: %s", res.StatusCode, bytes.TrimSpace(b))
}
func decodeSearchResponse(r io.Reader) (*SearchResponse, error) {
var raw struct {
Hits struct {
Total struct {
Value int64 `json:"value"`
} `json:"total"`
Hits []struct {
ID string `json:"_id"`
Score float64 `json:"_score"`
Source json.Value `json:"_source"`
Highlight map[string][]string `json:"highlight"`
} `json:"hits"`
} `json:"hits"`
Aggregations map[string]struct {
Buckets []struct {
Key any `json:"key"`
DocCount int64 `json:"doc_count"`
} `json:"buckets"`
} `json:"aggregations"`
}
if err := json.NewDecoder(r).Decode(&raw); err != nil {
return nil, err
}
resp := &SearchResponse{
Total: raw.Hits.Total.Value,
Hits: make([]SearchHit, 0, len(raw.Hits.Hits)),
}
for _, h := range raw.Hits.Hits {
resp.Hits = append(resp.Hits, SearchHit{
ID: h.ID,
Score: h.Score,
Source: h.Source,
Highlight: h.Highlight,
})
}
if len(raw.Aggregations) > 0 {
resp.Aggregations = make(map[string][]AggBucket, len(raw.Aggregations))
for name, agg := range raw.Aggregations {
buckets := make([]AggBucket, len(agg.Buckets))
for idx, b := range agg.Buckets {
buckets[idx] = AggBucket{Key: b.Key, DocCount: b.DocCount}
}
resp.Aggregations[name] = buckets
}
}
return resp, nil
}
// urlPath joins path segments with `/` and percent-escapes each.
func urlPath(segments ...string) string {
var b bytes.Buffer
for idx, s := range segments {
if idx > 0 {
b.WriteByte('/')
}
b.WriteString(url.PathEscape(s))
}
return b.String()
}
@@ -0,0 +1,44 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package elasticsearch
import (
"os"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func newRealIndexer(t *testing.T) *Indexer {
t.Helper()
url := "http://elasticsearch:9200"
if os.Getenv("CI") == "" {
url = os.Getenv("TEST_ELASTICSEARCH_URL")
if url == "" {
t.Skip("TEST_ELASTICSEARCH_URL not set and not running in CI")
}
}
indexName := "gitea_test_" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "_")
ix := NewIndexer(url, indexName, 1, `{"mappings":{"properties":{"x":{"type":"keyword"}}}}`)
_, err := ix.Init(t.Context())
require.NoError(t, err)
t.Cleanup(ix.Close)
return ix
}
func TestPing(t *testing.T) {
ix := newRealIndexer(t)
require.NoError(t, ix.Ping(t.Context()))
}
func TestDeleteSwallows404(t *testing.T) {
ix := newRealIndexer(t)
require.NoError(t, ix.Delete(t.Context(), "missing-id"))
}
func TestBulkAcceptsDelete404(t *testing.T) {
ix := newRealIndexer(t)
require.NoError(t, ix.Bulk(t.Context(), []BulkOp{DeleteOp("missing-id")}))
}
@@ -0,0 +1,132 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package elasticsearch
// MultiMatch types used by the call sites. See
// https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html#multi-match-types
const (
MultiMatchTypeBestFields = "best_fields"
MultiMatchTypePhrasePrefix = "phrase_prefix"
)
// ToAnySlice converts []T to []any for variadic query args like TermsQuery.
func ToAnySlice[T any](s []T) []any {
out := make([]any, len(s))
for idx, v := range s {
out[idx] = v
}
return out
}
// Query is an Elasticsearch query DSL node. It marshals to the JSON
// object expected by the ES query API.
type Query interface {
querySource() map[string]any
}
type rawQuery map[string]any
func (q rawQuery) querySource() map[string]any { return q }
// TermQuery matches documents whose `field` exactly equals `value`.
func TermQuery(field string, value any) Query {
return rawQuery{"term": map[string]any{field: value}}
}
// TermsQuery matches documents whose `field` equals any of `values`.
func TermsQuery(field string, values ...any) Query {
return rawQuery{"terms": map[string]any{field: values}}
}
// MatchQuery is a full-text match on a single field.
func MatchQuery(field string, value any) Query {
return rawQuery{"match": map[string]any{field: value}}
}
// MatchPhraseQuery matches the exact phrase on `field`.
func MatchPhraseQuery(field, value string) Query {
return rawQuery{"match_phrase": map[string]any{field: value}}
}
// MultiMatchQuery is the fluent builder for a multi_match query.
type MultiMatchQuery struct {
query any
fields []string
typ string
operator string
}
// NewMultiMatchQuery creates a multi_match query over the given fields.
func NewMultiMatchQuery(query any, fields ...string) *MultiMatchQuery {
return &MultiMatchQuery{query: query, fields: fields}
}
func (m *MultiMatchQuery) Type(t string) *MultiMatchQuery { m.typ = t; return m }
func (m *MultiMatchQuery) Operator(op string) *MultiMatchQuery { m.operator = op; return m }
func (m *MultiMatchQuery) querySource() map[string]any {
body := map[string]any{"query": m.query}
if len(m.fields) > 0 {
body["fields"] = m.fields
}
if m.typ != "" {
body["type"] = m.typ
}
if m.operator != "" {
body["operator"] = m.operator
}
return map[string]any{"multi_match": body}
}
// RangeQuery is the fluent builder for a range query.
type RangeQuery struct {
field string
body map[string]any
}
func NewRangeQuery(field string) *RangeQuery {
return &RangeQuery{field: field, body: map[string]any{}}
}
func (r *RangeQuery) Gte(v any) *RangeQuery { r.body["gte"] = v; return r }
func (r *RangeQuery) Lte(v any) *RangeQuery { r.body["lte"] = v; return r }
func (r *RangeQuery) querySource() map[string]any {
return map[string]any{"range": map[string]any{r.field: r.body}}
}
// BoolQuery is the fluent builder for a bool query.
type BoolQuery struct {
must []Query
should []Query
mustNot []Query
}
func NewBoolQuery() *BoolQuery { return &BoolQuery{} }
func (b *BoolQuery) Must(q ...Query) *BoolQuery { b.must = append(b.must, q...); return b }
func (b *BoolQuery) Should(q ...Query) *BoolQuery { b.should = append(b.should, q...); return b }
func (b *BoolQuery) MustNot(q ...Query) *BoolQuery { b.mustNot = append(b.mustNot, q...); return b }
func (b *BoolQuery) querySource() map[string]any {
body := map[string]any{}
if len(b.must) > 0 {
body["must"] = querySlice(b.must)
}
if len(b.should) > 0 {
body["should"] = querySlice(b.should)
}
if len(b.mustNot) > 0 {
body["must_not"] = querySlice(b.mustNot)
}
return map[string]any{"bool": body}
}
func querySlice(queries []Query) []map[string]any {
out := make([]map[string]any, len(queries))
for idx, q := range queries {
out[idx] = q.querySource()
}
return out
}
@@ -0,0 +1,76 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package elasticsearch
import "code.gitea.io/gitea/modules/json"
const (
bulkActionIndex = "index"
bulkActionDelete = "delete"
)
// BulkOp is a single write inside a Bulk call. Construct with IndexOp or DeleteOp.
type BulkOp struct {
action string
id string
doc any
}
// IndexOp builds a bulk index operation.
func IndexOp(id string, doc any) BulkOp {
return BulkOp{action: bulkActionIndex, id: id, doc: doc}
}
// DeleteOp builds a bulk delete operation.
func DeleteOp(id string) BulkOp {
return BulkOp{action: bulkActionDelete, id: id}
}
// SortField is one entry of the search sort array.
type SortField struct {
Field string
Desc bool
}
func (s SortField) source() map[string]any {
order := "asc"
if s.Desc {
order = "desc"
}
return map[string]any{s.Field: map[string]any{"order": order}}
}
// SearchRequest captures everything Gitea sends to the _search endpoint.
// Aggregations and Highlight are raw ES JSON bodies — callers write them as
// map[string]any since each has exactly one call site with a fixed shape.
type SearchRequest struct {
Query Query
Sort []SortField
From int
Size int
TrackTotal bool
Aggregations map[string]any
Highlight map[string]any
}
// SearchHit is a single result row.
type SearchHit struct {
ID string
Score float64
Source json.Value
Highlight map[string][]string
}
// AggBucket is a terms-aggregation bucket.
type AggBucket struct {
Key any
DocCount int64
}
// SearchResponse is Gitea's decoded view of the search reply.
type SearchResponse struct {
Total int64
Hits []SearchHit
Aggregations map[string][]AggBucket
}
+3 -37
View File
@@ -6,14 +6,11 @@ package elasticsearch
import (
"context"
"fmt"
"time"
"code.gitea.io/gitea/modules/log"
"github.com/olivere/elastic/v7"
)
// VersionedIndexName returns the full index name with version
// VersionedIndexName returns the full index name with version suffix.
func (i *Indexer) VersionedIndexName() string {
return versionedIndexName(i.indexName, i.version)
}
@@ -26,41 +23,10 @@ func versionedIndexName(indexName string, version int) string {
return fmt.Sprintf("%s.v%d", indexName, version)
}
func (i *Indexer) createIndex(ctx context.Context) error {
createIndex, err := i.Client.CreateIndex(i.VersionedIndexName()).BodyString(i.mapping).Do(ctx)
if err != nil {
return err
}
if !createIndex.Acknowledged {
return fmt.Errorf("create index %s with %s failed", i.VersionedIndexName(), i.mapping)
}
i.checkOldIndexes(ctx)
return nil
}
func (i *Indexer) initClient() (*elastic.Client, error) {
opts := []elastic.ClientOptionFunc{
elastic.SetURL(i.url),
elastic.SetSniff(false),
elastic.SetHealthcheckInterval(10 * time.Second),
elastic.SetGzip(false),
}
logger := log.GetLogger(log.DEFAULT)
opts = append(opts, elastic.SetTraceLog(&log.PrintfLogger{Logf: logger.Trace}))
opts = append(opts, elastic.SetInfoLog(&log.PrintfLogger{Logf: logger.Info}))
opts = append(opts, elastic.SetErrorLog(&log.PrintfLogger{Logf: logger.Error}))
return elastic.NewClient(opts...)
}
func (i *Indexer) checkOldIndexes(ctx context.Context) {
for v := 0; v < i.version; v++ {
for v := range i.version {
indexName := versionedIndexName(i.indexName, v)
exists, err := i.Client.IndexExists(indexName).Do(ctx)
exists, err := i.indexExists(ctx, indexName)
if err == nil && exists {
log.Warn("Found older elasticsearch index named %q, Gitea will keep the old NOT DELETED. You can delete the old version after the upgrade succeed.", indexName)
}
+12 -8
View File
@@ -27,7 +27,7 @@ import (
const (
issueIndexerAnalyzer = "issueIndexer"
issueIndexerDocType = "issueIndexerDocType"
issueIndexerLatestVersion = 5
issueIndexerLatestVersion = 6
)
const unicodeNormalizeName = "unicodeNormalize"
@@ -83,8 +83,8 @@ func generateIssueIndexMapping() (mapping.IndexMapping, error) {
docMapping.AddFieldMappingsAt("label_ids", numberFieldMapping)
docMapping.AddFieldMappingsAt("no_label", boolFieldMapping)
docMapping.AddFieldMappingsAt("milestone_id", numberFieldMapping)
docMapping.AddFieldMappingsAt("project_id", numberFieldMapping)
docMapping.AddFieldMappingsAt("project_board_id", numberFieldMapping)
docMapping.AddFieldMappingsAt("project_ids", numberFieldMapping)
docMapping.AddFieldMappingsAt("no_project", boolFieldMapping)
docMapping.AddFieldMappingsAt("poster_id", numberFieldMapping)
docMapping.AddFieldMappingsAt("assignee_id", numberFieldMapping)
docMapping.AddFieldMappingsAt("mention_ids", numberFieldMapping)
@@ -241,11 +241,15 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
queries = append(queries, bleve.NewDisjunctionQuery(milestoneQueries...))
}
if options.ProjectID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectID.Value(), "project_id"))
}
if options.ProjectColumnID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id"))
if options.NoProjectOnly {
queries = append(queries, inner_bleve.BoolFieldQuery(true, "no_project"))
} else if len(options.ProjectIDs) > 0 {
var projectQueries []query.Query
for _, projectID := range options.ProjectIDs {
projectQueries = append(projectQueries, inner_bleve.NumericEqualityQuery(projectID, "project_ids"))
}
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
queries = append(queries, bleve.NewDisjunctionQuery(projectQueries...))
}
if options.PosterID != "" {
+2 -2
View File
@@ -13,6 +13,7 @@ import (
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/util"
)
func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_model.IssuesOptions, error) {
@@ -65,8 +66,7 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
ReviewRequestedID: convertID(options.ReviewRequestedID),
ReviewedID: convertID(options.ReviewedID),
SubscriberID: convertID(options.SubscriberID),
ProjectID: convertID(options.ProjectID),
ProjectColumnID: convertID(options.ProjectColumnID),
ProjectIDs: util.Iif(options.NoProjectOnly, []int64{db.NoConditionID}, options.ProjectIDs),
IsClosed: options.IsClosed,
IsPull: options.IsPull,
IncludedLabelNames: nil,
+4 -5
View File
@@ -46,10 +46,10 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
searchOpt.MilestoneIDs = opts.MilestoneIDs
}
if opts.ProjectID > 0 {
searchOpt.ProjectID = optional.Some(opts.ProjectID)
} else if opts.ProjectID == db.NoConditionID { // FIXME: this is inconsistent from other places
searchOpt.ProjectID = optional.Some[int64](0) // Those issues with no project(projectid==0)
if len(opts.ProjectIDs) == 1 && opts.ProjectIDs[0] == db.NoConditionID {
searchOpt.NoProjectOnly = true
} else {
searchOpt.ProjectIDs = opts.ProjectIDs
}
searchOpt.AssigneeID = opts.AssigneeID
@@ -65,7 +65,6 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
return nil
}
searchOpt.ProjectColumnID = convertID(opts.ProjectColumnID)
searchOpt.PosterID = opts.PosterID
searchOpt.MentionID = convertID(opts.MentionedID)
searchOpt.ReviewedID = convertID(opts.ReviewedID)
@@ -11,27 +11,18 @@ import (
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/indexer"
indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
inner_elasticsearch "code.gitea.io/gitea/modules/indexer/internal/elasticsearch"
es "code.gitea.io/gitea/modules/indexer/internal/elasticsearch"
"code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/util"
"github.com/olivere/elastic/v7"
)
const (
issueIndexerLatestVersion = 2
// multi-match-types, currently only 2 types are used
// Reference: https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-dsl-multi-match-query.html#multi-match-types
esMultiMatchTypeBestFields = "best_fields"
esMultiMatchTypePhrasePrefix = "phrase_prefix"
)
const issueIndexerLatestVersion = 3
var _ internal.Indexer = &Indexer{}
// Indexer implements Indexer interface
type Indexer struct {
inner *inner_elasticsearch.Indexer
indexer_internal.Indexer // do not composite inner_elasticsearch.Indexer directly to avoid exposing too much
*es.Indexer
}
func (b *Indexer) SupportedSearchModes() []indexer.SearchMode {
@@ -41,12 +32,7 @@ func (b *Indexer) SupportedSearchModes() []indexer.SearchMode {
// NewIndexer creates a new elasticsearch indexer
func NewIndexer(url, indexerName string) *Indexer {
inner := inner_elasticsearch.NewIndexer(url, indexerName, issueIndexerLatestVersion, defaultMapping)
indexer := &Indexer{
inner: inner,
Indexer: inner,
}
return indexer
return &Indexer{Indexer: es.NewIndexer(url, indexerName, issueIndexerLatestVersion, defaultMapping)}
}
const (
@@ -68,8 +54,8 @@ const (
"label_ids": { "type": "integer", "index": true },
"no_label": { "type": "boolean", "index": true },
"milestone_id": { "type": "integer", "index": true },
"project_id": { "type": "integer", "index": true },
"project_board_id": { "type": "integer", "index": true },
"project_ids": { "type": "integer", "index": true },
"no_project": { "type": "boolean", "index": true },
"poster_id": { "type": "integer", "index": true },
"assignee_id": { "type": "integer", "index": true },
"mention_ids": { "type": "integer", "index": true },
@@ -93,29 +79,14 @@ func (b *Indexer) Index(ctx context.Context, issues ...*internal.IndexerData) er
return nil
} else if len(issues) == 1 {
issue := issues[0]
_, err := b.inner.Client.Index().
Index(b.inner.VersionedIndexName()).
Id(strconv.FormatInt(issue.ID, 10)).
BodyJson(issue).
Do(ctx)
return err
return b.Indexer.Index(ctx, strconv.FormatInt(issue.ID, 10), issue)
}
reqs := make([]elastic.BulkableRequest, 0)
ops := make([]es.BulkOp, 0, len(issues))
for _, issue := range issues {
reqs = append(reqs,
elastic.NewBulkIndexRequest().
Index(b.inner.VersionedIndexName()).
Id(strconv.FormatInt(issue.ID, 10)).
Doc(issue),
)
ops = append(ops, es.IndexOp(strconv.FormatInt(issue.ID, 10), issue))
}
_, err := b.inner.Client.Bulk().
Index(b.inner.VersionedIndexName()).
Add(reqs...).
Do(graceful.GetManager().HammerContext())
return err
return b.Bulk(graceful.GetManager().HammerContext(), ops)
}
// Delete deletes indexes by ids
@@ -123,129 +94,116 @@ func (b *Indexer) Delete(ctx context.Context, ids ...int64) error {
if len(ids) == 0 {
return nil
} else if len(ids) == 1 {
_, err := b.inner.Client.Delete().
Index(b.inner.VersionedIndexName()).
Id(strconv.FormatInt(ids[0], 10)).
Do(ctx)
return err
return b.Indexer.Delete(ctx, strconv.FormatInt(ids[0], 10))
}
reqs := make([]elastic.BulkableRequest, 0)
ops := make([]es.BulkOp, 0, len(ids))
for _, id := range ids {
reqs = append(reqs,
elastic.NewBulkDeleteRequest().
Index(b.inner.VersionedIndexName()).
Id(strconv.FormatInt(id, 10)),
)
ops = append(ops, es.DeleteOp(strconv.FormatInt(id, 10)))
}
_, err := b.inner.Client.Bulk().
Index(b.inner.VersionedIndexName()).
Add(reqs...).
Do(graceful.GetManager().HammerContext())
return err
return b.Bulk(graceful.GetManager().HammerContext(), ops)
}
// Search searches for issues by given conditions.
// Returns the matching issue IDs
func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) {
query := elastic.NewBoolQuery()
query := es.NewBoolQuery()
if options.Keyword != "" {
searchMode := util.IfZero(options.SearchMode, b.SupportedSearchModes()[0].ModeValue)
mm := es.NewMultiMatchQuery(options.Keyword, "title", "content", "comments")
if searchMode == indexer.SearchModeExact {
query.Must(elastic.NewMultiMatchQuery(options.Keyword, "title", "content", "comments").Type(esMultiMatchTypePhrasePrefix))
} else /* words */ {
query.Must(elastic.NewMultiMatchQuery(options.Keyword, "title", "content", "comments").Type(esMultiMatchTypeBestFields).Operator("and"))
mm = mm.Type(es.MultiMatchTypePhrasePrefix)
} else {
mm = mm.Type(es.MultiMatchTypeBestFields).Operator("and")
}
query.Must(mm)
}
if len(options.RepoIDs) > 0 {
q := elastic.NewBoolQuery()
q.Should(elastic.NewTermsQuery("repo_id", toAnySlice(options.RepoIDs)...))
q := es.NewBoolQuery()
q.Should(es.TermsQuery("repo_id", es.ToAnySlice(options.RepoIDs)...))
if options.AllPublic {
q.Should(elastic.NewTermQuery("is_public", true))
q.Should(es.TermQuery("is_public", true))
}
query.Must(q)
}
if options.IsPull.Has() {
query.Must(elastic.NewTermQuery("is_pull", options.IsPull.Value()))
query.Must(es.TermQuery("is_pull", options.IsPull.Value()))
}
if options.IsClosed.Has() {
query.Must(elastic.NewTermQuery("is_closed", options.IsClosed.Value()))
query.Must(es.TermQuery("is_closed", options.IsClosed.Value()))
}
if options.IsArchived.Has() {
query.Must(elastic.NewTermQuery("is_archived", options.IsArchived.Value()))
query.Must(es.TermQuery("is_archived", options.IsArchived.Value()))
}
if options.NoLabelOnly {
query.Must(elastic.NewTermQuery("no_label", true))
query.Must(es.TermQuery("no_label", true))
} else {
if len(options.IncludedLabelIDs) > 0 {
q := elastic.NewBoolQuery()
q := es.NewBoolQuery()
for _, labelID := range options.IncludedLabelIDs {
q.Must(elastic.NewTermQuery("label_ids", labelID))
q.Must(es.TermQuery("label_ids", labelID))
}
query.Must(q)
} else if len(options.IncludedAnyLabelIDs) > 0 {
query.Must(elastic.NewTermsQuery("label_ids", toAnySlice(options.IncludedAnyLabelIDs)...))
query.Must(es.TermsQuery("label_ids", es.ToAnySlice(options.IncludedAnyLabelIDs)...))
}
if len(options.ExcludedLabelIDs) > 0 {
q := elastic.NewBoolQuery()
q := es.NewBoolQuery()
for _, labelID := range options.ExcludedLabelIDs {
q.MustNot(elastic.NewTermQuery("label_ids", labelID))
q.MustNot(es.TermQuery("label_ids", labelID))
}
query.Must(q)
}
}
if len(options.MilestoneIDs) > 0 {
query.Must(elastic.NewTermsQuery("milestone_id", toAnySlice(options.MilestoneIDs)...))
query.Must(es.TermsQuery("milestone_id", es.ToAnySlice(options.MilestoneIDs)...))
}
if options.ProjectID.Has() {
query.Must(elastic.NewTermQuery("project_id", options.ProjectID.Value()))
}
if options.ProjectColumnID.Has() {
query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value()))
if options.NoProjectOnly {
query.Must(es.TermQuery("no_project", true))
} else if len(options.ProjectIDs) > 0 {
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
query.Must(es.TermsQuery("project_ids", es.ToAnySlice(options.ProjectIDs)...))
}
if options.PosterID != "" {
// "(none)" becomes 0, it means no poster
posterIDInt64, _ := strconv.ParseInt(options.PosterID, 10, 64)
query.Must(elastic.NewTermQuery("poster_id", posterIDInt64))
query.Must(es.TermQuery("poster_id", posterIDInt64))
}
if options.AssigneeID != "" {
if options.AssigneeID == "(any)" {
q := elastic.NewRangeQuery("assignee_id")
q.Gte(1)
query.Must(q)
query.Must(es.NewRangeQuery("assignee_id").Gte(1))
} else {
// "(none)" becomes 0, it means no assignee
assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64)
query.Must(elastic.NewTermQuery("assignee_id", assigneeIDInt64))
query.Must(es.TermQuery("assignee_id", assigneeIDInt64))
}
}
if options.MentionID.Has() {
query.Must(elastic.NewTermQuery("mention_ids", options.MentionID.Value()))
query.Must(es.TermQuery("mention_ids", options.MentionID.Value()))
}
if options.ReviewedID.Has() {
query.Must(elastic.NewTermQuery("reviewed_ids", options.ReviewedID.Value()))
query.Must(es.TermQuery("reviewed_ids", options.ReviewedID.Value()))
}
if options.ReviewRequestedID.Has() {
query.Must(elastic.NewTermQuery("review_requested_ids", options.ReviewRequestedID.Value()))
query.Must(es.TermQuery("review_requested_ids", options.ReviewRequestedID.Value()))
}
if options.SubscriberID.Has() {
query.Must(elastic.NewTermQuery("subscriber_ids", options.SubscriberID.Value()))
query.Must(es.TermQuery("subscriber_ids", options.SubscriberID.Value()))
}
if options.UpdatedAfterUnix.Has() || options.UpdatedBeforeUnix.Has() {
q := elastic.NewRangeQuery("updated_unix")
q := es.NewRangeQuery("updated_unix")
if options.UpdatedAfterUnix.Has() {
q.Gte(options.UpdatedAfterUnix.Value())
}
@@ -258,9 +216,9 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
if options.SortBy == "" {
options.SortBy = internal.SortByCreatedAsc
}
sortBy := []elastic.Sorter{
sortBy := []es.SortField{
parseSortBy(options.SortBy),
elastic.NewFieldSort("id").Desc(),
{Field: "id", Desc: true},
}
// See https://stackoverflow.com/questions/35206409/elasticsearch-2-1-result-window-is-too-large-index-max-result-window/35221900
@@ -268,43 +226,30 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
const maxPageSize = 10000
skip, limit := indexer_internal.ParsePaginator(options.Paginator, maxPageSize)
searchResult, err := b.inner.Client.Search().
Index(b.inner.VersionedIndexName()).
Query(query).
SortBy(sortBy...).
From(skip).Size(limit).
Do(ctx)
resp, err := b.Indexer.Search(ctx, es.SearchRequest{
Query: query,
Sort: sortBy,
From: skip,
Size: limit,
TrackTotal: true,
})
if err != nil {
return nil, err
}
hits := make([]internal.Match, 0, limit)
for _, hit := range searchResult.Hits.Hits {
id, _ := strconv.ParseInt(hit.Id, 10, 64)
hits = append(hits, internal.Match{
ID: id,
})
hits := make([]internal.Match, 0, len(resp.Hits))
for _, hit := range resp.Hits {
id, _ := strconv.ParseInt(hit.ID, 10, 64)
hits = append(hits, internal.Match{ID: id})
}
return &internal.SearchResult{
Total: searchResult.TotalHits(),
Total: resp.Total,
Hits: hits,
}, nil
}
func toAnySlice[T any](s []T) []any {
ret := make([]any, 0, len(s))
for _, item := range s {
ret = append(ret, item)
}
return ret
}
func parseSortBy(sortBy internal.SortBy) elastic.Sorter {
field := strings.TrimPrefix(string(sortBy), "-")
ret := elastic.NewFieldSort(field)
if strings.HasPrefix(string(sortBy), "-") {
ret.Desc()
}
return ret
func parseSortBy(sortBy internal.SortBy) es.SortField {
field, desc := strings.CutPrefix(string(sortBy), "-")
return es.SortField{Field: field, Desc: desc}
}
@@ -6,6 +6,7 @@ package elasticsearch
import (
"fmt"
"net/http"
"net/url"
"os"
"testing"
"time"
@@ -17,19 +18,36 @@ import (
func TestElasticsearchIndexer(t *testing.T) {
// The elasticsearch instance started by pull-db-tests.yml > test-unit > services > elasticsearch
url := "http://elastic:changeme@elasticsearch:9200"
rawURL := "http://elastic:changeme@elasticsearch:9200"
if os.Getenv("CI") == "" {
// Make it possible to run tests against a local elasticsearch instance
url = os.Getenv("TEST_ELASTICSEARCH_URL")
if url == "" {
rawURL = os.Getenv("TEST_ELASTICSEARCH_URL")
if rawURL == "" {
t.Skip("TEST_ELASTICSEARCH_URL not set and not running in CI")
return
}
}
// Go's net/http does not auto-attach URL userinfo as Basic Auth, so extract
// it and set the header explicitly; otherwise auth-enforced clusters answer
// 401 and the probe never reports ready.
parsed, err := url.Parse(rawURL)
require.NoError(t, err)
user := parsed.User
parsed.User = nil
probeURL := parsed.String()
require.Eventually(t, func() bool {
resp, err := http.Get(url)
req, err := http.NewRequest(http.MethodGet, probeURL, nil)
if err != nil {
return false
}
if user != nil {
pass, _ := user.Password()
req.SetBasicAuth(user.Username(), pass)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false
}
@@ -37,7 +55,7 @@ func TestElasticsearchIndexer(t *testing.T) {
return resp.StatusCode == http.StatusOK
}, time.Minute, time.Second, "Expected elasticsearch to be up")
indexer := NewIndexer(url, fmt.Sprintf("test_elasticsearch_indexer_%d", time.Now().Unix()))
indexer := NewIndexer(rawURL, fmt.Sprintf("test_elasticsearch_indexer_%d", time.Now().Unix()))
defer indexer.Close()
tests.TestIndexer(t, indexer)
+27 -13
View File
@@ -416,28 +416,42 @@ func searchIssueInProject(t *testing.T) {
}{
{
SearchOptions{
ProjectID: optional.Some(int64(1)),
ProjectIDs: []int64{1},
},
[]int64{5, 3, 2, 1},
},
{
SearchOptions{
ProjectColumnID: optional.Some(int64(1)),
},
[]int64{1},
},
{
SearchOptions{
ProjectColumnID: optional.Some(int64(0)), // issue with in default column
},
[]int64{2},
},
}
for _, test := range tests {
issueIDs, _, err := SearchIssues(t.Context(), &test.opts)
require.NoError(t, err)
assert.Equal(t, test.expectedIDs, issueIDs)
}
// Test filtering for issues with no project assigned using dynamic validation
t.Run("no project assigned", func(t *testing.T) {
issueIDs, total, err := SearchIssues(t.Context(), &SearchOptions{
ProjectIDs: []int64{db.NoConditionID},
})
require.NoError(t, err)
assert.NotEmpty(t, issueIDs)
assert.Equal(t, total, int64(len(issueIDs)))
// Verify each returned issue actually has no project
for _, issueID := range issueIDs {
issue, err := issues.GetIssueByID(t.Context(), issueID)
require.NoError(t, err)
err = issue.LoadProjects(t.Context())
require.NoError(t, err)
assert.Empty(t, issue.Projects, "Issue %d should have no projects", issueID)
}
// Count total issues with no project to verify we got them all
allIssues, err := issues.Issues(t.Context(), &issues.IssuesOptions{
ProjectIDs: []int64{db.NoConditionID},
})
require.NoError(t, err)
assert.Len(t, issueIDs, len(allIssues), "Should return all issues with no project")
})
}
func searchIssueWithPaginator(t *testing.T) {
+5 -4
View File
@@ -30,8 +30,9 @@ type IndexerData struct {
LabelIDs []int64 `json:"label_ids"`
NoLabel bool `json:"no_label"` // True if LabelIDs is empty
MilestoneID int64 `json:"milestone_id"`
ProjectID int64 `json:"project_id"`
ProjectColumnID int64 `json:"project_board_id"` // the key should be kept as project_board_id to keep compatible
ProjectIDs []int64 `json:"project_ids"`
NoProject bool `json:"no_project"` // True if ProjectIDs is empty
ProjectColumnMap map[int64]int64 `json:"project_column_map,omitempty"` // Maps project ID to column ID for each project the issue is in
PosterID int64 `json:"poster_id"`
AssigneeID int64 `json:"assignee_id"`
MentionIDs []int64 `json:"mention_ids"`
@@ -94,8 +95,8 @@ type SearchOptions struct {
MilestoneIDs []int64 // milestones the issues have
ProjectID optional.Option[int64] // project the issues belong to
ProjectColumnID optional.Option[int64] // project column the issues belong to
ProjectIDs []int64 // project the issues belong to. FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: no multiple project filter support yet. Search logic is wrong.
NoProjectOnly bool // if the issues have no project, if true, ProjectIDs will be ignored
PosterID string // poster of the issues, "(none)" or "(any)" or a user ID
AssigneeID string // assignee of the issues, "(none)" or "(any)" or a user ID
+30 -50
View File
@@ -116,6 +116,16 @@ var cases = []*testIndexerCase{
assert.Equal(t, len(data), int(result.Total))
},
},
{
// Exercises the single-doc Index/Delete fast path in backends that have one (e.g. Elasticsearch).
Name: "single-doc index",
ExtraData: []*internal.IndexerData{
{ID: 999, Title: "solo-issue-marker"},
},
SearchOptions: &internal.SearchOptions{Keyword: "solo-issue-marker"},
ExpectedIDs: []int64{999},
ExpectedTotal: 1,
},
{
Name: "Keyword",
ExtraData: []*internal.IndexerData{
@@ -301,75 +311,41 @@ var cases = []*testIndexerCase{
},
},
{
Name: "ProjectID",
Name: "ProjectIDs",
SearchOptions: &internal.SearchOptions{
Paginator: &db.ListOptions{
PageSize: 5,
},
ProjectID: optional.Some(int64(1)),
ProjectIDs: []int64{1},
},
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 5)
for _, v := range result.Hits {
assert.Equal(t, int64(1), data[v.ID].ProjectID)
assert.Contains(t, data[v.ID].ProjectIDs, int64(1))
}
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.ProjectID == 1
return slices.Contains(v.ProjectIDs, int64(1))
}), result.Total)
},
},
{
Name: "no ProjectID",
Name: "no ProjectIDs (empty array)",
SearchOptions: &internal.SearchOptions{
Paginator: &db.ListOptions{
PageSize: 5,
PageSize: 50,
},
ProjectID: optional.Some(int64(0)),
NoProjectOnly: true,
},
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 5)
// Verify only issues with no projects are returned
for _, v := range result.Hits {
assert.Equal(t, int64(0), data[v.ID].ProjectID)
assert.Empty(t, data[v.ID].ProjectIDs, "Issue %d should have no projects", v.ID)
}
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.ProjectID == 0
}), result.Total)
},
},
{
Name: "ProjectColumnID",
SearchOptions: &internal.SearchOptions{
Paginator: &db.ListOptions{
PageSize: 5,
},
ProjectColumnID: optional.Some(int64(1)),
},
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 5)
for _, v := range result.Hits {
assert.Equal(t, int64(1), data[v.ID].ProjectColumnID)
}
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.ProjectColumnID == 1
}), result.Total)
},
},
{
Name: "no ProjectColumnID",
SearchOptions: &internal.SearchOptions{
Paginator: &db.ListOptions{
PageSize: 5,
},
ProjectColumnID: optional.Some(int64(0)),
},
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 5)
for _, v := range result.Hits {
assert.Equal(t, int64(0), data[v.ID].ProjectColumnID)
}
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.ProjectColumnID == 0
}), result.Total)
// Verify we got ALL issues with no projects
expectedCount := countIndexerData(data, func(v *internal.IndexerData) bool {
return len(v.ProjectIDs) == 0
})
assert.Equal(t, expectedCount, result.Total, "Should return all %d issues with no project", expectedCount)
},
},
{
@@ -706,6 +682,10 @@ func generateDefaultIndexerData() []*internal.IndexerData {
for i := range subscriberIDs {
subscriberIDs[i] = int64(i) + 1 // SubscriberID should not be 0
}
projectIDs := make([]int64, id%5)
for i := range projectIDs {
projectIDs[i] = int64(i) + 1 // projectID should not be 0
}
data = append(data, &internal.IndexerData{
ID: id,
@@ -719,8 +699,8 @@ func generateDefaultIndexerData() []*internal.IndexerData {
LabelIDs: labelIDs,
NoLabel: len(labelIDs) == 0,
MilestoneID: issueIndex % 4,
ProjectID: issueIndex % 5,
ProjectColumnID: issueIndex % 6,
ProjectIDs: projectIDs,
NoProject: len(projectIDs) == 0,
PosterID: id%10 + 1, // PosterID should not be 0
AssigneeID: issueIndex % 10,
MentionIDs: mentionIDs,
@@ -20,7 +20,7 @@ import (
)
const (
issueIndexerLatestVersion = 4
issueIndexerLatestVersion = 5
// TODO: make this configurable if necessary
maxTotalHits = 10000
@@ -71,8 +71,8 @@ func NewIndexer(url, apiKey, indexerName string) *Indexer {
"label_ids",
"no_label",
"milestone_id",
"project_id",
"project_board_id",
"project_ids",
"no_project",
"poster_id",
"assignee_id",
"mention_ids",
@@ -182,11 +182,11 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
query.And(inner_meilisearch.NewFilterIn("milestone_id", options.MilestoneIDs...))
}
if options.ProjectID.Has() {
query.And(inner_meilisearch.NewFilterEq("project_id", options.ProjectID.Value()))
}
if options.ProjectColumnID.Has() {
query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value()))
if options.NoProjectOnly {
query.And(inner_meilisearch.NewFilterEq("no_project", true))
} else if len(options.ProjectIDs) > 0 {
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
query.And(inner_meilisearch.NewFilterIn("project_ids", options.ProjectIDs...))
}
if options.PosterID != "" {
+5 -10
View File
@@ -87,14 +87,9 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD
return nil, false, err
}
var projectID int64
if issue.Project != nil {
projectID = issue.Project.ID
}
projectColumnID, err := issue.ProjectColumnID(ctx)
if err != nil {
return nil, false, err
projectIDs := make([]int64, 0, len(issue.Projects))
for _, project := range issue.Projects {
projectIDs = append(projectIDs, project.ID)
}
if err := issue.Repo.LoadOwner(ctx); err != nil {
@@ -114,8 +109,8 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD
LabelIDs: labels,
NoLabel: len(labels) == 0,
MilestoneID: issue.MilestoneID,
ProjectID: projectID,
ProjectColumnID: projectColumnID,
ProjectIDs: projectIDs,
NoProject: len(projectIDs) == 0,
PosterID: issue.PosterID,
AssigneeID: issue.AssigneeID,
MentionIDs: mentionIDs,
-35
View File
@@ -1,35 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package json
import (
"bytes"
"io"
"github.com/goccy/go-json"
)
var _ Interface = jsonGoccy{}
type jsonGoccy struct{}
func (jsonGoccy) Marshal(v any) ([]byte, error) {
return json.Marshal(v)
}
func (jsonGoccy) Unmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}
func (jsonGoccy) NewEncoder(writer io.Writer) Encoder {
return json.NewEncoder(writer)
}
func (jsonGoccy) NewDecoder(reader io.Reader) Decoder {
return json.NewDecoder(reader)
}
func (jsonGoccy) Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error {
return json.Indent(dst, src, prefix, indent)
}
+1 -1
View File
@@ -11,7 +11,7 @@ import (
)
func getDefaultJSONHandler() Interface {
return jsonGoccy{}
return jsonV1{}
}
func MarshalKeepOptionalEmpty(v any) ([]byte, error) {
+16 -5
View File
@@ -75,12 +75,23 @@ func IsLoggerEnabled(name string) bool {
return GetManager().GetLogger(name).IsEnabled()
}
func SetConsoleLogger(loggerName, writerName string, level Level) {
func SetupStderrLogger(loggerName, writerName string, level Level) {
writer := NewEventWriterConsole(writerName, WriterMode{
Level: level,
Flags: FlagsFromBits(LstdFlags),
Colorize: CanColorStdout,
WriterOption: WriterConsoleOption{},
Level: level,
Flags: FlagsFromBits(LstdFlags),
Colorize: CanColorStderr,
// For most CLI commands, it's better to use Stderr as log output:
// this logger is installed early (app.Before), before subcommands like "dump" redirect logging to stderr.
// If Stdout, early log output (e.g.: warning during config loading) goes to stdout
// and corrupts any command that writes data to stdout (e.g. "gitea dump --file -").
//
// It is inconsistent with the web server's default console logger from config
// (which will be initialized later and use Stdout by default), but there is no other way at the moment:
// many existing users depend on such behavior to collect web logs (e.g. fail2ban).
//
// Maybe need to refactor the logger system again in the future.
WriterOption: WriterConsoleOption{Stderr: true},
})
GetManager().GetLogger(loggerName).ReplaceAllWriters(writer)
}
+27 -1
View File
@@ -21,7 +21,33 @@ import (
// RegisterRenderers registers all supported third part renderers according settings
func RegisterRenderers() {
markup.RegisterRenderer(&openAPIRenderer{})
markup.RegisterRenderer(&frontendRenderer{
name: "openapi-swagger",
patterns: []string{
"openapi.yaml",
"openapi.yml",
"openapi.json",
"swagger.yaml",
"swagger.yml",
"swagger.json",
},
})
markup.RegisterRenderer(&frontendRenderer{
name: "viewer-3d",
patterns: []string{
// 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: "*.amf, *.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.
"*.3dm", "*.3ds", "*.3mf", "*.amf", "*.bim", "*.brep",
"*.dae", "*.fbx", "*.fcstd", "*.glb", "*.gltf",
"*.ifc", "*.igs", "*.iges", "*.stp", "*.step",
"*.stl", "*.obj", "*.off", "*.ply", "*.wrl",
},
})
for _, renderer := range setting.ExternalMarkupRenderers {
markup.RegisterRenderer(&Renderer{renderer})
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package external
import (
"encoding/base64"
"io"
"unicode/utf8"
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
type frontendRenderer struct {
name string
patterns []string
}
var (
_ markup.PostProcessRenderer = (*frontendRenderer)(nil)
_ markup.ExternalRenderer = (*frontendRenderer)(nil)
)
func (p *frontendRenderer) Name() string {
return p.name
}
func (p *frontendRenderer) NeedPostProcess() bool {
return false
}
func (p *frontendRenderer) FileNamePatterns() []string {
// TODO: the file extensions are ambiguous, even if the file name matches, it doesn't mean that the file is a 3D model
// There are some approaches to make it more accurate, but they are all complicated:
// A. Make backend know everything (detect a file is a 3D model or not)
// B. Let frontend renders to try render one by one
//
// If there would be more frontend renders in the future, we need to implement the "frontend" approach:
// 1. Make backend or parent window collect the supported extensions of frontend renders (done: backend external render framework)
// 2. If the current file matches any extension, start the general iframe embedded render (done: this renderer)
// 3. The iframe window calls the frontend renders one by one (done: frontend external render)
// 4. Report the render result to parent by postMessage (TODO: when needed)
return p.patterns
}
func (p *frontendRenderer) SanitizerRules() []setting.MarkupSanitizerRule {
return nil
}
func (p *frontendRenderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) {
ret.SanitizerDisabled = true
ret.DisplayInIframe = true
ret.ContentSandbox = "allow-scripts allow-forms allow-modals allow-popups allow-downloads"
return ret
}
func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
if ctx.RenderOptions.StandalonePageOptions == nil {
opts := p.GetExternalRendererOptions()
return markup.RenderIFrame(ctx, &opts, output)
}
content, err := util.ReadWithLimit(input, int(setting.UI.MaxDisplayFileSize))
if err != nil {
return err
}
contentEncoding, contentString := "text", util.UnsafeBytesToString(content)
if !utf8.Valid(content) {
contentEncoding = "base64"
contentString = base64.StdEncoding.EncodeToString(content)
}
_, err = htmlutil.HTMLPrintf(output,
`<!DOCTYPE html>
<html>
<head>
<!-- external-render-helper will be injected here by the markup render -->
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="frontend-render-viewer" data-frontend-renders="%s" data-file-tree-path="%s"></div>
<textarea id="frontend-render-data" data-content-encoding="%s" hidden>%s</textarea>
<script nonce type="module" src="%s"></script>
</body>
</html>`,
p.name, ctx.RenderOptions.RelativePath,
contentEncoding, contentString,
public.AssetURI("js/external-render-frontend.js"))
return err
}
-84
View File
@@ -1,84 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package external
import (
"fmt"
"html"
"io"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
type openAPIRenderer struct{}
var (
_ markup.PostProcessRenderer = (*openAPIRenderer)(nil)
_ markup.ExternalRenderer = (*openAPIRenderer)(nil)
)
func (p *openAPIRenderer) Name() string {
return "openapi"
}
func (p *openAPIRenderer) NeedPostProcess() bool {
return false
}
func (p *openAPIRenderer) FileNamePatterns() []string {
return []string{
"openapi.yaml",
"openapi.yml",
"openapi.json",
"swagger.yaml",
"swagger.yml",
"swagger.json",
}
}
func (p *openAPIRenderer) SanitizerRules() []setting.MarkupSanitizerRule {
return nil
}
func (p *openAPIRenderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) {
ret.SanitizerDisabled = true
ret.DisplayInIframe = true
ret.ContentSandbox = "allow-scripts allow-forms allow-modals allow-popups allow-downloads"
return ret
}
func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
if ctx.RenderOptions.StandalonePageOptions == nil {
opts := p.GetExternalRendererOptions()
return markup.RenderIFrame(ctx, &opts, output)
}
content, err := util.ReadWithLimit(input, int(setting.UI.MaxDisplayFileSize))
if err != nil {
return err
}
// HINT: SWAGGER-OPENAPI-VIEWER: another place "templates/swagger/openapi-viewer.tmpl"
_, err = io.WriteString(output, fmt.Sprintf(
`<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="%s">
</head>
<body>
<div id="swagger-ui"><textarea class="swagger-spec-content" data-spec-filename="%s">%s</textarea></div>
<script type="module" src="%s"></script>
</body>
</html>`,
public.AssetURI("css/swagger.css"),
html.EscapeString(ctx.RenderOptions.RelativePath),
html.EscapeString(util.UnsafeBytesToString(content)),
public.AssetURI("js/swagger.js"),
))
return err
}
+2 -2
View File
@@ -317,7 +317,7 @@ func TestRender_email(t *testing.T) {
func TestRender_emoji(t *testing.T) {
setting.AppURL = markup.TestAppURL
setting.StaticURLPrefix = markup.TestAppURL
setting.StaticURLPrefix = strings.TrimSuffix(markup.TestAppURL, "/")
test := func(input, expected string) {
expected = strings.ReplaceAll(expected, "&", "&amp;")
@@ -500,7 +500,7 @@ func Test_ParseClusterFuzz(t *testing.T) {
}
func TestPostProcess(t *testing.T) {
setting.StaticURLPrefix = markup.TestAppURL // can't run standalone
setting.StaticURLPrefix = strings.TrimSuffix(markup.TestAppURL, "/") // can't run standalone
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
test := func(input, expected string) {
-4
View File
@@ -74,10 +74,6 @@ func (r *GlodmarkRender) Convert(source []byte, writer io.Writer, opts ...parser
return r.goldmarkMarkdown.Convert(source, writer, opts...)
}
func (r *GlodmarkRender) Renderer() renderer.Renderer {
return r.goldmarkMarkdown.Renderer()
}
func (r *GlodmarkRender) highlightingRenderer(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) {
if entering {
languageBytes, _ := c.Language()
+21 -8
View File
@@ -6,6 +6,7 @@ package markup
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"io"
@@ -43,7 +44,8 @@ type WebThemeInterface interface {
}
type StandalonePageOptions struct {
CurrentWebTheme WebThemeInterface
CurrentWebTheme WebThemeInterface
RenderQueryString string
}
type RenderOptions struct {
@@ -206,17 +208,23 @@ func RenderString(ctx *RenderContext, content string) (string, error) {
}
func RenderIFrame(ctx *RenderContext, opts *ExternalRendererOptions, output io.Writer) error {
ownerName, repoName := ctx.RenderOptions.Metas["user"], ctx.RenderOptions.Metas["repo"]
refSubURL := ctx.RenderOptions.Metas["RefTypeNameSubURL"]
if ownerName == "" || repoName == "" || refSubURL == "" {
setting.PanicInDevOrTesting("RenderIFrame requires user, repo and RefTypeNameSubURL metas")
return errors.New("RenderIFrame requires user, repo and RefTypeNameSubURL metas")
}
src := fmt.Sprintf("%s/%s/%s/render/%s/%s", setting.AppSubURL,
url.PathEscape(ctx.RenderOptions.Metas["user"]),
url.PathEscape(ctx.RenderOptions.Metas["repo"]),
util.PathEscapeSegments(ctx.RenderOptions.Metas["RefTypeNameSubURL"]),
url.PathEscape(ownerName),
url.PathEscape(repoName),
ctx.RenderOptions.Metas["RefTypeNameSubURL"],
util.PathEscapeSegments(ctx.RenderOptions.RelativePath),
)
var extraAttrs template.HTML
if opts.ContentSandbox != "" {
extraAttrs = htmlutil.HTMLFormat(` sandbox="%s"`, opts.ContentSandbox)
}
_, err := htmlutil.HTMLPrintf(output, `<iframe data-src="%s" class="external-render-iframe"%s></iframe>`, src, extraAttrs)
_, err := htmlutil.HTMLPrintf(output, `<iframe data-src="%s" data-global-init="initExternalRenderIframe" class="external-render-iframe"%s></iframe>`, src, extraAttrs)
return err
}
@@ -228,7 +236,7 @@ func pipes() (io.ReadCloser, io.WriteCloser, func()) {
}
}
func getExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions, _ bool) {
func GetExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions, _ bool) {
if externalRender, ok := renderer.(ExternalRenderer); ok {
return externalRender.GetExternalRendererOptions(), true
}
@@ -237,7 +245,7 @@ func getExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions,
func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
var extraHeadHTML template.HTML
if extOpts, ok := getExternalRendererOptions(renderer); ok && extOpts.DisplayInIframe {
if extOpts, ok := GetExternalRendererOptions(renderer); ok && extOpts.DisplayInIframe {
if ctx.RenderOptions.StandalonePageOptions == nil {
// for an external "DisplayInIFrame" render, it could only output its content in a standalone page
// otherwise, a <iframe> should be outputted to embed the external rendered page
@@ -248,7 +256,12 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader,
extraLinkHref := ctx.RenderOptions.StandalonePageOptions.CurrentWebTheme.PublicAssetURI()
// "<script>" must go before "<link>", to make Golang's http.DetectContentType() can still recognize the content as "text/html"
// DO NOT use "type=module", the script must run as early as possible, to set up the environment in the iframe
extraHeadHTML = htmlutil.HTMLFormat(`<script crossorigin src="%s"></script><link rel="stylesheet" href="%s">`, extraScriptSrc, extraLinkHref)
extraHeadHTML = htmlutil.HTMLFormat(
`<script nonce crossorigin src="%s" id="gitea-external-render-helper" data-render-query-string="%s"></script>`+
`<link rel="stylesheet" href="%s">`,
extraScriptSrc, ctx.RenderOptions.StandalonePageOptions.RenderQueryString,
extraLinkHref,
)
}
ctx.usedByRender = true
+2 -2
View File
@@ -24,8 +24,8 @@ func TestRenderIFrame(t *testing.T) {
// the value is read from config RENDER_CONTENT_SANDBOX, empty means "disabled"
ret := render(ctx, ExternalRendererOptions{ContentSandbox: ""})
assert.Equal(t, `<iframe data-src="/test-owner/test-repo/render/src/branch/master/tree-path" class="external-render-iframe"></iframe>`, ret)
assert.Equal(t, `<iframe data-src="/test-owner/test-repo/render/src/branch/master/tree-path" data-global-init="initExternalRenderIframe" class="external-render-iframe"></iframe>`, ret)
ret = render(ctx, ExternalRendererOptions{ContentSandbox: "allow"})
assert.Equal(t, `<iframe data-src="/test-owner/test-repo/render/src/branch/master/tree-path" class="external-render-iframe" sandbox="allow"></iframe>`, ret)
assert.Equal(t, `<iframe data-src="/test-owner/test-repo/render/src/branch/master/tree-path" data-global-init="initExternalRenderIframe" class="external-render-iframe" sandbox="allow"></iframe>`, ret)
}
+1
View File
@@ -11,6 +11,7 @@ type Repository struct {
IsPrivate bool `yaml:"is_private"`
IsMirror bool `yaml:"is_mirror"`
Description string
Website string
CloneURL string `yaml:"clone_url"` // SECURITY: This must be checked to ensure that is safe to be used
OriginalURL string `yaml:"original_url"`
DefaultBranch string
+2 -2
View File
@@ -12,10 +12,10 @@ import (
"path"
"sync"
_ "embed"
"code.gitea.io/gitea/modules/assetfs"
_ "embed"
"github.com/santhosh-tekuri/jsonschema/v6"
)
+2 -2
View File
@@ -10,9 +10,9 @@ package options
import (
"sync"
_ "embed"
"code.gitea.io/gitea/modules/assetfs"
_ "embed"
)
//go:embed bindata.dat
+19 -12
View File
@@ -47,6 +47,7 @@ type Metadata struct {
Keywords []string `json:"keywords,omitempty"`
RepositoryURL string `json:"repository_url,omitempty"`
License string `json:"license,omitempty"`
LicenseURL string `json:"license_url,omitempty"`
Author Person `json:"author"`
Manifests map[string]*Manifest `json:"manifests,omitempty"`
}
@@ -67,7 +68,8 @@ type SoftwareSourceCode struct {
Keywords []string `json:"keywords,omitempty"`
CodeRepository string `json:"codeRepository,omitempty"`
License string `json:"license,omitempty"`
Author Person `json:"author"`
LicenseURL string `json:"licenseURL,omitempty"`
Author *Person `json:"author,omitempty"`
ProgrammingLanguage ProgrammingLanguage `json:"programmingLanguage"`
RepositoryURLs []string `json:"repositoryURLs,omitempty"`
}
@@ -181,26 +183,31 @@ func ParsePackage(sr io.ReaderAt, size int64, mr io.Reader) (*Package, error) {
if err := json.NewDecoder(mr).Decode(&ssc); err != nil {
return nil, err
}
p.Metadata.Description = ssc.Description
p.Metadata.Keywords = ssc.Keywords
p.Metadata.License = ssc.License
author := Person{
Name: ssc.Author.Name,
GivenName: ssc.Author.GivenName,
MiddleName: ssc.Author.MiddleName,
FamilyName: ssc.Author.FamilyName,
p.Metadata.LicenseURL = ssc.LicenseURL
if ssc.Author != nil {
author := Person{
Name: ssc.Author.Name,
GivenName: ssc.Author.GivenName,
MiddleName: ssc.Author.MiddleName,
FamilyName: ssc.Author.FamilyName,
}
// If Name is not provided, generate it from individual name components
if author.Name == "" {
author.Name = author.String()
}
p.Metadata.Author = author
}
// If Name is not provided, generate it from individual name components
if author.Name == "" {
author.Name = author.String()
}
p.Metadata.Author = author
p.Metadata.RepositoryURL = ssc.CodeRepository
if !validation.IsValidURL(p.Metadata.RepositoryURL) {
p.Metadata.RepositoryURL = ""
}
if !validation.IsValidURL(p.Metadata.LicenseURL) {
p.Metadata.LicenseURL = ""
}
p.RepositoryURLs = ssc.RepositoryURLs
}
+39 -36
View File
@@ -4,11 +4,12 @@
package swift
import (
"archive/zip"
"bytes"
"strings"
"testing"
"code.gitea.io/gitea/modules/test"
"github.com/hashicorp/go-version"
"github.com/stretchr/testify/assert"
)
@@ -18,36 +19,24 @@ const (
packageVersion = "1.0.1"
packageDescription = "Package Description"
packageRepositoryURL = "https://gitea.io/gitea/gitea"
packageLicenseURL = "https://opensource.org/license/mit"
packageAuthor = "KN4CK3R"
packageLicense = "MIT"
)
func TestParsePackage(t *testing.T) {
createArchive := func(files map[string][]byte) *bytes.Reader {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for filename, content := range files {
w, _ := zw.Create(filename)
w.Write(content)
}
zw.Close()
return bytes.NewReader(buf.Bytes())
}
t.Run("MissingManifestFile", func(t *testing.T) {
data := createArchive(map[string][]byte{"dummy.txt": {}})
p, err := ParsePackage(data, data.Size(), nil)
data := test.WriteZipArchive(map[string]string{"dummy.txt": ""})
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
assert.Nil(t, p)
assert.ErrorIs(t, err, ErrMissingManifestFile)
})
t.Run("ManifestFileTooLarge", func(t *testing.T) {
data := createArchive(map[string][]byte{
"Package.swift": make([]byte, maxManifestFileSize+1),
data := test.WriteZipArchive(map[string]string{
"Package.swift": strings.Repeat("a", maxManifestFileSize+1),
})
p, err := ParsePackage(data, data.Size(), nil)
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
assert.Nil(t, p)
assert.ErrorIs(t, err, ErrManifestFileTooLarge)
})
@@ -56,12 +45,12 @@ func TestParsePackage(t *testing.T) {
content1 := "// swift-tools-version:5.7\n//\n// Package.swift"
content2 := "// swift-tools-version:5.6\n//\n// Package@swift-5.6.swift"
data := createArchive(map[string][]byte{
"Package.swift": []byte(content1),
"Package@swift-5.5.swift": []byte(content2),
data := test.WriteZipArchive(map[string]string{
"Package.swift": content1,
"Package@swift-5.5.swift": content2,
})
p, err := ParsePackage(data, data.Size(), nil)
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
assert.NotNil(t, p)
assert.NoError(t, err)
@@ -77,14 +66,13 @@ func TestParsePackage(t *testing.T) {
})
t.Run("WithMetadata", func(t *testing.T) {
data := createArchive(map[string][]byte{
"Package.swift": []byte("// swift-tools-version:5.7\n//\n// Package.swift"),
data := test.WriteZipArchive(map[string]string{
"Package.swift": "// swift-tools-version:5.7\n//\n// Package.swift",
})
p, err := ParsePackage(
data,
data.Size(),
strings.NewReader(`{"name":"`+packageName+`","version":"`+packageVersion+`","description":"`+packageDescription+`","keywords":["swift","package"],"license":"`+packageLicense+`","codeRepository":"`+packageRepositoryURL+`","author":{"givenName":"`+packageAuthor+`"},"repositoryURLs":["`+packageRepositoryURL+`"]}`),
bytes.NewReader(data.Bytes()), int64(data.Len()),
strings.NewReader(`{"name":"`+packageName+`","version":"`+packageVersion+`","description":"`+packageDescription+`","keywords":["swift","package"],"license":"`+packageLicense+`","licenseURL":"`+packageLicenseURL+`","codeRepository":"`+packageRepositoryURL+`","author":{"givenName":"`+packageAuthor+`"},"repositoryURLs":["`+packageRepositoryURL+`"]}`),
)
assert.NotNil(t, p)
assert.NoError(t, err)
@@ -97,6 +85,7 @@ func TestParsePackage(t *testing.T) {
assert.Equal(t, packageDescription, p.Metadata.Description)
assert.ElementsMatch(t, []string{"swift", "package"}, p.Metadata.Keywords)
assert.Equal(t, packageLicense, p.Metadata.License)
assert.Equal(t, packageLicenseURL, p.Metadata.LicenseURL)
assert.Equal(t, packageAuthor, p.Metadata.Author.Name)
assert.Equal(t, packageAuthor, p.Metadata.Author.GivenName)
assert.Equal(t, packageRepositoryURL, p.Metadata.RepositoryURL)
@@ -104,14 +93,13 @@ func TestParsePackage(t *testing.T) {
})
t.Run("WithExplicitNameField", func(t *testing.T) {
data := createArchive(map[string][]byte{
"Package.swift": []byte("// swift-tools-version:5.7\n//\n// Package.swift"),
data := test.WriteZipArchive(map[string]string{
"Package.swift": "// swift-tools-version:5.7\n//\n// Package.swift",
})
authorName := "John Doe"
p, err := ParsePackage(
data,
data.Size(),
bytes.NewReader(data.Bytes()), int64(data.Len()),
strings.NewReader(`{"name":"`+packageName+`","version":"`+packageVersion+`","description":"`+packageDescription+`","author":{"name":"`+authorName+`","givenName":"John","familyName":"Doe"}}`),
)
assert.NotNil(t, p)
@@ -122,15 +110,30 @@ func TestParsePackage(t *testing.T) {
assert.Equal(t, "Doe", p.Metadata.Author.FamilyName)
})
t.Run("WithEmptyJSONMetadata", func(t *testing.T) {
data := test.WriteZipArchive(map[string]string{
"Package.swift": "// swift-tools-version:5.7\n//\n// Package.swift",
})
p, err := ParsePackage(
bytes.NewReader(data.Bytes()), int64(data.Len()),
strings.NewReader(`{}`),
)
assert.NotNil(t, p)
assert.NoError(t, err)
assert.NotNil(t, p.Metadata)
assert.Empty(t, p.Metadata.Author.Name)
assert.Empty(t, p.RepositoryURLs)
})
t.Run("NameFieldGeneration", func(t *testing.T) {
data := createArchive(map[string][]byte{
"Package.swift": []byte("// swift-tools-version:5.7\n//\n// Package.swift"),
data := test.WriteZipArchive(map[string]string{
"Package.swift": "// swift-tools-version:5.7\n//\n// Package.swift",
})
// Test with only individual name components - Name should be auto-generated
p, err := ParsePackage(
data,
data.Size(),
bytes.NewReader(data.Bytes()), int64(data.Len()),
strings.NewReader(`{"author":{"givenName":"John","middleName":"Q","familyName":"Doe"}}`),
)
assert.NotNil(t, p)
+2
View File
@@ -56,6 +56,8 @@ func parseManifest(data []byte) (map[string]string, map[string]string) {
paths[key] = entry.File
names[entry.File] = entry.Name
// Map associated CSS files, e.g. "css/index.css" -> "css/index.B3zrQPqD.css"
// FIXME: INCORRECT-VITE-MANIFEST-PARSER: the logic is wrong, Vite manifest doesn't work this way
// It just happens to be correct for the current modules dependencies
for _, css := range entry.CSS {
cssKey := path.Dir(css) + "/" + entry.Name + path.Ext(css)
paths[cssKey] = css
+2 -2
View File
@@ -87,6 +87,7 @@ func getViteDevProxy() *httputil.ReverseProxy {
// the Vite dev server port from the port file written by the viteDevServerPortPlugin.
// It is needed because there are container-based development, only Gitea web server's port is exposed.
func ViteDevMiddleware(next http.Handler) http.Handler {
markLongPolling := routing.MarkLongPolling()
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
if !isViteDevRequest(req) {
next.ServeHTTP(resp, req)
@@ -97,8 +98,7 @@ func ViteDevMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(resp, req)
return
}
routing.MarkLongPolling(resp, req)
proxy.ServeHTTP(resp, req)
markLongPolling(proxy).ServeHTTP(resp, req)
})
}
-4
View File
@@ -13,11 +13,7 @@ import (
)
func TestManager(t *testing.T) {
oldAppDataPath := setting.AppDataPath
setting.AppDataPath = t.TempDir()
defer func() {
setting.AppDataPath = oldAppDataPath
}()
newQueueFromConfig := func(name, cfg string) (*WorkerPoolQueue[int], error) {
cfgProvider, err := setting.NewConfigProviderFromData(cfg)
-12
View File
@@ -1,18 +1,6 @@
// Copyright 2013 Beego Authors
// Copyright 2014 The Macaron Authors
// Copyright 2020 The Gitea Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License"): you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
// SPDX-License-Identifier: Apache-2.0
package session
+8
View File
@@ -12,6 +12,8 @@ import (
"code.gitea.io/gitea/modules/log"
)
const defaultMaxRerunAttempts = 50
// Actions settings
var (
Actions = struct {
@@ -27,11 +29,13 @@ var (
AbandonedJobTimeout time.Duration `ini:"ABANDONED_JOB_TIMEOUT"`
SkipWorkflowStrings []string `ini:"SKIP_WORKFLOW_STRINGS"`
WorkflowDirs []string `ini:"WORKFLOW_DIRS"`
MaxRerunAttempts int64 `ini:"MAX_RERUN_ATTEMPTS"`
}{
Enabled: true,
DefaultActionsURL: defaultActionsURLGitHub,
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
WorkflowDirs: []string{".gitea/workflows", ".github/workflows"},
MaxRerunAttempts: defaultMaxRerunAttempts,
}
)
@@ -118,6 +122,10 @@ func loadActionsFrom(rootCfg ConfigProvider) error {
Actions.EndlessTaskTimeout = sec.Key("ENDLESS_TASK_TIMEOUT").MustDuration(3 * time.Hour)
Actions.AbandonedJobTimeout = sec.Key("ABANDONED_JOB_TIMEOUT").MustDuration(24 * time.Hour)
if Actions.MaxRerunAttempts <= 0 {
Actions.MaxRerunAttempts = defaultMaxRerunAttempts
}
if !Actions.LogCompression.IsValid() {
return fmt.Errorf("invalid [actions] LOG_COMPRESSION: %q", Actions.LogCompression)
}
+17 -140
View File
@@ -4,13 +4,7 @@
package setting
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
@@ -20,24 +14,22 @@ var (
// DatabaseTypeNames contains the friendly names for all database types
DatabaseTypeNames = map[string]string{"mysql": "MySQL", "postgres": "PostgreSQL", "mssql": "MSSQL", "sqlite3": "SQLite3"}
// EnableSQLite3 use SQLite3, set by build flag
EnableSQLite3 bool
// Database holds the database settings
Database = struct {
Type DatabaseType
Host string
Name string
User string
Passwd string
Schema string
SSLMode string
Path string
Type DatabaseType
Host string
Name string
User string
Passwd string
Schema string
SSLMode string
Path string
SQLiteBusyTimeout int
SQLiteJournalMode string
LogSQL bool
MysqlCharset string
CharsetCollation string
Timeout int // seconds
SQLiteJournalMode string
DBConnectRetries int
DBConnectBackoff time.Duration
MaxIdleConns int
@@ -47,7 +39,7 @@ var (
AutoMigration bool
SlowQueryThreshold time.Duration
}{
Timeout: 500,
SQLiteBusyTimeout: 500,
IterateBufferSize: 50,
}
)
@@ -64,15 +56,14 @@ func loadDBSetting(rootCfg ConfigProvider) {
Database.Host = sec.Key("HOST").String()
Database.Name = sec.Key("NAME").String()
Database.User = sec.Key("USER").String()
if len(Database.Passwd) == 0 {
Database.Passwd = sec.Key("PASSWD").String()
}
Database.Passwd = sec.Key("PASSWD").String()
Database.Schema = sec.Key("SCHEMA").String()
Database.SSLMode = sec.Key("SSL_MODE").MustString("disable")
Database.CharsetCollation = sec.Key("CHARSET_COLLATION").String()
Database.Path = sec.Key("PATH").MustString(filepath.Join(AppDataPath, "gitea.db"))
Database.Timeout = sec.Key("SQLITE_TIMEOUT").MustInt(500)
Database.SQLiteBusyTimeout = sec.Key("SQLITE_TIMEOUT").MustInt(500)
Database.SQLiteJournalMode = sec.Key("SQLITE_JOURNAL_MODE").MustString("")
Database.MaxIdleConns = sec.Key("MAX_IDLE_CONNS").MustInt(2)
@@ -91,123 +82,9 @@ func loadDBSetting(rootCfg ConfigProvider) {
Database.SlowQueryThreshold = sec.Key("SLOW_QUERY_THRESHOLD").MustDuration(5 * time.Second)
}
// DBConnStr returns database connection string
func DBConnStr() (string, error) {
var connStr string
paramSep := "?"
if strings.Contains(Database.Name, paramSep) {
paramSep = "&"
}
switch Database.Type {
case "mysql":
connType := "tcp"
if len(Database.Host) > 0 && Database.Host[0] == '/' { // looks like a unix socket
connType = "unix"
}
tls := Database.SSLMode
if tls == "disable" { // allow (Postgres-inspired) default value to work in MySQL
tls = "false"
}
connStr = fmt.Sprintf("%s:%s@%s(%s)/%s%sparseTime=true&tls=%s",
Database.User, Database.Passwd, connType, Database.Host, Database.Name, paramSep, tls)
case "postgres":
connStr = getPostgreSQLConnectionString(Database.Host, Database.User, Database.Passwd, Database.Name, Database.SSLMode)
case "mssql":
host, port := ParseMSSQLHostPort(Database.Host)
connStr = fmt.Sprintf("server=%s; port=%s; database=%s; user id=%s; password=%s;", host, port, Database.Name, Database.User, Database.Passwd)
case "sqlite3":
if !EnableSQLite3 {
return "", errors.New("this Gitea binary was not built with SQLite3 support")
}
if err := os.MkdirAll(filepath.Dir(Database.Path), os.ModePerm); err != nil {
return "", fmt.Errorf("Failed to create directories: %w", err)
}
journalMode := ""
if Database.SQLiteJournalMode != "" {
journalMode = "&_journal_mode=" + Database.SQLiteJournalMode
}
connStr = fmt.Sprintf("file:%s?cache=shared&mode=rwc&_busy_timeout=%d&_txlock=immediate%s",
Database.Path, Database.Timeout, journalMode)
default:
return "", fmt.Errorf("unknown database type: %s", Database.Type)
}
return connStr, nil
}
// parsePostgreSQLHostPort parses given input in various forms defined in
// https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING
// and returns proper host and port number.
func parsePostgreSQLHostPort(info string) (host, port string) {
if h, p, err := net.SplitHostPort(info); err == nil {
host, port = h, p
} else {
// treat the "info" as "host", if it's an IPv6 address, remove the wrapper
host = info
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = host[1 : len(host)-1]
}
}
// set fallback values
if host == "" {
host = "127.0.0.1"
}
if port == "" {
port = "5432"
}
return host, port
}
func getPostgreSQLConnectionString(dbHost, dbUser, dbPasswd, dbName, dbsslMode string) (connStr string) {
dbName, dbParam, _ := strings.Cut(dbName, "?")
host, port := parsePostgreSQLHostPort(dbHost)
connURL := url.URL{
Scheme: "postgres",
User: url.UserPassword(dbUser, dbPasswd),
Host: net.JoinHostPort(host, port),
Path: dbName,
OmitHost: false,
RawQuery: dbParam,
}
query := connURL.Query()
if strings.HasPrefix(host, "/") { // looks like a unix socket
query.Add("host", host)
connURL.Host = ":" + port
}
query.Set("sslmode", dbsslMode)
connURL.RawQuery = query.Encode()
return connURL.String()
}
// ParseMSSQLHostPort splits the host into host and port
func ParseMSSQLHostPort(info string) (string, string) {
// the default port "0" might be related to MSSQL's dynamic port, maybe it should be double-confirmed in the future
host, port := "127.0.0.1", "0"
if strings.Contains(info, ":") {
host = strings.Split(info, ":")[0]
port = strings.Split(info, ":")[1]
} else if strings.Contains(info, ",") {
host = strings.Split(info, ",")[0]
port = strings.TrimSpace(strings.Split(info, ",")[1])
} else if len(info) > 0 {
host = info
}
if host == "" {
host = "127.0.0.1"
}
if port == "" {
port = "0"
}
return host, port
}
// DatabaseType FIXME: it is also used directly with "schemas.DBType", so the names must be consistent
type DatabaseType string
func (t DatabaseType) String() string {
return string(t)
}
func (t DatabaseType) IsSQLite3() bool {
return t == "sqlite3"
}
-15
View File
@@ -1,15 +0,0 @@
//go:build sqlite
// Copyright 2014 The Gogs Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
_ "github.com/mattn/go-sqlite3"
)
func init() {
EnableSQLite3 = true
SupportedDatabaseTypes = append(SupportedDatabaseTypes, "sqlite3")
}
-109
View File
@@ -1,109 +0,0 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_parsePostgreSQLHostPort(t *testing.T) {
tests := map[string]struct {
HostPort string
Host string
Port string
}{
"host-port": {
HostPort: "127.0.0.1:1234",
Host: "127.0.0.1",
Port: "1234",
},
"no-port": {
HostPort: "127.0.0.1",
Host: "127.0.0.1",
Port: "5432",
},
"ipv6-port": {
HostPort: "[::1]:1234",
Host: "::1",
Port: "1234",
},
"ipv6-no-port": {
HostPort: "[::1]",
Host: "::1",
Port: "5432",
},
"unix-socket": {
HostPort: "/tmp/pg.sock:1234",
Host: "/tmp/pg.sock",
Port: "1234",
},
"unix-socket-no-port": {
HostPort: "/tmp/pg.sock",
Host: "/tmp/pg.sock",
Port: "5432",
},
}
for k, test := range tests {
t.Run(k, func(t *testing.T) {
t.Log(test.HostPort)
host, port := parsePostgreSQLHostPort(test.HostPort)
assert.Equal(t, test.Host, host)
assert.Equal(t, test.Port, port)
})
}
}
func Test_getPostgreSQLConnectionString(t *testing.T) {
tests := []struct {
Host string
User string
Passwd string
Name string
SSLMode string
Output string
}{
{
Host: "", // empty means default
Output: "postgres://:@127.0.0.1:5432?sslmode=",
},
{
Host: "/tmp/pg.sock",
User: "testuser",
Passwd: "space space !#$%^^%^```-=?=",
Name: "gitea",
SSLMode: "false",
Output: "postgres://testuser:space%20space%20%21%23$%25%5E%5E%25%5E%60%60%60-=%3F=@:5432/gitea?host=%2Ftmp%2Fpg.sock&sslmode=false",
},
{
Host: "/tmp/pg.sock:6432",
User: "testuser",
Passwd: "pass",
Name: "gitea",
SSLMode: "false",
Output: "postgres://testuser:pass@:6432/gitea?host=%2Ftmp%2Fpg.sock&sslmode=false",
},
{
Host: "localhost",
User: "pgsqlusername",
Passwd: "I love Gitea!",
Name: "gitea",
SSLMode: "true",
Output: "postgres://pgsqlusername:I%20love%20Gitea%21@localhost:5432/gitea?sslmode=true",
},
{
Host: "localhost:1234",
User: "user",
Passwd: "pass",
Name: "gitea?param=1",
Output: "postgres://user:pass@localhost:1234/gitea?param=1&sslmode=",
},
}
for _, test := range tests {
connStr := getPostgreSQLConnectionString(test.Host, test.User, test.Passwd, test.Name, test.SSLMode)
assert.Equal(t, test.Output, connStr)
}
}
+1 -1
View File
@@ -256,7 +256,7 @@ func initLoggerByName(manager *log.LoggerManager, rootCfg ConfigProvider, logger
}
func InitSQLLoggersForCli(level log.Level) {
log.SetConsoleLogger("xorm", "console", level)
log.SetupStderrLogger("xorm", "console-stderr", level)
}
func IsAccessLogEnabled() bool {
+1
View File
@@ -99,6 +99,7 @@ var OAuth2 = struct {
JWTClaimIssuer string `ini:"JWT_CLAIM_ISSUER"`
MaxTokenLength int
DefaultApplications []string
CustomSchemes []string
}{
Enabled: true,
AccessTokenExpirationTime: 3600,
+6
View File
@@ -198,6 +198,12 @@ func InitWorkPathAndCfgProvider(getEnvFn func(name string) string, args ArgWorkP
CustomConf = tmpCustomConf.Value
}
func MockBuiltinPaths(workPath, customPath, customConf string) func() {
oldApp, oldCustom, oldConf := appWorkPathBuiltin, customPathBuiltin, customConfBuiltin
appWorkPathBuiltin, customPathBuiltin, customConfBuiltin = workPath, customPath, customConf
return func() { appWorkPathBuiltin, customPathBuiltin, customConfBuiltin = oldApp, oldCustom, oldConf }
}
// AppDataTempDir returns a managed temporary directory for the application data.
// Using empty sub will get the managed base temp directory, and it's safe to delete it.
// Gitea only creates subdirectories under it, but not the APP_TEMP_PATH directory itself.
+9
View File
@@ -18,6 +18,12 @@ const (
RepoCreatingPublic = "public"
)
// enumerates the values for [repository.pull-request] DEFAULT_TITLE_SOURCE
const (
RepoPRTitleSourceFirstCommit = "first-commit"
RepoPRTitleSourceAuto = "auto"
)
// ItemsPerPage maximum items per page in forks, watchers and stars of a repo
const ItemsPerPage = 40
@@ -89,6 +95,7 @@ var (
RetargetChildrenOnMerge bool
DelayCheckForInactiveDays int
DefaultDeleteBranchAfterMerge bool
DefaultTitleSource string
} `ini:"repository.pull-request"`
// Issue Setting
@@ -213,6 +220,7 @@ var (
RetargetChildrenOnMerge bool
DelayCheckForInactiveDays int
DefaultDeleteBranchAfterMerge bool
DefaultTitleSource string
}{
WorkInProgressPrefixes: []string{"WIP:", "[WIP]"},
// Same as GitHub. See
@@ -229,6 +237,7 @@ var (
AddCoCommitterTrailers: true,
RetargetChildrenOnMerge: true,
DelayCheckForInactiveDays: 7,
DefaultTitleSource: RepoPRTitleSourceFirstCommit,
},
// Issue settings
+6 -2
View File
@@ -16,9 +16,11 @@ import (
// Security settings
var Security = struct {
// TODO: move more settings to this struct in future
XFrameOptions string
XFrameOptions string
XContentTypeOptions string
}{
XFrameOptions: "SAMEORIGIN",
XFrameOptions: "SAMEORIGIN",
XContentTypeOptions: "nosniff",
}
var (
@@ -154,6 +156,8 @@ func loadSecurityFrom(rootCfg ConfigProvider) {
Security.XFrameOptions = rootCfg.Section("cors").Key("X_FRAME_OPTIONS").MustString(Security.XFrameOptions)
}
Security.XContentTypeOptions = sec.Key("X_CONTENT_TYPE_OPTIONS").MustString(Security.XContentTypeOptions)
twoFactorAuth := sec.Key("TWO_FACTOR_AUTH").String()
switch twoFactorAuth {
case "":
+1 -70
View File
@@ -4,7 +4,6 @@
package setting
import (
"encoding/base64"
"net"
"net/url"
"os"
@@ -13,7 +12,6 @@ import (
"strings"
"time"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
)
@@ -112,72 +110,9 @@ var (
StartupTimeout time.Duration
PerWriteTimeout = 30 * time.Second
PerWritePerKbTimeout = 10 * time.Second
StaticURLPrefix string
AbsoluteAssetURL string
ManifestData string
StaticURLPrefix string // no trailing slash, defaults to AppSubURL, the URL can be relative or absolute
)
// MakeManifestData generates web app manifest JSON
func MakeManifestData(appName, appURL, absoluteAssetURL string) []byte {
type manifestIcon struct {
Src string `json:"src"`
Type string `json:"type"`
Sizes string `json:"sizes"`
}
type manifestJSON struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
StartURL string `json:"start_url"`
Icons []manifestIcon `json:"icons"`
}
bytes, err := json.Marshal(&manifestJSON{
Name: appName,
ShortName: appName,
StartURL: appURL,
Icons: []manifestIcon{
{
Src: absoluteAssetURL + "/assets/img/logo.png",
Type: "image/png",
Sizes: "512x512",
},
{
Src: absoluteAssetURL + "/assets/img/logo.svg",
Type: "image/svg+xml",
Sizes: "512x512",
},
},
})
if err != nil {
log.Error("unable to marshal manifest JSON. Error: %v", err)
return make([]byte, 0)
}
return bytes
}
// MakeAbsoluteAssetURL returns the absolute asset url prefix without a trailing slash
func MakeAbsoluteAssetURL(appURL *url.URL, staticURLPrefix string) string {
parsedPrefix, err := url.Parse(strings.TrimSuffix(staticURLPrefix, "/"))
if err != nil {
log.Fatal("Unable to parse STATIC_URL_PREFIX: %v", err)
}
if err == nil && parsedPrefix.Hostname() == "" {
if staticURLPrefix == "" {
return strings.TrimSuffix(appURL.String(), "/")
}
// StaticURLPrefix is just a path
appHostURL := &url.URL{Scheme: appURL.Scheme, Host: appURL.Host}
return appHostURL.String() + "/" + strings.Trim(staticURLPrefix, "/")
}
return strings.TrimSuffix(staticURLPrefix, "/")
}
func loadServerFrom(rootCfg ConfigProvider) {
sec := rootCfg.Section("server")
AppName = rootCfg.Section("").Key("APP_NAME").MustString("Gitea: Git with a cup of tea")
@@ -313,10 +248,6 @@ func loadServerFrom(rootCfg ConfigProvider) {
Domain = urlHostname
}
AbsoluteAssetURL = MakeAbsoluteAssetURL(appURL, StaticURLPrefix)
manifestBytes := MakeManifestData(AppName, AppURL, AbsoluteAssetURL)
ManifestData = `application/json;base64,` + base64.StdEncoding.EncodeToString(manifestBytes)
var defaultLocalURL string
switch Protocol {
case HTTPUnix:
+2 -2
View File
@@ -41,9 +41,9 @@ func init() {
AppVer = "dev"
}
// We can rely on log.CanColorStdout being set properly because modules/log/console_windows.go comes before modules/setting/setting.go lexicographically
// FIXME: the logger shouldn't be initialized here, the app entry should initialize the logger
// By default set this logger at Info - we'll change it later, but we need to start with something.
log.SetConsoleLogger(log.DEFAULT, "console", log.INFO)
log.SetupStderrLogger(log.DEFAULT, "console-stderr", log.INFO)
}
// IsRunUserMatchCurrentUser returns false if configured run user does not match
-41
View File
@@ -1,41 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"net/url"
"testing"
"code.gitea.io/gitea/modules/json"
"github.com/stretchr/testify/assert"
)
func TestMakeAbsoluteAssetURL(t *testing.T) {
appURL1, _ := url.Parse("https://localhost:1234")
appURL2, _ := url.Parse("https://localhost:1234/")
appURLSub1, _ := url.Parse("https://localhost:1234/foo")
appURLSub2, _ := url.Parse("https://localhost:1234/foo/")
// static URL is an absolute URL, so should be used
assert.Equal(t, "https://localhost:2345", MakeAbsoluteAssetURL(appURL1, "https://localhost:2345"))
assert.Equal(t, "https://localhost:2345", MakeAbsoluteAssetURL(appURL1, "https://localhost:2345/"))
assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURL1, "/foo"))
assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURL2, "/foo"))
assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURL1, "/foo/"))
assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURLSub1, "/foo"))
assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURLSub2, "/foo"))
assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURLSub1, "/foo/"))
assert.Equal(t, "https://localhost:1234/bar", MakeAbsoluteAssetURL(appURLSub1, "/bar"))
assert.Equal(t, "https://localhost:1234/bar", MakeAbsoluteAssetURL(appURLSub2, "/bar"))
assert.Equal(t, "https://localhost:1234/bar", MakeAbsoluteAssetURL(appURLSub1, "/bar/"))
}
func TestMakeManifestData(t *testing.T) {
jsonBytes := MakeManifestData(`Example App '\"`, "https://example.com", "https://example.com/foo/bar")
assert.True(t, json.Valid(jsonBytes))
}
+136 -33
View File
@@ -4,69 +4,172 @@
package setting
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"code.gitea.io/gitea/modules/auth/password/hash"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
"github.com/kballard/go-shellquote"
)
var giteaTestSourceRoot *string
var giteaTestSourceRoot *string // intentionally use a pointer to make sure the uninitialized access panics
func GetGiteaTestSourceRoot() string {
return *giteaTestSourceRoot
}
func detectGiteaTestRoot() string {
_, filename, _, _ := runtime.Caller(0)
giteaRoot := filepath.Dir(filepath.Dir(filepath.Dir(filename)))
fixturesDir := filepath.Join(giteaRoot, "models", "fixtures")
if _, err := os.Stat(fixturesDir); err != nil {
panic("in gitea source code directory, fixtures directory not found: " + fixturesDir)
}
return giteaRoot
}
func SetupGiteaTestEnv() {
if giteaTestSourceRoot != nil {
return // already initialized
}
IsInTesting = true
giteaRoot := os.Getenv("GITEA_TEST_ROOT")
if giteaRoot == "" {
_, filename, _, _ := runtime.Caller(0)
giteaRoot = filepath.Dir(filepath.Dir(filepath.Dir(filename)))
fixturesDir := filepath.Join(giteaRoot, "models", "fixtures")
if _, err := os.Stat(fixturesDir); err != nil {
panic("in gitea source code directory, fixtures directory not found: " + fixturesDir)
log.OsExiter = func(code int) {
if code != 0 {
// Non-zero exit code (log.Fatal) shouldn't occur during testing, if it happens:
// * Show a full stacktrace for more details.
// * If the "log.Fatal" is abused in tests, should fix.
panic(fmt.Errorf("non-zero exit code during testing: %d", code))
}
os.Exit(0)
}
initGiteaRoot := func() string {
giteaRoot := os.Getenv("GITEA_TEST_ROOT")
if giteaRoot == "" {
giteaRoot = detectGiteaTestRoot()
}
giteaTestSourceRoot = &giteaRoot
return giteaRoot
}
giteaRoot := initGiteaRoot()
initGiteaPaths := func() {
// need to load assets (options, public) from the source code directory for testing
StaticRootPath = giteaRoot
// during testing, the AppPath must point to the pre-built Gitea binary in the source root
// it needs to be called by git hooks
AppPath = filepath.Join(giteaRoot, "gitea") + util.Iif(IsWindows, ".exe", "")
}
initGiteaConf := func() string {
// giteaConf (GITEA_CONF) must be relative because it is used in the git hooks as "$GITEA_ROOT/$GITEA_CONF"
giteaConf := os.Getenv("GITEA_TEST_CONF")
if giteaConf == "" {
// if no GITEA_TEST_CONF, then it is in unit test, use a temp (non-existing / empty) config file
// do not really use such config file, the test can run concurrently, using the same config file will cause data-race between tests
giteaConf = "custom/conf/app-test-tmp.ini"
customConfBuiltin = filepath.Join(AppWorkPath, giteaConf)
CustomConf = customConfBuiltin
_ = os.Remove(CustomConf)
} else {
// CustomConf must be absolute path to make tests pass.
// At the moment, GITEA_TEST_CONF is always in Gitea's source root
CustomConf = filepath.Join(giteaRoot, giteaConf)
}
return giteaConf
}
cleanUpEnv := func() {
// also unset unnecessary env vars for testing (only keep "GITEA_TEST_*" ones)
UnsetUnnecessaryEnvVars()
for _, env := range os.Environ() {
if strings.HasPrefix(env, "GIT_") || (strings.HasPrefix(env, "GITEA_") && !strings.HasPrefix(env, "GITEA_TEST_")) {
k, _, _ := strings.Cut(env, "=")
_ = os.Unsetenv(k)
}
}
}
appWorkPathBuiltin = giteaRoot
AppWorkPath = giteaRoot
AppPath = filepath.Join(giteaRoot, "gitea") + util.Iif(IsWindows, ".exe", "")
StaticRootPath = giteaRoot // need to load assets (options, public) from the source code directory for testing
initWorkPathAndConfig := func() {
// init paths and config system for testing
getTestEnv := func(key string) string { return "" }
InitWorkPathAndCommonConfig(getTestEnv, ArgWorkPathAndCustomConf{CustomConf: CustomConf})
// giteaConf (GITEA_CONF) must be relative because it is used in the git hooks as "$GITEA_ROOT/$GITEA_CONF"
giteaConf := os.Getenv("GITEA_TEST_CONF")
if giteaConf == "" {
// By default, use sqlite.ini for testing, then IDE like GoLand can start the test process with debugger.
// It's easier for developers to debug bugs step by step with a debugger.
// Notice: when doing "ssh push", Gitea executes sub processes, debugger won't work for the sub processes.
giteaConf = "tests/sqlite.ini"
_, _ = fmt.Fprintf(os.Stderr, "Environment variable GITEA_TEST_CONF not set - defaulting to %s\n", giteaConf)
if !EnableSQLite3 {
_, _ = fmt.Fprintf(os.Stderr, "sqlite3 requires: -tags sqlite,sqlite_unlock_notify\n")
os.Exit(1)
if err := PrepareAppDataPath(); err != nil {
log.Fatal("Can not prepare APP_DATA_PATH: %v", err)
}
// register the dummy hash algorithm function used in the test fixtures
_ = hash.Register("dummy", hash.NewDummyHasher)
PasswordHashAlgo, _ = hash.SetDefaultPasswordHashAlgorithm("dummy")
}
// CustomConf must be absolute path to make tests pass,
CustomConf = filepath.Join(AppWorkPath, giteaConf)
// also unset unnecessary env vars for testing (only keep "GITEA_TEST_*" ones)
UnsetUnnecessaryEnvVars()
for _, env := range os.Environ() {
if strings.HasPrefix(env, "GIT_") || (strings.HasPrefix(env, "GITEA_") && !strings.HasPrefix(env, "GITEA_TEST_")) {
k, _, _ := strings.Cut(env, "=")
_ = os.Unsetenv(k)
}
initGiteaPaths()
giteaConf := initGiteaConf()
cleanUpEnv()
initWorkPathAndConfig()
if RepoRootPath == "" || AppDataPath == "" {
panic("SetupGiteaTestEnv failed, paths are not initialized")
}
// TODO: some git repo hooks (test fixtures) still use these env variables, need to be refactored in the future
_ = os.Setenv("GITEA_ROOT", giteaRoot)
_ = os.Setenv("GITEA_CONF", giteaConf) // test fixture git hooks use "$GITEA_ROOT/$GITEA_CONF" in their scripts
giteaTestSourceRoot = &giteaRoot
}
func PrepareIntegrationTestConfig() error {
giteaTestRoot := detectGiteaTestRoot()
isInCI := os.Getenv("CI") != ""
testDatabase := os.Getenv("GITEA_TEST_DATABASE")
if testDatabase == "" {
if isInCI {
return errors.New("GITEA_TEST_DATABASE environment variable not set")
}
// for local development, default to sqlite. CI needs to explicitly set a database to avoid unexpected results
testDatabase = "sqlite"
_, _ = fmt.Fprintf(os.Stderr, "Environment variable GITEA_TEST_DATABASE not set - defaulting to %s\n", testDatabase)
}
_ = os.Setenv("GITEA_TEST_ROOT", giteaTestRoot)
_ = os.Setenv("GITEA_TEST_CONF", filepath.Join("tests", testDatabase+".ini"))
workPath := filepath.Join(giteaTestRoot, "tests/integration/gitea-integration-"+testDatabase)
if err := os.MkdirAll(workPath, 0o755); err != nil {
return err
}
confFile := filepath.Join(giteaTestRoot, "tests", testDatabase+".ini")
tmplBuf, err := os.ReadFile(confFile + ".tmpl")
if err != nil {
return err
}
tmpl := string(tmplBuf)
envVars, err := shellquote.Split(os.Getenv("MAKEFILE_VARS"))
if err != nil {
return err
}
envVarMap := map[string]string{
"TEST_WORK_PATH": workPath,
"TEST_LOGGER": "test,file",
}
for _, env := range append(os.Environ(), envVars...) {
k, v, _ := strings.Cut(env, "=")
k = strings.TrimSpace(k)
v = strings.TrimSpace(v)
envVarMap[k] = v
}
for k, v := range envVarMap {
tmpl = strings.ReplaceAll(tmpl, fmt.Sprintf("{{%s}}", k), v)
}
err = os.WriteFile(confFile, []byte(tmpl), 0o644)
return err
}
-19
View File
@@ -21,25 +21,6 @@ import (
// ErrURLNotSupported represents url is not supported
var ErrURLNotSupported = errors.New("url method not supported")
// ErrInvalidConfiguration is called when there is invalid configuration for a storage
type ErrInvalidConfiguration struct {
cfg any
err error
}
func (err ErrInvalidConfiguration) Error() string {
if err.err != nil {
return fmt.Sprintf("Invalid Configuration Argument: %v: Error: %v", err.cfg, err.err)
}
return fmt.Sprintf("Invalid Configuration Argument: %v", err.cfg)
}
// IsErrInvalidConfiguration checks if an error is an ErrInvalidConfiguration
func IsErrInvalidConfiguration(err error) bool {
_, ok := err.(ErrInvalidConfiguration)
return ok
}
type Type = setting.StorageType
// NewStorageFunc is a function that creates a storage
+2 -2
View File
@@ -30,7 +30,7 @@ type CreateUserOption struct {
// Whether the user has restricted access privileges
Restricted *bool `json:"restricted"`
// User visibility level: public, limited, or private
Visibility string `json:"visibility" binding:"In(,public,limited,private)"`
Visibility UserVisibility `json:"visibility" binding:"In(,public,limited,private)"`
// For explicitly setting the user creation timestamp. Useful when users are
// migrated from other systems. When omitted, the user's creation timestamp
@@ -79,5 +79,5 @@ type EditUserOption struct {
// Whether the user has restricted access privileges
Restricted *bool `json:"restricted"`
// User visibility level: public, limited, or private
Visibility string `json:"visibility" binding:"In(,public,limited,private)"`
Visibility UserVisibility `json:"visibility" binding:"In(,public,limited,private)"`
}
+7 -2
View File
@@ -60,6 +60,7 @@ type Issue struct {
Attachments []*Attachment `json:"assets"`
Labels []*Label `json:"labels"`
Milestone *Milestone `json:"milestone"`
Projects []*Project `json:"projects"`
// deprecated
Assignee *User `json:"assignee"`
Assignees []*User `json:"assignees"`
@@ -100,7 +101,9 @@ type CreateIssueOption struct {
Milestone int64 `json:"milestone"`
// list of label ids
Labels []int64 `json:"labels"`
Closed bool `json:"closed"`
// list of project ids
Projects []int64 `json:"projects"`
Closed bool `json:"closed"`
}
// EditIssueOption options for editing an issue
@@ -112,7 +115,9 @@ type EditIssueOption struct {
Assignee *string `json:"assignee"`
Assignees []string `json:"assignees"`
Milestone *int64 `json:"milestone"`
State *string `json:"state"`
// list of project ids to set (replaces existing projects)
Projects *[]int64 `json:"projects"`
State *string `json:"state"`
// swagger:strfmt date-time
Deadline *time.Time `json:"due_date"`
RemoveDeadline *bool `json:"unset_due_date"`
+4 -4
View File
@@ -68,12 +68,12 @@ const (
type NotifySubjectType string
const (
// NotifySubjectIssue an issue is subject of an notification
// NotifySubjectIssue a issue is subject of an notification
NotifySubjectIssue NotifySubjectType = "Issue"
// NotifySubjectPull an pull is subject of an notification
// NotifySubjectPull a pull is subject of an notification
NotifySubjectPull NotifySubjectType = "Pull"
// NotifySubjectCommit an commit is subject of an notification
// NotifySubjectCommit a commit is subject of an notification
NotifySubjectCommit NotifySubjectType = "Commit"
// NotifySubjectRepository an repository is subject of an notification
// NotifySubjectRepository a repository is subject of an notification
NotifySubjectRepository NotifySubjectType = "Repository"
)
+3 -5
View File
@@ -22,7 +22,7 @@ type Organization struct {
// The location of the organization
Location string `json:"location"`
// The visibility level of the organization (public, limited, private)
Visibility string `json:"visibility"`
Visibility UserVisibility `json:"visibility"`
// Whether repository administrators can change team access
RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access"`
// username of the organization
@@ -60,8 +60,7 @@ type CreateOrgOption struct {
// The location of the organization
Location string `json:"location" binding:"MaxSize(50)"`
// possible values are `public` (default), `limited` or `private`
// enum: ["public","limited","private"]
Visibility string `json:"visibility" binding:"In(,public,limited,private)"`
Visibility UserVisibility `json:"visibility" binding:"In(,public,limited,private)"`
// Whether repository administrators can change team access
RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access"`
}
@@ -79,8 +78,7 @@ type EditOrgOption struct {
// The location of the organization
Location *string `json:"location" binding:"MaxSize(50)"`
// possible values are `public`, `limited` or `private`
// enum: ["public","limited","private"]
Visibility *string `json:"visibility" binding:"In(,public,limited,private)"`
Visibility *UserVisibility `json:"visibility" binding:"In(,public,limited,private)"`
// Whether repository administrators can change team access
RepoAdminChangeTeamAccess *bool `json:"repo_admin_change_team_access"`
}
+6 -9
View File
@@ -15,9 +15,8 @@ type Team struct {
// The organization that the team belongs to
Organization *Organization `json:"organization"`
// Whether the team has access to all repositories in the organization
IncludesAllRepositories bool `json:"includes_all_repositories"`
// enum: ["none","read","write","admin","owner"]
Permission string `json:"permission"`
IncludesAllRepositories bool `json:"includes_all_repositories"`
Permission AccessLevelName `json:"permission"`
// example: ["repo.code","repo.issues","repo.ext_issues","repo.wiki","repo.pulls","repo.releases","repo.projects","repo.ext_wiki"]
// Deprecated: This variable should be replaced by UnitsMap and will be dropped in later versions.
Units []string `json:"units"`
@@ -34,9 +33,8 @@ type CreateTeamOption struct {
// The description of the team
Description string `json:"description" binding:"MaxSize(255)"`
// Whether the team has access to all repositories in the organization
IncludesAllRepositories bool `json:"includes_all_repositories"`
// enum: ["read","write","admin"]
Permission string `json:"permission"`
IncludesAllRepositories bool `json:"includes_all_repositories"`
Permission RepoWritePermission `json:"permission"`
// example: ["repo.actions","repo.code","repo.issues","repo.ext_issues","repo.wiki","repo.ext_wiki","repo.pulls","repo.releases","repo.projects","repo.ext_wiki"]
// Deprecated: This variable should be replaced by UnitsMap and will be dropped in later versions.
Units []string `json:"units"`
@@ -53,9 +51,8 @@ type EditTeamOption struct {
// The description of the team
Description *string `json:"description" binding:"MaxSize(255)"`
// Whether the team has access to all repositories in the organization
IncludesAllRepositories *bool `json:"includes_all_repositories"`
// enum: ["read","write","admin"]
Permission string `json:"permission"`
IncludesAllRepositories *bool `json:"includes_all_repositories"`
Permission RepoWritePermission `json:"permission"`
// example: ["repo.code","repo.issues","repo.ext_issues","repo.wiki","repo.pulls","repo.releases","repo.projects","repo.ext_wiki"]
// Deprecated: This variable should be replaced by UnitsMap and will be dropped in later versions.
Units []string `json:"units"`
+33
View File
@@ -0,0 +1,33 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package structs
import (
"time"
)
// Project represents a project
// swagger:model
type Project struct {
// ID is the unique identifier for the project
ID int64 `json:"id"`
// Title is the title of the project
Title string `json:"title"`
// Description provides details about the project
Description string `json:"description"`
// OwnerID is the owner of the project (for org-level projects)
OwnerID int64 `json:"owner_id,omitempty"`
// RepoID is the repository this project belongs to (for repo-level projects)
RepoID int64 `json:"repo_id,omitempty"`
// CreatorID is the user who created the project
CreatorID int64 `json:"creator_id"`
// IsClosed indicates if the project is closed
IsClosed bool `json:"is_closed"`
// swagger:strfmt date-time
Created time.Time `json:"created_at"`
// swagger:strfmt date-time
Updated time.Time `json:"updated_at"`
// swagger:strfmt date-time
Closed *time.Time `json:"closed_at,omitempty"`
}
+5
View File
@@ -94,6 +94,11 @@ type CreatePullReviewComment struct {
NewLineNum int64 `json:"new_position"`
}
// CreatePullReviewCommentReplyOptions are options to reply to a pull request review comment
type CreatePullReviewCommentReplyOptions struct {
Body string `json:"body" binding:"Required"`
}
// SubmitPullReviewOptions are options to submit a pending pull request review
type SubmitPullReviewOptions struct {
Event ReviewStateType `json:"event"`
+17 -4
View File
@@ -8,6 +8,15 @@ import (
"time"
)
// ObjectFormatName is the git hash algorithm used by a repository.
// swagger:enum ObjectFormatName
type ObjectFormatName string
const (
ObjectFormatSHA1 ObjectFormatName = "sha1"
ObjectFormatSHA256 ObjectFormatName = "sha256"
)
// Permission represents a set of permissions
type Permission struct {
Admin bool `json:"admin"` // Admin indicates if the user is an administrator of the repository.
@@ -114,8 +123,7 @@ type Repository struct {
Internal bool `json:"internal"`
MirrorInterval string `json:"mirror_interval"`
// ObjectFormatName of the underlying git repository
// enum: ["sha1","sha256"]
ObjectFormatName string `json:"object_format_name"`
ObjectFormatName ObjectFormatName `json:"object_format_name"`
// swagger:strfmt date-time
MirrorUpdated time.Time `json:"mirror_updated"`
RepoTransfer *RepoTransfer `json:"repo_transfer,omitempty"`
@@ -153,8 +161,7 @@ type CreateRepoOption struct {
// enum: ["default","collaborator","committer","collaboratorcommitter"]
TrustModel string `json:"trust_model"`
// ObjectFormatName of the underlying git repository, empty string for default (sha1)
// enum: ["sha1","sha256"]
ObjectFormatName string `json:"object_format_name" binding:"MaxSize(6)"`
ObjectFormatName ObjectFormatName `json:"object_format_name" binding:"MaxSize(6)"`
}
// EditRepoOption options when editing a repository's properties
@@ -229,6 +236,12 @@ type EditRepoOption struct {
MirrorInterval *string `json:"mirror_interval,omitempty"`
// enable prune - remove obsolete remote-tracking references when mirroring
EnablePrune *bool `json:"enable_prune,omitempty"`
// authentication username for the remote repository (mirrors)
MirrorUsername *string `json:"mirror_username,omitempty"`
// authentication password for the remote repository (mirrors)
MirrorPassword *string `json:"mirror_password,omitempty"`
// authentication token for the remote repository (mirrors)
MirrorToken *string `json:"mirror_token,omitempty"`
}
// GenerateRepoOption options when creating a repository using a template
+12 -6
View File
@@ -105,12 +105,18 @@ type ActionArtifact struct {
// ActionWorkflowRun represents a WorkflowRun
type ActionWorkflowRun struct {
ID int64 `json:"id"`
URL string `json:"url"`
HTMLURL string `json:"html_url"`
DisplayTitle string `json:"display_title"`
Path string `json:"path"`
Event string `json:"event"`
ID int64 `json:"id"`
URL string `json:"url"`
// PreviousAttemptURL is the API URL of the previous attempt of this run, e.g. ".../actions/runs/{run_id}/attempts/{attempt-1}".
// It is set only when the current attempt is > 1 (i.e. a rerun). For the first attempt, or for legacy runs that pre-date ActionRunAttempt, it is null.
PreviousAttemptURL *string `json:"previous_attempt_url"`
HTMLURL string `json:"html_url"`
DisplayTitle string `json:"display_title"`
Path string `json:"path"`
Event string `json:"event"`
// RunAttempt is 1-based for runs created after ActionRunAttempt was introduced.
// A value of 0 is a legacy-only sentinel for runs created before attempts existed
// and indicates no corresponding /attempts/{n} resource is available.
RunAttempt int64 `json:"run_attempt"`
RunNumber int64 `json:"run_number"`
RepositoryID int64 `json:"repository_id,omitempty"`
+27 -3
View File
@@ -3,17 +3,41 @@
package structs
// RepoWritePermission is a permission level callers may grant to a team or
// collaborator on input. Output fields use AccessLevelName instead.
// swagger:enum RepoWritePermission
type RepoWritePermission string
const (
RepoWritePermissionRead RepoWritePermission = "read"
RepoWritePermissionWrite RepoWritePermission = "write"
RepoWritePermissionAdmin RepoWritePermission = "admin"
)
// AccessLevelName is the string rendering of a perm.AccessMode produced on
// API responses. Callers must not send these values; use RepoWritePermission
// on input.
// swagger:enum AccessLevelName
type AccessLevelName string
const (
AccessLevelNameNone AccessLevelName = "none"
AccessLevelNameRead AccessLevelName = "read"
AccessLevelNameWrite AccessLevelName = "write"
AccessLevelNameAdmin AccessLevelName = "admin"
AccessLevelNameOwner AccessLevelName = "owner"
)
// AddCollaboratorOption options when adding a user as a collaborator of a repository
type AddCollaboratorOption struct {
// enum: ["read","write","admin"]
// Permission level to grant the collaborator
Permission *string `json:"permission"`
Permission *RepoWritePermission `json:"permission"`
}
// RepoCollaboratorPermission to get repository permission for a collaborator
type RepoCollaboratorPermission struct {
// Permission level of the collaborator
Permission string `json:"permission"`
Permission AccessLevelName `json:"permission"`
// RoleName is the name of the permission role
RoleName string `json:"role_name"`
// User information of the collaborator
+1 -1
View File
@@ -51,7 +51,7 @@ type User struct {
// the user's description
Description string `json:"description"`
// User visibility level option: public, limited, private
Visibility string `json:"visibility"`
Visibility UserVisibility `json:"visibility"`
// user counts
Followers int `json:"followers_count"`
+11
View File
@@ -56,3 +56,14 @@ func ExtractKeysFromMapString(in map[string]VisibleType) (keys []string) {
}
return keys
}
// UserVisibility defines the visibility level of a user or organization as
// rendered in API payloads. The DB representation is VisibleType (int).
// swagger:enum UserVisibility
type UserVisibility string
const (
UserVisibilityPublic UserVisibility = "public"
UserVisibilityLimited UserVisibility = "limited"
UserVisibilityPrivate UserVisibility = "private"
)
+1 -31
View File
@@ -6,12 +6,10 @@ package templates
import (
"fmt"
"html"
"html/template"
"net/url"
"strconv"
"strings"
"sync"
"time"
"code.gitea.io/gitea/modules/base"
@@ -69,8 +67,7 @@ func newFuncMapWebPage() template.FuncMap {
return strconv.FormatInt(time.Since(startTime).Nanoseconds()/1e6, 10) + "ms"
},
"AssetURI": public.AssetURI,
"ScriptImport": scriptImport,
"AssetURI": public.AssetURI,
// -----------------------------------------------------------------
// setting
@@ -290,30 +287,3 @@ func QueryBuild(a ...any) template.URL {
}
return template.URL(s)
}
var globalVars = sync.OnceValue(func() (ret struct {
scriptImportRemainingPart string
},
) {
// add onerror handler to alert users when the script fails to load:
// * for end users: there were many users reporting that "UI doesn't work", actually they made mistakes in their config
// * for developers: help them to remember to run "make watch-frontend" to build frontend assets
// the message will be directly put in the onerror JS code's string
onScriptErrorPrompt := `Please make sure the asset files can be accessed.`
if !setting.IsProd {
onScriptErrorPrompt += `\n\nFor development, run: make watch-frontend.`
}
onScriptErrorJS := fmt.Sprintf(`alert('Failed to load asset file from ' + this.src + '. %s')`, onScriptErrorPrompt)
ret.scriptImportRemainingPart = `onerror="` + html.EscapeString(onScriptErrorJS) + `"></script>`
return ret
})
func scriptImport(path string, typ ...string) template.HTML {
if len(typ) > 0 {
if typ[0] == "module" {
return template.HTML(`<script type="module" src="` + html.EscapeString(public.AssetURI(path)) + `" ` + globalVars().scriptImportRemainingPart)
}
panic("unsupported script type: " + typ[0])
}
return template.HTML(`<script src="` + html.EscapeString(public.AssetURI(path)) + `" ` + globalVars().scriptImportRemainingPart)
}
+16 -3
View File
@@ -5,6 +5,7 @@ package templates
import (
"html/template"
"net/url"
"strings"
"testing"
@@ -169,9 +170,21 @@ func TestQueryBuild(t *testing.T) {
})
}
const queryNonASCII = " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" // all non-letter & non-number chars
func TestQueryEscape(t *testing.T) {
// this test is a reference for "urlQueryEscape" in JS
in := "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" // all non-letter & non-number chars
expected := "%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~"
assert.Equal(t, expected, string(queryEscape(in)))
// Special case for space encoding:
// * RFC 3986: Uniform Resource Identifier (URI): %20
// * WHATWG HTML: application/x-www-form-urlencoded: +
// * JavaScript: encodeURIComponent() uses "%20". URLSearchParams uses "+"
// * Golang: QueryEscape uses "+"
expected := "+%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~"
assert.Equal(t, expected, url.QueryEscape(queryNonASCII))
}
func TestPathEscape(t *testing.T) {
// this test is a reference for "pathEscape" in JS
expected := "%20%21%22%23$%25&%27%28%29%2A+%2C-.%2F:%3B%3C=%3E%3F@%5B%5C%5D%5E_%60%7B%7C%7D~"
assert.Equal(t, expected, url.PathEscape(queryNonASCII))
}
+6 -1
View File
@@ -5,6 +5,7 @@ package templates
import (
"context"
"fmt"
"html/template"
"io"
"net/http"
@@ -36,7 +37,11 @@ func (r *pageRenderer) funcMapDummy() template.FuncMap {
}
func (r *pageRenderer) TemplateLookup(tmpl string, templateCtx context.Context) (TemplateExecutor, error) { //nolint:revive // we don't use ctx, only pass it to the template executor
return r.tmplRenderer.Templates().Executor(tmpl, r.funcMap(templateCtx))
tmpls := r.tmplRenderer.Templates()
if tmpls == nil {
return nil, fmt.Errorf("no templates defined for %s", tmpl)
}
return tmpls.Executor(tmpl, r.funcMap(templateCtx))
}
func (r *pageRenderer) HTML(w io.Writer, status int, tplName TplName, data any, templateCtx context.Context) error { //nolint:revive // we don't use ctx, only pass it to the template executor
+2 -2
View File
@@ -10,9 +10,9 @@ package templates
import (
"sync"
_ "embed"
"code.gitea.io/gitea/modules/assetfs"
_ "embed"
)
//go:embed bindata.dat
+31
View File
@@ -6,6 +6,11 @@ package templates
import (
"fmt"
"reflect"
"slices"
"strconv"
"strings"
"code.gitea.io/gitea/modules/util"
)
type SliceUtils struct{}
@@ -33,3 +38,29 @@ func (su *SliceUtils) Contains(s, v any) bool {
}
return false
}
// JoinInt64 joins a slice of int64 values into a comma-separated string.
func (su *SliceUtils) JoinInt64(values []int64) string {
if len(values) == 0 {
return ""
}
strs := make([]string, len(values))
for i, v := range values {
strs[i] = strconv.FormatInt(v, 10)
}
return strings.Join(strs, ",")
}
func (su *SliceUtils) JoinToggleIDs(values []int64, target int64) (ret struct {
IsIncluded bool
ToggledIDs string
},
) {
ret.IsIncluded = slices.Contains(values, target)
if ret.IsIncluded {
ret.ToggledIDs = su.JoinInt64(util.SliceRemoveAll(slices.Clone(values), target))
} else {
ret.ToggledIDs = su.JoinInt64(append(values, target))
}
return ret
}
+10
View File
@@ -70,6 +70,16 @@ func TestUtils(t *testing.T) {
actual = execTmpl("{{StringUtils.Contains .String .Value}}", map[string]any{"String": "abc", "Value": "x"})
assert.Equal(t, "false", actual)
// Test JoinInt64
actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{1, 2, 3}})
assert.Equal(t, "1,2,3", actual)
actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{}})
assert.Empty(t, actual)
actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{42}})
assert.Equal(t, "42", actual)
tmpl := template.New("test")
tmpl.Funcs(template.FuncMap{"SliceUtils": NewSliceUtils, "StringUtils": NewStringUtils})
template.Must(tmpl.Parse("{{SliceUtils.Contains .Slice .Value}}"))
+13 -1
View File
@@ -5,6 +5,7 @@ package test
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"io"
@@ -46,7 +47,7 @@ func ParseJSONError(buf []byte) (ret struct {
}
func ParseJSONRedirect(buf []byte) (ret struct {
Redirect string `json:"redirect"`
Redirect *string `json:"redirect"`
},
) {
_ = json.Unmarshal(buf, &ret)
@@ -97,6 +98,17 @@ func WriteTarArchive(files map[string]string) *bytes.Buffer {
return WriteTarCompression(func(w io.Writer) io.WriteCloser { return util.NopCloser{Writer: w} }, files)
}
func WriteZipArchive(files map[string]string) *bytes.Buffer {
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
for name, content := range files {
w, _ := zw.Create(name)
_, _ = w.Write([]byte(content))
}
_ = zw.Close()
return buf
}
func WriteTarCompression[F func(io.Writer) io.WriteCloser | func(io.Writer) (io.WriteCloser, error)](compression F, files map[string]string) *bytes.Buffer {
buf := &bytes.Buffer{}
var cw io.WriteCloser
+11 -10
View File
@@ -90,8 +90,8 @@ func (w *testLoggerWriterCloser) Reset() {
w.Unlock()
}
// Printf takes a format and args and prints the string to os.Stdout
func Printf(format string, args ...any) {
// stdoutPrintf takes a format and args and prints the string to os.Stdout
func stdoutPrintf(format string, args ...any) {
if !log.CanColorStdout {
for i := range args {
if c, ok := args[i].(*log.ColoredValue); ok {
@@ -118,20 +118,20 @@ func PrintCurrentTest(t testing.TB, skip ...int) func() {
deferHasRun := false
t.Cleanup(func() {
if !deferHasRun {
Printf("!!! %s defer function hasn't been run but Cleanup is called, usually caused by panic", t.Name())
stdoutPrintf("!!! %s: defer function hasn't been run but Cleanup is called, usually caused by panic\n", t.Name())
}
})
Printf("=== %s (%s:%d)\n", log.NewColoredValue(t.Name()), strings.TrimPrefix(filename, prefix), line)
stdoutPrintf("=== %s (%s:%d)\n", log.NewColoredValue(t.Name()), strings.TrimPrefix(filename, prefix), line)
WriterCloser.pushT(t)
timeoutChecker := time.AfterFunc(TestTimeout, func() {
Printf("!!! %s ... timeout: %v ... stacktrace:\n%s\n\n", log.NewColoredValue(t.Name(), log.Bold, log.FgRed), TestTimeout, getRuntimeStackAll())
stdoutPrintf("!!! %s ... timeout: %v ... stacktrace:\n%s\n\n", log.NewColoredValue(t.Name(), log.Bold, log.FgRed), TestTimeout, getRuntimeStackAll())
})
return func() {
deferHasRun = true
flushStart := time.Now()
slowFlushChecker := time.AfterFunc(TestSlowFlush, func() {
Printf("+++ %s ... still flushing after %v ...\n", log.NewColoredValue(t.Name(), log.Bold, log.FgRed), TestSlowFlush)
stdoutPrintf("+++ %s ... still flushing after %v ...\n", log.NewColoredValue(t.Name(), log.Bold, log.FgRed), TestSlowFlush)
})
if err := queue.GetManager().FlushAll(t.Context(), -1); err != nil {
// if panic occurs, then the t.Context() is also cancelled ahead, so here it shows "context canceled" error.
@@ -143,7 +143,7 @@ func PrintCurrentTest(t testing.TB, skip ...int) func() {
runDuration := time.Since(runStart)
flushDuration := time.Since(flushStart)
if runDuration > TestSlowRun {
Printf("+++ %s is a slow test (run: %v, flush: %v)\n", log.NewColoredValue(t.Name(), log.Bold, log.FgYellow), runDuration, flushDuration)
stdoutPrintf("+++ %s is a slow test (run: %v, flush: %v)\n", log.NewColoredValue(t.Name(), log.Bold, log.FgYellow), runDuration, flushDuration)
}
WriterCloser.popT()
}
@@ -173,7 +173,8 @@ func Init() {
log.RegisterEventWriter("test", newTestLoggerWriter)
}
func Panicf(format string, args ...any) {
// don't call os.Exit, otherwise the "defer" functions won't be executed
panic(fmt.Sprintf(format, args...))
// MainErrorf is used to report an error from TestMain and return a non-zero value to indicate the failure
func MainErrorf(msg string, a ...any) int {
_, _ = fmt.Fprintf(os.Stderr, msg+"\n", a...)
return 1
}
+74
View File
@@ -0,0 +1,74 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package util
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDiffSliceBasic(t *testing.T) {
// Typical integer cases
t.Run("additions", func(t *testing.T) {
added, removed := DiffSlice([]int{1, 2}, []int{1, 2, 3})
assert.Equal(t, []int{3}, added)
assert.Empty(t, removed)
})
t.Run("removals", func(t *testing.T) {
added, removed := DiffSlice([]int{1, 2, 3}, []int{1, 2})
assert.Empty(t, added)
assert.Equal(t, []int{3}, removed)
})
t.Run("no changes", func(t *testing.T) {
added, removed := DiffSlice([]int{1, 2}, []int{1, 2})
assert.Empty(t, added)
assert.Empty(t, removed)
})
t.Run("empty slices", func(t *testing.T) {
added, removed := DiffSlice([]int{}, []int{})
assert.Empty(t, added)
assert.Empty(t, removed)
})
t.Run("overlapping elements", func(t *testing.T) {
added, removed := DiffSlice([]int{1, 2, 4}, []int{2, 3, 4})
assert.Equal(t, []int{3}, added)
assert.Equal(t, []int{1}, removed)
})
}
func TestDiffSliceOrderAndDuplicates(t *testing.T) {
oldSlice := []int{1, 2, 2, 3}
newSlice := []int{2, 4, 2, 5}
added, removed := DiffSlice(oldSlice, newSlice)
assert.Equal(t, []int{4, 5}, added)
assert.Equal(t, []int{1, 3}, removed)
}
func TestDiffSliceDeduplicatesOutput(t *testing.T) {
// Test case from issue: newSlice contains [4, 4, 5] and oldSlice is [1]
// added should return [4, 5], not [4, 4, 5]
t.Run("deduplicates added", func(t *testing.T) {
added, removed := DiffSlice([]int{1}, []int{4, 4, 5})
assert.Equal(t, []int{4, 5}, added)
assert.Equal(t, []int{1}, removed)
})
t.Run("deduplicates removed", func(t *testing.T) {
added, removed := DiffSlice([]int{1, 1, 2}, []int{3})
assert.Equal(t, []int{3}, added)
assert.Equal(t, []int{1, 2}, removed)
})
t.Run("deduplicates both", func(t *testing.T) {
added, removed := DiffSlice([]int{1, 1, 2, 2}, []int{3, 3, 4, 4})
assert.Equal(t, []int{3, 4}, added)
assert.Equal(t, []int{1, 2}, removed)
})
}
+89 -33
View File
@@ -5,7 +5,8 @@ package util
import (
"bytes"
"unicode"
"net"
"strings"
)
type sanitizedError struct {
@@ -25,48 +26,103 @@ func SanitizeErrorCredentialURLs(err error) error {
return sanitizedError{err: err}
}
const userPlaceholder = "sanitized-credential"
var schemeSep = []byte("://")
// SanitizeCredentialURLs remove all credentials in URLs (starting with "scheme://") for the input string: "https://user:pass@domain.com" => "https://sanitized-credential@domain.com"
const userInfoPlaceholder = "(masked)"
// SanitizeCredentialURLs remove all credentials in URLs for the input string:
// * "https://userinfo@domain.com" => "https://***@domain.com"
// * "user:pass@domain.com" => "***@domain.com"
// "***" is a magic string internally used, doesn't guarantee to be anything.
func SanitizeCredentialURLs(s string) string {
sepColPos := strings.Index(s, ":")
if sepColPos == -1 {
return s // fast path: no colon, unlikely contain any URL credential
}
sepAtPos := strings.Index(s[sepColPos+1:], "@")
for sepAtPos == -1 {
return s // fast path: no "@" after colon, unlikely contain any URL credential
}
sepAtPos += sepColPos + 1
res := make([]byte, 0, len(s)+len(userInfoPlaceholder)) // a best guess to avoid too many re-allocations
bs := UnsafeStringToBytes(s)
schemeSepPos := bytes.Index(bs, schemeSep)
if schemeSepPos == -1 || bytes.IndexByte(bs[schemeSepPos:], '@') == -1 {
return s // fast return if there is no URL scheme or no userinfo
}
out := make([]byte, 0, len(bs)+len(userPlaceholder))
for schemeSepPos != -1 {
schemeSepPos += 3 // skip the "://"
sepAtPos := -1 // the possible '@' position: "https://foo@[^here]host"
sepEndPos := schemeSepPos // the possible end position: "The https://host[^here] in log for test"
sepLoop:
for ; sepEndPos < len(bs); sepEndPos++ {
c := bs[sepEndPos]
if ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z') || ('0' <= c && c <= '9') {
continue
}
for {
// left part (before "@") is likely to be the "userinfo" (single username, or "username:password")
leftPos := sepAtPos - 1
leftLoop:
for leftPos >= 0 {
c := bs[leftPos]
switch c {
case '@':
sepAtPos = sepEndPos
case '-', '.', '_', '~', '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=', ':', '%':
continue // due to RFC 3986, userinfo can contain - . _ ~ ! $ & ' ( ) * + , ; = : and any percent-encoded chars
// RFC 3986, userinfo can contain - . _ ~ ! $ & ' ( ) * + , ; = : and any percent-encoded chars
default:
break sepLoop // if it is an invalid char for URL (eg: space, '/', and others), stop the loop
valid := 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9'
if !valid {
break leftLoop
}
}
leftPos--
}
// if there is '@', and the string is like "s://u@h", then hide the "u" part
if sepAtPos != -1 && (schemeSepPos >= 4 && unicode.IsLetter(rune(bs[schemeSepPos-4]))) && sepAtPos-schemeSepPos > 0 && sepEndPos-sepAtPos > 0 {
out = append(out, bs[:schemeSepPos]...)
out = append(out, userPlaceholder...)
out = append(out, bs[sepAtPos:sepEndPos]...)
// left pos should point to the beginning of the left part, this pos is always valid in the buffer
leftPos++
// right part is likely to be the host (domain name, ip address)
rightPos := sepAtPos + 1
rightLoop:
for rightPos < len(bs) {
c := bs[rightPos]
switch c {
case '.', '-':
// valid host char
case '[':
// ipv6 begin
if rightPos != sepAtPos+1 {
break rightLoop
}
case ']':
// ipv6 end
rightPos++
break rightLoop
default:
valid := 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9'
if bs[sepAtPos+1] == '[' {
// ipv6 host
valid = 'a' <= c && c <= 'f' || 'A' <= c && c <= 'F' || '0' <= c && c <= '9' || c == ':'
}
if !valid {
break rightLoop
}
}
rightPos++
}
leading, leftPart, rightPart := bs[:leftPos], bs[leftPos:sepAtPos], bs[sepAtPos+1:rightPos]
// Either:
// * git log message: "user:pass@host" (it contains a colon in userinfo), ignore "git@host" pattern
// * http like URL: "https://userinfo@host.com" (it has "://" before the userinfo)
needSanitize := bytes.IndexByte(leftPart, ':') >= 0 || bytes.HasSuffix(leading, schemeSep)
needSanitize = needSanitize && len(leftPart) > 0 && len(rightPart) > 0
// TODO: can also do more checks for right part
// for example: ipv6 quick check
if needSanitize && rightPart[0] == '[' {
needSanitize = rightPart[len(rightPart)-1] == ']' && net.ParseIP(UnsafeBytesToString(rightPart[1:len(rightPart)-1])) != nil
}
if needSanitize {
res = append(res, leading...)
res = append(res, userInfoPlaceholder...)
res = append(res, '@')
res = append(res, rightPart...)
} else {
out = append(out, bs[:sepEndPos]...)
res = append(res, bs[:rightPos]...)
}
bs = bs[rightPos:]
sepAtPos = bytes.IndexByte(bs, '@')
if sepAtPos == -1 {
break
}
bs = bs[sepEndPos:]
schemeSepPos = bytes.Index(bs, schemeSep)
}
out = append(out, bs...)
return UnsafeBytesToString(out)
res = append(res, bs...)
return UnsafeBytesToString(res)
}
+43 -10
View File
@@ -13,7 +13,7 @@ import (
func TestSanitizeErrorCredentialURLs(t *testing.T) {
err := errors.New("error with https://a@b.com")
se := SanitizeErrorCredentialURLs(err)
assert.Equal(t, "error with https://"+userPlaceholder+"@b.com", se.Error())
assert.Equal(t, "error with https://"+userInfoPlaceholder+"@b.com", se.Error())
}
func TestSanitizeCredentialURLs(t *testing.T) {
@@ -27,15 +27,35 @@ func TestSanitizeCredentialURLs(t *testing.T) {
},
{
"https://mytoken@github.com/go-gitea/test_repo.git",
"https://" + userPlaceholder + "@github.com/go-gitea/test_repo.git",
"https://" + userInfoPlaceholder + "@github.com/go-gitea/test_repo.git",
},
{
"https://user:password@github.com/go-gitea/test_repo.git",
"https://" + userPlaceholder + "@github.com/go-gitea/test_repo.git",
"https://" + userInfoPlaceholder + "@github.com/go-gitea/test_repo.git",
},
{
"https://user:password@[::]/go-gitea/test_repo.git",
"https://" + userInfoPlaceholder + "@[::]/go-gitea/test_repo.git",
},
{
"https://user:password@[2001:db8::1]:8080/go-gitea/test_repo.git",
"https://" + userInfoPlaceholder + "@[2001:db8::1]:8080/go-gitea/test_repo.git",
},
{
"see https://u:p@[::1]/x and https://u2:p2@h2",
"see https://" + userInfoPlaceholder + "@[::1]/x and https://" + userInfoPlaceholder + "@h2",
},
{
"https://user:secret@[unclosed-ipv6",
"https://user:secret@[unclosed-ipv6",
},
{
"https://user:secret@[invalid-ipv6]",
"https://user:secret@[invalid-ipv6]",
},
{
"ftp://x@",
"ftp://" + userPlaceholder + "@",
"ftp://x@",
},
{
"ftp://x/@",
@@ -43,27 +63,40 @@ func TestSanitizeCredentialURLs(t *testing.T) {
},
{
"ftp://u@x/@", // test multiple @ chars
"ftp://" + userPlaceholder + "@x/@",
"ftp://" + userInfoPlaceholder + "@x/@",
},
{
"😊ftp://u@x😊", // test unicode
"😊ftp://" + userPlaceholder + "@x😊",
"😊ftp://" + userInfoPlaceholder + "@x😊",
},
{
"://@",
"://@",
},
{
"//u:p@h", // do not process URLs without explicit scheme, they are not treated as "valid" URLs because there is no scheme context in string
"//u:p@h",
"//" + userInfoPlaceholder + "@h",
},
{
"s://u@h", // the minimal pattern to be sanitized
"s://" + userPlaceholder + "@h",
"s://u@h",
"s://" + userInfoPlaceholder + "@h",
},
{
"URLs in log https://u:b@h and https://u:b@h:80/, with https://h.com and u@h.com",
"URLs in log https://" + userPlaceholder + "@h and https://" + userPlaceholder + "@h:80/, with https://h.com and u@h.com",
"URLs in log https://" + userInfoPlaceholder + "@h and https://" + userInfoPlaceholder + "@h:80/, with https://h.com and u@h.com",
},
{
"fatal: unable to look up username:token@github.com (port 9418)",
"fatal: unable to look up " + userInfoPlaceholder + "@github.com (port 9418)",
},
{
"git failed for user:token@github.com/go-gitea/test_repo.git",
"git failed for " + userInfoPlaceholder + "@github.com/go-gitea/test_repo.git",
},
{
// SSH-form git URL ("git@host:path") must not let a later credential URL through
"failed remote git@github.com:foo, retried via https://user:tok@github.com/foo",
"failed remote git@github.com:foo, retried via https://" + userInfoPlaceholder + "@github.com/foo",
},
}
+66 -18
View File
@@ -6,11 +6,16 @@ package util
import (
"bytes"
"crypto/rand"
"encoding/hex"
"fmt"
"math/big"
rand2 "math/rand/v2"
"slices"
"strconv"
"strings"
"sync"
"code.gitea.io/gitea/modules/container"
"golang.org/x/text/cases"
"golang.org/x/text/language"
@@ -58,37 +63,60 @@ func NormalizeEOL(input []byte) []byte {
}
// CryptoRandomInt returns a crypto random integer between 0 and limit, inclusive
func CryptoRandomInt(limit int64) (int64, error) {
func CryptoRandomInt(limit int64) int64 {
rInt, err := rand.Int(rand.Reader, big.NewInt(limit))
if err != nil {
return 0, err
panic(err) // this should never happen
}
return rInt.Int64(), nil
return rInt.Int64()
}
const alphanumericalChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// CryptoRandomString generates a crypto random alphanumerical string, each byte is generated by [0,61] range
func CryptoRandomString(length int64) (string, error) {
func CryptoRandomString(length int64) string {
const alphanumericalChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
buf := make([]byte, length)
limit := int64(len(alphanumericalChars))
for i := range buf {
num, err := CryptoRandomInt(limit)
if err != nil {
return "", err
}
num := CryptoRandomInt(limit)
buf[i] = alphanumericalChars[num]
}
return string(buf), nil
return string(buf)
}
// CryptoRandomBytes generates `length` crypto bytes
// This differs from CryptoRandomString, as each byte in CryptoRandomString is generated by [0,61] range
// This function generates totally random bytes, each byte is generated by [0,255] range
func CryptoRandomBytes(length int64) ([]byte, error) {
func CryptoRandomBytes(length int64) []byte {
buf := make([]byte, length)
_, err := rand.Read(buf)
return buf, err
if _, err := rand.Read(buf); err != nil {
panic(err) // this should never happen, "rand.Read" never fails
}
return buf
}
var chaCha8RandPool = sync.OnceValue(func() *sync.Pool {
return &sync.Pool{
New: func() any {
seed := CryptoRandomBytes(32)
return rand2.NewChaCha8([32]byte(seed))
},
}
})
func FastCryptoRandomBytes(length int) []byte {
// ChaCha8 is about 20x times faster than system's crypto/rand.
// It is suitable for UUIDs, session IDs, etc
pool := chaCha8RandPool()
chaCha8Rand := pool.Get().(*rand2.ChaCha8)
defer pool.Put(chaCha8Rand)
buf := make([]byte, length)
_, _ = chaCha8Rand.Read(buf)
return buf
}
func FastCryptoRandomHex(length int) string {
buf := FastCryptoRandomBytes(length / 2)
return hex.EncodeToString(buf)
}
// ToLowerASCII returns s with all ASCII letters mapped to their lower case.
@@ -255,11 +283,31 @@ func EnumValue[T comparable](val EnumConst[T]) (ret T, valid bool) {
return enums[0], false
}
func ReserveLineBreakForTextarea(input string) string {
func NormalizeStringEOL(input string) string {
// Since the content is from a form which is a textarea, the line endings are \r\n.
// It's a standard behavior of HTML.
// But we want to store them as \n like what GitHub does.
// And users are unlikely to really need to keep the \r.
// But in most cases, we only want "\n" for EOL
// * Text files: use "\n" by default because "\r\n" sometimes doesn't work in POSIX
// * Actions values: store them as "\n" like what GitHub does.
// And users are unlikely to really need the "\r".
// Other than this, we should respect the original content, even leading or trailing spaces.
return strings.ReplaceAll(input, "\r\n", "\n")
return UnsafeBytesToString(NormalizeEOL(UnsafeStringToBytes(input)))
}
func DiffSlice[T comparable](oldSlice, newSlice []T) (added, removed []T) {
oldSet := container.SetOf(oldSlice...)
newSet := container.SetOf(newSlice...)
addedSet, removedSet := container.Set[T]{}, container.Set[T]{}
for _, v := range newSlice {
if !oldSet.Contains(v) && addedSet.Add(v) {
added = append(added, v)
}
}
for _, v := range oldSlice {
if !newSet.Contains(v) && removedSet.Add(v) {
removed = append(removed, v)
}
}
return added, removed
}
+20 -21
View File
@@ -86,35 +86,31 @@ func Test_NormalizeEOL(t *testing.T) {
}
func Test_RandomInt(t *testing.T) {
randInt, err := CryptoRandomInt(255)
randInt := CryptoRandomInt(255)
assert.GreaterOrEqual(t, randInt, int64(0))
assert.LessOrEqual(t, randInt, int64(255))
assert.NoError(t, err)
}
func Test_RandomString(t *testing.T) {
str1, err := CryptoRandomString(32)
assert.NoError(t, err)
str1 := CryptoRandomString(32)
var err error
matches, err := regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
assert.NoError(t, err)
assert.True(t, matches)
str2, err := CryptoRandomString(32)
assert.NoError(t, err)
str2 := CryptoRandomString(32)
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
assert.NoError(t, err)
assert.True(t, matches)
assert.NotEqual(t, str1, str2)
str3, err := CryptoRandomString(256)
assert.NoError(t, err)
str3 := CryptoRandomString(256)
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str3)
assert.NoError(t, err)
assert.True(t, matches)
str4, err := CryptoRandomString(256)
assert.NoError(t, err)
str4 := CryptoRandomString(256)
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str4)
assert.NoError(t, err)
assert.True(t, matches)
@@ -123,19 +119,15 @@ func Test_RandomString(t *testing.T) {
}
func Test_RandomBytes(t *testing.T) {
bytes1, err := CryptoRandomBytes(32)
assert.NoError(t, err)
bytes1 := CryptoRandomBytes(32)
bytes2, err := CryptoRandomBytes(32)
assert.NoError(t, err)
bytes2 := CryptoRandomBytes(32)
assert.NotEqual(t, bytes1, bytes2)
bytes3, err := CryptoRandomBytes(256)
assert.NoError(t, err)
bytes3 := CryptoRandomBytes(256)
bytes4, err := CryptoRandomBytes(256)
assert.NoError(t, err)
bytes4 := CryptoRandomBytes(256)
assert.NotEqual(t, bytes3, bytes4)
}
@@ -175,9 +167,9 @@ func TestToTitleCase(t *testing.T) {
assert.Equal(t, `Foo Bar Baz`, ToTitleCase(`FOO BAR BAZ`))
}
func TestReserveLineBreakForTextarea(t *testing.T) {
assert.Equal(t, "test\ndata", ReserveLineBreakForTextarea("test\r\ndata"))
assert.Equal(t, "test\ndata\n", ReserveLineBreakForTextarea("test\r\ndata\r\n"))
func TestNormalizeStringEOL(t *testing.T) {
assert.Equal(t, "test\ndata", NormalizeStringEOL("test\r\ndata"))
assert.Equal(t, " test\ndata\n ", NormalizeStringEOL(" test\rdata\r "))
}
func TestOptionalArg(t *testing.T) {
@@ -192,3 +184,10 @@ func TestOptionalArg(t *testing.T) {
assert.Equal(t, 42, bar(nil))
assert.Equal(t, 100, bar(nil, 100))
}
func TestPathEscapeSegments(t *testing.T) {
assert.Equal(t, "a", PathEscapeSegments("a"))
assert.Equal(t, "a/b", PathEscapeSegments("a/b"))
assert.Equal(t, "a/b%20c", PathEscapeSegments("a/b c"))
assert.Equal(t, "a/b+c", PathEscapeSegments("a/b+c"))
}
Loaded 100 of 1148 files, more files were not shown because too many files have changed in this diff. Show more