rewritten DAO, user management
This commit is contained in:
parent
01d961539c
commit
f36ed55788
28
README.md
28
README.md
@ -33,23 +33,33 @@ PORT=8090 mvn -q compile exec:java
|
|||||||
|
|
||||||
## User Management
|
## User Management
|
||||||
|
|
||||||
To manage users (add, delete, update passwords), use the `UserManager` CLI tool:
|
User management is now available via `/user` CRUD API protected by a fixed bearer token:
|
||||||
|
- Bearer token: `MujBearer852654`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Interactive mode
|
# List users
|
||||||
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager
|
curl -H "Authorization: Bearer MujBearer852654" \
|
||||||
|
http://localhost:8080/user
|
||||||
|
|
||||||
# Add a user
|
# Get one user
|
||||||
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager add username password
|
curl -H "Authorization: Bearer MujBearer852654" \
|
||||||
|
"http://localhost:8080/user?username=admin"
|
||||||
|
|
||||||
# List all users
|
# Create user
|
||||||
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager list
|
curl -X POST -H "Authorization: Bearer MujBearer852654" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"user","password":"pass123"}' \
|
||||||
|
http://localhost:8080/user
|
||||||
|
|
||||||
# Update password
|
# Update password
|
||||||
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager update username new_password
|
curl -X PUT -H "Authorization: Bearer MujBearer852654" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"user","newPassword":"newPass123"}' \
|
||||||
|
http://localhost:8080/user
|
||||||
|
|
||||||
# Delete user
|
# Delete user
|
||||||
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager delete username
|
curl -X DELETE -H "Authorization: Bearer MujBearer852654" \
|
||||||
|
"http://localhost:8080/user?username=user"
|
||||||
```
|
```
|
||||||
|
|
||||||
See [USERS_MANAGEMENT.md](USERS_MANAGEMENT.md) for detailed user management documentation.
|
See [USERS_MANAGEMENT.md](USERS_MANAGEMENT.md) for detailed user management documentation.
|
||||||
|
|||||||
@ -1,122 +1,85 @@
|
|||||||
# Správa uživatelů - Xtream Player
|
# User Management - Xtream Player
|
||||||
|
|
||||||
## Přehled
|
## Overview
|
||||||
|
|
||||||
Uživatelé jsou ukládáni v H2 databázi (`~/.xtream-player/users.db`). Aplikace vytvoří výchozího uživatele `admin`/`admin` při prvním spuštění, pokud žádní uživatelé neexistují.
|
Users are stored in H2 database (`~/.xtream-player/users.db`).
|
||||||
|
If no users exist, application creates default user `admin` / `admin` on startup.
|
||||||
|
|
||||||
## UserManager - Nástroj pro správu uživatelů
|
## Authentication for User API
|
||||||
|
|
||||||
K přidání, smazání nebo úpravě uživatelů slouží třída `UserManager`, kterou lze spustit jako standalone aplikaci.
|
User CRUD API is protected by a fixed bearer token:
|
||||||
|
|
||||||
### Spuštění interaktivního režimu
|
- Header: `Authorization: Bearer MujBearer852654`
|
||||||
|
|
||||||
|
Without this header (or with wrong value), API returns `401 Unauthorized`.
|
||||||
|
|
||||||
|
## /user CRUD API
|
||||||
|
|
||||||
|
Base endpoint:
|
||||||
|
|
||||||
|
- `http://localhost:8080/user`
|
||||||
|
|
||||||
|
### List users
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager
|
curl -H "Authorization: Bearer MujBearer852654" \
|
||||||
|
http://localhost:8080/user
|
||||||
```
|
```
|
||||||
|
|
||||||
Zobrazí menu s následujícími možnostmi:
|
### Get one user
|
||||||
1. Přidat uživatele
|
|
||||||
2. Smazat uživatele
|
|
||||||
3. Aktualizovat heslo
|
|
||||||
4. Vypsat všechny uživatele
|
|
||||||
5. Ověřit heslo
|
|
||||||
0. Odejít
|
|
||||||
|
|
||||||
### Použití z příkazové řádky
|
|
||||||
|
|
||||||
#### Přidat uživatele
|
|
||||||
```bash
|
```bash
|
||||||
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager add username password
|
curl -H "Authorization: Bearer MujBearer852654" \
|
||||||
|
"http://localhost:8080/user?username=admin"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Smazat uživatele
|
### Create user
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager delete username
|
curl -X POST -H "Authorization: Bearer MujBearer852654" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"user","password":"pass123"}' \
|
||||||
|
http://localhost:8080/user
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Aktualizovat heslo
|
You can also send URL-encoded form data:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager update username new_password
|
curl -X POST -H "Authorization: Bearer MujBearer852654" \
|
||||||
|
-d "username=user&password=pass123" \
|
||||||
|
http://localhost:8080/user
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Vypsat všechny uživatele
|
### Update password
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager list
|
curl -X PUT -H "Authorization: Bearer MujBearer852654" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"user","newPassword":"newPass123"}' \
|
||||||
|
http://localhost:8080/user
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Ověřit heslo
|
`password` is accepted as fallback key as well.
|
||||||
|
|
||||||
|
### Delete user
|
||||||
|
|
||||||
|
By query param:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager verify username password
|
curl -X DELETE -H "Authorization: Bearer MujBearer852654" \
|
||||||
|
"http://localhost:8080/user?username=user"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Příklady
|
Or by JSON body:
|
||||||
|
|
||||||
### Přidať nového administrátora
|
|
||||||
```bash
|
```bash
|
||||||
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager add admin123 MySecurePassword123
|
curl -X DELETE -H "Authorization: Bearer MujBearer852654" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"user"}' \
|
||||||
|
http://localhost:8080/user
|
||||||
```
|
```
|
||||||
|
|
||||||
### Změnit heslo existujícího uživatele
|
## Response Notes
|
||||||
```bash
|
|
||||||
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager update admin newPassword456
|
|
||||||
```
|
|
||||||
|
|
||||||
### Zobrazit všechny uživatele
|
- Returned user objects include: `id`, `username`, `createdAt`, `updatedAt`
|
||||||
```bash
|
- Password hash is never returned by the API.
|
||||||
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager list
|
|
||||||
```
|
|
||||||
|
|
||||||
Výstup:
|
|
||||||
```
|
|
||||||
╔═══╦════════════════════╦═══════════════════════════════╦═══════════════════════════════╗
|
|
||||||
║ ID║ Username ║ Created At ║ Updated At ║
|
|
||||||
╠═══╬════════════════════╬═══════════════════════════════╬═══════════════════════════════╣
|
|
||||||
║ 1║ admin ║ 2026-03-09 10:30:45.123 ║ 2026-03-09 10:30:45.123 ║
|
|
||||||
║ 2║ admin123 ║ 2026-03-09 10:35:12.456 ║ 2026-03-09 10:35:12.456 ║
|
|
||||||
╚═══╩════════════════════╩═══════════════════════════════╩═══════════════════════════════╝
|
|
||||||
Total: 2 user(s)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architektura
|
|
||||||
|
|
||||||
### UserStore
|
|
||||||
- **Třída**: `cz.kamma.xtreamplayer.UserStore`
|
|
||||||
- **Odpovědnost**: Správa perzistentního úložiště uživatelů v H2 databázi
|
|
||||||
- **Metody**:
|
|
||||||
- `initialize()` - Initialisace databázové tabulky
|
|
||||||
- `createUser(username, password)` - Vytvoří nového uživatele
|
|
||||||
- `updatePassword(username, newPassword)` - Změní heslo
|
|
||||||
- `deleteUser(username)` - Odstraní uživatele
|
|
||||||
- `getUser(username)` - Retrieves uživatele
|
|
||||||
- `getAllUsers()` - Načte všechny uživatele
|
|
||||||
- `verifyPassword(username, password)` - Ověří heslo
|
|
||||||
- `userExists(username)` - Zkontroluje existenci
|
|
||||||
|
|
||||||
### UserAuthenticator
|
|
||||||
- **Třída**: `cz.kamma.xtreamplayer.UserAuthenticator`
|
|
||||||
- **Odpovědnost**: Správa session tokenů a přihlašování
|
|
||||||
- **Metody**:
|
|
||||||
- `authenticate(username, password)` - Přihlášení a vygenerování tokenu
|
|
||||||
- `validateToken(token)` - Ověření tokenu
|
|
||||||
- `isTokenValid(token)` - Kontrola validity tokenu
|
|
||||||
- `revokeToken(token)` - Zrušení tokenu
|
|
||||||
|
|
||||||
### UserManager
|
|
||||||
- **Třída**: `cz.kamma.xtreamplayer.UserManager`
|
|
||||||
- **Odpovědnost**: CLI nástroj pro správu uživatelů
|
|
||||||
- **Režimy**:
|
|
||||||
- Interaktivní - Menu-driven UI
|
|
||||||
- Příkazová řádka - Přímá spuštění příkazů
|
|
||||||
|
|
||||||
## Bezpečnost
|
|
||||||
|
|
||||||
- Hesla jsou hashována pomocí SHA-256 a zakódována v Base64
|
|
||||||
- Session tokeny jsou generovány pomocí `SecureRandom` a mají platnost 24 hodin
|
|
||||||
- Databáze je chráněna stejně jako ostatní aplikační data v `~/.xtream-player/`
|
|
||||||
|
|
||||||
## Migrace ze starý verze
|
|
||||||
|
|
||||||
Pokud upgradujete z verze bez databázové autentizace:
|
|
||||||
1. Aplikace automaticky vytvoří nový soubor `~/.xtream-player/users.db`
|
|
||||||
2. Výchozí uživatel `admin`/`admin` bude vytvořen automaticky
|
|
||||||
3. Můžete přidat nebo upravit uživatele pomocí `UserManager`
|
|
||||||
|
|||||||
@ -3,39 +3,67 @@ package cz.kamma.xtreamplayer;
|
|||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.DriverManager;
|
import java.sql.DriverManager;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.sql.Statement;
|
import java.sql.Statement;
|
||||||
|
import java.sql.Timestamp;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
final class LibraryRepository {
|
/**
|
||||||
private static final Logger LOGGER = LogManager.getLogger(LibraryRepository.class);
|
* Unified Data Access Object for both user credentials and library/stream data.
|
||||||
|
* Manages persistent data in H2 database for users, categories, streams, series, and favorites.
|
||||||
|
*/
|
||||||
|
final class ApplicationDao {
|
||||||
|
private static final Logger LOGGER = LogManager.getLogger(ApplicationDao.class);
|
||||||
private final Path dbPath;
|
private final Path dbPath;
|
||||||
private final String jdbcUrl;
|
private final String jdbcUrl;
|
||||||
|
|
||||||
LibraryRepository(Path dbPath) {
|
public ApplicationDao(Path dbPath) {
|
||||||
this.dbPath = dbPath;
|
this.dbPath = dbPath;
|
||||||
this.jdbcUrl = "jdbc:h2:file:" + dbPath.toAbsolutePath().toString().replace("\\", "/") + ";AUTO_SERVER=TRUE";
|
this.jdbcUrl = "jdbc:h2:file:" + dbPath.toAbsolutePath().toString().replace("\\", "/") + ";AUTO_SERVER=TRUE";
|
||||||
}
|
}
|
||||||
|
|
||||||
void initialize() {
|
public void initialize() {
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(dbPath.getParent());
|
Files.createDirectories(dbPath.getParent());
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOGGER.error("Failed to create database directory", e);
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
try (Connection connection = openConnection(); Statement statement = connection.createStatement()) {
|
try (Connection connection = openConnection(); Statement statement = connection.createStatement()) {
|
||||||
|
// User tables
|
||||||
|
statement.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id IDENTITY PRIMARY KEY,
|
||||||
|
username VARCHAR(120) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(256) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
// Library metadata
|
||||||
statement.execute("""
|
statement.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS source_meta (
|
CREATE TABLE IF NOT EXISTS source_meta (
|
||||||
meta_key VARCHAR(120) PRIMARY KEY,
|
meta_key VARCHAR(120) PRIMARY KEY,
|
||||||
meta_value CLOB
|
meta_value CLOB
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
// Categories
|
||||||
statement.execute("""
|
statement.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS live_categories (
|
CREATE TABLE IF NOT EXISTS live_categories (
|
||||||
category_id VARCHAR(80) PRIMARY KEY,
|
category_id VARCHAR(80) PRIMARY KEY,
|
||||||
@ -54,6 +82,7 @@ final class LibraryRepository {
|
|||||||
category_name VARCHAR(400) NOT NULL
|
category_name VARCHAR(400) NOT NULL
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
// Streams
|
||||||
statement.execute("""
|
statement.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS live_streams (
|
CREATE TABLE IF NOT EXISTS live_streams (
|
||||||
stream_id VARCHAR(120) PRIMARY KEY,
|
stream_id VARCHAR(120) PRIMARY KEY,
|
||||||
@ -70,6 +99,7 @@ final class LibraryRepository {
|
|||||||
container_extension VARCHAR(40)
|
container_extension VARCHAR(40)
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
// Series
|
||||||
statement.execute("""
|
statement.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS series_items (
|
CREATE TABLE IF NOT EXISTS series_items (
|
||||||
series_id VARCHAR(120) PRIMARY KEY,
|
series_id VARCHAR(120) PRIMARY KEY,
|
||||||
@ -87,6 +117,7 @@ final class LibraryRepository {
|
|||||||
container_extension VARCHAR(40)
|
container_extension VARCHAR(40)
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
// Favorites
|
||||||
statement.execute("""
|
statement.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS favorites (
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
favorite_key VARCHAR(260) PRIMARY KEY,
|
favorite_key VARCHAR(260) PRIMARY KEY,
|
||||||
@ -102,14 +133,12 @@ final class LibraryRepository {
|
|||||||
created_at BIGINT NOT NULL
|
created_at BIGINT NOT NULL
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
// Indexes
|
||||||
statement.execute("CREATE INDEX IF NOT EXISTS idx_live_streams_category ON live_streams(category_id)");
|
statement.execute("CREATE INDEX IF NOT EXISTS idx_live_streams_category ON live_streams(category_id)");
|
||||||
statement.execute("CREATE INDEX IF NOT EXISTS idx_vod_streams_category ON vod_streams(category_id)");
|
statement.execute("CREATE INDEX IF NOT EXISTS idx_vod_streams_category ON vod_streams(category_id)");
|
||||||
statement.execute("CREATE INDEX IF NOT EXISTS idx_series_items_category ON series_items(category_id)");
|
statement.execute("CREATE INDEX IF NOT EXISTS idx_series_items_category ON series_items(category_id)");
|
||||||
statement.execute("CREATE INDEX IF NOT EXISTS idx_series_episodes_series ON series_episodes(series_id)");
|
statement.execute("CREATE INDEX IF NOT EXISTS idx_series_episodes_series ON series_episodes(series_id)");
|
||||||
statement.execute(
|
statement.execute("CREATE INDEX IF NOT EXISTS idx_series_episodes_series_sort ON series_episodes(series_id, season, episode_num, title)");
|
||||||
"CREATE INDEX IF NOT EXISTS idx_series_episodes_series_sort "
|
|
||||||
+ "ON series_episodes(series_id, season, episode_num, title)"
|
|
||||||
);
|
|
||||||
statement.execute("CREATE INDEX IF NOT EXISTS idx_live_categories_name ON live_categories(category_name)");
|
statement.execute("CREATE INDEX IF NOT EXISTS idx_live_categories_name ON live_categories(category_name)");
|
||||||
statement.execute("CREATE INDEX IF NOT EXISTS idx_vod_categories_name ON vod_categories(category_name)");
|
statement.execute("CREATE INDEX IF NOT EXISTS idx_vod_categories_name ON vod_categories(category_name)");
|
||||||
statement.execute("CREATE INDEX IF NOT EXISTS idx_series_categories_name ON series_categories(category_name)");
|
statement.execute("CREATE INDEX IF NOT EXISTS idx_series_categories_name ON series_categories(category_name)");
|
||||||
@ -117,16 +146,148 @@ final class LibraryRepository {
|
|||||||
statement.execute("CREATE INDEX IF NOT EXISTS idx_vod_streams_name ON vod_streams(name)");
|
statement.execute("CREATE INDEX IF NOT EXISTS idx_vod_streams_name ON vod_streams(name)");
|
||||||
statement.execute("CREATE INDEX IF NOT EXISTS idx_series_items_name ON series_items(name)");
|
statement.execute("CREATE INDEX IF NOT EXISTS idx_series_items_name ON series_items(name)");
|
||||||
statement.execute("CREATE INDEX IF NOT EXISTS idx_favorites_created_at ON favorites(created_at DESC)");
|
statement.execute("CREATE INDEX IF NOT EXISTS idx_favorites_created_at ON favorites(created_at DESC)");
|
||||||
|
LOGGER.info("Database tables and indexes initialized at {}", dbPath);
|
||||||
}
|
}
|
||||||
try (Connection connection = openConnection()) {
|
try (Connection connection = openConnection()) {
|
||||||
initializeFullText(connection);
|
initializeFullText(connection);
|
||||||
}
|
}
|
||||||
LOGGER.info("H2 repository initialized at {}", dbPath);
|
LOGGER.info("H2 repository initialized at {}", dbPath);
|
||||||
} catch (Exception exception) {
|
} catch (SQLException exception) {
|
||||||
throw new IllegalStateException("Unable to initialize H2 repository.", exception);
|
LOGGER.error("Failed to initialize database", exception);
|
||||||
|
throw new RuntimeException(exception);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== USER MANAGEMENT =====
|
||||||
|
|
||||||
|
public void createUser(String username, String password) {
|
||||||
|
if (username == null || username.isBlank() || password == null || password.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Username and password must not be null or empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Connection connection = openConnection()) {
|
||||||
|
String sql = "INSERT INTO users (username, password_hash) VALUES (?, ?)";
|
||||||
|
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
|
||||||
|
stmt.setString(1, username);
|
||||||
|
stmt.setString(2, hashPassword(password));
|
||||||
|
stmt.executeUpdate();
|
||||||
|
LOGGER.info("User created: {}", username);
|
||||||
|
}
|
||||||
|
} catch (SQLException exception) {
|
||||||
|
if (exception.getMessage().contains("Unique constraint")) {
|
||||||
|
throw new IllegalArgumentException("User '" + username + "' already exists");
|
||||||
|
}
|
||||||
|
LOGGER.error("Failed to create user: {}", username, exception);
|
||||||
|
throw new RuntimeException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updatePassword(String username, String newPassword) {
|
||||||
|
if (username == null || username.isBlank() || newPassword == null || newPassword.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Username and password must not be null or empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Connection connection = openConnection()) {
|
||||||
|
String sql = "UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE username = ?";
|
||||||
|
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
|
||||||
|
stmt.setString(1, hashPassword(newPassword));
|
||||||
|
stmt.setString(2, username);
|
||||||
|
int updated = stmt.executeUpdate();
|
||||||
|
if (updated == 0) {
|
||||||
|
throw new IllegalArgumentException("User '" + username + "' not found");
|
||||||
|
}
|
||||||
|
LOGGER.info("Password updated for user: {}", username);
|
||||||
|
}
|
||||||
|
} catch (SQLException exception) {
|
||||||
|
LOGGER.error("Failed to update password for user: {}", username, exception);
|
||||||
|
throw new RuntimeException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteUser(String username) {
|
||||||
|
if (username == null || username.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Username must not be null or empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Connection connection = openConnection()) {
|
||||||
|
String sql = "DELETE FROM users WHERE username = ?";
|
||||||
|
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
|
||||||
|
stmt.setString(1, username);
|
||||||
|
int deleted = stmt.executeUpdate();
|
||||||
|
if (deleted == 0) {
|
||||||
|
throw new IllegalArgumentException("User '" + username + "' not found");
|
||||||
|
}
|
||||||
|
LOGGER.info("User deleted: {}", username);
|
||||||
|
}
|
||||||
|
} catch (SQLException exception) {
|
||||||
|
LOGGER.error("Failed to delete user: {}", username, exception);
|
||||||
|
throw new RuntimeException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<User> getUser(String username) {
|
||||||
|
if (username == null || username.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Connection connection = openConnection()) {
|
||||||
|
String sql = "SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = ?";
|
||||||
|
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
|
||||||
|
stmt.setString(1, username);
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
return Optional.of(new User(
|
||||||
|
rs.getInt("id"),
|
||||||
|
rs.getString("username"),
|
||||||
|
rs.getString("password_hash"),
|
||||||
|
rs.getTimestamp("created_at"),
|
||||||
|
rs.getTimestamp("updated_at")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException exception) {
|
||||||
|
LOGGER.error("Failed to get user: {}", username, exception);
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<User> getAllUsers() {
|
||||||
|
List<User> users = new ArrayList<>();
|
||||||
|
try (Connection connection = openConnection()) {
|
||||||
|
String sql = "SELECT id, username, password_hash, created_at, updated_at FROM users ORDER BY username";
|
||||||
|
try (Statement stmt = connection.createStatement();
|
||||||
|
ResultSet rs = stmt.executeQuery(sql)) {
|
||||||
|
while (rs.next()) {
|
||||||
|
users.add(new User(
|
||||||
|
rs.getInt("id"),
|
||||||
|
rs.getString("username"),
|
||||||
|
rs.getString("password_hash"),
|
||||||
|
rs.getTimestamp("created_at"),
|
||||||
|
rs.getTimestamp("updated_at")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException exception) {
|
||||||
|
LOGGER.error("Failed to get all users", exception);
|
||||||
|
}
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean verifyPassword(String username, String password) {
|
||||||
|
Optional<User> user = getUser(username);
|
||||||
|
if (user.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return hashPassword(password).equals(user.get().getPasswordHash());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean userExists(String username) {
|
||||||
|
return getUser(username).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== LIBRARY MANAGEMENT =====
|
||||||
|
|
||||||
void clearAllSources() {
|
void clearAllSources() {
|
||||||
LOGGER.info("Clearing all cached library data in H2");
|
LOGGER.info("Clearing all cached library data in H2");
|
||||||
inTransaction(connection -> {
|
inTransaction(connection -> {
|
||||||
@ -704,6 +865,8 @@ final class LibraryRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== PRIVATE HELPERS =====
|
||||||
|
|
||||||
private int countTable(Connection connection, String tableName) throws SQLException {
|
private int countTable(Connection connection, String tableName) throws SQLException {
|
||||||
try (Statement statement = connection.createStatement();
|
try (Statement statement = connection.createStatement();
|
||||||
ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) AS c FROM " + tableName)) {
|
ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) AS c FROM " + tableName)) {
|
||||||
@ -925,7 +1088,7 @@ final class LibraryRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Connection openConnection() throws SQLException {
|
private Connection openConnection() throws SQLException {
|
||||||
return DriverManager.getConnection(jdbcUrl, "sa", "");
|
return DriverManager.getConnection(jdbcUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void inTransaction(SqlWork work) {
|
private void inTransaction(SqlWork work) {
|
||||||
@ -945,6 +1108,64 @@ final class LibraryRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String hashPassword(String password) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hash = digest.digest(password.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return Base64.getEncoder().encodeToString(hash);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new RuntimeException("SHA-256 not available", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== DATA CLASSES =====
|
||||||
|
|
||||||
|
public static final class User {
|
||||||
|
private final int id;
|
||||||
|
private final String username;
|
||||||
|
private final String passwordHash;
|
||||||
|
private final Timestamp createdAt;
|
||||||
|
private final Timestamp updatedAt;
|
||||||
|
|
||||||
|
User(int id, String username, String passwordHash, Timestamp createdAt, Timestamp updatedAt) {
|
||||||
|
this.id = id;
|
||||||
|
this.username = username;
|
||||||
|
this.passwordHash = passwordHash;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUsername() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPasswordHash() {
|
||||||
|
return passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Timestamp getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Timestamp getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "User{" +
|
||||||
|
"id=" + id +
|
||||||
|
", username='" + username + '\'' +
|
||||||
|
", createdAt=" + createdAt +
|
||||||
|
", updatedAt=" + updatedAt +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
private interface SqlWork {
|
private interface SqlWork {
|
||||||
void execute(Connection connection) throws Exception;
|
void execute(Connection connection) throws Exception;
|
||||||
@ -7,17 +7,17 @@ import java.util.Map;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication provider that manages session tokens.
|
* Authentication provider that manages session tokens.
|
||||||
* User credentials are stored in the database via UserStore.
|
* User credentials are stored in the database via ApplicationDao.
|
||||||
*/
|
*/
|
||||||
public final class UserAuthenticator {
|
public final class UserAuthenticator {
|
||||||
private final UserStore userStore;
|
private final ApplicationDao dao;
|
||||||
private final Map<String, String> sessionTokens = new HashMap<>();
|
private final Map<String, String> sessionTokens = new HashMap<>();
|
||||||
private final Map<String, Long> tokenExpiry = new HashMap<>();
|
private final Map<String, Long> tokenExpiry = new HashMap<>();
|
||||||
private static final long TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
private static final long TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
public UserAuthenticator(UserStore userStore) {
|
public UserAuthenticator(ApplicationDao dao) {
|
||||||
this.userStore = userStore;
|
this.dao = dao;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -27,7 +27,7 @@ public final class UserAuthenticator {
|
|||||||
if (username == null || password == null) {
|
if (username == null || password == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!userStore.verifyPassword(username, password)) {
|
if (!dao.verifyPassword(username, password)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Generate and store session token
|
// Generate and store session token
|
||||||
|
|||||||
@ -1,219 +0,0 @@
|
|||||||
package cz.kamma.xtreamplayer;
|
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Scanner;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Command-line utility for managing users in the application.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* java -cp xtream-player.jar cz.kamma.xtreamplayer.UserManager
|
|
||||||
*/
|
|
||||||
public class UserManager {
|
|
||||||
private final UserStore userStore;
|
|
||||||
private final Scanner scanner;
|
|
||||||
|
|
||||||
public UserManager() {
|
|
||||||
this.userStore = new UserStore(
|
|
||||||
Path.of(System.getProperty("user.home"), ".xtream-player", "users.db")
|
|
||||||
);
|
|
||||||
this.userStore.initialize();
|
|
||||||
this.scanner = new Scanner(System.in);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
UserManager manager = new UserManager();
|
|
||||||
if (args.length > 0) {
|
|
||||||
manager.handleCommand(args);
|
|
||||||
} else {
|
|
||||||
manager.interactiveMode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleCommand(String[] args) {
|
|
||||||
try {
|
|
||||||
String command = args[0].toLowerCase();
|
|
||||||
switch (command) {
|
|
||||||
case "add" -> {
|
|
||||||
if (args.length < 3) {
|
|
||||||
System.err.println("Usage: add <username> <password>");
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
addUser(args[1], args[2]);
|
|
||||||
}
|
|
||||||
case "delete" -> {
|
|
||||||
if (args.length < 2) {
|
|
||||||
System.err.println("Usage: delete <username>");
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
deleteUser(args[1]);
|
|
||||||
}
|
|
||||||
case "update" -> {
|
|
||||||
if (args.length < 3) {
|
|
||||||
System.err.println("Usage: update <username> <new_password>");
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
updatePassword(args[1], args[2]);
|
|
||||||
}
|
|
||||||
case "list" -> listUsers();
|
|
||||||
case "verify" -> {
|
|
||||||
if (args.length < 3) {
|
|
||||||
System.err.println("Usage: verify <username> <password>");
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
verifyUser(args[1], args[2]);
|
|
||||||
}
|
|
||||||
default -> {
|
|
||||||
System.err.println("Unknown command: " + command);
|
|
||||||
System.err.println("Available commands: add, delete, update, list, verify");
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("Error: " + e.getMessage());
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void interactiveMode() {
|
|
||||||
System.out.println("╔════════════════════════════════════════╗");
|
|
||||||
System.out.println("║ Xtream Player - User Manager ║");
|
|
||||||
System.out.println("╚════════════════════════════════════════╝");
|
|
||||||
System.out.println();
|
|
||||||
System.out.println("Available commands:");
|
|
||||||
System.out.println(" 1. Add user");
|
|
||||||
System.out.println(" 2. Delete user");
|
|
||||||
System.out.println(" 3. Update password");
|
|
||||||
System.out.println(" 4. List all users");
|
|
||||||
System.out.println(" 5. Verify password");
|
|
||||||
System.out.println(" 0. Exit");
|
|
||||||
System.out.println();
|
|
||||||
|
|
||||||
boolean running = true;
|
|
||||||
while (running) {
|
|
||||||
System.out.print("\nSelect option: ");
|
|
||||||
String choice = scanner.nextLine().trim();
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (choice) {
|
|
||||||
case "1" -> addUserInteractive();
|
|
||||||
case "2" -> deleteUserInteractive();
|
|
||||||
case "3" -> updatePasswordInteractive();
|
|
||||||
case "4" -> listUsers();
|
|
||||||
case "5" -> verifyUserInteractive();
|
|
||||||
case "0" -> {
|
|
||||||
System.out.println("Goodbye!");
|
|
||||||
running = false;
|
|
||||||
}
|
|
||||||
default -> System.out.println("Invalid option. Please try again.");
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("Error: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void addUserInteractive() {
|
|
||||||
System.out.print("Username: ");
|
|
||||||
String username = scanner.nextLine().trim();
|
|
||||||
System.out.print("Password: ");
|
|
||||||
String password = scanner.nextLine().trim();
|
|
||||||
addUser(username, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void addUser(String username, String password) {
|
|
||||||
if (username.isBlank() || password.isBlank()) {
|
|
||||||
System.err.println("Username and password cannot be empty.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
userStore.createUser(username, password);
|
|
||||||
System.out.println("✓ User '" + username + "' created successfully.");
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
System.err.println("✗ " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void deleteUserInteractive() {
|
|
||||||
System.out.print("Username to delete: ");
|
|
||||||
String username = scanner.nextLine().trim();
|
|
||||||
System.out.print("Are you sure? (yes/no): ");
|
|
||||||
String confirm = scanner.nextLine().trim().toLowerCase();
|
|
||||||
if ("yes".equals(confirm) || "y".equals(confirm)) {
|
|
||||||
deleteUser(username);
|
|
||||||
} else {
|
|
||||||
System.out.println("Cancelled.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void deleteUser(String username) {
|
|
||||||
try {
|
|
||||||
userStore.deleteUser(username);
|
|
||||||
System.out.println("✓ User '" + username + "' deleted successfully.");
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
System.err.println("✗ " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void updatePasswordInteractive() {
|
|
||||||
System.out.print("Username: ");
|
|
||||||
String username = scanner.nextLine().trim();
|
|
||||||
System.out.print("New password: ");
|
|
||||||
String newPassword = scanner.nextLine().trim();
|
|
||||||
updatePassword(username, newPassword);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void updatePassword(String username, String newPassword) {
|
|
||||||
if (newPassword.isBlank()) {
|
|
||||||
System.err.println("Password cannot be empty.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
userStore.updatePassword(username, newPassword);
|
|
||||||
System.out.println("✓ Password for user '" + username + "' updated successfully.");
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
System.err.println("✗ " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void listUsers() {
|
|
||||||
List<UserStore.User> users = userStore.getAllUsers();
|
|
||||||
if (users.isEmpty()) {
|
|
||||||
System.out.println("No users found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println();
|
|
||||||
System.out.println("╔═══╦════════════════════╦═══════════════════════════════╦═══════════════════════════════╗");
|
|
||||||
System.out.println("║ ID║ Username ║ Created At ║ Updated At ║");
|
|
||||||
System.out.println("╠═══╬════════════════════╬═══════════════════════════════╬═══════════════════════════════╣");
|
|
||||||
|
|
||||||
for (UserStore.User user : users) {
|
|
||||||
System.out.printf("║ %2d║ %-18s ║ %-29s ║ %-29s ║%n",
|
|
||||||
user.getId(),
|
|
||||||
user.getUsername(),
|
|
||||||
user.getCreatedAt() != null ? user.getCreatedAt().toString() : "N/A",
|
|
||||||
user.getUpdatedAt() != null ? user.getUpdatedAt().toString() : "N/A"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
System.out.println("╚═══╩════════════════════╩═══════════════════════════════╩═══════════════════════════════╝");
|
|
||||||
System.out.println("Total: " + users.size() + " user(s)");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void verifyUserInteractive() {
|
|
||||||
System.out.print("Username: ");
|
|
||||||
String username = scanner.nextLine().trim();
|
|
||||||
System.out.print("Password: ");
|
|
||||||
String password = scanner.nextLine().trim();
|
|
||||||
verifyUser(username, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void verifyUser(String username, String password) {
|
|
||||||
if (userStore.verifyPassword(username, password)) {
|
|
||||||
System.out.println("✓ Password is correct for user '" + username + "'.");
|
|
||||||
} else {
|
|
||||||
System.out.println("✗ Password verification failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,273 +0,0 @@
|
|||||||
package cz.kamma.xtreamplayer;
|
|
||||||
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
|
||||||
import org.apache.logging.log4j.Logger;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.sql.DriverManager;
|
|
||||||
import java.sql.PreparedStatement;
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.sql.Statement;
|
|
||||||
import java.sql.Timestamp;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Base64;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Database-backed user store using H2 database.
|
|
||||||
* Manages user credentials stored persistently in the database.
|
|
||||||
*/
|
|
||||||
final class UserStore {
|
|
||||||
private static final Logger LOGGER = LogManager.getLogger(UserStore.class);
|
|
||||||
private final Path dbPath;
|
|
||||||
private final String jdbcUrl;
|
|
||||||
|
|
||||||
public UserStore(Path dbPath) {
|
|
||||||
this.dbPath = dbPath;
|
|
||||||
this.jdbcUrl = "jdbc:h2:file:" + dbPath.toAbsolutePath().toString().replace("\\", "/") + ";AUTO_SERVER=TRUE";
|
|
||||||
}
|
|
||||||
|
|
||||||
public void initialize() {
|
|
||||||
try {
|
|
||||||
Files.createDirectories(dbPath.getParent());
|
|
||||||
} catch (IOException e) {
|
|
||||||
LOGGER.error("Failed to create database directory", e);
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
try (Connection connection = openConnection(); Statement statement = connection.createStatement()) {
|
|
||||||
statement.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id IDENTITY PRIMARY KEY,
|
|
||||||
username VARCHAR(120) UNIQUE NOT NULL,
|
|
||||||
password_hash VARCHAR(256) NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
LOGGER.info("Users table initialized");
|
|
||||||
}
|
|
||||||
} catch (SQLException exception) {
|
|
||||||
LOGGER.error("Failed to initialize users table", exception);
|
|
||||||
throw new RuntimeException(exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new user with the given username and password.
|
|
||||||
*/
|
|
||||||
public void createUser(String username, String password) {
|
|
||||||
if (username == null || username.isBlank() || password == null || password.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("Username and password must not be null or empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
try (Connection connection = openConnection()) {
|
|
||||||
String sql = "INSERT INTO users (username, password_hash) VALUES (?, ?)";
|
|
||||||
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
|
|
||||||
stmt.setString(1, username);
|
|
||||||
stmt.setString(2, hashPassword(password));
|
|
||||||
stmt.executeUpdate();
|
|
||||||
LOGGER.info("User created: {}", username);
|
|
||||||
}
|
|
||||||
} catch (SQLException exception) {
|
|
||||||
if (exception.getMessage().contains("Unique constraint")) {
|
|
||||||
throw new IllegalArgumentException("User '" + username + "' already exists");
|
|
||||||
}
|
|
||||||
LOGGER.error("Failed to create user: {}", username, exception);
|
|
||||||
throw new RuntimeException(exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a user's password.
|
|
||||||
*/
|
|
||||||
public void updatePassword(String username, String newPassword) {
|
|
||||||
if (username == null || username.isBlank() || newPassword == null || newPassword.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("Username and password must not be null or empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
try (Connection connection = openConnection()) {
|
|
||||||
String sql = "UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE username = ?";
|
|
||||||
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
|
|
||||||
stmt.setString(1, hashPassword(newPassword));
|
|
||||||
stmt.setString(2, username);
|
|
||||||
int updated = stmt.executeUpdate();
|
|
||||||
if (updated == 0) {
|
|
||||||
throw new IllegalArgumentException("User '" + username + "' not found");
|
|
||||||
}
|
|
||||||
LOGGER.info("Password updated for user: {}", username);
|
|
||||||
}
|
|
||||||
} catch (SQLException exception) {
|
|
||||||
LOGGER.error("Failed to update password for user: {}", username, exception);
|
|
||||||
throw new RuntimeException(exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a user by username.
|
|
||||||
*/
|
|
||||||
public void deleteUser(String username) {
|
|
||||||
if (username == null || username.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("Username must not be null or empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
try (Connection connection = openConnection()) {
|
|
||||||
String sql = "DELETE FROM users WHERE username = ?";
|
|
||||||
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
|
|
||||||
stmt.setString(1, username);
|
|
||||||
int deleted = stmt.executeUpdate();
|
|
||||||
if (deleted == 0) {
|
|
||||||
throw new IllegalArgumentException("User '" + username + "' not found");
|
|
||||||
}
|
|
||||||
LOGGER.info("User deleted: {}", username);
|
|
||||||
}
|
|
||||||
} catch (SQLException exception) {
|
|
||||||
LOGGER.error("Failed to delete user: {}", username, exception);
|
|
||||||
throw new RuntimeException(exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a user by username.
|
|
||||||
*/
|
|
||||||
public Optional<User> getUser(String username) {
|
|
||||||
if (username == null || username.isBlank()) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
try (Connection connection = openConnection()) {
|
|
||||||
String sql = "SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = ?";
|
|
||||||
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
|
|
||||||
stmt.setString(1, username);
|
|
||||||
try (ResultSet rs = stmt.executeQuery()) {
|
|
||||||
if (rs.next()) {
|
|
||||||
return Optional.of(new User(
|
|
||||||
rs.getInt("id"),
|
|
||||||
rs.getString("username"),
|
|
||||||
rs.getString("password_hash"),
|
|
||||||
rs.getTimestamp("created_at"),
|
|
||||||
rs.getTimestamp("updated_at")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (SQLException exception) {
|
|
||||||
LOGGER.error("Failed to get user: {}", username, exception);
|
|
||||||
}
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all users.
|
|
||||||
*/
|
|
||||||
public List<User> getAllUsers() {
|
|
||||||
List<User> users = new ArrayList<>();
|
|
||||||
try (Connection connection = openConnection()) {
|
|
||||||
String sql = "SELECT id, username, password_hash, created_at, updated_at FROM users ORDER BY username";
|
|
||||||
try (Statement stmt = connection.createStatement();
|
|
||||||
ResultSet rs = stmt.executeQuery(sql)) {
|
|
||||||
while (rs.next()) {
|
|
||||||
users.add(new User(
|
|
||||||
rs.getInt("id"),
|
|
||||||
rs.getString("username"),
|
|
||||||
rs.getString("password_hash"),
|
|
||||||
rs.getTimestamp("created_at"),
|
|
||||||
rs.getTimestamp("updated_at")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (SQLException exception) {
|
|
||||||
LOGGER.error("Failed to get all users", exception);
|
|
||||||
}
|
|
||||||
return users;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify password for a user.
|
|
||||||
*/
|
|
||||||
public boolean verifyPassword(String username, String password) {
|
|
||||||
Optional<User> user = getUser(username);
|
|
||||||
if (user.isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return hashPassword(password).equals(user.get().getPasswordHash());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a user exists.
|
|
||||||
*/
|
|
||||||
public boolean userExists(String username) {
|
|
||||||
return getUser(username).isPresent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Connection openConnection() throws SQLException {
|
|
||||||
return DriverManager.getConnection(jdbcUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String hashPassword(String password) {
|
|
||||||
try {
|
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
||||||
byte[] hash = digest.digest(password.getBytes(StandardCharsets.UTF_8));
|
|
||||||
return Base64.getEncoder().encodeToString(hash);
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
throw new RuntimeException("SHA-256 not available", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Data class representing a user.
|
|
||||||
*/
|
|
||||||
public static final class User {
|
|
||||||
private final int id;
|
|
||||||
private final String username;
|
|
||||||
private final String passwordHash;
|
|
||||||
private final Timestamp createdAt;
|
|
||||||
private final Timestamp updatedAt;
|
|
||||||
|
|
||||||
User(int id, String username, String passwordHash, Timestamp createdAt, Timestamp updatedAt) {
|
|
||||||
this.id = id;
|
|
||||||
this.username = username;
|
|
||||||
this.passwordHash = passwordHash;
|
|
||||||
this.createdAt = createdAt;
|
|
||||||
this.updatedAt = updatedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getUsername() {
|
|
||||||
return username;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getPasswordHash() {
|
|
||||||
return passwordHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Timestamp getCreatedAt() {
|
|
||||||
return createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Timestamp getUpdatedAt() {
|
|
||||||
return updatedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
return "User{" +
|
|
||||||
"id=" + id +
|
|
||||||
", username='" + username + '\'' +
|
|
||||||
", createdAt=" + createdAt +
|
|
||||||
", updatedAt=" + updatedAt +
|
|
||||||
'}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -39,13 +39,13 @@ final class XtreamLibraryService {
|
|||||||
private static final String META_LOADED_AT = "source_loaded_at";
|
private static final String META_LOADED_AT = "source_loaded_at";
|
||||||
|
|
||||||
private final ConfigStore configStore;
|
private final ConfigStore configStore;
|
||||||
private final LibraryRepository repository;
|
private final ApplicationDao dao;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final HttpClient httpClient;
|
private final HttpClient httpClient;
|
||||||
|
|
||||||
XtreamLibraryService(ConfigStore configStore, LibraryRepository repository) {
|
XtreamLibraryService(ConfigStore configStore, ApplicationDao dao) {
|
||||||
this.configStore = configStore;
|
this.configStore = configStore;
|
||||||
this.repository = repository;
|
this.dao = dao;
|
||||||
this.objectMapper = new ObjectMapper();
|
this.objectMapper = new ObjectMapper();
|
||||||
this.httpClient = HttpClient.newBuilder()
|
this.httpClient = HttpClient.newBuilder()
|
||||||
.connectTimeout(Duration.ofSeconds(20))
|
.connectTimeout(Duration.ofSeconds(20))
|
||||||
@ -73,34 +73,34 @@ final class XtreamLibraryService {
|
|||||||
XtreamConfig config = requireConfigured();
|
XtreamConfig config = requireConfigured();
|
||||||
ensureFingerprint(config);
|
ensureFingerprint(config);
|
||||||
if ("live_categories".equals(step)) {
|
if ("live_categories".equals(step)) {
|
||||||
repository.setMeta(META_LOADED_AT, "");
|
dao.setMeta(META_LOADED_AT, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case "live_categories" -> {
|
case "live_categories" -> {
|
||||||
JsonNode response = fetchXtream(config, Map.of("action", "get_live_categories"));
|
JsonNode response = fetchXtream(config, Map.of("action", "get_live_categories"));
|
||||||
repository.replaceLiveCategories(parseCategories(response));
|
dao.replaceLiveCategories(parseCategories(response));
|
||||||
}
|
}
|
||||||
case "live_streams" -> {
|
case "live_streams" -> {
|
||||||
JsonNode response = fetchXtream(config, Map.of("action", "get_live_streams"));
|
JsonNode response = fetchXtream(config, Map.of("action", "get_live_streams"));
|
||||||
repository.replaceLiveStreams(parseLiveStreams(response));
|
dao.replaceLiveStreams(parseLiveStreams(response));
|
||||||
}
|
}
|
||||||
case "vod_categories" -> {
|
case "vod_categories" -> {
|
||||||
JsonNode response = fetchXtream(config, Map.of("action", "get_vod_categories"));
|
JsonNode response = fetchXtream(config, Map.of("action", "get_vod_categories"));
|
||||||
repository.replaceVodCategories(parseCategories(response));
|
dao.replaceVodCategories(parseCategories(response));
|
||||||
}
|
}
|
||||||
case "vod_streams" -> {
|
case "vod_streams" -> {
|
||||||
JsonNode response = fetchXtream(config, Map.of("action", "get_vod_streams"));
|
JsonNode response = fetchXtream(config, Map.of("action", "get_vod_streams"));
|
||||||
repository.replaceVodStreams(parseVodStreams(response));
|
dao.replaceVodStreams(parseVodStreams(response));
|
||||||
}
|
}
|
||||||
case "series_categories" -> {
|
case "series_categories" -> {
|
||||||
JsonNode response = fetchXtream(config, Map.of("action", "get_series_categories"));
|
JsonNode response = fetchXtream(config, Map.of("action", "get_series_categories"));
|
||||||
repository.replaceSeriesCategories(parseCategories(response));
|
dao.replaceSeriesCategories(parseCategories(response));
|
||||||
}
|
}
|
||||||
case "series_items" -> {
|
case "series_items" -> {
|
||||||
JsonNode response = fetchXtream(config, Map.of("action", "get_series"));
|
JsonNode response = fetchXtream(config, Map.of("action", "get_series"));
|
||||||
repository.replaceSeriesItems(parseSeriesItems(response));
|
dao.replaceSeriesItems(parseSeriesItems(response));
|
||||||
repository.setMeta(META_LOADED_AT, Long.toString(System.currentTimeMillis()));
|
dao.setMeta(META_LOADED_AT, Long.toString(System.currentTimeMillis()));
|
||||||
}
|
}
|
||||||
default -> throw new IllegalArgumentException("Unsupported load step: " + step);
|
default -> throw new IllegalArgumentException("Unsupported load step: " + step);
|
||||||
}
|
}
|
||||||
@ -114,9 +114,9 @@ final class XtreamLibraryService {
|
|||||||
|
|
||||||
Map<String, Object> status() {
|
Map<String, Object> status() {
|
||||||
XtreamConfig config = configStore.get();
|
XtreamConfig config = configStore.get();
|
||||||
LibraryRepository.LibraryCounts counts = repository.countAll();
|
ApplicationDao.LibraryCounts counts = dao.countAll();
|
||||||
String storedFingerprint = nullSafe(repository.getMeta(META_FINGERPRINT));
|
String storedFingerprint = nullSafe(dao.getMeta(META_FINGERPRINT));
|
||||||
String loadedAt = nullSafe(repository.getMeta(META_LOADED_AT));
|
String loadedAt = nullSafe(dao.getMeta(META_LOADED_AT));
|
||||||
String currentFingerprint = fingerprint(config);
|
String currentFingerprint = fingerprint(config);
|
||||||
|
|
||||||
boolean ready = config.isConfigured()
|
boolean ready = config.isConfigured()
|
||||||
@ -140,39 +140,39 @@ final class XtreamLibraryService {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<LibraryRepository.CategoryRow> listCategories(String type) {
|
List<ApplicationDao.CategoryRow> listCategories(String type) {
|
||||||
return repository.listCategories(type);
|
return dao.listCategories(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<?> listItems(String type, String categoryId, String search, Integer limit, Integer offset) {
|
List<?> listItems(String type, String categoryId, String search, Integer limit, Integer offset) {
|
||||||
String normalizedType = type == null ? "" : type.trim().toLowerCase(Locale.ROOT);
|
String normalizedType = type == null ? "" : type.trim().toLowerCase(Locale.ROOT);
|
||||||
return switch (normalizedType) {
|
return switch (normalizedType) {
|
||||||
case "live" -> repository.listLiveStreams(categoryId, search, limit, offset);
|
case "live" -> dao.listLiveStreams(categoryId, search, limit, offset);
|
||||||
case "vod" -> repository.listVodStreams(categoryId, search, limit, offset);
|
case "vod" -> dao.listVodStreams(categoryId, search, limit, offset);
|
||||||
case "series" -> repository.listSeriesItems(categoryId, search, limit, offset);
|
case "series" -> dao.listSeriesItems(categoryId, search, limit, offset);
|
||||||
default -> throw new IllegalArgumentException("Unsupported type: " + type);
|
default -> throw new IllegalArgumentException("Unsupported type: " + type);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
List<LibraryRepository.GlobalSearchRow> globalSearch(String query, int limitRaw, int offsetRaw) {
|
List<ApplicationDao.GlobalSearchRow> globalSearch(String query, int limitRaw, int offsetRaw) {
|
||||||
String normalizedQuery = nullSafe(query).trim();
|
String normalizedQuery = nullSafe(query).trim();
|
||||||
if (normalizedQuery.isBlank()) {
|
if (normalizedQuery.isBlank()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
int limit = Math.max(1, Math.min(limitRaw, 500));
|
int limit = Math.max(1, Math.min(limitRaw, 500));
|
||||||
int offset = Math.max(0, offsetRaw);
|
int offset = Math.max(0, offsetRaw);
|
||||||
return repository.globalSearch(normalizedQuery, limit, offset);
|
return dao.globalSearch(normalizedQuery, limit, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<LibraryRepository.FavoriteRow> listFavorites(String search, int limit, int offset) {
|
List<ApplicationDao.FavoriteRow> listFavorites(String search, int limit, int offset) {
|
||||||
return repository.listFavorites(search, limit, offset);
|
return dao.listFavorites(search, limit, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
int countFavorites(String search) {
|
int countFavorites(String search) {
|
||||||
return repository.countFavorites(search);
|
return dao.countFavorites(search);
|
||||||
}
|
}
|
||||||
|
|
||||||
LibraryRepository.FavoriteRow saveFavorite(
|
ApplicationDao.FavoriteRow saveFavorite(
|
||||||
String key,
|
String key,
|
||||||
String mode,
|
String mode,
|
||||||
String id,
|
String id,
|
||||||
@ -198,7 +198,7 @@ final class XtreamLibraryService {
|
|||||||
throw new IllegalArgumentException("Missing favorite title.");
|
throw new IllegalArgumentException("Missing favorite title.");
|
||||||
}
|
}
|
||||||
long safeCreatedAt = createdAt == null || createdAt <= 0 ? System.currentTimeMillis() : createdAt;
|
long safeCreatedAt = createdAt == null || createdAt <= 0 ? System.currentTimeMillis() : createdAt;
|
||||||
LibraryRepository.FavoriteRow row = new LibraryRepository.FavoriteRow(
|
ApplicationDao.FavoriteRow row = new ApplicationDao.FavoriteRow(
|
||||||
normalizedKey,
|
normalizedKey,
|
||||||
normalizedMode,
|
normalizedMode,
|
||||||
nullSafe(id).trim(),
|
nullSafe(id).trim(),
|
||||||
@ -211,7 +211,7 @@ final class XtreamLibraryService {
|
|||||||
nullSafe(url).trim(),
|
nullSafe(url).trim(),
|
||||||
safeCreatedAt
|
safeCreatedAt
|
||||||
);
|
);
|
||||||
repository.upsertFavorite(row);
|
dao.upsertFavorite(row);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,16 +220,16 @@ final class XtreamLibraryService {
|
|||||||
if (normalizedKey.isBlank()) {
|
if (normalizedKey.isBlank()) {
|
||||||
throw new IllegalArgumentException("Missing favorite key.");
|
throw new IllegalArgumentException("Missing favorite key.");
|
||||||
}
|
}
|
||||||
return repository.deleteFavorite(normalizedKey);
|
return dao.deleteFavorite(normalizedKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<LibraryRepository.SeriesEpisodeRow> listSeriesEpisodes(String seriesIdRaw) {
|
List<ApplicationDao.SeriesEpisodeRow> listSeriesEpisodes(String seriesIdRaw) {
|
||||||
String seriesId = seriesIdRaw == null ? "" : seriesIdRaw.trim();
|
String seriesId = seriesIdRaw == null ? "" : seriesIdRaw.trim();
|
||||||
if (seriesId.isBlank()) {
|
if (seriesId.isBlank()) {
|
||||||
throw new IllegalArgumentException("Missing series_id.");
|
throw new IllegalArgumentException("Missing series_id.");
|
||||||
}
|
}
|
||||||
|
|
||||||
List<LibraryRepository.SeriesEpisodeRow> cached = repository.listSeriesEpisodes(seriesId);
|
List<ApplicationDao.SeriesEpisodeRow> cached = dao.listSeriesEpisodes(seriesId);
|
||||||
if (!cached.isEmpty()) {
|
if (!cached.isEmpty()) {
|
||||||
LOGGER.debug("Series episodes served from cache for series_id={}", seriesId);
|
LOGGER.debug("Series episodes served from cache for series_id={}", seriesId);
|
||||||
return cached;
|
return cached;
|
||||||
@ -241,8 +241,8 @@ final class XtreamLibraryService {
|
|||||||
"action", "get_series_info",
|
"action", "get_series_info",
|
||||||
"series_id", seriesId
|
"series_id", seriesId
|
||||||
));
|
));
|
||||||
List<LibraryRepository.SeriesEpisodeRow> loaded = parseSeriesEpisodes(seriesId, response.path("episodes"));
|
List<ApplicationDao.SeriesEpisodeRow> loaded = parseSeriesEpisodes(seriesId, response.path("episodes"));
|
||||||
repository.replaceSeriesEpisodes(seriesId, loaded);
|
dao.replaceSeriesEpisodes(seriesId, loaded);
|
||||||
return loaded;
|
return loaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -261,11 +261,11 @@ final class XtreamLibraryService {
|
|||||||
return objectMapper.convertValue(response, Object.class);
|
return objectMapper.convertValue(response, Object.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<LibraryRepository.CategoryRow> parseCategories(JsonNode node) {
|
private List<ApplicationDao.CategoryRow> parseCategories(JsonNode node) {
|
||||||
if (!node.isArray()) {
|
if (!node.isArray()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
Map<String, LibraryRepository.CategoryRow> deduplicated = new LinkedHashMap<>();
|
Map<String, ApplicationDao.CategoryRow> deduplicated = new LinkedHashMap<>();
|
||||||
for (JsonNode item : node) {
|
for (JsonNode item : node) {
|
||||||
String id = text(item, "category_id");
|
String id = text(item, "category_id");
|
||||||
if (id.isBlank()) {
|
if (id.isBlank()) {
|
||||||
@ -275,16 +275,16 @@ final class XtreamLibraryService {
|
|||||||
if (name.isBlank()) {
|
if (name.isBlank()) {
|
||||||
name = "Category " + id;
|
name = "Category " + id;
|
||||||
}
|
}
|
||||||
deduplicated.put(id, new LibraryRepository.CategoryRow(id, name));
|
deduplicated.put(id, new ApplicationDao.CategoryRow(id, name));
|
||||||
}
|
}
|
||||||
return new ArrayList<>(deduplicated.values());
|
return new ArrayList<>(deduplicated.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<LibraryRepository.LiveStreamRow> parseLiveStreams(JsonNode node) {
|
private List<ApplicationDao.LiveStreamRow> parseLiveStreams(JsonNode node) {
|
||||||
if (!node.isArray()) {
|
if (!node.isArray()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
Map<String, LibraryRepository.LiveStreamRow> deduplicated = new LinkedHashMap<>();
|
Map<String, ApplicationDao.LiveStreamRow> deduplicated = new LinkedHashMap<>();
|
||||||
for (JsonNode item : node) {
|
for (JsonNode item : node) {
|
||||||
String streamId = text(item, "stream_id");
|
String streamId = text(item, "stream_id");
|
||||||
if (streamId.isBlank()) {
|
if (streamId.isBlank()) {
|
||||||
@ -294,7 +294,7 @@ final class XtreamLibraryService {
|
|||||||
if (name.isBlank()) {
|
if (name.isBlank()) {
|
||||||
name = "Stream " + streamId;
|
name = "Stream " + streamId;
|
||||||
}
|
}
|
||||||
deduplicated.put(streamId, new LibraryRepository.LiveStreamRow(
|
deduplicated.put(streamId, new ApplicationDao.LiveStreamRow(
|
||||||
streamId,
|
streamId,
|
||||||
name,
|
name,
|
||||||
text(item, "category_id"),
|
text(item, "category_id"),
|
||||||
@ -304,11 +304,11 @@ final class XtreamLibraryService {
|
|||||||
return new ArrayList<>(deduplicated.values());
|
return new ArrayList<>(deduplicated.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<LibraryRepository.VodStreamRow> parseVodStreams(JsonNode node) {
|
private List<ApplicationDao.VodStreamRow> parseVodStreams(JsonNode node) {
|
||||||
if (!node.isArray()) {
|
if (!node.isArray()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
Map<String, LibraryRepository.VodStreamRow> deduplicated = new LinkedHashMap<>();
|
Map<String, ApplicationDao.VodStreamRow> deduplicated = new LinkedHashMap<>();
|
||||||
for (JsonNode item : node) {
|
for (JsonNode item : node) {
|
||||||
String streamId = text(item, "stream_id");
|
String streamId = text(item, "stream_id");
|
||||||
if (streamId.isBlank()) {
|
if (streamId.isBlank()) {
|
||||||
@ -322,7 +322,7 @@ final class XtreamLibraryService {
|
|||||||
if (ext.isBlank()) {
|
if (ext.isBlank()) {
|
||||||
ext = "mp4";
|
ext = "mp4";
|
||||||
}
|
}
|
||||||
deduplicated.put(streamId, new LibraryRepository.VodStreamRow(
|
deduplicated.put(streamId, new ApplicationDao.VodStreamRow(
|
||||||
streamId,
|
streamId,
|
||||||
name,
|
name,
|
||||||
text(item, "category_id"),
|
text(item, "category_id"),
|
||||||
@ -332,11 +332,11 @@ final class XtreamLibraryService {
|
|||||||
return new ArrayList<>(deduplicated.values());
|
return new ArrayList<>(deduplicated.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<LibraryRepository.SeriesItemRow> parseSeriesItems(JsonNode node) {
|
private List<ApplicationDao.SeriesItemRow> parseSeriesItems(JsonNode node) {
|
||||||
if (!node.isArray()) {
|
if (!node.isArray()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
Map<String, LibraryRepository.SeriesItemRow> deduplicated = new LinkedHashMap<>();
|
Map<String, ApplicationDao.SeriesItemRow> deduplicated = new LinkedHashMap<>();
|
||||||
for (JsonNode item : node) {
|
for (JsonNode item : node) {
|
||||||
String seriesId = text(item, "series_id");
|
String seriesId = text(item, "series_id");
|
||||||
if (seriesId.isBlank()) {
|
if (seriesId.isBlank()) {
|
||||||
@ -346,7 +346,7 @@ final class XtreamLibraryService {
|
|||||||
if (name.isBlank()) {
|
if (name.isBlank()) {
|
||||||
name = "Series " + seriesId;
|
name = "Series " + seriesId;
|
||||||
}
|
}
|
||||||
deduplicated.put(seriesId, new LibraryRepository.SeriesItemRow(
|
deduplicated.put(seriesId, new ApplicationDao.SeriesItemRow(
|
||||||
seriesId,
|
seriesId,
|
||||||
name,
|
name,
|
||||||
text(item, "category_id")
|
text(item, "category_id")
|
||||||
@ -355,11 +355,11 @@ final class XtreamLibraryService {
|
|||||||
return new ArrayList<>(deduplicated.values());
|
return new ArrayList<>(deduplicated.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<LibraryRepository.SeriesEpisodeRow> parseSeriesEpisodes(String seriesId, JsonNode episodesBySeason) {
|
private List<ApplicationDao.SeriesEpisodeRow> parseSeriesEpisodes(String seriesId, JsonNode episodesBySeason) {
|
||||||
if (episodesBySeason == null || episodesBySeason.isMissingNode() || episodesBySeason.isNull()) {
|
if (episodesBySeason == null || episodesBySeason.isMissingNode() || episodesBySeason.isNull()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
Map<String, LibraryRepository.SeriesEpisodeRow> deduplicated = new LinkedHashMap<>();
|
Map<String, ApplicationDao.SeriesEpisodeRow> deduplicated = new LinkedHashMap<>();
|
||||||
episodesBySeason.fields().forEachRemaining(entry -> {
|
episodesBySeason.fields().forEachRemaining(entry -> {
|
||||||
String season = entry.getKey();
|
String season = entry.getKey();
|
||||||
JsonNode episodes = entry.getValue();
|
JsonNode episodes = entry.getValue();
|
||||||
@ -389,7 +389,7 @@ final class XtreamLibraryService {
|
|||||||
seasonValue = season;
|
seasonValue = season;
|
||||||
}
|
}
|
||||||
|
|
||||||
deduplicated.put(episodeId, new LibraryRepository.SeriesEpisodeRow(
|
deduplicated.put(episodeId, new ApplicationDao.SeriesEpisodeRow(
|
||||||
episodeId,
|
episodeId,
|
||||||
seriesId,
|
seriesId,
|
||||||
seasonValue,
|
seasonValue,
|
||||||
@ -405,12 +405,12 @@ final class XtreamLibraryService {
|
|||||||
|
|
||||||
private void ensureFingerprint(XtreamConfig config) {
|
private void ensureFingerprint(XtreamConfig config) {
|
||||||
String currentFingerprint = fingerprint(config);
|
String currentFingerprint = fingerprint(config);
|
||||||
String storedFingerprint = nullSafe(repository.getMeta(META_FINGERPRINT));
|
String storedFingerprint = nullSafe(dao.getMeta(META_FINGERPRINT));
|
||||||
if (!currentFingerprint.equals(storedFingerprint)) {
|
if (!currentFingerprint.equals(storedFingerprint)) {
|
||||||
LOGGER.info("Source fingerprint changed. Clearing cached library data.");
|
LOGGER.info("Source fingerprint changed. Clearing cached library data.");
|
||||||
repository.clearAllSources();
|
dao.clearAllSources();
|
||||||
repository.setMeta(META_FINGERPRINT, currentFingerprint);
|
dao.setMeta(META_FINGERPRINT, currentFingerprint);
|
||||||
repository.setMeta(META_LOADED_AT, "");
|
dao.setMeta(META_LOADED_AT, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package cz.kamma.xtreamplayer;
|
package cz.kamma.xtreamplayer;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.sun.net.httpserver.HttpExchange;
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
import com.sun.net.httpserver.HttpHandler;
|
import com.sun.net.httpserver.HttpHandler;
|
||||||
@ -35,6 +36,7 @@ import java.util.regex.Pattern;
|
|||||||
|
|
||||||
public final class XtreamPlayerApplication {
|
public final class XtreamPlayerApplication {
|
||||||
private static final int DEFAULT_PORT = 8080;
|
private static final int DEFAULT_PORT = 8080;
|
||||||
|
private static final String USER_API_BEARER_TOKEN = "MujBearer852654";
|
||||||
private static final Set<String> SENSITIVE_KEYS = Set.of("password", "pass", "pwd", "token", "authorization");
|
private static final Set<String> SENSITIVE_KEYS = Set.of("password", "pass", "pwd", "token", "authorization");
|
||||||
private static final String ATTR_REQ_START_NANOS = "reqStartNanos";
|
private static final String ATTR_REQ_START_NANOS = "reqStartNanos";
|
||||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
@ -59,29 +61,23 @@ public final class XtreamPlayerApplication {
|
|||||||
ConfigStore configStore = new ConfigStore(
|
ConfigStore configStore = new ConfigStore(
|
||||||
Path.of(System.getProperty("user.home"), ".xtream-player", "config.properties")
|
Path.of(System.getProperty("user.home"), ".xtream-player", "config.properties")
|
||||||
);
|
);
|
||||||
LibraryRepository libraryRepository = new LibraryRepository(
|
ApplicationDao applicationDao = new ApplicationDao(
|
||||||
Path.of(System.getProperty("user.home"), ".xtream-player", "library", "xtream-sources")
|
Path.of(System.getProperty("user.home"), ".xtream-player", "app.db")
|
||||||
);
|
);
|
||||||
libraryRepository.initialize();
|
applicationDao.initialize();
|
||||||
XtreamLibraryService libraryService = new XtreamLibraryService(configStore, libraryRepository);
|
|
||||||
|
|
||||||
// Initialize user store
|
|
||||||
UserStore userStore = new UserStore(
|
|
||||||
Path.of(System.getProperty("user.home"), ".xtream-player", "users.db")
|
|
||||||
);
|
|
||||||
userStore.initialize();
|
|
||||||
|
|
||||||
// Create default admin user if no users exist
|
// Create default admin user if no users exist
|
||||||
if (userStore.getAllUsers().isEmpty()) {
|
if (applicationDao.getAllUsers().isEmpty()) {
|
||||||
try {
|
try {
|
||||||
userStore.createUser("admin", "admin");
|
applicationDao.createUser("admin", "admin");
|
||||||
LOGGER.info("Default admin user created (username: admin, password: admin)");
|
LOGGER.info("Default admin user created (username: admin, password: admin)");
|
||||||
} catch (IllegalArgumentException ignored) {
|
} catch (IllegalArgumentException ignored) {
|
||||||
// User might already exist
|
// User might already exist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
UserAuthenticator userAuthenticator = new UserAuthenticator(userStore);
|
XtreamLibraryService libraryService = new XtreamLibraryService(configStore, applicationDao);
|
||||||
|
UserAuthenticator userAuthenticator = new UserAuthenticator(applicationDao);
|
||||||
|
|
||||||
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
|
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||||
server.createContext("/api/auth/login", new LoginHandler(userAuthenticator));
|
server.createContext("/api/auth/login", new LoginHandler(userAuthenticator));
|
||||||
@ -100,6 +96,7 @@ public final class XtreamPlayerApplication {
|
|||||||
server.createContext("/api/library/series-episodes", new LibrarySeriesEpisodesHandler(libraryService, userAuthenticator));
|
server.createContext("/api/library/series-episodes", new LibrarySeriesEpisodesHandler(libraryService, userAuthenticator));
|
||||||
server.createContext("/api/library/epg", new LibraryEpgHandler(libraryService, userAuthenticator));
|
server.createContext("/api/library/epg", new LibraryEpgHandler(libraryService, userAuthenticator));
|
||||||
server.createContext("/api/favorites", new FavoritesHandler(libraryService, userAuthenticator));
|
server.createContext("/api/favorites", new FavoritesHandler(libraryService, userAuthenticator));
|
||||||
|
server.createContext("/user", new UserCrudHandler(applicationDao));
|
||||||
server.createContext("/", new StaticHandler(userAuthenticator));
|
server.createContext("/", new StaticHandler(userAuthenticator));
|
||||||
server.setExecutor(Executors.newFixedThreadPool(12));
|
server.setExecutor(Executors.newFixedThreadPool(12));
|
||||||
server.start();
|
server.start();
|
||||||
@ -738,7 +735,7 @@ public final class XtreamPlayerApplication {
|
|||||||
? Map.of()
|
? Map.of()
|
||||||
: OBJECT_MAPPER.readValue(body, Map.class);
|
: OBJECT_MAPPER.readValue(body, Map.class);
|
||||||
|
|
||||||
LibraryRepository.FavoriteRow saved = libraryService.saveFavorite(
|
ApplicationDao.FavoriteRow saved = libraryService.saveFavorite(
|
||||||
asString(payload.get("key")),
|
asString(payload.get("key")),
|
||||||
asString(payload.get("mode")),
|
asString(payload.get("mode")),
|
||||||
asString(payload.get("id")),
|
asString(payload.get("id")),
|
||||||
@ -775,6 +772,103 @@ public final class XtreamPlayerApplication {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record UserCrudHandler(ApplicationDao applicationDao) implements HttpHandler {
|
||||||
|
@Override
|
||||||
|
public void handle(HttpExchange exchange) throws IOException {
|
||||||
|
if (!requireUserApiBearer(exchange)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String method = exchange.getRequestMethod().toUpperCase(Locale.ROOT);
|
||||||
|
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (method) {
|
||||||
|
case "GET" -> {
|
||||||
|
logApiRequest(exchange, "/user", query);
|
||||||
|
String username = query.getOrDefault("username", "").trim();
|
||||||
|
if (username.isBlank()) {
|
||||||
|
List<Map<String, Object>> users = applicationDao.getAllUsers().stream()
|
||||||
|
.map(XtreamPlayerApplication::toUserResponse)
|
||||||
|
.toList();
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("users", users);
|
||||||
|
out.put("count", users.size());
|
||||||
|
writeJsonObject(exchange, 200, out);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var user = applicationDao.getUser(username);
|
||||||
|
if (user.isEmpty()) {
|
||||||
|
writeJson(exchange, 404, errorJson("User not found."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeJsonObject(exchange, 200, toUserResponse(user.get()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "POST" -> {
|
||||||
|
Map<String, String> payload = parseRequestPayload(exchange);
|
||||||
|
logApiRequest(exchange, "/user", payload);
|
||||||
|
String username = payload.getOrDefault("username", "").trim();
|
||||||
|
String password = payload.getOrDefault("password", "");
|
||||||
|
if (username.isBlank() || password.isBlank()) {
|
||||||
|
writeJson(exchange, 400, errorJson("Fields 'username' and 'password' are required."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applicationDao.createUser(username, password);
|
||||||
|
var created = applicationDao.getUser(username);
|
||||||
|
if (created.isPresent()) {
|
||||||
|
writeJsonObject(exchange, 201, toUserResponse(created.get()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeJsonObject(exchange, 201, Map.of("created", true, "username", username));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "PUT" -> {
|
||||||
|
Map<String, String> payload = parseRequestPayload(exchange);
|
||||||
|
logApiRequest(exchange, "/user", payload);
|
||||||
|
String username = payload.getOrDefault("username", "").trim();
|
||||||
|
String newPassword = firstNonBlank(payload.get("newPassword"), payload.get("password"));
|
||||||
|
if (username.isBlank() || newPassword == null || newPassword.isBlank()) {
|
||||||
|
writeJson(exchange, 400, errorJson("Fields 'username' and 'newPassword' are required."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applicationDao.updatePassword(username, newPassword);
|
||||||
|
writeJsonObject(exchange, 200, Map.of("updated", true, "username", username));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "DELETE" -> {
|
||||||
|
String username = query.getOrDefault("username", "").trim();
|
||||||
|
Map<String, String> payload = Map.of();
|
||||||
|
if (username.isBlank()) {
|
||||||
|
payload = parseRequestPayload(exchange);
|
||||||
|
username = payload.getOrDefault("username", "").trim();
|
||||||
|
}
|
||||||
|
Map<String, String> logParams = new LinkedHashMap<>(query);
|
||||||
|
logParams.putAll(payload);
|
||||||
|
logApiRequest(exchange, "/user", logParams);
|
||||||
|
if (username.isBlank()) {
|
||||||
|
writeJson(exchange, 400, errorJson("Field 'username' is required."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applicationDao.deleteUser(username);
|
||||||
|
writeJsonObject(exchange, 200, Map.of("deleted", true, "username", username));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
logApiRequest(exchange, "/user", query);
|
||||||
|
methodNotAllowed(exchange, "GET, POST, PUT, DELETE");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
writeJson(exchange, 400, errorJson(exception.getMessage()));
|
||||||
|
} catch (Exception exception) {
|
||||||
|
LOGGER.error("User CRUD endpoint failed", exception);
|
||||||
|
writeJson(exchange, 500, errorJson("User API failed: " + exception.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static final class StaticHandler implements HttpHandler {
|
private static final class StaticHandler implements HttpHandler {
|
||||||
StaticHandler(UserAuthenticator userAuthenticator) {
|
StaticHandler(UserAuthenticator userAuthenticator) {
|
||||||
// Static handler allows unauthenticated access to public files
|
// Static handler allows unauthenticated access to public files
|
||||||
@ -859,6 +953,16 @@ public final class XtreamPlayerApplication {
|
|||||||
return query.get("token");
|
return query.get("token");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean requireUserApiBearer(HttpExchange exchange) throws IOException {
|
||||||
|
String authHeader = exchange.getRequestHeaders().getFirst("Authorization");
|
||||||
|
String expected = "Bearer " + USER_API_BEARER_TOKEN;
|
||||||
|
if (!expected.equals(authHeader)) {
|
||||||
|
writeJson(exchange, 401, errorJson("Unauthorized. Missing or invalid bearer token."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean requireAuth(HttpExchange exchange, UserAuthenticator userAuthenticator) throws IOException {
|
private static boolean requireAuth(HttpExchange exchange, UserAuthenticator userAuthenticator) throws IOException {
|
||||||
String token = extractAuthToken(exchange);
|
String token = extractAuthToken(exchange);
|
||||||
if (token == null || !userAuthenticator.isTokenValid(token)) {
|
if (token == null || !userAuthenticator.isTokenValid(token)) {
|
||||||
@ -1308,6 +1412,33 @@ public final class XtreamPlayerApplication {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> parseRequestPayload(HttpExchange exchange) throws IOException {
|
||||||
|
String body = readBody(exchange);
|
||||||
|
if (body == null || body.isBlank()) {
|
||||||
|
return new LinkedHashMap<>();
|
||||||
|
}
|
||||||
|
String contentType = firstNonBlank(exchange.getRequestHeaders().getFirst("Content-Type"), "").toLowerCase(Locale.ROOT);
|
||||||
|
if (contentType.contains("application/json")) {
|
||||||
|
Map<String, Object> raw = OBJECT_MAPPER.readValue(body, new TypeReference<>() {
|
||||||
|
});
|
||||||
|
Map<String, String> parsed = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, Object> entry : raw.entrySet()) {
|
||||||
|
parsed.put(entry.getKey(), asString(entry.getValue()));
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return parseKeyValue(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> toUserResponse(ApplicationDao.User user) {
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("id", user.getId());
|
||||||
|
out.put("username", user.getUsername());
|
||||||
|
out.put("createdAt", user.getCreatedAt() == null ? null : user.getCreatedAt().toString());
|
||||||
|
out.put("updatedAt", user.getUpdatedAt() == null ? null : user.getUpdatedAt().toString());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
private static String readBody(HttpExchange exchange) throws IOException {
|
private static String readBody(HttpExchange exchange) throws IOException {
|
||||||
try (InputStream inputStream = exchange.getRequestBody()) {
|
try (InputStream inputStream = exchange.getRequestBody()) {
|
||||||
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user