1
1
mirror of https://github.com/go-gitea/gitea synced 2024-06-16 00:05:47 +00:00

Merge branch 'main' into xfo

This commit is contained in:
silverwind 2024-04-03 16:10:16 +02:00 committed by GitHub
commit 608b6e01c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 471 additions and 396 deletions

View File

@ -6,14 +6,13 @@ package cmd
import ( import (
"fmt" "fmt"
"io"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/dump"
"code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@ -25,89 +24,17 @@ import (
"github.com/urfave/cli/v2" "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. // CmdDump represents the available dump sub-command.
var CmdDump = &cli.Command{ var CmdDump = &cli.Command{
Name: "dump", Name: "dump",
Usage: "Dump Gitea files and database", Usage: "Dump Gitea files and database",
Description: `Dump compresses all related files and database into zip file. 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`,
It can be used for backup and capture Gitea server image to send to maintainer`,
Action: runDump, Action: runDump,
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.StringFlag{ &cli.StringFlag{
Name: "file", Name: "file",
Aliases: []string{"f"}, Aliases: []string{"f"},
Value: fmt.Sprintf("gitea-dump-%d.zip", time.Now().Unix()), Usage: `Name of the dump file which will be created, default to "gitea-dump-{time}.zip". Supply '-' for stdout. See type for available types.`,
Usage: "Name of the dump file which will be created. Supply '-' for stdout. See type for available types.",
}, },
&cli.BoolFlag{ &cli.BoolFlag{
Name: "verbose", Name: "verbose",
@ -160,64 +87,52 @@ It can be used for backup and capture Gitea server image to send to maintainer`,
Name: "skip-index", Name: "skip-index",
Usage: "Skip bleve index data", Usage: "Skip bleve index data",
}, },
&cli.GenericFlag{ &cli.StringFlag{
Name: "type", Name: "type",
Value: outputTypeEnum, Usage: fmt.Sprintf(`Dump output format, default to "zip", supported types: %s`, strings.Join(dump.SupportedOutputTypes, ", ")),
Usage: fmt.Sprintf("Dump output format: %s", outputTypeEnum.Join()),
}, },
}, },
} }
func fatal(format string, args ...any) { func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
log.Fatal(format, args...) log.Fatal(format, args...)
} }
func runDump(ctx *cli.Context) error { 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() setting.MustInstalled()
// make sure we are logging to the console no matter what the configuration tells us do to quite := ctx.Bool("quiet")
// 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
verbose := ctx.Bool("verbose") verbose := ctx.Bool("verbose")
if verbose && ctx.Bool("quiet") { if verbose && quite {
return fmt.Errorf("--quiet and --verbose cannot both be set") 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() stdCtx, cancel := installSignals()
defer cancel() defer cancel()
@ -226,44 +141,32 @@ func runDump(ctx *cli.Context) error {
return err return err
} }
if err := storage.Init(); err != nil { if err = storage.Init(); err != nil {
return err return err
} }
if file == nil { archiverGeneric, err := archiver.ByExtension("." + outType)
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)
}
if err != nil { if err != nil {
fatal("Unable to get archiver for extension: %v", err) fatal("Unable to get archiver for extension: %v", err)
} }
w, _ := iface.(archiver.Writer) archiverWriter := archiverGeneric.(archiver.Writer)
if err := w.Create(file); err != nil { if err := archiverWriter.Create(outFile); err != nil {
fatal("Creating archiver.Writer failed: %v", err) 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") { if ctx.IsSet("skip-repository") && ctx.Bool("skip-repository") {
log.Info("Skip dumping local repositories") log.Info("Skip dumping local repositories")
} else { } else {
log.Info("Dumping local repositories... %s", setting.RepoRootPath) 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) fatal("Failed to include repositories: %v", err)
} }
@ -276,8 +179,7 @@ func runDump(ctx *cli.Context) error {
if err != nil { if err != nil {
return err return err
} }
return dumper.AddReader(object, info, path.Join("data", "lfs", objPath))
return addReader(w, object, info, path.Join("data", "lfs", objPath), verbose)
}); err != nil { }); err != nil {
fatal("Failed to dump LFS objects: %v", err) fatal("Failed to dump LFS objects: %v", err)
} }
@ -310,24 +212,22 @@ func runDump(ctx *cli.Context) error {
fatal("Failed to dump database: %v", err) 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) fatal("Failed to include gitea-db.sql: %v", err)
} }
if len(setting.CustomConf) > 0 {
log.Info("Adding custom configuration file from %s", setting.CustomConf) log.Info("Adding custom configuration file from %s", setting.CustomConf)
if err := addFile(w, "app.ini", setting.CustomConf, verbose); err != nil { if err = dumper.AddFile("app.ini", setting.CustomConf); err != nil {
fatal("Failed to include specified app.ini: %v", err) fatal("Failed to include specified app.ini: %v", err)
} }
}
if ctx.IsSet("skip-custom-dir") && ctx.Bool("skip-custom-dir") { if ctx.IsSet("skip-custom-dir") && ctx.Bool("skip-custom-dir") {
log.Info("Skipping custom directory") log.Info("Skipping custom directory")
} else { } else {
customDir, err := os.Stat(setting.CustomPath) customDir, err := os.Stat(setting.CustomPath)
if err == nil && customDir.IsDir() { if err == nil && customDir.IsDir() {
if is, _ := isSubdir(setting.AppDataPath, setting.CustomPath); !is { if is, _ := dump.IsSubdir(setting.AppDataPath, setting.CustomPath); !is {
if err := addRecursiveExclude(w, "custom", setting.CustomPath, []string{absFileName}, verbose); err != nil { if err := dumper.AddRecursiveExclude("custom", setting.CustomPath, nil); err != nil {
fatal("Failed to include custom: %v", err) fatal("Failed to include custom: %v", err)
} }
} else { } else {
@ -364,8 +264,7 @@ func runDump(ctx *cli.Context) error {
excludes = append(excludes, setting.Attachment.Storage.Path) excludes = append(excludes, setting.Attachment.Storage.Path)
excludes = append(excludes, setting.Packages.Storage.Path) excludes = append(excludes, setting.Packages.Storage.Path)
excludes = append(excludes, setting.Log.RootPath) excludes = append(excludes, setting.Log.RootPath)
excludes = append(excludes, absFileName) if err := dumper.AddRecursiveExclude("data", setting.AppDataPath, excludes); err != nil {
if err := addRecursiveExclude(w, "data", setting.AppDataPath, excludes, verbose); err != nil {
fatal("Failed to include data directory: %v", err) fatal("Failed to include data directory: %v", err)
} }
} }
@ -377,8 +276,7 @@ func runDump(ctx *cli.Context) error {
if err != nil { if err != nil {
return err return err
} }
return dumper.AddReader(object, info, path.Join("data", "attachments", objPath))
return addReader(w, object, info, path.Join("data", "attachments", objPath), verbose)
}); err != nil { }); err != nil {
fatal("Failed to dump attachments: %v", err) fatal("Failed to dump attachments: %v", err)
} }
@ -392,8 +290,7 @@ func runDump(ctx *cli.Context) error {
if err != nil { if err != nil {
return err return err
} }
return dumper.AddReader(object, info, path.Join("data", "packages", objPath))
return addReader(w, object, info, path.Join("data", "packages", objPath), verbose)
}); err != nil { }); err != nil {
fatal("Failed to dump packages: %v", err) 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) log.Error("Unable to check if %s exists. Error: %v", setting.Log.RootPath, err)
} }
if isExist { 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) fatal("Failed to include log: %v", err)
} }
} }
} }
if fileName != "-" { if outFileName == "-" {
if err = w.Close(); err != nil { log.Info("Finish dumping to stdout")
_ = util.Remove(fileName) } else {
fatal("Failed to save %s: %v", fileName, err) if err = archiverWriter.Close(); err != nil {
_ = os.Remove(outFileName)
fatal("Failed to save %q: %v", outFileName, err)
} }
if err = os.Chmod(outFileName, 0o600); err != nil {
if err := os.Chmod(fileName, 0o600); err != nil {
log.Info("Can't change file access permissions mask to 0600: %v", err) log.Info("Can't change file access permissions mask to 0600: %v", err)
} }
} log.Info("Finish dumping in file %s", outFileName)
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
}
}
}
} }
return nil return nil
} }

