diff --git a/.ignore b/.ignore index 5c945ab9810ee..5b96dabd38aa2 100644 --- a/.ignore +++ b/.ignore @@ -4,6 +4,8 @@ /modules/options/bindata.go /modules/public/bindata.go /modules/templates/bindata.go -/vendor +/options/gitignore +/options/license /public/assets +/vendor node_modules diff --git a/cmd/dump.go b/cmd/dump.go index 69ecdcec1244d..da0a51d9ce934 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,63 +87,51 @@ 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) + quite := ctx.Bool("quiet") + verbose := ctx.Bool("verbose") + if verbose && quite { + fatal("Option --quiet and --verbose cannot both be set") } - if _, err := setting.CfgProvider.Section("log.console").NewKey("STDERR", "true"); err != nil { - fatal("Setting console logger to stderr failed: %v", err) + + // 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") } - // 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) + 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() } - 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 + setupConsoleLogger(util.Iif(quite, log.WARN, log.INFO), log.CanColorStderr, os.Stderr) - verbose := ctx.Bool("verbose") - if verbose && ctx.Bool("quiet") { - return fmt.Errorf("--quiet and --verbose cannot both be set") - } + 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 e67f5bccb2a41..e8a80f7c9694a 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/models/asymkey/gpg_key_commit_verification.go b/models/asymkey/gpg_key_commit_verification.go index 83fbab5d36c8a..06ac31bc6f325 100644 --- a/models/asymkey/gpg_key_commit_verification.go +++ b/models/asymkey/gpg_key_commit_verification.go @@ -139,13 +139,7 @@ func ParseCommitWithSignature(ctx context.Context, c *git.Commit) *CommitVerific } } - keyID := "" - if sig.IssuerKeyId != nil && (*sig.IssuerKeyId) != 0 { - keyID = fmt.Sprintf("%X", *sig.IssuerKeyId) - } - if keyID == "" && sig.IssuerFingerprint != nil && len(sig.IssuerFingerprint) > 0 { - keyID = fmt.Sprintf("%X", sig.IssuerFingerprint[12:20]) - } + keyID := tryGetKeyIDFromSignature(sig) defaultReason := NoKeyFound // First check if the sig has a keyID and if so just look at that diff --git a/models/asymkey/gpg_key_common.go b/models/asymkey/gpg_key_common.go index b02be2851a75e..9c015582f1bdb 100644 --- a/models/asymkey/gpg_key_common.go +++ b/models/asymkey/gpg_key_common.go @@ -134,3 +134,13 @@ func extractSignature(s string) (*packet.Signature, error) { } return sig, nil } + +func tryGetKeyIDFromSignature(sig *packet.Signature) string { + if sig.IssuerKeyId != nil && (*sig.IssuerKeyId) != 0 { + return fmt.Sprintf("%016X", *sig.IssuerKeyId) + } + if sig.IssuerFingerprint != nil && len(sig.IssuerFingerprint) > 0 { + return fmt.Sprintf("%016X", sig.IssuerFingerprint[12:20]) + } + return "" +} diff --git a/models/asymkey/gpg_key_test.go b/models/asymkey/gpg_key_test.go index dee74bc281d0c..d3fbb01d82b1d 100644 --- a/models/asymkey/gpg_key_test.go +++ b/models/asymkey/gpg_key_test.go @@ -11,7 +11,9 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "github.com/keybase/go-crypto/openpgp/packet" "github.com/stretchr/testify/assert" ) @@ -391,3 +393,13 @@ epiDVQ== assert.Equal(t, time.Unix(1586105389, 0), expire) } } + +func TestTryGetKeyIDFromSignature(t *testing.T) { + assert.Empty(t, tryGetKeyIDFromSignature(&packet.Signature{})) + assert.Equal(t, "038D1A3EADDBEA9C", tryGetKeyIDFromSignature(&packet.Signature{ + IssuerKeyId: util.ToPointer(uint64(0x38D1A3EADDBEA9C)), + })) + assert.Equal(t, "038D1A3EADDBEA9C", tryGetKeyIDFromSignature(&packet.Signature{ + IssuerFingerprint: []uint8{0xb, 0x23, 0x24, 0xc7, 0xe6, 0xfe, 0x4f, 0x3a, 0x6, 0x26, 0xc1, 0x21, 0x3, 0x8d, 0x1a, 0x3e, 0xad, 0xdb, 0xea, 0x9c}, + })) +} diff --git a/models/migrations/fixtures/Test_AddUniqueIndexForProjectIssue/project_issue.yml b/models/migrations/fixtures/Test_AddUniqueIndexForProjectIssue/project_issue.yml new file mode 100644 index 0000000000000..6feaeb39f0598 --- /dev/null +++ b/models/migrations/fixtures/Test_AddUniqueIndexForProjectIssue/project_issue.yml @@ -0,0 +1,9 @@ +- + id: 1 + project_id: 1 + issue_id: 1 + +- + id: 2 + project_id: 1 + issue_id: 1 diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 0daa799ff6754..387cd96a53471 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -21,6 +21,7 @@ import ( "code.gitea.io/gitea/models/migrations/v1_20" "code.gitea.io/gitea/models/migrations/v1_21" "code.gitea.io/gitea/models/migrations/v1_22" + "code.gitea.io/gitea/models/migrations/v1_23" "code.gitea.io/gitea/models/migrations/v1_6" "code.gitea.io/gitea/models/migrations/v1_7" "code.gitea.io/gitea/models/migrations/v1_8" @@ -572,6 +573,10 @@ var migrations = []Migration{ NewMigration("Ensure every project has exactly one default column - No Op", noopMigration), // v293 -> v294 NewMigration("Ensure every project has exactly one default column", v1_22.CheckProjectColumnsConsistency), + + // Gitea 1.22.0 ends at 294 + + NewMigration("Add unique index for project issue table", v1_23.AddUniqueIndexForProjectIssue), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_23/main_test.go b/models/migrations/v1_23/main_test.go new file mode 100644 index 0000000000000..b7948bd4dd248 --- /dev/null +++ b/models/migrations/v1_23/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_23 //nolint + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" +) + +func TestMain(m *testing.M) { + base.MainTest(m) +} diff --git a/models/migrations/v1_23/v294.go b/models/migrations/v1_23/v294.go new file mode 100644 index 0000000000000..f2a54f6d23ecc --- /dev/null +++ b/models/migrations/v1_23/v294.go @@ -0,0 +1,53 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_23 //nolint + +import ( + "fmt" + + "xorm.io/xorm" + "xorm.io/xorm/schemas" +) + +// AddUniqueIndexForProjectIssue adds unique indexes for project issue table +func AddUniqueIndexForProjectIssue(x *xorm.Engine) error { + // remove possible duplicated records in table project_issue + type result struct { + IssueID int64 + ProjectID int64 + Cnt int + } + var results []result + if err := x.Select("issue_id, project_id, count(*) as cnt"). + Table("project_issue"). + GroupBy("issue_id, project_id"). + Having("count(*) > 1"). + Find(&results); err != nil { + return err + } + for _, r := range results { + if x.Dialect().URI().DBType == schemas.MSSQL { + if _, err := x.Exec(fmt.Sprintf("delete from project_issue where id in (SELECT top %d id FROM project_issue WHERE issue_id = ? and project_id = ?)", r.Cnt-1), r.IssueID, r.ProjectID); err != nil { + return err + } + } else { + var ids []int64 + if err := x.SQL("SELECT id FROM project_issue WHERE issue_id = ? and project_id = ? limit ?", r.IssueID, r.ProjectID, r.Cnt-1).Find(&ids); err != nil { + return err + } + if _, err := x.Table("project_issue").In("id", ids).Delete(); err != nil { + return err + } + } + } + + // add unique index for project_issue table + type ProjectIssue struct { //revive:disable-line:exported + ID int64 `xorm:"pk autoincr"` + IssueID int64 `xorm:"INDEX unique(s)"` + ProjectID int64 `xorm:"INDEX unique(s)"` + } + + return x.Sync(new(ProjectIssue)) +} diff --git a/models/migrations/v1_23/v294_test.go b/models/migrations/v1_23/v294_test.go new file mode 100644 index 0000000000000..d9a44ad866bb1 --- /dev/null +++ b/models/migrations/v1_23/v294_test.go @@ -0,0 +1,52 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_23 //nolint + +import ( + "slices" + "testing" + + "code.gitea.io/gitea/models/migrations/base" + + "github.com/stretchr/testify/assert" + "xorm.io/xorm/schemas" +) + +func Test_AddUniqueIndexForProjectIssue(t *testing.T) { + type ProjectIssue struct { //revive:disable-line:exported + ID int64 `xorm:"pk autoincr"` + IssueID int64 `xorm:"INDEX"` + ProjectID int64 `xorm:"INDEX"` + } + + // Prepare and load the testing database + x, deferable := base.PrepareTestEnv(t, 0, new(ProjectIssue)) + defer deferable() + if x == nil || t.Failed() { + return + } + + cnt, err := x.Table("project_issue").Where("project_id=1 AND issue_id=1").Count() + assert.NoError(t, err) + assert.EqualValues(t, 2, cnt) + + assert.NoError(t, AddUniqueIndexForProjectIssue(x)) + + cnt, err = x.Table("project_issue").Where("project_id=1 AND issue_id=1").Count() + assert.NoError(t, err) + assert.EqualValues(t, 1, cnt) + + tables, err := x.DBMetas() + assert.NoError(t, err) + assert.EqualValues(t, 1, len(tables)) + found := false + for _, index := range tables[0].Indexes { + if index.Type == schemas.UniqueType { + found = true + slices.Equal(index.Cols, []string{"project_id", "issue_id"}) + break + } + } + assert.True(t, found) +} diff --git a/models/user/email_address.go b/models/user/email_address.go index d26549f383db4..08771efe99d15 100644 --- a/models/user/email_address.go +++ b/models/user/email_address.go @@ -256,14 +256,6 @@ func IsEmailUsed(ctx context.Context, email string) (bool, error) { return db.GetEngine(ctx).Where("lower_email=?", strings.ToLower(email)).Get(&EmailAddress{}) } -// DeleteInactiveEmailAddresses deletes inactive email addresses -func DeleteInactiveEmailAddresses(ctx context.Context) error { - _, err := db.GetEngine(ctx). - Where("is_activated = ?", false). - Delete(new(EmailAddress)) - return err -} - // ActivateEmail activates the email address to given user. func ActivateEmail(ctx context.Context, email *EmailAddress) error { ctx, committer, err := db.TxContext(ctx) diff --git a/modules/charset/escape_test.go b/modules/charset/escape_test.go index a353ced63169f..9d796a0c1832e 100644 --- a/modules/charset/escape_test.go +++ b/modules/charset/escape_test.go @@ -4,6 +4,7 @@ package charset import ( + "regexp" "strings" "testing" @@ -156,13 +157,16 @@ func TestEscapeControlReader(t *testing.T) { tests = append(tests, test) } + re := regexp.MustCompile(`repo.ambiguous_character:\d+,\d+`) // simplify the output for the tests, remove the translation variants for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { output := &strings.Builder{} status, err := EscapeControlReader(strings.NewReader(tt.text), output, &translation.MockLocale{}) assert.NoError(t, err) assert.Equal(t, tt.status, *status) - assert.Equal(t, tt.result, output.String()) + outStr := output.String() + outStr = re.ReplaceAllString(outStr, "repo.ambiguous_character") + assert.Equal(t, tt.result, outStr) }) } } diff --git a/modules/csv/csv_test.go b/modules/csv/csv_test.go index f6e782a5a460d..3ddb47acbbe77 100644 --- a/modules/csv/csv_test.go +++ b/modules/csv/csv_test.go @@ -561,14 +561,14 @@ func TestFormatError(t *testing.T) { err: &csv.ParseError{ Err: csv.ErrFieldCount, }, - expectedMessage: "repo.error.csv.invalid_field_count", + expectedMessage: "repo.error.csv.invalid_field_count:0", expectsError: false, }, { err: &csv.ParseError{ Err: csv.ErrBareQuote, }, - expectedMessage: "repo.error.csv.unexpected", + expectedMessage: "repo.error.csv.unexpected:0,0", expectsError: false, }, { diff --git a/modules/dump/dumper.go b/modules/dump/dumper.go new file mode 100644 index 0000000000000..47730851fb369 --- /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 0000000000000..b444fa2de538e --- /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/git/commit.go b/modules/git/commit.go index ef2676762c174..5f442b0e1aaca 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -26,14 +26,14 @@ type Commit struct { Author *Signature Committer *Signature CommitMessage string - Signature *CommitGPGSignature + Signature *CommitSignature Parents []ObjectID // ID strings submoduleCache *ObjectCache } -// CommitGPGSignature represents a git commit signature part. -type CommitGPGSignature struct { +// CommitSignature represents a git commit signature part. +type CommitSignature struct { Signature string Payload string // TODO check if can be reconstruct from the rest of commit information to not have duplicate data } diff --git a/modules/git/commit_convert_gogit.go b/modules/git/commit_convert_gogit.go index 33ef2f4487020..d7b945ed6b8c3 100644 --- a/modules/git/commit_convert_gogit.go +++ b/modules/git/commit_convert_gogit.go @@ -13,7 +13,7 @@ import ( "github.com/go-git/go-git/v5/plumbing/object" ) -func convertPGPSignature(c *object.Commit) *CommitGPGSignature { +func convertPGPSignature(c *object.Commit) *CommitSignature { if c.PGPSignature == "" { return nil } @@ -57,7 +57,7 @@ func convertPGPSignature(c *object.Commit) *CommitGPGSignature { return nil } - return &CommitGPGSignature{ + return &CommitSignature{ Signature: c.PGPSignature, Payload: w.String(), } diff --git a/modules/git/commit_reader.go b/modules/git/commit_reader.go index 56c41dc473b1d..f1f4a0e588dec 100644 --- a/modules/git/commit_reader.go +++ b/modules/git/commit_reader.go @@ -99,7 +99,7 @@ readLoop: } } commit.CommitMessage = messageSB.String() - commit.Signature = &CommitGPGSignature{ + commit.Signature = &CommitSignature{ Signature: signatureSB.String(), Payload: payloadSB.String(), } diff --git a/modules/git/repo_tag.go b/modules/git/repo_tag.go index e8c5ce6fb85b4..2026a4c9f5c1a 100644 --- a/modules/git/repo_tag.go +++ b/modules/git/repo_tag.go @@ -185,17 +185,15 @@ func parseTagRef(ref map[string]string) (tag *Tag, err error) { tag.Tagger = parseSignatureFromCommitLine(ref["creator"]) tag.Message = ref["contents"] - // strip PGP signature if present in contents field - pgpStart := strings.Index(tag.Message, beginpgp) - if pgpStart >= 0 { - tag.Message = tag.Message[0:pgpStart] - } + + // strip any signature if present in contents field + _, tag.Message, _ = parsePayloadSignature(util.UnsafeStringToBytes(tag.Message), 0) // annotated tag with GPG signature if tag.Type == "tag" && ref["contents:signature"] != "" { payload := fmt.Sprintf("object %s\ntype commit\ntag %s\ntagger %s\n\n%s\n", tag.Object, tag.Name, ref["creator"], strings.TrimSpace(tag.Message)) - tag.Signature = &CommitGPGSignature{ + tag.Signature = &CommitSignature{ Signature: ref["contents:signature"], Payload: payload, } diff --git a/modules/git/repo_tag_test.go b/modules/git/repo_tag_test.go index 785c3442a79c5..0117cb902d4a6 100644 --- a/modules/git/repo_tag_test.go +++ b/modules/git/repo_tag_test.go @@ -315,7 +315,7 @@ qbHDASXl Type: "tag", Tagger: parseSignatureFromCommitLine("Foo Bar 1565789218 +0300"), Message: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md", - Signature: &CommitGPGSignature{ + Signature: &CommitSignature{ Signature: `-----BEGIN PGP SIGNATURE----- aBCGzBAABCgAdFiEEyWRwv/q1Q6IjSv+D4IPOwzt33PoFAmI8jbIACgkQ4IPOwzt3 diff --git a/modules/git/tag.go b/modules/git/tag.go index 94e5cd7c63b78..f7666aa89b126 100644 --- a/modules/git/tag.go +++ b/modules/git/tag.go @@ -6,16 +6,10 @@ package git import ( "bytes" "sort" - "strings" "code.gitea.io/gitea/modules/util" ) -const ( - beginpgp = "\n-----BEGIN PGP SIGNATURE-----\n" - endpgp = "\n-----END PGP SIGNATURE-----" -) - // Tag represents a Git tag. type Tag struct { Name string @@ -24,7 +18,7 @@ type Tag struct { Type string Tagger *Signature Message string - Signature *CommitGPGSignature + Signature *CommitSignature } // Commit return the commit of the tag reference @@ -32,6 +26,36 @@ func (tag *Tag) Commit(gitRepo *Repository) (*Commit, error) { return gitRepo.getCommit(tag.Object) } +func parsePayloadSignature(data []byte, messageStart int) (payload, msg, sign string) { + pos := messageStart + signStart, signEnd := -1, -1 + for { + eol := bytes.IndexByte(data[pos:], '\n') + if eol < 0 { + break + } + line := data[pos : pos+eol] + signType, hasPrefix := bytes.CutPrefix(line, []byte("-----BEGIN ")) + signType, hasSuffix := bytes.CutSuffix(signType, []byte(" SIGNATURE-----")) + if hasPrefix && hasSuffix { + signEndBytes := append([]byte("\n-----END "), signType...) + signEndBytes = append(signEndBytes, []byte(" SIGNATURE-----")...) + signEnd = bytes.Index(data[pos:], signEndBytes) + if signEnd != -1 { + signStart = pos + signEnd = pos + signEnd + len(signEndBytes) + } + } + pos += eol + 1 + } + + if signStart != -1 && signEnd != -1 { + msgEnd := max(messageStart, signStart-1) + return string(data[:msgEnd]), string(data[messageStart:msgEnd]), string(data[signStart:signEnd]) + } + return string(data), string(data[messageStart:]), "" +} + // Parse commit information from the (uncompressed) raw // data from the commit object. // \n\n separate headers from message @@ -40,47 +64,37 @@ func parseTagData(objectFormat ObjectFormat, data []byte) (*Tag, error) { tag.ID = objectFormat.EmptyObjectID() tag.Object = objectFormat.EmptyObjectID() tag.Tagger = &Signature{} - // we now have the contents of the commit object. Let's investigate... - nextline := 0 -l: + + pos := 0 for { - eol := bytes.IndexByte(data[nextline:], '\n') - switch { - case eol > 0: - line := data[nextline : nextline+eol] - spacepos := bytes.IndexByte(line, ' ') - reftype := line[:spacepos] - switch string(reftype) { - case "object": - id, err := NewIDFromString(string(line[spacepos+1:])) - if err != nil { - return nil, err - } - tag.Object = id - case "type": - // A commit can have one or more parents - tag.Type = string(line[spacepos+1:]) - case "tagger": - tag.Tagger = parseSignatureFromCommitLine(util.UnsafeBytesToString(line[spacepos+1:])) - } - nextline += eol + 1 - case eol == 0: - tag.Message = string(data[nextline+1:]) - break l - default: - break l + eol := bytes.IndexByte(data[pos:], '\n') + if eol == -1 { + break // shouldn't happen, but could just tolerate it } - } - idx := strings.LastIndex(tag.Message, beginpgp) - if idx > 0 { - endSigIdx := strings.Index(tag.Message[idx:], endpgp) - if endSigIdx > 0 { - tag.Signature = &CommitGPGSignature{ - Signature: tag.Message[idx+1 : idx+endSigIdx+len(endpgp)], - Payload: string(data[:bytes.LastIndex(data, []byte(beginpgp))+1]), + if eol == 0 { + pos++ + break // end of headers + } + line := data[pos : pos+eol] + key, val, _ := bytes.Cut(line, []byte(" ")) + switch string(key) { + case "object": + id, err := NewIDFromString(string(val)) + if err != nil { + return nil, err } - tag.Message = tag.Message[:idx+1] + tag.Object = id + case "type": + tag.Type = string(val) // A commit can have one or more parents + case "tagger": + tag.Tagger = parseSignatureFromCommitLine(util.UnsafeBytesToString(val)) } + pos += eol + 1 + } + payload, msg, sign := parsePayloadSignature(data, pos) + tag.Message = msg + if len(sign) > 0 { + tag.Signature = &CommitSignature{Signature: sign, Payload: payload} } return tag, nil } diff --git a/modules/git/tag_test.go b/modules/git/tag_test.go index f980b0c560c4f..ba02c28946de2 100644 --- a/modules/git/tag_test.go +++ b/modules/git/tag_test.go @@ -12,24 +12,28 @@ import ( func Test_parseTagData(t *testing.T) { testData := []struct { - data []byte - tag Tag + data string + expected Tag }{ - {data: []byte(`object 3b114ab800c6432ad42387ccf6bc8d4388a2885a + { + data: `object 3b114ab800c6432ad42387ccf6bc8d4388a2885a type commit tag 1.22.0 tagger Lucas Michot 1484491741 +0100 -`), tag: Tag{ - Name: "", - ID: Sha1ObjectFormat.EmptyObjectID(), - Object: &Sha1Hash{0x3b, 0x11, 0x4a, 0xb8, 0x0, 0xc6, 0x43, 0x2a, 0xd4, 0x23, 0x87, 0xcc, 0xf6, 0xbc, 0x8d, 0x43, 0x88, 0xa2, 0x88, 0x5a}, - Type: "commit", - Tagger: &Signature{Name: "Lucas Michot", Email: "lucas@semalead.com", When: time.Unix(1484491741, 0)}, - Message: "", - Signature: nil, - }}, - {data: []byte(`object 7cdf42c0b1cc763ab7e4c33c47a24e27c66bfccc +`, + expected: Tag{ + Name: "", + ID: Sha1ObjectFormat.EmptyObjectID(), + Object: MustIDFromString("3b114ab800c6432ad42387ccf6bc8d4388a2885a"), + Type: "commit", + Tagger: &Signature{Name: "Lucas Michot", Email: "lucas@semalead.com", When: time.Unix(1484491741, 0).In(time.FixedZone("", 3600))}, + Message: "", + Signature: nil, + }, + }, + { + data: `object 7cdf42c0b1cc763ab7e4c33c47a24e27c66bfccc type commit tag 1.22.1 tagger Lucas Michot 1484553735 +0100 @@ -37,37 +41,57 @@ tagger Lucas Michot 1484553735 +0100 test message o -ono`), tag: Tag{ - Name: "", - ID: Sha1ObjectFormat.EmptyObjectID(), - Object: &Sha1Hash{0x7c, 0xdf, 0x42, 0xc0, 0xb1, 0xcc, 0x76, 0x3a, 0xb7, 0xe4, 0xc3, 0x3c, 0x47, 0xa2, 0x4e, 0x27, 0xc6, 0x6b, 0xfc, 0xcc}, - Type: "commit", - Tagger: &Signature{Name: "Lucas Michot", Email: "lucas@semalead.com", When: time.Unix(1484553735, 0)}, - Message: "test message\no\n\nono", - Signature: nil, - }}, +ono`, + expected: Tag{ + Name: "", + ID: Sha1ObjectFormat.EmptyObjectID(), + Object: MustIDFromString("7cdf42c0b1cc763ab7e4c33c47a24e27c66bfccc"), + Type: "commit", + Tagger: &Signature{Name: "Lucas Michot", Email: "lucas@semalead.com", When: time.Unix(1484553735, 0).In(time.FixedZone("", 3600))}, + Message: "test message\no\n\nono", + Signature: nil, + }, + }, + { + data: `object 7cdf42c0b1cc763ab7e4c33c47a24e27c66bfaaa +type commit +tag v0 +tagger dummy user 1484491741 +0100 + +dummy message +-----BEGIN SSH SIGNATURE----- +dummy signature +-----END SSH SIGNATURE----- +`, + expected: Tag{ + Name: "", + ID: Sha1ObjectFormat.EmptyObjectID(), + Object: MustIDFromString("7cdf42c0b1cc763ab7e4c33c47a24e27c66bfaaa"), + Type: "commit", + Tagger: &Signature{Name: "dummy user", Email: "dummy-email@example.com", When: time.Unix(1484491741, 0).In(time.FixedZone("", 3600))}, + Message: "dummy message", + Signature: &CommitSignature{ + Signature: `-----BEGIN SSH SIGNATURE----- +dummy signature +-----END SSH SIGNATURE-----`, + Payload: `object 7cdf42c0b1cc763ab7e4c33c47a24e27c66bfaaa +type commit +tag v0 +tagger dummy user 1484491741 +0100 + +dummy message`, + }, + }, + }, } for _, test := range testData { - tag, err := parseTagData(Sha1ObjectFormat, test.data) + tag, err := parseTagData(Sha1ObjectFormat, []byte(test.data)) assert.NoError(t, err) - assert.EqualValues(t, test.tag.ID, tag.ID) - assert.EqualValues(t, test.tag.Object, tag.Object) - assert.EqualValues(t, test.tag.Name, tag.Name) - assert.EqualValues(t, test.tag.Message, tag.Message) - assert.EqualValues(t, test.tag.Type, tag.Type) - if test.tag.Signature != nil && assert.NotNil(t, tag.Signature) { - assert.EqualValues(t, test.tag.Signature.Signature, tag.Signature.Signature) - assert.EqualValues(t, test.tag.Signature.Payload, tag.Signature.Payload) - } else { - assert.Nil(t, tag.Signature) - } - if test.tag.Tagger != nil && assert.NotNil(t, tag.Tagger) { - assert.EqualValues(t, test.tag.Tagger.Name, tag.Tagger.Name) - assert.EqualValues(t, test.tag.Tagger.Email, tag.Tagger.Email) - assert.EqualValues(t, test.tag.Tagger.When.Unix(), tag.Tagger.When.Unix()) - } else { - assert.Nil(t, tag.Tagger) - } + assert.Equal(t, test.expected, *tag) } + + tag, err := parseTagData(Sha1ObjectFormat, []byte("type commit\n\nfoo\n-----BEGIN SSH SIGNATURE-----\ncorrupted...")) + assert.NoError(t, err) + assert.Equal(t, "foo\n-----BEGIN SSH SIGNATURE-----\ncorrupted...", tag.Message) } diff --git a/modules/indexer/code/search.go b/modules/indexer/code/search.go index 5f35e8073b6cb..74c957dde65e4 100644 --- a/modules/indexer/code/search.go +++ b/modules/indexer/code/search.go @@ -22,7 +22,7 @@ type Result struct { UpdatedUnix timeutil.TimeStamp Language string Color string - Lines []ResultLine + Lines []*ResultLine } type ResultLine struct { @@ -70,16 +70,18 @@ func writeStrings(buf *bytes.Buffer, strs ...string) error { return nil } -func HighlightSearchResultCode(filename string, lineNums []int, code string) []ResultLine { +func HighlightSearchResultCode(filename, language string, lineNums []int, code string) []*ResultLine { // we should highlight the whole code block first, otherwise it doesn't work well with multiple line highlighting - hl, _ := highlight.Code(filename, "", code) + hl, _ := highlight.Code(filename, language, code) highlightedLines := strings.Split(string(hl), "\n") // The lineNums outputted by highlight.Code might not match the original lineNums, because "highlight" removes the last `\n` - lines := make([]ResultLine, min(len(highlightedLines), len(lineNums))) + lines := make([]*ResultLine, min(len(highlightedLines), len(lineNums))) for i := 0; i < len(lines); i++ { - lines[i].Num = lineNums[i] - lines[i].FormattedContent = template.HTML(highlightedLines[i]) + lines[i] = &ResultLine{ + Num: lineNums[i], + FormattedContent: template.HTML(highlightedLines[i]), + } } return lines } @@ -122,7 +124,7 @@ func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Res UpdatedUnix: result.UpdatedUnix, Language: result.Language, Color: result.Color, - Lines: HighlightSearchResultCode(result.Filename, lineNums, formattedLinesBuffer.String()), + Lines: HighlightSearchResultCode(result.Filename, result.Language, lineNums, formattedLinesBuffer.String()), }, nil } diff --git a/modules/markup/html.go b/modules/markup/html.go index 21bd6206e0eb7..56aa1cb49cf9c 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -171,6 +171,7 @@ type processor func(ctx *RenderContext, node *html.Node) var defaultProcessors = []processor{ fullIssuePatternProcessor, comparePatternProcessor, + codePreviewPatternProcessor, fullHashPatternProcessor, shortLinkProcessor, linkProcessor, diff --git a/modules/markup/html_codepreview.go b/modules/markup/html_codepreview.go new file mode 100644 index 0000000000000..d9da24ea34495 --- /dev/null +++ b/modules/markup/html_codepreview.go @@ -0,0 +1,92 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markup + +import ( + "html/template" + "net/url" + "regexp" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/log" + + "golang.org/x/net/html" +) + +// codePreviewPattern matches "http://domain/.../{owner}/{repo}/src/commit/{commit}/{filepath}#L10-L20" +var codePreviewPattern = regexp.MustCompile(`https?://\S+/([^\s/]+)/([^\s/]+)/src/commit/([0-9a-f]{7,64})(/\S+)#(L\d+(-L\d+)?)`) + +type RenderCodePreviewOptions struct { + FullURL string + OwnerName string + RepoName string + CommitID string + FilePath string + + LineStart, LineStop int +} + +func renderCodeBlock(ctx *RenderContext, node *html.Node) (urlPosStart, urlPosStop int, htm template.HTML, err error) { + m := codePreviewPattern.FindStringSubmatchIndex(node.Data) + if m == nil { + return 0, 0, "", nil + } + + opts := RenderCodePreviewOptions{ + FullURL: node.Data[m[0]:m[1]], + OwnerName: node.Data[m[2]:m[3]], + RepoName: node.Data[m[4]:m[5]], + CommitID: node.Data[m[6]:m[7]], + FilePath: node.Data[m[8]:m[9]], + } + if !httplib.IsCurrentGiteaSiteURL(opts.FullURL) { + return 0, 0, "", nil + } + u, err := url.Parse(opts.FilePath) + if err != nil { + return 0, 0, "", err + } + opts.FilePath = strings.TrimPrefix(u.Path, "/") + + lineStartStr, lineStopStr, _ := strings.Cut(node.Data[m[10]:m[11]], "-") + lineStart, _ := strconv.Atoi(strings.TrimPrefix(lineStartStr, "L")) + lineStop, _ := strconv.Atoi(strings.TrimPrefix(lineStopStr, "L")) + opts.LineStart, opts.LineStop = lineStart, lineStop + h, err := DefaultProcessorHelper.RenderRepoFileCodePreview(ctx.Ctx, opts) + return m[0], m[1], h, err +} + +func codePreviewPatternProcessor(ctx *RenderContext, node *html.Node) { + for node != nil { + if node.Type != html.TextNode { + node = node.NextSibling + continue + } + urlPosStart, urlPosEnd, h, err := renderCodeBlock(ctx, node) + if err != nil || h == "" { + if err != nil { + log.Error("Unable to render code preview: %v", err) + } + node = node.NextSibling + continue + } + next := node.NextSibling + textBefore := node.Data[:urlPosStart] + textAfter := node.Data[urlPosEnd:] + // "textBefore" could be empty if there is only a URL in the text node, then an empty node (p, or li) will be left here. + // However, the empty node can't be simply removed, because: + // 1. the following processors will still try to access it (need to double-check undefined behaviors) + // 2. the new node is inserted as "

{TextBefore}

{TextAfter}

" (the parent could also be "li") + // then it is resolved as: "

{TextBefore}

{TextAfter}

", + // so unless it could correctly replace the parent "p/li" node, it is very difficult to eliminate the "TextBefore" empty node. + node.Data = textBefore + node.Parent.InsertBefore(&html.Node{Type: html.RawNode, Data: string(h)}, next) + if textAfter != "" { + node.Parent.InsertBefore(&html.Node{Type: html.TextNode, Data: textAfter}, next) + } + node = next + } +} diff --git a/modules/markup/html_codepreview_test.go b/modules/markup/html_codepreview_test.go new file mode 100644 index 0000000000000..d33630d0401b4 --- /dev/null +++ b/modules/markup/html_codepreview_test.go @@ -0,0 +1,34 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markup_test + +import ( + "context" + "html/template" + "strings" + "testing" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/markup" + + "github.com/stretchr/testify/assert" +) + +func TestRenderCodePreview(t *testing.T) { + markup.Init(&markup.ProcessorHelper{ + RenderRepoFileCodePreview: func(ctx context.Context, opts markup.RenderCodePreviewOptions) (template.HTML, error) { + return "
code preview
", nil + }, + }) + test := func(input, expected string) { + buffer, err := markup.RenderString(&markup.RenderContext{ + Ctx: git.DefaultContext, + Type: "markdown", + }, input) + assert.NoError(t, err) + assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer)) + } + test("http://localhost:3000/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20", "

code preview

") + test("http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20", `

http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20

`) +} diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go index c664758a277a0..a9c9024982871 100644 --- a/modules/markup/markdown/markdown_test.go +++ b/modules/markup/markdown/markdown_test.go @@ -511,9 +511,17 @@ func TestMathBlock(t *testing.T) { `\(a\) \(b\)`, `

a b

` + nl, }, + { + `$a$.`, + `

a.

` + nl, + }, + { + `.$a$`, + `

.$a$

` + nl, + }, { `$a a$b b$`, - `

a a$b b

` + nl, + `

$a a$b b$

` + nl, }, { `a a$b b`, @@ -521,7 +529,15 @@ func TestMathBlock(t *testing.T) { }, { `a$b $a a$b b$`, - `

a$b a a$b b

` + nl, + `

a$b $a a$b b$

` + nl, + }, + { + "a$x$", + `

a$x$

` + nl, + }, + { + "$x$a", + `

$x$a

` + nl, }, { "$$a$$", diff --git a/modules/markup/markdown/math/inline_parser.go b/modules/markup/markdown/math/inline_parser.go index 0ac25c2b2ac86..862234e69bb74 100644 --- a/modules/markup/markdown/math/inline_parser.go +++ b/modules/markup/markdown/math/inline_parser.go @@ -41,9 +41,12 @@ func (parser *inlineParser) Trigger() []byte { return parser.start[0:1] } +func isPunctuation(b byte) bool { + return b == '.' || b == '!' || b == '?' || b == ',' || b == ';' || b == ':' +} + func isAlphanumeric(b byte) bool { - // Github only cares about 0-9A-Za-z - return (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') } // Parse parses the current line and returns a result of parsing. @@ -56,7 +59,7 @@ func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser. } precedingCharacter := block.PrecendingCharacter() - if precedingCharacter < 256 && isAlphanumeric(byte(precedingCharacter)) { + if precedingCharacter < 256 && (isAlphanumeric(byte(precedingCharacter)) || isPunctuation(byte(precedingCharacter))) { // need to exclude things like `a$` from being considered a start return nil } @@ -75,14 +78,19 @@ func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser. ender += pos // Now we want to check the character at the end of our parser section - // that is ender + len(parser.end) + // that is ender + len(parser.end) and check if char before ender is '\' pos = ender + len(parser.end) if len(line) <= pos { break } - if !isAlphanumeric(line[pos]) { + suceedingCharacter := line[pos] + if !isPunctuation(suceedingCharacter) && !(suceedingCharacter == ' ') { + return nil + } + if line[ender-1] != '\\' { break } + // move the pointer onwards ender += len(parser.end) } diff --git a/modules/markup/renderer.go b/modules/markup/renderer.go index 0f0bf557403e4..005fcc278b973 100644 --- a/modules/markup/renderer.go +++ b/modules/markup/renderer.go @@ -8,6 +8,7 @@ import ( "context" "errors" "fmt" + "html/template" "io" "net/url" "path/filepath" @@ -33,6 +34,8 @@ type ProcessorHelper struct { IsUsernameMentionable func(ctx context.Context, username string) bool ElementDir string // the direction of the elements, eg: "ltr", "rtl", "auto", default to no direction attribute + + RenderRepoFileCodePreview func(ctx context.Context, options RenderCodePreviewOptions) (template.HTML, error) } var DefaultProcessorHelper ProcessorHelper diff --git a/modules/markup/sanitizer.go b/modules/markup/sanitizer.go index 79a2ba0dfb8d2..77fbdf4520fc6 100644 --- a/modules/markup/sanitizer.go +++ b/modules/markup/sanitizer.go @@ -60,6 +60,21 @@ func createDefaultPolicy() *bluemonday.Policy { // For JS code copy and Mermaid loading state policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-block( is-loading)?$`)).OnElements("pre") + // For code preview + policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-preview-[-\w]+( file-content)?$`)).Globally() + policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-num$`)).OnElements("td") + policy.AllowAttrs("data-line-number").OnElements("span") + policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-code chroma$`)).OnElements("td") + policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-inner$`)).OnElements("code") + + // For code preview (unicode escape) + policy.AllowAttrs("class").Matching(regexp.MustCompile(`^file-view( unicode-escaped)?$`)).OnElements("table") + policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-escape$`)).OnElements("td") + policy.AllowAttrs("class").Matching(regexp.MustCompile(`^toggle-escape-button btn interact-bg$`)).OnElements("a") // don't use button, button might submit a form + policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(ambiguous-code-point|escaped-code-point|broken-code-point)$`)).OnElements("span") + policy.AllowAttrs("class").Matching(regexp.MustCompile(`^char$`)).OnElements("span") + policy.AllowAttrs("data-tooltip-content", "data-escaped").OnElements("span") + // For color preview policy.AllowAttrs("class").Matching(regexp.MustCompile(`^color-preview$`)).OnElements("span") diff --git a/modules/setting/log.go b/modules/setting/log.go index e404074b72f08..50c57799945e5 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 27a80b668238b..e77652b24f230 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/translation/mock.go b/modules/translation/mock.go index 18fbc1044add5..f457271ea5478 100644 --- a/modules/translation/mock.go +++ b/modules/translation/mock.go @@ -6,6 +6,7 @@ package translation import ( "fmt" "html/template" + "strings" ) // MockLocale provides a mocked locale without any translations @@ -19,18 +20,25 @@ func (l MockLocale) Language() string { return "en" } -func (l MockLocale) TrString(s string, _ ...any) string { - return s +func (l MockLocale) TrString(s string, args ...any) string { + return sprintAny(s, args...) } -func (l MockLocale) Tr(s string, a ...any) template.HTML { - return template.HTML(s) +func (l MockLocale) Tr(s string, args ...any) template.HTML { + return template.HTML(sprintAny(s, args...)) } func (l MockLocale) TrN(cnt any, key1, keyN string, args ...any) template.HTML { - return template.HTML(key1) + return template.HTML(sprintAny(key1, args...)) } func (l MockLocale) PrettyNumber(v any) string { return fmt.Sprint(v) } + +func sprintAny(s string, args ...any) string { + if len(args) == 0 { + return s + } + return s + ":" + fmt.Sprintf(strings.Repeat(",%v", len(args))[1:], args...) +} diff --git a/modules/util/util.go b/modules/util/util.go index b6e730eb54bbd..3921002e2a5c2 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/options/license/AMD-newlib b/options/license/AMD-newlib new file mode 100644 index 0000000000000..1b2f1abd6fd0d --- /dev/null +++ b/options/license/AMD-newlib @@ -0,0 +1,11 @@ +Copyright 1989, 1990 Advanced Micro Devices, Inc. + +This software is the property of Advanced Micro Devices, Inc (AMD) which +specifically grants the user the right to modify, use and distribute this +software provided this notice is not removed or altered. All other rights +are reserved by AMD. + +AMD MAKES NO WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, WITH REGARD TO THIS +SOFTWARE. IN NO EVENT SHALL AMD BE LIABLE FOR INCIDENTAL OR CONSEQUENTIAL +DAMAGES IN CONNECTION WITH OR ARISING FROM THE FURNISHING, PERFORMANCE, OR +USE OF THIS SOFTWARE. diff --git a/options/license/OAR b/options/license/OAR new file mode 100644 index 0000000000000..ca5c4b9617f43 --- /dev/null +++ b/options/license/OAR @@ -0,0 +1,12 @@ +COPYRIGHT (c) 1989-2013, 2015. +On-Line Applications Research Corporation (OAR). + +Permission to use, copy, modify, and distribute this software for any +purpose without fee is hereby granted, provided that this entire notice +is included in all copies of any software which is or includes a copy +or modification of this software. + +THIS SOFTWARE IS BEING PROVIDED "AS IS", WITHOUT ANY EXPRESS OR IMPLIED +WARRANTY. IN PARTICULAR, THE AUTHOR MAKES NO REPRESENTATION +OR WARRANTY OF ANY KIND CONCERNING THE MERCHANTABILITY OF THIS +SOFTWARE OR ITS FITNESS FOR ANY PARTICULAR PURPOSE. diff --git a/options/license/xzoom b/options/license/xzoom new file mode 100644 index 0000000000000..f312dedbc2ce8 --- /dev/null +++ b/options/license/xzoom @@ -0,0 +1,12 @@ +Copyright Itai Nahshon 1995, 1996. +This program is distributed with no warranty. + +Source files for this program may be distributed freely. +Modifications to this file are okay as long as: + a. This copyright notice and comment are preserved and + left at the top of the file. + b. The man page is fixed to reflect the change. + c. The author of this change adds his name and change + description to the list of changes below. +Executable files may be distributed with sources, or with +exact location where the source code can be obtained. diff --git a/options/locale/locale_cs-CZ.ini b/options/locale/locale_cs-CZ.ini index 4abf8137253cb..82a8fe5d45628 100644 --- a/options/locale/locale_cs-CZ.ini +++ b/options/locale/locale_cs-CZ.ini @@ -2790,7 +2790,6 @@ settings=Nastavení správce dashboard.new_version_hint=Gitea %s je nyní k dispozici, právě u vás běži %s. Podívej se na blogu pro více informací. dashboard.statistic=Souhrn -dashboard.operations=Operace údržby dashboard.system_status=Status systému dashboard.operation_name=Název operace dashboard.operation_switch=Přepnout diff --git a/options/locale/locale_de-DE.ini b/options/locale/locale_de-DE.ini index 4d446db86ffb5..9a09c2922e3b0 100644 --- a/options/locale/locale_de-DE.ini +++ b/options/locale/locale_de-DE.ini @@ -2798,7 +2798,6 @@ settings=Administratoreinstellungen dashboard.new_version_hint=Gitea %s ist jetzt verfügbar, deine derzeitige Version ist %s. Weitere Details findest du im Blog. dashboard.statistic=Übersicht -dashboard.operations=Wartungsoperationen dashboard.system_status=System-Status dashboard.operation_name=Name der Operation dashboard.operation_switch=Wechseln diff --git a/options/locale/locale_el-GR.ini b/options/locale/locale_el-GR.ini index 1199d84581875..6ce5ae1ce9605 100644 --- a/options/locale/locale_el-GR.ini +++ b/options/locale/locale_el-GR.ini @@ -2687,7 +2687,6 @@ settings=Ρυθμίσεις Διαχειριστή dashboard.new_version_hint=Το Gitea %s είναι διαθέσιμο, τώρα εκτελείτε το %s. Ανατρέξτε στο blog για περισσότερες λεπτομέρειες. dashboard.statistic=Περίληψη -dashboard.operations=Λειτουργίες Συντήρησης dashboard.system_status=Κατάσταση Συστήματος dashboard.operation_name=Όνομα Λειτουργίας dashboard.operation_switch=Αλλαγή diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 39b9855186145..0a3d12d7a40ff 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1233,6 +1233,8 @@ file_view_rendered = View Rendered file_view_raw = View Raw file_permalink = Permalink file_too_large = The file is too large to be shown. +code_preview_line_from_to = Lines %[1]d to %[2]d in %[3]s +code_preview_line_in = Line %[1]d in %[2]s invisible_runes_header = `This file contains invisible Unicode characters` invisible_runes_description = `This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.` ambiguous_runes_header = `This file contains ambiguous Unicode characters` diff --git a/options/locale/locale_es-ES.ini b/options/locale/locale_es-ES.ini index ce50b71ec418d..fc78e1d4397f2 100644 --- a/options/locale/locale_es-ES.ini +++ b/options/locale/locale_es-ES.ini @@ -2672,7 +2672,6 @@ settings=Configuración de Admin dashboard.new_version_hint=Gitea %s ya está disponible, estás ejecutando %s. Revisa el blog para más detalles. dashboard.statistic=Resumen -dashboard.operations=Operaciones de mantenimiento dashboard.system_status=Estado del sistema dashboard.operation_name=Nombre de la operación dashboard.operation_switch=Interruptor diff --git a/options/locale/locale_fa-IR.ini b/options/locale/locale_fa-IR.ini index 31122841a7e5f..d19eb356d2829 100644 --- a/options/locale/locale_fa-IR.ini +++ b/options/locale/locale_fa-IR.ini @@ -2064,7 +2064,6 @@ last_page=واپسین total=مجموع: %d dashboard.statistic=چکیده -dashboard.operations=عملیات‌های نگهداری dashboard.system_status=وضعیت سامانه dashboard.operation_name=نام عملیات dashboard.operation_switch=تعویض diff --git a/options/locale/locale_fi-FI.ini b/options/locale/locale_fi-FI.ini index 00581f49fce67..f283209908c69 100644 --- a/options/locale/locale_fi-FI.ini +++ b/options/locale/locale_fi-FI.ini @@ -1407,7 +1407,6 @@ last_page=Viimeisin total=Yhteensä: %d dashboard.statistic=Yhteenveto -dashboard.operations=Huoltotoimet dashboard.system_status=Järjestelmän tila dashboard.operation_name=Toiminnon nimi dashboard.operation_switch=Vaihda diff --git a/options/locale/locale_fr-FR.ini b/options/locale/locale_fr-FR.ini index 062c818bd4bbc..dc664029014d7 100644 --- a/options/locale/locale_fr-FR.ini +++ b/options/locale/locale_fr-FR.ini @@ -2712,7 +2712,6 @@ settings=Paramètres administrateur dashboard.new_version_hint=Gitea %s est maintenant disponible, vous utilisez %s. Consultez le blog pour plus de détails. dashboard.statistic=Résumé -dashboard.operations=Opérations de maintenance dashboard.system_status=État du système dashboard.operation_name=Nom de l'Opération dashboard.operation_switch=Basculer diff --git a/options/locale/locale_hu-HU.ini b/options/locale/locale_hu-HU.ini index 93e3b42115153..fb229090d4eaf 100644 --- a/options/locale/locale_hu-HU.ini +++ b/options/locale/locale_hu-HU.ini @@ -1266,7 +1266,6 @@ last_page=Utolsó total=Összesen: %d dashboard.statistic=Összefoglaló -dashboard.operations=Karbantartási műveletek dashboard.system_status=Rendszer Állapota dashboard.operation_name=Művelet Neve dashboard.operation_switch=Váltás diff --git a/options/locale/locale_it-IT.ini b/options/locale/locale_it-IT.ini index cc379e8109f11..9a22995dfba4c 100644 --- a/options/locale/locale_it-IT.ini +++ b/options/locale/locale_it-IT.ini @@ -2233,7 +2233,6 @@ last_page=Ultima total=Totale: %d dashboard.statistic=Riepilogo -dashboard.operations=Operazioni di manutenzione dashboard.system_status=Stato del sistema dashboard.operation_name=Nome Operazione dashboard.operation_switch=Cambia diff --git a/options/locale/locale_ja-JP.ini b/options/locale/locale_ja-JP.ini index d5c2885f00405..eddad3507308f 100644 --- a/options/locale/locale_ja-JP.ini +++ b/options/locale/locale_ja-JP.ini @@ -2719,7 +2719,6 @@ settings=管理設定 dashboard.new_version_hint=Gitea %s が入手可能になりました。 現在実行しているのは %s です。 詳細は ブログ を確認してください。 dashboard.statistic=サマリー -dashboard.operations=メンテナンス操作 dashboard.system_status=システム状況 dashboard.operation_name=操作の名称 dashboard.operation_switch=切り替え diff --git a/options/locale/locale_lv-LV.ini b/options/locale/locale_lv-LV.ini index 0a2729980b860..9a1509001292b 100644 --- a/options/locale/locale_lv-LV.ini +++ b/options/locale/locale_lv-LV.ini @@ -2693,7 +2693,6 @@ settings=Administratora iestatījumi dashboard.new_version_hint=Ir pieejama Gitea versija %s, pašreizējā versija %s. Papildus informācija par jauno versiju ir pieejama mājas lapā. dashboard.statistic=Kopsavilkums -dashboard.operations=Uzturēšanas darbības dashboard.system_status=Sistēmas statuss dashboard.operation_name=Darbības nosaukums dashboard.operation_switch=Pārslēgt diff --git a/options/locale/locale_nl-NL.ini b/options/locale/locale_nl-NL.ini index 255a3db9fa2ca..6b5122a86f616 100644 --- a/options/locale/locale_nl-NL.ini +++ b/options/locale/locale_nl-NL.ini @@ -2135,7 +2135,6 @@ last_page=Laatste total=Totaal: %d dashboard.statistic=Overzicht -dashboard.operations=Onderhoudswerkzaamheden dashboard.system_status=Systeemtatus dashboard.operation_name=Bewerking naam dashboard.operation_switch=Omschakelen diff --git a/options/locale/locale_pl-PL.ini b/options/locale/locale_pl-PL.ini index 1496877fd59e3..a1d7e95842feb 100644 --- a/options/locale/locale_pl-PL.ini +++ b/options/locale/locale_pl-PL.ini @@ -2010,7 +2010,6 @@ last_page=Ostatnia total=Ogółem: %d dashboard.statistic=Podsumowanie -dashboard.operations=Operacje konserwacji dashboard.system_status=Status strony dashboard.operation_name=Nazwa operacji dashboard.operation_switch=Przełącz diff --git a/options/locale/locale_pt-BR.ini b/options/locale/locale_pt-BR.ini index 0d1614df3f037..45f1c3b3f8f68 100644 --- a/options/locale/locale_pt-BR.ini +++ b/options/locale/locale_pt-BR.ini @@ -2648,7 +2648,6 @@ settings=Configurações de Administrador dashboard.new_version_hint=Uma nova versão está disponível: %s. Versão atual: %s. Visite o blog para mais informações. dashboard.statistic=Resumo -dashboard.operations=Operações de manutenção dashboard.system_status=Status do sistema dashboard.operation_name=Nome da operação dashboard.operation_switch=Trocar diff --git a/options/locale/locale_pt-PT.ini b/options/locale/locale_pt-PT.ini index ea80cd7abb63a..09b9d4e3ce6c3 100644 --- a/options/locale/locale_pt-PT.ini +++ b/options/locale/locale_pt-PT.ini @@ -2775,6 +2775,7 @@ teams.invite.by=Convidado(a) por %s teams.invite.description=Clique no botão abaixo para se juntar à equipa. [admin] +maintenance=Manutenção dashboard=Painel de controlo self_check=Auto-verificação identity_access=Identidade e acesso @@ -2798,7 +2799,7 @@ settings=Configurações de administração dashboard.new_version_hint=O Gitea %s está disponível, você está a correr a versão %s. Verifique o blog para mais detalhes. dashboard.statistic=Resumo -dashboard.operations=Operações de manutenção +dashboard.maintenance_operations=Operações de manutenção dashboard.system_status=Estado do sistema dashboard.operation_name=Nome da operação dashboard.operation_switch=Comutar @@ -3305,6 +3306,7 @@ notices.op=Op. notices.delete_success=As notificações do sistema foram eliminadas. self_check.no_problem_found=Nenhum problema encontrado até agora. +self_check.startup_warnings=Alertas do arranque: self_check.database_collation_mismatch=Supor que a base de dados usa a colação: %s self_check.database_collation_case_insensitive=A base de dados está a usar a colação %s, que é insensível à diferença entre maiúsculas e minúsculas. Embora o Gitea possa trabalhar com ela, pode haver alguns casos raros que não funcionem como esperado. self_check.database_inconsistent_collation_columns=A base de dados está a usar a colação %s, mas estas colunas estão a usar colações diferentes. Isso poderá causar alguns problemas inesperados. diff --git a/options/locale/locale_ru-RU.ini b/options/locale/locale_ru-RU.ini index 74c4c9c9350d0..818dad1147dcc 100644 --- a/options/locale/locale_ru-RU.ini +++ b/options/locale/locale_ru-RU.ini @@ -2634,7 +2634,6 @@ total=Всего: %d dashboard.new_version_hint=Доступна новая версия Gitea %s, вы используете %s. Более подробную информацию читайте в блоге. dashboard.statistic=Статистика -dashboard.operations=Операции dashboard.system_status=Состояние системы dashboard.operation_name=Имя операции dashboard.operation_switch=Переключить diff --git a/options/locale/locale_si-LK.ini b/options/locale/locale_si-LK.ini index 7e82cfe3d62d5..99559802c521e 100644 --- a/options/locale/locale_si-LK.ini +++ b/options/locale/locale_si-LK.ini @@ -2024,7 +2024,6 @@ last_page=පසුගිය total=මුළු: %d dashboard.statistic=සාරාංශය -dashboard.operations=නඩත්තු මෙහෙයුම් dashboard.system_status=පද්ධතියේ තත්වය dashboard.operation_name=මෙහෙයුමේ නම dashboard.operation_switch=මාරුවන්න diff --git a/options/locale/locale_sv-SE.ini b/options/locale/locale_sv-SE.ini index e48d84ff78292..9234e9aa58998 100644 --- a/options/locale/locale_sv-SE.ini +++ b/options/locale/locale_sv-SE.ini @@ -1647,7 +1647,6 @@ last_page=Sista total=Totalt: %d dashboard.statistic=Översikt -dashboard.operations=Operationer för underhåll dashboard.system_status=Status dashboard.operation_name=Operationsnamn dashboard.operation_switch=Byt till diff --git a/options/locale/locale_tr-TR.ini b/options/locale/locale_tr-TR.ini index 5a5036f87d13a..119e1ef150107 100644 --- a/options/locale/locale_tr-TR.ini +++ b/options/locale/locale_tr-TR.ini @@ -2687,7 +2687,6 @@ settings=Yönetici Ayarları dashboard.new_version_hint=Gitea %s şimdi hazır, %s çalıştırıyorsunuz. Ayrıntılar için blog'a bakabilirsiniz. dashboard.statistic=Özet -dashboard.operations=Bakım İşlemleri dashboard.system_status=Sistem Durumu dashboard.operation_name=İşlem Adı dashboard.operation_switch=Geç diff --git a/options/locale/locale_uk-UA.ini b/options/locale/locale_uk-UA.ini index 09561a79028ea..e8a3acedda967 100644 --- a/options/locale/locale_uk-UA.ini +++ b/options/locale/locale_uk-UA.ini @@ -2074,7 +2074,6 @@ last_page=Остання total=Разом: %d dashboard.statistic=Підсумок -dashboard.operations=Технічне обслуговування dashboard.system_status=Статус системи dashboard.operation_name=Назва операції dashboard.operation_switch=Перемкнути diff --git a/options/locale/locale_zh-CN.ini b/options/locale/locale_zh-CN.ini index 406e9ac8f2956..01058d48d2db6 100644 --- a/options/locale/locale_zh-CN.ini +++ b/options/locale/locale_zh-CN.ini @@ -2711,7 +2711,6 @@ settings=管理设置 dashboard.new_version_hint=Gitea %s 现已可用,您正在运行 %s。查看 博客 了解详情。 dashboard.statistic=摘要 -dashboard.operations=维护操作 dashboard.system_status=系统状态 dashboard.operation_name=操作名称 dashboard.operation_switch=开关 diff --git a/options/locale/locale_zh-TW.ini b/options/locale/locale_zh-TW.ini index 0511fa44aeb2c..0447a7d8b765f 100644 --- a/options/locale/locale_zh-TW.ini +++ b/options/locale/locale_zh-TW.ini @@ -2439,7 +2439,6 @@ total=總計:%d dashboard.new_version_hint=現已推出 Gitea %s,您正在執行 %s。詳情請參閱部落格的說明。 dashboard.statistic=摘要 -dashboard.operations=維護作業 dashboard.system_status=系統狀態 dashboard.operation_name=作業名稱 dashboard.operation_switch=開關 diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 80504b9c330d4..822e368fa84ae 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -12,6 +12,7 @@ import ( "strings" "time" + actions_model "code.gitea.io/gitea/models/actions" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" @@ -31,6 +32,7 @@ import ( "code.gitea.io/gitea/modules/validation" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + actions_service "code.gitea.io/gitea/services/actions" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/issue" @@ -1035,6 +1037,9 @@ func updateRepoArchivedState(ctx *context.APIContext, opts api.EditRepoOption) e ctx.Error(http.StatusInternalServerError, "ArchiveRepoState", err) return err } + if err := actions_model.CleanRepoScheduleTasks(ctx, repo); err != nil { + log.Error("CleanRepoScheduleTasks for archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err) + } log.Trace("Repository was archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) } else { if err := repo_model.SetArchiveRepoState(ctx, repo, *opts.Archived); err != nil { @@ -1042,6 +1047,11 @@ func updateRepoArchivedState(ctx *context.APIContext, opts api.EditRepoOption) e ctx.Error(http.StatusInternalServerError, "ArchiveRepoState", err) return err } + if ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypeActions) { + if err := actions_service.DetectAndHandleSchedules(ctx, repo); err != nil { + log.Error("DetectAndHandleSchedules for un-archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err) + } + } log.Trace("Repository was un-archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) } } diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go index 935e6d78fc412..1887e4d95da3d 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -16,6 +16,7 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/highlight" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -87,9 +88,16 @@ func RefBlame(ctx *context.Context) { ctx.Data["IsBlame"] = true - ctx.Data["FileSize"] = blob.Size() + fileSize := blob.Size() + ctx.Data["FileSize"] = fileSize ctx.Data["FileName"] = blob.Name() + if fileSize >= setting.UI.MaxDisplayFileSize { + ctx.Data["IsFileTooLarge"] = true + ctx.HTML(http.StatusOK, tplRepoHome) + return + } + ctx.Data["NumLines"], err = blob.GetBlobLineCount() ctx.Data["NumLinesSet"] = true diff --git a/routers/web/repo/search.go b/routers/web/repo/search.go index 9d65427b8f348..46f0208453585 100644 --- a/routers/web/repo/search.go +++ b/routers/web/repo/search.go @@ -81,7 +81,7 @@ func Search(ctx *context.Context) { // UpdatedUnix: not supported yet // Language: not supported yet // Color: not supported yet - Lines: code_indexer.HighlightSearchResultCode(r.Filename, r.LineNumbers, strings.Join(r.LineCodes, "\n")), + Lines: code_indexer.HighlightSearchResultCode(r.Filename, "", r.LineNumbers, strings.Join(r.LineCodes, "\n")), }) } } diff --git a/routers/web/repo/setting/lfs.go b/routers/web/repo/setting/lfs.go index 32049cf0a423c..6dddade066e8e 100644 --- a/routers/web/repo/setting/lfs.go +++ b/routers/web/repo/setting/lfs.go @@ -287,22 +287,19 @@ func LFSFileGet(ctx *context.Context) { st := typesniffer.DetectContentType(buf) ctx.Data["IsTextFile"] = st.IsText() - isRepresentableAsText := st.IsRepresentableAsText() - - fileSize := meta.Size ctx.Data["FileSize"] = meta.Size ctx.Data["RawFileLink"] = fmt.Sprintf("%s%s/%s.git/info/lfs/objects/%s/%s", setting.AppURL, url.PathEscape(ctx.Repo.Repository.OwnerName), url.PathEscape(ctx.Repo.Repository.Name), url.PathEscape(meta.Oid), "direct") switch { - case isRepresentableAsText: - if st.IsSvgImage() { - ctx.Data["IsImageFile"] = true - } - - if fileSize >= setting.UI.MaxDisplayFileSize { + case st.IsRepresentableAsText(): + if meta.Size >= setting.UI.MaxDisplayFileSize { ctx.Data["IsFileTooLarge"] = true break } + if st.IsSvgImage() { + ctx.Data["IsImageFile"] = true + } + rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) // Building code view blocks with line number on server side. @@ -338,6 +335,8 @@ func LFSFileGet(ctx *context.Context) { ctx.Data["IsAudioFile"] = true case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()): ctx.Data["IsImageFile"] = true + default: + // TODO: the logic is not the same as "renderFile" in "view.go" } ctx.HTML(http.StatusOK, tplSettingsLFSFile) } diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index e045e3b8dcc09..00a5282f34e77 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -13,6 +13,7 @@ import ( "time" "code.gitea.io/gitea/models" + actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" repo_model "code.gitea.io/gitea/models/repo" @@ -29,6 +30,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/validation" "code.gitea.io/gitea/modules/web" + actions_service "code.gitea.io/gitea/services/actions" asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" @@ -897,6 +899,10 @@ func SettingsPost(ctx *context.Context) { return } + if err := actions_model.CleanRepoScheduleTasks(ctx, repo); err != nil { + log.Error("CleanRepoScheduleTasks for archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err) + } + ctx.Flash.Success(ctx.Tr("repo.settings.archive.success")) log.Trace("Repository was archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) @@ -915,6 +921,12 @@ func SettingsPost(ctx *context.Context) { return } + if ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypeActions) { + if err := actions_service.DetectAndHandleSchedules(ctx, repo); err != nil { + log.Error("DetectAndHandleSchedules for un-archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err) + } + } + ctx.Flash.Success(ctx.Tr("repo.settings.unarchive.success")) log.Trace("Repository was un-archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 93e0f5bcbdbdf..8aa9dbb1be333 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -482,17 +482,17 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) { switch { case isRepresentableAsText: + if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { + ctx.Data["IsFileTooLarge"] = true + break + } + if fInfo.st.IsSvgImage() { ctx.Data["IsImageFile"] = true ctx.Data["CanCopyContent"] = true ctx.Data["HasSourceRenderedToggle"] = true } - if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { - ctx.Data["IsFileTooLarge"] = true - break - } - rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) shouldRenderSource := ctx.FormString("display") == "source" @@ -606,6 +606,8 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) { break } + // TODO: this logic seems strange, it duplicates with "isRepresentableAsText=true", it is not the same as "LFSFileGet" in "lfs.go" + // maybe for this case, the file is a binary file, and shouldn't be rendered? if markupType := markup.Type(blob.Name()); markupType != "" { rd := io.MultiReader(bytes.NewReader(buf), dataRc) ctx.Data["IsMarkup"] = true diff --git a/routers/web/repo/wiki_test.go b/routers/web/repo/wiki_test.go index 52e216e6a0ac2..8b5207f9d9503 100644 --- a/routers/web/repo/wiki_test.go +++ b/routers/web/repo/wiki_test.go @@ -145,7 +145,7 @@ func TestNewWikiPost_ReservedName(t *testing.T) { }) NewWikiPost(ctx) assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) - assert.EqualValues(t, ctx.Tr("repo.wiki.reserved_page"), ctx.Flash.ErrorMsg) + assert.EqualValues(t, ctx.Tr("repo.wiki.reserved_page", "_edit"), ctx.Flash.ErrorMsg) assertWikiNotExists(t, ctx.Repo.Repository, "_edit") } diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go index 66a19844c20c7..8c98f56af53ef 100644 --- a/services/actions/notifier_helper.go +++ b/services/actions/notifier_helper.go @@ -117,7 +117,7 @@ func notify(ctx context.Context, input *notifyInput) error { log.Debug("ignore executing %v for event %v whose doer is %v", getMethod(ctx), input.Event, input.Doer.Name) return nil } - if input.Repo.IsEmpty { + if input.Repo.IsEmpty || input.Repo.IsArchived { return nil } if unit_model.TypeActions.UnitGlobalDisabled() { @@ -501,7 +501,7 @@ func handleSchedules( // DetectAndHandleSchedules detects the schedule workflows on the default branch and create schedule tasks func DetectAndHandleSchedules(ctx context.Context, repo *repo_model.Repository) error { - if repo.IsEmpty { + if repo.IsEmpty || repo.IsArchived { return nil } diff --git a/services/actions/schedule_tasks.go b/services/actions/schedule_tasks.go index 59862fd0d8db2..e4e56e5122968 100644 --- a/services/actions/schedule_tasks.go +++ b/services/actions/schedule_tasks.go @@ -66,6 +66,11 @@ func startTasks(ctx context.Context) error { } } + if row.Repo.IsArchived { + // Skip if the repo is archived + continue + } + cfg, err := row.Repo.GetUnit(ctx, unit.TypeActions) if err != nil { if repo_model.IsErrUnitTypeNotExist(err) { diff --git a/services/contexttest/context_tests.go b/services/contexttest/context_tests.go index d3e6de7efe4f9..3064c56590d45 100644 --- a/services/contexttest/context_tests.go +++ b/services/contexttest/context_tests.go @@ -63,6 +63,7 @@ func MockContext(t *testing.T, reqPath string, opts ...MockContextOption) (*cont base.Locale = &translation.MockLocale{} ctx := context.NewWebContext(base, opt.Render, nil) + ctx.AppendContextValue(context.WebContextKey, ctx) ctx.PageData = map[string]any{} ctx.Data["PageStartTime"] = time.Now() chiCtx := chi.NewRouteContext() diff --git a/services/markup/main_test.go b/services/markup/main_test.go index 89fe3e7e3461a..5553ebc058948 100644 --- a/services/markup/main_test.go +++ b/services/markup/main_test.go @@ -11,6 +11,6 @@ import ( func TestMain(m *testing.M) { unittest.MainTest(m, &unittest.TestOptions{ - FixtureFiles: []string{"user.yml"}, + FixtureFiles: []string{"user.yml", "repository.yml", "access.yml", "repo_unit.yml"}, }) } diff --git a/services/markup/processorhelper.go b/services/markup/processorhelper.go index a4378678a08b5..68487fb8dbb57 100644 --- a/services/markup/processorhelper.go +++ b/services/markup/processorhelper.go @@ -14,6 +14,8 @@ import ( func ProcessorHelper() *markup.ProcessorHelper { return &markup.ProcessorHelper{ ElementDir: "auto", // set dir="auto" for necessary (eg:

, , etc) tags + + RenderRepoFileCodePreview: renderRepoFileCodePreview, IsUsernameMentionable: func(ctx context.Context, username string) bool { mentionedUser, err := user.GetUserByName(ctx, username) if err != nil { diff --git a/services/markup/processorhelper_codepreview.go b/services/markup/processorhelper_codepreview.go new file mode 100644 index 0000000000000..ef95046128d08 --- /dev/null +++ b/services/markup/processorhelper_codepreview.go @@ -0,0 +1,117 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markup + +import ( + "bufio" + "context" + "fmt" + "html/template" + "strings" + + "code.gitea.io/gitea/models/perm/access" + "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/indexer/code" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/setting" + gitea_context "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/repository/files" +) + +func renderRepoFileCodePreview(ctx context.Context, opts markup.RenderCodePreviewOptions) (template.HTML, error) { + opts.LineStop = max(opts.LineStop, opts.LineStart) + lineCount := opts.LineStop - opts.LineStart + 1 + if lineCount <= 0 || lineCount > 140 /* GitHub at most show 140 lines */ { + lineCount = 10 + opts.LineStop = opts.LineStart + lineCount + } + + dbRepo, err := repo.GetRepositoryByOwnerAndName(ctx, opts.OwnerName, opts.RepoName) + if err != nil { + return "", err + } + + webCtx, ok := ctx.Value(gitea_context.WebContextKey).(*gitea_context.Context) + if !ok { + return "", fmt.Errorf("context is not a web context") + } + doer := webCtx.Doer + + perms, err := access.GetUserRepoPermission(ctx, dbRepo, doer) + if err != nil { + return "", err + } + if !perms.CanRead(unit.TypeCode) { + return "", fmt.Errorf("no permission") + } + + gitRepo, err := gitrepo.OpenRepository(ctx, dbRepo) + if err != nil { + return "", err + } + defer gitRepo.Close() + + commit, err := gitRepo.GetCommit(opts.CommitID) + if err != nil { + return "", err + } + + language, _ := files.TryGetContentLanguage(gitRepo, opts.CommitID, opts.FilePath) + blob, err := commit.GetBlobByPath(opts.FilePath) + if err != nil { + return "", err + } + + if blob.Size() > setting.UI.MaxDisplayFileSize { + return "", fmt.Errorf("file is too large") + } + + dataRc, err := blob.DataAsync() + if err != nil { + return "", err + } + defer dataRc.Close() + + reader := bufio.NewReader(dataRc) + for i := 1; i < opts.LineStart; i++ { + if _, err = reader.ReadBytes('\n'); err != nil { + return "", err + } + } + + lineNums := make([]int, 0, lineCount) + lineCodes := make([]string, 0, lineCount) + for i := opts.LineStart; i <= opts.LineStop; i++ { + if line, err := reader.ReadString('\n'); err != nil && line == "" { + break + } else { + lineNums = append(lineNums, i) + lineCodes = append(lineCodes, line) + } + } + realLineStop := max(opts.LineStart, opts.LineStart+len(lineNums)-1) + highlightLines := code.HighlightSearchResultCode(opts.FilePath, language, lineNums, strings.Join(lineCodes, "")) + + escapeStatus := &charset.EscapeStatus{} + lineEscapeStatus := make([]*charset.EscapeStatus, len(highlightLines)) + for i, hl := range highlightLines { + lineEscapeStatus[i], hl.FormattedContent = charset.EscapeControlHTML(hl.FormattedContent, webCtx.Base.Locale, charset.RuneNBSP) + escapeStatus = escapeStatus.Or(lineEscapeStatus[i]) + } + + return webCtx.RenderToHTML("base/markup_codepreview", map[string]any{ + "FullURL": opts.FullURL, + "FilePath": opts.FilePath, + "LineStart": opts.LineStart, + "LineStop": realLineStop, + "RepoLink": dbRepo.Link(), + "CommitID": opts.CommitID, + "HighlightLines": highlightLines, + "EscapeStatus": escapeStatus, + "LineEscapeStatus": lineEscapeStatus, + }) +} diff --git a/services/markup/processorhelper_codepreview_test.go b/services/markup/processorhelper_codepreview_test.go new file mode 100644 index 0000000000000..01db792925e30 --- /dev/null +++ b/services/markup/processorhelper_codepreview_test.go @@ -0,0 +1,83 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markup + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestProcessorHelperCodePreview(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + htm, err := renderRepoFileCodePreview(ctx, markup.RenderCodePreviewOptions{ + FullURL: "http://full", + OwnerName: "user2", + RepoName: "repo1", + CommitID: "65f1bf27bc3bf70f64657658635e66094edbcb4d", + FilePath: "/README.md", + LineStart: 1, + LineStop: 2, + }) + assert.NoError(t, err) + assert.Equal(t, `

+
+ /README.md + repo.code_preview_line_from_to:1,2,65f1bf27bc +
+ + + + + + + + +
# repo1
+
+`, string(htm)) + + ctx, _ = contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + htm, err = renderRepoFileCodePreview(ctx, markup.RenderCodePreviewOptions{ + FullURL: "http://full", + OwnerName: "user2", + RepoName: "repo1", + CommitID: "65f1bf27bc3bf70f64657658635e66094edbcb4d", + FilePath: "/README.md", + LineStart: 1, + }) + assert.NoError(t, err) + assert.Equal(t, `
+
+ /README.md + repo.code_preview_line_in:1,65f1bf27bc +
+ + + + + +
# repo1
+
+`, string(htm)) + + ctx, _ = contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + _, err = renderRepoFileCodePreview(ctx, markup.RenderCodePreviewOptions{ + FullURL: "http://full", + OwnerName: "user15", + RepoName: "big_test_private_1", + CommitID: "65f1bf27bc3bf70f64657658635e66094edbcb4d", + FilePath: "/README.md", + LineStart: 1, + LineStop: 10, + }) + assert.ErrorContains(t, err, "no permission") +} diff --git a/services/user/user.go b/services/user/user.go index 4fcb81581d0d8..2287e36c716ac 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -126,7 +126,7 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { return fmt.Errorf("%s is an organization not a user", u.Name) } - if user_model.IsLastAdminUser(ctx, u) { + if u.IsActive && user_model.IsLastAdminUser(ctx, u) { return models.ErrDeleteLastAdminUser{UID: u.ID} } @@ -250,7 +250,7 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { if err := committer.Commit(); err != nil { return err } - committer.Close() + _ = committer.Close() if err = asymkey_service.RewriteAllPublicKeys(ctx); err != nil { return err @@ -259,50 +259,45 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { return err } - // Note: There are something just cannot be roll back, - // so just keep error logs of those operations. + // Note: There are something just cannot be roll back, so just keep error logs of those operations. path := user_model.UserPath(u.Name) - if err := util.RemoveAll(path); err != nil { - err = fmt.Errorf("Failed to RemoveAll %s: %w", path, err) + if err = util.RemoveAll(path); err != nil { + err = fmt.Errorf("failed to RemoveAll %s: %w", path, err) _ = system_model.CreateNotice(ctx, system_model.NoticeTask, fmt.Sprintf("delete user '%s': %v", u.Name, err)) - return err } if u.Avatar != "" { avatarPath := u.CustomAvatarRelativePath() - if err := storage.Avatars.Delete(avatarPath); err != nil { - err = fmt.Errorf("Failed to remove %s: %w", avatarPath, err) + if err = storage.Avatars.Delete(avatarPath); err != nil { + err = fmt.Errorf("failed to remove %s: %w", avatarPath, err) _ = system_model.CreateNotice(ctx, system_model.NoticeTask, fmt.Sprintf("delete user '%s': %v", u.Name, err)) - return err } } return nil } -// DeleteInactiveUsers deletes all inactive users and email addresses. +// DeleteInactiveUsers deletes all inactive users and their email addresses. func DeleteInactiveUsers(ctx context.Context, olderThan time.Duration) error { - users, err := user_model.GetInactiveUsers(ctx, olderThan) + inactiveUsers, err := user_model.GetInactiveUsers(ctx, olderThan) if err != nil { return err } // FIXME: should only update authorized_keys file once after all deletions. - for _, u := range users { - select { - case <-ctx.Done(): - return db.ErrCancelledf("Before delete inactive user %s", u.Name) - default: - } - if err := DeleteUser(ctx, u, false); err != nil { - // Ignore users that were set inactive by admin. - if models.IsErrUserOwnRepos(err) || models.IsErrUserHasOrgs(err) || - models.IsErrUserOwnPackages(err) || models.IsErrDeleteLastAdminUser(err) { + for _, u := range inactiveUsers { + if err = DeleteUser(ctx, u, false); err != nil { + // Ignore inactive users that were ever active but then were set inactive by admin + if models.IsErrUserOwnRepos(err) || models.IsErrUserHasOrgs(err) || models.IsErrUserOwnPackages(err) { continue } - return err + select { + case <-ctx.Done(): + return db.ErrCancelledf("when deleting inactive user %q", u.Name) + default: + return err + } } } - - return user_model.DeleteInactiveEmailAddresses(ctx) + return nil // TODO: there could be still inactive users left, and the number would increase gradually } diff --git a/services/user/user_test.go b/services/user/user_test.go index f110bd26d0886..bd6019a14fdcf 100644 --- a/services/user/user_test.go +++ b/services/user/user_test.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" "testing" + "time" "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/auth" @@ -16,6 +17,7 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" "github.com/stretchr/testify/assert" ) @@ -185,3 +187,26 @@ func TestCreateUser_Issue5882(t *testing.T) { assert.NoError(t, DeleteUser(db.DefaultContext, v.user, false)) } } + +func TestDeleteInactiveUsers(t *testing.T) { + addUser := func(name, email string, createdUnix timeutil.TimeStamp, active bool) { + inactiveUser := &user_model.User{Name: name, LowerName: strings.ToLower(name), Email: email, CreatedUnix: createdUnix, IsActive: active} + _, err := db.GetEngine(db.DefaultContext).NoAutoTime().Insert(inactiveUser) + assert.NoError(t, err) + inactiveUserEmail := &user_model.EmailAddress{UID: inactiveUser.ID, IsPrimary: true, Email: email, LowerEmail: strings.ToLower(email), IsActivated: active} + err = db.Insert(db.DefaultContext, inactiveUserEmail) + assert.NoError(t, err) + } + addUser("user-inactive-10", "user-inactive-10@test.com", timeutil.TimeStampNow().Add(-600), false) + addUser("user-inactive-5", "user-inactive-5@test.com", timeutil.TimeStampNow().Add(-300), false) + addUser("user-active-10", "user-active-10@test.com", timeutil.TimeStampNow().Add(-600), true) + addUser("user-active-5", "user-active-5@test.com", timeutil.TimeStampNow().Add(-300), true) + unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user-inactive-10"}) + unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{Email: "user-inactive-10@test.com"}) + assert.NoError(t, DeleteInactiveUsers(db.DefaultContext, 8*time.Minute)) + unittest.AssertNotExistsBean(t, &user_model.User{Name: "user-inactive-10"}) + unittest.AssertNotExistsBean(t, &user_model.EmailAddress{Email: "user-inactive-10@test.com"}) + unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user-inactive-5"}) + unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user-active-10"}) + unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user-active-5"}) +} diff --git a/templates/base/markup_codepreview.tmpl b/templates/base/markup_codepreview.tmpl new file mode 100644 index 0000000000000..c65ab284065c7 --- /dev/null +++ b/templates/base/markup_codepreview.tmpl @@ -0,0 +1,25 @@ +
+
+ {{.FilePath}} + {{$link := HTMLFormat `%s` .RepoLink .CommitID (.CommitID | ShortSha) -}} + {{- if eq .LineStart .LineStop -}} + {{ctx.Locale.Tr "repo.code_preview_line_in" .LineStart $link}} + {{- else -}} + {{ctx.Locale.Tr "repo.code_preview_line_from_to" .LineStart .LineStop $link}} + {{- end}} +
+ + + {{- range $idx, $line := .HighlightLines -}} + + + {{- if $.EscapeStatus.Escaped -}} + {{- $lineEscapeStatus := index $.LineEscapeStatus $idx -}} + + {{- end}} + + + {{- end -}} + +
{{if $lineEscapeStatus.Escaped}}{{end}}{{$line.FormattedContent}}
+
diff --git a/templates/repo/blame.tmpl b/templates/repo/blame.tmpl index 1a148a2d1cc29..30d1a3d78d9e8 100644 --- a/templates/repo/blame.tmpl +++ b/templates/repo/blame.tmpl @@ -30,6 +30,9 @@
+ {{if .IsFileTooLarge}} + {{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}} + {{else}} {{range $row := .BlameRows}} @@ -75,6 +78,7 @@ {{end}}
+ {{end}}{{/* end if .IsFileTooLarge */}}
{{if $.Permission.CanRead $.UnitTypeIssues}} {{ctx.Locale.Tr "repo.issues.context.reference_issue"}} diff --git a/templates/repo/issue/choose.tmpl b/templates/repo/issue/choose.tmpl index a8037482beeed..38cf9e485f5d0 100644 --- a/templates/repo/issue/choose.tmpl +++ b/templates/repo/issue/choose.tmpl @@ -3,7 +3,7 @@ {{template "repo/header" .}}
{{template "base/alert" .}} -