From 6f4e2e79ffd1a244e9c266db19840a5bfda09119 Mon Sep 17 00:00:00 2001 From: silverwind Date: Wed, 3 Apr 2024 03:44:15 +0200 Subject: [PATCH 1/4] Show 12 lines in markup code preview (#30255) Show up to 12 lines instead of previous 5. --- web_src/css/markup/codepreview.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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}" */ } From b28d3a4218b1338ce6f683d11993081b722bae0a Mon Sep 17 00:00:00 2001 From: scribblemaniac Date: Tue, 2 Apr 2024 19:47:13 -0600 Subject: [PATCH 2/4] Add -u git to docs when using docker exec with root installation (#29314) This fixes a minor issue in the documentation for SSH Container Passthrough for non-rootless installs. The non-rootless Dockerfile and docker-compose do not set `USER`/`user` instructions so `docker exec` will run as root by default. While running as root, gitea commands will refuse to execute, breaking these approaches. For containers built with the rootless instructions, `docker exec` will run as git by default so this is not necessary in that case. This issue was already discussed in #19065, but it does not appear this part of the issue was ever added to the documentation. --- docs/content/installation/with-docker.en-us.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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.) From 654cfd1dfbd3f3f1d94addee50b6fe2b018a49c3 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 3 Apr 2024 10:16:46 +0800 Subject: [PATCH 3/4] Refactor "dump" sub-command (#30240) Major changes: * Move some functions like "addReader" / "isSubDir" / "addRecursiveExclude" to a separate package, and add tests * Clarify the filename&dump type logic and add tests * Clarify the logger behavior and remove FIXME comments Co-authored-by: Giteabot --- cmd/dump.go | 296 ++++++++-------------------------- modules/dump/dumper.go | 174 ++++++++++++++++++++ modules/dump/dumper_test.go | 113 +++++++++++++ modules/setting/log.go | 9 ++ modules/timeutil/timestamp.go | 3 +- modules/util/util.go | 8 + 6 files changed, 374 insertions(+), 229 deletions(-) create mode 100644 modules/dump/dumper.go create mode 100644 modules/dump/dumper_test.go 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/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 From 1195be41a13d2198ab644c8558549edd74485510 Mon Sep 17 00:00:00 2001 From: silverwind Date: Wed, 3 Apr 2024 11:15:06 +0200 Subject: [PATCH 4/4] Replace coloris with vanilla-colorful (#30201) Found [a better color picker](https://github.com/web-padawan/vanilla-colorful) that [does not rely](https://github.com/mdbassit/Coloris/issues/139) on `querySelectorAll` or a global shared instance, and is also around a third of the size of the previous one. The popover is handled by tippy.js for which I introduced a new "bare" theme and it uses a new sibling-based mechanism which should prove useful later to create tippy popovers via HTML only. Screenshot 2024-03-31 at 04 03 38 --- package-lock.json | 12 +-- package.json | 2 +- web_src/css/features/colorpicker.css | 141 +++------------------------ web_src/css/modules/tippy.css | 11 +++ web_src/js/features/colorpicker.js | 85 +++++++++++----- web_src/js/modules/tippy.js | 7 +- 6 files changed, 94 insertions(+), 164 deletions(-) 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/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, });