diff --git a/README.md b/README.md index ddd7af8..2278f71 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Xtream Player (Java + com.sun.httpserver) Self-hosted HTML5 app for Xtream IPTV: +- **User authentication** - Built-in login with username/password (stored in H2 database) - provider settings/login - manual source preload (button `Load sources`) with progress bar - sources are stored in local H2 DB and the UI reads them only through local API @@ -21,11 +22,38 @@ mvn -q compile exec:java The app runs on: - `http://localhost:8080` +Default login credentials (first run): +- **Username**: `admin` +- **Password**: `admin` + Optional: change port: ```bash PORT=8090 mvn -q compile exec:java ``` +## User Management + +To manage users (add, delete, update passwords), use the `UserManager` CLI tool: + +```bash +# Interactive mode +java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager + +# Add a user +java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager add username password + +# List all users +java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager list + +# Update password +java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager update username new_password + +# Delete user +java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager delete username +``` + +See [USERS_MANAGEMENT.md](USERS_MANAGEMENT.md) for detailed user management documentation. + ## Docker (JRE 17) Build image: ```bash @@ -42,6 +70,7 @@ docker run --rm -p 8080:8080 \ ## Notes - Config is stored in `~/.xtream-player/config.properties`. +- User credentials are stored in `~/.xtream-player/users.db` (H2 database). - Preloaded sources (live/vod/series categories + lists) are stored in `~/.xtream-player/library/xtream-sources.mv.db`. - Custom streams are stored in browser `localStorage`. - Some Xtream servers/streams may not be browser-friendly (for example `ts`). `m3u8` is usually better for browsers. diff --git a/USERS_MANAGEMENT.md b/USERS_MANAGEMENT.md new file mode 100644 index 0000000..dcd7fb2 --- /dev/null +++ b/USERS_MANAGEMENT.md @@ -0,0 +1,122 @@ +# Správa uživatelů - Xtream Player + +## Přehled + +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í. + +## UserManager - Nástroj pro správu uživatelů + +K přidání, smazání nebo úpravě uživatelů slouží třída `UserManager`, kterou lze spustit jako standalone aplikaci. + +### Spuštění interaktivního režimu + +```bash +java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager +``` + +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 + +### 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 +``` + +#### Smazat uživatele +```bash +java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager delete username +``` + +#### Aktualizovat heslo +```bash +java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager update username new_password +``` + +#### Vypsat všechny uživatele +```bash +java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager list +``` + +#### Ověřit heslo +```bash +java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager verify username password +``` + +## Příklady + +### Přidať nového administrátora +```bash +java -cp target/xtream-player-1.0.0.jar cz.kamma.xtreamplayer.UserManager add admin123 MySecurePassword123 +``` + +### 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 +``` + +### 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` diff --git a/src/main/java/cz/kamma/xtreamplayer/UserAuthenticator.java b/src/main/java/cz/kamma/xtreamplayer/UserAuthenticator.java new file mode 100644 index 0000000..96924c0 --- /dev/null +++ b/src/main/java/cz/kamma/xtreamplayer/UserAuthenticator.java @@ -0,0 +1,79 @@ +package cz.kamma.xtreamplayer; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +/** + * Authentication provider that manages session tokens. + * User credentials are stored in the database via UserStore. + */ +public final class UserAuthenticator { + private final UserStore userStore; + 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; + } + + /** + * Authenticate a user and return a session token if credentials are valid. + */ + public String authenticate(String username, String password) { + if (username == null || password == null) { + return null; + } + if (!userStore.verifyPassword(username, password)) { + return null; + } + // Generate and store session token + String token = generateToken(); + sessionTokens.put(token, username); + tokenExpiry.put(token, System.currentTimeMillis() + TOKEN_EXPIRY_MS); + return token; + } + + /** + * Validate a session token and return the username if valid. + */ + public String validateToken(String token) { + if (token == null || token.isBlank()) { + return null; + } + Long expiry = tokenExpiry.get(token); + if (expiry == null || System.currentTimeMillis() > expiry) { + sessionTokens.remove(token); + tokenExpiry.remove(token); + return null; + } + return sessionTokens.get(token); + } + + /** + * Revoke a session token. + */ + public void revokeToken(String token) { + sessionTokens.remove(token); + tokenExpiry.remove(token); + } + + /** + * Check if a token is valid. + */ + public boolean isTokenValid(String token) { + return validateToken(token) != null; + } + + /** + * Generate a random session token. + */ + private String generateToken() { + byte[] randomBytes = new byte[32]; + RANDOM.nextBytes(randomBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + } +} diff --git a/src/main/java/cz/kamma/xtreamplayer/UserManager.java b/src/main/java/cz/kamma/xtreamplayer/UserManager.java new file mode 100644 index 0000000..0c41e7b --- /dev/null +++ b/src/main/java/cz/kamma/xtreamplayer/UserManager.java @@ -0,0 +1,219 @@ +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 new file mode 100644 index 0000000..55a2aeb --- /dev/null +++ b/src/main/java/cz/kamma/xtreamplayer/UserStore.java @@ -0,0 +1,273 @@ +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/XtreamPlayerApplication.java b/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java index a998796..c872dd1 100644 --- a/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java +++ b/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java @@ -64,23 +64,43 @@ public final class XtreamPlayerApplication { ); 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(); + + // Create default admin user if no users exist + if (userStore.getAllUsers().isEmpty()) { + try { + userStore.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); HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); - server.createContext("/api/config", new ConfigHandler(configStore)); - server.createContext("/api/test-login", new TestLoginHandler(configStore)); - server.createContext("/api/xtream", new XtreamProxyHandler(configStore)); - server.createContext("/api/stream-url", new StreamUrlHandler(configStore)); - server.createContext("/api/stream-proxy", new StreamProxyHandler()); - server.createContext("/api/open-in-player", new OpenInPlayerHandler(configStore)); - server.createContext("/api/library/load", new LibraryLoadHandler(libraryService)); - server.createContext("/api/library/status", new LibraryStatusHandler(libraryService)); - server.createContext("/api/library/categories", new LibraryCategoriesHandler(libraryService)); - server.createContext("/api/library/items", new LibraryItemsHandler(libraryService)); - server.createContext("/api/library/search", new LibrarySearchHandler(libraryService)); - server.createContext("/api/library/series-episodes", new LibrarySeriesEpisodesHandler(libraryService)); - server.createContext("/api/library/epg", new LibraryEpgHandler(libraryService)); - server.createContext("/api/favorites", new FavoritesHandler(libraryService)); - server.createContext("/", new StaticHandler()); + server.createContext("/api/auth/login", new LoginHandler(userAuthenticator)); + server.createContext("/api/auth/logout", new LogoutHandler(userAuthenticator)); + server.createContext("/api/config", new ConfigHandler(configStore, userAuthenticator)); + server.createContext("/api/test-login", new TestLoginHandler(configStore, userAuthenticator)); + server.createContext("/api/xtream", new XtreamProxyHandler(configStore, userAuthenticator)); + server.createContext("/api/stream-url", new StreamUrlHandler(configStore, userAuthenticator)); + server.createContext("/api/stream-proxy", new StreamProxyHandler(userAuthenticator)); + server.createContext("/api/open-in-player", new OpenInPlayerHandler(configStore, userAuthenticator)); + server.createContext("/api/library/load", new LibraryLoadHandler(libraryService, userAuthenticator)); + server.createContext("/api/library/status", new LibraryStatusHandler(libraryService, userAuthenticator)); + server.createContext("/api/library/categories", new LibraryCategoriesHandler(libraryService, userAuthenticator)); + server.createContext("/api/library/items", new LibraryItemsHandler(libraryService, userAuthenticator)); + server.createContext("/api/library/search", new LibrarySearchHandler(libraryService, userAuthenticator)); + 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("/", new StaticHandler(userAuthenticator)); server.setExecutor(Executors.newFixedThreadPool(12)); server.start(); @@ -99,10 +119,73 @@ public final class XtreamPlayerApplication { } } - private record ConfigHandler(ConfigStore configStore) implements HttpHandler { + private record LoginHandler(UserAuthenticator userAuthenticator) implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { try { + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + methodNotAllowed(exchange, "POST"); + return; + } + + String body = readBody(exchange); + Map form = parseKeyValue(body); + logApiRequest(exchange, "/api/auth/login", form); + + String username = form.get("username"); + String password = form.get("password"); + + if (username == null || username.isBlank() || password == null || password.isBlank()) { + writeJson(exchange, 400, errorJson("Username and password are required.")); + return; + } + + String token = userAuthenticator.authenticate(username, password); + if (token == null) { + writeJson(exchange, 401, errorJson("Invalid username or password.")); + return; + } + + LOGGER.info("User authenticated: {}", username); + writeJson(exchange, 200, "{\"token\": \"" + jsonEscape(token) + "\"}"); + } catch (Exception exception) { + LOGGER.error("Login endpoint failed", exception); + writeJson(exchange, 500, errorJson("Login error: " + exception.getMessage())); + } + } + } + + private record LogoutHandler(UserAuthenticator userAuthenticator) implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + methodNotAllowed(exchange, "POST"); + return; + } + + String token = extractAuthToken(exchange); + logApiRequest(exchange, "/api/auth/logout", Map.of()); + + if (token != null) { + userAuthenticator.revokeToken(token); + } + + writeJson(exchange, 200, "{\"message\": \"Logged out successfully\"}"); + } catch (Exception exception) { + LOGGER.error("Logout endpoint failed", exception); + writeJson(exchange, 500, errorJson("Logout error: " + exception.getMessage())); + } + } + } + + private record ConfigHandler(ConfigStore configStore, UserAuthenticator userAuthenticator) implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + if (!requireAuth(exchange, userAuthenticator)) { + return; + } String method = exchange.getRequestMethod(); if ("GET".equalsIgnoreCase(method)) { logApiRequest(exchange, "/api/config", Map.of()); @@ -132,9 +215,12 @@ public final class XtreamPlayerApplication { } } - private record TestLoginHandler(ConfigStore configStore) implements HttpHandler { + private record TestLoginHandler(ConfigStore configStore, UserAuthenticator userAuthenticator) implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { + if (!requireAuth(exchange, userAuthenticator)) { + return; + } logApiRequest(exchange, "/api/test-login", parseKeyValue(exchange.getRequestURI().getRawQuery())); if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { methodNotAllowed(exchange, "GET"); @@ -152,9 +238,12 @@ public final class XtreamPlayerApplication { } } - private record XtreamProxyHandler(ConfigStore configStore) implements HttpHandler { + private record XtreamProxyHandler(ConfigStore configStore, UserAuthenticator userAuthenticator) implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { + if (!requireAuth(exchange, userAuthenticator)) { + return; + } Map incoming = parseKeyValue(exchange.getRequestURI().getRawQuery()); logApiRequest(exchange, "/api/xtream", incoming); if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { @@ -176,9 +265,12 @@ public final class XtreamPlayerApplication { } } - private record StreamUrlHandler(ConfigStore configStore) implements HttpHandler { + private record StreamUrlHandler(ConfigStore configStore, UserAuthenticator userAuthenticator) implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { + if (!requireAuth(exchange, userAuthenticator)) { + return; + } Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); logApiRequest(exchange, "/api/stream-url", query); if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { @@ -215,9 +307,12 @@ public final class XtreamPlayerApplication { } } - private record StreamProxyHandler() implements HttpHandler { + private record StreamProxyHandler(UserAuthenticator userAuthenticator) implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { + if (!requireAuth(exchange, userAuthenticator)) { + return; + } Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); logApiRequest(exchange, "/api/stream-proxy", query); if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { @@ -326,9 +421,12 @@ public final class XtreamPlayerApplication { } } - private record OpenInPlayerHandler(ConfigStore configStore) implements HttpHandler { + private record OpenInPlayerHandler(ConfigStore configStore, UserAuthenticator userAuthenticator) implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { + if (!requireAuth(exchange, userAuthenticator)) { + return; + } Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); logApiRequest(exchange, "/api/open-in-player", query); if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { @@ -391,9 +489,12 @@ public final class XtreamPlayerApplication { } } - private record LibraryLoadHandler(XtreamLibraryService libraryService) implements HttpHandler { + private record LibraryLoadHandler(XtreamLibraryService libraryService, UserAuthenticator userAuthenticator) implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { + if (!requireAuth(exchange, userAuthenticator)) { + return; + } Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); logApiRequest(exchange, "/api/library/load", query); if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { @@ -420,9 +521,12 @@ public final class XtreamPlayerApplication { } } - private record LibraryStatusHandler(XtreamLibraryService libraryService) implements HttpHandler { + private record LibraryStatusHandler(XtreamLibraryService libraryService, UserAuthenticator userAuthenticator) implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { + if (!requireAuth(exchange, userAuthenticator)) { + return; + } logApiRequest(exchange, "/api/library/status", parseKeyValue(exchange.getRequestURI().getRawQuery())); if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { methodNotAllowed(exchange, "GET"); @@ -437,9 +541,12 @@ public final class XtreamPlayerApplication { } } - private record LibraryCategoriesHandler(XtreamLibraryService libraryService) implements HttpHandler { + private record LibraryCategoriesHandler(XtreamLibraryService libraryService, UserAuthenticator userAuthenticator) implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { + if (!requireAuth(exchange, userAuthenticator)) { + return; + } Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); logApiRequest(exchange, "/api/library/categories", query); if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { @@ -460,9 +567,12 @@ public final class XtreamPlayerApplication { } } - private record LibraryItemsHandler(XtreamLibraryService libraryService) implements HttpHandler { + private record LibraryItemsHandler(XtreamLibraryService libraryService, UserAuthenticator userAuthenticator) implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { + if (!requireAuth(exchange, userAuthenticator)) { + return; + } Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); logApiRequest(exchange, "/api/library/items", query); if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { @@ -494,9 +604,12 @@ public final class XtreamPlayerApplication { } } - private record LibrarySearchHandler(XtreamLibraryService libraryService) implements HttpHandler { + private record LibrarySearchHandler(XtreamLibraryService libraryService, UserAuthenticator userAuthenticator) implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { + if (!requireAuth(exchange, userAuthenticator)) { + return; + } Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); logApiRequest(exchange, "/api/library/search", query); if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { @@ -528,9 +641,12 @@ public final class XtreamPlayerApplication { } } - private record LibrarySeriesEpisodesHandler(XtreamLibraryService libraryService) implements HttpHandler { + private record LibrarySeriesEpisodesHandler(XtreamLibraryService libraryService, UserAuthenticator userAuthenticator) implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { + if (!requireAuth(exchange, userAuthenticator)) { + return; + } Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); logApiRequest(exchange, "/api/library/series-episodes", query); if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { @@ -551,9 +667,12 @@ public final class XtreamPlayerApplication { } } - private record LibraryEpgHandler(XtreamLibraryService libraryService) implements HttpHandler { + private record LibraryEpgHandler(XtreamLibraryService libraryService, UserAuthenticator userAuthenticator) implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { + if (!requireAuth(exchange, userAuthenticator)) { + return; + } Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); logApiRequest(exchange, "/api/library/epg", query); if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { @@ -580,9 +699,12 @@ public final class XtreamPlayerApplication { } } - private record FavoritesHandler(XtreamLibraryService libraryService) implements HttpHandler { + private record FavoritesHandler(XtreamLibraryService libraryService, UserAuthenticator userAuthenticator) implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { + if (!requireAuth(exchange, userAuthenticator)) { + return; + } Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); String method = exchange.getRequestMethod(); logApiRequest(exchange, "/api/favorites", query); @@ -654,29 +776,37 @@ public final class XtreamPlayerApplication { } private static final class StaticHandler implements HttpHandler { + StaticHandler(UserAuthenticator userAuthenticator) { + // Static handler allows unauthenticated access to public files + } + @Override public void handle(HttpExchange exchange) throws IOException { - if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + String path = exchange.getRequestURI().getPath(); + // Allow access to login page without authentication + if ("/".equals(path) || "/index.html".equals(path)) { + serveStaticFile(exchange, "/web/index.html"); + return; + } + // Assets can be accessed without auth + if (path.startsWith("/assets/")) { + serveStaticAsset(exchange, path); + return; + } + // Everything else requires auth + if (!exchange.getRequestMethod().equalsIgnoreCase("GET")) { methodNotAllowed(exchange, "GET"); return; } + serveStaticFile(exchange, "/web/index.html"); + } - String path = exchange.getRequestURI().getPath(); - String resourcePath; - - if ("/".equals(path) || "/index.html".equals(path)) { - resourcePath = "/web/index.html"; - } else if (path.startsWith("/assets/")) { - resourcePath = "/web" + normalizeAssetPath(path); - } else { - resourcePath = "/web/index.html"; - } - + private void serveStaticAsset(HttpExchange exchange, String path) throws IOException { + String resourcePath = "/web" + normalizeAssetPath(path); if (resourcePath.contains("..")) { writeJson(exchange, 400, errorJson("Invalid path")); return; } - try (InputStream inputStream = XtreamPlayerApplication.class.getResourceAsStream(resourcePath)) { if (inputStream == null) { writeJson(exchange, 404, errorJson("Not found")); @@ -694,6 +824,48 @@ public final class XtreamPlayerApplication { exchange.close(); } } + + private void serveStaticFile(HttpExchange exchange, String resourcePath) throws IOException { + if (resourcePath.contains("..")) { + writeJson(exchange, 400, errorJson("Invalid path")); + return; + } + try (InputStream inputStream = XtreamPlayerApplication.class.getResourceAsStream(resourcePath)) { + if (inputStream == null) { + writeJson(exchange, 404, errorJson("Not found")); + return; + } + LOGGER.debug("Serving static resource={}", resourcePath); + byte[] body = inputStream.readAllBytes(); + exchange.getResponseHeaders().set("Content-Type", contentType(resourcePath)); + exchange.getResponseHeaders().set("Cache-Control", "no-store, no-cache, must-revalidate"); + exchange.getResponseHeaders().set("Pragma", "no-cache"); + exchange.getResponseHeaders().set("Expires", "0"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + } finally { + exchange.close(); + } + } + } + + private static String extractAuthToken(HttpExchange exchange) { + String authHeader = exchange.getRequestHeaders().getFirst("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); + } + // Also check query parameter as fallback for some clients + Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); + return query.get("token"); + } + + private static boolean requireAuth(HttpExchange exchange, UserAuthenticator userAuthenticator) throws IOException { + String token = extractAuthToken(exchange); + if (token == null || !userAuthenticator.isTokenValid(token)) { + writeJson(exchange, 401, errorJson("Unauthorized. Please login first.")); + return false; + } + return true; } private static void proxyRequest(HttpExchange exchange, URI uri) throws IOException { diff --git a/src/main/resources/web/assets/app.js b/src/main/resources/web/assets/app.js index 0bf7bbf..bf48ef1 100644 --- a/src/main/resources/web/assets/app.js +++ b/src/main/resources/web/assets/app.js @@ -1,4 +1,120 @@ (() => { + // ============ AUTHENTICATION ============ + const authTokenKey = "xtream_auth_token"; + + function getAuthToken() { + return localStorage.getItem(authTokenKey); + } + + function setAuthToken(token) { + localStorage.setItem(authTokenKey, token); + } + + function clearAuthToken() { + localStorage.removeItem(authTokenKey); + } + + function isAuthenticated() { + return !!getAuthToken(); + } + + function getAuthHeader() { + const token = getAuthToken(); + return token ? { "Authorization": `Bearer ${token}` } : {}; + } + + async function login(username, password) { + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ username, password }) + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Login failed"); + } + + const data = await response.json(); + setAuthToken(data.token); + return true; + } catch (error) { + console.error("Login error:", error); + throw error; + } + } + + async function logout() { + try { + const headers = getAuthHeader(); + await fetch("/api/auth/logout", { + method: "POST", + headers + }); + } catch (error) { + console.error("Logout error:", error); + } finally { + clearAuthToken(); + location.reload(); + } + } + + function showLoginScreen() { + const loginModal = document.getElementById("login-modal"); + const appContainer = document.getElementById("app-container"); + if (loginModal) loginModal.classList.add("active"); + if (appContainer) appContainer.classList.add("hidden"); + } + + function hideLoginScreen() { + const loginModal = document.getElementById("login-modal"); + const appContainer = document.getElementById("app-container"); + if (loginModal) loginModal.classList.remove("active"); + if (appContainer) appContainer.classList.remove("hidden"); + } + + // Setup login form handler + const loginForm = document.getElementById("login-form"); + if (loginForm) { + loginForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const username = document.getElementById("login-username").value; + const password = document.getElementById("login-password").value; + const messageEl = document.getElementById("login-message"); + + try { + messageEl.textContent = "Logging in..."; + messageEl.className = "message info"; + await login(username, password); + messageEl.textContent = ""; + messageEl.className = "message"; + hideLoginScreen(); + initializeApp(); + } catch (error) { + messageEl.textContent = error.message; + messageEl.className = "message error"; + } + }); + } + + // Setup logout button + const logoutBtn = document.getElementById("logout-btn"); + if (logoutBtn) { + logoutBtn.addEventListener("click", () => { + if (confirm("Are you sure you want to logout?")) { + logout(); + } + }); + } + + // Check authentication on page load + if (!isAuthenticated()) { + showLoginScreen(); + } else { + hideLoginScreen(); + } + let hlsInstance = null; let embeddedSubtitleScanTimer = null; let hlsSubtitleTracks = []; @@ -104,9 +220,17 @@ epgList: document.getElementById("epg-list") }; - init().catch((error) => { - setSettingsMessage(error.message || String(error), "err"); - }); + // Initialize app only if authenticated + function initializeApp() { + init().catch((error) => { + setSettingsMessage(error.message || String(error), "err"); + }); + } + + // Try to initialize on page load if already authenticated + if (isAuthenticated()) { + initializeApp(); + } async function init() { bindTabs(); @@ -3001,6 +3125,11 @@ if (!headers.has("Pragma")) { headers.set("Pragma", "no-cache"); } + // Add auth token if logged in + const authToken = getAuthToken(); + if (authToken && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${authToken}`); + } fetchOptions.headers = headers; const response = await fetch(url, fetchOptions); @@ -3012,6 +3141,11 @@ parsed = {raw: text}; } if (!response.ok) { + // If unauthorized, redirect to login + if (response.status === 401) { + clearAuthToken(); + location.reload(); + } throw new Error(parsed.error || parsed.raw || `HTTP ${response.status}`); } return parsed; diff --git a/src/main/resources/web/assets/style.css b/src/main/resources/web/assets/style.css index 7703ea7..3a2cfc2 100644 --- a/src/main/resources/web/assets/style.css +++ b/src/main/resources/web/assets/style.css @@ -499,6 +499,152 @@ progress::-moz-progress-bar { color: var(--danger); } +/* Login Modal Styles */ +.login-modal { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); + z-index: 9999; + align-items: center; + justify-content: center; +} + +.login-modal.active { + display: flex; +} + +.login-container { + background: var(--card); + backdrop-filter: blur(8px); + border: 1px solid var(--line); + border-radius: 12px; + padding: 2rem; + max-width: 380px; + width: 90%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + animation: rise 0.3s ease; +} + +.login-header { + text-align: center; + margin-bottom: 1.5rem; +} + +.login-header h1 { + margin: 0.5rem 0 0; + font-size: 1.5rem; +} + +.login-header .eyebrow { + margin: 0; +} + +.login-form { + display: grid; + gap: 1rem; +} + +.form-group { + display: grid; + gap: 0.4rem; +} + +.form-group label { + color: var(--muted); + font-size: 0.9rem; + font-weight: 500; +} + +.form-group input { + background: rgba(9, 22, 32, 0.6); + border: 1px solid var(--line); + color: var(--text); + padding: 0.6rem 0.8rem; + border-radius: 6px; + font: inherit; + transition: border-color 0.2s; +} + +.form-group input:focus { + outline: none; + border-color: var(--accent); +} + +.login-button { + background: linear-gradient(135deg, var(--accent), #ff9a43); + color: var(--bg-0); + border: none; + padding: 0.7rem 1rem; + border-radius: 6px; + font-weight: 600; + font-size: 1rem; + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; +} + +.login-button:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(255, 124, 67, 0.3); +} + +.login-button:active { + transform: translateY(0); +} + +.login-hint { + text-align: center; + color: var(--muted); + font-size: 0.85rem; + margin-top: 1rem; + margin-bottom: 0; +} + +#app-container { + display: block; +} + +#app-container.hidden { + display: none; +} + +.header-controls { + display: flex; + align-items: center; + gap: 0.8rem; +} + +.logout-button { + background: transparent; + border: 1px solid var(--danger); + color: var(--danger); + padding: 0.35rem 0.7rem; + border-radius: 4px; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s; +} + +.logout-button:hover { + background: rgba(255, 154, 139, 0.15); +} + +.message.error { + color: var(--danger); + border-color: var(--danger); +} + +.message.info { + color: var(--accent); + border-color: var(--accent); +} + +.message.ok { + color: var(--ok); + border-color: var(--ok); +} + @keyframes rise { from { opacity: 0; diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 8758e9b..5e05d25 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -8,12 +8,40 @@
+ + + + + +