mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-03 00:02:43 +02:00
fix: Handle the merge conflicts
This commit is contained in:
commit
3f27d054f8
1148 files changed
+59125
-15301
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -46,7 +46,9 @@ func TestParseEntryMode(t *testing.T) {
|
||||
{"160755", EntryModeCommit},
|
||||
|
||||
{"040000", EntryModeTree},
|
||||
{"40000", EntryModeTree},
|
||||
{"040755", EntryModeTree},
|
||||
{"40755", EntryModeTree},
|
||||
|
||||
{"777777", EntryModeNoEntry}, // invalid mode
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
func getDefaultJSONHandler() Interface {
|
||||
return jsonGoccy{}
|
||||
return jsonV1{}
|
||||
}
|
||||
|
||||
func MarshalKeepOptionalEmpty(v any) ([]byte, error) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Vendored
+27
-1
@@ -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})
|
||||
}
|
||||
|
||||
Vendored
+95
@@ -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
|
||||
}
|
||||
Vendored
-84
@@ -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
|
||||
}
|
||||
@@ -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, "&", "&")
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
"path"
|
||||
"sync"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"code.gitea.io/gitea/modules/assetfs"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
)
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ package options
|
||||
import (
|
||||
"sync"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"code.gitea.io/gitea/modules/assetfs"
|
||||
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed bindata.dat
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -99,6 +99,7 @@ var OAuth2 = struct {
|
||||
JWTClaimIssuer string `ini:"JWT_CLAIM_ISSUER"`
|
||||
MaxTokenLength int
|
||||
DefaultApplications []string
|
||||
CustomSchemes []string
|
||||
}{
|
||||
Enabled: true,
|
||||
AccessTokenExpirationTime: 3600,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -10,9 +10,9 @@ package templates
|
||||
import (
|
||||
"sync"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"code.gitea.io/gitea/modules/assetfs"
|
||||
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed bindata.dat
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
Reference in new issue
Block a user