Merge branch 'main' into lunny/rename_board_column

This commit is contained in:
Lunny Xiao 2024-04-03 12:18:35 +08:00 committed by GitHub
commit 42bec44df6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
107 changed files with 1600 additions and 1376 deletions

View File

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

View File

@ -6,14 +6,13 @@ package cmd
import (
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"time"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/dump"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
@ -25,89 +24,17 @@ import (
"github.com/urfave/cli/v2"
)
func addReader(w archiver.Writer, r io.ReadCloser, info os.FileInfo, customName string, verbose bool) error {
if verbose {
log.Info("Adding file %s", customName)
}
return w.Write(archiver.File{
FileInfo: archiver.FileInfo{
FileInfo: info,
CustomName: customName,
},
ReadCloser: r,
})
}
func addFile(w archiver.Writer, filePath, absPath string, verbose bool) error {
file, err := os.Open(absPath)
if err != nil {
return err
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
return err
}
return addReader(w, file, fileInfo, filePath, verbose)
}
func isSubdir(upper, lower string) (bool, error) {
if relPath, err := filepath.Rel(upper, lower); err != nil {
return false, err
} else if relPath == "." || !strings.HasPrefix(relPath, ".") {
return true, nil
}
return false, nil
}
type outputType struct {
Enum []string
Default string
selected string
}
func (o outputType) Join() string {
return strings.Join(o.Enum, ", ")
}
func (o *outputType) Set(value string) error {
for _, enum := range o.Enum {
if enum == value {
o.selected = value
return nil
}
}
return fmt.Errorf("allowed values are %s", o.Join())
}
func (o outputType) String() string {
if o.selected == "" {
return o.Default
}
return o.selected
}
var outputTypeEnum = &outputType{
Enum: []string{"zip", "tar", "tar.sz", "tar.gz", "tar.xz", "tar.bz2", "tar.br", "tar.lz4", "tar.zst"},
Default: "zip",
}
// CmdDump represents the available dump sub-command.
var CmdDump = &cli.Command{
Name: "dump",
Usage: "Dump Gitea files and database",
Description: `Dump compresses all related files and database into zip file.
It can be used for backup and capture Gitea server image to send to maintainer`,
Action: runDump,
Name: "dump",
Usage: "Dump Gitea files and database",
Description: `Dump compresses all related files and database into zip file. It can be used for backup and capture Gitea server image to send to maintainer`,
Action: runDump,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "file",
Aliases: []string{"f"},
Value: fmt.Sprintf("gitea-dump-%d.zip", time.Now().Unix()),
Usage: "Name of the dump file which will be created. Supply '-' for stdout. See type for available types.",
Usage: `Name of the dump file which will be created, default to "gitea-dump-{time}.zip". Supply '-' for stdout. See type for available types.`,
},
&cli.BoolFlag{
Name: "verbose",
@ -160,64 +87,52 @@ It can be used for backup and capture Gitea server image to send to maintainer`,
Name: "skip-index",
Usage: "Skip bleve index data",
},
&cli.GenericFlag{
&cli.StringFlag{
Name: "type",
Value: outputTypeEnum,
Usage: fmt.Sprintf("Dump output format: %s", outputTypeEnum.Join()),
Usage: fmt.Sprintf(`Dump output format, default to "zip", supported types: %s`, strings.Join(dump.SupportedOutputTypes, ", ")),
},
},
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
log.Fatal(format, args...)
}
func runDump(ctx *cli.Context) error {
var file *os.File
fileName := ctx.String("file")
outType := ctx.String("type")
if fileName == "-" {
file = os.Stdout
setupConsoleLogger(log.FATAL, log.CanColorStderr, os.Stderr)
} else {
for _, suffix := range outputTypeEnum.Enum {
if strings.HasSuffix(fileName, "."+suffix) {
fileName = strings.TrimSuffix(fileName, "."+suffix)
break
}
}
fileName += "." + outType
}
setting.MustInstalled()
// make sure we are logging to the console no matter what the configuration tells us do to
// FIXME: don't use CfgProvider directly
if _, err := setting.CfgProvider.Section("log").NewKey("MODE", "console"); err != nil {
fatal("Setting logging mode to console failed: %v", err)
}
if _, err := setting.CfgProvider.Section("log.console").NewKey("STDERR", "true"); err != nil {
fatal("Setting console logger to stderr failed: %v", err)
}
// Set loglevel to Warn if quiet-mode is requested
if ctx.Bool("quiet") {
if _, err := setting.CfgProvider.Section("log.console").NewKey("LEVEL", "Warn"); err != nil {
fatal("Setting console log-level failed: %v", err)
}
}
if !setting.InstallLock {
log.Error("Is '%s' really the right config path?\n", setting.CustomConf)
return fmt.Errorf("gitea is not initialized")
}
setting.LoadSettings() // cannot access session settings otherwise
quite := ctx.Bool("quiet")
verbose := ctx.Bool("verbose")
if verbose && ctx.Bool("quiet") {
return fmt.Errorf("--quiet and --verbose cannot both be set")
if verbose && quite {
fatal("Option --quiet and --verbose cannot both be set")
}
// outFileName is either "-" or a file name (will be made absolute)
outFileName, outType := dump.PrepareFileNameAndType(ctx.String("file"), ctx.String("type"))
if outType == "" {
fatal("Invalid output type")
}
outFile := os.Stdout
if outFileName != "-" {
var err error
if outFileName, err = filepath.Abs(outFileName); err != nil {
fatal("Unable to get absolute path of dump file: %v", err)
}
if exist, _ := util.IsExist(outFileName); exist {
fatal("Dump file %q exists", outFileName)
}
if outFile, err = os.Create(outFileName); err != nil {
fatal("Unable to create dump file %q: %v", outFileName, err)
}
defer outFile.Close()
}
setupConsoleLogger(util.Iif(quite, log.WARN, log.INFO), log.CanColorStderr, os.Stderr)
setting.DisableLoggerInit()
setting.LoadSettings() // cannot access session settings otherwise
stdCtx, cancel := installSignals()
defer cancel()
@ -226,44 +141,32 @@ func runDump(ctx *cli.Context) error {
return err
}
if err := storage.Init(); err != nil {
if err = storage.Init(); err != nil {
return err
}
if file == nil {
file, err = os.Create(fileName)
if err != nil {
fatal("Unable to open %s: %v", fileName, err)
}
}
defer file.Close()
absFileName, err := filepath.Abs(fileName)
if err != nil {
return err
}
var iface any
if fileName == "-" {
iface, err = archiver.ByExtension(fmt.Sprintf(".%s", outType))
} else {
iface, err = archiver.ByExtension(fileName)
}
archiverGeneric, err := archiver.ByExtension("." + outType)
if err != nil {
fatal("Unable to get archiver for extension: %v", err)
}
w, _ := iface.(archiver.Writer)
if err := w.Create(file); err != nil {
archiverWriter := archiverGeneric.(archiver.Writer)
if err := archiverWriter.Create(outFile); err != nil {
fatal("Creating archiver.Writer failed: %v", err)
}
defer w.Close()
defer archiverWriter.Close()
dumper := &dump.Dumper{
Writer: archiverWriter,
Verbose: verbose,
}
dumper.GlobalExcludeAbsPath(outFileName)
if ctx.IsSet("skip-repository") && ctx.Bool("skip-repository") {
log.Info("Skip dumping local repositories")
} else {
log.Info("Dumping local repositories... %s", setting.RepoRootPath)
if err := addRecursiveExclude(w, "repos", setting.RepoRootPath, []string{absFileName}, verbose); err != nil {
if err := dumper.AddRecursiveExclude("repos", setting.RepoRootPath, nil); err != nil {
fatal("Failed to include repositories: %v", err)
}
@ -276,8 +179,7 @@ func runDump(ctx *cli.Context) error {
if err != nil {
return err
}
return addReader(w, object, info, path.Join("data", "lfs", objPath), verbose)
return dumper.AddReader(object, info, path.Join("data", "lfs", objPath))
}); err != nil {
fatal("Failed to dump LFS objects: %v", err)
}
@ -310,15 +212,13 @@ func runDump(ctx *cli.Context) error {
fatal("Failed to dump database: %v", err)
}
if err := addFile(w, "gitea-db.sql", dbDump.Name(), verbose); err != nil {
if err = dumper.AddFile("gitea-db.sql", dbDump.Name()); err != nil {
fatal("Failed to include gitea-db.sql: %v", err)
}
if len(setting.CustomConf) > 0 {
log.Info("Adding custom configuration file from %s", setting.CustomConf)
if err := addFile(w, "app.ini", setting.CustomConf, verbose); err != nil {
fatal("Failed to include specified app.ini: %v", err)
}
log.Info("Adding custom configuration file from %s", setting.CustomConf)
if err = dumper.AddFile("app.ini", setting.CustomConf); err != nil {
fatal("Failed to include specified app.ini: %v", err)
}
if ctx.IsSet("skip-custom-dir") && ctx.Bool("skip-custom-dir") {
@ -326,8 +226,8 @@ func runDump(ctx *cli.Context) error {
} else {
customDir, err := os.Stat(setting.CustomPath)
if err == nil && customDir.IsDir() {
if is, _ := isSubdir(setting.AppDataPath, setting.CustomPath); !is {
if err := addRecursiveExclude(w, "custom", setting.CustomPath, []string{absFileName}, verbose); err != nil {
if is, _ := dump.IsSubdir(setting.AppDataPath, setting.CustomPath); !is {
if err := dumper.AddRecursiveExclude("custom", setting.CustomPath, nil); err != nil {
fatal("Failed to include custom: %v", err)
}
} else {
@ -364,8 +264,7 @@ func runDump(ctx *cli.Context) error {
excludes = append(excludes, setting.Attachment.Storage.Path)
excludes = append(excludes, setting.Packages.Storage.Path)
excludes = append(excludes, setting.Log.RootPath)
excludes = append(excludes, absFileName)
if err := addRecursiveExclude(w, "data", setting.AppDataPath, excludes, verbose); err != nil {
if err := dumper.AddRecursiveExclude("data", setting.AppDataPath, excludes); err != nil {
fatal("Failed to include data directory: %v", err)
}
}
@ -377,8 +276,7 @@ func runDump(ctx *cli.Context) error {
if err != nil {
return err
}
return addReader(w, object, info, path.Join("data", "attachments", objPath), verbose)
return dumper.AddReader(object, info, path.Join("data", "attachments", objPath))
}); err != nil {
fatal("Failed to dump attachments: %v", err)
}
@ -392,8 +290,7 @@ func runDump(ctx *cli.Context) error {
if err != nil {
return err
}
return addReader(w, object, info, path.Join("data", "packages", objPath), verbose)
return dumper.AddReader(object, info, path.Join("data", "packages", objPath))
}); err != nil {
fatal("Failed to dump packages: %v", err)
}
@ -409,80 +306,23 @@ func runDump(ctx *cli.Context) error {
log.Error("Unable to check if %s exists. Error: %v", setting.Log.RootPath, err)
}
if isExist {
if err := addRecursiveExclude(w, "log", setting.Log.RootPath, []string{absFileName}, verbose); err != nil {
if err := dumper.AddRecursiveExclude("log", setting.Log.RootPath, nil); err != nil {
fatal("Failed to include log: %v", err)
}
}
}
if fileName != "-" {
if err = w.Close(); err != nil {
_ = util.Remove(fileName)
fatal("Failed to save %s: %v", fileName, err)
if outFileName == "-" {
log.Info("Finish dumping to stdout")
} else {
if err = archiverWriter.Close(); err != nil {
_ = os.Remove(outFileName)
fatal("Failed to save %q: %v", outFileName, err)
}
if err := os.Chmod(fileName, 0o600); err != nil {
if err = os.Chmod(outFileName, 0o600); err != nil {
log.Info("Can't change file access permissions mask to 0600: %v", err)
}
}
if fileName != "-" {
log.Info("Finish dumping in file %s", fileName)
} else {
log.Info("Finish dumping to stdout")
}
return nil
}
// addRecursiveExclude zips absPath to specified insidePath inside writer excluding excludeAbsPath
func addRecursiveExclude(w archiver.Writer, insidePath, absPath string, excludeAbsPath []string, verbose bool) error {
absPath, err := filepath.Abs(absPath)
if err != nil {
return err
}
dir, err := os.Open(absPath)
if err != nil {
return err
}
defer dir.Close()
files, err := dir.Readdir(0)
if err != nil {
return err
}
for _, file := range files {
currentAbsPath := filepath.Join(absPath, file.Name())
currentInsidePath := path.Join(insidePath, file.Name())
if file.IsDir() {
if !util.SliceContainsString(excludeAbsPath, currentAbsPath) {
if err := addFile(w, currentInsidePath, currentAbsPath, false); err != nil {
return err
}
if err = addRecursiveExclude(w, currentInsidePath, currentAbsPath, excludeAbsPath, verbose); err != nil {
return err
}
}
} else {
// only copy regular files and symlink regular files, skip non-regular files like socket/pipe/...
shouldAdd := file.Mode().IsRegular()
if !shouldAdd && file.Mode()&os.ModeSymlink == os.ModeSymlink {
target, err := filepath.EvalSymlinks(currentAbsPath)
if err != nil {
return err
}
targetStat, err := os.Stat(target)
if err != nil {
return err
}
shouldAdd = targetStat.Mode().IsRegular()
}
if shouldAdd {
if err = addFile(w, currentInsidePath, currentAbsPath, verbose); err != nil {
return err
}
}
}
log.Info("Finish dumping in file %s", outFileName)
}
return nil
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
-
id: 1
project_id: 1
issue_id: 1
-
id: 2
project_id: 1
issue_id: 1

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@ -0,0 +1,174 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package dump
import (
"fmt"
"io"
"os"
"path"
"path/filepath"
"slices"
"strings"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"github.com/mholt/archiver/v3"
)
var SupportedOutputTypes = []string{"zip", "tar", "tar.sz", "tar.gz", "tar.xz", "tar.bz2", "tar.br", "tar.lz4", "tar.zst"}
// PrepareFileNameAndType prepares the output file name and type, if the type is not supported, it returns an empty "outType"
func PrepareFileNameAndType(argFile, argType string) (outFileName, outType string) {
if argFile == "" && argType == "" {
outType = SupportedOutputTypes[0]
outFileName = fmt.Sprintf("gitea-dump-%d.%s", timeutil.TimeStampNow(), outType)
} else if argFile == "" {
outType = argType
outFileName = fmt.Sprintf("gitea-dump-%d.%s", timeutil.TimeStampNow(), outType)
} else if argType == "" {
if filepath.Ext(outFileName) == "" {
outType = SupportedOutputTypes[0]
outFileName = argFile
} else {
for _, t := range SupportedOutputTypes {
if strings.HasSuffix(argFile, "."+t) {
outFileName = argFile
outType = t
}
}
}
} else {
outFileName, outType = argFile, argType
}
if !slices.Contains(SupportedOutputTypes, outType) {
return "", ""
}
return outFileName, outType
}
func IsSubdir(upper, lower string) (bool, error) {
if relPath, err := filepath.Rel(upper, lower); err != nil {
return false, err
} else if relPath == "." || !strings.HasPrefix(relPath, ".") {
return true, nil
}
return false, nil
}
type Dumper struct {
Writer archiver.Writer
Verbose bool
globalExcludeAbsPaths []string
}
func (dumper *Dumper) AddReader(r io.ReadCloser, info os.FileInfo, customName string) error {
if dumper.Verbose {
log.Info("Adding file %s", customName)
}
return dumper.Writer.Write(archiver.File{
FileInfo: archiver.FileInfo{
FileInfo: info,
CustomName: customName,
},
ReadCloser: r,
})
}
func (dumper *Dumper) AddFile(filePath, absPath string) error {
file, err := os.Open(absPath)
if err != nil {
return err
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
return err
}
return dumper.AddReader(file, fileInfo, filePath)
}
func (dumper *Dumper) normalizeFilePath(absPath string) string {
absPath = filepath.Clean(absPath)
if setting.IsWindows {
absPath = strings.ToLower(absPath)
}
return absPath
}
func (dumper *Dumper) GlobalExcludeAbsPath(absPaths ...string) {
for _, absPath := range absPaths {
dumper.globalExcludeAbsPaths = append(dumper.globalExcludeAbsPaths, dumper.normalizeFilePath(absPath))
}
}
func (dumper *Dumper) shouldExclude(absPath string, excludes []string) bool {
norm := dumper.normalizeFilePath(absPath)
return slices.Contains(dumper.globalExcludeAbsPaths, norm) || slices.Contains(excludes, norm)
}
func (dumper *Dumper) AddRecursiveExclude(insidePath, absPath string, excludes []string) error {
excludes = slices.Clone(excludes)
for i := range excludes {
excludes[i] = dumper.normalizeFilePath(excludes[i])
}
return dumper.addFileOrDir(insidePath, absPath, excludes)
}
func (dumper *Dumper) addFileOrDir(insidePath, absPath string, excludes []string) error {
absPath, err := filepath.Abs(absPath)
if err != nil {
return err
}
dir, err := os.Open(absPath)
if err != nil {
return err
}
defer dir.Close()
files, err := dir.Readdir(0)
if err != nil {
return err
}
for _, file := range files {
currentAbsPath := filepath.Join(absPath, file.Name())
if dumper.shouldExclude(currentAbsPath, excludes) {
continue
}
currentInsidePath := path.Join(insidePath, file.Name())
if file.IsDir() {
if err := dumper.AddFile(currentInsidePath, currentAbsPath); err != nil {
return err
}
if err = dumper.addFileOrDir(currentInsidePath, currentAbsPath, excludes); err != nil {
return err
}
} else {
// only copy regular files and symlink regular files, skip non-regular files like socket/pipe/...
shouldAdd := file.Mode().IsRegular()
if !shouldAdd && file.Mode()&os.ModeSymlink == os.ModeSymlink {
target, err := filepath.EvalSymlinks(currentAbsPath)
if err != nil {
return err
}
targetStat, err := os.Stat(target)
if err != nil {
return err
}
shouldAdd = targetStat.Mode().IsRegular()
}
if shouldAdd {
if err = dumper.AddFile(currentInsidePath, currentAbsPath); err != nil {
return err
}
}
}
}
return nil
}

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

@ -0,0 +1,113 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package dump
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"testing"
"time"
"code.gitea.io/gitea/modules/timeutil"
"github.com/mholt/archiver/v3"
"github.com/stretchr/testify/assert"
)
func TestPrepareFileNameAndType(t *testing.T) {
defer timeutil.MockSet(time.Unix(1234, 0))()
test := func(argFile, argType, expFile, expType string) {
outFile, outType := PrepareFileNameAndType(argFile, argType)
assert.Equal(t,
fmt.Sprintf("outFile=%s, outType=%s", expFile, expType),
fmt.Sprintf("outFile=%s, outType=%s", outFile, outType),
fmt.Sprintf("argFile=%s, argType=%s", argFile, argType),
)
}
test("", "", "gitea-dump-1234.zip", "zip")
test("", "tar.gz", "gitea-dump-1234.tar.gz", "tar.gz")
test("", "no-such", "", "")
test("-", "", "-", "zip")
test("-", "tar.gz", "-", "tar.gz")
test("-", "no-such", "", "")
test("a", "", "a", "zip")
test("a", "tar.gz", "a", "tar.gz")
test("a", "no-such", "", "")
test("a.zip", "", "a.zip", "zip")
test("a.zip", "tar.gz", "a.zip", "tar.gz")
test("a.zip", "no-such", "", "")
test("a.tar.gz", "", "a.tar.gz", "zip")
test("a.tar.gz", "tar.gz", "a.tar.gz", "tar.gz")
test("a.tar.gz", "no-such", "", "")
}
func TestIsSubDir(t *testing.T) {
tmpDir := t.TempDir()
_ = os.MkdirAll(filepath.Join(tmpDir, "include/sub"), 0o755)
isSub, err := IsSubdir(filepath.Join(tmpDir, "include"), filepath.Join(tmpDir, "include"))
assert.NoError(t, err)
assert.True(t, isSub)
isSub, err = IsSubdir(filepath.Join(tmpDir, "include"), filepath.Join(tmpDir, "include/sub"))
assert.NoError(t, err)
assert.True(t, isSub)
isSub, err = IsSubdir(filepath.Join(tmpDir, "include/sub"), filepath.Join(tmpDir, "include"))
assert.NoError(t, err)
assert.False(t, isSub)
}
type testWriter struct {
added []string
}
func (t *testWriter) Create(out io.Writer) error {
return nil
}
func (t *testWriter) Write(f archiver.File) error {
t.added = append(t.added, f.Name())
return nil
}
func (t *testWriter) Close() error {
return nil
}
func TestDumper(t *testing.T) {
sortStrings := func(s []string) []string {
sort.Strings(s)
return s
}
tmpDir := t.TempDir()
_ = os.MkdirAll(filepath.Join(tmpDir, "include/exclude1"), 0o755)
_ = os.MkdirAll(filepath.Join(tmpDir, "include/exclude2"), 0o755)
_ = os.MkdirAll(filepath.Join(tmpDir, "include/sub"), 0o755)
_ = os.WriteFile(filepath.Join(tmpDir, "include/a"), nil, 0o644)
_ = os.WriteFile(filepath.Join(tmpDir, "include/sub/b"), nil, 0o644)
_ = os.WriteFile(filepath.Join(tmpDir, "include/exclude1/a-1"), nil, 0o644)
_ = os.WriteFile(filepath.Join(tmpDir, "include/exclude2/a-2"), nil, 0o644)
tw := &testWriter{}
d := &Dumper{Writer: tw}
d.GlobalExcludeAbsPath(filepath.Join(tmpDir, "include/exclude1"))
err := d.AddRecursiveExclude("include", filepath.Join(tmpDir, "include"), []string{filepath.Join(tmpDir, "include/exclude2")})
assert.NoError(t, err)
assert.EqualValues(t, sortStrings([]string{"include/a", "include/sub", "include/sub/b"}), sortStrings(tw.added))
tw = &testWriter{}
d = &Dumper{Writer: tw}
err = d.AddRecursiveExclude("include", filepath.Join(tmpDir, "include"), nil)
assert.NoError(t, err)
assert.EqualValues(t, sortStrings([]string{"include/exclude2", "include/exclude2/a-2", "include/a", "include/sub", "include/sub/b", "include/exclude1", "include/exclude1/a-1"}), sortStrings(tw.added))
}

View File

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

View File

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

View File

@ -99,7 +99,7 @@ readLoop:
}
}
commit.CommitMessage = messageSB.String()
commit.Signature = &CommitGPGSignature{
commit.Signature = &CommitSignature{
Signature: signatureSB.String(),
Payload: payloadSB.String(),
}

View File

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

View File

@ -315,7 +315,7 @@ qbHDASXl
Type: "tag",
Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 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

View File

@ -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
}
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.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
}
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]),
}
tag.Message = tag.Message[:idx+1]
}
payload, msg, sign := parsePayloadSignature(data, pos)
tag.Message = msg
if len(sign) > 0 {
tag.Signature = &CommitSignature{Signature: sign, Payload: payload}
}
return tag, nil
}

View File

@ -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 <lucas@semalead.com> 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 <lucas@semalead.com> 1484553735 +0100
@ -37,37 +41,57 @@ tagger Lucas Michot <lucas@semalead.com> 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 <dummy-email@example.com> 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 <dummy-email@example.com> 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)
}

View File

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

View File

@ -171,6 +171,7 @@ type processor func(ctx *RenderContext, node *html.Node)
var defaultProcessors = []processor{
fullIssuePatternProcessor,
comparePatternProcessor,
codePreviewPatternProcessor,
fullHashPatternProcessor,
shortLinkProcessor,
linkProcessor,

View File

@ -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 "<p>{TextBefore}<div NewNode/>{TextAfter}</p>" (the parent could also be "li")
// then it is resolved as: "<p>{TextBefore}</p><div NewNode/><p>{TextAfter}</p>",
// 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
}
}

View File

@ -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 "<div>code preview</div>", 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", "<p><div>code preview</div></p>")
test("http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20", `<p><a href="http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20" rel="nofollow">http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20</a></p>`)
}

View File

@ -511,9 +511,17 @@ func TestMathBlock(t *testing.T) {
`\(a\) \(b\)`,
`<p><code class="language-math is-loading">a</code> <code class="language-math is-loading">b</code></p>` + nl,
},
{
`$a$.`,
`<p><code class="language-math is-loading">a</code>.</p>` + nl,
},
{
`.$a$`,
`<p>.$a$</p>` + nl,
},
{
`$a a$b b$`,
`<p><code class="language-math is-loading">a a$b b</code></p>` + nl,
`<p>$a a$b b$</p>` + nl,
},
{
`a a$b b`,
@ -521,7 +529,15 @@ func TestMathBlock(t *testing.T) {
},
{
`a$b $a a$b b$`,
`<p>a$b <code class="language-math is-loading">a a$b b</code></p>` + nl,
`<p>a$b $a a$b b$</p>` + nl,
},
{
"a$x$",
`<p>a$x$</p>` + nl,
},
{
"$x$a",
`<p>$x$a</p>` + nl,
},
{
"$$a$$",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

12
options/license/OAR Normal file
View File

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

12
options/license/xzoom Normal file
View File

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

View File

@ -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 <a target="_blank" rel="noreferrer" href="https://blog.gitea.io">blogu</a> 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

View File

@ -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 <a target="_blank" rel="noreferrer" href="https://blog.gitea.io">Blog</a>.
dashboard.statistic=Übersicht
dashboard.operations=Wartungsoperationen
dashboard.system_status=System-Status
dashboard.operation_name=Name der Operation
dashboard.operation_switch=Wechseln

View File

@ -2687,7 +2687,6 @@ settings=Ρυθμίσεις Διαχειριστή
dashboard.new_version_hint=Το Gitea %s είναι διαθέσιμο, τώρα εκτελείτε το %s. Ανατρέξτε <a target="_blank" rel="noreferrer" href="https://blog.gitea.io">στο blog</a> για περισσότερες λεπτομέρειες.
dashboard.statistic=Περίληψη
dashboard.operations=Λειτουργίες Συντήρησης
dashboard.system_status=Κατάσταση Συστήματος
dashboard.operation_name=Όνομα Λειτουργίας
dashboard.operation_switch=Αλλαγή

View File

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

View File

@ -2672,7 +2672,6 @@ settings=Configuración de Admin
dashboard.new_version_hint=Gitea %s ya está disponible, estás ejecutando %s. Revisa <a target="_blank" rel="noreferrer" href="https://blog.gitea.io">el blog</a> 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

View File

@ -2064,7 +2064,6 @@ last_page=واپسین
total=مجموع: %d
dashboard.statistic=چکیده
dashboard.operations=عملیات‌های نگهداری
dashboard.system_status=وضعیت سامانه
dashboard.operation_name=نام عملیات
dashboard.operation_switch=تعویض

View File

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

View File

@ -2712,7 +2712,6 @@ settings=Paramètres administrateur
dashboard.new_version_hint=Gitea %s est maintenant disponible, vous utilisez %s. Consultez <a target="_blank" rel="noreferrer" href="https://blog.gitea.io">le blog</a> 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

View File

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

View File

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

View File

@ -2719,7 +2719,6 @@ settings=管理設定
dashboard.new_version_hint=Gitea %s が入手可能になりました。 現在実行しているのは %s です。 詳細は <a target="_blank" rel="noreferrer" href="https://blog.gitea.io">ブログ</a> を確認してください。
dashboard.statistic=サマリー
dashboard.operations=メンテナンス操作
dashboard.system_status=システム状況
dashboard.operation_name=操作の名称
dashboard.operation_switch=切り替え

View File

@ -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 <a target="_blank" rel="noreferrer" href="https://blog.gitea.io">mājas lapā</a>.
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

View File

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

View File

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

View File

@ -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 <a target="_blank" rel="noreferrer" href="https://blog.gitea.io">o blog</a> 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

View File

@ -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 <a target="_blank" rel="noreferrer" href="https://blog.gitea.io">blog</a> 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.

View File

@ -2634,7 +2634,6 @@ total=Всего: %d
dashboard.new_version_hint=Доступна новая версия Gitea %s, вы используете %s. Более подробную информацию читайте в <a target="_blank" rel="noreferrer" href="https://blog.gitea.io">блоге</a>.
dashboard.statistic=Статистика
dashboard.operations=Операции
dashboard.system_status=Состояние системы
dashboard.operation_name=Имя операции
dashboard.operation_switch=Переключить

View File

@ -2024,7 +2024,6 @@ last_page=පසුගිය
total=මුළු: %d
dashboard.statistic=සාරාංශය
dashboard.operations=නඩත්තු මෙහෙයුම්
dashboard.system_status=පද්ධතියේ තත්වය
dashboard.operation_name=මෙහෙයුමේ නම
dashboard.operation_switch=මාරුවන්න

View File

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

View File

@ -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 <a target="_blank" rel="noreferrer" href="https://blog.gitea.io">blog</a>'a bakabilirsiniz.
dashboard.statistic=Özet
dashboard.operations=Bakım İşlemleri
dashboard.system_status=Sistem Durumu
dashboard.operation_name=İşlem Adı
dashboard.operation_switch=Geç

View File

@ -2074,7 +2074,6 @@ last_page=Остання
total=Разом: %d
dashboard.statistic=Підсумок
dashboard.operations=Технічне обслуговування
dashboard.system_status=Статус системи
dashboard.operation_name=Назва операції
dashboard.operation_switch=Перемкнути

View File

@ -2711,7 +2711,6 @@ settings=管理设置
dashboard.new_version_hint=Gitea %s 现已可用,您正在运行 %s。查看 <a target="_blank" rel="noreferrer" href="https://blog.gitea.io">博客</a> 了解详情。
dashboard.statistic=摘要
dashboard.operations=维护操作
dashboard.system_status=系统状态
dashboard.operation_name=操作名称
dashboard.operation_switch=开关

View File

@ -2439,7 +2439,6 @@ total=總計:%d
dashboard.new_version_hint=現已推出 Gitea %s您正在執行 %s。詳情請參閱<a target="_blank" rel="noreferrer" href="https://blog.gitea.io">部落格</a>的說明。
dashboard.statistic=摘要
dashboard.operations=維護作業
dashboard.system_status=系統狀態
dashboard.operation_name=作業名稱
dashboard.operation_switch=開關

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,8 @@ import (
func ProcessorHelper() *markup.ProcessorHelper {
return &markup.ProcessorHelper{
ElementDir: "auto", // set dir="auto" for necessary (eg: <p>, <h?>, etc) tags
RenderRepoFileCodePreview: renderRepoFileCodePreview,
IsUsernameMentionable: func(ctx context.Context, username string) bool {
mentionedUser, err := user.GetUserByName(ctx, username)
if err != nil {

View File

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

View File

@ -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, `<div class="code-preview-container file-content">
<div class="code-preview-header">
<a href="http://full" class="muted" rel="nofollow">/README.md</a>
repo.code_preview_line_from_to:1,2,<a href="/user2/repo1/src/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d" rel="nofollow">65f1bf27bc</a>
</div>
<table class="file-view">
<tbody><tr>
<td class="lines-num"><span data-line-number="1"></span></td>
<td class="lines-code chroma"><code class="code-inner"><span class="gh"># repo1</code></td>
</tr><tr>
<td class="lines-num"><span data-line-number="2"></span></td>
<td class="lines-code chroma"><code class="code-inner"></span><span class="gh"></span></code></td>
</tr></tbody>
</table>
</div>
`, 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, `<div class="code-preview-container file-content">
<div class="code-preview-header">
<a href="http://full" class="muted" rel="nofollow">/README.md</a>
repo.code_preview_line_in:1,<a href="/user2/repo1/src/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d" rel="nofollow">65f1bf27bc</a>
</div>
<table class="file-view">
<tbody><tr>
<td class="lines-num"><span data-line-number="1"></span></td>
<td class="lines-code chroma"><code class="code-inner"><span class="gh"># repo1</code></td>
</tr></tbody>
</table>
</div>
`, 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")
}

View File

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

View File

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

View File

@ -0,0 +1,25 @@
<div class="code-preview-container file-content">
<div class="code-preview-header">
<a href="{{.FullURL}}" class="muted" rel="nofollow">{{.FilePath}}</a>
{{$link := HTMLFormat `<a href="%s/src/commit/%s" rel="nofollow">%s</a>` .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}}
</div>
<table class="file-view">
<tbody>
{{- range $idx, $line := .HighlightLines -}}
<tr>
<td class="lines-num"><span data-line-number="{{$line.Num}}"></span></td>
{{- if $.EscapeStatus.Escaped -}}
{{- $lineEscapeStatus := index $.LineEscapeStatus $idx -}}
<td class="lines-escape">{{if $lineEscapeStatus.Escaped}}<a href="#" class="toggle-escape-button btn interact-bg" title="{{if $lineEscapeStatus.HasInvisible}}{{ctx.Locale.Tr "repo.invisible_runes_line"}} {{end}}{{if $lineEscapeStatus.HasAmbiguous}}{{ctx.Locale.Tr "repo.ambiguous_runes_line"}}{{end}}"></a>{{end}}</td>
{{- end}}
<td class="lines-code chroma"><code class="code-inner">{{$line.FormattedContent}}</code></td>
</tr>
{{- end -}}
</tbody>
</table>
</div>

View File

@ -102,7 +102,7 @@
<div>
<h1>Loading</h1>
<div class="is-loading small-loading-icon tw-border tw-border-secondary tw-py-1"><span>loading ...</span></div>
<div class="is-loading loading-icon-2px tw-border tw-border-secondary tw-py-1"><span>loading ...</span></div>
<div class="is-loading tw-border tw-border-secondary tw-py-4">
<p>loading ...</p>
<p>loading ...</p>

View File

@ -30,6 +30,9 @@
</h4>
<div class="ui attached table unstackable segment">
<div class="file-view code-view unicode-escaped">
{{if .IsFileTooLarge}}
{{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}}
{{else}}
<table>
<tbody>
{{range $row := .BlameRows}}
@ -75,6 +78,7 @@
{{end}}
</tbody>
</table>
{{end}}{{/* end if .IsFileTooLarge */}}
<div class="code-line-menu tippy-target">
{{if $.Permission.CanRead $.UnitTypeIssues}}
<a class="item ref-in-new-issue" role="menuitem" data-url-issue-new="{{.RepoLink}}/issues/new" data-url-param-body-link="{{.Repository.Link}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}" rel="nofollow noindex">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</a>

View File

@ -18,22 +18,21 @@
</div>
</form>
</div>
<div class="tw-flex tw-items-center tw-flex-wrap tw-gap-1" id="repo-topics">
{{range .Topics}}<a class="ui repo-topic large label topic tw-m-0" href="{{AppSubUrl}}/explore/repos?q={{.Name}}&topic=1">{{.Name}}</a>{{end}}
<div class="tw-flex tw-items-center tw-flex-wrap tw-gap-2 tw-my-2" id="repo-topics">
{{/* it should match the code in issue-home.js */}}
{{range .Topics}}<a class="repo-topic ui large label" href="{{AppSubUrl}}/explore/repos?q={{.Name}}&topic=1">{{.Name}}</a>{{end}}
{{if and .Permission.IsAdmin (not .Repository.IsArchived)}}<button id="manage_topic" class="btn interact-fg tw-text-12">{{ctx.Locale.Tr "repo.topic.manage_topics"}}</button>{{end}}
</div>
{{end}}
{{if and .Permission.IsAdmin (not .Repository.IsArchived)}}
<div class="ui form tw-hidden tw-flex tw-flex-col tw-mt-4" id="topic_edit">
<div class="field tw-flex-1 tw-mb-1">
<div class="ui fluid multiple search selection dropdown tw-flex-wrap" data-text-count-prompt="{{ctx.Locale.Tr "repo.topic.count_prompt"}}" data-text-format-prompt="{{ctx.Locale.Tr "repo.topic.format_prompt"}}">
<input type="hidden" name="topics" value="{{range $i, $v := .Topics}}{{.Name}}{{if Eval $i "+" 1 "<" (len $.Topics)}},{{end}}{{end}}">
{{range .Topics}}
{{/* keey the same layout as Fomantic UI generated labels */}}
<a class="ui label transition visible tw-cursor-default tw-inline-block" data-value="{{.Name}}">{{.Name}}{{svg "octicon-x" 16 "delete icon"}}</a>
{{end}}
<div class="text"></div>
</div>
<div class="ui form tw-hidden tw-flex tw-gap-2 tw-my-2" id="topic_edit">
<div class="ui fluid multiple search selection dropdown tw-flex-wrap tw-flex-1">
<input type="hidden" name="topics" value="{{range $i, $v := .Topics}}{{.Name}}{{if Eval $i "+" 1 "<" (len $.Topics)}},{{end}}{{end}}">
{{range .Topics}}
{{/* keep the same layout as Fomantic UI generated labels */}}
<a class="ui label transition visible tw-cursor-default tw-inline-block" data-value="{{.Name}}">{{.Name}}{{svg "octicon-x" 16 "delete icon"}}</a>
{{end}}
<div class="text"></div>
</div>
<div>
<button class="ui basic button" id="cancel_topic_edit">{{ctx.Locale.Tr "cancel"}}</button>

View File

@ -3,7 +3,7 @@
{{template "repo/header" .}}
<div class="ui container">
{{template "base/alert" .}}
<div class="navbar">
<div class="issue-navbar">
{{template "repo/issue/navbar" .}}
</div>
<div class="divider"></div>

View File

@ -2,7 +2,7 @@
<div role="main" aria-label="{{.Title}}" class="page-content repository labels">
{{template "repo/header" .}}
<div class="ui container">
<div class="navbar tw-mb-4">
<div class="issue-navbar tw-mb-4">
{{template "repo/issue/navbar" .}}
{{if and (or .CanWriteIssues .CanWritePulls) (not .Repository.IsArchived)}}
<button class="ui small primary new-label button">{{ctx.Locale.Tr "repo.issues.new_label"}}</button>

View File

@ -8,7 +8,7 @@
{{ctx.Locale.Tr "repo.issues.filter_sort"}}
</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="left menu">
<div class="menu">
<a class="{{if or (eq .SortType "alphabetically") (not .SortType)}}active {{end}}item" href="?sort=alphabetically&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.label.filter_sort.alphabetically"}}</a>
<a class="{{if eq .SortType "reversealphabetically"}}active {{end}}item" href="?sort=reversealphabetically&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.label.filter_sort.reverse_alphabetically"}}</a>
<a class="{{if eq .SortType "leastissues"}}active {{end}}item" href="?sort=leastissues&state={{$.State}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.least_issues"}}</a>

View File

@ -2,7 +2,7 @@
<div role="main" aria-label="{{.Title}}" class="page-content repository new milestone">
{{template "repo/header" .}}
<div class="ui container">
<div class="navbar">
<div class="issue-navbar">
{{template "repo/issue/navbar" .}}
{{if and (or .CanWriteIssues .CanWritePulls) .PageIsEditMilestone}}
<div class="ui right floated secondary menu">

View File

@ -677,7 +677,7 @@
{{if and (not (eq .Issue.PullRequest.HeadRepo.FullName .Issue.PullRequest.BaseRepo.FullName)) .CanWriteToHeadRepo}}
<div class="divider"></div>
<div class="inline field">
<div class="ui checkbox small-loading-icon" id="allow-edits-from-maintainers"
<div class="ui checkbox loading-icon-2px" id="allow-edits-from-maintainers"
data-url="{{.Issue.Link}}"
data-tooltip-content="{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers_desc"}}"
data-prompt-error="{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers_err"}}"

View File

@ -14,7 +14,9 @@
<div class="ui attached table unstackable segment">
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus "root" $}}
<div class="file-view{{if .IsMarkup}} markup {{.MarkupType}}{{else if .IsPlainText}} plain-text{{else if .IsTextFile}} code-view{{end}}">
{{if .IsMarkup}}
{{if .IsFileTooLarge}}
{{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}}
{{else if .IsMarkup}}
{{if .FileContent}}{{.FileContent | SafeHTML}}{{end}}
{{else if .IsPlainText}}
<pre>{{if .FileContent}}{{.FileContent | SafeHTML}}{{end}}</pre>
@ -33,19 +35,15 @@
{{else if .IsPDFFile}}
<div class="pdf-content is-loading" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "diff.view_file"}}"></div>
{{else}}
<a href="{{$.RawFileLink}}" rel="nofollow">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
<a href="{{$.RawFileLink}}" rel="nofollow" class="tw-p-4">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
{{end}}
</div>
{{else if .FileSize}}
<table>
<tbody>
<tr>
{{if .IsFileTooLarge}}
<td><strong>{{ctx.Locale.Tr "repo.file_too_large"}}</strong></td>
{{else}}
<td class="lines-num">{{.LineNums}}</td>
<td class="lines-code"><pre><code class="{{.HighlightClass}}"><ol>{{.FileContent}}</ol></code></pre></td>
{{end}}
</tr>
</tbody>
</table>

View File

@ -89,7 +89,9 @@
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus "root" $}}
{{end}}
<div class="file-view{{if .IsMarkup}} markup {{.MarkupType}}{{else if .IsPlainText}} plain-text{{else if .IsTextSource}} code-view{{end}}">
{{if .IsMarkup}}
{{if .IsFileTooLarge}}
{{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}}
{{else if .IsMarkup}}
{{if .FileContent}}{{.FileContent}}{{end}}
{{else if .IsPlainText}}
<pre>{{if .FileContent}}{{.FileContent}}{{end}}</pre>
@ -108,19 +110,10 @@
{{else if .IsPDFFile}}
<div class="pdf-content is-loading" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "repo.diff.view_file"}}"></div>
{{else}}
<a href="{{$.RawFileLink}}" rel="nofollow">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
<a href="{{$.RawFileLink}}" rel="nofollow" class="tw-p-4">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
{{end}}
</div>
{{else if .FileSize}}
{{if .IsFileTooLarge}}
<table>
<tbody>
<tr>
<td><strong>{{ctx.Locale.Tr "repo.file_too_large"}}</strong></td>
</tr>
</tbody>
</table>
{{else}}
<table>
<tbody>
{{range $idx, $code := .FileContent}}
@ -142,7 +135,6 @@
<a class="item view_git_blame" role="menuitem" href="{{.Repository.Link}}/blame/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}">{{ctx.Locale.Tr "repo.view_git_blame"}}</a>
<a class="item copy-line-permalink" role="menuitem" data-url="{{.Repository.Link}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}">{{ctx.Locale.Tr "repo.file_copy_permalink"}}</a>
</div>
{{end}}
{{end}}
</div>
</div>

View File

@ -0,0 +1,4 @@
<div class="tw-p-4">
{{ctx.Locale.Tr "repo.file_too_large"}}
{{if .RawFileLink}}<a href="{{.RawFileLink}}" rel="nofollow">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>{{end}}
</div>

View File

@ -305,12 +305,6 @@ a.label,
background-color: var(--color-label-bg);
}
/* fix Fomantic's line-height cutting off "g" on Windows Chrome with Segoe UI */
.ui.input > input {
line-height: var(--line-height-default);
text-align: start; /* Override fomantic's `text-align: left` to make RTL work via HTML `dir="auto"` */
}
/* fix Fomantic's line-height causing vertical scrollbars to appear */
ul.ui.list li,
ol.ui.list li,
@ -319,47 +313,6 @@ ol.ui.list li,
line-height: var(--line-height-default);
}
.ui.input.focus > input,
.ui.input > input:focus {
border-color: var(--color-primary);
}
.ui.action.input .ui.ui.button {
border-color: var(--color-input-border);
padding-top: 0; /* the ".action.input" is "flex + stretch", so let the buttons layout themselves */
padding-bottom: 0;
}
/* currently used for search bar dropdowns in repo search and explore code */
.ui.action.input:not([class*="left action"]) > .ui.dropdown.selection {
min-width: 10em;
}
.ui.action.input:not([class*="left action"]) > .ui.dropdown.selection:not(:focus) {
border-right: none;
}
.ui.action.input:not([class*="left action"]) > .ui.dropdown.selection:not(.active):hover {
border-color: var(--color-input-border);
}
.ui.action.input:not([class*="left action"]) .ui.dropdown.selection.upward.visible {
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.ui.action.input:not([class*="left action"]) > input,
.ui.action.input:not([class*="left action"]) > input:hover {
border-right: none;
}
.ui.action.input:not([class*="left action"]) > input:focus + .ui.dropdown.selection,
.ui.action.input:not([class*="left action"]) > input:focus + .ui.dropdown.selection:hover,
.ui.action.input:not([class*="left action"]) > input:focus + .button,
.ui.action.input:not([class*="left action"]) > input:focus + .button:hover,
.ui.action.input:not([class*="left action"]) > input:focus + .icon + .button,
.ui.action.input:not([class*="left action"]) > input:focus + .icon + .button:hover {
border-left-color: var(--color-primary);
}
.ui.action.input:not([class*="left action"]) > input:focus {
border-right-color: var(--color-primary);
}
.ui.menu {
display: flex;
}
@ -1233,10 +1186,13 @@ overflow-menu .ui.label {
content: attr(data-line-number);
line-height: 20px !important;
padding: 0 10px;
cursor: pointer;
display: block;
}
.code-view .lines-num span::after {
cursor: pointer;
}
.lines-type-marker {
vertical-align: top;
}
@ -1599,16 +1555,6 @@ table th[data-sortt-desc] .svg {
align-items: stretch;
}
.ui.ui.icon.input .icon {
display: flex;
align-items: center;
justify-content: center;
}
.ui.icon.input > i.icon {
transition: none;
}
.flex-items-block > .item,
.flex-text-block {
display: flex;

View File

@ -6,6 +6,7 @@
@import "./modules/container.css";
@import "./modules/divider.css";
@import "./modules/header.css";
@import "./modules/input.css";
@import "./modules/label.css";
@import "./modules/segment.css";
@import "./modules/grid.css";
@ -40,6 +41,7 @@
@import "./markup/content.css";
@import "./markup/codecopy.css";
@import "./markup/codepreview.css";
@import "./markup/asciicast.css";
@import "./chroma/base.css";

View File

@ -0,0 +1,36 @@
.markup .code-preview-container {
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
margin: 0.25em 0;
}
.markup .code-preview-container .code-preview-header {
border-bottom: 1px solid var(--color-secondary);
padding: 0.5em;
font-size: 12px;
}
.markup .code-preview-container table {
width: 100%;
max-height: 240px; /* 12 lines at 20px per line */
overflow-y: auto;
margin: 0; /* override ".markup table {margin}" */
}
/* workaround to hide empty p before container - more details are in "html_codepreview.go" */
.markup p:empty:has(+ .code-preview-container) {
display: none;
}
/* override the polluted styles from the content.css: ".markup table ..." */
.markup .code-preview-container table tr {
border: 0 !important;
}
.markup .code-preview-container table th,
.markup .code-preview-container table td {
border: 0 !important;
padding: 0 0 0 5px !important;
}
.markup .code-preview-container table tr:nth-child(2n) {
background: none !important;
}

View File

@ -382,7 +382,7 @@
text-align: center;
}
.markup span.align-center span img
.markup span.align-center span img,
.markup span.align-center span video {
margin: 0 auto;
text-align: center;
@ -432,7 +432,7 @@
text-align: right;
}
.markup code,
.markup code:not(.code-inner),
.markup tt {
padding: 0.2em 0.4em;
margin: 0;

View File

@ -34,10 +34,14 @@
border-radius: var(--border-radius-circle);
}
.is-loading.small-loading-icon::after {
.is-loading.loading-icon-2px::after {
border-width: 2px;
}
.is-loading.loading-icon-3px::after {
border-width: 3px;
}
/* for single form button, the loading state should be on the button, but not go semi-transparent, just replace the text on the button with the loader. */
form.single-button-form.is-loading > * {
opacity: 1;
@ -62,7 +66,7 @@ form.single-button-form.is-loading .button {
background: transparent;
}
/* TODO: not needed, use "is-loading small-loading-icon" instead */
/* TODO: not needed, use "is-loading loading-icon-2px" instead */
code.language-math.is-loading::after {
padding: 0;
border-width: 2px;

View File

@ -135,6 +135,12 @@ h4.ui.header .sub.header {
font-weight: var(--font-weight-normal);
}
/* open dropdown menus to the left in right-attached headers */
.ui.attached.header > .ui.right .ui.dropdown .menu {
right: 0;
left: auto;
}
/* if a .top.attached.header is followed by a .segment, add some margin */
.ui.segments + .ui.top.attached.header,
.ui.attached.segment + .ui.top.attached.header {

View File

@ -0,0 +1,192 @@
/* based on Fomantic UI input module, with just the parts extracted that we use. If you find any
unused rules here after refactoring, please remove them. */
.ui.input {
position: relative;
font-weight: var(--font-weight-normal);
display: inline-flex;
color: var(--color-input-text);
}
.ui.input > input {
margin: 0;
max-width: 100%;
flex: 1 0 auto;
outline: none;
font-family: var(--fonts-regular);
padding: 0.67857143em 1em;
border: 1px solid var(--color-input-border);
color: var(--color-input-text);
border-radius: 0.28571429rem;
line-height: var(--line-height-default);
text-align: start;
}
.ui.disabled.input,
.ui.input:not(.disabled) input[disabled] {
opacity: var(--opacity-disabled);
}
.ui.disabled.input > input,
.ui.input:not(.disabled) input[disabled] {
pointer-events: none;
}
.ui.input.focus > input,
.ui.input > input:focus {
border-color: var(--color-primary);
}
.ui.input.error > input {
background: var(--color-error-bg);
border-color: var(--color-error-border);
color: var(--color-error-text);
}
.ui.icon.input > i.icon {
display: flex;
align-items: center;
justify-content: center;
cursor: default;
position: absolute;
text-align: center;
top: 50%;
transform: translateY(-50%);
opacity: 0.5;
border-radius: 0 0.28571429rem 0.28571429rem 0;
pointer-events: none;
padding: 4px;
}
.ui.icon.input > i.icon.is-loading {
position: absolute !important;
}
.ui.icon.input > i.icon.is-loading > * {
visibility: hidden;
}
.ui.ui.ui.ui.icon.input > textarea,
.ui.ui.ui.ui.icon.input > input {
padding-right: 2.67142857em;
}
.ui.icon.input > i.link.icon {
cursor: pointer;
}
.ui.icon.input > i.circular.icon {
top: 0.35em;
right: 0.5em;
}
.ui[class*="left icon"].input > i.icon {
right: auto;
left: 8px;
border-radius: 0.28571429rem 0 0 0.28571429rem;
}
.ui[class*="left icon"].input > i.circular.icon {
right: auto;
left: 0.5em;
}
.ui.ui.ui.ui[class*="left icon"].input > textarea,
.ui.ui.ui.ui[class*="left icon"].input > input {
padding-left: 2.67142857em;
padding-right: 1em;
}
.ui.icon.input > textarea:focus ~ .icon,
.ui.icon.input > input:focus ~ .icon {
opacity: 1;
}
.ui.icon.input > textarea ~ i.icon {
height: 3em;
}
.ui.form .field.error > .ui.action.input > .ui.button,
.ui.action.input.error > .ui.button {
border-top: 1px solid var(--color-error-border);
border-bottom: 1px solid var(--color-error-border);
}
.ui.action.input > .button,
.ui.action.input > .buttons {
display: flex;
align-items: center;
flex: 0 0 auto;
}
.ui.action.input > .button,
.ui.action.input > .buttons > .button {
padding-top: 0.78571429em;
padding-bottom: 0.78571429em;
margin: 0;
}
.ui.action.input:not([class*="left action"]) > input {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right-color: transparent;
}
.ui.action.input > .dropdown:first-child,
.ui.action.input > .button:first-child,
.ui.action.input > .buttons:first-child > .button {
border-radius: 0.28571429rem 0 0 0.28571429rem;
}
.ui.action.input > .dropdown:not(:first-child),
.ui.action.input > .button:not(:first-child),
.ui.action.input > .buttons:not(:first-child) > .button {
border-radius: 0;
}
.ui.action.input > .dropdown:last-child,
.ui.action.input > .button:last-child,
.ui.action.input > .buttons:last-child > .button {
border-radius: 0 0.28571429rem 0.28571429rem 0;
}
.ui.fluid.input {
display: flex;
}
.ui.fluid.input > input {
width: 0 !important;
}
.ui.tiny.input {
font-size: 0.85714286em;
}
.ui.small.input {
font-size: 0.92857143em;
}
.ui.action.input .ui.ui.button {
border-color: var(--color-input-border);
padding-top: 0; /* the ".action.input" is "flex + stretch", so let the buttons layout themselves */
padding-bottom: 0;
}
/* currently used for search bar dropdowns in repo search and explore code */
.ui.action.input:not([class*="left action"]) > .ui.dropdown.selection {
min-width: 10em;
}
.ui.action.input:not([class*="left action"]) > .ui.dropdown.selection:not(:focus) {
border-right: none;
}
.ui.action.input:not([class*="left action"]) > .ui.dropdown.selection:not(.active):hover {
border-color: var(--color-input-border);
}
.ui.action.input:not([class*="left action"]) .ui.dropdown.selection.upward.visible {
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.ui.action.input:not([class*="left action"]) > input,
.ui.action.input:not([class*="left action"]) > input:hover {
border-right: none;
}
.ui.action.input:not([class*="left action"]) > input:focus + .ui.dropdown.selection,
.ui.action.input:not([class*="left action"]) > input:focus + .ui.dropdown.selection:hover,
.ui.action.input:not([class*="left action"]) > input:focus + .button,
.ui.action.input:not([class*="left action"]) > input:focus + .button:hover,
.ui.action.input:not([class*="left action"]) > input:focus + .icon + .button,
.ui.action.input:not([class*="left action"]) > input:focus + .icon + .button:hover {
border-left-color: var(--color-primary);
}
.ui.action.input:not([class*="left action"]) > input:focus {
border-right-color: var(--color-primary);
}

View File

@ -140,3 +140,8 @@
.secondary-nav {
background: var(--color-secondary-nav-bg) !important; /* important because of .ui.secondary.menu */
}
.issue-navbar {
display: flex;
justify-content: space-between;
}

View File

@ -2437,6 +2437,7 @@ tbody.commit-list {
#repo-topics .repo-topic {
font-weight: var(--font-weight-normal);
cursor: pointer;
margin: 0;
}
#new-dependency-drop-list.ui.selection.dropdown {

View File

@ -6474,750 +6474,6 @@ select.ui.dropdown {
Theme Overrides
*******************************/
/*******************************
Site Overrides
*******************************/
/*!
* # Fomantic-UI - Input
* http://github.com/fomantic/Fomantic-UI/
*
*
* Released under the MIT license
* http://opensource.org/licenses/MIT
*
*/
/*******************************
Standard
*******************************/
/*--------------------
Inputs
---------------------*/
.ui.input {
position: relative;
font-weight: normal;
font-style: normal;
display: inline-flex;
color: rgba(0, 0, 0, 0.87);
}
.ui.input > input {
margin: 0;
max-width: 100%;
flex: 1 0 auto;
outline: none;
-webkit-tap-highlight-color: rgba(255, 255, 255, 0);
text-align: left;
line-height: 1.21428571em;
font-family: var(--fonts-regular);
padding: 0.67857143em 1em;
background: #FFFFFF;
border: 1px solid rgba(34, 36, 38, 0.15);
color: rgba(0, 0, 0, 0.87);
border-radius: 0.28571429rem;
transition: box-shadow 0.1s ease, border-color 0.1s ease;
box-shadow: none;
}
/*--------------------
Placeholder
---------------------*/
/* browsers require these rules separate */
.ui.input > input::-webkit-input-placeholder {
color: rgba(191, 191, 191, 0.87);
}
.ui.input > input::-moz-placeholder {
color: rgba(191, 191, 191, 0.87);
}
.ui.input > input:-ms-input-placeholder {
color: rgba(191, 191, 191, 0.87);
}
/*******************************
States
*******************************/
/*--------------------
Disabled
---------------------*/
.ui.disabled.input,
.ui.input:not(.disabled) input[disabled] {
opacity: var(--opacity-disabled);
}
.ui.disabled.input > input,
.ui.input:not(.disabled) input[disabled] {
pointer-events: none;
}
/*--------------------
Active
---------------------*/
.ui.input > input:active,
.ui.input.down input {
border-color: rgba(0, 0, 0, 0.3);
background: #FAFAFA;
color: rgba(0, 0, 0, 0.87);
box-shadow: none;
}
/*--------------------
Loading
---------------------*/
.ui.loading.loading.input > i.icon:before {
position: absolute;
content: '';
top: 50%;
left: 50%;
margin: -0.64285714em 0 0 -0.64285714em;
width: 1.28571429em;
height: 1.28571429em;
border-radius: 500rem;
border: 0.2em solid rgba(0, 0, 0, 0.1);
}
.ui.loading.loading.input > i.icon:after {
position: absolute;
content: '';
top: 50%;
left: 50%;
margin: -0.64285714em 0 0 -0.64285714em;
width: 1.28571429em;
height: 1.28571429em;
animation: loader 0.6s infinite linear;
border: 0.2em solid #767676;
border-radius: 500rem;
box-shadow: 0 0 0 1px transparent;
}
/*--------------------
Focus
---------------------*/
.ui.input.focus > input,
.ui.input > input:focus {
border-color: #85B7D9;
background: #FFFFFF;
color: rgba(0, 0, 0, 0.8);
box-shadow: none;
}
.ui.input.focus > input::-webkit-input-placeholder,
.ui.input > input:focus::-webkit-input-placeholder {
color: rgba(115, 115, 115, 0.87);
}
.ui.input.focus > input::-moz-placeholder,
.ui.input > input:focus::-moz-placeholder {
color: rgba(115, 115, 115, 0.87);
}
.ui.input.focus > input:-ms-input-placeholder,
.ui.input > input:focus:-ms-input-placeholder {
color: rgba(115, 115, 115, 0.87);
}
/*--------------------
States
---------------------*/
.ui.input.error > input {
background-color: #FFF6F6;
border-color: #E0B4B4;
color: #9F3A38;
box-shadow: none;
}
/* Placeholder */
.ui.input.error > input::-webkit-input-placeholder {
color: #e7bdbc;
}
.ui.input.error > input::-moz-placeholder {
color: #e7bdbc;
}
.ui.input.error > input:-ms-input-placeholder {
color: #e7bdbc !important;
}
/* Focused Placeholder */
.ui.input.error > input:focus::-webkit-input-placeholder {
color: #da9796;
}
.ui.input.error > input:focus::-moz-placeholder {
color: #da9796;
}
.ui.input.error > input:focus:-ms-input-placeholder {
color: #da9796 !important;
}
.ui.input.info > input {
background-color: #F8FFFF;
border-color: #A9D5DE;
color: #276F86;
box-shadow: none;
}
/* Placeholder */
.ui.input.info > input::-webkit-input-placeholder {
color: #98cfe1;
}
.ui.input.info > input::-moz-placeholder {
color: #98cfe1;
}
.ui.input.info > input:-ms-input-placeholder {
color: #98cfe1 !important;
}
/* Focused Placeholder */
.ui.input.info > input:focus::-webkit-input-placeholder {
color: #70bdd6;
}
.ui.input.info > input:focus::-moz-placeholder {
color: #70bdd6;
}
.ui.input.info > input:focus:-ms-input-placeholder {
color: #70bdd6 !important;
}
.ui.input.success > input {
background-color: #FCFFF5;
border-color: #A3C293;
color: #2C662D;
box-shadow: none;
}
/* Placeholder */
.ui.input.success > input::-webkit-input-placeholder {
color: #8fcf90;
}
.ui.input.success > input::-moz-placeholder {
color: #8fcf90;
}
.ui.input.success > input:-ms-input-placeholder {
color: #8fcf90 !important;
}
/* Focused Placeholder */
.ui.input.success > input:focus::-webkit-input-placeholder {
color: #6cbf6d;
}
.ui.input.success > input:focus::-moz-placeholder {
color: #6cbf6d;
}
.ui.input.success > input:focus:-ms-input-placeholder {
color: #6cbf6d !important;
}
.ui.input.warning > input {
background-color: #FFFAF3;
border-color: #C9BA9B;
color: #573A08;
box-shadow: none;
}
/* Placeholder */
.ui.input.warning > input::-webkit-input-placeholder {
color: #edad3e;
}
.ui.input.warning > input::-moz-placeholder {
color: #edad3e;
}
.ui.input.warning > input:-ms-input-placeholder {
color: #edad3e !important;
}
/* Focused Placeholder */
.ui.input.warning > input:focus::-webkit-input-placeholder {
color: #e39715;
}
.ui.input.warning > input:focus::-moz-placeholder {
color: #e39715;
}
.ui.input.warning > input:focus:-ms-input-placeholder {
color: #e39715 !important;
}
/*******************************
Variations
*******************************/
/*--------------------
Transparent
---------------------*/
.ui.transparent.input > textarea,
.ui.transparent.input > input {
border-color: transparent !important;
background-color: transparent !important;
padding: 0;
box-shadow: none !important;
border-radius: 0 !important;
}
.field .ui.transparent.input > textarea {
padding: 0.67857143em 1em;
}
/* Transparent Icon */
:not(.field) > .ui.transparent.icon.input > i.icon {
width: 1.1em;
}
:not(.field) > .ui.ui.ui.transparent.icon.input > input {
padding-left: 0;
padding-right: 2em;
}
:not(.field) > .ui.ui.ui.transparent[class*="left icon"].input > input {
padding-left: 2em;
padding-right: 0;
}
/*--------------------
Icon
---------------------*/
.ui.icon.input > i.icon {
cursor: default;
position: absolute;
line-height: 1;
text-align: center;
top: 0;
right: 0;
margin: 0;
height: 100%;
width: 2.67142857em;
opacity: 0.5;
border-radius: 0 0.28571429rem 0.28571429rem 0;
transition: opacity 0.3s ease;
}
.ui.icon.input > i.icon:not(.link) {
pointer-events: none;
}
.ui.ui.ui.ui.icon.input > textarea,
.ui.ui.ui.ui.icon.input > input {
padding-right: 2.67142857em;
}
.ui.icon.input > i.icon:before,
.ui.icon.input > i.icon:after {
left: 0;
position: absolute;
text-align: center;
top: 50%;
width: 100%;
margin-top: -0.5em;
}
.ui.icon.input > i.link.icon {
cursor: pointer;
}
.ui.icon.input > i.circular.icon {
top: 0.35em;
right: 0.5em;
}
/* Left Icon Input */
.ui[class*="left icon"].input > i.icon {
right: auto;
left: 1px;
border-radius: 0.28571429rem 0 0 0.28571429rem;
}
.ui[class*="left icon"].input > i.circular.icon {
right: auto;
left: 0.5em;
}
.ui.ui.ui.ui[class*="left icon"].input > textarea,
.ui.ui.ui.ui[class*="left icon"].input > input {
padding-left: 2.67142857em;
padding-right: 1em;
}
/* Focus */
.ui.icon.input > textarea:focus ~ i.icon,
.ui.icon.input > input:focus ~ i.icon {
opacity: 1;
}
/*--------------------
Labeled
---------------------*/
/* Adjacent Label */
.ui.labeled.input > .label {
flex: 0 0 auto;
margin: 0;
font-size: 1em;
}
.ui.labeled.input > .label:not(.corner) {
padding-top: 0.78571429em;
padding-bottom: 0.78571429em;
}
/* Regular Label on Left */
.ui.labeled.input:not([class*="corner labeled"]) .label:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.ui.labeled.input:not([class*="corner labeled"]) .label:first-child + input {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left-color: transparent;
}
.ui.labeled.input:not([class*="corner labeled"]) .label:first-child + input:focus {
border-left-color: #85B7D9;
}
/* Regular Label on Right */
.ui[class*="right labeled"].input > input {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
border-right-color: transparent !important;
}
.ui[class*="right labeled"].input > input + .label {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.ui[class*="right labeled"].input > input:focus {
border-right-color: #85B7D9 !important;
}
/* Corner Label */
.ui.labeled.input .corner.label {
top: 1px;
right: 1px;
font-size: 0.64285714em;
border-radius: 0 0.28571429rem 0 0;
}
/* Spacing with corner label */
.ui[class*="corner labeled"]:not([class*="left corner labeled"]).labeled.input > textarea,
.ui[class*="corner labeled"]:not([class*="left corner labeled"]).labeled.input > input {
padding-right: 2.5em !important;
}
.ui[class*="corner labeled"].icon.input:not([class*="left corner labeled"]) > textarea,
.ui[class*="corner labeled"].icon.input:not([class*="left corner labeled"]) > input {
padding-right: 3.25em !important;
}
.ui[class*="corner labeled"].icon.input:not([class*="left corner labeled"]) > i.icon {
margin-right: 1.25em;
}
/* Left Labeled */
.ui[class*="left corner labeled"].labeled.input > textarea,
.ui[class*="left corner labeled"].labeled.input > input {
padding-left: 2.5em !important;
}
.ui[class*="left corner labeled"].icon.input > textarea,
.ui[class*="left corner labeled"].icon.input > input {
padding-left: 3.25em !important;
}
.ui[class*="left corner labeled"].icon.input > i.icon {
margin-left: 1.25em;
}
.ui.icon.input > textarea ~ i.icon {
height: 3em;
}
:not(.field) > .ui.transparent.icon.input > textarea ~ i.icon {
height: 1.3em;
}
/* Corner Label Position */
.ui.input > .ui.corner.label {
top: 1px;
right: 1px;
}
.ui.input > .ui.left.corner.label {
right: auto;
left: 1px;
}
/* Labeled and action input states */
.ui.form .field.error > .ui.action.input > .ui.button,
.ui.form .field.error > .ui.labeled.input:not([class*="corner labeled"]) > .ui.label,
.ui.action.input.error > .ui.button,
.ui.labeled.input.error:not([class*="corner labeled"]) > .ui.label {
border-top: 1px solid #E0B4B4;
border-bottom: 1px solid #E0B4B4;
}
.ui.form .field.error > .ui[class*="left action"].input > .ui.button,
.ui.form .field.error > .ui.labeled.input:not(.right):not([class*="corner labeled"]) > .ui.label,
.ui[class*="left action"].input.error > .ui.button,
.ui.labeled.input.error:not(.right):not([class*="corner labeled"]) > .ui.label {
border-left: 1px solid #E0B4B4;
}
.ui.form .field.error > .ui.action.input:not([class*="left action"]) > input + .ui.button,
.ui.form .field.error > .ui.right.labeled.input:not([class*="corner labeled"]) > input + .ui.label,
.ui.action.input.error:not([class*="left action"]) > input + .ui.button,
.ui.right.labeled.input.error:not([class*="corner labeled"]) > input + .ui.label {
border-right: 1px solid #E0B4B4;
}
.ui.form .field.error > .ui.right.labeled.input:not([class*="corner labeled"]) > .ui.label:first-child,
.ui.right.labeled.input.error:not([class*="corner labeled"]) > .ui.label:first-child {
border-left: 1px solid #E0B4B4;
}
.ui.form .field.info > .ui.action.input > .ui.button,
.ui.form .field.info > .ui.labeled.input:not([class*="corner labeled"]) > .ui.label,
.ui.action.input.info > .ui.button,
.ui.labeled.input.info:not([class*="corner labeled"]) > .ui.label {
border-top: 1px solid #A9D5DE;
border-bottom: 1px solid #A9D5DE;
}
.ui.form .field.info > .ui[class*="left action"].input > .ui.button,
.ui.form .field.info > .ui.labeled.input:not(.right):not([class*="corner labeled"]) > .ui.label,
.ui[class*="left action"].input.info > .ui.button,
.ui.labeled.input.info:not(.right):not([class*="corner labeled"]) > .ui.label {
border-left: 1px solid #A9D5DE;
}
.ui.form .field.info > .ui.action.input:not([class*="left action"]) > input + .ui.button,
.ui.form .field.info > .ui.right.labeled.input:not([class*="corner labeled"]) > input + .ui.label,
.ui.action.input.info:not([class*="left action"]) > input + .ui.button,
.ui.right.labeled.input.info:not([class*="corner labeled"]) > input + .ui.label {
border-right: 1px solid #A9D5DE;
}
.ui.form .field.info > .ui.right.labeled.input:not([class*="corner labeled"]) > .ui.label:first-child,
.ui.right.labeled.input.info:not([class*="corner labeled"]) > .ui.label:first-child {
border-left: 1px solid #A9D5DE;
}
.ui.form .field.success > .ui.action.input > .ui.button,
.ui.form .field.success > .ui.labeled.input:not([class*="corner labeled"]) > .ui.label,
.ui.action.input.success > .ui.button,
.ui.labeled.input.success:not([class*="corner labeled"]) > .ui.label {
border-top: 1px solid #A3C293;
border-bottom: 1px solid #A3C293;
}
.ui.form .field.success > .ui[class*="left action"].input > .ui.button,
.ui.form .field.success > .ui.labeled.input:not(.right):not([class*="corner labeled"]) > .ui.label,
.ui[class*="left action"].input.success > .ui.button,
.ui.labeled.input.success:not(.right):not([class*="corner labeled"]) > .ui.label {
border-left: 1px solid #A3C293;
}
.ui.form .field.success > .ui.action.input:not([class*="left action"]) > input + .ui.button,
.ui.form .field.success > .ui.right.labeled.input:not([class*="corner labeled"]) > input + .ui.label,
.ui.action.input.success:not([class*="left action"]) > input + .ui.button,
.ui.right.labeled.input.success:not([class*="corner labeled"]) > input + .ui.label {
border-right: 1px solid #A3C293;
}
.ui.form .field.success > .ui.right.labeled.input:not([class*="corner labeled"]) > .ui.label:first-child,
.ui.right.labeled.input.success:not([class*="corner labeled"]) > .ui.label:first-child {
border-left: 1px solid #A3C293;
}
.ui.form .field.warning > .ui.action.input > .ui.button,
.ui.form .field.warning > .ui.labeled.input:not([class*="corner labeled"]) > .ui.label,
.ui.action.input.warning > .ui.button,
.ui.labeled.input.warning:not([class*="corner labeled"]) > .ui.label {
border-top: 1px solid #C9BA9B;
border-bottom: 1px solid #C9BA9B;
}
.ui.form .field.warning > .ui[class*="left action"].input > .ui.button,
.ui.form .field.warning > .ui.labeled.input:not(.right):not([class*="corner labeled"]) > .ui.label,
.ui[class*="left action"].input.warning > .ui.button,
.ui.labeled.input.warning:not(.right):not([class*="corner labeled"]) > .ui.label {
border-left: 1px solid #C9BA9B;
}
.ui.form .field.warning > .ui.action.input:not([class*="left action"]) > input + .ui.button,
.ui.form .field.warning > .ui.right.labeled.input:not([class*="corner labeled"]) > input + .ui.label,
.ui.action.input.warning:not([class*="left action"]) > input + .ui.button,
.ui.right.labeled.input.warning:not([class*="corner labeled"]) > input + .ui.label {
border-right: 1px solid #C9BA9B;
}
.ui.form .field.warning > .ui.right.labeled.input:not([class*="corner labeled"]) > .ui.label:first-child,
.ui.right.labeled.input.warning:not([class*="corner labeled"]) > .ui.label:first-child {
border-left: 1px solid #C9BA9B;
}
/*--------------------
Action
---------------------*/
.ui.action.input > .button,
.ui.action.input > .buttons {
display: flex;
align-items: center;
flex: 0 0 auto;
}
.ui.action.input > .button,
.ui.action.input > .buttons > .button {
padding-top: 0.78571429em;
padding-bottom: 0.78571429em;
margin: 0;
}
/* Input when ui Left*/
.ui[class*="left action"].input > input {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left-color: transparent;
}
/* Input when ui Right*/
.ui.action.input:not([class*="left action"]) > input {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right-color: transparent;
}
/* Button and Dropdown */
.ui.action.input > .dropdown:first-child,
.ui.action.input > .button:first-child,
.ui.action.input > .buttons:first-child > .button {
border-radius: 0.28571429rem 0 0 0.28571429rem;
}
.ui.action.input > .dropdown:not(:first-child),
.ui.action.input > .button:not(:first-child),
.ui.action.input > .buttons:not(:first-child) > .button {
border-radius: 0;
}
.ui.action.input > .dropdown:last-child,
.ui.action.input > .button:last-child,
.ui.action.input > .buttons:last-child > .button {
border-radius: 0 0.28571429rem 0.28571429rem 0;
}
/* Input Focus */
.ui.action.input:not([class*="left action"]) > input:focus {
border-right-color: #85B7D9;
}
.ui.ui[class*="left action"].input > input:focus {
border-left-color: #85B7D9;
}
/*--------------------
Fluid
---------------------*/
.ui.fluid.input {
display: flex;
}
.ui.fluid.input > input {
width: 0 !important;
}
/*--------------------
Size
---------------------*/
.ui.input {
font-size: 1em;
}
.ui.mini.input {
font-size: 0.78571429em;
}
.ui.tiny.input {
font-size: 0.85714286em;
}
.ui.small.input {
font-size: 0.92857143em;
}
.ui.large.input {
font-size: 1.14285714em;
}
.ui.big.input {
font-size: 1.28571429em;
}
.ui.huge.input {
font-size: 1.42857143em;
}
.ui.massive.input {
font-size: 1.71428571em;
}
/*******************************
Theme Overrides
*******************************/
/*******************************
Site Overrides
*******************************/

View File

@ -26,7 +26,6 @@
"dimmer",
"dropdown",
"form",
"input",
"list",
"menu",
"modal",

Some files were not shown because too many files have changed in this diff Show More