Add user management
This commit is contained in:
Generated
+10
@@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GoImports">
|
||||
<option name="excludedPackages">
|
||||
<array>
|
||||
<option value="golang.org/x/net/context" />
|
||||
</array>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/backend.iml" filepath="$PROJECT_DIR$/.idea/backend.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,269 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"quay/app/models"
|
||||
"quay/app/repository"
|
||||
"quay/internal/security"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
Repo repository.UserRepository
|
||||
}
|
||||
|
||||
func NewUserHandler(repo repository.UserRepository) *UserHandler {
|
||||
return &UserHandler{Repo: repo}
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetAllUsers(c fiber.Ctx) error {
|
||||
users, err := h.Repo.GetAllUsers()
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "User not found",
|
||||
})
|
||||
}
|
||||
log.Println("Error getting user: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while getting site",
|
||||
})
|
||||
}
|
||||
|
||||
if users == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "User not found",
|
||||
})
|
||||
}
|
||||
|
||||
for i := range users {
|
||||
users[i].HashedPassword = ""
|
||||
}
|
||||
|
||||
return c.JSON(users)
|
||||
}
|
||||
|
||||
// GetUserById godoc
|
||||
// @Summary Get user by ID
|
||||
// @Description Get a single user by its ID
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 200 {object} models.User
|
||||
// @Failure 404 {object} models.APIError
|
||||
// @Failure 500 {object} models.APIError
|
||||
func (h *UserHandler) GetUserById(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
|
||||
user, err := h.Repo.GetUserById(id)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "User not found",
|
||||
})
|
||||
}
|
||||
log.Println("Error getting user: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while getting site",
|
||||
})
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "User not found",
|
||||
})
|
||||
}
|
||||
|
||||
user.HashedPassword = ""
|
||||
|
||||
return c.JSON(user)
|
||||
}
|
||||
|
||||
// GetUserByName godoc
|
||||
// @Summary Get user by Name
|
||||
// @Description Get a single user by its Name
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name path string true "User Name"
|
||||
// @Success 200 {object} models.User
|
||||
// @Failure 404 {object} models.APIError
|
||||
// @Failure 500 {object} models.APIError
|
||||
func (h *UserHandler) GetUserByName(c fiber.Ctx) error {
|
||||
name := c.Params("name")
|
||||
|
||||
user, err := h.Repo.GetUserByName(name)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "User not found",
|
||||
})
|
||||
}
|
||||
log.Println("Error getting user: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while getting site",
|
||||
})
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "User not found",
|
||||
})
|
||||
}
|
||||
|
||||
user.HashedPassword = ""
|
||||
|
||||
return c.JSON(user)
|
||||
}
|
||||
|
||||
// CreateUser godoc
|
||||
// @Summary Create a new user
|
||||
// @Description Create a new user with the provided name, role, and password
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.User
|
||||
// @Failure 400 {object} models.APIError
|
||||
// @Failure 409 {object} models.APIError
|
||||
// @Failure 500 {object} models.APIError
|
||||
func (h *UserHandler) CreateUser(c fiber.Ctx) error {
|
||||
var createUserRequest models.CreateUserRequest
|
||||
|
||||
if err := c.Bind().Body(&createUserRequest); err != nil {
|
||||
log.Println("Error parsing body: ", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
}
|
||||
|
||||
if err := models.ValidateCreateUserRequest(&createUserRequest); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{
|
||||
Message: "Invalid request body: " + err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
hashedPassword, err := security.HashPassword(createUserRequest.Password)
|
||||
if err != nil {
|
||||
log.Println("Error hashing password: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while hashing password",
|
||||
})
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
Name: createUserRequest.Name,
|
||||
Role: createUserRequest.Role,
|
||||
HashedPassword: hashedPassword,
|
||||
CreatedAt: time.Now().UTC().Format(time.DateTime),
|
||||
}
|
||||
|
||||
err = h.Repo.CreateUser(&user)
|
||||
if err != nil {
|
||||
log.Println("Error creating user: ", err)
|
||||
if errors.Is(err, repository.ErrUserAlreadyExists) {
|
||||
return c.Status(fiber.StatusConflict).JSON(&models.APIError{
|
||||
Message: "User name already taken",
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while creating user",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(user)
|
||||
}
|
||||
|
||||
// UpdateUser godoc
|
||||
// @Summary Update an existing user
|
||||
// @Description Update an existing user with the provided name, role, and password
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 200 {object} models.User
|
||||
// @Failure 400 {object} models.APIError
|
||||
// @Failure 404 {object} models.APIError
|
||||
// @Failure 409 {object} models.APIError
|
||||
// @Failure 500 {object} models.APIError
|
||||
func (h *UserHandler) UpdateUser(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
var updateUserRequest models.UpdateUserRequest
|
||||
|
||||
if err := c.Bind().Body(&updateUserRequest); err != nil {
|
||||
log.Println("Error parsing body: ", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
}
|
||||
|
||||
if err := models.ValidateUpdateUserRequest(&updateUserRequest); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{
|
||||
Message: "Invalid request body: " + err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
ID: id,
|
||||
Name: updateUserRequest.Name,
|
||||
Role: updateUserRequest.Role,
|
||||
}
|
||||
|
||||
err := h.Repo.UpdateUser(&user)
|
||||
if err != nil {
|
||||
log.Println("Error updating user: ", err)
|
||||
if errors.Is(err, repository.ErrUserAlreadyExists) {
|
||||
return c.Status(fiber.StatusConflict).JSON(&models.APIError{
|
||||
Message: "User name already taken",
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while updating user",
|
||||
})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeleteUser godoc
|
||||
// @Summary Delete a user
|
||||
// @Description Delete a user by its ID
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 404 {object} models.APIError
|
||||
// @Failure 500 {object} models.APIError
|
||||
func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
|
||||
if _, err := h.Repo.GetUserById(id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "User not found",
|
||||
})
|
||||
}
|
||||
log.Println("Error checking user before delete: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while deleting user",
|
||||
})
|
||||
}
|
||||
|
||||
err := h.Repo.DeleteUser(id)
|
||||
if err != nil {
|
||||
log.Println("Error deleting user: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while deleting user",
|
||||
})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package models
|
||||
|
||||
import "errors"
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
HashedPassword string `json:"hashed_password"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func ValidateCreateUserRequest(req *CreateUserRequest) error {
|
||||
if req.Name == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
if req.Role == "" {
|
||||
return errors.New("role is required")
|
||||
}
|
||||
if req.Role != "admin" && req.Role != "user" {
|
||||
return errors.New("role must be either 'admin' or 'user'")
|
||||
}
|
||||
if req.Password == "" {
|
||||
return errors.New("password is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func ValidateUpdateUserRequest(req *UpdateUserRequest) error {
|
||||
if req.Name == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
if req.Role == "" {
|
||||
return errors.New("role is required")
|
||||
}
|
||||
if req.Role != "admin" && req.Role != "user" {
|
||||
return errors.New("role must be either 'admin' or 'user'")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"quay/app/models"
|
||||
)
|
||||
|
||||
var ErrUserAlreadyExists = errors.New("user already exists")
|
||||
|
||||
type UserRepository interface {
|
||||
GetAllUsers() ([]models.User, error)
|
||||
GetUserById(id string) (*models.User, error)
|
||||
GetUserByName(name string) (*models.User, error)
|
||||
CreateUser(user *models.User) error
|
||||
UpdateUser(user *models.User) error
|
||||
DeleteUser(id string) error
|
||||
}
|
||||
@@ -18,9 +18,11 @@ import (
|
||||
func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, db *sql.DB) {
|
||||
siteRepository := cachedrepo.NewCachedSiteRepository(database.NewSQLiteSiteRepository(db))
|
||||
deploymentRepository := database.NewSQLiteDeploymentRepository(db)
|
||||
userRepository := database.NewSQLiteUserRepository(db)
|
||||
|
||||
deploySiteHandler := handlers.NewDeploySiteHandler(envCfg, siteRepository, deploymentRepository)
|
||||
siteHandler := handlers.NewSiteHandler(siteRepository)
|
||||
deploySiteHandler := handlers.NewDeploySiteHandler(envCfg, siteRepository, deploymentRepository)
|
||||
userHandler := handlers.NewUserHandler(userRepository)
|
||||
deploymentsHandler := handlers.NewDeploymentHandler(deploymentRepository)
|
||||
|
||||
api := app.Group("/api/v1", middleware.APIHostGuard(envCfg.DashboardHost))
|
||||
@@ -61,6 +63,14 @@ func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, d
|
||||
api.Get("/deployments/:id", deploymentsHandler.GetDeployment)
|
||||
api.Get("/sites/:id/deployments", deploymentsHandler.GetDeploymentsBySite)
|
||||
|
||||
// Users
|
||||
api.Get("/users", userHandler.GetAllUsers)
|
||||
api.Get("/users/:id", userHandler.GetUserById)
|
||||
api.Get("/users/by-name/:name", userHandler.GetUserByName)
|
||||
api.Post("/users", userHandler.CreateUser)
|
||||
api.Put("/users/:id", userHandler.UpdateUser)
|
||||
api.Delete("/users/:id", userHandler.DeleteUser)
|
||||
|
||||
api.Use(func(c fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "Endpoint not found",
|
||||
|
||||
@@ -59,6 +59,14 @@ CREATE TABLE IF NOT EXISTS deployments (
|
||||
start_time TIMESTAMP NOT NULL DEFAULT 0,
|
||||
finish_time TIMESTAMP NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL,
|
||||
hashed_password TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"quay/app/models"
|
||||
"quay/app/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type SQLiteUserRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSQLiteUserRepository(db *sql.DB) *SQLiteUserRepository {
|
||||
return &SQLiteUserRepository{db: db}
|
||||
}
|
||||
|
||||
var _ repository.UserRepository = (*SQLiteUserRepository)(nil)
|
||||
|
||||
func (r *SQLiteUserRepository) GetAllUsers() ([]models.User, error) {
|
||||
rows, err := r.db.Query(`
|
||||
SELECT id, name, role, hashed_password, created_at
|
||||
FROM users`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list users: %w", err)
|
||||
}
|
||||
|
||||
var users []models.User
|
||||
for rows.Next() {
|
||||
s, err := scanUser(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("list users scan: %w", err)
|
||||
}
|
||||
users = append(users, *s)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteUserRepository) GetUserById(id string) (*models.User, error) {
|
||||
row := r.db.QueryRow(`
|
||||
SELECT id, name, role, hashed_password, created_at
|
||||
FROM users WHERE id = ?`, id)
|
||||
|
||||
u, err := scanUser(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user by id: %w", err)
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteUserRepository) GetUserByName(name string) (*models.User, error) {
|
||||
row := r.db.QueryRow(`
|
||||
SELECT id, name, role, hashed_password, created_at
|
||||
FROM users WHERE name = ?`, name)
|
||||
|
||||
u, err := scanUser(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user by name: %w", err)
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteUserRepository) CreateUser(user *models.User) error {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create user begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
user.ID = uuid.NewString()
|
||||
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO users (id, name, role, hashed_password, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
user.ID, user.Name, user.Role, user.HashedPassword, user.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if isSQLiteUniqueConstraintError(err) {
|
||||
return fmt.Errorf("create user insert: %w", repository.ErrUserAlreadyExists)
|
||||
}
|
||||
return fmt.Errorf("create user insert: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *SQLiteUserRepository) UpdateUser(user *models.User) error {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update user begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec(`
|
||||
UPDATE users SET name=?, role=? WHERE id=?`,
|
||||
user.Name, user.Role, user.ID,
|
||||
)
|
||||
if err != nil {
|
||||
if isSQLiteUniqueConstraintError(err) {
|
||||
return fmt.Errorf("update user: %w", repository.ErrUserAlreadyExists)
|
||||
}
|
||||
return fmt.Errorf("update user: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *SQLiteUserRepository) DeleteUser(id string) error {
|
||||
_, err := r.db.Exec(`DELETE FROM users WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanUser(s scanner) (*models.User, error) {
|
||||
u := new(models.User)
|
||||
err := s.Scan(
|
||||
&u.ID,
|
||||
&u.Name,
|
||||
&u.Role,
|
||||
&u.HashedPassword,
|
||||
&u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteUserRepository) AdminUserExists() (bool, error) {
|
||||
var count int
|
||||
err := r.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'admin'`).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("admin user exists: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func isSQLiteUniqueConstraintError(err error) bool {
|
||||
var sqliteErr sqlite3.Error
|
||||
if !errors.As(err, &sqliteErr) {
|
||||
return false
|
||||
}
|
||||
|
||||
return sqliteErr.Code == sqlite3.ErrConstraint && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package security
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
func CheckPasswordHash(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
Reference in New Issue
Block a user