rewritten DAO, user management

This commit is contained in:
Radek Davidek 2026-03-10 15:15:20 +01:00
parent 01d961539c
commit f36ed55788
8 changed files with 507 additions and 674 deletions

View File

@ -33,23 +33,33 @@ PORT=8090 mvn -q compile exec:java
## 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
# Interactive mode
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager
# List users
curl -H "Authorization: Bearer MujBearer852654" \
http://localhost:8080/user
# Add a user
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager add username password
# Get one user
curl -H "Authorization: Bearer MujBearer852654" \
"http://localhost:8080/user?username=admin"
# List all users
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager list
# Create user
curl -X POST -H "Authorization: Bearer MujBearer852654" \
-H "Content-Type: application/json" \
-d '{"username":"user","password":"pass123"}' \
http://localhost:8080/user
# 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
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.

View File

@ -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
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:
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
### Get one user
### Použití z příkazové řádky
#### Přidat uživatele
```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
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
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
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
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
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
```bash
java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager update admin newPassword456
```
## Response Notes
### Zobrazit všechny uživatele
```bash
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`
- Returned user objects include: `id`, `username`, `createdAt`, `updatedAt`
- Password hash is never returned by the API.

View File

@ -3,39 +3,67 @@ 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.Comparator;
import java.util.List;
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 String jdbcUrl;
LibraryRepository(Path dbPath) {
public ApplicationDao(Path dbPath) {
this.dbPath = dbPath;
this.jdbcUrl = "jdbc:h2:file:" + dbPath.toAbsolutePath().toString().replace("\\", "/") + ";AUTO_SERVER=TRUE";
}
void initialize() {
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()) {
// 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("""
CREATE TABLE IF NOT EXISTS source_meta (
meta_key VARCHAR(120) PRIMARY KEY,
meta_value CLOB
)
""");
// Categories
statement.execute("""
CREATE TABLE IF NOT EXISTS live_categories (
category_id VARCHAR(80) PRIMARY KEY,
@ -54,6 +82,7 @@ final class LibraryRepository {
category_name VARCHAR(400) NOT NULL
)
""");
// Streams
statement.execute("""
CREATE TABLE IF NOT EXISTS live_streams (
stream_id VARCHAR(120) PRIMARY KEY,
@ -70,6 +99,7 @@ final class LibraryRepository {
container_extension VARCHAR(40)
)
""");
// Series
statement.execute("""
CREATE TABLE IF NOT EXISTS series_items (
series_id VARCHAR(120) PRIMARY KEY,
@ -87,6 +117,7 @@ final class LibraryRepository {
container_extension VARCHAR(40)
)
""");
// Favorites
statement.execute("""
CREATE TABLE IF NOT EXISTS favorites (
favorite_key VARCHAR(260) PRIMARY KEY,
@ -102,14 +133,12 @@ final class LibraryRepository {
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_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_episodes_series ON series_episodes(series_id)");
statement.execute(
"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_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_vod_categories_name ON vod_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_series_items_name ON series_items(name)");
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()) {
initializeFullText(connection);
}
LOGGER.info("H2 repository initialized at {}", dbPath);
} catch (Exception exception) {
throw new IllegalStateException("Unable to initialize H2 repository.", exception);
} catch (SQLException 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() {
LOGGER.info("Clearing all cached library data in H2");
inTransaction(connection -> {
@ -704,6 +865,8 @@ final class LibraryRepository {
}
}
// ===== PRIVATE HELPERS =====
private int countTable(Connection connection, String tableName) throws SQLException {
try (Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) AS c FROM " + tableName)) {
@ -925,7 +1088,7 @@ final class LibraryRepository {
}
private Connection openConnection() throws SQLException {
return DriverManager.getConnection(jdbcUrl, "sa", "");
return DriverManager.getConnection(jdbcUrl);
}
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
private interface SqlWork {
void execute(Connection connection) throws Exception;

View File

@ -7,17 +7,17 @@ import java.util.Map;
/**
* 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 {
private final UserStore userStore;
private final ApplicationDao dao;
private final Map<String, String> sessionTokens = 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 SecureRandom RANDOM = new SecureRandom();
public UserAuthenticator(UserStore userStore) {
this.userStore = userStore;
public UserAuthenticator(ApplicationDao dao) {
this.dao = dao;
}
/**
@ -27,7 +27,7 @@ public final class UserAuthenticator {
if (username == null || password == null) {
return null;
}
if (!userStore.verifyPassword(username, password)) {
if (!dao.verifyPassword(username, password)) {
return null;
}
// Generate and store session token

View File

@ -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.");
}
}
}

View File

@ -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 +
'}';
}
}
}

View File

@ -39,13 +39,13 @@ final class XtreamLibraryService {
private static final String META_LOADED_AT = "source_loaded_at";
private final ConfigStore configStore;
private final LibraryRepository repository;
private final ApplicationDao dao;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
XtreamLibraryService(ConfigStore configStore, LibraryRepository repository) {
XtreamLibraryService(ConfigStore configStore, ApplicationDao dao) {
this.configStore = configStore;
this.repository = repository;
this.dao = dao;
this.objectMapper = new ObjectMapper();
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
@ -73,34 +73,34 @@ final class XtreamLibraryService {
XtreamConfig config = requireConfigured();
ensureFingerprint(config);
if ("live_categories".equals(step)) {
repository.setMeta(META_LOADED_AT, "");
dao.setMeta(META_LOADED_AT, "");
}
switch (step) {
case "live_categories" -> {
JsonNode response = fetchXtream(config, Map.of("action", "get_live_categories"));
repository.replaceLiveCategories(parseCategories(response));
dao.replaceLiveCategories(parseCategories(response));
}
case "live_streams" -> {
JsonNode response = fetchXtream(config, Map.of("action", "get_live_streams"));
repository.replaceLiveStreams(parseLiveStreams(response));
dao.replaceLiveStreams(parseLiveStreams(response));
}
case "vod_categories" -> {
JsonNode response = fetchXtream(config, Map.of("action", "get_vod_categories"));
repository.replaceVodCategories(parseCategories(response));
dao.replaceVodCategories(parseCategories(response));
}
case "vod_streams" -> {
JsonNode response = fetchXtream(config, Map.of("action", "get_vod_streams"));
repository.replaceVodStreams(parseVodStreams(response));
dao.replaceVodStreams(parseVodStreams(response));
}
case "series_categories" -> {
JsonNode response = fetchXtream(config, Map.of("action", "get_series_categories"));
repository.replaceSeriesCategories(parseCategories(response));
dao.replaceSeriesCategories(parseCategories(response));
}
case "series_items" -> {
JsonNode response = fetchXtream(config, Map.of("action", "get_series"));
repository.replaceSeriesItems(parseSeriesItems(response));
repository.setMeta(META_LOADED_AT, Long.toString(System.currentTimeMillis()));
dao.replaceSeriesItems(parseSeriesItems(response));
dao.setMeta(META_LOADED_AT, Long.toString(System.currentTimeMillis()));
}
default -> throw new IllegalArgumentException("Unsupported load step: " + step);
}
@ -114,9 +114,9 @@ final class XtreamLibraryService {
Map<String, Object> status() {
XtreamConfig config = configStore.get();
LibraryRepository.LibraryCounts counts = repository.countAll();
String storedFingerprint = nullSafe(repository.getMeta(META_FINGERPRINT));
String loadedAt = nullSafe(repository.getMeta(META_LOADED_AT));
ApplicationDao.LibraryCounts counts = dao.countAll();
String storedFingerprint = nullSafe(dao.getMeta(META_FINGERPRINT));
String loadedAt = nullSafe(dao.getMeta(META_LOADED_AT));
String currentFingerprint = fingerprint(config);
boolean ready = config.isConfigured()
@ -140,39 +140,39 @@ final class XtreamLibraryService {
return out;
}
List<LibraryRepository.CategoryRow> listCategories(String type) {
return repository.listCategories(type);
List<ApplicationDao.CategoryRow> listCategories(String type) {
return dao.listCategories(type);
}
List<?> listItems(String type, String categoryId, String search, Integer limit, Integer offset) {
String normalizedType = type == null ? "" : type.trim().toLowerCase(Locale.ROOT);
return switch (normalizedType) {
case "live" -> repository.listLiveStreams(categoryId, search, limit, offset);
case "vod" -> repository.listVodStreams(categoryId, search, limit, offset);
case "series" -> repository.listSeriesItems(categoryId, search, limit, offset);
case "live" -> dao.listLiveStreams(categoryId, search, limit, offset);
case "vod" -> dao.listVodStreams(categoryId, search, limit, offset);
case "series" -> dao.listSeriesItems(categoryId, search, limit, offset);
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();
if (normalizedQuery.isBlank()) {
return List.of();
}
int limit = Math.max(1, Math.min(limitRaw, 500));
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) {
return repository.listFavorites(search, limit, offset);
List<ApplicationDao.FavoriteRow> listFavorites(String search, int limit, int offset) {
return dao.listFavorites(search, limit, offset);
}
int countFavorites(String search) {
return repository.countFavorites(search);
return dao.countFavorites(search);
}
LibraryRepository.FavoriteRow saveFavorite(
ApplicationDao.FavoriteRow saveFavorite(
String key,
String mode,
String id,
@ -198,7 +198,7 @@ final class XtreamLibraryService {
throw new IllegalArgumentException("Missing favorite title.");
}
long safeCreatedAt = createdAt == null || createdAt <= 0 ? System.currentTimeMillis() : createdAt;
LibraryRepository.FavoriteRow row = new LibraryRepository.FavoriteRow(
ApplicationDao.FavoriteRow row = new ApplicationDao.FavoriteRow(
normalizedKey,
normalizedMode,
nullSafe(id).trim(),
@ -211,7 +211,7 @@ final class XtreamLibraryService {
nullSafe(url).trim(),
safeCreatedAt
);
repository.upsertFavorite(row);
dao.upsertFavorite(row);
return row;
}
@ -220,16 +220,16 @@ final class XtreamLibraryService {
if (normalizedKey.isBlank()) {
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();
if (seriesId.isBlank()) {
throw new IllegalArgumentException("Missing series_id.");
}
List<LibraryRepository.SeriesEpisodeRow> cached = repository.listSeriesEpisodes(seriesId);
List<ApplicationDao.SeriesEpisodeRow> cached = dao.listSeriesEpisodes(seriesId);
if (!cached.isEmpty()) {
LOGGER.debug("Series episodes served from cache for series_id={}", seriesId);
return cached;
@ -241,8 +241,8 @@ final class XtreamLibraryService {
"action", "get_series_info",
"series_id", seriesId
));
List<LibraryRepository.SeriesEpisodeRow> loaded = parseSeriesEpisodes(seriesId, response.path("episodes"));
repository.replaceSeriesEpisodes(seriesId, loaded);
List<ApplicationDao.SeriesEpisodeRow> loaded = parseSeriesEpisodes(seriesId, response.path("episodes"));
dao.replaceSeriesEpisodes(seriesId, loaded);
return loaded;
}
@ -261,11 +261,11 @@ final class XtreamLibraryService {
return objectMapper.convertValue(response, Object.class);
}
private List<LibraryRepository.CategoryRow> parseCategories(JsonNode node) {
private List<ApplicationDao.CategoryRow> parseCategories(JsonNode node) {
if (!node.isArray()) {
return List.of();
}
Map<String, LibraryRepository.CategoryRow> deduplicated = new LinkedHashMap<>();
Map<String, ApplicationDao.CategoryRow> deduplicated = new LinkedHashMap<>();
for (JsonNode item : node) {
String id = text(item, "category_id");
if (id.isBlank()) {
@ -275,16 +275,16 @@ final class XtreamLibraryService {
if (name.isBlank()) {
name = "Category " + id;
}
deduplicated.put(id, new LibraryRepository.CategoryRow(id, name));
deduplicated.put(id, new ApplicationDao.CategoryRow(id, name));
}
return new ArrayList<>(deduplicated.values());
}
private List<LibraryRepository.LiveStreamRow> parseLiveStreams(JsonNode node) {
private List<ApplicationDao.LiveStreamRow> parseLiveStreams(JsonNode node) {
if (!node.isArray()) {
return List.of();
}
Map<String, LibraryRepository.LiveStreamRow> deduplicated = new LinkedHashMap<>();
Map<String, ApplicationDao.LiveStreamRow> deduplicated = new LinkedHashMap<>();
for (JsonNode item : node) {
String streamId = text(item, "stream_id");
if (streamId.isBlank()) {
@ -294,7 +294,7 @@ final class XtreamLibraryService {
if (name.isBlank()) {
name = "Stream " + streamId;
}
deduplicated.put(streamId, new LibraryRepository.LiveStreamRow(
deduplicated.put(streamId, new ApplicationDao.LiveStreamRow(
streamId,
name,
text(item, "category_id"),
@ -304,11 +304,11 @@ final class XtreamLibraryService {
return new ArrayList<>(deduplicated.values());
}
private List<LibraryRepository.VodStreamRow> parseVodStreams(JsonNode node) {
private List<ApplicationDao.VodStreamRow> parseVodStreams(JsonNode node) {
if (!node.isArray()) {
return List.of();
}
Map<String, LibraryRepository.VodStreamRow> deduplicated = new LinkedHashMap<>();
Map<String, ApplicationDao.VodStreamRow> deduplicated = new LinkedHashMap<>();
for (JsonNode item : node) {
String streamId = text(item, "stream_id");
if (streamId.isBlank()) {
@ -322,7 +322,7 @@ final class XtreamLibraryService {
if (ext.isBlank()) {
ext = "mp4";
}
deduplicated.put(streamId, new LibraryRepository.VodStreamRow(
deduplicated.put(streamId, new ApplicationDao.VodStreamRow(
streamId,
name,
text(item, "category_id"),
@ -332,11 +332,11 @@ final class XtreamLibraryService {
return new ArrayList<>(deduplicated.values());
}
private List<LibraryRepository.SeriesItemRow> parseSeriesItems(JsonNode node) {
private List<ApplicationDao.SeriesItemRow> parseSeriesItems(JsonNode node) {
if (!node.isArray()) {
return List.of();
}
Map<String, LibraryRepository.SeriesItemRow> deduplicated = new LinkedHashMap<>();
Map<String, ApplicationDao.SeriesItemRow> deduplicated = new LinkedHashMap<>();
for (JsonNode item : node) {
String seriesId = text(item, "series_id");
if (seriesId.isBlank()) {
@ -346,7 +346,7 @@ final class XtreamLibraryService {
if (name.isBlank()) {
name = "Series " + seriesId;
}
deduplicated.put(seriesId, new LibraryRepository.SeriesItemRow(
deduplicated.put(seriesId, new ApplicationDao.SeriesItemRow(
seriesId,
name,
text(item, "category_id")
@ -355,11 +355,11 @@ final class XtreamLibraryService {
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()) {
return List.of();
}
Map<String, LibraryRepository.SeriesEpisodeRow> deduplicated = new LinkedHashMap<>();
Map<String, ApplicationDao.SeriesEpisodeRow> deduplicated = new LinkedHashMap<>();
episodesBySeason.fields().forEachRemaining(entry -> {
String season = entry.getKey();
JsonNode episodes = entry.getValue();
@ -389,7 +389,7 @@ final class XtreamLibraryService {
seasonValue = season;
}
deduplicated.put(episodeId, new LibraryRepository.SeriesEpisodeRow(
deduplicated.put(episodeId, new ApplicationDao.SeriesEpisodeRow(
episodeId,
seriesId,
seasonValue,
@ -405,12 +405,12 @@ final class XtreamLibraryService {
private void ensureFingerprint(XtreamConfig config) {
String currentFingerprint = fingerprint(config);
String storedFingerprint = nullSafe(repository.getMeta(META_FINGERPRINT));
String storedFingerprint = nullSafe(dao.getMeta(META_FINGERPRINT));
if (!currentFingerprint.equals(storedFingerprint)) {
LOGGER.info("Source fingerprint changed. Clearing cached library data.");
repository.clearAllSources();
repository.setMeta(META_FINGERPRINT, currentFingerprint);
repository.setMeta(META_LOADED_AT, "");
dao.clearAllSources();
dao.setMeta(META_FINGERPRINT, currentFingerprint);
dao.setMeta(META_LOADED_AT, "");
}
}

View File

@ -1,5 +1,6 @@
package cz.kamma.xtreamplayer;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
@ -35,6 +36,7 @@ import java.util.regex.Pattern;
public final class XtreamPlayerApplication {
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 String ATTR_REQ_START_NANOS = "reqStartNanos";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@ -59,29 +61,23 @@ public final class XtreamPlayerApplication {
ConfigStore configStore = new ConfigStore(
Path.of(System.getProperty("user.home"), ".xtream-player", "config.properties")
);
LibraryRepository libraryRepository = new LibraryRepository(
Path.of(System.getProperty("user.home"), ".xtream-player", "library", "xtream-sources")
ApplicationDao applicationDao = new ApplicationDao(
Path.of(System.getProperty("user.home"), ".xtream-player", "app.db")
);
libraryRepository.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();
applicationDao.initialize();
// Create default admin user if no users exist
if (userStore.getAllUsers().isEmpty()) {
if (applicationDao.getAllUsers().isEmpty()) {
try {
userStore.createUser("admin", "admin");
applicationDao.createUser("admin", "admin");
LOGGER.info("Default admin user created (username: admin, password: admin)");
} catch (IllegalArgumentException ignored) {
// 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);
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/epg", new LibraryEpgHandler(libraryService, userAuthenticator));
server.createContext("/api/favorites", new FavoritesHandler(libraryService, userAuthenticator));
server.createContext("/user", new UserCrudHandler(applicationDao));
server.createContext("/", new StaticHandler(userAuthenticator));
server.setExecutor(Executors.newFixedThreadPool(12));
server.start();
@ -738,7 +735,7 @@ public final class XtreamPlayerApplication {
? Map.of()
: OBJECT_MAPPER.readValue(body, Map.class);
LibraryRepository.FavoriteRow saved = libraryService.saveFavorite(
ApplicationDao.FavoriteRow saved = libraryService.saveFavorite(
asString(payload.get("key")),
asString(payload.get("mode")),
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 {
StaticHandler(UserAuthenticator userAuthenticator) {
// Static handler allows unauthenticated access to public files
@ -859,6 +953,16 @@ public final class XtreamPlayerApplication {
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 {
String token = extractAuthToken(exchange);
if (token == null || !userAuthenticator.isTokenValid(token)) {
@ -1308,6 +1412,33 @@ public final class XtreamPlayerApplication {
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 {
try (InputStream inputStream = exchange.getRequestBody()) {
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);