mirror of
https://github.com/ordinary-dev/phoenix
synced 2024-09-19 19:30:28 +05:00
Refactor API handlers
This commit is contained in:
parent
e7fe2939cf
commit
77ddb3747b
164
views/auth.go
164
views/auth.go
|
@ -15,22 +15,24 @@ import (
|
|||
|
||||
const TOKEN_LIFETIME_IN_SECONDS = 60 * 60 * 24 * 30
|
||||
|
||||
func ShowRegistrationForm(c *gin.Context, db *gorm.DB) {
|
||||
if database.CountAdmins(db) > 0 {
|
||||
ShowError(c, errors.New("At least 1 user already exists"))
|
||||
return
|
||||
}
|
||||
func ShowRegistrationForm(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
if database.CountAdmins(db) > 0 {
|
||||
ShowError(ctx, errors.New("At least 1 user already exists"))
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "auth.html.tmpl", gin.H{
|
||||
"title": "Create an account",
|
||||
"description": "To prevent other people from seeing your links, create an account.",
|
||||
"button": "Create",
|
||||
"formAction": "/api/users",
|
||||
})
|
||||
ctx.HTML(http.StatusOK, "auth.html.tmpl", gin.H{
|
||||
"title": "Create an account",
|
||||
"description": "To prevent other people from seeing your links, create an account.",
|
||||
"button": "Create",
|
||||
"formAction": "/api/users",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ShowLoginForm(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "auth.html.tmpl", gin.H{
|
||||
func ShowLoginForm(ctx *gin.Context) {
|
||||
ctx.HTML(http.StatusOK, "auth.html.tmpl", gin.H{
|
||||
"title": "Sign in",
|
||||
"description": "Authorization is required to view this page.",
|
||||
"button": "Sign in",
|
||||
|
@ -69,37 +71,39 @@ func RequireAuth(c *gin.Context, cfg *config.Config) (*jwt.RegisteredClaims, err
|
|||
return claims, nil
|
||||
}
|
||||
|
||||
func AuthMiddleware(c *gin.Context, db *gorm.DB, cfg *config.Config) {
|
||||
claims, err := RequireAuth(c, cfg)
|
||||
if err != nil {
|
||||
if cfg.HeaderAuth && c.Request.Header.Get("Remote-User") != "" {
|
||||
// Generate access token.
|
||||
token, err := GetJWTToken(cfg)
|
||||
if err != nil {
|
||||
ShowError(c, err)
|
||||
func AuthMiddleware(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
claims, err := RequireAuth(ctx, cfg)
|
||||
if err != nil {
|
||||
if cfg.HeaderAuth && ctx.Request.Header.Get("Remote-User") != "" {
|
||||
// Generate access token.
|
||||
token, err := GetJWTToken(cfg)
|
||||
if err != nil {
|
||||
ShowError(ctx, err)
|
||||
return
|
||||
}
|
||||
SetTokenCookie(ctx, token, cfg)
|
||||
return
|
||||
}
|
||||
SetTokenCookie(c, token, cfg)
|
||||
|
||||
if database.CountAdmins(db) < 1 {
|
||||
ctx.Redirect(http.StatusFound, "/registration")
|
||||
} else {
|
||||
ctx.Redirect(http.StatusFound, "/signin")
|
||||
}
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if database.CountAdmins(db) < 1 {
|
||||
c.Redirect(http.StatusFound, "/registration")
|
||||
} else {
|
||||
c.Redirect(http.StatusFound, "/signin")
|
||||
// Create a new token if the old one is about to expire
|
||||
if time.Now().Add(time.Second * (TOKEN_LIFETIME_IN_SECONDS / 2)).After(claims.ExpiresAt.Time) {
|
||||
newToken, err := GetJWTToken(cfg)
|
||||
if err != nil {
|
||||
ShowError(ctx, err)
|
||||
return
|
||||
}
|
||||
SetTokenCookie(ctx, newToken, cfg)
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new token if the old one is about to expire
|
||||
if time.Now().Add(time.Second * (TOKEN_LIFETIME_IN_SECONDS / 2)).After(claims.ExpiresAt.Time) {
|
||||
newToken, err := GetJWTToken(cfg)
|
||||
if err != nil {
|
||||
ShowError(c, err)
|
||||
return
|
||||
}
|
||||
SetTokenCookie(c, newToken, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -111,53 +115,57 @@ func GetJWTToken(cfg *config.Config) (string, error) {
|
|||
return token.SignedString([]byte(cfg.SecretKey))
|
||||
}
|
||||
|
||||
func CreateUser(c *gin.Context, db *gorm.DB, cfg *config.Config) {
|
||||
if database.CountAdmins(db) > 0 {
|
||||
ShowError(c, errors.New("At least 1 user already exists"))
|
||||
return
|
||||
}
|
||||
func CreateUser(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
if database.CountAdmins(db) > 0 {
|
||||
ShowError(ctx, errors.New("At least 1 user already exists"))
|
||||
return
|
||||
}
|
||||
|
||||
// Try to create a user.
|
||||
username := c.PostForm("username")
|
||||
password := c.PostForm("password")
|
||||
_, err := database.CreateAdmin(db, username, password)
|
||||
if err != nil {
|
||||
ShowError(c, err)
|
||||
return
|
||||
}
|
||||
// Try to create a user.
|
||||
username := ctx.PostForm("username")
|
||||
password := ctx.PostForm("password")
|
||||
_, err := database.CreateAdmin(db, username, password)
|
||||
if err != nil {
|
||||
ShowError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate access token.
|
||||
token, err := GetJWTToken(cfg)
|
||||
if err != nil {
|
||||
ShowError(c, err)
|
||||
return
|
||||
}
|
||||
SetTokenCookie(c, token, cfg)
|
||||
// Generate access token.
|
||||
token, err := GetJWTToken(cfg)
|
||||
if err != nil {
|
||||
ShowError(ctx, err)
|
||||
return
|
||||
}
|
||||
SetTokenCookie(ctx, token, cfg)
|
||||
|
||||
// Redirect to homepage.
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
// Redirect to homepage.
|
||||
ctx.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
}
|
||||
|
||||
func AuthorizeUser(c *gin.Context, db *gorm.DB, cfg *config.Config) {
|
||||
// Check credentials.
|
||||
username := c.PostForm("username")
|
||||
password := c.PostForm("password")
|
||||
_, err := database.AuthorizeAdmin(db, username, password)
|
||||
if err != nil {
|
||||
ShowError(c, err)
|
||||
return
|
||||
}
|
||||
func AuthorizeUser(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
// Check credentials.
|
||||
username := ctx.PostForm("username")
|
||||
password := ctx.PostForm("password")
|
||||
_, err := database.AuthorizeAdmin(db, username, password)
|
||||
if err != nil {
|
||||
ShowError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate an access token.
|
||||
token, err := GetJWTToken(cfg)
|
||||
if err != nil {
|
||||
ShowError(c, err)
|
||||
return
|
||||
}
|
||||
SetTokenCookie(c, token, cfg)
|
||||
// Generate an access token.
|
||||
token, err := GetJWTToken(cfg)
|
||||
if err != nil {
|
||||
ShowError(ctx, err)
|
||||
return
|
||||
}
|
||||
SetTokenCookie(ctx, token, cfg)
|
||||
|
||||
// Redirect to homepage.
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
// Redirect to homepage.
|
||||
ctx.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
}
|
||||
|
||||
// Save token in cookies
|
||||
|
|
|
@ -5,13 +5,13 @@ import (
|
|||
"net/http"
|
||||
)
|
||||
|
||||
func ShowError(c *gin.Context, err error) {
|
||||
c.HTML(
|
||||
func ShowError(ctx *gin.Context, err error) {
|
||||
ctx.HTML(
|
||||
http.StatusBadRequest,
|
||||
"error.html.tmpl",
|
||||
gin.H{
|
||||
"error": err.Error(),
|
||||
},
|
||||
)
|
||||
c.Abort()
|
||||
ctx.Abort()
|
||||
}
|
||||
|
|
|
@ -8,55 +8,61 @@ import (
|
|||
"strconv"
|
||||
)
|
||||
|
||||
func CreateGroup(c *gin.Context, db *gorm.DB) {
|
||||
// Save new group to the database.
|
||||
group := database.Group{
|
||||
Name: c.PostForm("groupName"),
|
||||
}
|
||||
if result := db.Create(&group); result.Error != nil {
|
||||
ShowError(c, result.Error)
|
||||
return
|
||||
}
|
||||
func CreateGroup(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
// Save new group to the database.
|
||||
group := database.Group{
|
||||
Name: ctx.PostForm("groupName"),
|
||||
}
|
||||
if result := db.Create(&group); result.Error != nil {
|
||||
ShowError(ctx, result.Error)
|
||||
return
|
||||
}
|
||||
|
||||
// This page is called from the settings, return the user back.
|
||||
c.Redirect(http.StatusFound, "/settings")
|
||||
// This page is called from the settings, return the user back.
|
||||
ctx.Redirect(http.StatusFound, "/settings")
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateGroup(c *gin.Context, db *gorm.DB) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
ShowError(c, err)
|
||||
return
|
||||
}
|
||||
func UpdateGroup(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
ShowError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
var group database.Group
|
||||
if result := db.First(&group, id); result.Error != nil {
|
||||
ShowError(c, result.Error)
|
||||
return
|
||||
}
|
||||
var group database.Group
|
||||
if result := db.First(&group, id); result.Error != nil {
|
||||
ShowError(ctx, result.Error)
|
||||
return
|
||||
}
|
||||
|
||||
group.Name = c.PostForm("groupName")
|
||||
if result := db.Save(&group); result.Error != nil {
|
||||
ShowError(c, result.Error)
|
||||
return
|
||||
}
|
||||
group.Name = ctx.PostForm("groupName")
|
||||
if result := db.Save(&group); result.Error != nil {
|
||||
ShowError(ctx, result.Error)
|
||||
return
|
||||
}
|
||||
|
||||
// This page is called from the settings, return the user back.
|
||||
c.Redirect(http.StatusFound, "/settings")
|
||||
// This page is called from the settings, return the user back.
|
||||
ctx.Redirect(http.StatusFound, "/settings")
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteGroup(c *gin.Context, db *gorm.DB) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
ShowError(c, err)
|
||||
return
|
||||
}
|
||||
func DeleteGroup(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
ShowError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result := db.Delete(&database.Group{}, id); result.Error != nil {
|
||||
ShowError(c, result.Error)
|
||||
return
|
||||
}
|
||||
if result := db.Delete(&database.Group{}, id); result.Error != nil {
|
||||
ShowError(ctx, result.Error)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to settings.
|
||||
c.Redirect(http.StatusFound, "/settings")
|
||||
// Redirect to settings.
|
||||
ctx.Redirect(http.StatusFound, "/settings")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,20 +7,22 @@ import (
|
|||
"net/http"
|
||||
)
|
||||
|
||||
func ShowMainPage(c *gin.Context, db *gorm.DB) {
|
||||
// Get a list of groups with links
|
||||
var groups []database.Group
|
||||
result := db.
|
||||
Model(&database.Group{}).
|
||||
Preload("Links").
|
||||
Find(&groups)
|
||||
func ShowMainPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
// Get a list of groups with links
|
||||
var groups []database.Group
|
||||
result := db.
|
||||
Model(&database.Group{}).
|
||||
Preload("Links").
|
||||
Find(&groups)
|
||||
|
||||
if result.Error != nil {
|
||||
ShowError(c, result.Error)
|
||||
return
|
||||
if result.Error != nil {
|
||||
ShowError(ctx, result.Error)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "index.html.tmpl", gin.H{
|
||||
"groups": groups,
|
||||
})
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "index.html.tmpl", gin.H{
|
||||
"groups": groups,
|
||||
})
|
||||
}
|
||||
|
|
126
views/links.go
126
views/links.go
|
@ -8,75 +8,81 @@ import (
|
|||
"strconv"
|
||||
)
|
||||
|
||||
func CreateLink(c *gin.Context, db *gorm.DB) {
|
||||
groupID, err := strconv.ParseUint(c.PostForm("groupID"), 10, 32)
|
||||
if err != nil {
|
||||
ShowError(c, err)
|
||||
return
|
||||
}
|
||||
func CreateLink(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
groupID, err := strconv.ParseUint(ctx.PostForm("groupID"), 10, 32)
|
||||
if err != nil {
|
||||
ShowError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
link := database.Link{
|
||||
Name: c.PostForm("linkName"),
|
||||
Href: c.PostForm("href"),
|
||||
GroupID: groupID,
|
||||
}
|
||||
icon := c.PostForm("icon")
|
||||
if icon == "" {
|
||||
link.Icon = nil
|
||||
} else {
|
||||
link.Icon = &icon
|
||||
}
|
||||
if result := db.Create(&link); result.Error != nil {
|
||||
ShowError(c, result.Error)
|
||||
return
|
||||
}
|
||||
link := database.Link{
|
||||
Name: ctx.PostForm("linkName"),
|
||||
Href: ctx.PostForm("href"),
|
||||
GroupID: groupID,
|
||||
}
|
||||
icon := ctx.PostForm("icon")
|
||||
if icon == "" {
|
||||
link.Icon = nil
|
||||
} else {
|
||||
link.Icon = &icon
|
||||
}
|
||||
if result := db.Create(&link); result.Error != nil {
|
||||
ShowError(ctx, result.Error)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to settings.
|
||||
c.Redirect(http.StatusFound, "/settings")
|
||||
// Redirect to settings.
|
||||
ctx.Redirect(http.StatusFound, "/settings")
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateLink(c *gin.Context, db *gorm.DB) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
ShowError(c, err)
|
||||
return
|
||||
}
|
||||
func UpdateLink(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
ShowError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
var link database.Link
|
||||
if result := db.First(&link, id); result.Error != nil {
|
||||
ShowError(c, err)
|
||||
return
|
||||
}
|
||||
var link database.Link
|
||||
if result := db.First(&link, id); result.Error != nil {
|
||||
ShowError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
link.Name = c.PostForm("linkName")
|
||||
link.Href = c.PostForm("href")
|
||||
icon := c.PostForm("icon")
|
||||
if icon == "" {
|
||||
link.Icon = nil
|
||||
} else {
|
||||
link.Icon = &icon
|
||||
}
|
||||
if result := db.Save(&link); result.Error != nil {
|
||||
ShowError(c, result.Error)
|
||||
return
|
||||
}
|
||||
link.Name = ctx.PostForm("linkName")
|
||||
link.Href = ctx.PostForm("href")
|
||||
icon := ctx.PostForm("icon")
|
||||
if icon == "" {
|
||||
link.Icon = nil
|
||||
} else {
|
||||
link.Icon = &icon
|
||||
}
|
||||
if result := db.Save(&link); result.Error != nil {
|
||||
ShowError(ctx, result.Error)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to settings.
|
||||
c.Redirect(http.StatusFound, "/settings")
|
||||
// Redirect to settings.
|
||||
ctx.Redirect(http.StatusFound, "/settings")
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteLink(c *gin.Context, db *gorm.DB) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
ShowError(c, err)
|
||||
return
|
||||
}
|
||||
func DeleteLink(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
ShowError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result := db.Delete(&database.Link{}, id); result.Error != nil {
|
||||
ShowError(c, result.Error)
|
||||
return
|
||||
}
|
||||
if result := db.Delete(&database.Link{}, id); result.Error != nil {
|
||||
ShowError(ctx, result.Error)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to settings.
|
||||
c.Redirect(http.StatusFound, "/settings")
|
||||
// Redirect to settings.
|
||||
ctx.Redirect(http.StatusFound, "/settings")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,68 +22,42 @@ func GetGinEngine(cfg *config.Config, db *gorm.DB) *gin.Engine {
|
|||
|
||||
engine.Use(SecurityHeadersMiddleware)
|
||||
|
||||
engine.GET("/signin", func(c *gin.Context) {
|
||||
ShowLoginForm(c)
|
||||
})
|
||||
engine.POST("/api/users/signin", func(c *gin.Context) {
|
||||
AuthorizeUser(c, db, cfg)
|
||||
})
|
||||
engine.GET("/signin", ShowLoginForm)
|
||||
engine.POST("/api/users/signin", AuthorizeUser(db, cfg))
|
||||
|
||||
engine.GET("/registration", func(c *gin.Context) {
|
||||
ShowRegistrationForm(c, db)
|
||||
})
|
||||
engine.POST("/api/users", func(c *gin.Context) {
|
||||
CreateUser(c, db, cfg)
|
||||
})
|
||||
engine.GET("/registration", ShowRegistrationForm(db))
|
||||
engine.POST("/api/users", CreateUser(db, cfg))
|
||||
|
||||
// This group requires authorization before viewing.
|
||||
protected := engine.Group("/")
|
||||
protected.Use(func(c *gin.Context) {
|
||||
AuthMiddleware(c, db, cfg)
|
||||
})
|
||||
protected.Use(AuthMiddleware(db, cfg))
|
||||
|
||||
// Main page
|
||||
protected.GET("/", func(c *gin.Context) {
|
||||
ShowMainPage(c, db)
|
||||
})
|
||||
protected.GET("/", ShowMainPage(db))
|
||||
|
||||
protected.GET("/settings", func(c *gin.Context) {
|
||||
ShowSettings(c, db)
|
||||
})
|
||||
protected.GET("/settings", ShowSettings(db))
|
||||
|
||||
// Create new group
|
||||
protected.POST("/api/groups", func(c *gin.Context) {
|
||||
CreateGroup(c, db)
|
||||
})
|
||||
protected.POST("/api/groups", CreateGroup(db))
|
||||
|
||||
// Update group
|
||||
// HTML forms cannot be submitted using PUT or PATCH methods without javascript.
|
||||
protected.POST("/api/groups/:id/put", func(c *gin.Context) {
|
||||
UpdateGroup(c, db)
|
||||
})
|
||||
protected.POST("/api/groups/:id/put", UpdateGroup(db))
|
||||
|
||||
// Delete group
|
||||
// HTML forms cannot be submitted using the DELETE method without javascript.
|
||||
protected.POST("/api/groups/:id/delete", func(c *gin.Context) {
|
||||
DeleteGroup(c, db)
|
||||
})
|
||||
protected.POST("/api/groups/:id/delete", DeleteGroup(db))
|
||||
|
||||
// Create new link
|
||||
protected.POST("/api/links", func(c *gin.Context) {
|
||||
CreateLink(c, db)
|
||||
})
|
||||
protected.POST("/api/links", CreateLink(db))
|
||||
|
||||
// Update link.
|
||||
// HTML forms cannot be submitted using PUT or PATCH methods without javascript.
|
||||
protected.POST("/api/links/:id/put", func(c *gin.Context) {
|
||||
UpdateLink(c, db)
|
||||
})
|
||||
protected.POST("/api/links/:id/put", UpdateLink(db))
|
||||
|
||||
// Delete link
|
||||
// HTML forms cannot be submitted using the DELETE method without javascript.
|
||||
protected.POST("/api/links/:id/delete", func(c *gin.Context) {
|
||||
DeleteLink(c, db)
|
||||
})
|
||||
protected.POST("/api/links/:id/delete", DeleteLink(db))
|
||||
|
||||
return engine
|
||||
}
|
||||
|
|
|
@ -7,20 +7,22 @@ import (
|
|||
"net/http"
|
||||
)
|
||||
|
||||
func ShowSettings(c *gin.Context, db *gorm.DB) {
|
||||
// Get a list of groups with links
|
||||
var groups []database.Group
|
||||
result := db.
|
||||
Model(&database.Group{}).
|
||||
Preload("Links").
|
||||
Find(&groups)
|
||||
func ShowSettings(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
// Get a list of groups with links
|
||||
var groups []database.Group
|
||||
result := db.
|
||||
Model(&database.Group{}).
|
||||
Preload("Links").
|
||||
Find(&groups)
|
||||
|
||||
if result.Error != nil {
|
||||
ShowError(c, result.Error)
|
||||
return
|
||||
if result.Error != nil {
|
||||
ShowError(ctx, result.Error)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "settings.html.tmpl", gin.H{
|
||||
"groups": groups,
|
||||
})
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "settings.html.tmpl", gin.H{
|
||||
"groups": groups,
|
||||
})
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue