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",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}"
"program": "${workspaceFolder}",
"args": ["-c", "./config_example/config.yaml", "-o", "./log.txt"]
}
]
}

View File

@ -1,6 +1,6 @@
create table users (
id int generated always as identity,
login text unique not null,
email text unique not null,
secret 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/text v0.16.0 // 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/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=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
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 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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

23
main.go
View File

@ -67,7 +67,7 @@ func main() {
logger.Fatal().Err(err).Msg("failed parsing postgres connection string")
}
sqlDb := stdlib.OpenDB(connConf)
sqlDb = stdlib.OpenDB(connConf)
if err := sqlDb.Ping(); err != nil {
logger.Fatal().Err(err).Msg("failed pinging postgres db")
}
@ -77,13 +77,22 @@ func main() {
passwordUtil := utils.NewPasswordUtil()
userRepo := repo.NewUserRepo(sqlDb)
userCache := repo.NewCacheInmem[string, models.UserDTO](60 * 60)
emailRepo := repo.NewEmailRepo()
actionTokenRepo := repo.NewActionTokenRepo(sqlDb)
userService := services.NewUserService(
services.UserServiceDeps{
Jwt: jwtUtil,
Password: passwordUtil,
UserRepo: userRepo,
UserCache: userCache,
Jwt: jwtUtil,
Password: passwordUtil,
UserRepo: userRepo,
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(gin.Recovery())
linkGroup := r.Group("/s")
linkGroup.POST("/new", handlers.NewShortlinkCreateHandler(linkService))
linkGroup.GET("/:linkId", handlers.NewShortlinkResolveHandler(linkService))
userGroup := r.Group("/user")
userGroup.POST("/create", handlers.NewUserCreateHandler(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 {
Login string
Email string
Password string
Name string
}
type createUserOutput struct {
Id string `json:"id"`
Login string `json:"login"`
Email string `json:"email"`
Name string `json:"name"`
}
@ -28,7 +28,7 @@ func NewUserCreateHandler(userService services.UserService) gin.HandlerFunc {
}
dto, err := userService.CreateUser(ctx, services.UserCreateParams{
Login: params.Login,
Email: params.Email,
Password: params.Password,
Name: params.Name,
})
@ -43,7 +43,7 @@ func NewUserCreateHandler(userService services.UserService) gin.HandlerFunc {
resultBody, err := json.Marshal(createUserOutput{
Id: dto.Id,
Login: dto.Login,
Email: dto.Email,
Name: dto.Name,
})
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 {
Id string
Login string
Email string
Secret string
Name string
}
type UserUpdateDTO struct {
Secret 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 {
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)
GetUserByLogin(ctx context.Context, login string) (*models.UserDTO, error)
GetUserByEmail(ctx context.Context, login string) (*models.UserDTO, error)
}
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) {
query := `insert into users (login, secret, name) values ($1, $2, $3) returning id;`
row := u.db.QueryRowContext(ctx, query, dto.Login, dto.Secret, dto.Name)
query := `insert into users (email, secret, name) values ($1, $2, $3) returning id;`
row := u.db.QueryRowContext(ctx, query, dto.Email, dto.Secret, dto.Name)
id := ""
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{
Id: id,
Login: dto.Login,
Email: dto.Email,
Secret: dto.Secret,
Name: dto.Name,
}, 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) {
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)
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 {
return dto, nil
}
@ -61,12 +72,12 @@ func (u *userRepo) GetUserById(ctx context.Context, id string) (*models.UserDTO,
return nil, err
}
func (u *userRepo) GetUserByLogin(ctx context.Context, login string) (*models.UserDTO, error) {
query := `select id, login, secret, name from users where login = $1;`
func (u *userRepo) GetUserByEmail(ctx context.Context, login string) (*models.UserDTO, error) {
query := `select id, email, secret, name from users where email = $1;`
row := u.db.QueryRowContext(ctx, query, login)
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 {
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"
"context"
"fmt"
"github.com/google/uuid"
)
var (
@ -28,10 +30,12 @@ func NewUserService(deps UserServiceDeps) UserService {
}
type UserServiceDeps struct {
Jwt utils.JwtUtil
Password utils.PasswordUtil
UserRepo repo.UserRepo
UserCache repo.Cache[string, models.UserDTO]
Jwt utils.JwtUtil
Password utils.PasswordUtil
UserRepo repo.UserRepo
UserCache repo.Cache[string, models.UserDTO]
EmailRepo repo.EmailRepo
ActionTokenRepo repo.ActionTokenRepo
}
type userService struct {
@ -39,13 +43,13 @@ type userService struct {
}
type UserCreateParams struct {
Login string
Email string
Password string
Name string
}
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 {
return nil, err
}
@ -63,7 +67,7 @@ func (u *userService) CreateUser(ctx context.Context, params UserCreateParams) (
}
user := models.UserDTO{
Login: params.Login,
Email: params.Email,
Secret: string(secret),
Name: params.Name,
}
@ -78,8 +82,8 @@ func (u *userService) CreateUser(ctx context.Context, params UserCreateParams) (
return result, nil
}
func (u *userService) AuthenticateUser(ctx context.Context, login, password string) (string, error) {
user, err := u.deps.UserRepo.GetUserByLogin(ctx, login)
func (u *userService) AuthenticateUser(ctx context.Context, email, password string) (string, error) {
user, err := u.deps.UserRepo.GetUserByEmail(ctx, email)
if err != nil {
return "", err
}
@ -102,6 +106,76 @@ func (u *userService) AuthenticateUser(ctx context.Context, login, password stri
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) {
if user, ok := u.deps.UserCache.Get(userId); ok {
return &user, nil