Files
TermorServer/internal/auth/service.go
benya 46c2c3fb28 feat: import library from media root and stream tracks
Add a filesystem scanner that ingests supported audio files from MEDIA_ROOT into SQLite using embedded tags with filename fallbacks. Wire startup scanning, manual rescan, and authenticated audio streaming into the backend, then connect the web player to the real stream endpoint.
2026-04-02 22:29:04 +03:00

193 lines
4.1 KiB
Go

package auth
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUnauthorized = errors.New("unauthorized")
)
type User struct {
ID string `json:"id"`
Username string `json:"username"`
IsAdmin bool `json:"isAdmin"`
CreatedAt time.Time `json:"createdAt"`
LastLoginAt time.Time `json:"lastLoginAt"`
}
type Session struct {
Token string `json:"token"`
User User `json:"user"`
}
type Service struct {
db *sql.DB
}
func NewService(db *sql.DB) *Service {
return &Service{db: db}
}
func (s *Service) Login(ctx context.Context, username, password string) (Session, error) {
user, passwordHash, err := s.findUserByUsername(ctx, username)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Session{}, ErrInvalidCredentials
}
return Session{}, fmt.Errorf("find user: %w", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
return Session{}, ErrInvalidCredentials
}
token, err := newToken()
if err != nil {
return Session{}, fmt.Errorf("generate token: %w", err)
}
now := time.Now().UTC()
expiresAt := now.Add(30 * 24 * time.Hour)
if _, err := s.db.ExecContext(
ctx,
`INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
token,
user.ID,
now.Format(time.RFC3339),
expiresAt.Format(time.RFC3339),
); err != nil {
return Session{}, fmt.Errorf("insert session: %w", err)
}
if _, err := s.db.ExecContext(
ctx,
`UPDATE users SET last_login_at = ? WHERE id = ?`,
now.Format(time.RFC3339),
user.ID,
); err == nil {
user.LastLoginAt = now
}
return Session{
Token: token,
User: user,
}, nil
}
func (s *Service) CurrentUser(ctx context.Context, authorizationHeader string) (User, error) {
token := strings.TrimSpace(strings.TrimPrefix(authorizationHeader, "Bearer "))
return s.CurrentUserByToken(ctx, token)
}
func (s *Service) CurrentUserByToken(ctx context.Context, token string) (User, error) {
if token == "" {
return User{}, ErrUnauthorized
}
user, err := s.findUserByToken(ctx, token)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrUnauthorized
}
return User{}, fmt.Errorf("find user by token: %w", err)
}
return user, nil
}
func (s *Service) findUserByUsername(ctx context.Context, username string) (User, string, error) {
var user User
var passwordHash string
var createdAt string
var lastLoginAt sql.NullString
var isAdmin int
err := s.db.QueryRowContext(
ctx,
`SELECT id, username, password_hash, is_admin, created_at, last_login_at
FROM users
WHERE username = ?`,
username,
).Scan(
&user.ID,
&user.Username,
&passwordHash,
&isAdmin,
&createdAt,
&lastLoginAt,
)
if err != nil {
return User{}, "", err
}
user.IsAdmin = isAdmin == 1
user.CreatedAt = parseTime(createdAt)
if lastLoginAt.Valid {
user.LastLoginAt = parseTime(lastLoginAt.String)
}
return user, passwordHash, nil
}
func (s *Service) findUserByToken(ctx context.Context, token string) (User, error) {
var user User
var createdAt string
var lastLoginAt sql.NullString
var isAdmin int
err := s.db.QueryRowContext(
ctx,
`SELECT u.id, u.username, u.is_admin, u.created_at, u.last_login_at
FROM users u
JOIN sessions s ON s.user_id = u.id
WHERE s.token = ? AND s.expires_at > ?`,
token,
time.Now().UTC().Format(time.RFC3339),
).Scan(
&user.ID,
&user.Username,
&isAdmin,
&createdAt,
&lastLoginAt,
)
if err != nil {
return User{}, err
}
user.IsAdmin = isAdmin == 1
user.CreatedAt = parseTime(createdAt)
if lastLoginAt.Valid {
user.LastLoginAt = parseTime(lastLoginAt.String)
}
return user, nil
}
func parseTime(raw string) time.Time {
parsed, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}
}
return parsed
}
func newToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}