Compare commits

...

3 Commits

Author SHA1 Message Date
zeripath
9da4642c8c
Fix blame view missing lines (#22826) (#22929)
Backport #22826

Creating a new buffered reader for every part of the blame can miss
lines, as it will read and buffer bytes that the next buffered reader
will not get.

---------

Signed-off-by: Andrew Thornton <art27@cantab.net>
Co-authored-by: Brecht Van Lommel <brecht@blender.org>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2023-02-17 10:19:24 +08:00
Lunny Xiao
1d191f9b5a
some refactor about code comments(#20821) (#22707)
fix #22691
backport #20821

Co-authored-by: zeripath <art27@cantab.net>
2023-02-16 21:21:25 +08:00
zeripath
2e1afd54b2
Add command to bulk set must-change-password (#22823) (#22928)
Backport #22823

As part of administration sometimes it is appropriate to forcibly tell
users to update their passwords.

This PR creates a new command `gitea admin user must-change-password`
which will set the `MustChangePassword` flag on the provided users.
---------

Signed-off-by: Andrew Thornton <art27@cantab.net>
Co-authored-by: Jason Song <i@wolfogre.com>
2023-02-16 20:33:24 +08:00
19 changed files with 867 additions and 606 deletions

View File

@ -6,7 +6,6 @@
package cmd package cmd
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@ -17,20 +16,14 @@ import (
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo" 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/git"
"code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
pwd "code.gitea.io/gitea/modules/password"
repo_module "code.gitea.io/gitea/modules/repository" 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" auth_service "code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/auth/source/oauth2"
"code.gitea.io/gitea/services/auth/source/smtp" "code.gitea.io/gitea/services/auth/source/smtp"
repo_service "code.gitea.io/gitea/services/repository" repo_service "code.gitea.io/gitea/services/repository"
user_service "code.gitea.io/gitea/services/user"
"github.com/urfave/cli" "github.com/urfave/cli"
) )
@ -49,142 +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",
},
},
Action: runGenerateAccessToken,
}
subcmdRepoSyncReleases = cli.Command{ subcmdRepoSyncReleases = cli.Command{
Name: "repo-sync-releases", Name: "repo-sync-releases",
Usage: "Synchronize repository releases with tags", Usage: "Synchronize repository releases with tags",
@ -468,259 +325,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(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
}
t := &auth_model.AccessToken{
Name: c.String("token-name"),
UID: user.ID,
}
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 { func runRepoSyncReleases(_ *cli.Context) error {
ctx, cancel := installSignals() ctx, cancel := installSignals()
defer cancel() defer cancel()

21
cmd/admin_user.go Normal file
View 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,
},
}

View 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/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
View 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/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
View 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(c.String("email"))
} else if c.IsSet("username") {
user, err = user_model.GetUserByName(ctx, c.String("username"))
} else {
user, err = user_model.GetUserByID(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"))
}

View File

@ -0,0 +1,69 @@
// 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",
},
},
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
}
t := &auth_model.AccessToken{
Name: c.String("token-name"),
UID: user.ID,
}
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
View 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
}

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

View File

@ -99,6 +99,13 @@ Admin operations:
- `--password value`, `-p value`: New password. Required. - `--password value`, `-p value`: New password. Required.
- Examples: - Examples:
- `gitea admin user change-password --username myname --password asecurepassword` - `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` - `regenerate`
- Options: - Options:
- `hooks`: Regenerate Git Hooks for all repositories - `hooks`: Regenerate Git Hooks for all repositories

View File

