diff --git a/cmd/dump.go b/cmd/dump.go
index 69ecdcec12..da0a51d9ce 100644
--- a/cmd/dump.go
+++ b/cmd/dump.go
@@ -6,14 +6,13 @@ package cmd
import (
"fmt"
- "io"
"os"
"path"
"path/filepath"
"strings"
- "time"
"code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/dump"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
@@ -25,89 +24,17 @@ import (
"github.com/urfave/cli/v2"
)
-func addReader(w archiver.Writer, r io.ReadCloser, info os.FileInfo, customName string, verbose bool) error {
- if verbose {
- log.Info("Adding file %s", customName)
- }
-
- return w.Write(archiver.File{
- FileInfo: archiver.FileInfo{
- FileInfo: info,
- CustomName: customName,
- },
- ReadCloser: r,
- })
-}
-
-func addFile(w archiver.Writer, filePath, absPath string, verbose bool) error {
- file, err := os.Open(absPath)
- if err != nil {
- return err
- }
- defer file.Close()
- fileInfo, err := file.Stat()
- if err != nil {
- return err
- }
-
- return addReader(w, file, fileInfo, filePath, verbose)
-}
-
-func isSubdir(upper, lower string) (bool, error) {
- if relPath, err := filepath.Rel(upper, lower); err != nil {
- return false, err
- } else if relPath == "." || !strings.HasPrefix(relPath, ".") {
- return true, nil
- }
- return false, nil
-}
-
-type outputType struct {
- Enum []string
- Default string
- selected string
-}
-
-func (o outputType) Join() string {
- return strings.Join(o.Enum, ", ")
-}
-
-func (o *outputType) Set(value string) error {
- for _, enum := range o.Enum {
- if enum == value {
- o.selected = value
- return nil
- }
- }
-
- return fmt.Errorf("allowed values are %s", o.Join())
-}
-
-func (o outputType) String() string {
- if o.selected == "" {
- return o.Default
- }
- return o.selected
-}
-
-var outputTypeEnum = &outputType{
- Enum: []string{"zip", "tar", "tar.sz", "tar.gz", "tar.xz", "tar.bz2", "tar.br", "tar.lz4", "tar.zst"},
- Default: "zip",
-}
-
// CmdDump represents the available dump sub-command.
var CmdDump = &cli.Command{
- Name: "dump",
- Usage: "Dump Gitea files and database",
- Description: `Dump compresses all related files and database into zip file.
-It can be used for backup and capture Gitea server image to send to maintainer`,
- Action: runDump,
+ Name: "dump",
+ Usage: "Dump Gitea files and database",
+ Description: `Dump compresses all related files and database into zip file. It can be used for backup and capture Gitea server image to send to maintainer`,
+ Action: runDump,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "file",
Aliases: []string{"f"},
- Value: fmt.Sprintf("gitea-dump-%d.zip", time.Now().Unix()),
- Usage: "Name of the dump file which will be created. Supply '-' for stdout. See type for available types.",
+ Usage: `Name of the dump file which will be created, default to "gitea-dump-{time}.zip". Supply '-' for stdout. See type for available types.`,
},
&cli.BoolFlag{
Name: "verbose",
@@ -160,64 +87,52 @@ It can be used for backup and capture Gitea server image to send to maintainer`,
Name: "skip-index",
Usage: "Skip bleve index data",
},
- &cli.GenericFlag{
+ &cli.StringFlag{
Name: "type",
- Value: outputTypeEnum,
- Usage: fmt.Sprintf("Dump output format: %s", outputTypeEnum.Join()),
+ Usage: fmt.Sprintf(`Dump output format, default to "zip", supported types: %s`, strings.Join(dump.SupportedOutputTypes, ", ")),
},
},
}
func fatal(format string, args ...any) {
- fmt.Fprintf(os.Stderr, format+"\n", args...)
log.Fatal(format, args...)
}
func runDump(ctx *cli.Context) error {
- var file *os.File
- fileName := ctx.String("file")
- outType := ctx.String("type")
- if fileName == "-" {
- file = os.Stdout
- setupConsoleLogger(log.FATAL, log.CanColorStderr, os.Stderr)
- } else {
- for _, suffix := range outputTypeEnum.Enum {
- if strings.HasSuffix(fileName, "."+suffix) {
- fileName = strings.TrimSuffix(fileName, "."+suffix)
- break
- }
- }
- fileName += "." + outType
- }
setting.MustInstalled()
- // make sure we are logging to the console no matter what the configuration tells us do to
- // FIXME: don't use CfgProvider directly
- if _, err := setting.CfgProvider.Section("log").NewKey("MODE", "console"); err != nil {
- fatal("Setting logging mode to console failed: %v", err)
- }
- if _, err := setting.CfgProvider.Section("log.console").NewKey("STDERR", "true"); err != nil {
- fatal("Setting console logger to stderr failed: %v", err)
- }
-
- // Set loglevel to Warn if quiet-mode is requested
- if ctx.Bool("quiet") {
- if _, err := setting.CfgProvider.Section("log.console").NewKey("LEVEL", "Warn"); err != nil {
- fatal("Setting console log-level failed: %v", err)
- }
- }
-
- if !setting.InstallLock {
- log.Error("Is '%s' really the right config path?\n", setting.CustomConf)
- return fmt.Errorf("gitea is not initialized")
- }
- setting.LoadSettings() // cannot access session settings otherwise
-
+ quite := ctx.Bool("quiet")
verbose := ctx.Bool("verbose")
- if verbose && ctx.Bool("quiet") {
- return fmt.Errorf("--quiet and --verbose cannot both be set")
+ if verbose && quite {
+ fatal("Option --quiet and --verbose cannot both be set")
}
+ // outFileName is either "-" or a file name (will be made absolute)
+ outFileName, outType := dump.PrepareFileNameAndType(ctx.String("file"), ctx.String("type"))
+ if outType == "" {
+ fatal("Invalid output type")
+ }
+
+ outFile := os.Stdout
+ if outFileName != "-" {
+ var err error
+ if outFileName, err = filepath.Abs(outFileName); err != nil {
+ fatal("Unable to get absolute path of dump file: %v", err)
+ }
+ if exist, _ := util.IsExist(outFileName); exist {
+ fatal("Dump file %q exists", outFileName)
+ }
+ if outFile, err = os.Create(outFileName); err != nil {
+ fatal("Unable to create dump file %q: %v", outFileName, err)
+ }
+ defer outFile.Close()
+ }
+
+ setupConsoleLogger(util.Iif(quite, log.WARN, log.INFO), log.CanColorStderr, os.Stderr)
+
+ setting.DisableLoggerInit()
+ setting.LoadSettings() // cannot access session settings otherwise
+
stdCtx, cancel := installSignals()
defer cancel()
@@ -226,44 +141,32 @@ func runDump(ctx *cli.Context) error {
return err
}
- if err := storage.Init(); err != nil {
+ if err = storage.Init(); err != nil {
return err
}
- if file == nil {
- file, err = os.Create(fileName)
- if err != nil {
- fatal("Unable to open %s: %v", fileName, err)
- }
- }
- defer file.Close()
-
- absFileName, err := filepath.Abs(fileName)
- if err != nil {
- return err
- }
-
- var iface any
- if fileName == "-" {
- iface, err = archiver.ByExtension(fmt.Sprintf(".%s", outType))
- } else {
- iface, err = archiver.ByExtension(fileName)
- }
+ archiverGeneric, err := archiver.ByExtension("." + outType)
if err != nil {
fatal("Unable to get archiver for extension: %v", err)
}
- w, _ := iface.(archiver.Writer)
- if err := w.Create(file); err != nil {
+ archiverWriter := archiverGeneric.(archiver.Writer)
+ if err := archiverWriter.Create(outFile); err != nil {
fatal("Creating archiver.Writer failed: %v", err)
}
- defer w.Close()
+ defer archiverWriter.Close()
+
+ dumper := &dump.Dumper{
+ Writer: archiverWriter,
+ Verbose: verbose,
+ }
+ dumper.GlobalExcludeAbsPath(outFileName)
if ctx.IsSet("skip-repository") && ctx.Bool("skip-repository") {
log.Info("Skip dumping local repositories")
} else {
log.Info("Dumping local repositories... %s", setting.RepoRootPath)
- if err := addRecursiveExclude(w, "repos", setting.RepoRootPath, []string{absFileName}, verbose); err != nil {
+ if err := dumper.AddRecursiveExclude("repos", setting.RepoRootPath, nil); err != nil {
fatal("Failed to include repositories: %v", err)
}
@@ -276,8 +179,7 @@ func runDump(ctx *cli.Context) error {
if err != nil {
return err
}
-
- return addReader(w, object, info, path.Join("data", "lfs", objPath), verbose)
+ return dumper.AddReader(object, info, path.Join("data", "lfs", objPath))
}); err != nil {
fatal("Failed to dump LFS objects: %v", err)
}
@@ -310,15 +212,13 @@ func runDump(ctx *cli.Context) error {
fatal("Failed to dump database: %v", err)
}
- if err := addFile(w, "gitea-db.sql", dbDump.Name(), verbose); err != nil {
+ if err = dumper.AddFile("gitea-db.sql", dbDump.Name()); err != nil {
fatal("Failed to include gitea-db.sql: %v", err)
}
- if len(setting.CustomConf) > 0 {
- log.Info("Adding custom configuration file from %s", setting.CustomConf)
- if err := addFile(w, "app.ini", setting.CustomConf, verbose); err != nil {
- fatal("Failed to include specified app.ini: %v", err)
- }
+ log.Info("Adding custom configuration file from %s", setting.CustomConf)
+ if err = dumper.AddFile("app.ini", setting.CustomConf); err != nil {
+ fatal("Failed to include specified app.ini: %v", err)
}
if ctx.IsSet("skip-custom-dir") && ctx.Bool("skip-custom-dir") {
@@ -326,8 +226,8 @@ func runDump(ctx *cli.Context) error {
} else {
customDir, err := os.Stat(setting.CustomPath)
if err == nil && customDir.IsDir() {
- if is, _ := isSubdir(setting.AppDataPath, setting.CustomPath); !is {
- if err := addRecursiveExclude(w, "custom", setting.CustomPath, []string{absFileName}, verbose); err != nil {
+ if is, _ := dump.IsSubdir(setting.AppDataPath, setting.CustomPath); !is {
+ if err := dumper.AddRecursiveExclude("custom", setting.CustomPath, nil); err != nil {
fatal("Failed to include custom: %v", err)
}
} else {
@@ -364,8 +264,7 @@ func runDump(ctx *cli.Context) error {
excludes = append(excludes, setting.Attachment.Storage.Path)
excludes = append(excludes, setting.Packages.Storage.Path)
excludes = append(excludes, setting.Log.RootPath)
- excludes = append(excludes, absFileName)
- if err := addRecursiveExclude(w, "data", setting.AppDataPath, excludes, verbose); err != nil {
+ if err := dumper.AddRecursiveExclude("data", setting.AppDataPath, excludes); err != nil {
fatal("Failed to include data directory: %v", err)
}
}
@@ -377,8 +276,7 @@ func runDump(ctx *cli.Context) error {
if err != nil {
return err
}
-
- return addReader(w, object, info, path.Join("data", "attachments", objPath), verbose)
+ return dumper.AddReader(object, info, path.Join("data", "attachments", objPath))
}); err != nil {
fatal("Failed to dump attachments: %v", err)
}
@@ -392,8 +290,7 @@ func runDump(ctx *cli.Context) error {
if err != nil {
return err
}
-
- return addReader(w, object, info, path.Join("data", "packages", objPath), verbose)
+ return dumper.AddReader(object, info, path.Join("data", "packages", objPath))
}); err != nil {
fatal("Failed to dump packages: %v", err)
}
@@ -409,80 +306,23 @@ func runDump(ctx *cli.Context) error {
log.Error("Unable to check if %s exists. Error: %v", setting.Log.RootPath, err)
}
if isExist {
- if err := addRecursiveExclude(w, "log", setting.Log.RootPath, []string{absFileName}, verbose); err != nil {
+ if err := dumper.AddRecursiveExclude("log", setting.Log.RootPath, nil); err != nil {
fatal("Failed to include log: %v", err)
}
}
}
- if fileName != "-" {
- if err = w.Close(); err != nil {
- _ = util.Remove(fileName)
- fatal("Failed to save %s: %v", fileName, err)
+ if outFileName == "-" {
+ log.Info("Finish dumping to stdout")
+ } else {
+ if err = archiverWriter.Close(); err != nil {
+ _ = os.Remove(outFileName)
+ fatal("Failed to save %q: %v", outFileName, err)
}
-
- if err := os.Chmod(fileName, 0o600); err != nil {
+ if err = os.Chmod(outFileName, 0o600); err != nil {
log.Info("Can't change file access permissions mask to 0600: %v", err)
}
- }
-
- if fileName != "-" {
- log.Info("Finish dumping in file %s", fileName)
- } else {
- log.Info("Finish dumping to stdout")
- }
-
- return nil
-}
-
-// addRecursiveExclude zips absPath to specified insidePath inside writer excluding excludeAbsPath
-func addRecursiveExclude(w archiver.Writer, insidePath, absPath string, excludeAbsPath []string, verbose bool) error {
- absPath, err := filepath.Abs(absPath)
- if err != nil {
- return err
- }
- dir, err := os.Open(absPath)
- if err != nil {
- return err
- }
- defer dir.Close()
-
- files, err := dir.Readdir(0)
- if err != nil {
- return err
- }
- for _, file := range files {
- currentAbsPath := filepath.Join(absPath, file.Name())
- currentInsidePath := path.Join(insidePath, file.Name())
- if file.IsDir() {
- if !util.SliceContainsString(excludeAbsPath, currentAbsPath) {
- if err := addFile(w, currentInsidePath, currentAbsPath, false); err != nil {
- return err
- }
- if err = addRecursiveExclude(w, currentInsidePath, currentAbsPath, excludeAbsPath, verbose); err != nil {
- return err
- }
- }
- } else {
- // only copy regular files and symlink regular files, skip non-regular files like socket/pipe/...
- shouldAdd := file.Mode().IsRegular()
- if !shouldAdd && file.Mode()&os.ModeSymlink == os.ModeSymlink {
- target, err := filepath.EvalSymlinks(currentAbsPath)
- if err != nil {
- return err
- }
- targetStat, err := os.Stat(target)
- if err != nil {
- return err
- }
- shouldAdd = targetStat.Mode().IsRegular()
- }
- if shouldAdd {
- if err = addFile(w, currentInsidePath, currentAbsPath, verbose); err != nil {
- return err
- }
- }
- }
+ log.Info("Finish dumping in file %s", outFileName)
}
return nil
}
diff --git a/docs/content/installation/with-docker.en-us.md b/docs/content/installation/with-docker.en-us.md
index e67f5bccb2..e8a80f7c96 100644
--- a/docs/content/installation/with-docker.en-us.md
+++ b/docs/content/installation/with-docker.en-us.md
@@ -545,7 +545,7 @@ In this option, the idea is that the host SSH uses an `AuthorizedKeysCommand` in
```bash
cat <<"EOF" | sudo tee /home/git/docker-shell
#!/bin/sh
- /usr/bin/docker exec -i --env SSH_ORIGINAL_COMMAND="$SSH_ORIGINAL_COMMAND" gitea sh "$@"
+ /usr/bin/docker exec -i -u git --env SSH_ORIGINAL_COMMAND="$SSH_ORIGINAL_COMMAND" gitea sh "$@"
EOF
sudo chmod +x /home/git/docker-shell
sudo usermod -s /home/git/docker-shell git
@@ -560,7 +560,7 @@ Add the following block to `/etc/ssh/sshd_config`, on the host:
```bash
Match User git
AuthorizedKeysCommandUser git
- AuthorizedKeysCommand /usr/bin/docker exec -i gitea /usr/local/bin/gitea keys -c /data/gitea/conf/app.ini -e git -u %u -t %t -k %k
+ AuthorizedKeysCommand /usr/bin/docker exec -i -u git gitea /usr/local/bin/gitea keys -c /data/gitea/conf/app.ini -e git -u %u -t %t -k %k
```
(From 1.16.0 you will not need to set the `-c /data/gitea/conf/app.ini` option.)
diff --git a/modules/dump/dumper.go b/modules/dump/dumper.go
new file mode 100644
index 0000000000..47730851fb
--- /dev/null
+++ b/modules/dump/dumper.go
@@ -0,0 +1,174 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package dump
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "github.com/mholt/archiver/v3"
+)
+
+var SupportedOutputTypes = []string{"zip", "tar", "tar.sz", "tar.gz", "tar.xz", "tar.bz2", "tar.br", "tar.lz4", "tar.zst"}
+
+// PrepareFileNameAndType prepares the output file name and type, if the type is not supported, it returns an empty "outType"
+func PrepareFileNameAndType(argFile, argType string) (outFileName, outType string) {
+ if argFile == "" && argType == "" {
+ outType = SupportedOutputTypes[0]
+ outFileName = fmt.Sprintf("gitea-dump-%d.%s", timeutil.TimeStampNow(), outType)
+ } else if argFile == "" {
+ outType = argType
+ outFileName = fmt.Sprintf("gitea-dump-%d.%s", timeutil.TimeStampNow(), outType)
+ } else if argType == "" {
+ if filepath.Ext(outFileName) == "" {
+ outType = SupportedOutputTypes[0]
+ outFileName = argFile
+ } else {
+ for _, t := range SupportedOutputTypes {
+ if strings.HasSuffix(argFile, "."+t) {
+ outFileName = argFile
+ outType = t
+ }
+ }
+ }
+ } else {
+ outFileName, outType = argFile, argType
+ }
+ if !slices.Contains(SupportedOutputTypes, outType) {
+ return "", ""
+ }
+ return outFileName, outType
+}
+
+func IsSubdir(upper, lower string) (bool, error) {
+ if relPath, err := filepath.Rel(upper, lower); err != nil {
+ return false, err
+ } else if relPath == "." || !strings.HasPrefix(relPath, ".") {
+ return true, nil
+ }
+ return false, nil
+}
+
+type Dumper struct {
+ Writer archiver.Writer
+ Verbose bool
+
+ globalExcludeAbsPaths []string
+}
+
+func (dumper *Dumper) AddReader(r io.ReadCloser, info os.FileInfo, customName string) error {
+ if dumper.Verbose {
+ log.Info("Adding file %s", customName)
+ }
+
+ return dumper.Writer.Write(archiver.File{
+ FileInfo: archiver.FileInfo{
+ FileInfo: info,
+ CustomName: customName,
+ },
+ ReadCloser: r,
+ })
+}
+
+func (dumper *Dumper) AddFile(filePath, absPath string) error {
+ file, err := os.Open(absPath)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ fileInfo, err := file.Stat()
+ if err != nil {
+ return err
+ }
+ return dumper.AddReader(file, fileInfo, filePath)
+}
+
+func (dumper *Dumper) normalizeFilePath(absPath string) string {
+ absPath = filepath.Clean(absPath)
+ if setting.IsWindows {
+ absPath = strings.ToLower(absPath)
+ }
+ return absPath
+}
+
+func (dumper *Dumper) GlobalExcludeAbsPath(absPaths ...string) {
+ for _, absPath := range absPaths {
+ dumper.globalExcludeAbsPaths = append(dumper.globalExcludeAbsPaths, dumper.normalizeFilePath(absPath))
+ }
+}
+
+func (dumper *Dumper) shouldExclude(absPath string, excludes []string) bool {
+ norm := dumper.normalizeFilePath(absPath)
+ return slices.Contains(dumper.globalExcludeAbsPaths, norm) || slices.Contains(excludes, norm)
+}
+
+func (dumper *Dumper) AddRecursiveExclude(insidePath, absPath string, excludes []string) error {
+ excludes = slices.Clone(excludes)
+ for i := range excludes {
+ excludes[i] = dumper.normalizeFilePath(excludes[i])
+ }
+ return dumper.addFileOrDir(insidePath, absPath, excludes)
+}
+
+func (dumper *Dumper) addFileOrDir(insidePath, absPath string, excludes []string) error {
+ absPath, err := filepath.Abs(absPath)
+ if err != nil {
+ return err
+ }
+ dir, err := os.Open(absPath)
+ if err != nil {
+ return err
+ }
+ defer dir.Close()
+
+ files, err := dir.Readdir(0)
+ if err != nil {
+ return err
+ }
+ for _, file := range files {
+ currentAbsPath := filepath.Join(absPath, file.Name())
+ if dumper.shouldExclude(currentAbsPath, excludes) {
+ continue
+ }
+
+ currentInsidePath := path.Join(insidePath, file.Name())
+ if file.IsDir() {
+ if err := dumper.AddFile(currentInsidePath, currentAbsPath); err != nil {
+ return err
+ }
+ if err = dumper.addFileOrDir(currentInsidePath, currentAbsPath, excludes); err != nil {
+ return err
+ }
+ } else {
+ // only copy regular files and symlink regular files, skip non-regular files like socket/pipe/...
+ shouldAdd := file.Mode().IsRegular()
+ if !shouldAdd && file.Mode()&os.ModeSymlink == os.ModeSymlink {
+ target, err := filepath.EvalSymlinks(currentAbsPath)
+ if err != nil {
+ return err
+ }
+ targetStat, err := os.Stat(target)
+ if err != nil {
+ return err
+ }
+ shouldAdd = targetStat.Mode().IsRegular()
+ }
+ if shouldAdd {
+ if err = dumper.AddFile(currentInsidePath, currentAbsPath); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ return nil
+}
diff --git a/modules/dump/dumper_test.go b/modules/dump/dumper_test.go
new file mode 100644
index 0000000000..b444fa2de5
--- /dev/null
+++ b/modules/dump/dumper_test.go
@@ -0,0 +1,113 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package dump
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "sort"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "github.com/mholt/archiver/v3"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestPrepareFileNameAndType(t *testing.T) {
+ defer timeutil.MockSet(time.Unix(1234, 0))()
+ test := func(argFile, argType, expFile, expType string) {
+ outFile, outType := PrepareFileNameAndType(argFile, argType)
+ assert.Equal(t,
+ fmt.Sprintf("outFile=%s, outType=%s", expFile, expType),
+ fmt.Sprintf("outFile=%s, outType=%s", outFile, outType),
+ fmt.Sprintf("argFile=%s, argType=%s", argFile, argType),
+ )
+ }
+
+ test("", "", "gitea-dump-1234.zip", "zip")
+ test("", "tar.gz", "gitea-dump-1234.tar.gz", "tar.gz")
+ test("", "no-such", "", "")
+
+ test("-", "", "-", "zip")
+ test("-", "tar.gz", "-", "tar.gz")
+ test("-", "no-such", "", "")
+
+ test("a", "", "a", "zip")
+ test("a", "tar.gz", "a", "tar.gz")
+ test("a", "no-such", "", "")
+
+ test("a.zip", "", "a.zip", "zip")
+ test("a.zip", "tar.gz", "a.zip", "tar.gz")
+ test("a.zip", "no-such", "", "")
+
+ test("a.tar.gz", "", "a.tar.gz", "zip")
+ test("a.tar.gz", "tar.gz", "a.tar.gz", "tar.gz")
+ test("a.tar.gz", "no-such", "", "")
+}
+
+func TestIsSubDir(t *testing.T) {
+ tmpDir := t.TempDir()
+ _ = os.MkdirAll(filepath.Join(tmpDir, "include/sub"), 0o755)
+
+ isSub, err := IsSubdir(filepath.Join(tmpDir, "include"), filepath.Join(tmpDir, "include"))
+ assert.NoError(t, err)
+ assert.True(t, isSub)
+
+ isSub, err = IsSubdir(filepath.Join(tmpDir, "include"), filepath.Join(tmpDir, "include/sub"))
+ assert.NoError(t, err)
+ assert.True(t, isSub)
+
+ isSub, err = IsSubdir(filepath.Join(tmpDir, "include/sub"), filepath.Join(tmpDir, "include"))
+ assert.NoError(t, err)
+ assert.False(t, isSub)
+}
+
+type testWriter struct {
+ added []string
+}
+
+func (t *testWriter) Create(out io.Writer) error {
+ return nil
+}
+
+func (t *testWriter) Write(f archiver.File) error {
+ t.added = append(t.added, f.Name())
+ return nil
+}
+
+func (t *testWriter) Close() error {
+ return nil
+}
+
+func TestDumper(t *testing.T) {
+ sortStrings := func(s []string) []string {
+ sort.Strings(s)
+ return s
+ }
+ tmpDir := t.TempDir()
+ _ = os.MkdirAll(filepath.Join(tmpDir, "include/exclude1"), 0o755)
+ _ = os.MkdirAll(filepath.Join(tmpDir, "include/exclude2"), 0o755)
+ _ = os.MkdirAll(filepath.Join(tmpDir, "include/sub"), 0o755)
+ _ = os.WriteFile(filepath.Join(tmpDir, "include/a"), nil, 0o644)
+ _ = os.WriteFile(filepath.Join(tmpDir, "include/sub/b"), nil, 0o644)
+ _ = os.WriteFile(filepath.Join(tmpDir, "include/exclude1/a-1"), nil, 0o644)
+ _ = os.WriteFile(filepath.Join(tmpDir, "include/exclude2/a-2"), nil, 0o644)
+
+ tw := &testWriter{}
+ d := &Dumper{Writer: tw}
+ d.GlobalExcludeAbsPath(filepath.Join(tmpDir, "include/exclude1"))
+ err := d.AddRecursiveExclude("include", filepath.Join(tmpDir, "include"), []string{filepath.Join(tmpDir, "include/exclude2")})
+ assert.NoError(t, err)
+ assert.EqualValues(t, sortStrings([]string{"include/a", "include/sub", "include/sub/b"}), sortStrings(tw.added))
+
+ tw = &testWriter{}
+ d = &Dumper{Writer: tw}
+ err = d.AddRecursiveExclude("include", filepath.Join(tmpDir, "include"), nil)
+ assert.NoError(t, err)
+ assert.EqualValues(t, sortStrings([]string{"include/exclude2", "include/exclude2/a-2", "include/a", "include/sub", "include/sub/b", "include/exclude1", "include/exclude1/a-1"}), sortStrings(tw.added))
+}
diff --git a/modules/setting/log.go b/modules/setting/log.go
index e404074b72..50c5779994 100644
--- a/modules/setting/log.go
+++ b/modules/setting/log.go
@@ -185,8 +185,13 @@ func InitLoggersForTest() {
initAllLoggers()
}
+var initLoggerDisabled bool
+
// initAllLoggers creates all the log services
func initAllLoggers() {
+ if initLoggerDisabled {
+ return
+ }
initManagedLoggers(log.GetManager(), CfgProvider)
golog.SetFlags(0)
@@ -194,6 +199,10 @@ func initAllLoggers() {
golog.SetOutput(log.LoggerToWriter(log.GetLogger(log.DEFAULT).Info))
}
+func DisableLoggerInit() {
+ initLoggerDisabled = true
+}
+
func initManagedLoggers(manager *log.LoggerManager, cfg ConfigProvider) {
loadLogGlobalFrom(cfg)
prepareLoggerConfig(cfg)
diff --git a/modules/timeutil/timestamp.go b/modules/timeutil/timestamp.go
index 27a80b6682..e77652b24f 100644
--- a/modules/timeutil/timestamp.go
+++ b/modules/timeutil/timestamp.go
@@ -21,8 +21,9 @@ var (
)
// MockSet sets the time to a mocked time.Time
-func MockSet(now time.Time) {
+func MockSet(now time.Time) func() {
mockNow = now
+ return MockUnset
}
// MockUnset will unset the mocked time.Time
diff --git a/modules/util/util.go b/modules/util/util.go
index b6e730eb54..3921002e2a 100644
--- a/modules/util/util.go
+++ b/modules/util/util.go
@@ -213,6 +213,14 @@ func ToPointer[T any](val T) *T {
return &val
}
+// Iif is an "inline-if", it returns "trueVal" if "condition" is true, otherwise "falseVal"
+func Iif[T comparable](condition bool, trueVal, falseVal T) T {
+ if condition {
+ return trueVal
+ }
+ return falseVal
+}
+
// IfZero returns "def" if "v" is a zero value, otherwise "v"
func IfZero[T comparable](v, def T) T {
var zero T
diff --git a/package-lock.json b/package-lock.json
index 21de79387f..a5f7a09ed0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,6 @@
"@github/relative-time-element": "4.4.0",
"@github/text-expander-element": "2.6.1",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
- "@melloware/coloris": "0.23.0",
"@primer/octicons": "19.9.0",
"add-asset-webpack-plugin": "2.0.1",
"ansi_up": "6.0.2",
@@ -54,6 +53,7 @@
"toastify-js": "1.12.0",
"tributejs": "5.1.3",
"uint8-to-base64": "0.2.0",
+ "vanilla-colorful": "0.7.2",
"vue": "3.4.21",
"vue-bar-graph": "2.0.0",
"vue-chartjs": "5.3.0",
@@ -1290,11 +1290,6 @@
"@mcaptcha/core-glue": "^0.1.0-alpha-5"
}
},
- "node_modules/@melloware/coloris": {
- "version": "0.23.0",
- "resolved": "https://registry.npmjs.org/@melloware/coloris/-/coloris-0.23.0.tgz",
- "integrity": "sha512-VGIjI9+IQwg6BHjIE10yl0K2ARYz5bsjn6BgFEs1y1ErPAQymgdoxwVcSVL4Ai5t9OVs8xaCB7JKHqFu2N96Ow=="
- },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -11853,6 +11848,11 @@
"builtins": "^1.0.3"
}
},
+ "node_modules/vanilla-colorful": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/vanilla-colorful/-/vanilla-colorful-0.7.2.tgz",
+ "integrity": "sha512-z2YZusTFC6KnLERx1cgoIRX2CjPRP0W75N+3CC6gbvdX5Ch47rZkEMGO2Xnf+IEmi3RiFLxS18gayMA27iU7Kg=="
+ },
"node_modules/vite": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.6.tgz",
diff --git a/package.json b/package.json
index beea0e5d86..004ac9e2bf 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,6 @@
"@github/relative-time-element": "4.4.0",
"@github/text-expander-element": "2.6.1",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
- "@melloware/coloris": "0.23.0",
"@primer/octicons": "19.9.0",
"add-asset-webpack-plugin": "2.0.1",
"ansi_up": "6.0.2",
@@ -53,6 +52,7 @@
"toastify-js": "1.12.0",
"tributejs": "5.1.3",
"uint8-to-base64": "0.2.0",
+ "vanilla-colorful": "0.7.2",
"vue": "3.4.21",
"vue-bar-graph": "2.0.0",
"vue-chartjs": "5.3.0",
diff --git a/web_src/css/features/colorpicker.css b/web_src/css/features/colorpicker.css
index 0c651cfeb3..b7436783df 100644
--- a/web_src/css/features/colorpicker.css
+++ b/web_src/css/features/colorpicker.css
@@ -1,10 +1,6 @@
-/* This is a stripped-down version of coloris's CSS tailored to our needs. It does only include
- opaqua colors, and if more features like opacity are needed, the CSS needs to be extended
- based on upstream: https://github.com/mdbassit/Coloris/blob/main/src/coloris.css. */
-
.js-color-picker-input {
display: flex;
- flex-wrap: wrap;
+ position: relative;
}
.js-color-picker-input input {
@@ -13,152 +9,39 @@
padding-left: 32px !important;
}
-.clr-picker {
- display: none;
- flex-wrap: wrap;
- position: absolute;
- width: 200px;
- z-index: 1002; /* above .ui.modal which has 1001 */
- border-radius: var(--border-radius);
- background-color: var(--color-menu);
- justify-content: flex-end;
- direction: ltr;
- box-shadow: 0 5px 20px var(--color-shadow);
- user-select: none;
-}
-
-.clr-picker.clr-open {
- display: flex;
-}
-
-.clr-gradient {
- position: relative;
- width: 100%;
- height: 100px;
- border-radius: 3px 3px 0 0;
- background: linear-gradient(rgba(0,0,0,0), #000), linear-gradient(90deg, #fff, currentcolor); /* stylelint-disable-line scale-unlimited/declaration-strict-value */
- cursor: pointer;
-}
-
-.clr-marker {
- position: absolute;
- width: 12px;
- height: 12px;
- margin: -6px 0 0 -6px;
- border: 1px solid var(--color-white);
- border-radius: 50%;
- background-color: currentcolor;
- cursor: pointer;
-}
-
-.clr-picker input[type="range"]::-webkit-slider-runnable-track {
- width: 100%;
- height: 16px;
-}
-
-.clr-picker input[type="range"]::-webkit-slider-thumb {
- width: 16px;
- height: 16px;
- -webkit-appearance: none;
-}
-
-.clr-picker input[type="range"]::-moz-range-track {
- width: 100%;
- height: 16px;
- border: 0;
-}
-
-.clr-picker input[type="range"]::-moz-range-thumb {
- width: 16px;
- height: 16px;
- border: 0;
-}
-
-.clr-hue {
- background: linear-gradient(to right, #f00 0%, #ff0 16.66%, #0f0 33.33%, #0ff 50%, #00f 66.66%, #f0f 83.33%, #f00 100%); /* stylelint-disable-line scale-unlimited/declaration-strict-value */
- position: relative;
- width: calc(100% - 40px);
- height: 10px;
- margin: 10px 20px;
- border-radius: 4px;
-}
-
-.clr-hue input[type="range"] {
- position: absolute;
- width: calc(100% + 32px);
- margin: 0;
- background-color: transparent;
- opacity: 0;
- cursor: pointer;
- appearance: none;
-}
-
-.clr-hue div {
- position: absolute;
- width: 16px;
- height: 16px;
- left: 0;
- top: 50%;
- transform: translate(-50%, -50%);
- border: 2px solid var(--color-white);
- border-radius: 50%;
- background-color: currentcolor;
- box-shadow: 0 0 1px var(--color-shadow);
- pointer-events: none;
-}
-
-.clr-field {
- flex: 1;
- position: relative;
- color: transparent;
-}
-
-.clr-field button {
+.js-color-picker-input .preview-square {
position: absolute;
aspect-ratio: 1;
height: 16px;
left: 10px;
top: 50%;
transform: translateY(-50%);
- margin: 0;
- padding: 0;
- border: 0;
- color: inherit;
- pointer-events: none;
border-radius: 2px;
background: repeating-linear-gradient(45deg, #aaa 25%, transparent 25%, transparent 75%, #aaa 75%, #aaa), repeating-linear-gradient(45deg, #aaa 25%, #fff 25%, #fff 75%, #aaa 75%, #aaa); /* stylelint-disable-line scale-unlimited/declaration-strict-value */
background-position: 0 0, 4px 4px;
background-size: 8px 8px;
}
-.clr-field button::after {
+.js-color-picker-input .preview-square::after {
content: "";
- display: block;
position: absolute;
width: 100%;
height: 100%;
- left: 0;
- top: 0;
border-radius: inherit;
background-color: currentcolor;
}
-.clr-marker:focus {
- outline: none;
+hex-color-picker {
+ width: 180px;
+ height: 120px;
}
-.clr-keyboard-nav .clr-marker:focus,
-.clr-keyboard-nav .clr-hue input:focus + div,
-.clr-keyboard-nav .clr-alpha input:focus + div {
- outline: none;
- box-shadow: 0 0 2px 2px var(--color-white);
+hex-color-picker::part(hue-pointer),
+hex-color-picker::part(saturation-pointer) {
+ width: 22px;
+ height: 22px;
}
-.clr-picker .clr-preview,
-.clr-picker .clr-clear,
-.clr-picker .clr-swatches,
-.clr-picker .clr-format,
-.clr-picker .clr-alpha,
-.clr-picker .clr-color {
- display: none;
+hex-color-picker::part(hue) {
+ flex-basis: 16px;
}
diff --git a/web_src/css/markup/codepreview.css b/web_src/css/markup/codepreview.css
index 9219544993..c9d19f5cc8 100644
--- a/web_src/css/markup/codepreview.css
+++ b/web_src/css/markup/codepreview.css
@@ -12,7 +12,7 @@
.markup .code-preview-container table {
width: 100%;
- max-height: 100px;
+ max-height: 240px; /* 12 lines at 20px per line */
overflow-y: auto;
margin: 0; /* override ".markup table {margin}" */
}
diff --git a/web_src/css/modules/tippy.css b/web_src/css/modules/tippy.css
index 76d36b4293..6ac7c37d93 100644
--- a/web_src/css/modules/tippy.css
+++ b/web_src/css/modules/tippy.css
@@ -29,6 +29,17 @@
z-index: 1;
}
+/* bare theme, no styling at all, except box-shadow */
+.tippy-box[data-theme="bare"] {
+ border: none;
+ box-shadow: 0 6px 18px var(--color-shadow);
+}
+
+.tippy-box[data-theme="bare"] .tippy-content {
+ padding: 0;
+ background: transparent;
+}
+
/* tooltip theme for text tooltips */
.tippy-box[data-theme="tooltip"] {
diff --git a/web_src/js/features/colorpicker.js b/web_src/js/features/colorpicker.js
index f342598e66..6d00d908c9 100644
--- a/web_src/js/features/colorpicker.js
+++ b/web_src/js/features/colorpicker.js
@@ -1,31 +1,66 @@
-export async function initColorPickers(selector = '.js-color-picker-input input', opts = {}) {
- const inputEls = document.querySelectorAll(selector);
- if (!inputEls.length) return;
+import {createTippy} from '../modules/tippy.js';
- const [{coloris, init}] = await Promise.all([
- import(/* webpackChunkName: "colorpicker" */'@melloware/coloris'),
+export async function initColorPickers() {
+ const els = document.getElementsByClassName('js-color-picker-input');
+ if (!els.length) return;
+
+ await Promise.all([
+ import(/* webpackChunkName: "colorpicker" */'vanilla-colorful/hex-color-picker.js'),
import(/* webpackChunkName: "colorpicker" */'../../css/features/colorpicker.css'),
]);
- init();
- coloris({
- el: selector,
- alpha: false,
- focusInput: true,
- selectInput: false,
- ...opts,
- });
-
- for (const inputEl of inputEls) {
- const parent = inputEl.closest('.js-color-picker-input');
- // prevent tabbing on the color preview `button` inside the input
- parent.querySelector('button').tabIndex = -1;
- // init precolors
- for (const el of parent.querySelectorAll('.precolors .color')) {
- el.addEventListener('click', (e) => {
- inputEl.value = e.target.getAttribute('data-color-hex');
- inputEl.dispatchEvent(new Event('input', {bubbles: true}));
- });
- }
+ for (const el of els) {
+ initPicker(el);
+ }
+}
+
+function updateSquare(el, newValue) {
+ el.style.color = /#[0-9a-f]{6}/i.test(newValue) ? newValue : 'transparent';
+}
+
+function updatePicker(el, newValue) {
+ el.setAttribute('color', newValue);
+}
+
+function initPicker(el) {
+ const input = el.querySelector('input');
+
+ const square = document.createElement('div');
+ square.classList.add('preview-square');
+ updateSquare(square, input.value);
+ el.append(square);
+
+ const picker = document.createElement('hex-color-picker');
+ picker.addEventListener('color-changed', (e) => {
+ input.value = e.detail.value;
+ input.focus();
+ updateSquare(square, e.detail.value);
+ });
+
+ input.addEventListener('input', (e) => {
+ updateSquare(square, e.target.value);
+ updatePicker(picker, e.target.value);
+ });
+
+ createTippy(input, {
+ trigger: 'focus click',
+ theme: 'bare',
+ hideOnClick: true,
+ content: picker,
+ placement: 'bottom-start',
+ interactive: true,
+ onShow() {
+ updatePicker(picker, input.value);
+ },
+ });
+
+ // init precolors
+ for (const colorEl of el.querySelectorAll('.precolors .color')) {
+ colorEl.addEventListener('click', (e) => {
+ const newValue = e.target.getAttribute('data-color-hex');
+ input.value = newValue;
+ input.dispatchEvent(new Event('input', {bubbles: true}));
+ updateSquare(square, newValue);
+ });
}
}
diff --git a/web_src/js/modules/tippy.js b/web_src/js/modules/tippy.js
index 220c9e5512..83b28e5745 100644
--- a/web_src/js/modules/tippy.js
+++ b/web_src/js/modules/tippy.js
@@ -3,11 +3,12 @@ import {isDocumentFragmentOrElementNode} from '../utils/dom.js';
import {formatDatetime} from '../utils/time.js';
const visibleInstances = new Set();
+const arrowSvg = ``;
export function createTippy(target, opts = {}) {
// the callback functions should be destructured from opts,
// because we should use our own wrapper functions to handle them, do not let the user override them
- const {onHide, onShow, onDestroy, role, theme, ...other} = opts;
+ const {onHide, onShow, onDestroy, role, theme, arrow, ...other} = opts;
const instance = tippy(target, {
appendTo: document.body,
@@ -35,9 +36,9 @@ export function createTippy(target, opts = {}) {
visibleInstances.add(instance);
return onShow?.(instance);
},
- arrow: ``,
+ arrow: arrow || (theme === 'bare' ? false : arrowSvg),
role: role || 'menu', // HTML role attribute
- theme: theme || role || 'menu', // CSS theme, either "tooltip", "menu" or "box-with-header"
+ theme: theme || role || 'menu', // CSS theme, either "tooltip", "menu", "box-with-header" or "bare"
plugins: [followCursor],
...other,
});