Merge pull request #4 from Nucrea/feature/url-shortener

Feature/url shortener
This commit is contained in:
Sergey Chubaryan 2024-08-05 10:03:51 +03:00 committed by GitHub
commit 34ad89172a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 388 additions and 30 deletions

3
.vscode/launch.json vendored
View File

@ -9,7 +9,8 @@
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"program": "${workspaceFolder}" "program": "${workspaceFolder}",
"args": ["-c", "./config_example/config.yaml", "-o", "./log.txt"]
} }
] ]
} }

View File

@ -1,6 +1,6 @@
create table users ( create table users (
id int generated always as identity, id int generated always as identity,
login text unique not null, email text unique not null,
secret text not null, secret text not null,
name text not null, name text not null,

2
go.mod
View File

@ -49,5 +49,7 @@ require (
golang.org/x/sys v0.22.0 // indirect golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect golang.org/x/text v0.16.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect
) )

4
go.sum
View File

@ -122,11 +122,15 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IV
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

15
main.go
View File

@ -67,7 +67,7 @@ func main() {
logger.Fatal().Err(err).Msg("failed parsing postgres connection string") logger.Fatal().Err(err).Msg("failed parsing postgres connection string")
} }
sqlDb := stdlib.OpenDB(connConf) sqlDb = stdlib.OpenDB(connConf)
if err := sqlDb.Ping(); err != nil { if err := sqlDb.Ping(); err != nil {
logger.Fatal().Err(err).Msg("failed pinging postgres db") logger.Fatal().Err(err).Msg("failed pinging postgres db")
} }
@ -77,6 +77,8 @@ func main() {
passwordUtil := utils.NewPasswordUtil() passwordUtil := utils.NewPasswordUtil()
userRepo := repo.NewUserRepo(sqlDb) userRepo := repo.NewUserRepo(sqlDb)
userCache := repo.NewCacheInmem[string, models.UserDTO](60 * 60) userCache := repo.NewCacheInmem[string, models.UserDTO](60 * 60)
emailRepo := repo.NewEmailRepo()
actionTokenRepo := repo.NewActionTokenRepo(sqlDb)
userService := services.NewUserService( userService := services.NewUserService(
services.UserServiceDeps{ services.UserServiceDeps{
@ -84,6 +86,13 @@ func main() {
Password: passwordUtil, Password: passwordUtil,
UserRepo: userRepo, UserRepo: userRepo,
UserCache: userCache, UserCache: userCache,
EmailRepo: emailRepo,
ActionTokenRepo: actionTokenRepo,
},
)
linkService := services.NewShortlinkSevice(
services.NewShortlinkServiceParams{
Cache: repo.NewCacheInmem[string, string](7 * 24 * 60 * 60),
}, },
) )
@ -95,6 +104,10 @@ func main() {
r.Use(middleware.NewRequestLogMiddleware(logger)) r.Use(middleware.NewRequestLogMiddleware(logger))
r.Use(gin.Recovery()) r.Use(gin.Recovery())
linkGroup := r.Group("/s")
linkGroup.POST("/new", handlers.NewShortlinkCreateHandler(linkService))
linkGroup.GET("/:linkId", handlers.NewShortlinkResolveHandler(linkService))
userGroup := r.Group("/user") userGroup := r.Group("/user")
userGroup.POST("/create", handlers.NewUserCreateHandler(userService)) userGroup.POST("/create", handlers.NewUserCreateHandler(userService))
userGroup.POST("/login", handlers.NewUserLoginHandler(userService)) userGroup.POST("/login", handlers.NewUserLoginHandler(userService))

View File

@ -0,0 +1,61 @@
package handlers
import (
"backend/src/services"
"encoding/json"
"fmt"
"net/url"
"github.com/gin-gonic/gin"
)
type shortlinkCreateOutput struct {
Link string `json:"link"`
}
func NewShortlinkCreateHandler(shortlinkService services.ShortlinkService) gin.HandlerFunc {
return func(ctx *gin.Context) {
rawUrl := ctx.Query("url")
if rawUrl == "" {
ctx.AbortWithError(400, fmt.Errorf("no url param"))
return
}
u, err := url.Parse(rawUrl)
if err != nil {
ctx.Data(500, "plain/text", []byte(err.Error()))
return
}
u.Scheme = "https"
linkId, err := shortlinkService.CreateLink(u.String())
if err != nil {
ctx.Data(500, "plain/text", []byte(err.Error()))
return
}
resultBody, err := json.Marshal(shortlinkCreateOutput{
Link: "https:/nucrea.ru/s/" + linkId,
})
if err != nil {
ctx.AbortWithError(500, err)
return
}
ctx.Data(200, "application/json", resultBody)
}
}
func NewShortlinkResolveHandler(shortlinkService services.ShortlinkService) gin.HandlerFunc {
return func(ctx *gin.Context) {
linkId := ctx.Param("linkId")
linkUrl, err := shortlinkService.GetLink(linkId)
if err != nil {
ctx.AbortWithError(500, err)
return
}
ctx.Redirect(301, linkUrl)
}
}

View File

@ -8,14 +8,14 @@ import (
) )
type createUserInput struct { type createUserInput struct {
Login string Email string
Password string Password string
Name string Name string
} }
type createUserOutput struct { type createUserOutput struct {
Id string `json:"id"` Id string `json:"id"`
Login string `json:"login"` Email string `json:"email"`
Name string `json:"name"` Name string `json:"name"`
} }
@ -28,7 +28,7 @@ func NewUserCreateHandler(userService services.UserService) gin.HandlerFunc {
} }
dto, err := userService.CreateUser(ctx, services.UserCreateParams{ dto, err := userService.CreateUser(ctx, services.UserCreateParams{
Login: params.Login, Email: params.Email,
Password: params.Password, Password: params.Password,
Name: params.Name, Name: params.Name,
}) })
@ -43,7 +43,7 @@ func NewUserCreateHandler(userService services.UserService) gin.HandlerFunc {
resultBody, err := json.Marshal(createUserOutput{ resultBody, err := json.Marshal(createUserOutput{
Id: dto.Id, Id: dto.Id,
Login: dto.Login, Email: dto.Email,
Name: dto.Name, Name: dto.Name,
}) })
if err != nil { if err != nil {

View File

@ -0,0 +1,15 @@
package models
type ActionTokenTarget int
const (
ActionTokenTargetForgotPassword ActionTokenTarget = iota
ActionTokenTargetLogin2FA
)
type ActionTokenDTO struct {
Id string
UserId string
Value string
Target ActionTokenTarget
}

View File

@ -2,7 +2,12 @@ package models
type UserDTO struct { type UserDTO struct {
Id string Id string
Login string Email string
Secret string
Name string
}
type UserUpdateDTO struct {
Secret string Secret string
Name string Name string
} }

64
src/repo/action_token.go Normal file
View File

@ -0,0 +1,64 @@
package repo
import (
"backend/src/models"
"context"
"database/sql"
)
type ActionTokenRepo interface {
CreateActionToken(ctx context.Context, dto models.ActionTokenDTO) (*models.ActionTokenDTO, error)
PopActionToken(ctx context.Context, userId, value string, target models.ActionTokenTarget) (*models.ActionTokenDTO, error)
}
func NewActionTokenRepo(db *sql.DB) ActionTokenRepo {
return &actionTokenRepo{
db: db,
}
}
type actionTokenRepo struct {
db *sql.DB
}
func (a *actionTokenRepo) CreateActionToken(ctx context.Context, dto models.ActionTokenDTO) (*models.ActionTokenDTO, error) {
query := `
insert into
action_tokens (user_id, value, target)
values ($1, $2, $3)
returning id;`
row := a.db.QueryRowContext(ctx, query, dto.UserId, dto.Value, dto.Target)
id := ""
if err := row.Scan(&id); err != nil {
return nil, err
}
return &models.ActionTokenDTO{
Id: id,
UserId: dto.UserId,
Value: dto.Value,
Target: dto.Target,
}, nil
}
func (a *actionTokenRepo) PopActionToken(ctx context.Context, userId, value string, target models.ActionTokenTarget) (*models.ActionTokenDTO, error) {
query := `
delete
from action_tokens
where user_id=$1 and value=$2 and target=$3
returning id;`
row := a.db.QueryRowContext(ctx, query, userId, value, target)
id := ""
if err := row.Scan(&id); err != nil {
return nil, err
}
return &models.ActionTokenDTO{
Id: id,
UserId: userId,
Value: value,
Target: target,
}, nil
}

48
src/repo/email_repo.go Normal file
View File

@ -0,0 +1,48 @@
package repo
import (
"strings"
"gopkg.in/gomail.v2"
)
const MSG_TEXT = `
<html>
<head>
</head>
<body>
<p>This message was sent because you forgot a password</p>
<p>To change a password, use <a href="{{Link}}"/>this</a> link</p>
</body>
</html>
`
type EmailRepo interface {
SendEmailForgotPassword(email, token string)
}
func NewEmailRepo() EmailRepo {
return &emailRepo{}
}
type emailRepo struct {
// mail *gomail.Dialer
}
func (e *emailRepo) SendEmailForgotPassword(email, token string) {
link := "https://nucrea.ru?token=" + token
msgText := strings.ReplaceAll(MSG_TEXT, "{{Link}}", link)
m := gomail.NewMessage()
m.SetHeader("From", "email")
m.SetHeader("To", email)
m.SetHeader("Subject", "Hello!")
m.SetBody("text/html", msgText)
d := gomail.NewDialer("smtp.yandex.ru", 587, "login", "password")
// Send the email to Bob, Cora and Dan.
if err := d.DialAndSend(m); err != nil {
panic(err)
}
}

View File

@ -16,8 +16,9 @@ import (
type UserRepo interface { type UserRepo interface {
CreateUser(ctx context.Context, dto models.UserDTO) (*models.UserDTO, error) CreateUser(ctx context.Context, dto models.UserDTO) (*models.UserDTO, error)
UpdateUser(ctx context.Context, userId string, dto models.UserUpdateDTO) error
GetUserById(ctx context.Context, id string) (*models.UserDTO, error) GetUserById(ctx context.Context, id string) (*models.UserDTO, error)
GetUserByLogin(ctx context.Context, login string) (*models.UserDTO, error) GetUserByEmail(ctx context.Context, login string) (*models.UserDTO, error)
} }
func NewUserRepo(db *sql.DB) UserRepo { func NewUserRepo(db *sql.DB) UserRepo {
@ -29,8 +30,8 @@ type userRepo struct {
} }
func (u *userRepo) CreateUser(ctx context.Context, dto models.UserDTO) (*models.UserDTO, error) { func (u *userRepo) CreateUser(ctx context.Context, dto models.UserDTO) (*models.UserDTO, error) {
query := `insert into users (login, secret, name) values ($1, $2, $3) returning id;` query := `insert into users (email, secret, name) values ($1, $2, $3) returning id;`
row := u.db.QueryRowContext(ctx, query, dto.Login, dto.Secret, dto.Name) row := u.db.QueryRowContext(ctx, query, dto.Email, dto.Secret, dto.Name)
id := "" id := ""
if err := row.Scan(&id); err != nil { if err := row.Scan(&id); err != nil {
@ -39,18 +40,28 @@ func (u *userRepo) CreateUser(ctx context.Context, dto models.UserDTO) (*models.
return &models.UserDTO{ return &models.UserDTO{
Id: id, Id: id,
Login: dto.Login, Email: dto.Email,
Secret: dto.Secret, Secret: dto.Secret,
Name: dto.Name, Name: dto.Name,
}, nil }, nil
} }
func (u *userRepo) UpdateUser(ctx context.Context, userId string, dto models.UserUpdateDTO) error {
query := `update users set secret=$1, name=$2 where id = $3;`
_, err := u.db.ExecContext(ctx, query, dto.Secret, dto.Name, userId)
if err != nil {
return err
}
return nil
}
func (u *userRepo) GetUserById(ctx context.Context, id string) (*models.UserDTO, error) { func (u *userRepo) GetUserById(ctx context.Context, id string) (*models.UserDTO, error) {
query := `select id, login, secret, name from users where id = $1;` query := `select id, email, secret, name from users where id = $1;`
row := u.db.QueryRowContext(ctx, query, id) row := u.db.QueryRowContext(ctx, query, id)
dto := &models.UserDTO{} dto := &models.UserDTO{}
err := row.Scan(&dto.Id, &dto.Login, &dto.Secret, &dto.Name) err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.Name)
if err == nil { if err == nil {
return dto, nil return dto, nil
} }
@ -61,12 +72,12 @@ func (u *userRepo) GetUserById(ctx context.Context, id string) (*models.UserDTO,
return nil, err return nil, err
} }
func (u *userRepo) GetUserByLogin(ctx context.Context, login string) (*models.UserDTO, error) { func (u *userRepo) GetUserByEmail(ctx context.Context, login string) (*models.UserDTO, error) {
query := `select id, login, secret, name from users where login = $1;` query := `select id, email, secret, name from users where email = $1;`
row := u.db.QueryRowContext(ctx, query, login) row := u.db.QueryRowContext(ctx, query, login)
dto := &models.UserDTO{} dto := &models.UserDTO{}
err := row.Scan(&dto.Id, &dto.Login, &dto.Secret, &dto.Name) err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.Name)
if err == nil { if err == nil {
return dto, nil return dto, nil
} }

View File

@ -0,0 +1,60 @@
package services
import (
"backend/src/repo"
"fmt"
"math/rand"
"strings"
"time"
)
type ShortlinkService interface {
CreateLink(in string) (string, error)
GetLink(id string) (string, error)
}
type NewShortlinkServiceParams struct {
Endpoint string
Cache repo.Cache[string, string]
}
func NewShortlinkSevice(params NewShortlinkServiceParams) ShortlinkService {
return &shortlinkService{
cache: params.Cache,
}
}
type shortlinkService struct {
cache repo.Cache[string, string]
}
func (s *shortlinkService) randomStr() string {
src := rand.NewSource(time.Now().UnixMicro())
randGen := rand.New(src)
builder := strings.Builder{}
for i := 0; i < 9; i++ {
offset := 0x41
if randGen.Int()%2 == 1 {
offset = 0x61
}
byte := offset + (randGen.Int() % 26)
builder.WriteRune(rune(byte))
}
return builder.String()
}
func (s *shortlinkService) CreateLink(in string) (string, error) {
str := s.randomStr()
s.cache.Set(str, in, 7*24*60*60)
return str, nil
}
func (s *shortlinkService) GetLink(id string) (string, error) {
val, ok := s.cache.Get(id)
if !ok {
return "", fmt.Errorf("link does not exist or expired")
}
return val, nil
}

View File

@ -6,6 +6,8 @@ import (
"backend/src/utils" "backend/src/utils"
"context" "context"
"fmt" "fmt"
"github.com/google/uuid"
) )
var ( var (
@ -32,6 +34,8 @@ type UserServiceDeps struct {
Password utils.PasswordUtil Password utils.PasswordUtil
UserRepo repo.UserRepo UserRepo repo.UserRepo
UserCache repo.Cache[string, models.UserDTO] UserCache repo.Cache[string, models.UserDTO]
EmailRepo repo.EmailRepo
ActionTokenRepo repo.ActionTokenRepo
} }
type userService struct { type userService struct {
@ -39,13 +43,13 @@ type userService struct {
} }
type UserCreateParams struct { type UserCreateParams struct {
Login string Email string
Password string Password string
Name string Name string
} }
func (u *userService) CreateUser(ctx context.Context, params UserCreateParams) (*models.UserDTO, error) { func (u *userService) CreateUser(ctx context.Context, params UserCreateParams) (*models.UserDTO, error) {
exisitngUser, err := u.deps.UserRepo.GetUserByLogin(ctx, params.Login) exisitngUser, err := u.deps.UserRepo.GetUserByEmail(ctx, params.Email)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -63,7 +67,7 @@ func (u *userService) CreateUser(ctx context.Context, params UserCreateParams) (
} }
user := models.UserDTO{ user := models.UserDTO{
Login: params.Login, Email: params.Email,
Secret: string(secret), Secret: string(secret),
Name: params.Name, Name: params.Name,
} }
@ -78,8 +82,8 @@ func (u *userService) CreateUser(ctx context.Context, params UserCreateParams) (
return result, nil return result, nil
} }
func (u *userService) AuthenticateUser(ctx context.Context, login, password string) (string, error) { func (u *userService) AuthenticateUser(ctx context.Context, email, password string) (string, error) {
user, err := u.deps.UserRepo.GetUserByLogin(ctx, login) user, err := u.deps.UserRepo.GetUserByEmail(ctx, email)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -102,6 +106,76 @@ func (u *userService) AuthenticateUser(ctx context.Context, login, password stri
return jwt, nil return jwt, nil
} }
func (u *userService) HelpPasswordForgot(ctx context.Context, userId string) error {
user, err := u.getUserById(ctx, userId)
if err != nil {
return err
}
actionToken, err := u.deps.ActionTokenRepo.CreateActionToken(
ctx,
models.ActionTokenDTO{
UserId: user.Id,
Value: uuid.New().String(),
Target: models.ActionTokenTargetForgotPassword,
},
)
if err != nil {
return err
}
u.deps.EmailRepo.SendEmailForgotPassword(user.Email, actionToken.Value)
return nil
}
func (u *userService) ChangePasswordForgot(ctx context.Context, userId, newPassword, accessCode string) error {
user, err := u.getUserById(ctx, userId)
if err != nil {
return err
}
code, err := u.deps.ActionTokenRepo.PopActionToken(ctx, userId, accessCode, models.ActionTokenTargetForgotPassword)
if err != nil {
return err
}
if code == nil {
return fmt.Errorf("wrong user access code")
}
return u.updatePassword(ctx, *user, newPassword)
}
func (u *userService) ChangePassword(ctx context.Context, userId, oldPassword, newPassword string) error {
user, err := u.getUserById(ctx, userId)
if err != nil {
return err
}
if !u.deps.Password.Compare(oldPassword, user.Secret) {
return ErrUserWrongPassword
}
return u.updatePassword(ctx, *user, newPassword)
}
func (u *userService) updatePassword(ctx context.Context, user models.UserDTO, newPassword string) error {
if err := u.deps.Password.Validate(newPassword); err != nil {
return ErrUserBadPassword
}
u.deps.UserCache.Del(user.Id)
newSecret, err := u.deps.Password.Hash(newPassword)
if err != nil {
return err
}
return u.deps.UserRepo.UpdateUser(ctx, user.Id, models.UserUpdateDTO{
Secret: newSecret,
Name: user.Name,
})
}
func (u *userService) getUserById(ctx context.Context, userId string) (*models.UserDTO, error) { func (u *userService) getUserById(ctx context.Context, userId string) (*models.UserDTO, error) {
if user, ok := u.deps.UserCache.Get(userId); ok { if user, ok := u.deps.UserCache.Get(userId); ok {
return &user, nil return &user, nil