@ -5,8 +5,11 @@
package db package db
import ( import (
"context"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"xorm.io/builder"
"xorm.io/xorm" "xorm.io/xorm"
) )
@ -19,6 +22,7 @@ const (
type Paginator interface { type Paginator interface {
GetSkipTake() (skip, take int) GetSkipTake() (skip, take int)
GetStartEnd() (start, end int) GetStartEnd() (start, end int)
IsListAll() bool
} }
// GetPaginatedSession creates a paginated database session // GetPaginatedSession creates a paginated database session
@ -45,9 +49,12 @@ func SetEnginePagination(e Engine, p Paginator) Engine {
// ListOptions options to paginate results // ListOptions options to paginate results
type ListOptions struct { type ListOptions struct {
PageSize int PageSize int
Page int // start from 1 Page int // start from 1
ListAll bool // if true, then PageSize and Page will not be taken
} }
var _ Paginator = &ListOptions{}
// GetSkipTake returns the skip and take values // GetSkipTake returns the skip and take values
func (opts *ListOptions) GetSkipTake() (skip, take int) { func (opts *ListOptions) GetSkipTake() (skip, take int) {
opts.SetDefaultValues() opts.SetDefaultValues()
@ -61,6 +68,11 @@ func (opts *ListOptions) GetStartEnd() (start, end int) {
return start, end return start, end
} }
// IsListAll indicates PageSize and Page will be ignored
func (opts *ListOptions) IsListAll() bool {
return opts.ListAll
}
// SetDefaultValues sets default values // SetDefaultValues sets default values
func (opts *ListOptions) SetDefaultValues() { func (opts *ListOptions) SetDefaultValues() {
if opts.PageSize <= 0 { if opts.PageSize <= 0 {
@ -80,6 +92,8 @@ type AbsoluteListOptions struct {
take int take int
} }
var _ Paginator = &AbsoluteListOptions{}
// NewAbsoluteListOptions creates a list option with applied limits // NewAbsoluteListOptions creates a list option with applied limits
func NewAbsoluteListOptions(skip, take int) *AbsoluteListOptions { func NewAbsoluteListOptions(skip, take int) *AbsoluteListOptions {
if skip < 0 { if skip < 0 {
@ -94,6 +108,11 @@ func NewAbsoluteListOptions(skip, take int) *AbsoluteListOptions {
return &AbsoluteListOptions{skip, take} return &AbsoluteListOptions{skip, take}
} }
// IsListAll will always return false
func (opts *AbsoluteListOptions) IsListAll() bool {
return false
}
// GetSkipTake returns the skip and take values // GetSkipTake returns the skip and take values
func (opts *AbsoluteListOptions) GetSkipTake() (skip, take int) { func (opts *AbsoluteListOptions) GetSkipTake() (skip, take int) {
return opts.skip, opts.take return opts.skip, opts.take
@ -103,3 +122,32 @@ func (opts *AbsoluteListOptions) GetSkipTake() (skip, take int) {
func (opts *AbsoluteListOptions) GetStartEnd() (start, end int) { func (opts *AbsoluteListOptions) GetStartEnd() (start, end int) {
return opts.skip, opts.skip + opts.take return opts.skip, opts.skip + opts.take
} }
// FindOptions represents a find options
type FindOptions interface {
Paginator
ToConds() builder.Cond
}
// Find represents a common find function which accept an options interface
func Find[T any](ctx context.Context, opts FindOptions, objects *[]T) error {
sess := GetEngine(ctx).Where(opts.ToConds())
if !opts.IsListAll() {
sess.Limit(opts.GetSkipTake())
}
return sess.Find(&objects)
}
// Count represents a common count function which accept an options interface
func Count[T any](ctx context.Context, opts FindOptions, object T) (int64, error) {
return GetEngine(ctx).Where(opts.ToConds()).Count(object)
}
// FindAndCount represents a common findandcount function which accept an options interface
func FindAndCount[T any](ctx context.Context, opts FindOptions, objects *[]T) (int64, error) {
sess := GetEngine(ctx).Where(opts.ToConds())
if !opts.IsListAll() {
sess.Limit(opts.GetSkipTake())
}
return sess.FindAndCount(&objects)
}

View File

@ -9,9 +9,7 @@ package issues
import ( import (
"context" "context"
"fmt" "fmt"
"regexp"
"strconv" "strconv"
"strings"
"unicode/utf8" "unicode/utf8"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
@ -23,8 +21,6 @@ import (
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/references" "code.gitea.io/gitea/modules/references"
"code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
@ -697,31 +693,6 @@ func (c *Comment) LoadReview() error {
return c.loadReview(db.DefaultContext) return c.loadReview(db.DefaultContext)
} }
var notEnoughLines = regexp.MustCompile(`fatal: file .* has only \d+ lines?`)
func (c *Comment) checkInvalidation(doer *user_model.User, repo *git.Repository, branch string) error {
// FIXME differentiate between previous and proposed line
commit, err := repo.LineBlame(branch, repo.Path, c.TreePath, uint(c.UnsignedLine()))
if err != nil && (strings.Contains(err.Error(), "fatal: no such path") || notEnoughLines.MatchString(err.Error())) {
c.Invalidated = true
return UpdateComment(c, doer)
}
if err != nil {
return err
}
if c.CommitSHA != "" && c.CommitSHA != commit.ID.String() {
c.Invalidated = true
return UpdateComment(c, doer)
}
return nil
}
// CheckInvalidation checks if the line of code comment got changed by another commit.
// If the line got changed the comment is going to be invalidated.
func (c *Comment) CheckInvalidation(repo *git.Repository, doer *user_model.User, branch string) error {
return c.checkInvalidation(doer, repo, branch)
}
// DiffSide returns "previous" if Comment.Line is a LOC of the previous changes and "proposed" if it is a LOC of the proposed changes. // DiffSide returns "previous" if Comment.Line is a LOC of the previous changes and "proposed" if it is a LOC of the proposed changes.
func (c *Comment) DiffSide() string { func (c *Comment) DiffSide() string {
if c.Line < 0 { if c.Line < 0 {
@ -1065,23 +1036,28 @@ func GetCommentByID(ctx context.Context, id int64) (*Comment, error) {
// FindCommentsOptions describes the conditions to Find comments // FindCommentsOptions describes the conditions to Find comments
type FindCommentsOptions struct { type FindCommentsOptions struct {
db.ListOptions db.ListOptions
RepoID int64 RepoID int64
IssueID int64 IssueID int64
ReviewID int64 ReviewID int64
Since int64 Since int64
Before int64 Before int64
Line int64 Line int64
TreePath string TreePath string
Type CommentType Type CommentType
IssueIDs []int64
Invalidated util.OptionalBool
} }
func (opts *FindCommentsOptions) toConds() builder.Cond { // ToConds implements FindOptions interface
func (opts *FindCommentsOptions) ToConds() builder.Cond {
cond := builder.NewCond() cond := builder.NewCond()
if opts.RepoID > 0 { if opts.RepoID > 0 {
cond = cond.And(builder.Eq{"issue.repo_id": opts.RepoID}) cond = cond.And(builder.Eq{"issue.repo_id": opts.RepoID})
} }
if opts.IssueID > 0 { if opts.IssueID > 0 {
cond = cond.And(builder.Eq{"comment.issue_id": opts.IssueID}) cond = cond.And(builder.Eq{"comment.issue_id": opts.IssueID})
} else if len(opts.IssueIDs) > 0 {
cond = cond.And(builder.In("comment.issue_id", opts.IssueIDs))
} }
if opts.ReviewID > 0 { if opts.ReviewID > 0 {
cond = cond.And(builder.Eq{"comment.review_id": opts.ReviewID}) cond = cond.And(builder.Eq{"comment.review_id": opts.ReviewID})
@ -1101,13 +1077,16 @@ func (opts *FindCommentsOptions) toConds() builder.Cond {
if len(opts.TreePath) > 0 { if len(opts.TreePath) > 0 {
cond = cond.And(builder.Eq{"comment.tree_path": opts.TreePath}) cond = cond.And(builder.Eq{"comment.tree_path": opts.TreePath})
} }
if !opts.Invalidated.IsNone() {
cond = cond.And(builder.Eq{"comment.invalidated": opts.Invalidated.IsTrue()})
}
return cond return cond
} }
// FindComments returns all comments according options // FindComments returns all comments according options
func FindComments(ctx context.Context, opts *FindCommentsOptions) ([]*Comment, error) { func FindComments(ctx context.Context, opts *FindCommentsOptions) ([]*Comment, error) {
comments := make([]*Comment, 0, 10) comments := make([]*Comment, 0, 10)
sess := db.GetEngine(ctx).Where(opts.toConds()) sess := db.GetEngine(ctx).Where(opts.ToConds())
if opts.RepoID > 0 { if opts.RepoID > 0 {
sess.Join("INNER", "issue", "issue.id = comment.issue_id") sess.Join("INNER", "issue", "issue.id = comment.issue_id")
} }
@ -1126,13 +1105,19 @@ func FindComments(ctx context.Context, opts *FindCommentsOptions) ([]*Comment, e
// CountComments count all comments according options by ignoring pagination // CountComments count all comments according options by ignoring pagination
func CountComments(opts *FindCommentsOptions) (int64, error) { func CountComments(opts *FindCommentsOptions) (int64, error) {
sess := db.GetEngine(db.DefaultContext).Where(opts.toConds()) sess := db.GetEngine(db.DefaultContext).Where(opts.ToConds())
if opts.RepoID > 0 { if opts.RepoID > 0 {
sess.Join("INNER", "issue", "issue.id = comment.issue_id") sess.Join("INNER", "issue", "issue.id = comment.issue_id")
} }
return sess.Count(&Comment{}) return sess.Count(&Comment{})
} }
// UpdateCommentInvalidate updates comment invalidated column
func UpdateCommentInvalidate(ctx context.Context, c *Comment) error {
_, err := db.GetEngine(ctx).ID(c.ID).Cols("invalidated").Update(c)
return err
}
// UpdateComment updates information of comment. // UpdateComment updates information of comment.
func UpdateComment(c *Comment, doer *user_model.User) error { func UpdateComment(c *Comment, doer *user_model.User) error {
ctx, committer, err := db.TxContext() ctx, committer, err := db.TxContext()
@ -1191,120 +1176,6 @@ func DeleteComment(ctx context.Context, comment *Comment) error {
return DeleteReaction(ctx, &ReactionOptions{CommentID: comment.ID}) return DeleteReaction(ctx, &ReactionOptions{CommentID: comment.ID})
} }
// CodeComments represents comments on code by using this structure: FILENAME -> LINE (+ == proposed; - == previous) -> COMMENTS
type CodeComments map[string]map[int64][]*Comment
// FetchCodeComments will return a 2d-map: ["Path"]["Line"] = Comments at line
func FetchCodeComments(ctx context.Context, issue *Issue, currentUser *user_model.User) (CodeComments, error) {
return fetchCodeCommentsByReview(ctx, issue, currentUser, nil)
}
func fetchCodeCommentsByReview(ctx context.Context, issue *Issue, currentUser *user_model.User, review *Review) (CodeComments, error) {
pathToLineToComment := make(CodeComments)
if review == nil {
review = &Review{ID: 0}
}
opts := FindCommentsOptions{
Type: CommentTypeCode,
IssueID: issue.ID,
ReviewID: review.ID,
}
comments, err := findCodeComments(ctx, opts, issue, currentUser, review)
if err != nil {
return nil, err
}
for _, comment := range comments {
if pathToLineToComment[comment.TreePath] == nil {
pathToLineToComment[comment.TreePath] = make(map[int64][]*Comment)
}
pathToLineToComment[comment.TreePath][comment.Line] = append(pathToLineToComment[comment.TreePath][comment.Line], comment)
}
return pathToLineToComment, nil
}
func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issue, currentUser *user_model.User, review *Review) ([]*Comment, error) {
var comments []*Comment
if review == nil {
review = &Review{ID: 0}
}
conds := opts.toConds()
if review.ID == 0 {
conds = conds.And(builder.Eq{"invalidated": false})
}
e := db.GetEngine(ctx)
if err := e.Where(conds).
Asc("comment.created_unix").
Asc("comment.id").
Find(&comments); err != nil {
return nil, err
}
if err := issue.LoadRepo(ctx); err != nil {
return nil, err
}
if err := CommentList(comments).loadPosters(ctx); err != nil {
return nil, err
}
// Find all reviews by ReviewID
reviews := make(map[int64]*Review)
ids := make([]int64, 0, len(comments))
for _, comment := range comments {
if comment.ReviewID != 0 {
ids = append(ids, comment.ReviewID)
}
}
if err := e.In("id", ids).Find(&reviews); err != nil {
return nil, err
}
n := 0
for _, comment := range comments {
if re, ok := reviews[comment.ReviewID]; ok && re != nil {
// If the review is pending only the author can see the comments (except if the review is set)
if review.ID == 0 && re.Type == ReviewTypePending &&
(currentUser == nil || currentUser.ID != re.ReviewerID) {
continue
}
comment.Review = re
}
comments[n] = comment
n++
if err := comment.LoadResolveDoer(); err != nil {
return nil, err
}
if err := comment.LoadReactions(issue.Repo); err != nil {
return nil, err
}
var err error
if comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{
Ctx: ctx,
URLPrefix: issue.Repo.Link(),
Metas: issue.Repo.ComposeMetas(),
}, comment.Content); err != nil {
return nil, err
}
}
return comments[:n], nil
}
// FetchCodeCommentsByLine fetches the code comments for a given treePath and line number
func FetchCodeCommentsByLine(ctx context.Context, issue *Issue, currentUser *user_model.User, treePath string, line int64) ([]*Comment, error) {
opts := FindCommentsOptions{
Type: CommentTypeCode,
IssueID: issue.ID,
TreePath: treePath,
Line: line,
}
return findCodeComments(ctx, opts, issue, currentUser, nil)
}
// UpdateCommentsMigrationsByType updates comments' migrations information via given git service type and original id and poster id // UpdateCommentsMigrationsByType updates comments' migrations information via given git service type and original id and poster id
func UpdateCommentsMigrationsByType(tp structs.GitServiceType, originalAuthorID string, posterID int64) error { func UpdateCommentsMigrationsByType(tp structs.GitServiceType, originalAuthorID string, posterID int64) error {
_, err := db.GetEngine(db.DefaultContext).Table("comment"). _, err := db.GetEngine(db.DefaultContext).Table("comment").

View File

@ -0,0 +1,129 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"xorm.io/builder"
)
// CodeComments represents comments on code by using this structure: FILENAME -> LINE (+ == proposed; - == previous) -> COMMENTS
type CodeComments map[string]map[int64][]*Comment
// FetchCodeComments will return a 2d-map: ["Path"]["Line"] = Comments at line
func FetchCodeComments(ctx context.Context, issue *Issue, currentUser *user_model.User) (CodeComments, error) {
return fetchCodeCommentsByReview(ctx, issue, currentUser, nil)
}
func fetchCodeCommentsByReview(ctx context.Context, issue *Issue, currentUser *user_model.User, review *Review) (CodeComments, error) {
pathToLineToComment := make(CodeComments)
if review == nil {
review = &Review{ID: 0}
}
opts := FindCommentsOptions{
Type: CommentTypeCode,
IssueID: issue.ID,
ReviewID: review.ID,
}
comments, err := findCodeComments(ctx, opts, issue, currentUser, review)
if err != nil {
return nil, err
}
for _, comment := range comments {
if pathToLineToComment[comment.TreePath] == nil {
pathToLineToComment[comment.TreePath] = make(map[int64][]*Comment)
}
pathToLineToComment[comment.TreePath][comment.Line] = append(pathToLineToComment[comment.TreePath][comment.Line], comment)
}
return pathToLineToComment, nil
}
func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issue, currentUser *user_model.User, review *Review) ([]*Comment, error) {
var comments []*Comment
if review == nil {
review = &Review{ID: 0}
}
conds := opts.ToConds()
if review.ID == 0 {
conds = conds.And(builder.Eq{"invalidated": false})
}
e := db.GetEngine(ctx)
if err := e.Where(conds).
Asc("comment.created_unix").
Asc("comment.id").
Find(&comments); err != nil {
return nil, err
}
if err := issue.LoadRepo(ctx); err != nil {
return nil, err
}
if err := CommentList(comments).loadPosters(ctx); err != nil {
return nil, err
}
// Find all reviews by ReviewID
reviews := make(map[int64]*Review)
ids := make([]int64, 0, len(comments))
for _, comment := range comments {
if comment.ReviewID != 0 {
ids = append(ids, comment.ReviewID)
}
}
if err := e.In("id", ids).Find(&reviews); err != nil {
return nil, err
}
n := 0
for _, comment := range comments {
if re, ok := reviews[comment.ReviewID]; ok && re != nil {
// If the review is pending only the author can see the comments (except if the review is set)
if review.ID == 0 && re.Type == ReviewTypePending &&
(currentUser == nil || currentUser.ID != re.ReviewerID) {
continue
}
comment.Review = re
}
comments[n] = comment
n++
if err := comment.LoadResolveDoer(); err != nil {
return nil, err
}
if err := comment.LoadReactions(issue.Repo); err != nil {
return nil, err
}
var err error
if comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{
Ctx: ctx,
URLPrefix: issue.Repo.Link(),
Metas: issue.Repo.ComposeMetas(),
}, comment.Content); err != nil {
return nil, err
}
}
return comments[:n], nil
}
// FetchCodeCommentsByLine fetches the code comments for a given treePath and line number
func FetchCodeCommentsByLine(ctx context.Context, issue *Issue, currentUser *user_model.User, treePath string, line int64) ([]*Comment, error) {
opts := FindCommentsOptions{
Type: CommentTypeCode,
IssueID: issue.ID,
TreePath: treePath,
Line: line,
}
return findCodeComments(ctx, opts, issue, currentUser, nil)
}

View File

@ -13,7 +13,6 @@ import (
"code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"xorm.io/xorm" "xorm.io/xorm"
@ -162,7 +161,7 @@ func (prs PullRequestList) loadAttributes(ctx context.Context) error {
} }
// Load issues. // Load issues.
issueIDs := prs.getIssueIDs() issueIDs := prs.GetIssueIDs()
issues := make([]*Issue, 0, len(issueIDs)) issues := make([]*Issue, 0, len(issueIDs))
if err := db.GetEngine(ctx). if err := db.GetEngine(ctx).
Where("id > 0"). Where("id > 0").
@ -181,7 +180,8 @@ func (prs PullRequestList) loadAttributes(ctx context.Context) error {
return nil return nil
} }
func (prs PullRequestList) getIssueIDs() []int64 { // GetIssueIDs returns all issue ids
func (prs PullRequestList) GetIssueIDs() []int64 {
issueIDs := make([]int64, 0, len(prs)) issueIDs := make([]int64, 0, len(prs))
for i := range prs { for i := range prs {
issueIDs = append(issueIDs, prs[i].IssueID) issueIDs = append(issueIDs, prs[i].IssueID)
@ -193,24 +193,3 @@ func (prs PullRequestList) getIssueIDs() []int64 {
func (prs PullRequestList) LoadAttributes() error { func (prs PullRequestList) LoadAttributes() error {
return prs.loadAttributes(db.DefaultContext) return prs.loadAttributes(db.DefaultContext)
} }
// InvalidateCodeComments will lookup the prs for code comments which got invalidated by change
func (prs PullRequestList) InvalidateCodeComments(ctx context.Context, doer *user_model.User, repo *git.Repository, branch string) error {
if len(prs) == 0 {
return nil
}
issueIDs := prs.getIssueIDs()
var codeComments []*Comment
if err := db.GetEngine(ctx).
Where("type = ? and invalidated = ?", CommentTypeCode, false).
In("issue_id", issueIDs).
Find(&codeComments); err != nil {
return fmt.Errorf("find code comments: %w", err)
}
for _, comment := range codeComments {
if err := comment.CheckInvalidation(repo, doer, branch); err != nil {
return err
}
}
return nil
}

View File

@ -978,7 +978,7 @@ func DeleteReview(r *Review) error {
ReviewID: r.ID, ReviewID: r.ID,
} }
if _, err := sess.Where(opts.toConds()).Delete(new(Comment)); err != nil { if _, err := sess.Where(opts.ToConds()).Delete(new(Comment)); err != nil {
return err return err
} }
@ -988,7 +988,7 @@ func DeleteReview(r *Review) error {
ReviewID: r.ID, ReviewID: r.ID,
} }
if _, err := sess.Where(opts.toConds()).Delete(new(Comment)); err != nil { if _, err := sess.Where(opts.ToConds()).Delete(new(Comment)); err != nil {
return err return err
} }
@ -1012,7 +1012,7 @@ func (r *Review) GetCodeCommentsCount() int {
IssueID: r.IssueID, IssueID: r.IssueID,
ReviewID: r.ID, ReviewID: r.ID,
} }
conds := opts.toConds() conds := opts.ToConds()
if r.ID == 0 { if r.ID == 0 {
conds = conds.And(builder.Eq{"invalidated": false}) conds = conds.And(builder.Eq{"invalidated": false})
} }
@ -1032,7 +1032,7 @@ func (r *Review) HTMLURL() string {
ReviewID: r.ID, ReviewID: r.ID,
} }
comment := new(Comment) comment := new(Comment)
has, err := db.GetEngine(db.DefaultContext).Where(opts.toConds()).Get(comment) has, err := db.GetEngine(db.DefaultContext).Where(opts.ToConds()).Get(comment)
if err != nil || !has { if err != nil || !has {
return "" return ""
} }

View File

@ -0,0 +1,49 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"context"
"fmt"
"strings"
"code.gitea.io/gitea/models/db"
"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, fmt.Errorf("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})
}

View File

@ -24,12 +24,12 @@ type BlamePart struct {
// BlameReader returns part of file blame one by one // BlameReader returns part of file blame one by one
type BlameReader struct { type BlameReader struct {
cmd *exec.Cmd cmd *exec.Cmd
output io.ReadCloser reader io.ReadCloser
reader *bufio.Reader lastSha *string
lastSha *string cancel context.CancelFunc // Cancels the context that this reader runs in
cancel context.CancelFunc // Cancels the context that this reader runs in finished process.FinishedFunc // Tells the process manager we're finished and it can remove the associated process from the process table
finished process.FinishedFunc // Tells the process manager we're finished and it can remove the associated process from the process table bufferedReader *bufio.Reader
} }
var shaLineRegex = regexp.MustCompile("^([a-z0-9]{40})") var shaLineRegex = regexp.MustCompile("^([a-z0-9]{40})")
@ -38,8 +38,6 @@ var shaLineRegex = regexp.MustCompile("^([a-z0-9]{40})")
func (r *BlameReader) NextPart() (*BlamePart, error) { func (r *BlameReader) NextPart() (*BlamePart, error) {
var blamePart *BlamePart var blamePart *BlamePart
reader := r.reader
if r.lastSha != nil { if r.lastSha != nil {
blamePart = &BlamePart{*r.lastSha, make([]string, 0)} blamePart = &BlamePart{*r.lastSha, make([]string, 0)}
} }
@ -49,7 +47,7 @@ func (r *BlameReader) NextPart() (*BlamePart, error) {
var err error var err error
for err != io.EOF { for err != io.EOF {
line, isPrefix, err = reader.ReadLine() line, isPrefix, err = r.bufferedReader.ReadLine()
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
return blamePart, err return blamePart, err
} }
@ -71,7 +69,7 @@ func (r *BlameReader) NextPart() (*BlamePart, error) {
r.lastSha = &sha1 r.lastSha = &sha1
// need to munch to end of line... // need to munch to end of line...
for isPrefix { for isPrefix {
_, isPrefix, err = reader.ReadLine() _, isPrefix, err = r.bufferedReader.ReadLine()
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
return blamePart, err return blamePart, err
} }
@ -86,7 +84,7 @@ func (r *BlameReader) NextPart() (*BlamePart, error) {
// need to munch to end of line... // need to munch to end of line...
for isPrefix { for isPrefix {
_, isPrefix, err = reader.ReadLine() _, isPrefix, err = r.bufferedReader.ReadLine()
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
return blamePart, err return blamePart, err
} }
@ -102,9 +100,9 @@ func (r *BlameReader) NextPart() (*BlamePart, error) {
func (r *BlameReader) Close() error { func (r *BlameReader) Close() error {
defer r.finished() // Only remove the process from the process table when the underlying command is closed defer r.finished() // Only remove the process from the process table when the underlying command is closed
r.cancel() // However, first cancel our own context early r.cancel() // However, first cancel our own context early
r.bufferedReader = nil
_ = r.output.Close() _ = r.reader.Close()
if err := r.cmd.Wait(); err != nil { if err := r.cmd.Wait(); err != nil {
return fmt.Errorf("Wait: %w", err) return fmt.Errorf("Wait: %w", err)
} }
@ -126,25 +124,27 @@ func createBlameReader(ctx context.Context, dir string, command ...string) (*Bla
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
process.SetSysProcAttribute(cmd) process.SetSysProcAttribute(cmd)
stdout, err := cmd.StdoutPipe() reader, stdout, err := os.Pipe()
if err != nil { if err != nil {
defer finished() defer finished()
return nil, fmt.Errorf("StdoutPipe: %w", err) return nil, fmt.Errorf("StdoutPipe: %w", err)
} }
cmd.Stdout = stdout
if err = cmd.Start(); err != nil { if err = cmd.Start(); err != nil {
defer finished() defer finished()
_ = stdout.Close() _ = stdout.Close()
return nil, fmt.Errorf("Start: %w", err) return nil, fmt.Errorf("Start: %w", err)
} }
_ = stdout.Close()
reader := bufio.NewReader(stdout) bufferedReader := bufio.NewReader(reader)
return &BlameReader{ return &BlameReader{
cmd: cmd, cmd: cmd,
output: stdout, reader: reader,
reader: reader, cancel: cancel,
cancel: cancel, finished: finished,
finished: finished, bufferedReader: bufferedReader,
}, nil }, nil
} }

View File

@ -65,7 +65,7 @@ summary Add code of delete user
previous be0ba9ea88aff8a658d0495d36accf944b74888d gogs.go previous be0ba9ea88aff8a658d0495d36accf944b74888d gogs.go
filename gogs.go filename gogs.go
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
` + `
e2aa991e10ffd924a828ec149951f2f20eecead2 6 6 2 e2aa991e10ffd924a828ec149951f2f20eecead2 6 6 2
author Lunny Xiao author Lunny Xiao
author-mail <xiaolunwen@gmail.com> author-mail <xiaolunwen@gmail.com>
@ -112,9 +112,7 @@ func TestReadingBlameOutput(t *testing.T) {
}, },
{ {
"ce21ed6c3490cdfad797319cbb1145e2330a8fef", "ce21ed6c3490cdfad797319cbb1145e2330a8fef",
[]string{ []string{"// Copyright 2016 The Gitea Authors. All rights reserved."},
"// Copyright 2016 The Gitea Authors. All rights reserved.",
},
}, },
{ {
"4b92a6c2df28054ad766bc262f308db9f6066596", "4b92a6c2df28054ad766bc262f308db9f6066596",

View File

@ -240,7 +240,7 @@ func checkForInvalidation(ctx context.Context, requests issues_model.PullRequest
} }
go func() { go func() {
// FIXME: graceful: We need to tell the manager we're doing something... // FIXME: graceful: We need to tell the manager we're doing something...
err := requests.InvalidateCodeComments(ctx, doer, gitRepo, branch) err := InvalidateCodeComments(ctx, requests, doer, gitRepo, branch)
if err != nil { if err != nil {
log.Error("PullRequestList.InvalidateCodeComments: %v", err) log.Error("PullRequestList.InvalidateCodeComments: %v", err)
} }

View File

@ -23,6 +23,53 @@ import (
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
) )
var notEnoughLines = regexp.MustCompile(`fatal: file .* has only \d+ lines?`)
// checkInvalidation checks if the line of code comment got changed by another commit.
// If the line got changed the comment is going to be invalidated.
func checkInvalidation(ctx context.Context, c *issues_model.Comment, doer *user_model.User, repo *git.Repository, branch string) error {
// FIXME differentiate between previous and proposed line
commit, err := repo.LineBlame(branch, repo.Path, c.TreePath, uint(c.UnsignedLine()))
if err != nil && (strings.Contains(err.Error(), "fatal: no such path") || notEnoughLines.MatchString(err.Error())) {
c.Invalidated = true
return issues_model.UpdateCommentInvalidate(ctx, c)
}
if err != nil {
return err
}
if c.CommitSHA != "" && c.CommitSHA != commit.ID.String() {
c.Invalidated = true
return issues_model.UpdateCommentInvalidate(ctx, c)
}
return nil
}
// InvalidateCodeComments will lookup the prs for code comments which got invalidated by change
func InvalidateCodeComments(ctx context.Context, prs issues_model.PullRequestList, doer *user_model.User, repo *git.Repository, branch string) error {
if len(prs) == 0 {
return nil
}
issueIDs := prs.GetIssueIDs()
var codeComments []*issues_model.Comment
if err := db.Find(ctx, &issues_model.FindCommentsOptions{
ListOptions: db.ListOptions{
ListAll: true,
},
Type: issues_model.CommentTypeCode,
Invalidated: util.OptionalBoolFalse,
IssueIDs: issueIDs,
}, &codeComments); err != nil {
return fmt.Errorf("find code comments: %v", err)
}
for _, comment := range codeComments {
if err := checkInvalidation(ctx, comment, doer, repo, branch); err != nil {
return err
}
}
return nil
}
// CreateCodeComment creates a comment on the code line // CreateCodeComment creates a comment on the code line
func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, issue *issues_model.Issue, line int64, content, treePath string, isReview bool, replyReviewID int64, latestCommitID string) (*issues_model.Comment, error) { func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, issue *issues_model.Issue, line int64, content, treePath string, isReview bool, replyReviewID int64, latestCommitID string) (*issues_model.Comment, error) {
var ( var (
@ -114,8 +161,6 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.
return comment, nil return comment, nil
} }
var notEnoughLines = regexp.MustCompile(`exit status 128 - fatal: file .* has only \d+ lines?`)
// createCodeComment creates a plain code comment at the specified line / path // createCodeComment creates a plain code comment at the specified line / path
func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content, treePath string, line, reviewID int64) (*issues_model.Comment, error) { func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content, treePath string, line, reviewID int64) (*issues_model.Comment, error) {
var commitID, patch string var commitID, patch string