Add Go/Postgres admin APIs, Angular admin UI, manual build flow, asset uploads, markdown import/export, configurable slug generation, and the Yar reading theme. Exclude local docs and generated development artifacts from version control.
166 lines
4 KiB
Go
166 lines
4 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
const sessionLifetime = 30 * 24 * time.Hour
|
|
|
|
type LoginInput struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type LoginResult struct {
|
|
User User `json:"user"`
|
|
ExpiresAt time.Time `json:"expiresAt"`
|
|
Token string `json:"-"`
|
|
}
|
|
|
|
func (s *Store) CreateOrUpdateUser(ctx context.Context, username string, password string) (User, error) {
|
|
if username == "" {
|
|
return User{}, errors.New("username is required")
|
|
}
|
|
passwordHash, err := HashPassword(password)
|
|
if err != nil {
|
|
return User{}, err
|
|
}
|
|
|
|
user, err := scanUser(s.db.QueryRow(ctx, `
|
|
INSERT INTO users (username, password_hash)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (username)
|
|
DO UPDATE SET password_hash = excluded.password_hash, updated_at = now()
|
|
RETURNING id, username, created_at, updated_at, last_login_at`, username, passwordHash))
|
|
if err != nil {
|
|
return User{}, fmt.Errorf("create or update user: %w", err)
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func (s *Store) Login(ctx context.Context, input LoginInput) (LoginResult, error) {
|
|
if input.Username == "" || input.Password == "" {
|
|
return LoginResult{}, ErrInvalidCredentials
|
|
}
|
|
|
|
var user User
|
|
var passwordHash string
|
|
var lastLoginAt sql.NullTime
|
|
err := s.db.QueryRow(ctx, `
|
|
SELECT id, username, password_hash, created_at, updated_at, last_login_at
|
|
FROM users
|
|
WHERE username = $1`, input.Username).Scan(
|
|
&user.ID,
|
|
&user.Username,
|
|
&passwordHash,
|
|
&user.CreatedAt,
|
|
&user.UpdatedAt,
|
|
&lastLoginAt,
|
|
)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return LoginResult{}, ErrInvalidCredentials
|
|
}
|
|
if err != nil {
|
|
return LoginResult{}, fmt.Errorf("find user: %w", err)
|
|
}
|
|
user.LastLoginAt = nullTimePtr(lastLoginAt)
|
|
|
|
if !CheckPassword(passwordHash, input.Password) {
|
|
return LoginResult{}, ErrInvalidCredentials
|
|
}
|
|
|
|
token, err := NewSessionToken()
|
|
if err != nil {
|
|
return LoginResult{}, err
|
|
}
|
|
loginAt := time.Now()
|
|
expiresAt := loginAt.Add(sessionLifetime)
|
|
|
|
tx, err := s.db.Begin(ctx)
|
|
if err != nil {
|
|
return LoginResult{}, fmt.Errorf("begin login: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE users
|
|
SET last_login_at = now(), updated_at = now()
|
|
WHERE id = $1`, user.ID); err != nil {
|
|
return LoginResult{}, fmt.Errorf("update last login: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO admin_sessions (user_id, token_hash, expires_at)
|
|
VALUES ($1, $2, $3)`, user.ID, SessionTokenHash(token), expiresAt); err != nil {
|
|
return LoginResult{}, fmt.Errorf("create session: %w", err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return LoginResult{}, fmt.Errorf("commit login: %w", err)
|
|
}
|
|
|
|
user.LastLoginAt = &loginAt
|
|
return LoginResult{
|
|
User: user,
|
|
ExpiresAt: expiresAt,
|
|
Token: token,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Store) UserBySessionToken(ctx context.Context, token string) (User, error) {
|
|
if token == "" {
|
|
return User{}, ErrInvalidCredentials
|
|
}
|
|
|
|
user, err := scanUser(s.db.QueryRow(ctx, `
|
|
SELECT u.id, u.username, u.created_at, u.updated_at, u.last_login_at
|
|
FROM admin_sessions s
|
|
JOIN users u ON u.id = s.user_id
|
|
WHERE s.token_hash = $1 AND s.expires_at > now()`, SessionTokenHash(token)))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return User{}, ErrInvalidCredentials
|
|
}
|
|
if err != nil {
|
|
return User{}, fmt.Errorf("find session: %w", err)
|
|
}
|
|
|
|
_, _ = s.db.Exec(ctx, `
|
|
UPDATE admin_sessions
|
|
SET last_seen_at = now()
|
|
WHERE token_hash = $1`, SessionTokenHash(token))
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func (s *Store) Logout(ctx context.Context, token string) error {
|
|
if token == "" {
|
|
return nil
|
|
}
|
|
if _, err := s.db.Exec(ctx, `
|
|
DELETE FROM admin_sessions
|
|
WHERE token_hash = $1`, SessionTokenHash(token)); err != nil {
|
|
return fmt.Errorf("delete session: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func scanUser(row postScanner) (User, error) {
|
|
var user User
|
|
var lastLoginAt sql.NullTime
|
|
err := row.Scan(
|
|
&user.ID,
|
|
&user.Username,
|
|
&user.CreatedAt,
|
|
&user.UpdatedAt,
|
|
&lastLoginAt,
|
|
)
|
|
if err != nil {
|
|
return User{}, err
|
|
}
|
|
user.LastLoginAt = nullTimePtr(lastLoginAt)
|
|
return user, nil
|
|
}
|