Merge remote-tracking branch 'origin/main' into forgejo-federation
This commit is contained in:
commit
dc20c28328
138
.drone.yml
138
.drone.yml
|
@ -985,7 +985,10 @@ depends_on:
|
|||
|
||||
trigger:
|
||||
ref:
|
||||
- "refs/tags/**"
|
||||
include:
|
||||
- "refs/tags/**"
|
||||
exclude:
|
||||
- "refs/tags/**-rc*"
|
||||
event:
|
||||
exclude:
|
||||
- cron
|
||||
|
@ -1033,6 +1036,68 @@ steps:
|
|||
event:
|
||||
exclude:
|
||||
- pull_request
|
||||
---
|
||||
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: docker-linux-amd64-release-candidate-version
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: amd64
|
||||
|
||||
depends_on:
|
||||
- testing-amd64
|
||||
- testing-arm64
|
||||
|
||||
trigger:
|
||||
ref:
|
||||
- "refs/tags/**-rc*"
|
||||
event:
|
||||
exclude:
|
||||
- cron
|
||||
|
||||
steps:
|
||||
- name: fetch-tags
|
||||
image: docker:git
|
||||
pull: always
|
||||
commands:
|
||||
- git config --global --add safe.directory /drone/src
|
||||
- git fetch --tags --force
|
||||
|
||||
- name: publish
|
||||
image: techknowlogick/drone-docker:latest
|
||||
pull: always
|
||||
settings:
|
||||
tags: ${DRONE_TAG##v}-linux-amd64
|
||||
repo: gitea/gitea
|
||||
build_args:
|
||||
- GOPROXY=https://goproxy.io
|
||||
password:
|
||||
from_secret: docker_password
|
||||
username:
|
||||
from_secret: docker_username
|
||||
when:
|
||||
event:
|
||||
exclude:
|
||||
- pull_request
|
||||
|
||||
- name: publish-rootless
|
||||
image: techknowlogick/drone-docker:latest
|
||||
settings:
|
||||
dockerfile: Dockerfile.rootless
|
||||
tags: ${DRONE_TAG##v}-linux-amd64-rootless
|
||||
repo: gitea/gitea
|
||||
build_args:
|
||||
- GOPROXY=https://goproxy.io
|
||||
password:
|
||||
from_secret: docker_password
|
||||
username:
|
||||
from_secret: docker_username
|
||||
when:
|
||||
event:
|
||||
exclude:
|
||||
- pull_request
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
|
@ -1209,7 +1274,10 @@ depends_on:
|
|||
|
||||
trigger:
|
||||
ref:
|
||||
- "refs/tags/**"
|
||||
include:
|
||||
- "refs/tags/**"
|
||||
exclude:
|
||||
- "refs/tags/**-rc*"
|
||||
event:
|
||||
exclude:
|
||||
- cron
|
||||
|
@ -1258,6 +1326,68 @@ steps:
|
|||
exclude:
|
||||
- pull_request
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: docker-linux-arm64-release-candidate-version
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: arm64
|
||||
|
||||
depends_on:
|
||||
- testing-amd64
|
||||
- testing-arm64
|
||||
|
||||
trigger:
|
||||
ref:
|
||||
- "refs/tags/**-rc*"
|
||||
event:
|
||||
exclude:
|
||||
- cron
|
||||
|
||||
steps:
|
||||
- name: fetch-tags
|
||||
image: docker:git
|
||||
pull: always
|
||||
commands:
|
||||
- git config --global --add safe.directory /drone/src
|
||||
- git fetch --tags --force
|
||||
|
||||
- name: publish
|
||||
image: techknowlogick/drone-docker:latest
|
||||
pull: always
|
||||
settings:
|
||||
tags: ${DRONE_TAG##v}-linux-arm64
|
||||
repo: gitea/gitea
|
||||
build_args:
|
||||
- GOPROXY=https://goproxy.io
|
||||
password:
|
||||
from_secret: docker_password
|
||||
username:
|
||||
from_secret: docker_username
|
||||
when:
|
||||
event:
|
||||
exclude:
|
||||
- pull_request
|
||||
|
||||
- name: publish-rootless
|
||||
image: techknowlogick/drone-docker:latest
|
||||
settings:
|
||||
dockerfile: Dockerfile.rootless
|
||||
tags: ${DRONE_TAG##v}-linux-arm64-rootless
|
||||
repo: gitea/gitea
|
||||
build_args:
|
||||
- GOPROXY=https://goproxy.io
|
||||
password:
|
||||
from_secret: docker_password
|
||||
username:
|
||||
from_secret: docker_username
|
||||
when:
|
||||
event:
|
||||
exclude:
|
||||
- pull_request
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
|
@ -1427,7 +1557,9 @@ trigger:
|
|||
|
||||
depends_on:
|
||||
- docker-linux-amd64-release-version
|
||||
- docker-linux-amd64-release-candidate-version
|
||||
- docker-linux-arm64-release-version
|
||||
- docker-linux-arm64-release-candidate-version
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
|
@ -1509,6 +1641,8 @@ depends_on:
|
|||
- docker-linux-arm64-release
|
||||
- docker-linux-amd64-release-version
|
||||
- docker-linux-arm64-release-version
|
||||
- docker-linux-amd64-release-candidate-version
|
||||
- docker-linux-arm64-release-candidate-version
|
||||
- docker-linux-amd64-release-branch
|
||||
- docker-linux-arm64-release-branch
|
||||
- docker-manifest
|
||||
|
|
|
@ -149,7 +149,7 @@ rules:
|
|||
jquery/no-global-eval: [2]
|
||||
jquery/no-grep: [2]
|
||||
jquery/no-has: [2]
|
||||
jquery/no-hide: [0]
|
||||
jquery/no-hide: [2]
|
||||
jquery/no-html: [0]
|
||||
jquery/no-in-array: [2]
|
||||
jquery/no-is-array: [2]
|
||||
|
@ -166,13 +166,13 @@ rules:
|
|||
jquery/no-proxy: [2]
|
||||
jquery/no-ready: [0]
|
||||
jquery/no-serialize: [2]
|
||||
jquery/no-show: [0]
|
||||
jquery/no-show: [2]
|
||||
jquery/no-size: [2]
|
||||
jquery/no-sizzle: [0]
|
||||
jquery/no-slide: [0]
|
||||
jquery/no-submit: [0]
|
||||
jquery/no-text: [0]
|
||||
jquery/no-toggle: [0]
|
||||
jquery/no-toggle: [2]
|
||||
jquery/no-trigger: [0]
|
||||
jquery/no-trim: [2]
|
||||
jquery/no-val: [0]
|
||||
|
|
|
@ -28,7 +28,7 @@ linters:
|
|||
fast: false
|
||||
|
||||
run:
|
||||
go: 1.20
|
||||
go: "1.20"
|
||||
timeout: 10m
|
||||
skip-dirs:
|
||||
- node_modules
|
||||
|
|
|
@ -6,7 +6,6 @@ Kim Carlbäcker <kim.carlbacker@gmail.com> (@bkcsoft)
|
|||
LefsFlare <nobody@nobody.tld> (@LefsFlarey)
|
||||
Lunny Xiao <xiaolunwen@gmail.com> (@lunny)
|
||||
Matthias Loibl <mail@matthiasloibl.com> (@metalmatze)
|
||||
Morgan Bazalgette <the@howl.moe> (@thehowl)
|
||||
Rachid Zarouali <nobody@nobody.tld> (@xinity)
|
||||
Rémy Boulanouar <admin@dblk.org> (@DblK)
|
||||
Sandro Santilli <strk@kbt.io> (@strk)
|
||||
|
|
406
cmd/admin.go
406
cmd/admin.go
|
@ -5,7 +5,6 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
@ -16,20 +15,15 @@ import (
|
|||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/graceful"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
pwd "code.gitea.io/gitea/modules/password"
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
auth_service "code.gitea.io/gitea/services/auth"
|
||||
"code.gitea.io/gitea/services/auth/source/oauth2"
|
||||
"code.gitea.io/gitea/services/auth/source/smtp"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
user_service "code.gitea.io/gitea/services/user"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
@ -48,147 +42,6 @@ var (
|
|||
},
|
||||
}
|
||||
|
||||
subcmdUser = cli.Command{
|
||||
Name: "user",
|
||||
Usage: "Modify users",
|
||||
Subcommands: []cli.Command{
|
||||
microcmdUserCreate,
|
||||
microcmdUserList,
|
||||
microcmdUserChangePassword,
|
||||
microcmdUserDelete,
|
||||
microcmdUserGenerateAccessToken,
|
||||
},
|
||||
}
|
||||
|
||||
microcmdUserList = cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List users",
|
||||
Action: runListUsers,
|
||||
Flags: []cli.Flag{
|
||||
cli.BoolFlag{
|
||||
Name: "admin",
|
||||
Usage: "List only admin users",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
microcmdUserCreate = cli.Command{
|
||||
Name: "create",
|
||||
Usage: "Create a new user in database",
|
||||
Action: runCreateUser,
|
||||
Flags: []cli.Flag{
|
||||
cli.StringFlag{
|
||||
Name: "name",
|
||||
Usage: "Username. DEPRECATED: use username instead",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "username",
|
||||
Usage: "Username",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "password",
|
||||
Usage: "User password",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "email",
|
||||
Usage: "User email address",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "admin",
|
||||
Usage: "User is an admin",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "random-password",
|
||||
Usage: "Generate a random password for the user",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "must-change-password",
|
||||
Usage: "Set this option to false to prevent forcing the user to change their password after initial login, (Default: true)",
|
||||
},
|
||||
cli.IntFlag{
|
||||
Name: "random-password-length",
|
||||
Usage: "Length of the random password to be generated",
|
||||
Value: 12,
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "access-token",
|
||||
Usage: "Generate access token for the user",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "restricted",
|
||||
Usage: "Make a restricted user account",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
microcmdUserChangePassword = cli.Command{
|
||||
Name: "change-password",
|
||||
Usage: "Change a user's password",
|
||||
Action: runChangePassword,
|
||||
Flags: []cli.Flag{
|
||||
cli.StringFlag{
|
||||
Name: "username,u",
|
||||
Value: "",
|
||||
Usage: "The user to change password for",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "password,p",
|
||||
Value: "",
|
||||
Usage: "New password to set for user",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
microcmdUserDelete = cli.Command{
|
||||
Name: "delete",
|
||||
Usage: "Delete specific user by id, name or email",
|
||||
Flags: []cli.Flag{
|
||||
cli.Int64Flag{
|
||||
Name: "id",
|
||||
Usage: "ID of user of the user to delete",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "username,u",
|
||||
Usage: "Username of the user to delete",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "email,e",
|
||||
Usage: "Email of the user to delete",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "purge",
|
||||
Usage: "Purge user, all their repositories, organizations and comments",
|
||||
},
|
||||
},
|
||||
Action: runDeleteUser,
|
||||
}
|
||||
|
||||
microcmdUserGenerateAccessToken = cli.Command{
|
||||
Name: "generate-access-token",
|
||||
Usage: "Generate a access token for a specific user",
|
||||
Flags: []cli.Flag{
|
||||
cli.StringFlag{
|
||||
Name: "username,u",
|
||||
Usage: "Username",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "token-name,t",
|
||||
Usage: "Token name",
|
||||
Value: "gitea-admin",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "raw",
|
||||
Usage: "Display only the token value",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "scopes",
|
||||
Value: "",
|
||||
Usage: "Comma separated list of scopes to apply to access token",
|
||||
},
|
||||
},
|
||||
Action: runGenerateAccessToken,
|
||||
}
|
||||
|
||||
subcmdRepoSyncReleases = cli.Command{
|
||||
Name: "repo-sync-releases",
|
||||
Usage: "Synchronize repository releases with tags",
|
||||
|
@ -486,265 +339,6 @@ var (
|
|||
}
|
||||
)
|
||||
|
||||
func runChangePassword(c *cli.Context) error {
|
||||
if err := argsSet(c, "username", "password"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := installSignals()
|
||||
defer cancel()
|
||||
|
||||
if err := initDB(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(c.String("password")) < setting.MinPasswordLength {
|
||||
return fmt.Errorf("Password is not long enough. Needs to be at least %d", setting.MinPasswordLength)
|
||||
}
|
||||
|
||||
if !pwd.IsComplexEnough(c.String("password")) {
|
||||
return errors.New("Password does not meet complexity requirements")
|
||||
}
|
||||
pwned, err := pwd.IsPwned(context.Background(), c.String("password"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pwned {
|
||||
return errors.New("The password you chose is on a list of stolen passwords previously exposed in public data breaches. Please try again with a different password.\nFor more details, see https://haveibeenpwned.com/Passwords")
|
||||
}
|
||||
uname := c.String("username")
|
||||
user, err := user_model.GetUserByName(ctx, uname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = user.SetPassword(c.String("password")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = user_model.UpdateUserCols(ctx, user, "passwd", "passwd_hash_algo", "salt"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("%s's password has been successfully updated!\n", user.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCreateUser(c *cli.Context) error {
|
||||
if err := argsSet(c, "email"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.IsSet("name") && c.IsSet("username") {
|
||||
return errors.New("Cannot set both --name and --username flags")
|
||||
}
|
||||
if !c.IsSet("name") && !c.IsSet("username") {
|
||||
return errors.New("One of --name or --username flags must be set")
|
||||
}
|
||||
|
||||
if c.IsSet("password") && c.IsSet("random-password") {
|
||||
return errors.New("cannot set both -random-password and -password flags")
|
||||
}
|
||||
|
||||
var username string
|
||||
if c.IsSet("username") {
|
||||
username = c.String("username")
|
||||
} else {
|
||||
username = c.String("name")
|
||||
fmt.Fprintf(os.Stderr, "--name flag is deprecated. Use --username instead.\n")
|
||||
}
|
||||
|
||||
ctx, cancel := installSignals()
|
||||
defer cancel()
|
||||
|
||||
if err := initDB(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var password string
|
||||
if c.IsSet("password") {
|
||||
password = c.String("password")
|
||||
} else if c.IsSet("random-password") {
|
||||
var err error
|
||||
password, err = pwd.Generate(c.Int("random-password-length"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("generated random password is '%s'\n", password)
|
||||
} else {
|
||||
return errors.New("must set either password or random-password flag")
|
||||
}
|
||||
|
||||
// always default to true
|
||||
changePassword := true
|
||||
|
||||
// If this is the first user being created.
|
||||
// Take it as the admin and don't force a password update.
|
||||
if n := user_model.CountUsers(nil); n == 0 {
|
||||
changePassword = false
|
||||
}
|
||||
|
||||
if c.IsSet("must-change-password") {
|
||||
changePassword = c.Bool("must-change-password")
|
||||
}
|
||||
|
||||
restricted := util.OptionalBoolNone
|
||||
|
||||
if c.IsSet("restricted") {
|
||||
restricted = util.OptionalBoolOf(c.Bool("restricted"))
|
||||
}
|
||||
|
||||
// default user visibility in app.ini
|
||||
visibility := setting.Service.DefaultUserVisibilityMode
|
||||
|
||||
u := &user_model.User{
|
||||
Name: username,
|
||||
Email: c.String("email"),
|
||||
Passwd: password,
|
||||
IsAdmin: c.Bool("admin"),
|
||||
MustChangePassword: changePassword,
|
||||
Visibility: visibility,
|
||||
}
|
||||
|
||||
overwriteDefault := &user_model.CreateUserOverwriteOptions{
|
||||
IsActive: util.OptionalBoolTrue,
|
||||
IsRestricted: restricted,
|
||||
}
|
||||
|
||||
if err := user_model.CreateUser(u, overwriteDefault); err != nil {
|
||||
return fmt.Errorf("CreateUser: %w", err)
|
||||
}
|
||||
|
||||
if c.Bool("access-token") {
|
||||
t := &auth_model.AccessToken{
|
||||
Name: "gitea-admin",
|
||||
UID: u.ID,
|
||||
}
|
||||
|
||||
if err := auth_model.NewAccessToken(t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Access token was successfully created... %s\n", t.Token)
|
||||
}
|
||||
|
||||
fmt.Printf("New user '%s' has been successfully created!\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runListUsers(c *cli.Context) error {
|
||||
ctx, cancel := installSignals()
|
||||
defer cancel()
|
||||
|
||||
if err := initDB(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
users, err := user_model.GetAllUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 5, 0, 1, ' ', 0)
|
||||
|
||||
if c.IsSet("admin") {
|
||||
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\n")
|
||||
for _, u := range users {
|
||||
if u.IsAdmin {
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%t\n", u.ID, u.Name, u.Email, u.IsActive)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
twofa := user_model.UserList(users).GetTwoFaStatus()
|
||||
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tIsAdmin\t2FA\n")
|
||||
for _, u := range users {
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%t\t%t\n", u.ID, u.Name, u.Email, u.IsActive, u.IsAdmin, twofa[u.ID])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDeleteUser(c *cli.Context) error {
|
||||
if !c.IsSet("id") && !c.IsSet("username") && !c.IsSet("email") {
|
||||
return fmt.Errorf("You must provide the id, username or email of a user to delete")
|
||||
}
|
||||
|
||||
ctx, cancel := installSignals()
|
||||
defer cancel()
|
||||
|
||||
if err := initDB(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := storage.Init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var err error
|
||||
var user *user_model.User
|
||||
if c.IsSet("email") {
|
||||
user, err = user_model.GetUserByEmail(c.String("email"))
|
||||
} else if c.IsSet("username") {
|
||||
user, err = user_model.GetUserByName(ctx, c.String("username"))
|
||||
} else {
|
||||
user, err = user_model.GetUserByID(ctx, c.Int64("id"))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.IsSet("username") && user.LowerName != strings.ToLower(strings.TrimSpace(c.String("username"))) {
|
||||
return fmt.Errorf("The user %s who has email %s does not match the provided username %s", user.Name, c.String("email"), c.String("username"))
|
||||
}
|
||||
|
||||
if c.IsSet("id") && user.ID != c.Int64("id") {
|
||||
return fmt.Errorf("The user %s does not match the provided id %d", user.Name, c.Int64("id"))
|
||||
}
|
||||
|
||||
return user_service.DeleteUser(ctx, user, c.Bool("purge"))
|
||||
}
|
||||
|
||||
func runGenerateAccessToken(c *cli.Context) error {
|
||||
if !c.IsSet("username") {
|
||||
return fmt.Errorf("You must provide the username to generate a token for them")
|
||||
}
|
||||
|
||||
ctx, cancel := installSignals()
|
||||
defer cancel()
|
||||
|
||||
if err := initDB(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := user_model.GetUserByName(ctx, c.String("username"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accessTokenScope, err := auth_model.AccessTokenScope(c.String("scopes")).Normalize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t := &auth_model.AccessToken{
|
||||
Name: c.String("token-name"),
|
||||
UID: user.ID,
|
||||
Scope: accessTokenScope,
|
||||
}
|
||||
|
||||
if err := auth_model.NewAccessToken(t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Bool("raw") {
|
||||
fmt.Printf("%s\n", t.Token)
|
||||
} else {
|
||||
fmt.Printf("Access token was successfully created: %s\n", t.Token)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runRepoSyncReleases(_ *cli.Context) error {
|
||||
ctx, cancel := installSignals()
|
||||
defer cancel()
|
||||
|
|
21
cmd/admin_user.go
Normal file
21
cmd/admin_user.go
Normal file
|
@ -0,0 +1,21 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var subcmdUser = cli.Command{
|
||||
Name: "user",
|
||||
Usage: "Modify users",
|
||||
Subcommands: []cli.Command{
|
||||
microcmdUserCreate,
|
||||
microcmdUserList,
|
||||
microcmdUserChangePassword,
|
||||
microcmdUserDelete,
|
||||
microcmdUserGenerateAccessToken,
|
||||
microcmdUserMustChangePassword,
|
||||
},
|
||||
}
|
76
cmd/admin_user_change_password.go
Normal file
76
cmd/admin_user_change_password.go
Normal file
|
@ -0,0 +1,76 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
pwd "code.gitea.io/gitea/modules/auth/password"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var microcmdUserChangePassword = cli.Command{
|
||||
Name: "change-password",
|
||||
Usage: "Change a user's password",
|
||||
Action: runChangePassword,
|
||||
Flags: []cli.Flag{
|
||||
cli.StringFlag{
|
||||
Name: "username,u",
|
||||
Value: "",
|
||||
Usage: "The user to change password for",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "password,p",
|
||||
Value: "",
|
||||
Usage: "New password to set for user",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func runChangePassword(c *cli.Context) error {
|
||||
if err := argsSet(c, "username", "password"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := installSignals()
|
||||
defer cancel()
|
||||
|
||||
if err := initDB(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(c.String("password")) < setting.MinPasswordLength {
|
||||
return fmt.Errorf("Password is not long enough. Needs to be at least %d", setting.MinPasswordLength)
|
||||
}
|
||||
|
||||
if !pwd.IsComplexEnough(c.String("password")) {
|
||||
return errors.New("Password does not meet complexity requirements")
|
||||
}
|
||||
pwned, err := pwd.IsPwned(context.Background(), c.String("password"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pwned {
|
||||
return errors.New("The password you chose is on a list of stolen passwords previously exposed in public data breaches. Please try again with a different password.\nFor more details, see https://haveibeenpwned.com/Passwords")
|
||||
}
|
||||
uname := c.String("username")
|
||||
user, err := user_model.GetUserByName(ctx, uname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = user.SetPassword(c.String("password")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = user_model.UpdateUserCols(ctx, user, "passwd", "passwd_hash_algo", "salt"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("%s's password has been successfully updated!\n", user.Name)
|
||||
return nil
|
||||
}
|
169
cmd/admin_user_create.go
Normal file
169
cmd/admin_user_create.go
Normal file
|
@ -0,0 +1,169 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
pwd "code.gitea.io/gitea/modules/auth/password"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var microcmdUserCreate = cli.Command{
|
||||
Name: "create",
|
||||
Usage: "Create a new user in database",
|
||||
Action: runCreateUser,
|
||||
Flags: []cli.Flag{
|
||||
cli.StringFlag{
|
||||
Name: "name",
|
||||
Usage: "Username. DEPRECATED: use username instead",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "username",
|
||||
Usage: "Username",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "password",
|
||||
Usage: "User password",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "email",
|
||||
Usage: "User email address",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "admin",
|
||||
Usage: "User is an admin",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "random-password",
|
||||
Usage: "Generate a random password for the user",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "must-change-password",
|
||||
Usage: "Set this option to false to prevent forcing the user to change their password after initial login, (Default: true)",
|
||||
},
|
||||
cli.IntFlag{
|
||||
Name: "random-password-length",
|
||||
Usage: "Length of the random password to be generated",
|
||||
Value: 12,
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "access-token",
|
||||
Usage: "Generate access token for the user",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "restricted",
|
||||
Usage: "Make a restricted user account",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func runCreateUser(c *cli.Context) error {
|
||||
if err := argsSet(c, "email"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.IsSet("name") && c.IsSet("username") {
|
||||
return errors.New("Cannot set both --name and --username flags")
|
||||
}
|
||||
if !c.IsSet("name") && !c.IsSet("username") {
|
||||
return errors.New("One of --name or --username flags must be set")
|
||||
}
|
||||
|
||||
if c.IsSet("password") && c.IsSet("random-password") {
|
||||
return errors.New("cannot set both -random-password and -password flags")
|
||||
}
|
||||
|
||||
var username string
|
||||
if c.IsSet("username") {
|
||||
username = c.String("username")
|
||||
} else {
|
||||
username = c.String("name")
|
||||
fmt.Fprintf(os.Stderr, "--name flag is deprecated. Use --username instead.\n")
|
||||
}
|
||||
|
||||
ctx, cancel := installSignals()
|
||||
defer cancel()
|
||||
|
||||
if err := initDB(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var password string
|
||||
if c.IsSet("password") {
|
||||
password = c.String("password")
|
||||
} else if c.IsSet("random-password") {
|
||||
var err error
|
||||
password, err = pwd.Generate(c.Int("random-password-length"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("generated random password is '%s'\n", password)
|
||||
} else {
|
||||
return errors.New("must set either password or random-password flag")
|
||||
}
|
||||
|
||||
// always default to true
|
||||
changePassword := true
|
||||
|
||||
// If this is the first user being created.
|
||||
// Take it as the admin and don't force a password update.
|
||||
if n := user_model.CountUsers(nil); n == 0 {
|
||||
changePassword = false
|
||||
}
|
||||
|
||||
if c.IsSet("must-change-password") {
|
||||
changePassword = c.Bool("must-change-password")
|
||||
}
|
||||
|
||||
restricted := util.OptionalBoolNone
|
||||
|
||||
if c.IsSet("restricted") {
|
||||
restricted = util.OptionalBoolOf(c.Bool("restricted"))
|
||||
}
|
||||
|
||||
// default user visibility in app.ini
|
||||
visibility := setting.Service.DefaultUserVisibilityMode
|
||||
|
||||
u := &user_model.User{
|
||||
Name: username,
|
||||
Email: c.String("email"),
|
||||
Passwd: password,
|
||||
IsAdmin: c.Bool("admin"),
|
||||
MustChangePassword: changePassword,
|
||||
Visibility: visibility,
|
||||
}
|
||||
|
||||
overwriteDefault := &user_model.CreateUserOverwriteOptions{
|
||||
IsActive: util.OptionalBoolTrue,
|
||||
IsRestricted: restricted,
|
||||
}
|
||||
|
||||
if err := user_model.CreateUser(u, overwriteDefault); err != nil {
|
||||
return fmt.Errorf("CreateUser: %w", err)
|
||||
}
|
||||
|
||||
if c.Bool("access-token") {
|
||||
t := &auth_model.AccessToken{
|
||||
Name: "gitea-admin",
|
||||
UID: u.ID,
|
||||
}
|
||||
|
||||
if err := auth_model.NewAccessToken(t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Access token was successfully created... %s\n", t.Token)
|
||||
}
|
||||
|
||||
fmt.Printf("New user '%s' has been successfully created!\n", username)
|
||||
return nil
|
||||
}
|
78
cmd/admin_user_delete.go
Normal file
78
cmd/admin_user_delete.go
Normal file
|
@ -0,0 +1,78 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
user_service "code.gitea.io/gitea/services/user"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var microcmdUserDelete = cli.Command{
|
||||
Name: "delete",
|
||||
Usage: "Delete specific user by id, name or email",
|
||||
Flags: []cli.Flag{
|
||||
cli.Int64Flag{
|
||||
Name: "id",
|
||||
Usage: "ID of user of the user to delete",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "username,u",
|
||||
Usage: "Username of the user to delete",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "email,e",
|
||||
Usage: "Email of the user to delete",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "purge",
|
||||
Usage: "Purge user, all their repositories, organizations and comments",
|
||||
},
|
||||
},
|
||||
Action: runDeleteUser,
|
||||
}
|
||||
|
||||
func runDeleteUser(c *cli.Context) error {
|
||||
if !c.IsSet("id") && !c.IsSet("username") && !c.IsSet("email") {
|
||||
return fmt.Errorf("You must provide the id, username or email of a user to delete")
|
||||
}
|
||||
|
||||
ctx, cancel := installSignals()
|
||||
defer cancel()
|
||||
|
||||
if err := initDB(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := storage.Init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var err error
|
||||
var user *user_model.User
|
||||
if c.IsSet("email") {
|
||||
user, err = user_model.GetUserByEmail(ctx, c.String("email"))
|
||||
} else if c.IsSet("username") {
|
||||
user, err = user_model.GetUserByName(ctx, c.String("username"))
|
||||
} else {
|
||||
user, err = user_model.GetUserByID(ctx, c.Int64("id"))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.IsSet("username") && user.LowerName != strings.ToLower(strings.TrimSpace(c.String("username"))) {
|
||||
return fmt.Errorf("The user %s who has email %s does not match the provided username %s", user.Name, c.String("email"), c.String("username"))
|
||||
}
|
||||
|
||||
if c.IsSet("id") && user.ID != c.Int64("id") {
|
||||
return fmt.Errorf("The user %s does not match the provided id %d", user.Name, c.Int64("id"))
|
||||
}
|
||||
|
||||
return user_service.DeleteUser(ctx, user, c.Bool("purge"))
|
||||
}
|
80
cmd/admin_user_generate_access_token.go
Normal file
80
cmd/admin_user_generate_access_token.go
Normal file
|
@ -0,0 +1,80 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var microcmdUserGenerateAccessToken = cli.Command{
|
||||
Name: "generate-access-token",
|
||||
Usage: "Generate an access token for a specific user",
|
||||
Flags: []cli.Flag{
|
||||
cli.StringFlag{
|
||||
Name: "username,u",
|
||||
Usage: "Username",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "token-name,t",
|
||||
Usage: "Token name",
|
||||
Value: "gitea-admin",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "raw",
|
||||
Usage: "Display only the token value",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "scopes",
|
||||
Value: "",
|
||||
Usage: "Comma separated list of scopes to apply to access token",
|
||||
},
|
||||
},
|
||||
Action: runGenerateAccessToken,
|
||||
}
|
||||
|
||||
func runGenerateAccessToken(c *cli.Context) error {
|
||||
if !c.IsSet("username") {
|
||||
return fmt.Errorf("You must provide a username to generate a token for")
|
||||
}
|
||||
|
||||
ctx, cancel := installSignals()
|
||||
defer cancel()
|
||||
|
||||
if err := initDB(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := user_model.GetUserByName(ctx, c.String("username"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accessTokenScope, err := auth_model.AccessTokenScope(c.String("scopes")).Normalize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t := &auth_model.AccessToken{
|
||||
Name: c.String("token-name"),
|
||||
UID: user.ID,
|
||||
Scope: accessTokenScope,
|
||||
}
|
||||
|
||||
if err := auth_model.NewAccessToken(t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Bool("raw") {
|
||||
fmt.Printf("%s\n", t.Token)
|
||||
} else {
|
||||
fmt.Printf("Access token was successfully created: %s\n", t.Token)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
60
cmd/admin_user_list.go
Normal file
60
cmd/admin_user_list.go
Normal file
|
@ -0,0 +1,60 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var microcmdUserList = cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List users",
|
||||
Action: runListUsers,
|
||||
Flags: []cli.Flag{
|
||||
cli.BoolFlag{
|
||||
Name: "admin",
|
||||
Usage: "List only admin users",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func runListUsers(c *cli.Context) error {
|
||||
ctx, cancel := installSignals()
|
||||
defer cancel()
|
||||
|
||||
if err := initDB(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
users, err := user_model.GetAllUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 5, 0, 1, ' ', 0)
|
||||
|
||||
if c.IsSet("admin") {
|
||||
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\n")
|
||||
for _, u := range users {
|
||||
if u.IsAdmin {
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%t\n", u.ID, u.Name, u.Email, u.IsActive)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
twofa := user_model.UserList(users).GetTwoFaStatus()
|
||||
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tIsAdmin\t2FA\n")
|
||||
for _, u := range users {
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%t\t%t\n", u.ID, u.Name, u.Email, u.IsActive, u.IsAdmin, twofa[u.ID])
|
||||
}
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
return nil
|
||||
}
|
58
cmd/admin_user_must_change_password.go
Normal file
58
cmd/admin_user_must_change_password.go
Normal file
|
@ -0,0 +1,58 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var microcmdUserMustChangePassword = cli.Command{
|
||||
Name: "must-change-password",
|
||||
Usage: "Set the must change password flag for the provided users or all users",
|
||||
Action: runMustChangePassword,
|
||||
Flags: []cli.Flag{
|
||||
cli.BoolFlag{
|
||||
Name: "all,A",
|
||||
Usage: "All users must change password, except those explicitly excluded with --exclude",
|
||||
},
|
||||
cli.StringSliceFlag{
|
||||
Name: "exclude,e",
|
||||
Usage: "Do not change the must-change-password flag for these users",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "unset",
|
||||
Usage: "Instead of setting the must-change-password flag, unset it",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func runMustChangePassword(c *cli.Context) error {
|
||||
ctx, cancel := installSignals()
|
||||
defer cancel()
|
||||
|
||||
if c.NArg() == 0 && !c.IsSet("all") {
|
||||
return errors.New("either usernames or --all must be provided")
|
||||
}
|
||||
|
||||
mustChangePassword := !c.Bool("unset")
|
||||
all := c.Bool("all")
|
||||
exclude := c.StringSlice("exclude")
|
||||
|
||||
if err := initDB(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n, err := user_model.SetMustChangePassword(ctx, all, mustChangePassword, c.Args(), exclude)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Updated %d users setting MustChangePassword to %t\n", n, mustChangePassword)
|
||||
return nil
|
||||
}
|
|
@ -57,9 +57,10 @@ func confirm() (bool, error) {
|
|||
}
|
||||
|
||||
func initDB(ctx context.Context) error {
|
||||
setting.LoadFromExisting()
|
||||
setting.InitDBConfig()
|
||||
setting.NewXORMLogService(false)
|
||||
setting.InitProviderFromExistingFile()
|
||||
setting.LoadCommonSettings()
|
||||
setting.LoadDBSetting()
|
||||
setting.InitSQLLog(false)
|
||||
|
||||
if setting.Database.Type == "" {
|
||||
log.Fatal(`Database settings are missing from the configuration file: %q.
|
||||
|
|
|
@ -32,7 +32,7 @@ func runConvert(ctx *cli.Context) error {
|
|||
log.Info("AppPath: %s", setting.AppPath)
|
||||
log.Info("AppWorkPath: %s", setting.AppWorkPath)
|
||||
log.Info("Custom path: %s", setting.CustomPath)
|
||||
log.Info("Log path: %s", setting.LogRootPath)
|
||||
log.Info("Log path: %s", setting.Log.RootPath)
|
||||
log.Info("Configuration file: %s", setting.CustomConf)
|
||||
|
||||
if !setting.Database.UseMySQL {
|
||||
|
|
|
@ -87,14 +87,16 @@ func runRecreateTable(ctx *cli.Context) error {
|
|||
golog.SetPrefix("")
|
||||
golog.SetOutput(log.NewLoggerAsWriter("INFO", log.GetLogger(log.DEFAULT)))
|
||||
|
||||
setting.LoadFromExisting()
|
||||
setting.InitDBConfig()
|
||||
setting.InitProviderFromExistingFile()
|
||||
setting.LoadCommonSettings()
|
||||
setting.LoadDBSetting()
|
||||
|
||||
setting.EnableXORMLog = ctx.Bool("debug")
|
||||
setting.Log.EnableXORMLog = ctx.Bool("debug")
|
||||
setting.Database.LogSQL = ctx.Bool("debug")
|
||||
setting.Cfg.Section("log").Key("XORM").SetValue(",")
|
||||
// FIXME: don't use CfgProvider directly
|
||||
setting.CfgProvider.Section("log").Key("XORM").SetValue(",")
|
||||
|
||||
setting.NewXORMLogService(!ctx.Bool("debug"))
|
||||
setting.InitSQLLog(!ctx.Bool("debug"))
|
||||
stdCtx, cancel := installSignals()
|
||||
defer cancel()
|
||||
|
||||
|
|
20
cmd/dump.go
20
cmd/dump.go
|
@ -181,20 +181,22 @@ func runDump(ctx *cli.Context) error {
|
|||
}
|
||||
fileName += "." + outType
|
||||
}
|
||||
setting.LoadFromExisting()
|
||||
setting.InitProviderFromExistingFile()
|
||||
setting.LoadCommonSettings()
|
||||
|
||||
// make sure we are logging to the console no matter what the configuration tells us do to
|
||||
if _, err := setting.Cfg.Section("log").NewKey("MODE", "console"); err != nil {
|
||||
// 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.Cfg.Section("log.console").NewKey("STDERR", "true"); err != nil {
|
||||
if _, err := setting.CfgProvider.Section("log.console").NewKey("STDERR", "true"); err != nil {
|
||||
fatal("Setting console logger to stderr 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.NewServices() // cannot access session settings otherwise
|
||||
setting.LoadSettings() // cannot access session settings otherwise
|
||||
|
||||
stdCtx, cancel := installSignals()
|
||||
defer cancel()
|
||||
|
@ -322,7 +324,7 @@ func runDump(ctx *cli.Context) error {
|
|||
log.Info("Packing data directory...%s", setting.AppDataPath)
|
||||
|
||||
var excludes []string
|
||||
if setting.Cfg.Section("session").Key("PROVIDER").Value() == "file" {
|
||||
if setting.SessionConfig.OriginalProvider == "file" {
|
||||
var opts session.Options
|
||||
if err = json.Unmarshal([]byte(setting.SessionConfig.ProviderConfig), &opts); err != nil {
|
||||
return err
|
||||
|
@ -339,7 +341,7 @@ func runDump(ctx *cli.Context) error {
|
|||
excludes = append(excludes, setting.LFS.Path)
|
||||
excludes = append(excludes, setting.Attachment.Path)
|
||||
excludes = append(excludes, setting.Packages.Path)
|
||||
excludes = append(excludes, setting.LogRootPath)
|
||||
excludes = append(excludes, setting.Log.RootPath)
|
||||
excludes = append(excludes, absFileName)
|
||||
if err := addRecursiveExclude(w, "data", setting.AppDataPath, excludes, verbose); err != nil {
|
||||
fatal("Failed to include data directory: %v", err)
|
||||
|
@ -378,12 +380,12 @@ func runDump(ctx *cli.Context) error {
|
|||
if ctx.IsSet("skip-log") && ctx.Bool("skip-log") {
|
||||
log.Info("Skip dumping log files")
|
||||
} else {
|
||||
isExist, err := util.IsExist(setting.LogRootPath)
|
||||
isExist, err := util.IsExist(setting.Log.RootPath)
|
||||
if err != nil {
|
||||
log.Error("Unable to check if %s exists. Error: %v", setting.LogRootPath, err)
|
||||
log.Error("Unable to check if %s exists. Error: %v", setting.Log.RootPath, err)
|
||||
}
|
||||
if isExist {
|
||||
if err := addRecursiveExclude(w, "log", setting.LogRootPath, []string{absFileName}, verbose); err != nil {
|
||||
if err := addRecursiveExclude(w, "log", setting.Log.RootPath, []string{absFileName}, verbose); err != nil {
|
||||
fatal("Failed to include log: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -94,7 +94,7 @@ func runDumpRepository(ctx *cli.Context) error {
|
|||
log.Info("AppPath: %s", setting.AppPath)
|
||||
log.Info("AppWorkPath: %s", setting.AppWorkPath)
|
||||
log.Info("Custom path: %s", setting.CustomPath)
|
||||
log.Info("Log path: %s", setting.LogRootPath)
|
||||
log.Info("Log path: %s", setting.Log.RootPath)
|
||||
log.Info("Configuration file: %s", setting.CustomConf)
|
||||
|
||||
var (
|
||||
|
|
|
@ -112,7 +112,8 @@ func initEmbeddedExtractor(c *cli.Context) error {
|
|||
log.DelNamedLogger(log.DEFAULT)
|
||||
|
||||
// Read configuration file
|
||||
setting.LoadAllowEmpty()
|
||||
setting.InitProviderAllowEmpty()
|
||||
setting.LoadCommonSettings()
|
||||
|
||||
pats, err := getPatterns(c.Args())
|
||||
if err != nil {
|
||||
|
|
|
@ -17,7 +17,8 @@ func runSendMail(c *cli.Context) error {
|
|||
ctx, cancel := installSignals()
|
||||
defer cancel()
|
||||
|
||||
setting.LoadFromExisting()
|
||||
setting.InitProviderFromExistingFile()
|
||||
setting.LoadCommonSettings()
|
||||
|
||||
if err := argsSet(c, "title"); err != nil {
|
||||
return err
|
||||
|
|
|
@ -12,7 +12,7 @@ import (
|
|||
|
||||
func init() {
|
||||
setting.SetCustomPathAndConf("", "", "")
|
||||
setting.LoadForTest()
|
||||
setting.InitProviderAndLoadCommonSettingsForTest()
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
|
|
|
@ -33,7 +33,7 @@ func runMigrate(ctx *cli.Context) error {
|
|||
log.Info("AppPath: %s", setting.AppPath)
|
||||
log.Info("AppWorkPath: %s", setting.AppWorkPath)
|
||||
log.Info("Custom path: %s", setting.CustomPath)
|
||||
log.Info("Log path: %s", setting.LogRootPath)
|
||||
log.Info("Log path: %s", setting.Log.RootPath)
|
||||
log.Info("Configuration file: %s", setting.CustomConf)
|
||||
|
||||
if err := db.InitEngineWithMigration(context.Background(), migrations.Migrate); err != nil {
|
||||
|
|
|
@ -136,7 +136,7 @@ func runMigrateStorage(ctx *cli.Context) error {
|
|||
log.Info("AppPath: %s", setting.AppPath)
|
||||
log.Info("AppWorkPath: %s", setting.AppWorkPath)
|
||||
log.Info("Custom path: %s", setting.CustomPath)
|
||||
log.Info("Log path: %s", setting.LogRootPath)
|
||||
log.Info("Log path: %s", setting.Log.RootPath)
|
||||
log.Info("Configuration file: %s", setting.CustomConf)
|
||||
|
||||
if err := db.InitEngineWithMigration(context.Background(), migrations.Migrate); err != nil {
|
||||
|
|
|
@ -54,7 +54,8 @@ func runRestoreRepository(c *cli.Context) error {
|
|||
ctx, cancel := installSignals()
|
||||
defer cancel()
|
||||
|
||||
setting.LoadFromExisting()
|
||||
setting.InitProviderFromExistingFile()
|
||||
setting.LoadCommonSettings()
|
||||
var units []string
|
||||
if s := c.String("units"); s != "" {
|
||||
units = strings.Split(s, ",")
|
||||
|
|
|
@ -61,7 +61,8 @@ func setup(logPath string, debug bool) {
|
|||
} else {
|
||||
_ = log.NewLogger(1000, "console", "console", `{"level":"fatal","stacktracelevel":"NONE","stderr":true}`)
|
||||
}
|
||||
setting.LoadFromExisting()
|
||||
setting.InitProviderFromExistingFile()
|
||||
setting.LoadCommonSettings()
|
||||
if debug {
|
||||
setting.RunMode = "dev"
|
||||
}
|
||||
|
|
|
@ -158,7 +158,8 @@ func runWeb(ctx *cli.Context) error {
|
|||
|
||||
log.Info("Global init")
|
||||
// Perform global initialization
|
||||
setting.LoadFromExisting()
|
||||
setting.InitProviderFromExistingFile()
|
||||
setting.LoadCommonSettings()
|
||||
routers.GlobalInitInstalled(graceful.GetManager().HammerContext())
|
||||
|
||||
// We check that AppDataPath exists here (it should have been created during installation)
|
||||
|
|
|
@ -79,6 +79,10 @@ func main() {
|
|||
Name: "no-xdg-open",
|
||||
Usage: "Set this flag to not use xdg-open to open the PR URL",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "continue",
|
||||
Usage: "Set this flag to continue from a git cherry-pick that has broken",
|
||||
},
|
||||
}
|
||||
cli.AppHelpTemplate = `NAME:
|
||||
{{.Name}} - {{.Usage}}
|
||||
|
@ -104,7 +108,19 @@ func runBackport(c *cli.Context) error {
|
|||
ctx, cancel := installSignals()
|
||||
defer cancel()
|
||||
|
||||
continuing := c.Bool("continue")
|
||||
|
||||
var pr string
|
||||
|
||||
version := c.String("version")
|
||||
if version == "" && continuing {
|
||||
// determine version from current branch name
|
||||
var err error
|
||||
pr, version, err = readCurrentBranch(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if version == "" {
|
||||
version = readVersion()
|
||||
}
|
||||
|
@ -135,13 +151,14 @@ func runBackport(c *cli.Context) error {
|
|||
localReleaseBranch := path.Join(upstream, upstreamReleaseBranch)
|
||||
|
||||
args := c.Args()
|
||||
if len(args) == 0 {
|
||||
if len(args) == 0 && pr == "" {
|
||||
return fmt.Errorf("no PR number provided\nProvide a PR number to backport")
|
||||
} else if len(args) != 1 {
|
||||
} else if len(args) != 1 && pr == "" {
|
||||
return fmt.Errorf("multiple PRs provided %v\nOnly a single PR can be backported at a time", args)
|
||||
}
|
||||
|
||||
pr := args[0]
|
||||
if pr == "" {
|
||||
pr = args[0]
|
||||
}
|
||||
|
||||
backportBranch := c.String("backport-branch")
|
||||
if backportBranch == "" {
|
||||
|
@ -168,8 +185,10 @@ func runBackport(c *cli.Context) error {
|
|||
}
|
||||
}
|
||||
|
||||
if err := checkoutBackportBranch(ctx, backportBranch, localReleaseBranch); err != nil {
|
||||
return err
|
||||
if !continuing {
|
||||
if err := checkoutBackportBranch(ctx, backportBranch, localReleaseBranch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := cherrypick(ctx, sha); err != nil {
|
||||
|
@ -353,6 +372,22 @@ func determineRemote(ctx context.Context, forkUser string) (string, string, erro
|
|||
return "", "", fmt.Errorf("unable to find appropriate remote in:\n%s", string(out))
|
||||
}
|
||||
|
||||
func readCurrentBranch(ctx context.Context) (pr, version string, err error) {
|
||||
out, err := exec.CommandContext(ctx, "git", "branch", "--show-current").Output()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Unable to read current git branch:\n%s\n", string(out))
|
||||
return "", "", fmt.Errorf("unable to read current git branch: %w", err)
|
||||
}
|
||||
parts := strings.Split(strings.TrimSpace(string(out)), "-")
|
||||
|
||||
if len(parts) != 3 || parts[0] != "backport" {
|
||||
fmt.Fprintf(os.Stderr, "Unable to continue from git branch:\n%s\n", string(out))
|
||||
return "", "", fmt.Errorf("unable to continue from git branch:\n%s", string(out))
|
||||
}
|
||||
|
||||
return parts[1], parts[2], nil
|
||||
}
|
||||
|
||||
func readVersion() string {
|
||||
bs, err := os.ReadFile("docs/config.yaml")
|
||||
if err != nil {
|
||||
|
|
|
@ -49,7 +49,8 @@ func runPR() {
|
|||
log.Fatal(err)
|
||||
}
|
||||
setting.SetCustomPathAndConf("", "", "")
|
||||
setting.LoadAllowEmpty()
|
||||
setting.InitProviderAllowEmpty()
|
||||
setting.LoadCommonSettings()
|
||||
|
||||
setting.RepoRootPath, err = os.MkdirTemp(os.TempDir(), "repos")
|
||||
if err != nil {
|
||||
|
@ -82,7 +83,7 @@ func runPR() {
|
|||
setting.Database.Path = ":memory:"
|
||||
setting.Database.Timeout = 500
|
||||
*/
|
||||
dbCfg := setting.Cfg.Section("database")
|
||||
dbCfg := setting.CfgProvider.Section("database")
|
||||
dbCfg.NewKey("DB_TYPE", "sqlite3")
|
||||
dbCfg.NewKey("PATH", ":memory:")
|
||||
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
image: gitea/gitea:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}{{#if (hasPrefix "refs/heads/release/v" build.ref)}}{{trimPrefix "refs/heads/release/v" build.ref}}-{{/if}}dev{{/if}}-rootless
|
||||
{{#if build.tags}}
|
||||
{{#unless contains "-rc" build.tag}}
|
||||
tags:
|
||||
{{#each build.tags}}
|
||||
- {{this}}-rootless
|
||||
{{/each}}
|
||||
- "latest-rootless"
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
manifests:
|
||||
-
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
image: gitea/gitea:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}{{#if (hasPrefix "refs/heads/release/v" build.ref)}}{{trimPrefix "refs/heads/release/v" build.ref}}-{{/if}}dev{{/if}}
|
||||
{{#if build.tags}}
|
||||
{{#unless contains "-rc" build.tag }}
|
||||
tags:
|
||||
{{#each build.tags}}
|
||||
- {{this}}
|
||||
{{/each}}
|
||||
- "latest"
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
manifests:
|
||||
-
|
||||
|
|
|
@ -568,7 +568,22 @@ Certain queues have defaults that override the defaults set in `[queue]` (this o
|
|||
- `IMPORT_LOCAL_PATHS`: **false**: Set to `false` to prevent all users (including admin) from importing local path on server.
|
||||
- `INTERNAL_TOKEN`: **\<random at every install if no uri set\>**: Secret used to validate communication within Gitea binary.
|
||||
- `INTERNAL_TOKEN_URI`: **<empty>**: Instead of defining INTERNAL_TOKEN in the configuration, this configuration option can be used to give Gitea a path to a file that contains the internal token (example value: `file:/etc/gitea/internal_token`)
|
||||
- `PASSWORD_HASH_ALGO`: **pbkdf2**: The hash algorithm to use \[argon2, pbkdf2, scrypt, bcrypt\], argon2 will spend more memory than others.
|
||||
- `PASSWORD_HASH_ALGO`: **pbkdf2**: The hash algorithm to use \[argon2, pbkdf2, pbkdf2_v1, pbkdf2_hi, scrypt, bcrypt\], argon2 and scrypt will spend significant amounts of memory.
|
||||
- Note: The default parameters for `pbkdf2` hashing have changed - the previous settings are available as `pbkdf2_v1` but are not recommended.
|
||||
- The hash functions may be tuned by using `$` after the algorithm:
|
||||
- `argon2$<time>$<memory>$<threads>$<key-length>`
|
||||
- `bcrypt$<cost>`
|
||||
- `pbkdf2$<iterations>$<key-length>`
|
||||
- `scrypt$<n>$<r>$<p>$<key-length>`
|
||||
- The defaults are:
|
||||
- `argon2`: `argon2$2$65536$8$50`
|
||||
- `bcrypt`: `bcrypt$10`
|
||||
- `pbkdf2`: `pbkdf2$50000$50`
|
||||
- `pbkdf2_v1`: `pbkdf2$10000$50`
|
||||
- `pbkdf2_v2`: `pbkdf2$50000$50`
|
||||
- `pbkdf2_hi`: `pbkdf2$320000$50`
|
||||
- `scrypt`: `scrypt$65536$16$2$50`
|
||||
- Adjusting the algorithm parameters using this functionality is done at your own risk.
|
||||
- `CSRF_COOKIE_HTTP_ONLY`: **true**: Set false to allow JavaScript to read CSRF cookie.
|
||||
- `MIN_PASSWORD_LENGTH`: **6**: Minimum password length for new users.
|
||||
- `PASSWORD_COMPLEXITY`: **off**: Comma separated list of character classes required to pass minimum complexity. If left empty or no valid values are specified, checking is disabled (off):
|
||||
|
|
|
@ -93,6 +93,11 @@ However, there are still some special cases, so the current guideline is:
|
|||
* `node.dataset` should not be used, use `node.getAttribute` instead.
|
||||
* never bind any user data to a DOM node, use a suitable design pattern to describe the relation between node and data.
|
||||
|
||||
### Show/Hide Elements
|
||||
|
||||
* Vue components are recommended to use `v-if` and `v-show` to show/hide elements.
|
||||
* Go template code should use Gitea's `.gt-hidden` and `showElem()/hideElem()/toggleElem()`, see more details in `.gt-hidden`'s comment.
|
||||
|
||||
### Legacy Code
|
||||
|
||||
A lot of legacy code already existed before this document's written. It's recommended to refactor legacy code to follow the guidelines.
|
||||
|
|
51
docs/content/doc/developers/guidelines-refactoring.en-us.md
Normal file
51
docs/content/doc/developers/guidelines-refactoring.en-us.md
Normal file
|
@ -0,0 +1,51 @@
|
|||
---
|
||||
date: "2023-02-14T00:00:00+00:00"
|
||||
title: "Guidelines for Refactoring"
|
||||
slug: "guidelines-refactoring"
|
||||
weight: 20
|
||||
toc: false
|
||||
draft: false
|
||||
menu:
|
||||
sidebar:
|
||||
parent: "developers"
|
||||
name: "Guidelines for Refactoring"
|
||||
weight: 20
|
||||
identifier: "guidelines-refactoring"
|
||||
---
|
||||
|
||||
# Guidelines for Refactoring
|
||||
|
||||
**Table of Contents**
|
||||
|
||||
{{< toc >}}
|
||||
|
||||
## Background
|
||||
|
||||
Since the first line of code was written at Feb 12, 2014, Gitea has grown to be a large project.
|
||||
As a result, the codebase has become larger and larger. The larger the codebase is, the more difficult it is to maintain.
|
||||
A lot of outdated mechanisms exist, a lot of frameworks are mixed together, some legacy code might cause bugs and block new features.
|
||||
To make the codebase more maintainable and make Gitea better, developers should keep in mind to use modern mechanisms to refactor the old code.
|
||||
|
||||
This document is a collection of guidelines for refactoring the codebase.
|
||||
|
||||
## Refactoring Suggestions
|
||||
|
||||
* Design more about the future, but not only resolve the current problem.
|
||||
* Reduce ambiguity, reduce conflicts, improve maintainability.
|
||||
* Describe the refactoring, for example:
|
||||
* Why the refactoring is needed.
|
||||
* How the legacy problems would be solved.
|
||||
* What's the Pros/Cons of the refactoring.
|
||||
* Only do necessary changes, keep the old logic as much as possible.
|
||||
* Introduce some intermediate steps to make the refactoring easier to review, a complete refactoring plan could be done in several PRs.
|
||||
* If there is any divergence, the TOC(Technical Oversight Committee) should be involved to help to make decisions.
|
||||
* Add necessary tests to make sure the refactoring is correct.
|
||||
* Non-bug refactoring is preferred to be done at the beginning of a milestone, it would be easier to find problems before the release.
|
||||
|
||||
## Reviewing & Merging Suggestions
|
||||
|
||||
* A refactoring PR shouldn't be kept open for a long time (usually 7 days), it should be reviewed as soon as possible.
|
||||
* A refactoring PR should be merged as soon as possible, it should not be blocked by other PRs.
|
||||
* If there is no objection from TOC, a refactoring PR could be merged after 7 days with one core member's approval (not the author).
|
||||
* Tolerate some dirty/hacky intermediate steps if the final result is good.
|
||||
* Tolerate some regression bugs if the refactoring is necessary, fix bugs as soon as possible.
|
|
@ -16,7 +16,7 @@ menu:
|
|||
# Installation from binary
|
||||
|
||||
All downloads come with SQLite, MySQL and PostgreSQL support, and are built with
|
||||
embedded assets. This can be different for older releases.
|
||||
embedded assets. This can be different from Gogs.
|
||||
|
||||
**Table of Contents**
|
||||
|
||||
|
@ -24,7 +24,7 @@ embedded assets. This can be different for older releases.
|
|||
|
||||
## Download
|
||||
|
||||
You can find the file matching your platform from the [downloads page](https://dl.gitea.io/gitea/) after navigating to the version you want to download.
|
||||
You can find the file matching your platform from the [downloads page](https://dl.gitea.com/gitea/) after navigating to the version you want to download.
|
||||
|
||||
### Choosing the right file
|
||||
|
||||
|
@ -36,12 +36,14 @@ You can find the file matching your platform from the [downloads page](https://d
|
|||
|
||||
**For macOS**, you should choose `darwin-arm64` if your hardware uses Apple Silicon, or `darwin-amd64` for Intel.
|
||||
|
||||
**For FreeBSD**, you should choose `freebsd12-amd64` for 64-bit Intel/AMD platforms.
|
||||
|
||||
### Downloading with wget
|
||||
|
||||
Copy the commands below and replace the URL within the one you wish to download.
|
||||
|
||||
```sh
|
||||
wget -O gitea https://dl.gitea.io/gitea/{{< version >}}/gitea-{{< version >}}-linux-amd64
|
||||
wget -O gitea https://dl.gitea.com/gitea/{{< version >}}/gitea-{{< version >}}-linux-amd64
|
||||
chmod +x gitea
|
||||
```
|
||||
|
||||
|
@ -173,7 +175,7 @@ an update of your Gitea version.
|
|||
Older Linux distributions (such as Debian 7 and CentOS 6) may not be able to load the
|
||||
Gitea binary, usually producing an error such as `./gitea: /lib/x86_64-linux-gnu/libc.so.6:
|
||||
version 'GLIBC\_2.14' not found (required by ./gitea)`. This is due to the integrated
|
||||
SQLite support in the binaries provided by dl.gitea.io. In this situation, it is usually
|
||||
SQLite support in the binaries provided by dl.gitea.com. In this situation, it is usually
|
||||
possible to [install from source]({{< relref "from-source.en-us.md" >}}), without including
|
||||
SQLite support.
|
||||
|
||||
|
|
|
@ -15,21 +15,150 @@ menu:
|
|||
|
||||
# 从二进制安装
|
||||
|
||||
所有下载均包括 SQLite, MySQL 和 PostgreSQL 的支持,同时所有资源均已嵌入到可执行程序中,这一点和老版本有所不同。 基于二进制的安装非常简单,只要从 [下载页面](https://dl.gitea.io/gitea) 选择对应平台,拷贝下载URL,执行以下命令即可(以Linux为例):
|
||||
所有打包的二进制程序均包含 SQLite,MySQL 和 PostgreSQL 的数据库连接支持,同时网站的静态资源均已嵌入到可执行程序中,这一点和曾经的 Gogs 有所不同。
|
||||
|
||||
```
|
||||
wget -O gitea https://dl.gitea.io/gitea/{{< version >}}/gitea-{{< version >}}-linux-amd64
|
||||
**目录**
|
||||
|
||||
{{< toc >}}
|
||||
|
||||
## 下载
|
||||
|
||||
你可以从 [下载页面](https://dl.gitea.com/gitea/) 选择对应平台的二进制文件。
|
||||
|
||||
### 选择架构
|
||||
|
||||
- **对于 Linux**,`linux-amd64` 适用于 64-bit 的 Intel/AMD 平台。更多架构包含 `arm64` (Raspberry PI 4),`386` (32-bit),`arm-5` 以及 `arm-6`。
|
||||
|
||||
- **对于 Windows**,`windows-4.0-amd64` 适用于 64-bit 的 Intel/AMD 平台,`386` 适用于 32-bit 的 Intel/AMD 平台。(提示:`gogit-windows` 版本内建了 gogit 可能缓解在旧的 Windows 平台上 Go 程序调用 git 子程序时面临的 [性能问题](https://github.com/go-gitea/gitea/pull/15482))
|
||||
|
||||
- **对于 macOS**,`darwin-arm64` 适用于 Apple Silicon 架构,`darwin-amd64` 适用于 Intel 架构.
|
||||
|
||||
- **对于 FreeBSD**,`freebsd12-amd64` 适用于 64-bit 的 Intel/AMD 平台。
|
||||
|
||||
### 使用 wget 下载
|
||||
|
||||
使用以下命令下载适用于 64-bit Linux 平台的二进制文件。
|
||||
|
||||
```sh
|
||||
wget -O gitea https://dl.gitea.com/gitea/{{< version >}}/gitea-{{< version >}}-linux-amd64
|
||||
chmod +x gitea
|
||||
```
|
||||
|
||||
## 测试
|
||||
## 验证 GPG 签名
|
||||
|
||||
在执行了以上步骤之后,你将会获得 `gitea` 的二进制文件,在你复制到部署的机器之前可以先测试一下。在命令行执行完后,你可以 `Ctrl + C` 关掉程序。
|
||||
Gitea 对打包的二进制文件使用 [GPG密钥](https://keys.openpgp.org/search?q=teabot%40gitea.io) 签名以防止篡改。
|
||||
请根据对应文件名 `.asc` 中包含的校验码检验文件的一致性。
|
||||
|
||||
```
|
||||
./gitea web
|
||||
```sh
|
||||
gpg --keyserver keys.openpgp.org --recv 7C9E68152594688862D62AF62D9AE806EC1592E2
|
||||
gpg --verify gitea-{{< version >}}-linux-amd64.asc gitea-{{< version >}}-linux-amd64
|
||||
```
|
||||
|
||||
## 需要帮助?
|
||||
校验正确时的信息为 `Good signature from "Teabot <teabot@gitea.io>"`。
|
||||
校验错误时的信息为 `This key is not certified with a trusted signature!`。
|
||||
|
||||
## 服务器设置
|
||||
|
||||
**提示:** `GITEA_WORK_DIR` 表示 Gitea 工作的路径。以下路径可以通过 [环境变量]({{< relref "doc/advanced/environment-variables.zh-cn.md" >}}) 初始化。
|
||||
|
||||
### 准备环境
|
||||
|
||||
检查是否安装 Git。要求 Git 版本 >= 2.0。
|
||||
|
||||
```sh
|
||||
git --version
|
||||
```
|
||||
|
||||
创建用户(推荐使用名称 `git`)
|
||||
|
||||
```sh
|
||||
adduser \
|
||||
--system \
|
||||
--shell /bin/bash \
|
||||
--gecos 'Git Version Control' \
|
||||
--group \
|
||||
--disabled-password \
|
||||
--home /home/git \
|
||||
git
|
||||
```
|
||||
|
||||
### 创建工作路径
|
||||
|
||||
```sh
|
||||
mkdir -p /var/lib/gitea/{custom,data,log}
|
||||
chown -R git:git /var/lib/gitea/
|
||||
chmod -R 750 /var/lib/gitea/
|
||||
mkdir /etc/gitea
|
||||
chown root:git /etc/gitea
|
||||
chmod 770 /etc/gitea
|
||||
```
|
||||
|
||||
> **注意:** 为了让 Web 安装程序可以写入配置文件,我们临时为 `/etc/gitea` 路径授予了组外用户 `git` 写入权限。建议在安装结束后将配置文件的权限设置为只读。
|
||||
>
|
||||
> ```sh
|
||||
> chmod 750 /etc/gitea
|
||||
> chmod 640 /etc/gitea/app.ini
|
||||
> ```
|
||||
|
||||
如果您不希望通过 Web 安装程序创建配置文件,可以将配置文件设置为仅供 Gitea 用户只读(owner/group `root:git`, mode `0640`)并手工创建配置文件:
|
||||
|
||||
- 设置 `INSTALL_LOCK=true` 关闭安装界面
|
||||
- 手动配置数据库连接参数
|
||||
- 使用 `gitea generate secret` 创建 `SECRET_KEY` 和 `INTERNAL_TOKEN`
|
||||
- 提供所有必要的密钥
|
||||
|
||||
详情参考 [命令行文档](/zh-cn/command-line/) 中有关 `gitea generate secret` 的内容。
|
||||
|
||||
### 配置 Gitea 工作路径
|
||||
|
||||
**提示:** 如果使用 Systemd 管理 Gitea 的 Linux 服务,你可以采用 `WorkingDirectory` 参数来配置工作路径。 否则,使用环境变量 `GITEA_WORK_DIR` 来明确指出程序工作和数据存放路径。
|
||||
|
||||
```sh
|
||||
export GITEA_WORK_DIR=/var/lib/gitea/
|
||||
```
|
||||
|
||||
### 复制二进制文件到全局位置
|
||||
|
||||
```sh
|
||||
cp gitea /usr/local/bin/gitea
|
||||
```
|
||||
|
||||
## 运行 Gitea
|
||||
|
||||
完成以上步骤后,可以通过两种方式运行 Gitea:
|
||||
|
||||
### 1. 创建服务自动启动 Gitea(推荐)
|
||||
|
||||
学习创建 [Linux 服务]({{< relref "run-as-service-in-ubuntu.zh-cn.md" >}})
|
||||
|
||||
### 2. 通过命令行终端运行
|
||||
|
||||
```sh
|
||||
GITEA_WORK_DIR=/var/lib/gitea/ /usr/local/bin/gitea web -c /etc/gitea/app.ini
|
||||
```
|
||||
|
||||
## 升级到最新版本
|
||||
|
||||
您可以通过停止程序,替换 `/usr/local/bin/gitea` 并重启来更新到新版本。直接替换可执行程序时不要更改或使用新的文件名称,以避免数据出错。
|
||||
|
||||
建议您在更新之前进行[备份]({{< relref "doc/usage/backup-and-restore.zh-cn.md" >}})。
|
||||
|
||||
### 1. 使用 systemd 重新启动 Gitea(推荐)
|
||||
|
||||
我们建议使用 systemd 作为服务管理器,使用 `systemctl restart gitea` 安全地重启程序。
|
||||
|
||||
### 2. 非 systemd 重启方法
|
||||
|
||||
使用 SIGHUP 信号关闭程序:查询到 Gitea 程序的 PID,使用 `kill -1 $GITEA_PID`,或者 `killall -1 gitea`。
|
||||
|
||||
更优雅的停止指令可能包括 `kill $GITEA_PID` 或者 `killall gitea`。
|
||||
|
||||
**提示:** 我们不建议使用 SIGKILL 信号(`-9`),这会强制停止 Gitea 程序,但不会正确关闭队列、索引器等任务。
|
||||
|
||||
请参阅下面的疑难解答说明,以在Gitea版本更新后修复损坏的仓库。
|
||||
|
||||
## 排查故障
|
||||
|
||||
> 更多经验总结,请参考英文版 [Troubleshooting](/en-us/install-from-binary/#troubleshooting)
|
||||
|
||||
如果从本页中没有找到你需要的内容,请访问 [帮助页面]({{< relref "seek-help.zh-cn.md" >}})
|
||||
|
|
|
@ -99,6 +99,13 @@ Admin operations:
|
|||
- `--password value`, `-p value`: New password. Required.
|
||||
- Examples:
|
||||
- `gitea admin user change-password --username myname --password asecurepassword`
|
||||
- `must-change-password`:
|
||||
- Args:
|
||||
- `[username...]`: Users that must change their passwords
|
||||
- Options:
|
||||
- `--all`, `-A`: Force a password change for all users
|
||||
- `--exclude username`, `-e username`: Exclude the given user. Can be set multiple times.
|
||||
- `--unset`: Revoke forced password change for the given users
|
||||
- `regenerate`
|
||||
- Options:
|
||||
- `hooks`: Regenerate Git Hooks for all repositories
|
||||
|
|
|
@ -106,7 +106,7 @@ You can try it out using [the online demo](https://try.gitea.io/).
|
|||
- Permission to create organizations
|
||||
- Permission to import repositories
|
||||
- Organization management
|
||||
- People
|
||||
- Members
|
||||
- Teams
|
||||
- Avatar
|
||||
- Hooks
|
||||
|
|
|
@ -534,7 +534,7 @@ func NotifyWatchers(ctx context.Context, actions ...*Action) error {
|
|||
repo = act.Repo
|
||||
|
||||
// check repo owner exist.
|
||||
if err := act.Repo.GetOwner(ctx); err != nil {
|
||||
if err := act.Repo.LoadOwner(ctx); err != nil {
|
||||
return fmt.Errorf("can't get repo owner: %w", err)
|
||||
}
|
||||
} else if act.Repo == nil {
|
||||
|
|
|
@ -97,12 +97,12 @@ func GetActivityStatsTopAuthors(ctx context.Context, repo *repo_model.Repository
|
|||
}
|
||||
users := make(map[int64]*ActivityAuthorData)
|
||||
var unknownUserID int64
|
||||
unknownUserAvatarLink := user_model.NewGhostUser().AvatarLink()
|
||||
unknownUserAvatarLink := user_model.NewGhostUser().AvatarLink(ctx)
|
||||
for _, v := range code.Authors {
|
||||
if len(v.Email) == 0 {
|
||||
continue
|
||||
}
|
||||
u, err := user_model.GetUserByEmail(v.Email)
|
||||
u, err := user_model.GetUserByEmail(ctx, v.Email)
|
||||
if u == nil || user_model.IsErrUserNotExist(err) {
|
||||
unknownUserID--
|
||||
users[unknownUserID] = &ActivityAuthorData{
|
||||
|
@ -119,7 +119,7 @@ func GetActivityStatsTopAuthors(ctx context.Context, repo *repo_model.Repository
|
|||
users[u.ID] = &ActivityAuthorData{
|
||||
Name: u.DisplayName(),
|
||||
Login: u.LowerName,
|
||||
AvatarLink: u.AvatarLink(),
|
||||
AvatarLink: u.AvatarLink(ctx),
|
||||
HomeLink: u.HomeLink(),
|
||||
Commits: v.Commits,
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
package asymkey
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"hash"
|
||||
"strings"
|
||||
|
@ -70,14 +71,14 @@ const (
|
|||
)
|
||||
|
||||
// ParseCommitsWithSignature checks if signaute of commits are corresponding to users gpg keys.
|
||||
func ParseCommitsWithSignature(oldCommits []*user_model.UserCommit, repoTrustModel repo_model.TrustModelType, isOwnerMemberCollaborator func(*user_model.User) (bool, error)) []*SignCommit {
|
||||
func ParseCommitsWithSignature(ctx context.Context, oldCommits []*user_model.UserCommit, repoTrustModel repo_model.TrustModelType, isOwnerMemberCollaborator func(*user_model.User) (bool, error)) []*SignCommit {
|
||||
newCommits := make([]*SignCommit, 0, len(oldCommits))
|
||||
keyMap := map[string]bool{}
|
||||
|
||||
for _, c := range oldCommits {
|
||||
signCommit := &SignCommit{
|
||||
UserCommit: c,
|
||||
Verification: ParseCommitWithSignature(c.Commit),
|
||||
Verification: ParseCommitWithSignature(ctx, c.Commit),
|
||||
}
|
||||
|
||||
_ = CalculateTrustStatus(signCommit.Verification, repoTrustModel, isOwnerMemberCollaborator, &keyMap)
|
||||
|
@ -88,13 +89,13 @@ func ParseCommitsWithSignature(oldCommits []*user_model.UserCommit, repoTrustMod
|
|||
}
|
||||
|
||||
// ParseCommitWithSignature check if signature is good against keystore.
|
||||
func ParseCommitWithSignature(c *git.Commit) *CommitVerification {
|
||||
func ParseCommitWithSignature(ctx context.Context, c *git.Commit) *CommitVerification {
|
||||
var committer *user_model.User
|
||||
if c.Committer != nil {
|
||||
var err error
|
||||
// Find Committer account
|
||||
committer, err = user_model.GetUserByEmail(c.Committer.Email) // This finds the user by primary email or activated email so commit will not be valid if email is not
|
||||
if err != nil { // Skipping not user for committer
|
||||
committer, err = user_model.GetUserByEmail(ctx, c.Committer.Email) // This finds the user by primary email or activated email so commit will not be valid if email is not
|
||||
if err != nil { // Skipping not user for committer
|
||||
committer = &user_model.User{
|
||||
Name: c.Committer.Name,
|
||||
Email: c.Committer.Email,
|
||||
|
|
|
@ -23,7 +23,7 @@ import "code.gitea.io/gitea/models/db"
|
|||
// GPGKeyImport the original import of key
|
||||
type GPGKeyImport struct {
|
||||
KeyID string `xorm:"pk CHAR(16) NOT NULL"`
|
||||
Content string `xorm:"TEXT NOT NULL"`
|
||||
Content string `xorm:"MEDIUMTEXT NOT NULL"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
|
|
@ -13,7 +13,7 @@ import (
|
|||
|
||||
func init() {
|
||||
setting.SetCustomPathAndConf("", "", "")
|
||||
setting.LoadForTest()
|
||||
setting.InitProviderAndLoadCommonSettingsForTest()
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
|
|
|
@ -21,7 +21,7 @@ import (
|
|||
|
||||
const (
|
||||
// DefaultAvatarClass is the default class of a rendered avatar
|
||||
DefaultAvatarClass = "ui avatar vm"
|
||||
DefaultAvatarClass = "ui avatar gt-vm"
|
||||
// DefaultAvatarPixelSize is the default size in pixels of a rendered avatar
|
||||
DefaultAvatarPixelSize = 28
|
||||
)
|
||||
|
@ -147,13 +147,13 @@ func generateRecognizedAvatarURL(u url.URL, size int) string {
|
|||
// generateEmailAvatarLink returns a email avatar link.
|
||||
// if final is true, it may use a slow path (eg: query DNS).
|
||||
// if final is false, it always uses a fast path.
|
||||
func generateEmailAvatarLink(email string, size int, final bool) string {
|
||||
func generateEmailAvatarLink(ctx context.Context, email string, size int, final bool) string {
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" {
|
||||
return DefaultAvatarLink()
|
||||
}
|
||||
|
||||
enableFederatedAvatar := system_model.GetSettingBool(system_model.KeyPictureEnableFederatedAvatar)
|
||||
enableFederatedAvatar := system_model.GetSettingBool(ctx, system_model.KeyPictureEnableFederatedAvatar)
|
||||
|
||||
var err error
|
||||
if enableFederatedAvatar && system_model.LibravatarService != nil {
|
||||
|
@ -174,7 +174,7 @@ func generateEmailAvatarLink(email string, size int, final bool) string {
|
|||
return urlStr
|
||||
}
|
||||
|
||||
disableGravatar := system_model.GetSettingBool(system_model.KeyPictureDisableGravatar)
|
||||
disableGravatar := system_model.GetSettingBool(ctx, system_model.KeyPictureDisableGravatar)
|
||||
if !disableGravatar {
|
||||
// copy GravatarSourceURL, because we will modify its Path.
|
||||
avatarURLCopy := *system_model.GravatarSourceURL
|
||||
|
@ -186,11 +186,11 @@ func generateEmailAvatarLink(email string, size int, final bool) string {
|
|||
}
|
||||
|
||||
// GenerateEmailAvatarFastLink returns a avatar link (fast, the link may be a delegated one: "/avatar/${hash}")
|
||||
func GenerateEmailAvatarFastLink(email string, size int) string {
|
||||
return generateEmailAvatarLink(email, size, false)
|
||||
func GenerateEmailAvatarFastLink(ctx context.Context, email string, size int) string {
|
||||
return generateEmailAvatarLink(ctx, email, size, false)
|
||||
}
|
||||
|
||||
// GenerateEmailAvatarFinalLink returns a avatar final link (maybe slow)
|
||||
func GenerateEmailAvatarFinalLink(email string, size int) string {
|
||||
return generateEmailAvatarLink(email, size, true)
|
||||
func GenerateEmailAvatarFinalLink(ctx context.Context, email string, size int) string {
|
||||
return generateEmailAvatarLink(ctx, email, size, true)
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"testing"
|
||||
|
||||
avatars_model "code.gitea.io/gitea/models/avatars"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
system_model "code.gitea.io/gitea/models/system"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
|
@ -16,15 +17,15 @@ import (
|
|||
const gravatarSource = "https://secure.gravatar.com/avatar/"
|
||||
|
||||
func disableGravatar(t *testing.T) {
|
||||
err := system_model.SetSettingNoVersion(system_model.KeyPictureEnableFederatedAvatar, "false")
|
||||
err := system_model.SetSettingNoVersion(db.DefaultContext, system_model.KeyPictureEnableFederatedAvatar, "false")
|
||||
assert.NoError(t, err)
|
||||
err = system_model.SetSettingNoVersion(system_model.KeyPictureDisableGravatar, "true")
|
||||
err = system_model.SetSettingNoVersion(db.DefaultContext, system_model.KeyPictureDisableGravatar, "true")
|
||||
assert.NoError(t, err)
|
||||
system_model.LibravatarService = nil
|
||||
}
|
||||
|
||||
func enableGravatar(t *testing.T) {
|
||||
err := system_model.SetSettingNoVersion(system_model.KeyPictureDisableGravatar, "false")
|
||||
err := system_model.SetSettingNoVersion(db.DefaultContext, system_model.KeyPictureDisableGravatar, "false")
|
||||
assert.NoError(t, err)
|
||||
setting.GravatarSource = gravatarSource
|
||||
err = system_model.Init()
|
||||
|
@ -47,11 +48,11 @@ func TestSizedAvatarLink(t *testing.T) {
|
|||
|
||||
disableGravatar(t)
|
||||
assert.Equal(t, "/testsuburl/assets/img/avatar_default.png",
|
||||
avatars_model.GenerateEmailAvatarFastLink("gitea@example.com", 100))
|
||||
avatars_model.GenerateEmailAvatarFastLink(db.DefaultContext, "gitea@example.com", 100))
|
||||
|
||||
enableGravatar(t)
|
||||
assert.Equal(t,
|
||||
"https://secure.gravatar.com/avatar/353cbad9b58e69c96154ad99f92bedc7?d=identicon&s=100",
|
||||
avatars_model.GenerateEmailAvatarFastLink("gitea@example.com", 100),
|
||||
avatars_model.GenerateEmailAvatarFastLink(db.DefaultContext, "gitea@example.com", 100),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"context"
|
||||
"database/sql"
|
||||
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
"xorm.io/xorm/schemas"
|
||||
)
|
||||
|
@ -183,6 +184,31 @@ func DeleteByBean(ctx context.Context, bean interface{}) (int64, error) {
|
|||
return GetEngine(ctx).Delete(bean)
|
||||
}
|
||||
|
||||
// DeleteByID deletes the given bean with the given ID
|
||||
func DeleteByID(ctx context.Context, id int64, bean interface{}) (int64, error) {
|
||||
return GetEngine(ctx).ID(id).NoAutoTime().Delete(bean)
|
||||
}
|
||||
|
||||
// FindIDs finds the IDs for the given table name satisfying the given condition
|
||||
// By passing a different value than "id" for "idCol", you can query for foreign IDs, i.e. the repo IDs which satisfy the condition
|
||||
func FindIDs(ctx context.Context, tableName, idCol string, cond builder.Cond) ([]int64, error) {
|
||||
ids := make([]int64, 0, 10)
|
||||
if err := GetEngine(ctx).Table(tableName).
|
||||
Cols(idCol).
|
||||
Where(cond).
|
||||
Find(&ids); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// DecrByIDs decreases the given column for entities of the "bean" type with one of the given ids by one
|
||||
// Timestamps of the entities won't be updated
|
||||
func DecrByIDs(ctx context.Context, ids []int64, decrCol string, bean interface{}) error {
|
||||
_, err := GetEngine(ctx).Decr(decrCol).In("id", ids).NoAutoCondition().NoAutoTime().Update(bean)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteBeans deletes all given beans, beans should contain delete conditions.
|
||||
func DeleteBeans(ctx context.Context, beans ...interface{}) (err error) {
|
||||
e := GetEngine(ctx)
|
||||
|
|
|
@ -25,7 +25,7 @@ func TestIterate(t *testing.T) {
|
|||
return nil
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 81, repoCnt)
|
||||
assert.EqualValues(t, 83, repoCnt)
|
||||
|
||||
err = db.Iterate(db.DefaultContext, nil, func(ctx context.Context, repoUnit *repo_model.RepoUnit) error {
|
||||
reopUnit2 := repo_model.RepoUnit{ID: repoUnit.ID}
|
||||
|
|
|
@ -37,7 +37,9 @@ func (d *postgresSchemaDriver) Open(name string) (driver.Conn, error) {
|
|||
}
|
||||
schemaValue, _ := driver.String.ConvertValue(setting.Database.Schema)
|
||||
|
||||
if execer, ok := conn.(driver.Execer); ok {
|
||||
// golangci lint is incorrect here - there is no benefit to using driver.ExecerContext here
|
||||
// and in any case pq does not implement it
|
||||
if execer, ok := conn.(driver.Execer); ok { //nolint
|
||||
_, err := execer.Exec(`SELECT set_config(
|
||||
'search_path',
|
||||
$1 || ',' || current_setting('search_path'),
|
||||
|
@ -61,7 +63,8 @@ func (d *postgresSchemaDriver) Open(name string) (driver.Conn, error) {
|
|||
|
||||
// driver.String.ConvertValue will never return err for string
|
||||
|
||||
_, err = stmt.Exec([]driver.Value{schemaValue})
|
||||
// golangci lint is incorrect here - there is no benefit to using stmt.ExecWithContext here
|
||||
_, err = stmt.Exec([]driver.Value{schemaValue}) //nolint
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
|
|
|
@ -13,7 +13,7 @@ import (
|
|||
|
||||
func init() {
|
||||
setting.SetCustomPathAndConf("", "", "")
|
||||
setting.LoadForTest()
|
||||
setting.InitProviderAndLoadCommonSettingsForTest()
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
|
|
|
@ -287,3 +287,20 @@
|
|||
created_unix: 1602935696
|
||||
updated_unix: 1602935696
|
||||
is_locked: false
|
||||
|
||||
-
|
||||
id: 18
|
||||
repo_id: 55
|
||||
index: 1
|
||||
poster_id: 2
|
||||
original_author_id: 0
|
||||
name: issue for scoped labels
|
||||
content: content
|
||||
milestone_id: 0
|
||||
priority: 0
|
||||
is_closed: false
|
||||
is_pull: false
|
||||
num_comments: 0
|
||||
created_unix: 946684830
|
||||
updated_unix: 978307200
|
||||
is_locked: false
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
org_id: 0
|
||||
name: label1
|
||||
color: '#abcdef'
|
||||
exclusive: false
|
||||
num_issues: 2
|
||||
num_closed_issues: 0
|
||||
|
||||
|
@ -13,6 +14,7 @@
|
|||
org_id: 0
|
||||
name: label2
|
||||
color: '#000000'
|
||||
exclusive: false
|
||||
num_issues: 1
|
||||
num_closed_issues: 1
|
||||
|
||||
|
@ -22,6 +24,7 @@
|
|||
org_id: 3
|
||||
name: orglabel3
|
||||
color: '#abcdef'
|
||||
exclusive: false
|
||||
num_issues: 0
|
||||
num_closed_issues: 0
|
||||
|
||||
|
@ -31,6 +34,7 @@
|
|||
org_id: 3
|
||||
name: orglabel4
|
||||
color: '#000000'
|
||||
exclusive: false
|
||||
num_issues: 1
|
||||
num_closed_issues: 0
|
||||
|
||||
|
@ -40,5 +44,46 @@
|
|||
org_id: 0
|
||||
name: pull-test-label
|
||||
color: '#000000'
|
||||
exclusive: false
|
||||
num_issues: 0
|
||||
num_closed_issues: 0
|
||||
|
||||
-
|
||||
id: 6
|
||||
repo_id: 55
|
||||
org_id: 0
|
||||
name: unscoped_label
|
||||
color: '#000000'
|
||||
exclusive: false
|
||||
num_issues: 0
|
||||
num_closed_issues: 0
|
||||
|
||||
-
|
||||
id: 7
|
||||
repo_id: 55
|
||||
org_id: 0
|
||||
name: scope/label1
|
||||
color: '#000000'
|
||||
exclusive: true
|
||||
num_issues: 0
|
||||
num_closed_issues: 0
|
||||
|
||||
-
|
||||
id: 8
|
||||
repo_id: 55
|
||||
org_id: 0
|
||||
name: scope/label2
|
||||
color: '#000000'
|
||||
exclusive: true
|
||||
num_issues: 0
|
||||
num_closed_issues: 0
|
||||
|
||||
-
|
||||
id: 9
|
||||
repo_id: 55
|
||||
org_id: 0
|
||||
name: scope/subscope/label2
|
||||
color: '#000000'
|
||||
exclusive: true
|
||||
num_issues: 0
|
||||
num_closed_issues: 0
|
||||
|
|
|
@ -556,3 +556,16 @@
|
|||
repo_id: 54
|
||||
type: 1
|
||||
created_unix: 946684810
|
||||
|
||||
-
|
||||
id: 82
|
||||
repo_id: 31
|
||||
type: 1
|
||||
created_unix: 946684810
|
||||
|
||||
-
|
||||
id: 83
|
||||
repo_id: 31
|
||||
type: 3
|
||||
config: "{\"IgnoreWhitespaceConflicts\":false,\"AllowMerge\":true,\"AllowRebase\":true,\"AllowRebaseMerge\":true,\"AllowSquash\":true}"
|
||||
created_unix: 946684810
|
||||
|
|
|
@ -1622,3 +1622,15 @@
|
|||
is_archived: false
|
||||
is_private: true
|
||||
status: 0
|
||||
|
||||
-
|
||||
id: 55
|
||||
owner_id: 2
|
||||
owner_name: user2
|
||||
lower_name: scoped_label
|
||||
name: scoped_label
|
||||
is_empty: false
|
||||
is_archived: false
|
||||
is_private: true
|
||||
num_issues: 1
|
||||
status: 0
|
||||
|
|
|
@ -8,8 +8,8 @@
|
|||
email: user1@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user1
|
||||
|
@ -45,8 +45,8 @@
|
|||
email: user2@example.com
|
||||
keep_email_private: true
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user2
|
||||
|
@ -66,7 +66,7 @@
|
|||
num_followers: 2
|
||||
num_following: 1
|
||||
num_stars: 2
|
||||
num_repos: 10
|
||||
num_repos: 11
|
||||
num_teams: 0
|
||||
num_members: 0
|
||||
visibility: 0
|
||||
|
@ -82,8 +82,8 @@
|
|||
email: user3@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: onmention
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user3
|
||||
|
@ -119,8 +119,8 @@
|
|||
email: user4@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: onmention
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user4
|
||||
|
@ -156,8 +156,8 @@
|
|||
email: user5@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user5
|
||||
|
@ -193,8 +193,8 @@
|
|||
email: user6@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user6
|
||||
|
@ -230,8 +230,8 @@
|
|||
email: user7@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: disabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user7
|
||||
|
@ -267,8 +267,8 @@
|
|||
email: user8@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user8
|
||||
|
@ -304,8 +304,8 @@
|
|||
email: user9@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: onmention
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user9
|
||||
|
@ -341,8 +341,8 @@
|
|||
email: user10@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user10
|
||||
|
@ -378,8 +378,8 @@
|
|||
email: user11@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user11
|
||||
|
@ -415,8 +415,8 @@
|
|||
email: user12@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user12
|
||||
|
@ -452,8 +452,8 @@
|
|||
email: user13@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user13
|
||||
|
@ -489,8 +489,8 @@
|
|||
email: user14@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user14
|
||||
|
@ -526,8 +526,8 @@
|
|||
email: user15@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user15
|
||||
|
@ -563,8 +563,8 @@
|
|||
email: user16@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user16
|
||||
|
@ -600,8 +600,8 @@
|
|||
email: user17@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user17
|
||||
|
@ -637,8 +637,8 @@
|
|||
email: user18@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user18
|
||||
|
@ -674,8 +674,8 @@
|
|||
email: user19@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user19
|
||||
|
@ -711,8 +711,8 @@
|
|||
email: user20@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user20
|
||||
|
@ -748,8 +748,8 @@
|
|||
email: user21@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user21
|
||||
|
@ -785,8 +785,8 @@
|
|||
email: limited_org@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: limited_org
|
||||
|
@ -822,8 +822,8 @@
|
|||
email: privated_org@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: privated_org
|
||||
|
@ -859,8 +859,8 @@
|
|||
email: user24@example.com
|
||||
keep_email_private: true
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user24
|
||||
|
@ -896,8 +896,8 @@
|
|||
email: org25@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: org25
|
||||
|
@ -933,8 +933,8 @@
|
|||
email: org26@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: onmention
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: org26
|
||||
|
@ -970,8 +970,8 @@
|
|||
email: user27@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user27
|
||||
|
@ -1007,8 +1007,8 @@
|
|||
email: user28@example.com
|
||||
keep_email_private: true
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user28
|
||||
|
@ -1044,8 +1044,8 @@
|
|||
email: user29@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user29
|
||||
|
@ -1081,8 +1081,8 @@
|
|||
email: user30@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user30
|
||||
|
@ -1118,8 +1118,8 @@
|
|||
email: user31@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user31
|
||||
|
@ -1155,8 +1155,8 @@
|
|||
email: user32@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:notpassword
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user32
|
||||
|
@ -1192,8 +1192,8 @@
|
|||
email: user33@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b
|
||||
passwd_hash_algo: argon2
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user33
|
||||
|
|
|
@ -351,7 +351,8 @@ func hashCommitStatusContext(context string) string {
|
|||
func ConvertFromGitCommit(ctx context.Context, commits []*git.Commit, repo *repo_model.Repository) []*SignCommitWithStatuses {
|
||||
return ParseCommitsWithStatus(ctx,
|
||||
asymkey_model.ParseCommitsWithSignature(
|
||||
user_model.ValidateCommitsWithEmails(commits),
|
||||
ctx,
|
||||
user_model.ValidateCommitsWithEmails(ctx, commits),
|
||||
repo.GetTrustModel(),
|
||||
func(user *user_model.User) (bool, error) {
|
||||
return repo_model.IsOwnerMemberCollaborator(repo, user.ID)
|
||||
|
|
|
@ -314,8 +314,8 @@ type WhitelistOptions struct {
|
|||
// This function also performs check if whitelist user and team's IDs have been changed
|
||||
// to avoid unnecessary whitelist delete and regenerate.
|
||||
func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, protectBranch *ProtectedBranch, opts WhitelistOptions) (err error) {
|
||||
if err = repo.GetOwner(ctx); err != nil {
|
||||
return fmt.Errorf("GetOwner: %v", err)
|
||||
if err = repo.LoadOwner(ctx); err != nil {
|
||||
return fmt.Errorf("LoadOwner: %v", err)
|
||||
}
|
||||
|
||||
whitelist, err := updateUserWhitelist(ctx, repo, protectBranch.WhitelistUserIDs, opts.UserIDs)
|
||||
|
|
|
@ -622,7 +622,7 @@ func (c *Comment) LoadAssigneeUserAndTeam() error {
|
|||
return err
|
||||
}
|
||||
|
||||
if err = c.Issue.Repo.GetOwner(db.DefaultContext); err != nil {
|
||||
if err = c.Issue.Repo.LoadOwner(db.DefaultContext); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -826,7 +826,7 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment,
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if err = opts.Repo.GetOwner(ctx); err != nil {
|
||||
if err = opts.Repo.LoadOwner(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
@ -1247,6 +1247,11 @@ func FixCommentTypeLabelWithOutsideLabels(ctx context.Context) (int64, error) {
|
|||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// HasOriginalAuthor returns if a comment was migrated and has an original author.
|
||||
func (c *Comment) HasOriginalAuthor() bool {
|
||||
return c.OriginalAuthor != "" && c.OriginalAuthorID != 0
|
||||
}
|
||||
|
||||
func (c *Comment) GetIRI(ctx context.Context) string {
|
||||
err := c.LoadIssue(ctx)
|
||||
if err != nil {
|
||||
|
|
|
@ -540,6 +540,31 @@ func (ts labelSorter) Swap(i, j int) {
|
|||
[]*Label(ts)[i], []*Label(ts)[j] = []*Label(ts)[j], []*Label(ts)[i]
|
||||
}
|
||||
|
||||
// Ensure only one label of a given scope exists, with labels at the end of the
|
||||
// array getting preference over earlier ones.
|
||||
func RemoveDuplicateExclusiveLabels(labels []*Label) []*Label {
|
||||
validLabels := make([]*Label, 0, len(labels))
|
||||
|
||||
for i, label := range labels {
|
||||
scope := label.ExclusiveScope()
|
||||
if scope != "" {
|
||||
foundOther := false
|
||||
for _, otherLabel := range labels[i+1:] {
|
||||
if otherLabel.ExclusiveScope() == scope {
|
||||
foundOther = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if foundOther {
|
||||
continue
|
||||
}
|
||||
}
|
||||
validLabels = append(validLabels, label)
|
||||
}
|
||||
|
||||
return validLabels
|
||||
}
|
||||
|
||||
// ReplaceIssueLabels removes all current labels and add new labels to the issue.
|
||||
// Triggers appropriate WebHooks, if any.
|
||||
func ReplaceIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) {
|
||||
|
@ -557,6 +582,8 @@ func ReplaceIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (e
|
|||
return err
|
||||
}
|
||||
|
||||
labels = RemoveDuplicateExclusiveLabels(labels)
|
||||
|
||||
sort.Sort(labelSorter(labels))
|
||||
sort.Sort(labelSorter(issue.Labels))
|
||||
|
||||
|
@ -2101,7 +2128,7 @@ func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *u
|
|||
resolved := make(map[string]bool, 10)
|
||||
var mentionTeams []string
|
||||
|
||||
if err := issue.Repo.GetOwner(ctx); err != nil {
|
||||
if err := issue.Repo.LoadOwner(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
@ -2406,6 +2433,11 @@ func DeleteOrphanedIssues(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// HasOriginalAuthor returns if an issue was migrated and has an original author.
|
||||
func (issue *Issue) HasOriginalAuthor() bool {
|
||||
return issue.OriginalAuthor != "" && issue.OriginalAuthorID != 0
|
||||
}
|
||||
|
||||
func (issue *Issue) GetIRI(ctx context.Context) string {
|
||||
err := issue.LoadRepo(ctx)
|
||||
if err != nil {
|
||||
|
@ -2415,4 +2447,4 @@ func (issue *Issue) GetIRI(ctx context.Context) string {
|
|||
return issue.OriginalAuthor
|
||||
}
|
||||
return setting.AppURL + "api/v1/activitypub/ticket/" + issue.Repo.OwnerName + "/" + issue.Repo.Name + "/" + strconv.FormatInt(issue.Index, 10)
|
||||
}
|
||||
}
|
|
@ -25,7 +25,7 @@ import (
|
|||
func TestIssue_ReplaceLabels(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
testSuccess := func(issueID int64, labelIDs []int64) {
|
||||
testSuccess := func(issueID int64, labelIDs, expectedLabelIDs []int64) {
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueID})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
|
||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
@ -35,15 +35,20 @@ func TestIssue_ReplaceLabels(t *testing.T) {
|
|||
labels[i] = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: labelID, RepoID: repo.ID})
|
||||
}
|
||||
assert.NoError(t, issues_model.ReplaceIssueLabels(issue, labels, doer))
|
||||
unittest.AssertCount(t, &issues_model.IssueLabel{IssueID: issueID}, len(labelIDs))
|
||||
for _, labelID := range labelIDs {
|
||||
unittest.AssertCount(t, &issues_model.IssueLabel{IssueID: issueID}, len(expectedLabelIDs))
|
||||
for _, labelID := range expectedLabelIDs {
|
||||
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issueID, LabelID: labelID})
|
||||
}
|
||||
}
|
||||
|
||||
testSuccess(1, []int64{2})
|
||||
testSuccess(1, []int64{1, 2})
|
||||
testSuccess(1, []int64{})
|
||||
testSuccess(1, []int64{2}, []int64{2})
|
||||
testSuccess(1, []int64{1, 2}, []int64{1, 2})
|
||||
testSuccess(1, []int64{}, []int64{})
|
||||
|
||||
// mutually exclusive scoped labels 7 and 8
|
||||
testSuccess(18, []int64{6, 7}, []int64{6, 7})
|
||||
testSuccess(18, []int64{7, 8}, []int64{8})
|
||||
testSuccess(18, []int64{6, 8, 7}, []int64{6, 7})
|
||||
}
|
||||
|
||||
func Test_GetIssueIDsByRepoID(t *testing.T) {
|
||||
|
@ -523,5 +528,5 @@ func TestCountIssues(t *testing.T) {
|
|||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
count, err := issues_model.CountIssues(db.DefaultContext, &issues_model.IssuesOptions{})
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 17, count)
|
||||
assert.EqualValues(t, 18, count)
|
||||
}
|
||||
|
|
|
@ -7,8 +7,6 @@ package issues
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
@ -89,6 +87,7 @@ type Label struct {
|
|||
RepoID int64 `xorm:"INDEX"`
|
||||
OrgID int64 `xorm:"INDEX"`
|
||||
Name string
|
||||
Exclusive bool
|
||||
Description string
|
||||
Color string `xorm:"VARCHAR(7)"`
|
||||
NumIssues int
|
||||
|
@ -128,18 +127,22 @@ func (label *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64)
|
|||
}
|
||||
|
||||
// LoadSelectedLabelsAfterClick calculates the set of selected labels when a label is clicked
|
||||
func (label *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64) {
|
||||
func (label *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64, currentSelectedExclusiveScopes []string) {
|
||||
var labelQuerySlice []string
|
||||
labelSelected := false
|
||||
labelID := strconv.FormatInt(label.ID, 10)
|
||||
for _, s := range currentSelectedLabels {
|
||||
labelScope := label.ExclusiveScope()
|
||||
for i, s := range currentSelectedLabels {
|
||||
if s == label.ID {
|
||||
labelSelected = true
|
||||
} else if -s == label.ID {
|
||||
labelSelected = true
|
||||
label.IsExcluded = true
|
||||
} else if s != 0 {
|
||||
labelQuerySlice = append(labelQuerySlice, strconv.FormatInt(s, 10))
|
||||
// Exclude other labels in the same scope from selection
|
||||
if s < 0 || labelScope == "" || labelScope != currentSelectedExclusiveScopes[i] {
|
||||
labelQuerySlice = append(labelQuerySlice, strconv.FormatInt(s, 10))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !labelSelected {
|
||||
|
@ -159,49 +162,43 @@ func (label *Label) BelongsToRepo() bool {
|
|||
return label.RepoID > 0
|
||||
}
|
||||
|
||||
// SrgbToLinear converts a component of an sRGB color to its linear intensity
|
||||
// See: https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation_(sRGB_to_CIE_XYZ)
|
||||
func SrgbToLinear(color uint8) float64 {
|
||||
flt := float64(color) / 255
|
||||
if flt <= 0.04045 {
|
||||
return flt / 12.92
|
||||
// Get color as RGB values in 0..255 range
|
||||
func (label *Label) ColorRGB() (float64, float64, float64, error) {
|
||||
color, err := strconv.ParseUint(label.Color[1:], 16, 64)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
return math.Pow((flt+0.055)/1.055, 2.4)
|
||||
|
||||
r := float64(uint8(0xFF & (uint32(color) >> 16)))
|
||||
g := float64(uint8(0xFF & (uint32(color) >> 8)))
|
||||
b := float64(uint8(0xFF & uint32(color)))
|
||||
return r, g, b, nil
|
||||
}
|
||||
|
||||
// Luminance returns the luminance of an sRGB color
|
||||
func Luminance(color uint32) float64 {
|
||||
r := SrgbToLinear(uint8(0xFF & (color >> 16)))
|
||||
g := SrgbToLinear(uint8(0xFF & (color >> 8)))
|
||||
b := SrgbToLinear(uint8(0xFF & color))
|
||||
|
||||
// luminance ratios for sRGB
|
||||
return 0.2126*r + 0.7152*g + 0.0722*b
|
||||
}
|
||||
|
||||
// LuminanceThreshold is the luminance at which white and black appear to have the same contrast
|
||||
// i.e. x such that 1.05 / (x + 0.05) = (x + 0.05) / 0.05
|
||||
// i.e. math.Sqrt(1.05*0.05) - 0.05
|
||||
const LuminanceThreshold float64 = 0.179
|
||||
|
||||
// ForegroundColor calculates the text color for labels based
|
||||
// on their background color.
|
||||
func (label *Label) ForegroundColor() template.CSS {
|
||||
// Determine if label text should be light or dark to be readable on background color
|
||||
func (label *Label) UseLightTextColor() bool {
|
||||
if strings.HasPrefix(label.Color, "#") {
|
||||
if color, err := strconv.ParseUint(label.Color[1:], 16, 64); err == nil {
|
||||
// NOTE: see web_src/js/components/ContextPopup.vue for similar implementation
|
||||
luminance := Luminance(uint32(color))
|
||||
|
||||
// prefer white or black based upon contrast
|
||||
if luminance < LuminanceThreshold {
|
||||
return template.CSS("#fff")
|
||||
}
|
||||
return template.CSS("#000")
|
||||
if r, g, b, err := label.ColorRGB(); err == nil {
|
||||
// Perceived brightness from: https://www.w3.org/TR/AERT/#color-contrast
|
||||
// In the future WCAG 3 APCA may be a better solution
|
||||
brightness := (0.299*r + 0.587*g + 0.114*b) / 255
|
||||
return brightness < 0.35
|
||||
}
|
||||
}
|
||||
|
||||
// default to black
|
||||
return template.CSS("#000")
|
||||
return false
|
||||
}
|
||||
|
||||
// Return scope substring of label name, or empty string if none exists
|
||||
func (label *Label) ExclusiveScope() string {
|
||||
if !label.Exclusive {
|
||||
return ""
|
||||
}
|
||||
lastIndex := strings.LastIndex(label.Name, "/")
|
||||
if lastIndex == -1 || lastIndex == 0 || lastIndex == len(label.Name)-1 {
|
||||
return ""
|
||||
}
|
||||
return label.Name[:lastIndex]
|
||||
}
|
||||
|
||||
// NewLabel creates a new label
|
||||
|
@ -253,7 +250,7 @@ func UpdateLabel(l *Label) error {
|
|||
if !LabelColorPattern.MatchString(l.Color) {
|
||||
return fmt.Errorf("bad color code: %s", l.Color)
|
||||
}
|
||||
return updateLabelCols(db.DefaultContext, l, "name", "description", "color")
|
||||
return updateLabelCols(db.DefaultContext, l, "name", "description", "color", "exclusive")
|
||||
}
|
||||
|
||||
// DeleteLabel delete a label
|
||||
|
@ -620,6 +617,29 @@ func newIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_m
|
|||
return updateLabelCols(ctx, label, "num_issues", "num_closed_issue")
|
||||
}
|
||||
|
||||
// Remove all issue labels in the given exclusive scope
|
||||
func RemoveDuplicateExclusiveIssueLabels(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) {
|
||||
scope := label.ExclusiveScope()
|
||||
if scope == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var toRemove []*Label
|
||||
for _, issueLabel := range issue.Labels {
|
||||
if label.ID != issueLabel.ID && issueLabel.ExclusiveScope() == scope {
|
||||
toRemove = append(toRemove, issueLabel)
|
||||
}
|
||||
}
|
||||
|
||||
for _, issueLabel := range toRemove {
|
||||
if err = deleteIssueLabel(ctx, issue, issueLabel, doer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewIssueLabel creates a new issue-label relation.
|
||||
func NewIssueLabel(issue *Issue, label *Label, doer *user_model.User) (err error) {
|
||||
if HasIssueLabel(db.DefaultContext, issue.ID, label.ID) {
|
||||
|
@ -641,6 +661,10 @@ func NewIssueLabel(issue *Issue, label *Label, doer *user_model.User) (err error
|
|||
return nil
|
||||
}
|
||||
|
||||
if err = RemoveDuplicateExclusiveIssueLabels(ctx, issue, label, doer); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err = newIssueLabel(ctx, issue, label, doer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
package issues_test
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
|
@ -25,13 +24,22 @@ func TestLabel_CalOpenIssues(t *testing.T) {
|
|||
assert.EqualValues(t, 2, label.NumOpenIssues)
|
||||
}
|
||||
|
||||
func TestLabel_ForegroundColor(t *testing.T) {
|
||||
func TestLabel_TextColor(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
|
||||
assert.Equal(t, template.CSS("#000"), label.ForegroundColor())
|
||||
assert.False(t, label.UseLightTextColor())
|
||||
|
||||
label = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 2})
|
||||
assert.Equal(t, template.CSS("#fff"), label.ForegroundColor())
|
||||
assert.True(t, label.UseLightTextColor())
|
||||
}
|
||||
|
||||
func TestLabel_ExclusiveScope(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 7})
|
||||
assert.Equal(t, "scope", label.ExclusiveScope())
|
||||
|
||||
label = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 9})
|
||||
assert.Equal(t, "scope/subscope", label.ExclusiveScope())
|
||||
}
|
||||
|
||||
func TestNewLabels(t *testing.T) {
|
||||
|
@ -266,6 +274,7 @@ func TestUpdateLabel(t *testing.T) {
|
|||
Color: "#ffff00",
|
||||
Name: "newLabelName",
|
||||
Description: label.Description,
|
||||
Exclusive: false,
|
||||
}
|
||||
label.Color = update.Color
|
||||
label.Name = update.Name
|
||||
|
@ -323,6 +332,34 @@ func TestNewIssueLabel(t *testing.T) {
|
|||
unittest.CheckConsistencyFor(t, &issues_model.Issue{}, &issues_model.Label{})
|
||||
}
|
||||
|
||||
func TestNewIssueExclusiveLabel(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 18})
|
||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
otherLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 6})
|
||||
exclusiveLabelA := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 7})
|
||||
exclusiveLabelB := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 8})
|
||||
|
||||
// coexisting regular and exclusive label
|
||||
assert.NoError(t, issues_model.NewIssueLabel(issue, otherLabel, doer))
|
||||
assert.NoError(t, issues_model.NewIssueLabel(issue, exclusiveLabelA, doer))
|
||||
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID})
|
||||
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID})
|
||||
|
||||
// exclusive label replaces existing one
|
||||
assert.NoError(t, issues_model.NewIssueLabel(issue, exclusiveLabelB, doer))
|
||||
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID})
|
||||
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelB.ID})
|
||||
unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID})
|
||||
|
||||
// exclusive label replaces existing one again
|
||||
assert.NoError(t, issues_model.NewIssueLabel(issue, exclusiveLabelA, doer))
|
||||
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID})
|
||||
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID})
|
||||
unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelB.ID})
|
||||
}
|
||||
|
||||
func TestNewIssueLabels(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
label1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
|
||||
|
|
|
@ -20,7 +20,7 @@ import (
|
|||
|
||||
func init() {
|
||||
setting.SetCustomPathAndConf("", "", "")
|
||||
setting.LoadForTest()
|
||||
setting.InitProviderAndLoadCommonSettingsForTest()
|
||||
}
|
||||
|
||||
func TestFixturesAreConsistent(t *testing.T) {
|
||||
|
|
|
@ -498,7 +498,7 @@ func (pr *PullRequest) SetMerged(ctx context.Context) (bool, error) {
|
|||
return false, err
|
||||
}
|
||||
|
||||
if err := pr.Issue.Repo.GetOwner(ctx); err != nil {
|
||||
if err := pr.Issue.Repo.LoadOwner(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ import (
|
|||
|
||||
func init() {
|
||||
setting.SetCustomPathAndConf("", "", "")
|
||||
setting.LoadForTest()
|
||||
setting.InitProviderAndLoadCommonSettingsForTest()
|
||||
}
|
||||
|
||||
// TestFixturesAreConsistent assert that test fixtures are consistent
|
||||
|
|
|
@ -149,13 +149,13 @@ func MainTest(m *testing.M) {
|
|||
setting.AppDataPath = tmpDataPath
|
||||
|
||||
setting.SetCustomPathAndConf("", "", "")
|
||||
setting.LoadForTest()
|
||||
setting.InitProviderAndLoadCommonSettingsForTest()
|
||||
if err = git.InitFull(context.Background()); err != nil {
|
||||
fmt.Printf("Unable to InitFull: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
setting.InitDBConfig()
|
||||
setting.NewLogServices(true)
|
||||
setting.LoadDBSetting()
|
||||
setting.InitLogs(true)
|
||||
|
||||
exitStatus := m.Run()
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
org_id: 0
|
||||
name: label1
|
||||
color: '#abcdef'
|
||||
exclusive: false
|
||||
num_issues: 2
|
||||
num_closed_issues: 0
|
||||
|
||||
|
@ -13,6 +14,7 @@
|
|||
org_id: 0
|
||||
name: label2
|
||||
color: '#000000'
|
||||
exclusive: false
|
||||
num_issues: 1
|
||||
num_closed_issues: 1
|
||||
-
|
||||
|
@ -21,6 +23,7 @@
|
|||
org_id: 3
|
||||
name: orglabel3
|
||||
color: '#abcdef'
|
||||
exclusive: false
|
||||
num_issues: 0
|
||||
num_closed_issues: 0
|
||||
|
||||
|
@ -30,6 +33,7 @@
|
|||
org_id: 3
|
||||
name: orglabel4
|
||||
color: '#000000'
|
||||
exclusive: false
|
||||
num_issues: 1
|
||||
num_closed_issues: 0
|
||||
|
||||
|
@ -39,5 +43,6 @@
|
|||
org_id: 0
|
||||
name: pull-test-label
|
||||
color: '#000000'
|
||||
exclusive: false
|
||||
num_issues: 0
|
||||
num_closed_issues: 0
|
||||
|
|
|
@ -455,6 +455,14 @@ var migrations = []Migration{
|
|||
NewMigration("Add scope for access_token", v1_19.AddScopeForAccessTokens),
|
||||
// v240 -> v241
|
||||
NewMigration("Add actions tables", v1_19.AddActionsTables),
|
||||
// v241 -> v242
|
||||
NewMigration("Add card_type column to project table", v1_19.AddCardTypeToProjectTable),
|
||||
// v242 -> v243
|
||||
NewMigration("Alter gpg_key_import content TEXT field to MEDIUMTEXT", v1_19.AlterPublicGPGKeyImportContentFieldToMediumText),
|
||||
// v243 -> v244
|
||||
NewMigration("Add exclusive label", v1_19.AddExclusiveLabel),
|
||||
|
||||
// Gitea 1.19.0 ends at v244
|
||||
}
|
||||
|
||||
// GetCurrentDBVersion returns the current db version
|
||||
|
|
17
models/migrations/v1_19/v241.go
Normal file
17
models/migrations/v1_19/v241.go
Normal file
|
@ -0,0 +1,17 @@
|
|||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_19 //nolint
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// AddCardTypeToProjectTable: add CardType column, setting existing rows to CardTypeTextOnly
|
||||
func AddCardTypeToProjectTable(x *xorm.Engine) error {
|
||||
type Project struct {
|
||||
CardType int `xorm:"NOT NULL DEFAULT 0"`
|
||||
}
|
||||
|
||||
return x.Sync(new(Project))
|
||||
}
|
26
models/migrations/v1_19/v242.go
Normal file
26
models/migrations/v1_19/v242.go
Normal file
|
@ -0,0 +1,26 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_19 //nolint
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// AlterPublicGPGKeyImportContentFieldToMediumText: set GPGKeyImport Content field to MEDIUMTEXT
|
||||
func AlterPublicGPGKeyImportContentFieldToMediumText(x *xorm.Engine) error {
|
||||
sess := x.NewSession()
|
||||
defer sess.Close()
|
||||
if err := sess.Begin(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if setting.Database.UseMySQL {
|
||||
if _, err := sess.Exec("ALTER TABLE `gpg_key_import` CHANGE `content` `content` MEDIUMTEXT"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return sess.Commit()
|
||||
}
|
16
models/migrations/v1_19/v243.go
Normal file
16
models/migrations/v1_19/v243.go
Normal file
|
@ -0,0 +1,16 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_19 //nolint
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func AddExclusiveLabel(x *xorm.Engine) error {
|
||||
type Label struct {
|
||||
Exclusive bool
|
||||
}
|
||||
|
||||
return x.Sync(new(Label))
|
||||
}
|
|
@ -156,8 +156,8 @@ func (org *Organization) hasMemberWithUserID(ctx context.Context, userID int64)
|
|||
}
|
||||
|
||||
// AvatarLink returns the full avatar link with http host
|
||||
func (org *Organization) AvatarLink() string {
|
||||
return org.AsUser().AvatarLink()
|
||||
func (org *Organization) AvatarLink(ctx context.Context) string {
|
||||
return org.AsUser().AvatarLink(ctx)
|
||||
}
|
||||
|
||||
// HTMLURL returns the organization's full link.
|
||||
|
|
|
@ -111,12 +111,8 @@ func (t *Team) ColorFormat(s fmt.State) {
|
|||
t.AccessMode)
|
||||
}
|
||||
|
||||
// GetUnits return a list of available units for a team
|
||||
func (t *Team) GetUnits() error {
|
||||
return t.getUnits(db.DefaultContext)
|
||||
}
|
||||
|
||||
func (t *Team) getUnits(ctx context.Context) (err error) {
|
||||
// LoadUnits load a list of available units for a team
|
||||
func (t *Team) LoadUnits(ctx context.Context) (err error) {
|
||||
if t.Units != nil {
|
||||
return nil
|
||||
}
|
||||
|
@ -193,7 +189,7 @@ func (t *Team) UnitEnabled(ctx context.Context, tp unit.Type) bool {
|
|||
|
||||
// UnitAccessMode returns if the team has the given unit type enabled
|
||||
func (t *Team) UnitAccessMode(ctx context.Context, tp unit.Type) perm.AccessMode {
|
||||
if err := t.getUnits(ctx); err != nil {
|
||||
if err := t.LoadUnits(ctx); err != nil {
|
||||
log.Warn("Error loading team (ID: %d) units: %s", t.ID, err.Error())
|
||||
}
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@ type TeamList []*Team
|
|||
|
||||
func (t TeamList) LoadUnits(ctx context.Context) error {
|
||||
for _, team := range t {
|
||||
if err := team.getUnits(ctx); err != nil {
|
||||
if err := team.LoadUnits(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
|
@ -85,8 +85,8 @@ func updateUserAccess(accessMap map[int64]*userAccess, user *user_model.User, mo
|
|||
// FIXME: do cross-comparison so reduce deletions and additions to the minimum?
|
||||
func refreshAccesses(ctx context.Context, repo *repo_model.Repository, accessMap map[int64]*userAccess) (err error) {
|
||||
minMode := perm.AccessModeRead
|
||||
if err := repo.GetOwner(ctx); err != nil {
|
||||
return fmt.Errorf("GetOwner: %w", err)
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return fmt.Errorf("LoadOwner: %w", err)
|
||||
}
|
||||
|
||||
// If the repo isn't private and isn't owned by a organization,
|
||||
|
@ -143,7 +143,7 @@ func refreshCollaboratorAccesses(ctx context.Context, repoID int64, accessMap ma
|
|||
func RecalculateTeamAccesses(ctx context.Context, repo *repo_model.Repository, ignTeamID int64) (err error) {
|
||||
accessMap := make(map[int64]*userAccess, 20)
|
||||
|
||||
if err = repo.GetOwner(ctx); err != nil {
|
||||
if err = repo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
} else if !repo.Owner.IsOrganization() {
|
||||
return fmt.Errorf("owner is not an organization: %d", repo.OwnerID)
|
||||
|
@ -199,7 +199,7 @@ func RecalculateUserAccess(ctx context.Context, repo *repo_model.Repository, uid
|
|||
accessMode = collaborator.Mode
|
||||
}
|
||||
|
||||
if err = repo.GetOwner(ctx); err != nil {
|
||||
if err = repo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
} else if repo.Owner.IsOrganization() {
|
||||
var teams []organization.Team
|
||||
|
|
|
@ -97,7 +97,7 @@ func TestRepository_RecalculateAccesses(t *testing.T) {
|
|||
// test with organization repo
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
assert.NoError(t, repo1.GetOwner(db.DefaultContext))
|
||||
assert.NoError(t, repo1.LoadOwner(db.DefaultContext))
|
||||
|
||||
_, err := db.GetEngine(db.DefaultContext).Delete(&repo_model.Collaboration{UserID: 2, RepoID: 3})
|
||||
assert.NoError(t, err)
|
||||
|
@ -114,7 +114,7 @@ func TestRepository_RecalculateAccesses2(t *testing.T) {
|
|||
// test with non-organization repo
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
assert.NoError(t, repo1.GetOwner(db.DefaultContext))
|
||||
assert.NoError(t, repo1.LoadOwner(db.DefaultContext))
|
||||
|
||||
_, err := db.GetEngine(db.DefaultContext).Delete(&repo_model.Collaboration{UserID: 4, RepoID: 4})
|
||||
assert.NoError(t, err)
|
||||
|
|
|
@ -175,7 +175,7 @@ func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, use
|
|||
}
|
||||
}
|
||||
|
||||
if err = repo.GetOwner(ctx); err != nil {
|
||||
if err = repo.LoadOwner(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -210,7 +210,7 @@ func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, use
|
|||
return
|
||||
}
|
||||
|
||||
if err = repo.GetOwner(ctx); err != nil {
|
||||
if err = repo.LoadOwner(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
if !repo.Owner.IsOrganization() {
|
||||
|
@ -281,7 +281,7 @@ func IsUserRealRepoAdmin(repo *repo_model.Repository, user *user_model.User) (bo
|
|||
return true, nil
|
||||
}
|
||||
|
||||
if err := repo.GetOwner(db.DefaultContext); err != nil {
|
||||
if err := repo.LoadOwner(db.DefaultContext); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
|
@ -378,7 +378,7 @@ func HasAccess(ctx context.Context, userID int64, repo *repo_model.Repository) (
|
|||
|
||||
// getUsersWithAccessMode returns users that have at least given access mode to the repository.
|
||||
func getUsersWithAccessMode(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode) (_ []*user_model.User, err error) {
|
||||
if err = repo.GetOwner(ctx); err != nil {
|
||||
if err = repo.LoadOwner(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,9 @@ type (
|
|||
// BoardType is used to represent a project board type
|
||||
BoardType uint8
|
||||
|
||||
// CardType is used to represent a project board card type
|
||||
CardType uint8
|
||||
|
||||
// BoardList is a list of all project boards in a repository
|
||||
BoardList []*Board
|
||||
)
|
||||
|
@ -34,6 +37,14 @@ const (
|
|||
BoardTypeBugTriage
|
||||
)
|
||||
|
||||
const (
|
||||
// CardTypeTextOnly is a project board card type that is text only
|
||||
CardTypeTextOnly CardType = iota
|
||||
|
||||
// CardTypeImagesAndText is a project board card type that has images and text
|
||||
CardTypeImagesAndText
|
||||
)
|
||||
|
||||
// BoardColorPattern is a regexp witch can validate BoardColor
|
||||
var BoardColorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$")
|
||||
|
||||
|
@ -85,6 +96,16 @@ func IsBoardTypeValid(p BoardType) bool {
|
|||
}
|
||||
}
|
||||
|
||||
// IsCardTypeValid checks if the project board card type is valid
|
||||
func IsCardTypeValid(p CardType) bool {
|
||||
switch p {
|
||||
case CardTypeTextOnly, CardTypeImagesAndText:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func createBoardsForProjectsType(ctx context.Context, project *Project) error {
|
||||
var items []string
|
||||
|
||||
|
|
|
@ -19,12 +19,18 @@ import (
|
|||
)
|
||||
|
||||
type (
|
||||
// ProjectsConfig is used to identify the type of board that is being created
|
||||
ProjectsConfig struct {
|
||||
// BoardConfig is used to identify the type of board that is being created
|
||||
BoardConfig struct {
|
||||
BoardType BoardType
|
||||
Translation string
|
||||
}
|
||||
|
||||
// CardConfig is used to identify the type of board card that is being used
|
||||
CardConfig struct {
|
||||
CardType CardType
|
||||
Translation string
|
||||
}
|
||||
|
||||
// Type is used to identify the type of project in question and ownership
|
||||
Type uint8
|
||||
)
|
||||
|
@ -91,6 +97,7 @@ type Project struct {
|
|||
CreatorID int64 `xorm:"NOT NULL"`
|
||||
IsClosed bool `xorm:"INDEX"`
|
||||
BoardType BoardType
|
||||
CardType CardType
|
||||
Type Type
|
||||
|
||||
RenderedContent string `xorm:"-"`
|
||||
|
@ -145,15 +152,23 @@ func init() {
|
|||
db.RegisterModel(new(Project))
|
||||
}
|
||||
|
||||
// GetProjectsConfig retrieves the types of configurations projects could have
|
||||
func GetProjectsConfig() []ProjectsConfig {
|
||||
return []ProjectsConfig{
|
||||
// GetBoardConfig retrieves the types of configurations project boards could have
|
||||
func GetBoardConfig() []BoardConfig {
|
||||
return []BoardConfig{
|
||||
{BoardTypeNone, "repo.projects.type.none"},
|
||||
{BoardTypeBasicKanban, "repo.projects.type.basic_kanban"},
|
||||
{BoardTypeBugTriage, "repo.projects.type.bug_triage"},
|
||||
}
|
||||
}
|
||||
|
||||
// GetCardConfig retrieves the types of configurations project board cards could have
|
||||
func GetCardConfig() []CardConfig {
|
||||
return []CardConfig{
|
||||
{CardTypeTextOnly, "repo.projects.card_type.text_only"},
|
||||
{CardTypeImagesAndText, "repo.projects.card_type.images_and_text"},
|
||||
}
|
||||
}
|
||||
|
||||
// IsTypeValid checks if a project type is valid
|
||||
func IsTypeValid(p Type) bool {
|
||||
switch p {
|
||||
|
@ -237,6 +252,10 @@ func NewProject(p *Project) error {
|
|||
p.BoardType = BoardTypeNone
|
||||
}
|
||||
|
||||
if !IsCardTypeValid(p.CardType) {
|
||||
p.CardType = CardTypeTextOnly
|
||||
}
|
||||
|
||||
if !IsTypeValid(p.Type) {
|
||||
return util.NewInvalidArgumentErrorf("project type is not valid")
|
||||
}
|
||||
|
@ -280,9 +299,14 @@ func GetProjectByID(ctx context.Context, id int64) (*Project, error) {
|
|||
|
||||
// UpdateProject updates project properties
|
||||
func UpdateProject(ctx context.Context, p *Project) error {
|
||||
if !IsCardTypeValid(p.CardType) {
|
||||
p.CardType = CardTypeTextOnly
|
||||
}
|
||||
|
||||
_, err := db.GetEngine(ctx).ID(p.ID).Cols(
|
||||
"title",
|
||||
"description",
|
||||
"card_type",
|
||||
).Update(p)
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -53,6 +53,7 @@ func TestProject(t *testing.T) {
|
|||
project := &Project{
|
||||
Type: TypeRepository,
|
||||
BoardType: BoardTypeBasicKanban,
|
||||
CardType: CardTypeTextOnly,
|
||||
Title: "New Project",
|
||||
RepoID: 1,
|
||||
CreatedUnix: timeutil.TimeStampNow(),
|
||||
|
|
|
@ -132,6 +132,21 @@ func GetAttachmentsByIssueID(ctx context.Context, issueID int64) ([]*Attachment,
|
|||
return attachments, db.GetEngine(ctx).Where("issue_id = ? AND comment_id = 0", issueID).Find(&attachments)
|
||||
}
|
||||
|
||||
// GetAttachmentsByIssueIDImagesLatest returns the latest image attachments of an issue.
|
||||
func GetAttachmentsByIssueIDImagesLatest(ctx context.Context, issueID int64) ([]*Attachment, error) {
|
||||
attachments := make([]*Attachment, 0, 5)
|
||||
return attachments, db.GetEngine(ctx).Where(`issue_id = ? AND (name like '%.apng'
|
||||
OR name like '%.avif'
|
||||
OR name like '%.bmp'
|
||||
OR name like '%.gif'
|
||||
OR name like '%.jpg'
|
||||
OR name like '%.jpeg'
|
||||
OR name like '%.jxl'
|
||||
OR name like '%.png'
|
||||
OR name like '%.svg'
|
||||
OR name like '%.webp')`, issueID).Desc("comment_id").Limit(5).Find(&attachments)
|
||||
}
|
||||
|
||||
// GetAttachmentsByCommentID returns all attachments if comment by given ID.
|
||||
func GetAttachmentsByCommentID(ctx context.Context, commentID int64) ([]*Attachment, error) {
|
||||
attachments := make([]*Attachment, 0, 10)
|
||||
|
|
|
@ -85,12 +85,7 @@ func (repo *Repository) relAvatarLink(ctx context.Context) string {
|
|||
}
|
||||
|
||||
// AvatarLink returns a link to the repository's avatar.
|
||||
func (repo *Repository) AvatarLink() string {
|
||||
return repo.avatarLink(db.DefaultContext)
|
||||
}
|
||||
|
||||
// avatarLink returns user avatar absolute link.
|
||||
func (repo *Repository) avatarLink(ctx context.Context) string {
|
||||
func (repo *Repository) AvatarLink(ctx context.Context) string {
|
||||
link := repo.relAvatarLink(ctx)
|
||||
// we only prepend our AppURL to our known (relative, internal) avatar link to get an absolute URL
|
||||
if strings.HasPrefix(link, "/") && !strings.HasPrefix(link, "//") {
|
||||
|
|
|
@ -238,7 +238,7 @@ func (repo *Repository) AfterLoad() {
|
|||
// LoadAttributes loads attributes of the repository.
|
||||
func (repo *Repository) LoadAttributes(ctx context.Context) error {
|
||||
// Load owner
|
||||
if err := repo.GetOwner(ctx); err != nil {
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return fmt.Errorf("load owner: %w", err)
|
||||
}
|
||||
|
||||
|
@ -275,7 +275,7 @@ func (repo *Repository) CommitLink(commitID string) (result string) {
|
|||
if commitID == "" || commitID == "0000000000000000000000000000000000000000" {
|
||||
result = ""
|
||||
} else {
|
||||
result = repo.HTMLURL() + "/commit/" + url.PathEscape(commitID)
|
||||
result = repo.Link() + "/commit/" + url.PathEscape(commitID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
@ -374,8 +374,8 @@ func (repo *Repository) GetUnit(ctx context.Context, tp unit.Type) (*RepoUnit, e
|
|||
return nil, ErrUnitTypeNotExist{tp}
|
||||
}
|
||||
|
||||
// GetOwner returns the repository owner
|
||||
func (repo *Repository) GetOwner(ctx context.Context) (err error) {
|
||||
// LoadOwner loads owner user
|
||||
func (repo *Repository) LoadOwner(ctx context.Context) (err error) {
|
||||
if repo.Owner != nil {
|
||||
return nil
|
||||
}
|
||||
|
@ -389,7 +389,7 @@ func (repo *Repository) GetOwner(ctx context.Context) (err error) {
|
|||
// It creates a fake object that contains error details
|
||||
// when error occurs.
|
||||
func (repo *Repository) MustOwner(ctx context.Context) *user_model.User {
|
||||
if err := repo.GetOwner(ctx); err != nil {
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return &user_model.User{
|
||||
Name: "error",
|
||||
FullName: err.Error(),
|
||||
|
|
|
@ -125,6 +125,7 @@ type PullRequestsConfig struct {
|
|||
AllowRebaseUpdate bool
|
||||
DefaultDeleteBranchAfterMerge bool
|
||||
DefaultMergeStyle MergeStyle
|
||||
DefaultAllowMaintainerEdit bool
|
||||
}
|
||||
|
||||
// FromDB fills up a PullRequestsConfig from serialized format.
|
||||
|
|
|
@ -143,7 +143,7 @@ func ChangeRepositoryName(doer *user_model.User, repo *Repository, newRepoName s
|
|||
return err
|
||||
}
|
||||
|
||||
if err := repo.GetOwner(db.DefaultContext); err != nil {
|
||||
if err := repo.LoadOwner(db.DefaultContext); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
@ -61,7 +61,7 @@ func GetWatchedRepos(ctx context.Context, userID int64, private bool, listOption
|
|||
// GetRepoAssignees returns all users that have write access and can be assigned to issues
|
||||
// of the repository,
|
||||
func GetRepoAssignees(ctx context.Context, repo *Repository) (_ []*user_model.User, err error) {
|
||||
if err = repo.GetOwner(ctx); err != nil {
|
||||
if err = repo.LoadOwner(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
@ -111,7 +111,7 @@ func GetRepoAssignees(ctx context.Context, repo *Repository) (_ []*user_model.Us
|
|||
// TODO: may be we should have a busy choice for users to block review request to them.
|
||||
func GetReviewers(ctx context.Context, repo *Repository, doerID, posterID int64) ([]*user_model.User, error) {
|
||||
// Get the owner of the repository - this often already pre-cached and if so saves complexity for the following queries
|
||||
if err := repo.GetOwner(ctx); err != nil {
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ func TestRepository_DeleteCollaboration(t *testing.T) {
|
|||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
assert.NoError(t, repo.GetOwner(db.DefaultContext))
|
||||
assert.NoError(t, repo.LoadOwner(db.DefaultContext))
|
||||
assert.NoError(t, DeleteCollaboration(repo, 4))
|
||||
unittest.AssertNotExistsBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: 4})
|
||||
|
||||
|
|
|
@ -80,8 +80,8 @@ func IsErrDataExpired(err error) bool {
|
|||
}
|
||||
|
||||
// GetSettingNoCache returns specific setting without using the cache
|
||||
func GetSettingNoCache(key string) (*Setting, error) {
|
||||
v, err := GetSettings([]string{key})
|
||||
func GetSettingNoCache(ctx context.Context, key string) (*Setting, error) {
|
||||
v, err := GetSettings(ctx, []string{key})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -91,27 +91,31 @@ func GetSettingNoCache(key string) (*Setting, error) {
|
|||
return v[strings.ToLower(key)], nil
|
||||
}
|
||||
|
||||
const contextCacheKey = "system_setting"
|
||||
|
||||
// GetSetting returns the setting value via the key
|
||||
func GetSetting(key string) (string, error) {
|
||||
return cache.GetString(genSettingCacheKey(key), func() (string, error) {
|
||||
res, err := GetSettingNoCache(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return res.SettingValue, nil
|
||||
func GetSetting(ctx context.Context, key string) (string, error) {
|
||||
return cache.GetWithContextCache(ctx, contextCacheKey, key, func() (string, error) {
|
||||
return cache.GetString(genSettingCacheKey(key), func() (string, error) {
|
||||
res, err := GetSettingNoCache(ctx, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return res.SettingValue, nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// GetSettingBool return bool value of setting,
|
||||
// none existing keys and errors are ignored and result in false
|
||||
func GetSettingBool(key string) bool {
|
||||
s, _ := GetSetting(key)
|
||||
func GetSettingBool(ctx context.Context, key string) bool {
|
||||
s, _ := GetSetting(ctx, key)
|
||||
v, _ := strconv.ParseBool(s)
|
||||
return v
|
||||
}
|
||||
|
||||
// GetSettings returns specific settings
|
||||
func GetSettings(keys []string) (map[string]*Setting, error) {
|
||||
func GetSettings(ctx context.Context, keys []string) (map[string]*Setting, error) {
|
||||
for i := 0; i < len(keys); i++ {
|
||||
keys[i] = strings.ToLower(keys[i])
|
||||
}
|
||||
|
@ -161,16 +165,17 @@ func GetAllSettings() (AllSettings, error) {
|
|||
}
|
||||
|
||||
// DeleteSetting deletes a specific setting for a user
|
||||
func DeleteSetting(setting *Setting) error {
|
||||
func DeleteSetting(ctx context.Context, setting *Setting) error {
|
||||
cache.RemoveContextData(ctx, contextCacheKey, setting.SettingKey)
|
||||
cache.Remove(genSettingCacheKey(setting.SettingKey))
|
||||
_, err := db.GetEngine(db.DefaultContext).Delete(setting)
|
||||
return err
|
||||
}
|
||||
|
||||
func SetSettingNoVersion(key, value string) error {
|
||||
s, err := GetSettingNoCache(key)
|
||||
func SetSettingNoVersion(ctx context.Context, key, value string) error {
|
||||
s, err := GetSettingNoCache(ctx, key)
|
||||
if IsErrSettingIsNotExist(err) {
|
||||
return SetSetting(&Setting{
|
||||
return SetSetting(ctx, &Setting{
|
||||
SettingKey: key,
|
||||
SettingValue: value,
|
||||
})
|
||||
|
@ -179,11 +184,11 @@ func SetSettingNoVersion(key, value string) error {
|
|||
return err
|
||||
}
|
||||
s.SettingValue = value
|
||||
return SetSetting(s)
|
||||
return SetSetting(ctx, s)
|
||||
}
|
||||
|
||||
// SetSetting updates a users' setting for a specific key
|
||||
func SetSetting(setting *Setting) error {
|
||||
func SetSetting(ctx context.Context, setting *Setting) error {
|
||||
if err := upsertSettingValue(strings.ToLower(setting.SettingKey), setting.SettingValue, setting.Version); err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -192,9 +197,11 @@ func SetSetting(setting *Setting) error {
|
|||
|
||||
cc := cache.GetCache()
|
||||
if cc != nil {
|
||||
return cc.Put(genSettingCacheKey(setting.SettingKey), setting.SettingValue, setting_module.CacheService.TTLSeconds())
|
||||
if err := cc.Put(genSettingCacheKey(setting.SettingKey), setting.SettingValue, setting_module.CacheService.TTLSeconds()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
cache.SetContextData(ctx, contextCacheKey, setting.SettingKey, setting.SettingValue)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -244,7 +251,7 @@ var (
|
|||
|
||||
func Init() error {
|
||||
var disableGravatar bool
|
||||
disableGravatarSetting, err := GetSettingNoCache(KeyPictureDisableGravatar)
|
||||
disableGravatarSetting, err := GetSettingNoCache(db.DefaultContext, KeyPictureDisableGravatar)
|
||||
if IsErrSettingIsNotExist(err) {
|
||||
disableGravatar = setting_module.GetDefaultDisableGravatar()
|
||||
disableGravatarSetting = &Setting{SettingValue: strconv.FormatBool(disableGravatar)}
|
||||
|
@ -255,7 +262,7 @@ func Init() error {
|
|||
}
|
||||
|
||||
var enableFederatedAvatar bool
|
||||
enableFederatedAvatarSetting, err := GetSettingNoCache(KeyPictureEnableFederatedAvatar)
|
||||
enableFederatedAvatarSetting, err := GetSettingNoCache(db.DefaultContext, KeyPictureEnableFederatedAvatar)
|
||||
if IsErrSettingIsNotExist(err) {
|
||||
enableFederatedAvatar = setting_module.GetDefaultEnableFederatedAvatar(disableGravatar)
|
||||
enableFederatedAvatarSetting = &Setting{SettingValue: strconv.FormatBool(enableFederatedAvatar)}
|
||||
|
@ -268,13 +275,13 @@ func Init() error {
|
|||
if setting_module.OfflineMode {
|
||||
disableGravatar = true
|
||||
enableFederatedAvatar = false
|
||||
if !GetSettingBool(KeyPictureDisableGravatar) {
|
||||
if err := SetSettingNoVersion(KeyPictureDisableGravatar, "true"); err != nil {
|
||||
if !GetSettingBool(db.DefaultContext, KeyPictureDisableGravatar) {
|
||||
if err := SetSettingNoVersion(db.DefaultContext, KeyPictureDisableGravatar, "true"); err != nil {
|
||||
return fmt.Errorf("Failed to set setting %q: %w", KeyPictureDisableGravatar, err)
|
||||
}
|
||||
}
|
||||
if GetSettingBool(KeyPictureEnableFederatedAvatar) {
|
||||
if err := SetSettingNoVersion(KeyPictureEnableFederatedAvatar, "false"); err != nil {
|
||||
if GetSettingBool(db.DefaultContext, KeyPictureEnableFederatedAvatar) {
|
||||
if err := SetSettingNoVersion(db.DefaultContext, KeyPictureEnableFederatedAvatar, "false"); err != nil {
|
||||
return fmt.Errorf("Failed to set setting %q: %w", KeyPictureEnableFederatedAvatar, err)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/system"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
|
||||
|
@ -20,24 +21,24 @@ func TestSettings(t *testing.T) {
|
|||
newSetting := &system.Setting{SettingKey: keyName, SettingValue: "50"}
|
||||
|
||||
// create setting
|
||||
err := system.SetSetting(newSetting)
|
||||
err := system.SetSetting(db.DefaultContext, newSetting)
|
||||
assert.NoError(t, err)
|
||||
// test about saving unchanged values
|
||||
err = system.SetSetting(newSetting)
|
||||
err = system.SetSetting(db.DefaultContext, newSetting)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// get specific setting
|
||||
settings, err := system.GetSettings([]string{keyName})
|
||||
settings, err := system.GetSettings(db.DefaultContext, []string{keyName})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, settings, 1)
|
||||
assert.EqualValues(t, newSetting.SettingValue, settings[strings.ToLower(keyName)].SettingValue)
|
||||
|
||||
// updated setting
|
||||
updatedSetting := &system.Setting{SettingKey: keyName, SettingValue: "100", Version: settings[strings.ToLower(keyName)].Version}
|
||||
err = system.SetSetting(updatedSetting)
|
||||
err = system.SetSetting(db.DefaultContext, updatedSetting)
|
||||
assert.NoError(t, err)
|
||||
|
||||
value, err := system.GetSetting(keyName)
|
||||
value, err := system.GetSetting(db.DefaultContext, keyName)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, updatedSetting.SettingValue, value)
|
||||
|
||||
|
@ -48,7 +49,7 @@ func TestSettings(t *testing.T) {
|
|||
assert.EqualValues(t, updatedSetting.SettingValue, settings[strings.ToLower(updatedSetting.SettingKey)].SettingValue)
|
||||
|
||||
// delete setting
|
||||
err = system.DeleteSetting(&system.Setting{SettingKey: strings.ToLower(keyName)})
|
||||
err = system.DeleteSetting(db.DefaultContext, &system.Setting{SettingKey: strings.ToLower(keyName)})
|
||||
assert.NoError(t, err)
|
||||
settings, err = system.GetAllSettings()
|
||||
assert.NoError(t, err)
|
||||
|
|
|
@ -9,6 +9,8 @@ import (
|
|||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/auth/password/hash"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
"github.com/go-testfixtures/testfixtures/v3"
|
||||
"xorm.io/xorm"
|
||||
|
@ -64,6 +66,11 @@ func InitFixtures(opts FixturesOptions, engine ...*xorm.Engine) (err error) {
|
|||
return err
|
||||
}
|
||||
|
||||
// register the dummy hash algorithm function used in the test fixtures
|
||||
_ = hash.Register("dummy", hash.NewDummyHasher)
|
||||
|
||||
setting.PasswordHashAlgo, _ = hash.SetDefaultPasswordHashAlgorithm("dummy")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -115,5 +122,8 @@ func LoadFixtures(engine ...*xorm.Engine) error {
|
|||
}
|
||||
}
|
||||
}
|
||||
_ = hash.Register("dummy", hash.NewDummyHasher)
|
||||
setting.PasswordHashAlgo, _ = hash.SetDefaultPasswordHashAlgorithm("dummy")
|
||||
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -58,7 +58,7 @@ func GenerateRandomAvatar(ctx context.Context, u *User) error {
|
|||
}
|
||||
|
||||
// AvatarLinkWithSize returns a link to the user's avatar with size. size <= 0 means default size
|
||||
func (u *User) AvatarLinkWithSize(size int) string {
|
||||
func (u *User) AvatarLinkWithSize(ctx context.Context, size int) string {
|
||||
if u.ID == -1 {
|
||||
// ghost user
|
||||
return avatars.DefaultAvatarLink()
|
||||
|
@ -67,7 +67,7 @@ func (u *User) AvatarLinkWithSize(size int) string {
|
|||
useLocalAvatar := false
|
||||
autoGenerateAvatar := false
|
||||
|
||||
disableGravatar := system_model.GetSettingBool(system_model.KeyPictureDisableGravatar)
|
||||
disableGravatar := system_model.GetSettingBool(ctx, system_model.KeyPictureDisableGravatar)
|
||||
|
||||
switch {
|
||||
case u.UseCustomAvatar:
|
||||
|
@ -79,7 +79,7 @@ func (u *User) AvatarLinkWithSize(size int) string {
|
|||
|
||||
if useLocalAvatar {
|
||||
if u.Avatar == "" && autoGenerateAvatar {
|
||||
if err := GenerateRandomAvatar(db.DefaultContext, u); err != nil {
|
||||
if err := GenerateRandomAvatar(ctx, u); err != nil {
|
||||
log.Error("GenerateRandomAvatar: %v", err)
|
||||
}
|
||||
}
|
||||
|
@ -88,12 +88,12 @@ func (u *User) AvatarLinkWithSize(size int) string {
|
|||
}
|
||||
return avatars.GenerateUserAvatarImageLink(u.Avatar, size)
|
||||
}
|
||||
return avatars.GenerateEmailAvatarFastLink(u.AvatarEmail, size)
|
||||
return avatars.GenerateEmailAvatarFastLink(ctx, u.AvatarEmail, size)
|
||||
}
|
||||
|
||||
// AvatarLink returns the full avatar link with http host
|
||||
func (u *User) AvatarLink() string {
|
||||
link := u.AvatarLinkWithSize(0)
|
||||
func (u *User) AvatarLink(ctx context.Context) string {
|
||||
link := u.AvatarLinkWithSize(ctx, 0)
|
||||
if !strings.HasPrefix(link, "//") && !strings.Contains(link, "://") {
|
||||
return setting.AppURL + strings.TrimPrefix(link, setting.AppSubURL+"/")
|
||||
}
|
||||
|
@ -101,8 +101,8 @@ func (u *User) AvatarLink() string {
|
|||
}
|
||||
|
||||
// AvatarFullLinkWithSize returns the full avatar link with size and http host
|
||||
func (u *User) AvatarFullLinkWithSize(size int) string {
|
||||
link := u.AvatarLinkWithSize(size)
|
||||
func (u *User) AvatarFullLinkWithSize(ctx context.Context, size int) string {
|
||||
link := u.AvatarLinkWithSize(ctx, size)
|
||||
if !strings.HasPrefix(link, "//") && !strings.Contains(link, "://") {
|
||||
return setting.AppURL + strings.TrimPrefix(link, setting.AppSubURL+"/")
|
||||
}
|
||||
|
|
49
models/user/must_change_password.go
Normal file
49
models/user/must_change_password.go
Normal file
|
@ -0,0 +1,49 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
func SetMustChangePassword(ctx context.Context, all, mustChangePassword bool, include, exclude []string) (int64, error) {
|
||||
sliceTrimSpaceDropEmpty := func(input []string) []string {
|
||||
output := make([]string, 0, len(input))
|
||||
for _, in := range input {
|
||||
in = strings.ToLower(strings.TrimSpace(in))
|
||||
if in == "" {
|
||||
continue
|
||||
}
|
||||
output = append(output, in)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
var cond builder.Cond
|
||||
|
||||
// Only include the users where something changes to get an accurate count
|
||||
cond = builder.Neq{"must_change_password": mustChangePassword}
|
||||
|
||||
if !all {
|
||||
include = sliceTrimSpaceDropEmpty(include)
|
||||
if len(include) == 0 {
|
||||
return 0, util.NewSilentWrapErrorf(util.ErrInvalidArgument, "no users to include provided")
|
||||
}
|
||||
|
||||
cond = cond.And(builder.In("lower_name", include))
|
||||
}
|
||||
|
||||
exclude = sliceTrimSpaceDropEmpty(exclude)
|
||||
if len(exclude) > 0 {
|
||||
cond = cond.And(builder.NotIn("lower_name", exclude))
|
||||
}
|
||||
|
||||
return db.GetEngine(ctx).Where(cond).MustCols("must_change_password").Update(&User{MustChangePassword: mustChangePassword})
|
||||
}
|
|
@ -6,8 +6,6 @@ package user
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
@ -22,6 +20,7 @@ import (
|
|||
"code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/auth/openid"
|
||||
"code.gitea.io/gitea/modules/auth/password/hash"
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
|
@ -31,10 +30,6 @@ import (
|
|||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/validation"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
"golang.org/x/crypto/scrypt"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
|
@ -49,21 +44,6 @@ const (
|
|||
UserTypeOrganization
|
||||
)
|
||||
|
||||
const (
|
||||
algoBcrypt = "bcrypt"
|
||||
algoScrypt = "scrypt"
|
||||
algoArgon2 = "argon2"
|
||||
algoPbkdf2 = "pbkdf2"
|
||||
)
|
||||
|
||||
// AvailableHashAlgorithms represents the available password hashing algorithms
|
||||
var AvailableHashAlgorithms = []string{
|
||||
algoPbkdf2,
|
||||
algoArgon2,
|
||||
algoScrypt,
|
||||
algoBcrypt,
|
||||
}
|
||||
|
||||
const (
|
||||
// EmailNotificationsEnabled indicates that the user would like to receive all email notifications except your own
|
||||
EmailNotificationsEnabled = "enabled"
|
||||
|
@ -378,42 +358,6 @@ func (u *User) NewGitSig() *git.Signature {
|
|||
}
|
||||
}
|
||||
|
||||
func hashPassword(passwd, salt, algo string) (string, error) {
|
||||
var tempPasswd []byte
|
||||
var saltBytes []byte
|
||||
|
||||
// There are two formats for the Salt value:
|
||||
// * The new format is a (32+)-byte hex-encoded string
|
||||
// * The old format was a 10-byte binary format
|
||||
// We have to tolerate both here but Authenticate should
|
||||
// regenerate the Salt following a successful validation.
|
||||
if len(salt) == 10 {
|
||||
saltBytes = []byte(salt)
|
||||
} else {
|
||||
var err error
|
||||
saltBytes, err = hex.DecodeString(salt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
switch algo {
|
||||
case algoBcrypt:
|
||||
tempPasswd, _ = bcrypt.GenerateFromPassword([]byte(passwd), bcrypt.DefaultCost)
|
||||
return string(tempPasswd), nil
|
||||
case algoScrypt:
|
||||
tempPasswd, _ = scrypt.Key([]byte(passwd), saltBytes, 65536, 16, 2, 50)
|
||||
case algoArgon2:
|
||||
tempPasswd = argon2.IDKey([]byte(passwd), saltBytes, 2, 65536, 8, 50)
|
||||
case algoPbkdf2:
|
||||
fallthrough
|
||||
default:
|
||||
tempPasswd = pbkdf2.Key([]byte(passwd), saltBytes, 10000, 50, sha256.New)
|
||||
}
|
||||
|
||||
return hex.EncodeToString(tempPasswd), nil
|
||||
}
|
||||
|
||||
// SetPassword hashes a password using the algorithm defined in the config value of PASSWORD_HASH_ALGO
|
||||
// change passwd, salt and passwd_hash_algo fields
|
||||
func (u *User) SetPassword(passwd string) (err error) {
|
||||
|
@ -427,7 +371,7 @@ func (u *User) SetPassword(passwd string) (err error) {
|
|||
if u.Salt, err = GetUserSalt(); err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Passwd, err = hashPassword(passwd, u.Salt, setting.PasswordHashAlgo); err != nil {
|
||||
if u.Passwd, err = hash.Parse(setting.PasswordHashAlgo).Hash(passwd, u.Salt); err != nil {
|
||||
return err
|
||||
}
|
||||
u.PasswdHashAlgo = setting.PasswordHashAlgo
|
||||
|
@ -435,20 +379,9 @@ func (u *User) SetPassword(passwd string) (err error) {
|
|||
return nil
|
||||
}
|
||||
|
||||
// ValidatePassword checks if given password matches the one belongs to the user.
|
||||
// ValidatePassword checks if the given password matches the one belonging to the user.
|
||||
func (u *User) ValidatePassword(passwd string) bool {
|
||||
tempHash, err := hashPassword(passwd, u.Salt, u.PasswdHashAlgo)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if u.PasswdHashAlgo != algoBcrypt && subtle.ConstantTimeCompare([]byte(u.Passwd), []byte(tempHash)) == 1 {
|
||||
return true
|
||||
}
|
||||
if u.PasswdHashAlgo == algoBcrypt && bcrypt.CompareHashAndPassword([]byte(u.Passwd), []byte(passwd)) == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return hash.Parse(u.PasswdHashAlgo).VerifyPassword(passwd, u.Passwd, u.Salt)
|
||||
}
|
||||
|
||||
// IsPasswordSet checks if the password is set or left empty
|
||||
|
@ -641,6 +574,11 @@ func CreateUser(u *User, overwriteDefault ...*CreateUserOverwriteOptions) (err e
|
|||
u.IsRestricted = setting.Service.DefaultUserIsRestricted
|
||||
u.IsActive = !(setting.Service.RegisterEmailConfirm || setting.Service.RegisterManualConfirm)
|
||||
|
||||
// Ensure consistency of the dates.
|
||||
if u.UpdatedUnix < u.CreatedUnix {
|
||||
u.UpdatedUnix = u.CreatedUnix
|
||||
}
|
||||
|
||||
// overwrite defaults if set
|
||||
if len(overwriteDefault) != 0 && overwriteDefault[0] != nil {
|
||||
overwrite := overwriteDefault[0]
|
||||
|
@ -718,7 +656,15 @@ func CreateUser(u *User, overwriteDefault ...*CreateUserOverwriteOptions) (err e
|
|||
return err
|
||||
}
|
||||
|
||||
if err = db.Insert(ctx, u); err != nil {
|
||||
if u.CreatedUnix == 0 {
|
||||
// Caller expects auto-time for creation & update timestamps.
|
||||
err = db.Insert(ctx, u)
|
||||
} else {
|
||||
// Caller sets the timestamps themselves. They are responsible for ensuring
|
||||
// both `CreatedUnix` and `UpdatedUnix` are set appropriately.
|
||||
_, err = db.GetEngine(ctx).NoAutoTime().Insert(u)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -1138,11 +1084,11 @@ type UserCommit struct { //revive:disable-line:exported
|
|||
}
|
||||
|
||||
// ValidateCommitWithEmail check if author's e-mail of commit is corresponding to a user.
|
||||
func ValidateCommitWithEmail(c *git.Commit) *User {
|
||||
func ValidateCommitWithEmail(ctx context.Context, c *git.Commit) *User {
|
||||
if c.Author == nil {
|
||||
return nil
|
||||
}
|
||||
u, err := GetUserByEmail(c.Author.Email)
|
||||
u, err := GetUserByEmail(ctx, c.Author.Email)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
@ -1150,7 +1096,7 @@ func ValidateCommitWithEmail(c *git.Commit) *User {
|
|||
}
|
||||
|
||||
// ValidateCommitsWithEmails checks if authors' e-mails of commits are corresponding to users.
|
||||
func ValidateCommitsWithEmails(oldCommits []*git.Commit) []*UserCommit {
|
||||
func ValidateCommitsWithEmails(ctx context.Context, oldCommits []*git.Commit) []*UserCommit {
|
||||
var (
|
||||
emails = make(map[string]*User)
|
||||
newCommits = make([]*UserCommit, 0, len(oldCommits))
|
||||
|
@ -1159,7 +1105,7 @@ func ValidateCommitsWithEmails(oldCommits []*git.Commit) []*UserCommit {
|
|||
var u *User
|
||||
if c.Author != nil {
|
||||
if v, ok := emails[c.Author.Email]; !ok {
|
||||
u, _ = GetUserByEmail(c.Author.Email)
|
||||
u, _ = GetUserByEmail(ctx, c.Author.Email)
|
||||
emails[c.Author.Email] = u
|
||||
} else {
|
||||
u = v
|
||||
|
@ -1175,12 +1121,7 @@ func ValidateCommitsWithEmails(oldCommits []*git.Commit) []*UserCommit {
|
|||
}
|
||||
|
||||
// GetUserByEmail returns the user object by given e-mail if exists.
|
||||
func GetUserByEmail(email string) (*User, error) {
|
||||
return GetUserByEmailContext(db.DefaultContext, email)
|
||||
}
|
||||
|
||||
// GetUserByEmailContext returns the user object by given e-mail if exists with db context
|
||||
func GetUserByEmailContext(ctx context.Context, email string) (*User, error) {
|
||||
func GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
if len(email) == 0 {
|
||||
return nil, ErrUserNotExist{0, email, 0}
|
||||
}
|
||||
|
|
|
@ -4,16 +4,20 @@
|
|||
package user_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/auth/password/hash"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
@ -161,7 +165,7 @@ func TestEmailNotificationPreferences(t *testing.T) {
|
|||
func TestHashPasswordDeterministic(t *testing.T) {
|
||||
b := make([]byte, 16)
|
||||
u := &user_model.User{}
|
||||
algos := []string{"argon2", "pbkdf2", "scrypt", "bcrypt"}
|
||||
algos := hash.RecommendedHashAlgorithms
|
||||
for j := 0; j < len(algos); j++ {
|
||||
u.PasswdHashAlgo = algos[j]
|
||||
for i := 0; i < 50; i++ {
|
||||
|
@ -252,6 +256,58 @@ func TestCreateUserEmailAlreadyUsed(t *testing.T) {
|
|||
assert.True(t, user_model.IsErrEmailAlreadyUsed(err))
|
||||
}
|
||||
|
||||
func TestCreateUserCustomTimestamps(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
// Add new user with a custom creation timestamp.
|
||||
var creationTimestamp timeutil.TimeStamp = 12345
|
||||
user.Name = "testuser"
|
||||
user.LowerName = strings.ToLower(user.Name)
|
||||
user.ID = 0
|
||||
user.Email = "unique@example.com"
|
||||
user.CreatedUnix = creationTimestamp
|
||||
err := user_model.CreateUser(user)
|
||||
assert.NoError(t, err)
|
||||
|
||||
fetched, err := user_model.GetUserByID(context.Background(), user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, creationTimestamp, fetched.CreatedUnix)
|
||||
assert.Equal(t, creationTimestamp, fetched.UpdatedUnix)
|
||||
}
|
||||
|
||||
func TestCreateUserWithoutCustomTimestamps(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
// There is no way to use a mocked time for the XORM auto-time functionality,
|
||||
// so use the real clock to approximate the expected timestamp.
|
||||
timestampStart := time.Now().Unix()
|
||||
|
||||
// Add new user without a custom creation timestamp.
|
||||
user.Name = "Testuser"
|
||||
user.LowerName = strings.ToLower(user.Name)
|
||||
user.ID = 0
|
||||
user.Email = "unique@example.com"
|
||||
user.CreatedUnix = 0
|
||||
user.UpdatedUnix = 0
|
||||
err := user_model.CreateUser(user)
|
||||
assert.NoError(t, err)
|
||||
|
||||
timestampEnd := time.Now().Unix()
|
||||
|
||||
fetched, err := user_model.GetUserByID(context.Background(), user.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.LessOrEqual(t, timestampStart, fetched.CreatedUnix)
|
||||
assert.LessOrEqual(t, fetched.CreatedUnix, timestampEnd)
|
||||
|
||||
assert.LessOrEqual(t, timestampStart, fetched.UpdatedUnix)
|
||||
assert.LessOrEqual(t, fetched.UpdatedUnix, timestampEnd)
|
||||
}
|
||||
|
||||
func TestGetUserIDsByNames(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
|
|
80
modules/auth/password/hash/argon2.go
Normal file
80
modules/auth/password/hash/argon2.go
Normal file
|
@ -0,0 +1,80 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MustRegister("argon2", NewArgon2Hasher)
|
||||
}
|
||||
|
||||
// Argon2Hasher implements PasswordHasher
|
||||
// and uses the Argon2 key derivation function, hybrant variant
|
||||
type Argon2Hasher struct {
|
||||
time uint32
|
||||
memory uint32
|
||||
threads uint8
|
||||
keyLen uint32
|
||||
}
|
||||
|
||||
// HashWithSaltBytes a provided password and salt
|
||||
func (hasher *Argon2Hasher) HashWithSaltBytes(password string, salt []byte) string {
|
||||
if hasher == nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(argon2.IDKey([]byte(password), salt, hasher.time, hasher.memory, hasher.threads, hasher.keyLen))
|
||||
}
|
||||
|
||||
// NewArgon2Hasher is a factory method to create an Argon2Hasher
|
||||
// The provided config should be either empty or of the form:
|
||||
// "<time>$<memory>$<threads>$<keyLen>", where <x> is the string representation
|
||||
// of an integer
|
||||
func NewArgon2Hasher(config string) *Argon2Hasher {
|
||||
// This default configuration uses the following parameters:
|
||||
// time=2, memory=64*1024, threads=8, keyLen=50.
|
||||
// It will make two passes through the memory, using 64MiB in total.
|
||||
// This matches the original configuration for `argon2` prior to storing hash parameters
|
||||
// in the database.
|
||||
// THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK
|
||||
hasher := &Argon2Hasher{
|
||||
time: 2,
|
||||
memory: 1 << 16,
|
||||
threads: 8,
|
||||
keyLen: 50,
|
||||
}
|
||||
|
||||
if config == "" {
|
||||
return hasher
|
||||
}
|
||||
|
||||
vals := strings.SplitN(config, "$", 4)
|
||||
if len(vals) != 4 {
|
||||
log.Error("invalid argon2 hash spec %s", config)
|
||||
return nil
|
||||
}
|
||||
|
||||
parsed, err := parseUIntParam(vals[0], "time", "argon2", config, nil)
|
||||
hasher.time = uint32(parsed)
|
||||
|
||||
parsed, err = parseUIntParam(vals[1], "memory", "argon2", config, err)
|
||||
hasher.memory = uint32(parsed)
|
||||
|
||||
parsed, err = parseUIntParam(vals[2], "threads", "argon2", config, err)
|
||||
hasher.threads = uint8(parsed)
|
||||
|
||||
parsed, err = parseUIntParam(vals[3], "keyLen", "argon2", config, err)
|
||||
hasher.keyLen = uint32(parsed)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return hasher
|
||||
}
|
54
modules/auth/password/hash/bcrypt.go
Normal file
54
modules/auth/password/hash/bcrypt.go
Normal file
|
@ -0,0 +1,54 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MustRegister("bcrypt", NewBcryptHasher)
|
||||
}
|
||||
|
||||
// BcryptHasher implements PasswordHasher
|
||||
// and uses the bcrypt password hash function.
|
||||
type BcryptHasher struct {
|
||||
cost int
|
||||
}
|
||||
|
||||
// HashWithSaltBytes a provided password and salt
|
||||
func (hasher *BcryptHasher) HashWithSaltBytes(password string, salt []byte) string {
|
||||
if hasher == nil {
|
||||
return ""
|
||||
}
|
||||
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), hasher.cost)
|
||||
return string(hashedPassword)
|
||||
}
|
||||
|
||||
func (hasher *BcryptHasher) VerifyPassword(password, hashedPassword, salt string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) == nil
|
||||
}
|
||||
|
||||
// NewBcryptHasher is a factory method to create an BcryptHasher
|
||||
// The provided config should be either empty or the string representation of the "<cost>"
|
||||
// as an integer
|
||||
func NewBcryptHasher(config string) *BcryptHasher {
|
||||
// This matches the original configuration for `bcrypt` prior to storing hash parameters
|
||||
// in the database.
|
||||
// THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK
|
||||
hasher := &BcryptHasher{
|
||||
cost: 10, // cost=10. i.e. 2^10 rounds of key expansion.
|
||||
}
|
||||
|
||||
if config == "" {
|
||||
return hasher
|
||||
}
|
||||
var err error
|
||||
hasher.cost, err = parseIntParam(config, "cost", "bcrypt", config, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return hasher
|
||||
}
|
28
modules/auth/password/hash/common.go
Normal file
28
modules/auth/password/hash/common.go
Normal file
|
@ -0,0 +1,28 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
)
|
||||
|
||||
func parseIntParam(value, param, algorithmName, config string, previousErr error) (int, error) {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
log.Error("invalid integer for %s representation in %s hash spec %s", param, algorithmName, config)
|
||||
return 0, err
|
||||
}
|
||||
return parsed, previousErr // <- Keep the previous error as this function should still return an error once everything has been checked if any call failed
|
||||
}
|
||||
|
||||
func parseUIntParam(value, param, algorithmName, config string, previousErr error) (uint64, error) {
|
||||
parsed, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil {
|
||||
log.Error("invalid integer for %s representation in %s hash spec %s", param, algorithmName, config)
|
||||
return 0, err
|
||||
}
|
||||
return parsed, previousErr // <- Keep the previous error as this function should still return an error once everything has been checked if any call failed
|
||||
}
|
33
modules/auth/password/hash/dummy.go
Normal file
33
modules/auth/password/hash/dummy.go
Normal file
|
@ -0,0 +1,33 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// DummyHasher implements PasswordHasher and is a dummy hasher that simply
|
||||
// puts the password in place with its salt
|
||||
// This SHOULD NOT be used in production and is provided to make the integration
|
||||
// tests faster only
|
||||
type DummyHasher struct{}
|
||||
|
||||
// HashWithSaltBytes a provided password and salt
|
||||
func (hasher *DummyHasher) HashWithSaltBytes(password string, salt []byte) string {
|
||||
if hasher == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(salt) == 10 {
|
||||
return string(salt) + ":" + password
|
||||
}
|
||||
|
||||
return hex.EncodeToString(salt) + ":" + password
|
||||
}
|
||||
|
||||
// NewDummyHasher is a factory method to create a DummyHasher
|
||||
// Any provided configuration is ignored
|
||||
func NewDummyHasher(_ string) *DummyHasher {
|
||||
return &DummyHasher{}
|
||||
}
|
25
modules/auth/password/hash/dummy_test.go
Normal file
25
modules/auth/password/hash/dummy_test.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDummyHasher(t *testing.T) {
|
||||
dummy := &PasswordHashAlgorithm{
|
||||
PasswordSaltHasher: NewDummyHasher(""),
|
||||
Specification: "dummy",
|
||||
}
|
||||
|
||||
password, salt := "password", "ZogKvWdyEx"
|
||||
|
||||
hash, err := dummy.Hash(password, salt)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, hash, salt+":"+password)
|
||||
|
||||
assert.True(t, dummy.VerifyPassword(password, hash, salt))
|
||||
}
|
189
modules/auth/password/hash/hash.go
Normal file
189
modules/auth/password/hash/hash.go
Normal file
|
@ -0,0 +1,189 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
)
|
||||
|
||||
// This package takes care of hashing passwords, verifying passwords, defining
|
||||
// available password algorithms, defining recommended password algorithms and
|
||||
// choosing the default password algorithm.
|
||||
|
||||
// PasswordSaltHasher will hash a provided password with the provided saltBytes
|
||||
type PasswordSaltHasher interface {
|
||||
HashWithSaltBytes(password string, saltBytes []byte) string
|
||||
}
|
||||
|
||||
// PasswordHasher will hash a provided password with the salt
|
||||
type PasswordHasher interface {
|
||||
Hash(password, salt string) (string, error)
|
||||
}
|
||||
|
||||
// PasswordVerifier will ensure that a providedPassword matches the hashPassword when hashed with the salt
|
||||
type PasswordVerifier interface {
|
||||
VerifyPassword(providedPassword, hashedPassword, salt string) bool
|
||||
}
|
||||
|
||||
// PasswordHashAlgorithms are named PasswordSaltHashers with a default verifier and hash function
|
||||
type PasswordHashAlgorithm struct {
|
||||
PasswordSaltHasher
|
||||
Specification string // The specification that is used to create the internal PasswordSaltHasher
|
||||
}
|
||||
|
||||
// Hash the provided password with the salt and return the hash
|
||||
func (algorithm *PasswordHashAlgorithm) Hash(password, salt string) (string, error) {
|
||||
var saltBytes []byte
|
||||
|
||||
// There are two formats for the salt value:
|
||||
// * The new format is a (32+)-byte hex-encoded string
|
||||
// * The old format was a 10-byte binary format
|
||||
// We have to tolerate both here.
|
||||
if len(salt) == 10 {
|
||||
saltBytes = []byte(salt)
|
||||
} else {
|
||||
var err error
|
||||
saltBytes, err = hex.DecodeString(salt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return algorithm.HashWithSaltBytes(password, saltBytes), nil
|
||||
}
|
||||
|
||||
// Verify the provided password matches the hashPassword when hashed with the salt
|
||||
func (algorithm *PasswordHashAlgorithm) VerifyPassword(providedPassword, hashedPassword, salt string) bool {
|
||||
// Some PasswordSaltHashers have their own specialised compare function that takes into
|
||||
// account the stored parameters within the hash. e.g. bcrypt
|
||||
if verifier, ok := algorithm.PasswordSaltHasher.(PasswordVerifier); ok {
|
||||
return verifier.VerifyPassword(providedPassword, hashedPassword, salt)
|
||||
}
|
||||
|
||||
// Compute the hash of the password.
|
||||
providedPasswordHash, err := algorithm.Hash(providedPassword, salt)
|
||||
if err != nil {
|
||||
log.Error("passwordhash: %v.Hash(): %v", algorithm.Specification, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare it against the hashed password in constant-time.
|
||||
return subtle.ConstantTimeCompare([]byte(hashedPassword), []byte(providedPasswordHash)) == 1
|
||||
}
|
||||
|
||||
var (
|
||||
lastNonDefaultAlgorithm atomic.Value
|
||||
availableHasherFactories = map[string]func(string) PasswordSaltHasher{}
|
||||
)
|
||||
|
||||
// MustRegister registers a PasswordSaltHasher with the availableHasherFactories
|
||||
// Caution: This is not thread safe.
|
||||
func MustRegister[T PasswordSaltHasher](name string, newFn func(config string) T) {
|
||||
if err := Register(name, newFn); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Register registers a PasswordSaltHasher with the availableHasherFactories
|
||||
// Caution: This is not thread safe.
|
||||
func Register[T PasswordSaltHasher](name string, newFn func(config string) T) error {
|
||||
if _, has := availableHasherFactories[name]; has {
|
||||
return fmt.Errorf("duplicate registration of password salt hasher: %s", name)
|
||||
}
|
||||
|
||||
availableHasherFactories[name] = func(config string) PasswordSaltHasher {
|
||||
n := newFn(config)
|
||||
return n
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// In early versions of gitea the password hash algorithm field of a user could be
|
||||
// empty. At that point the default was `pbkdf2` without configuration values
|
||||
//
|
||||
// Please note this is not the same as the DefaultAlgorithm which is used
|
||||
// to determine what an empty PASSWORD_HASH_ALGO setting in the app.ini means.
|
||||
// These are not the same even if they have the same apparent value and they mean different things.
|
||||
//
|
||||
// DO NOT COALESCE THESE VALUES
|
||||
const defaultEmptyHashAlgorithmSpecification = "pbkdf2"
|
||||
|
||||
// Parse will convert the provided algorithm specification in to a PasswordHashAlgorithm
|
||||
// If the provided specification matches the DefaultHashAlgorithm Specification it will be
|
||||
// used.
|
||||
// In addition the last non-default hasher will be cached to help reduce the load from
|
||||
// parsing specifications.
|
||||
//
|
||||
// NOTE: No de-aliasing is done in this function, thus any specification which does not
|
||||
// contain a configuration will use the default values for that hasher. These are not
|
||||
// necessarily the same values as those obtained by dealiasing. This allows for
|
||||
// seamless backwards compatibility with the original configuration.
|
||||
//
|
||||
// To further labour this point, running `Parse("pbkdf2")` does not obtain the
|
||||
// same algorithm as setting `PASSWORD_HASH_ALGO=pbkdf2` in app.ini, nor is it intended to.
|
||||
// A user that has `password_hash_algo='pbkdf2'` in the db means get the original, unconfigured algorithm
|
||||
// Users will be migrated automatically as they log-in to have the complete specification stored
|
||||
// in their `password_hash_algo` fields by other code.
|
||||
func Parse(algorithmSpec string) *PasswordHashAlgorithm {
|
||||
if algorithmSpec == "" {
|
||||
algorithmSpec = defaultEmptyHashAlgorithmSpecification
|
||||
}
|
||||
|
||||
if DefaultHashAlgorithm != nil && algorithmSpec == DefaultHashAlgorithm.Specification {
|
||||
return DefaultHashAlgorithm
|
||||
}
|
||||
|
||||
ptr := lastNonDefaultAlgorithm.Load()
|
||||
if ptr != nil {
|
||||
hashAlgorithm, ok := ptr.(*PasswordHashAlgorithm)
|
||||
if ok && hashAlgorithm.Specification == algorithmSpec {
|
||||
return hashAlgorithm
|
||||
}
|
||||
}
|
||||
|
||||
// Now convert the provided specification in to a hasherType +/- some configuration parameters
|
||||
vals := strings.SplitN(algorithmSpec, "$", 2)
|
||||
var hasherType string
|
||||
var config string
|
||||
|
||||
if len(vals) == 0 {
|
||||
// This should not happen as algorithmSpec should not be empty
|
||||
// due to it being assigned to defaultEmptyHashAlgorithmSpecification above
|
||||
// but we should be absolutely cautious here
|
||||
return nil
|
||||
}
|
||||
|
||||
hasherType = vals[0]
|
||||
if len(vals) > 1 {
|
||||
config = vals[1]
|
||||
}
|
||||
|
||||
newFn, has := availableHasherFactories[hasherType]
|
||||
if !has {
|
||||
// unknown hasher type
|
||||
return nil
|
||||
}
|
||||
|
||||
ph := newFn(config)
|
||||
if ph == nil {
|
||||
// The provided configuration is likely invalid - it will have been logged already
|
||||
// but we cannot hash safely
|
||||
return nil
|
||||
}
|
||||
|
||||
hashAlgorithm := &PasswordHashAlgorithm{
|
||||
PasswordSaltHasher: ph,
|
||||
Specification: algorithmSpec,
|
||||
}
|
||||
|
||||
lastNonDefaultAlgorithm.Store(hashAlgorithm)
|
||||
|
||||
return hashAlgorithm
|
||||
}
|
190
modules/auth/password/hash/hash_test.go
Normal file
190
modules/auth/password/hash/hash_test.go
Normal file
|
@ -0,0 +1,190 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type testSaltHasher string
|
||||
|
||||
func (t testSaltHasher) HashWithSaltBytes(password string, salt []byte) string {
|
||||
return password + "$" + string(salt) + "$" + string(t)
|
||||
}
|
||||
|
||||
func Test_registerHasher(t *testing.T) {
|
||||
MustRegister("Test_registerHasher", func(config string) testSaltHasher {
|
||||
return testSaltHasher(config)
|
||||
})
|
||||
|
||||
assert.Panics(t, func() {
|
||||
MustRegister("Test_registerHasher", func(config string) testSaltHasher {
|
||||
return testSaltHasher(config)
|
||||
})
|
||||
})
|
||||
|
||||
assert.Error(t, Register("Test_registerHasher", func(config string) testSaltHasher {
|
||||
return testSaltHasher(config)
|
||||
}))
|
||||
|
||||
assert.Equal(t, "password$salt$",
|
||||
Parse("Test_registerHasher").PasswordSaltHasher.HashWithSaltBytes("password", []byte("salt")))
|
||||
|
||||
assert.Equal(t, "password$salt$config",
|
||||
Parse("Test_registerHasher$config").PasswordSaltHasher.HashWithSaltBytes("password", []byte("salt")))
|
||||
|
||||
delete(availableHasherFactories, "Test_registerHasher")
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
hashAlgorithmsToTest := []string{}
|
||||
for plainHashAlgorithmNames := range availableHasherFactories {
|
||||
hashAlgorithmsToTest = append(hashAlgorithmsToTest, plainHashAlgorithmNames)
|
||||
}
|
||||
for _, aliased := range aliasAlgorithmNames {
|
||||
if strings.Contains(aliased, "$") {
|
||||
hashAlgorithmsToTest = append(hashAlgorithmsToTest, aliased)
|
||||
}
|
||||
}
|
||||
for _, algorithmName := range hashAlgorithmsToTest {
|
||||
t.Run(algorithmName, func(t *testing.T) {
|
||||
algo := Parse(algorithmName)
|
||||
assert.NotNil(t, algo, "Algorithm %s resulted in an empty algorithm", algorithmName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashing(t *testing.T) {
|
||||
hashAlgorithmsToTest := []string{}
|
||||
for plainHashAlgorithmNames := range availableHasherFactories {
|
||||
hashAlgorithmsToTest = append(hashAlgorithmsToTest, plainHashAlgorithmNames)
|
||||
}
|
||||
for _, aliased := range aliasAlgorithmNames {
|
||||
if strings.Contains(aliased, "$") {
|
||||
hashAlgorithmsToTest = append(hashAlgorithmsToTest, aliased)
|
||||
}
|
||||
}
|
||||
|
||||
runTests := func(password, salt string, shouldPass bool) {
|
||||
for _, algorithmName := range hashAlgorithmsToTest {
|
||||
t.Run(algorithmName, func(t *testing.T) {
|
||||
output, err := Parse(algorithmName).Hash(password, salt)
|
||||
if shouldPass {
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, output, "output for %s was empty", algorithmName)
|
||||
} else {
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, Parse(algorithmName).VerifyPassword(password, output, salt), shouldPass)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test with new salt format.
|
||||
runTests(strings.Repeat("a", 16), hex.EncodeToString([]byte{0x01, 0x02, 0x03}), true)
|
||||
|
||||
// Test with legacy salt format.
|
||||
runTests(strings.Repeat("a", 16), strings.Repeat("b", 10), true)
|
||||
|
||||
// Test with invalid salt.
|
||||
runTests(strings.Repeat("a", 16), "a", false)
|
||||
}
|
||||
|
||||
// vectors were generated using the current codebase.
|
||||
var vectors = []struct {
|
||||
algorithms []string
|
||||
password string
|
||||
salt string
|
||||
output string
|
||||
shouldfail bool
|
||||
}{
|
||||
{
|
||||
algorithms: []string{"bcrypt", "bcrypt$10"},
|
||||
password: "abcdef",
|
||||
salt: strings.Repeat("a", 10),
|
||||
output: "$2a$10$fjtm8BsQ2crym01/piJroenO3oSVUBhSLKaGdTYJ4tG0ePVCrU0G2",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"scrypt", "scrypt$65536$16$2$50"},
|
||||
password: "abcdef",
|
||||
salt: strings.Repeat("a", 10),
|
||||
output: "3b571d0c07c62d42b7bad3dbf18fb0cd67d4d8cd4ad4c6928e1090e5b2a4a84437c6fd2627d897c0e7e65025ca62b67a0002",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"argon2", "argon2$2$65536$8$50"},
|
||||
password: "abcdef",
|
||||
salt: strings.Repeat("a", 10),
|
||||
output: "551f089f570f989975b6f7c6a8ff3cf89bc486dd7bbe87ed4d80ad4362f8ee599ec8dda78dac196301b98456402bcda775dc",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"pbkdf2", "pbkdf2$10000$50"},
|
||||
password: "abcdef",
|
||||
salt: strings.Repeat("a", 10),
|
||||
output: "ab48d5471b7e6ed42d10001db88c852ff7303c788e49da5c3c7b63d5adf96360303724b74b679223a3dea8a242d10abb1913",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"bcrypt", "bcrypt$10"},
|
||||
password: "abcdef",
|
||||
salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}),
|
||||
output: "$2a$10$qhgm32w9ZpqLygugWJsLjey8xRGcaq9iXAfmCeNBXxddgyoaOC3Gq",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"scrypt", "scrypt$65536$16$2$50"},
|
||||
password: "abcdef",
|
||||
salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}),
|
||||
output: "25fe5f66b43fa4eb7b6717905317cd2223cf841092dc8e0a1e8c75720ad4846cb5d9387303e14bc3c69faa3b1c51ef4b7de1",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"argon2", "argon2$2$65536$8$50"},
|
||||
password: "abcdef",
|
||||
salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}),
|
||||
output: "9c287db63a91d18bb1414b703216da4fc431387c1ae7c8acdb280222f11f0929831055dbfd5126a3b48566692e83ec750d2a",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"pbkdf2", "pbkdf2$10000$50"},
|
||||
password: "abcdef",
|
||||
salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}),
|
||||
output: "45d6cdc843d65cf0eda7b90ab41435762a282f7df013477a1c5b212ba81dbdca2edf1ecc4b5cb05956bb9e0c37ab29315d78",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"pbkdf2$320000$50"},
|
||||
password: "abcdef",
|
||||
salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}),
|
||||
output: "84e233114499e8721da80e85568e5b7b5900b3e49a30845fcda9d1e1756da4547d70f8740ac2b4a5d82f88cebcd27f21bfe2",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"pbkdf2", "pbkdf2$10000$50"},
|
||||
password: "abcdef",
|
||||
salt: "",
|
||||
output: "",
|
||||
shouldfail: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Ensure that the current code will correctly verify against the test vectors.
|
||||
func TestVectors(t *testing.T) {
|
||||
for i, vector := range vectors {
|
||||
for _, algorithm := range vector.algorithms {
|
||||
t.Run(strconv.Itoa(i)+": "+algorithm, func(t *testing.T) {
|
||||
pa := Parse(algorithm)
|
||||
assert.Equal(t, !vector.shouldfail, pa.VerifyPassword(vector.password, vector.output, vector.salt))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
67
modules/auth/password/hash/pbkdf2.go
Normal file
67
modules/auth/password/hash/pbkdf2.go
Normal file
|
@ -0,0 +1,67 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MustRegister("pbkdf2", NewPBKDF2Hasher)
|
||||
}
|
||||
|
||||
// PBKDF2Hasher implements PasswordHasher
|
||||
// and uses the PBKDF2 key derivation function.
|
||||
type PBKDF2Hasher struct {
|
||||
iter, keyLen int
|
||||
}
|
||||
|
||||
// HashWithSaltBytes a provided password and salt
|
||||
func (hasher *PBKDF2Hasher) HashWithSaltBytes(password string, salt []byte) string {
|
||||
if hasher == nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(pbkdf2.Key([]byte(password), salt, hasher.iter, hasher.keyLen, sha256.New))
|
||||
}
|
||||
|
||||
// NewPBKDF2Hasher is a factory method to create an PBKDF2Hasher
|
||||
// config should be either empty or of the form:
|
||||
// "<iter>$<keyLen>", where <x> is the string representation
|
||||
// of an integer
|
||||
func NewPBKDF2Hasher(config string) *PBKDF2Hasher {
|
||||
// This default configuration uses the following parameters:
|
||||
// iter=10000, keyLen=50.
|
||||
// This matches the original configuration for `pbkdf2` prior to storing parameters
|
||||
// in the database.
|
||||
// THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK
|
||||
hasher := &PBKDF2Hasher{
|
||||
iter: 10_000,
|
||||
keyLen: 50,
|
||||
}
|
||||
|
||||
if config == "" {
|
||||
return hasher
|
||||
}
|
||||
|
||||
vals := strings.SplitN(config, "$", 2)
|
||||
if len(vals) != 2 {
|
||||
log.Error("invalid pbkdf2 hash spec %s", config)
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
hasher.iter, err = parseIntParam(vals[0], "iter", "pbkdf2", config, nil)
|
||||
hasher.keyLen, err = parseIntParam(vals[1], "keyLen", "pbkdf2", config, err)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return hasher
|
||||
}
|
67
modules/auth/password/hash/scrypt.go
Normal file
67
modules/auth/password/hash/scrypt.go
Normal file
|
@ -0,0 +1,67 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
|
||||
"golang.org/x/crypto/scrypt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MustRegister("scrypt", NewScryptHasher)
|
||||
}
|
||||
|
||||
// ScryptHasher implements PasswordHasher
|
||||
// and uses the scrypt key derivation function.
|
||||
type ScryptHasher struct {
|
||||
n, r, p, keyLen int
|
||||
}
|
||||
|
||||
// HashWithSaltBytes a provided password and salt
|
||||
func (hasher *ScryptHasher) HashWithSaltBytes(password string, salt []byte) string {
|
||||
if hasher == nil {
|
||||
return ""
|
||||
}
|
||||
hashedPassword, _ := scrypt.Key([]byte(password), salt, hasher.n, hasher.r, hasher.p, hasher.keyLen)
|
||||
return hex.EncodeToString(hashedPassword)
|
||||
}
|
||||
|
||||
// NewScryptHasher is a factory method to create an ScryptHasher
|
||||
// The provided config should be either empty or of the form:
|
||||
// "<n>$<r>$<p>$<keyLen>", where <x> is the string representation
|
||||
// of an integer
|
||||
func NewScryptHasher(config string) *ScryptHasher {
|
||||
// This matches the original configuration for `scrypt` prior to storing hash parameters
|
||||
// in the database.
|
||||
// THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK
|
||||
hasher := &ScryptHasher{
|
||||
n: 1 << 16,
|
||||
r: 16,
|
||||
p: 2, // 2 passes through memory - this default config will use 128MiB in total.
|
||||
keyLen: 50,
|
||||
}
|
||||
|
||||
if config == "" {
|
||||
return hasher
|
||||
}
|
||||
|
||||
vals := strings.SplitN(config, "$", 4)
|
||||
if len(vals) != 4 {
|
||||
log.Error("invalid scrypt hash spec %s", config)
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
hasher.n, err = parseIntParam(vals[0], "n", "scrypt", config, nil)
|
||||
hasher.r, err = parseIntParam(vals[1], "r", "scrypt", config, err)
|
||||
hasher.p, err = parseIntParam(vals[2], "p", "scrypt", config, err)
|
||||
hasher.keyLen, err = parseIntParam(vals[3], "keyLen", "scrypt", config, err)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return hasher
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Reference in a new issue