added login page
This commit is contained in:
parent
2f4c35a797
commit
01d961539c
29
README.md
29
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.
|
||||
|
||||
122
USERS_MANAGEMENT.md
Normal file
122
USERS_MANAGEMENT.md
Normal 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`
|
||||
79
src/main/java/cz/kamma/xtreamplayer/UserAuthenticator.java
Normal file
79
src/main/java/cz/kamma/xtreamplayer/UserAuthenticator.java
Normal 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);
|
||||
}
|
||||
}
|
||||
219
src/main/java/cz/kamma/xtreamplayer/UserManager.java
Normal file
219
src/main/java/cz/kamma/xtreamplayer/UserManager.java
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
273
src/main/java/cz/kamma/xtreamplayer/UserStore.java
Normal file
273
src/main/java/cz/kamma/xtreamplayer/UserStore.java
Normal 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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user