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.
193 lines
4.1 KiB
Go
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
|
|
}
|