feat: add admin publishing workflow and yar theme
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.
This commit is contained in:
parent
b78f4b39c9
commit
f0b50d13ea
121 changed files with 27139 additions and 550 deletions
166
backend/internal/admin/auth_store.go
Normal file
166
backend/internal/admin/auth_store.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue