added login page

This commit is contained in:
rdavidek 2026-03-09 21:07:34 +01:00
parent 2f4c35a797
commit 01d961539c
9 changed files with 1249 additions and 46 deletions

View File

@ -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.

122
USERS_MANAGEMENT.md Normal file
View File

@ -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`

View File

@ -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<String, String> sessionTokens = new HashMap<>();
private final Map<String, Long> tokenExpiry = new HashMap<>();
private static final long TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
private static final SecureRandom RANDOM = new SecureRandom();
public UserAuthenticator(UserStore userStore) {
this.userStore = userStore;
}
/**
* 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);
}
}

View File

@ -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 <username> <password>");
System.exit(1);
}
addUser(args[1], args[2]);
}
case "delete" -> {
if (args.length < 2) {
System.err.println("Usage: delete <username>");
System.exit(1);
}
deleteUser(args[1]);
}
case "update" -> {
if (args.length < 3) {
System.err.println("Usage: update <username> <new_password>");
System.exit(1);
}
updatePassword(args[1], args[2]);
}
case "list" -> listUsers();
case "verify" -> {
if (args.length < 3) {
System.err.println("Usage: verify <username> <password>");
System.exit(1);
}
verifyUser(args[1], args[2]);
}
default -> {
System.err.println("Unknown command: " + command);
System.err.println("Available commands: add, delete, update, list, verify");
System.exit(1);
}
}
} catch (Exception e) {
System.err.println("Error: " + e.getMessage());
System.exit(1);
}
}
private void interactiveMode() {
System.out.println("╔════════════════════════════════════════╗");
System.out.println("║ Xtream Player - User Manager ║");
System.out.println("╚════════════════════════════════════════╝");
System.out.println();
System.out.println("Available commands:");
System.out.println(" 1. Add user");
System.out.println(" 2. Delete user");
System.out.println(" 3. Update password");
System.out.println(" 4. List all users");
System.out.println(" 5. Verify password");
System.out.println(" 0. Exit");
System.out.println();
boolean running = true;
while (running) {
System.out.print("\nSelect option: ");
String choice = scanner.nextLine().trim();
try {
switch (choice) {
case "1" -> addUserInteractive();
case "2" -> deleteUserInteractive();
case "3" -> updatePasswordInteractive();
case "4" -> listUsers();
case "5" -> verifyUserInteractive();
case "0" -> {
System.out.println("Goodbye!");
running = false;
}
default -> System.out.println("Invalid option. Please try again.");
}
} catch (Exception e) {
System.err.println("Error: " + e.getMessage());
}
}
}
private void addUserInteractive() {
System.out.print("Username: ");
String username = scanner.nextLine().trim();
System.out.print("Password: ");
String password = scanner.nextLine().trim();
addUser(username, password);
}
private void addUser(String username, String password) {
if (username.isBlank() || password.isBlank()) {
System.err.println("Username and password cannot be empty.");
return;
}
try {
userStore.createUser(username, password);
System.out.println("✓ User '" + username + "' created successfully.");
} catch (IllegalArgumentException e) {
System.err.println("" + e.getMessage());
}
}
private void deleteUserInteractive() {
System.out.print("Username to delete: ");
String username = scanner.nextLine().trim();
System.out.print("Are you sure? (yes/no): ");
String confirm = scanner.nextLine().trim().toLowerCase();
if ("yes".equals(confirm) || "y".equals(confirm)) {
deleteUser(username);
} else {
System.out.println("Cancelled.");
}
}
private void deleteUser(String username) {
try {
userStore.deleteUser(username);
System.out.println("✓ User '" + username + "' deleted successfully.");
} catch (IllegalArgumentException e) {
System.err.println("" + e.getMessage());
}
}
private void updatePasswordInteractive() {
System.out.print("Username: ");
String username = scanner.nextLine().trim();
System.out.print("New password: ");
String newPassword = scanner.nextLine().trim();
updatePassword(username, newPassword);
}
private void updatePassword(String username, String newPassword) {
if (newPassword.isBlank()) {
System.err.println("Password cannot be empty.");
return;
}
try {
userStore.updatePassword(username, newPassword);
System.out.println("✓ Password for user '" + username + "' updated successfully.");
} catch (IllegalArgumentException e) {
System.err.println("" + e.getMessage());
}
}
private void listUsers() {
List<UserStore.User> users = userStore.getAllUsers();
if (users.isEmpty()) {
System.out.println("No users found.");
return;
}
System.out.println();
System.out.println("╔═══╦════════════════════╦═══════════════════════════════╦═══════════════════════════════╗");
System.out.println("║ ID║ Username ║ Created At ║ Updated At ║");
System.out.println("╠═══╬════════════════════╬═══════════════════════════════╬═══════════════════════════════╣");
for (UserStore.User user : users) {
System.out.printf("║ %2d║ %-18s ║ %-29s ║ %-29s ║%n",
user.getId(),
user.getUsername(),
user.getCreatedAt() != null ? user.getCreatedAt().toString() : "N/A",
user.getUpdatedAt() != null ? user.getUpdatedAt().toString() : "N/A"
);
}
System.out.println("╚═══╩════════════════════╩═══════════════════════════════╩═══════════════════════════════╝");
System.out.println("Total: " + users.size() + " user(s)");
}
private void verifyUserInteractive() {
System.out.print("Username: ");
String username = scanner.nextLine().trim();
System.out.print("Password: ");
String password = scanner.nextLine().trim();
verifyUser(username, password);
}
private void verifyUser(String username, String password) {
if (userStore.verifyPassword(username, password)) {
System.out.println("✓ Password is correct for user '" + username + "'.");
} else {
System.out.println("✗ Password verification failed.");
}
}
}

View File

@ -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<User> getUser(String username) {
if (username == null || username.isBlank()) {
return Optional.empty();
}
try (Connection connection = openConnection()) {
String sql = "SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, username);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return Optional.of(new User(
rs.getInt("id"),
rs.getString("username"),
rs.getString("password_hash"),
rs.getTimestamp("created_at"),
rs.getTimestamp("updated_at")
));
}
}
}
} catch (SQLException exception) {
LOGGER.error("Failed to get user: {}", username, exception);
}
return Optional.empty();
}
/**
* Get all users.
*/
public List<User> getAllUsers() {
List<User> users = new ArrayList<>();
try (Connection connection = openConnection()) {
String sql = "SELECT id, username, password_hash, created_at, updated_at FROM users ORDER BY username";
try (Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
while (rs.next()) {
users.add(new User(
rs.getInt("id"),
rs.getString("username"),
rs.getString("password_hash"),
rs.getTimestamp("created_at"),
rs.getTimestamp("updated_at")
));
}
}
} catch (SQLException exception) {
LOGGER.error("Failed to get all users", exception);
}
return users;
}
/**
* Verify password for a user.
*/
public boolean verifyPassword(String username, String password) {
Optional<User> user = getUser(username);
if (user.isEmpty()) {
return false;
}
return hashPassword(password).equals(user.get().getPasswordHash());
}
/**
* Check if a user exists.
*/
public boolean userExists(String username) {
return getUser(username).isPresent();
}
private Connection openConnection() throws SQLException {
return DriverManager.getConnection(jdbcUrl);
}
private String hashPassword(String password) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(password.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 not available", e);
}
}
/**
* Data class representing a user.
*/
public static final class User {
private final int id;
private final String username;
private final String passwordHash;
private final Timestamp createdAt;
private final Timestamp updatedAt;
User(int id, String username, String passwordHash, Timestamp createdAt, Timestamp updatedAt) {
this.id = id;
this.username = username;
this.passwordHash = passwordHash;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
public int getId() {
return id;
}
public String getUsername() {
return username;
}
public String getPasswordHash() {
return passwordHash;
}
public Timestamp getCreatedAt() {
return createdAt;
}
public Timestamp getUpdatedAt() {
return updatedAt;
}
@Override
public String toString() {
return "User{" +
"id=" + id +
", username='" + username + '\'' +
", createdAt=" + createdAt +
", updatedAt=" + updatedAt +
'}';
}
}
}

View File

@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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 {

View File

@ -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;

View File

@ -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;

View File

@ -8,12 +8,40 @@
</head>
<body>
<div class="bg-glow"></div>
<!-- Login Modal -->
<div id="login-modal" class="login-modal active">
<div class="login-container">
<div class="login-header">
<p class="eyebrow">Selfhosted IPTV</p>
<h1>Xtream HTML5 Player</h1>
</div>
<form id="login-form" class="login-form">
<div class="form-group">
<label for="login-username">Username</label>
<input type="text" id="login-username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="login-password">Password</label>
<input type="password" id="login-password" name="password" required>
</div>
<div id="login-message" class="message"></div>
<button type="submit" class="login-button">Login</button>
</form>
</div>
</div>
<!-- Main Application -->
<div id="app-container" class="hidden">
<header class="app-header">
<div>
<p class="eyebrow">Selfhosted IPTV</p>
<h1>Xtream HTML5 Player</h1>
</div>
<div id="global-status" class="status-chip">Not configured</div>
<div class="header-controls">
<div id="global-status" class="status-chip">Not configured</div>
<button id="logout-btn" class="logout-button" title="Logout">Logout</button>
</div>
</header>
<nav class="tabs" id="tabs">
@ -218,6 +246,7 @@
</section>
</aside>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js"></script>
<script src="/assets/app.20260304b.js"></script>