0e7890a465
Run gofmt -w across the backend, normalising the manually-aligned := blocks to the gofmt standard. No code behaviour changes. Add Prettier (+ prettier-plugin-svelte) to the frontend with the SvelteKit default config (tabs, single quotes) so formatting is reproducible, then run it over the whole tree. Add format / format:check npm scripts and a .prettierignore (build output, generated schema.ts, static assets). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
193 lines
5.4 KiB
Go
193 lines
5.4 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"tanabata/backend/internal/domain"
|
|
"tanabata/backend/internal/port"
|
|
)
|
|
|
|
// UserService handles user CRUD and profile management.
|
|
type UserService struct {
|
|
users port.UserRepo
|
|
sessions port.SessionRepo
|
|
audit *AuditService
|
|
}
|
|
|
|
// NewUserService creates a UserService.
|
|
func NewUserService(users port.UserRepo, sessions port.SessionRepo, audit *AuditService) *UserService {
|
|
return &UserService{users: users, sessions: sessions, audit: audit}
|
|
}
|
|
|
|
// EnsureAdmin creates the initial administrator account if it does not already
|
|
// exist. It is idempotent and never overwrites an existing user's password, so
|
|
// an operator who has changed the admin password keeps it across restarts.
|
|
func (s *UserService) EnsureAdmin(ctx context.Context, username, password string) error {
|
|
if username == "" || password == "" {
|
|
return fmt.Errorf("EnsureAdmin: username and password must be set")
|
|
}
|
|
|
|
if _, err := s.users.GetByName(ctx, username); err == nil {
|
|
return nil // already exists
|
|
} else if !errors.Is(err, domain.ErrNotFound) {
|
|
return fmt.Errorf("EnsureAdmin: lookup: %w", err)
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return fmt.Errorf("EnsureAdmin: hash: %w", err)
|
|
}
|
|
_, err = s.users.Create(ctx, &domain.User{
|
|
Name: username,
|
|
Password: string(hash),
|
|
IsAdmin: true,
|
|
CanCreate: true,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("EnsureAdmin: create: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Self-service
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// GetMe returns the profile of the currently authenticated user.
|
|
func (s *UserService) GetMe(ctx context.Context) (*domain.User, error) {
|
|
userID, _, _ := domain.UserFromContext(ctx)
|
|
return s.users.GetByID(ctx, userID)
|
|
}
|
|
|
|
// UpdateMeParams holds fields a user may change on their own profile.
|
|
type UpdateMeParams struct {
|
|
Name string // empty = no change
|
|
Password *string // nil = no change
|
|
}
|
|
|
|
// UpdateMe allows a user to change their own name and/or password.
|
|
func (s *UserService) UpdateMe(ctx context.Context, p UpdateMeParams) (*domain.User, error) {
|
|
userID, _, _ := domain.UserFromContext(ctx)
|
|
|
|
current, err := s.users.GetByID(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
patch := *current
|
|
if p.Name != "" {
|
|
patch.Name = p.Name
|
|
}
|
|
if p.Password != nil {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(*p.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("UserService.UpdateMe hash: %w", err)
|
|
}
|
|
patch.Password = string(hash)
|
|
}
|
|
|
|
return s.users.Update(ctx, userID, &patch)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Admin CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// List returns a paginated list of users (admin only — caller must enforce).
|
|
func (s *UserService) List(ctx context.Context, params port.OffsetParams) (*domain.UserPage, error) {
|
|
return s.users.List(ctx, params)
|
|
}
|
|
|
|
// Get returns a user by ID (admin only).
|
|
func (s *UserService) Get(ctx context.Context, id int16) (*domain.User, error) {
|
|
return s.users.GetByID(ctx, id)
|
|
}
|
|
|
|
// CreateParams holds fields for creating a new user.
|
|
type CreateUserParams struct {
|
|
Name string
|
|
Password string
|
|
IsAdmin bool
|
|
CanCreate bool
|
|
}
|
|
|
|
// Create inserts a new user with a bcrypt-hashed password (admin only).
|
|
func (s *UserService) Create(ctx context.Context, p CreateUserParams) (*domain.User, error) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(p.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("UserService.Create hash: %w", err)
|
|
}
|
|
|
|
u := &domain.User{
|
|
Name: p.Name,
|
|
Password: string(hash),
|
|
IsAdmin: p.IsAdmin,
|
|
CanCreate: p.CanCreate,
|
|
}
|
|
created, err := s.users.Create(ctx, u)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_ = s.audit.Log(ctx, "user_create", nil, nil, map[string]any{"target_user_id": created.ID})
|
|
return created, nil
|
|
}
|
|
|
|
// UpdateAdminParams holds fields an admin may change on any user.
|
|
type UpdateAdminParams struct {
|
|
IsAdmin *bool
|
|
CanCreate *bool
|
|
IsBlocked *bool
|
|
}
|
|
|
|
// UpdateAdmin applies an admin-level patch to a user.
|
|
func (s *UserService) UpdateAdmin(ctx context.Context, id int16, p UpdateAdminParams) (*domain.User, error) {
|
|
current, err := s.users.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
patch := *current
|
|
if p.IsAdmin != nil {
|
|
patch.IsAdmin = *p.IsAdmin
|
|
}
|
|
if p.CanCreate != nil {
|
|
patch.CanCreate = *p.CanCreate
|
|
}
|
|
if p.IsBlocked != nil {
|
|
patch.IsBlocked = *p.IsBlocked
|
|
}
|
|
|
|
updated, err := s.users.Update(ctx, id, &patch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Log block/unblock specifically, and revoke all sessions on block so the
|
|
// user's outstanding access tokens stop working immediately.
|
|
if p.IsBlocked != nil {
|
|
action := "user_unblock"
|
|
if *p.IsBlocked {
|
|
action = "user_block"
|
|
if err := s.sessions.DeleteByUserID(ctx, id); err != nil {
|
|
return nil, fmt.Errorf("UserService.UpdateAdmin revoke sessions: %w", err)
|
|
}
|
|
}
|
|
_ = s.audit.Log(ctx, action, nil, nil, map[string]any{"target_user_id": id})
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
// Delete removes a user by ID (admin only).
|
|
func (s *UserService) Delete(ctx context.Context, id int16) error {
|
|
if err := s.users.Delete(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
_ = s.audit.Log(ctx, "user_delete", nil, nil, map[string]any{"target_user_id": id})
|
|
return nil
|
|
}
|