diff --git a/README.md b/README.md index 2278f71..e07a382 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/USERS_MANAGEMENT.md b/USERS_MANAGEMENT.md index dcd7fb2..16fb420 100644 --- a/USERS_MANAGEMENT.md +++ b/USERS_MANAGEMENT.md @@ -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. diff --git a/src/main/java/cz/kamma/xtreamplayer/LibraryRepository.java b/src/main/java/cz/kamma/xtreamplayer/ApplicationDao.java similarity index 82% rename from src/main/java/cz/kamma/xtreamplayer/LibraryRepository.java rename to src/main/java/cz/kamma/xtreamplayer/ApplicationDao.java index 699976b..ea47b01 100644 --- a/src/main/java/cz/kamma/xtreamplayer/LibraryRepository.java +++ b/src/main/java/cz/kamma/xtreamplayer/ApplicationDao.java @@ -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 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 getAllUsers() { + List 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 = 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; diff --git a/src/main/java/cz/kamma/xtreamplayer/UserAuthenticator.java b/src/main/java/cz/kamma/xtreamplayer/UserAuthenticator.java index 96924c0..413697f 100644 --- a/src/main/java/cz/kamma/xtreamplayer/UserAuthenticator.java +++ b/src/main/java/cz/kamma/xtreamplayer/UserAuthenticator.java @@ -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 sessionTokens = new HashMap<>(); private final Map 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 diff --git a/src/main/java/cz/kamma/xtreamplayer/UserManager.java b/src/main/java/cz/kamma/xtreamplayer/UserManager.java deleted file mode 100644 index 0c41e7b..0000000 --- a/src/main/java/cz/kamma/xtreamplayer/UserManager.java +++ /dev/null @@ -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 "); - System.exit(1); - } - addUser(args[1], args[2]); - } - case "delete" -> { - if (args.length < 2) { - System.err.println("Usage: delete "); - System.exit(1); - } - deleteUser(args[1]); - } - case "update" -> { - if (args.length < 3) { - System.err.println("Usage: update "); - System.exit(1); - } - updatePassword(args[1], args[2]); - } - case "list" -> listUsers(); - case "verify" -> { - if (args.length < 3) { - System.err.println("Usage: verify "); - 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 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."); - } - } -} diff --git a/src/main/java/cz/kamma/xtreamplayer/UserStore.java b/src/main/java/cz/kamma/xtreamplayer/UserStore.java deleted file mode 100644 index 55a2aeb..0000000 --- a/src/main/java/cz/kamma/xtreamplayer/UserStore.java +++ /dev/null @@ -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 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 getAllUsers() { - List 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 = 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 + - '}'; - } - } -} diff --git a/src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java b/src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java index 776ab08..ada32ba 100644 --- a/src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java +++ b/src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java @@ -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 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 listCategories(String type) { - return repository.listCategories(type); + List 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 globalSearch(String query, int limitRaw, int offsetRaw) { + List 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 listFavorites(String search, int limit, int offset) { - return repository.listFavorites(search, limit, offset); + List 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 listSeriesEpisodes(String seriesIdRaw) { + List listSeriesEpisodes(String seriesIdRaw) { String seriesId = seriesIdRaw == null ? "" : seriesIdRaw.trim(); if (seriesId.isBlank()) { throw new IllegalArgumentException("Missing series_id."); } - List cached = repository.listSeriesEpisodes(seriesId); + List 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 loaded = parseSeriesEpisodes(seriesId, response.path("episodes")); - repository.replaceSeriesEpisodes(seriesId, loaded); + List 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 parseCategories(JsonNode node) { + private List parseCategories(JsonNode node) { if (!node.isArray()) { return List.of(); } - Map deduplicated = new LinkedHashMap<>(); + Map 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 parseLiveStreams(JsonNode node) { + private List parseLiveStreams(JsonNode node) { if (!node.isArray()) { return List.of(); } - Map deduplicated = new LinkedHashMap<>(); + Map 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 parseVodStreams(JsonNode node) { + private List parseVodStreams(JsonNode node) { if (!node.isArray()) { return List.of(); } - Map deduplicated = new LinkedHashMap<>(); + Map 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 parseSeriesItems(JsonNode node) { + private List parseSeriesItems(JsonNode node) { if (!node.isArray()) { return List.of(); } - Map deduplicated = new LinkedHashMap<>(); + Map 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 parseSeriesEpisodes(String seriesId, JsonNode episodesBySeason) { + private List parseSeriesEpisodes(String seriesId, JsonNode episodesBySeason) { if (episodesBySeason == null || episodesBySeason.isMissingNode() || episodesBySeason.isNull()) { return List.of(); } - Map deduplicated = new LinkedHashMap<>(); + Map 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, ""); } } diff --git a/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java b/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java index c872dd1..a8b1bfc 100644 --- a/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java +++ b/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java @@ -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 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 query = parseKeyValue(exchange.getRequestURI().getRawQuery()); + + try { + switch (method) { + case "GET" -> { + logApiRequest(exchange, "/user", query); + String username = query.getOrDefault("username", "").trim(); + if (username.isBlank()) { + List> users = applicationDao.getAllUsers().stream() + .map(XtreamPlayerApplication::toUserResponse) + .toList(); + Map 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 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 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 payload = Map.of(); + if (username.isBlank()) { + payload = parseRequestPayload(exchange); + username = payload.getOrDefault("username", "").trim(); + } + Map 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 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 raw = OBJECT_MAPPER.readValue(body, new TypeReference<>() { + }); + Map parsed = new LinkedHashMap<>(); + for (Map.Entry entry : raw.entrySet()) { + parsed.put(entry.getKey(), asString(entry.getValue())); + } + return parsed; + } + return parseKeyValue(body); + } + + private static Map toUserResponse(ApplicationDao.User user) { + Map 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);