View File

@ -545,7 +545,7 @@ In this option, the idea is that the host SSH uses an `AuthorizedKeysCommand` in
```bash ```bash
cat <<"EOF" | sudo tee /home/git/docker-shell cat <<"EOF" | sudo tee /home/git/docker-shell
#!/bin/sh #!/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 EOF
sudo chmod +x /home/git/docker-shell sudo chmod +x /home/git/docker-shell
sudo usermod -s /home/git/docker-shell git 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 ```bash
Match User git Match User git
AuthorizedKeysCommandUser 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 1.16.0 you will not need to set the `-c /data/gitea/conf/app.ini` option.)

174
modules/dump/dumper.go Normal file
View File

@ -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
}

113
modules/dump/dumper_test.go Normal file
View File

@ -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))
}

View File

@ -185,8 +185,13 @@ func InitLoggersForTest() {
initAllLoggers() initAllLoggers()
} }
var initLoggerDisabled bool
// initAllLoggers creates all the log services // initAllLoggers creates all the log services
func initAllLoggers() { func initAllLoggers() {
if initLoggerDisabled {
return
}
initManagedLoggers(log.GetManager(), CfgProvider) initManagedLoggers(log.GetManager(), CfgProvider)
golog.SetFlags(0) golog.SetFlags(0)
@ -194,6 +199,10 @@ func initAllLoggers() {
golog.SetOutput(log.LoggerToWriter(log.GetLogger(log.DEFAULT).Info)) golog.SetOutput(log.LoggerToWriter(log.GetLogger(log.DEFAULT).Info))
} }
func DisableLoggerInit() {
initLoggerDisabled = true
}
func initManagedLoggers(manager *log.LoggerManager, cfg ConfigProvider) { func initManagedLoggers(manager *log.LoggerManager, cfg ConfigProvider) {
loadLogGlobalFrom(cfg) loadLogGlobalFrom(cfg)
prepareLoggerConfig(cfg) prepareLoggerConfig(cfg)

View File

@ -21,8 +21,9 @@ var (
) )
// MockSet sets the time to a mocked time.Time // MockSet sets the time to a mocked time.Time
func MockSet(now time.Time) { func MockSet(now time.Time) func() {
mockNow = now mockNow = now
return MockUnset
} }
// MockUnset will unset the mocked time.Time // MockUnset will unset the mocked time.Time

View File

@ -213,6 +213,14 @@ func ToPointer[T any](val T) *T {
return &val 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" // IfZero returns "def" if "v" is a zero value, otherwise "v"
func IfZero[T comparable](v, def T) T { func IfZero[T comparable](v, def T) T {
var zero T var zero T

12
package-lock.json generated
View File

@ -13,7 +13,6 @@
"@github/relative-time-element": "4.4.0", "@github/relative-time-element": "4.4.0",
"@github/text-expander-element": "2.6.1", "@github/text-expander-element": "2.6.1",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@melloware/coloris": "0.23.0",
"@primer/octicons": "19.9.0", "@primer/octicons": "19.9.0",
"add-asset-webpack-plugin": "2.0.1", "add-asset-webpack-plugin": "2.0.1",
"ansi_up": "6.0.2", "ansi_up": "6.0.2",
@ -54,6 +53,7 @@
"toastify-js": "1.12.0", "toastify-js": "1.12.0",
"tributejs": "5.1.3", "tributejs": "5.1.3",
"uint8-to-base64": "0.2.0", "uint8-to-base64": "0.2.0",
"vanilla-colorful": "0.7.2",
"vue": "3.4.21", "vue": "3.4.21",
"vue-bar-graph": "2.0.0", "vue-bar-graph": "2.0.0",
"vue-chartjs": "5.3.0", "vue-chartjs": "5.3.0",
@ -1290,11 +1290,6 @@
"@mcaptcha/core-glue": "^0.1.0-alpha-5" "@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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -11853,6 +11848,11 @@
"builtins": "^1.0.3" "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": { "node_modules/vite": {
"version": "5.2.6", "version": "5.2.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.6.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.6.tgz",

View File

@ -12,7 +12,6 @@
"@github/relative-time-element": "4.4.0", "@github/relative-time-element": "4.4.0",
"@github/text-expander-element": "2.6.1", "@github/text-expander-element": "2.6.1",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@melloware/coloris": "0.23.0",
"@primer/octicons": "19.9.0", "@primer/octicons": "19.9.0",
"add-asset-webpack-plugin": "2.0.1", "add-asset-webpack-plugin": "2.0.1",
"ansi_up": "6.0.2", "ansi_up": "6.0.2",
@ -53,6 +52,7 @@
"toastify-js": "1.12.0", "toastify-js": "1.12.0",
"tributejs": "5.1.3", "tributejs": "5.1.3",
"uint8-to-base64": "0.2.0", "uint8-to-base64": "0.2.0",
"vanilla-colorful": "0.7.2",
"vue": "3.4.21", "vue": "3.4.21",
"vue-bar-graph": "2.0.0", "vue-bar-graph": "2.0.0",
"vue-chartjs": "5.3.0", "vue-chartjs": "5.3.0",

View File

@ -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 { .js-color-picker-input {
display: flex; display: flex;
flex-wrap: wrap; position: relative;
} }
.js-color-picker-input input { .js-color-picker-input input {
@ -13,152 +9,39 @@
padding-left: 32px !important; padding-left: 32px !important;
} }
.clr-picker { .js-color-picker-input .preview-square {
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 {
position: absolute; position: absolute;
aspect-ratio: 1; aspect-ratio: 1;
height: 16px; height: 16px;
left: 10px; left: 10px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
margin: 0;
padding: 0;
border: 0;
color: inherit;
pointer-events: none;
border-radius: 2px; 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: 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-position: 0 0, 4px 4px;
background-size: 8px 8px; background-size: 8px 8px;
} }
.clr-field button::after { .js-color-picker-input .preview-square::after {
content: ""; content: "";
display: block;
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
left: 0;
top: 0;
border-radius: inherit; border-radius: inherit;
background-color: currentcolor; background-color: currentcolor;
} }
.clr-marker:focus { hex-color-picker {
outline: none; width: 180px;
height: 120px;
} }
.clr-keyboard-nav .clr-marker:focus, hex-color-picker::part(hue-pointer),
.clr-keyboard-nav .clr-hue input:focus + div, hex-color-picker::part(saturation-pointer) {
.clr-keyboard-nav .clr-alpha input:focus + div { width: 22px;
outline: none; height: 22px;
box-shadow: 0 0 2px 2px var(--color-white);
} }
.clr-picker .clr-preview, hex-color-picker::part(hue) {
.clr-picker .clr-clear, flex-basis: 16px;
.clr-picker .clr-swatches,
.clr-picker .clr-format,
.clr-picker .clr-alpha,
.clr-picker .clr-color {
display: none;
} }

View File

@ -12,7 +12,7 @@
.markup .code-preview-container table { .markup .code-preview-container table {
width: 100%; width: 100%;
max-height: 100px; max-height: 240px; /* 12 lines at 20px per line */
overflow-y: auto; overflow-y: auto;
margin: 0; /* override ".markup table {margin}" */ margin: 0; /* override ".markup table {margin}" */
} }

View File

@ -29,6 +29,17 @@
z-index: 1; 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 */ /* tooltip theme for text tooltips */
.tippy-box[data-theme="tooltip"] { .tippy-box[data-theme="tooltip"] {

View File

@ -1,31 +1,66 @@
export async function initColorPickers(selector = '.js-color-picker-input input', opts = {}) { import {createTippy} from '../modules/tippy.js';
const inputEls = document.querySelectorAll(selector);
if (!inputEls.length) return;
const [{coloris, init}] = await Promise.all([ export async function initColorPickers() {
import(/* webpackChunkName: "colorpicker" */'@melloware/coloris'), 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'), import(/* webpackChunkName: "colorpicker" */'../../css/features/colorpicker.css'),
]); ]);
init(); for (const el of els) {
coloris({ initPicker(el);
el: selector, }
alpha: false, }
focusInput: true,
selectInput: false, function updateSquare(el, newValue) {
...opts, 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);
},
}); });
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 // init precolors
for (const el of parent.querySelectorAll('.precolors .color')) { for (const colorEl of el.querySelectorAll('.precolors .color')) {
el.addEventListener('click', (e) => { colorEl.addEventListener('click', (e) => {
inputEl.value = e.target.getAttribute('data-color-hex'); const newValue = e.target.getAttribute('data-color-hex');
inputEl.dispatchEvent(new Event('input', {bubbles: true})); input.value = newValue;
input.dispatchEvent(new Event('input', {bubbles: true}));
updateSquare(square, newValue);
}); });
} }
} }
}

View File

@ -3,11 +3,12 @@ import {isDocumentFragmentOrElementNode} from '../utils/dom.js';
import {formatDatetime} from '../utils/time.js'; import {formatDatetime} from '../utils/time.js';
const visibleInstances = new Set(); const visibleInstances = new Set();
const arrowSvg = `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`;
export function createTippy(target, opts = {}) { export function createTippy(target, opts = {}) {
// the callback functions should be destructured from 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 // 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, { const instance = tippy(target, {
appendTo: document.body, appendTo: document.body,
@ -35,9 +36,9 @@ export function createTippy(target, opts = {}) {
visibleInstances.add(instance); visibleInstances.add(instance);
return onShow?.(instance); return onShow?.(instance);
}, },
arrow: `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`, arrow: arrow || (theme === 'bare' ? false : arrowSvg),
role: role || 'menu', // HTML role attribute 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], plugins: [followCursor],
...other, ...other,
}); });