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)
|
# Xtream Player (Java + com.sun.httpserver)
|
||||||
|
|
||||||
Self-hosted HTML5 app for Xtream IPTV:
|
Self-hosted HTML5 app for Xtream IPTV:
|
||||||
|
- **User authentication** - Built-in login with username/password (stored in H2 database)
|
||||||
- provider settings/login
|
- provider settings/login
|
||||||
- manual source preload (button `Load sources`) with progress bar
|
- 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
|
- 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:
|
The app runs on:
|
||||||
- `http://localhost:8080`
|
- `http://localhost:8080`
|
||||||
|
|
||||||
|
Default login credentials (first run):
|
||||||
|
- **Username**: `admin`
|
||||||
|
- **Password**: `admin`
|
||||||
|
|
||||||
Optional: change port:
|
Optional: change port:
|
||||||
```bash
|
```bash
|
||||||
PORT=8090 mvn -q compile exec:java
|
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)
|
## Docker (JRE 17)
|
||||||
Build image:
|
Build image:
|
||||||
```bash
|
```bash
|
||||||
@ -42,6 +70,7 @@ docker run --rm -p 8080:8080 \
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- Config is stored in `~/.xtream-player/config.properties`.
|
- 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`.
|
- Preloaded sources (live/vod/series categories + lists) are stored in `~/.xtream-player/library/xtream-sources.mv.db`.
|
||||||
- Custom streams are stored in browser `localStorage`.
|
- 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.
|
- 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();
|
libraryRepository.initialize();
|
||||||
XtreamLibraryService libraryService = new XtreamLibraryService(configStore, libraryRepository);
|
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);
|
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||||
server.createContext("/api/config", new ConfigHandler(configStore));
|
server.createContext("/api/auth/login", new LoginHandler(userAuthenticator));
|
||||||
server.createContext("/api/test-login", new TestLoginHandler(configStore));
|
server.createContext("/api/auth/logout", new LogoutHandler(userAuthenticator));
|
||||||
server.createContext("/api/xtream", new XtreamProxyHandler(configStore));
|
server.createContext("/api/config", new ConfigHandler(configStore, userAuthenticator));
|
||||||
server.createContext("/api/stream-url", new StreamUrlHandler(configStore));
|
server.createContext("/api/test-login", new TestLoginHandler(configStore, userAuthenticator));
|
||||||
server.createContext("/api/stream-proxy", new StreamProxyHandler());
|
server.createContext("/api/xtream", new XtreamProxyHandler(configStore, userAuthenticator));
|
||||||
server.createContext("/api/open-in-player", new OpenInPlayerHandler(configStore));
|
server.createContext("/api/stream-url", new StreamUrlHandler(configStore, userAuthenticator));
|
||||||
server.createContext("/api/library/load", new LibraryLoadHandler(libraryService));
|
server.createContext("/api/stream-proxy", new StreamProxyHandler(userAuthenticator));
|
||||||
server.createContext("/api/library/status", new LibraryStatusHandler(libraryService));
|
server.createContext("/api/open-in-player", new OpenInPlayerHandler(configStore, userAuthenticator));
|
||||||
server.createContext("/api/library/categories", new LibraryCategoriesHandler(libraryService));
|
server.createContext("/api/library/load", new LibraryLoadHandler(libraryService, userAuthenticator));
|
||||||
server.createContext("/api/library/items", new LibraryItemsHandler(libraryService));
|
server.createContext("/api/library/status", new LibraryStatusHandler(libraryService, userAuthenticator));
|
||||||
server.createContext("/api/library/search", new LibrarySearchHandler(libraryService));
|
server.createContext("/api/library/categories", new LibraryCategoriesHandler(libraryService, userAuthenticator));
|
||||||
server.createContext("/api/library/series-episodes", new LibrarySeriesEpisodesHandler(libraryService));
|
server.createContext("/api/library/items", new LibraryItemsHandler(libraryService, userAuthenticator));
|
||||||
server.createContext("/api/library/epg", new LibraryEpgHandler(libraryService));
|
server.createContext("/api/library/search", new LibrarySearchHandler(libraryService, userAuthenticator));
|
||||||
server.createContext("/api/favorites", new FavoritesHandler(libraryService));
|
server.createContext("/api/library/series-episodes", new LibrarySeriesEpisodesHandler(libraryService, userAuthenticator));
|
||||||
server.createContext("/", new StaticHandler());
|
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.setExecutor(Executors.newFixedThreadPool(12));
|
||||||
server.start();
|
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
|
@Override
|
||||||
public void handle(HttpExchange exchange) throws IOException {
|
public void handle(HttpExchange exchange) throws IOException {
|
||||||
try {
|
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();
|
String method = exchange.getRequestMethod();
|
||||||
if ("GET".equalsIgnoreCase(method)) {
|
if ("GET".equalsIgnoreCase(method)) {
|
||||||
logApiRequest(exchange, "/api/config", Map.of());
|
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
|
@Override
|
||||||
public void handle(HttpExchange exchange) throws IOException {
|
public void handle(HttpExchange exchange) throws IOException {
|
||||||
|
if (!requireAuth(exchange, userAuthenticator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
logApiRequest(exchange, "/api/test-login", parseKeyValue(exchange.getRequestURI().getRawQuery()));
|
logApiRequest(exchange, "/api/test-login", parseKeyValue(exchange.getRequestURI().getRawQuery()));
|
||||||
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
|
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
|
||||||
methodNotAllowed(exchange, "GET");
|
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
|
@Override
|
||||||
public void handle(HttpExchange exchange) throws IOException {
|
public void handle(HttpExchange exchange) throws IOException {
|
||||||
|
if (!requireAuth(exchange, userAuthenticator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Map<String, String> incoming = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
Map<String, String> incoming = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
||||||
logApiRequest(exchange, "/api/xtream", incoming);
|
logApiRequest(exchange, "/api/xtream", incoming);
|
||||||
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
|
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
|
@Override
|
||||||
public void handle(HttpExchange exchange) throws IOException {
|
public void handle(HttpExchange exchange) throws IOException {
|
||||||
|
if (!requireAuth(exchange, userAuthenticator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
||||||
logApiRequest(exchange, "/api/stream-url", query);
|
logApiRequest(exchange, "/api/stream-url", query);
|
||||||
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
|
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
|
@Override
|
||||||
public void handle(HttpExchange exchange) throws IOException {
|
public void handle(HttpExchange exchange) throws IOException {
|
||||||
|
if (!requireAuth(exchange, userAuthenticator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
||||||
logApiRequest(exchange, "/api/stream-proxy", query);
|
logApiRequest(exchange, "/api/stream-proxy", query);
|
||||||
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
|
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
|
@Override
|
||||||
public void handle(HttpExchange exchange) throws IOException {
|
public void handle(HttpExchange exchange) throws IOException {
|
||||||
|
if (!requireAuth(exchange, userAuthenticator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
||||||
logApiRequest(exchange, "/api/open-in-player", query);
|
logApiRequest(exchange, "/api/open-in-player", query);
|
||||||
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
|
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
|
@Override
|
||||||
public void handle(HttpExchange exchange) throws IOException {
|
public void handle(HttpExchange exchange) throws IOException {
|
||||||
|
if (!requireAuth(exchange, userAuthenticator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
||||||
logApiRequest(exchange, "/api/library/load", query);
|
logApiRequest(exchange, "/api/library/load", query);
|
||||||
if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
|
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
|
@Override
|
||||||
public void handle(HttpExchange exchange) throws IOException {
|
public void handle(HttpExchange exchange) throws IOException {
|
||||||
|
if (!requireAuth(exchange, userAuthenticator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
logApiRequest(exchange, "/api/library/status", parseKeyValue(exchange.getRequestURI().getRawQuery()));
|
logApiRequest(exchange, "/api/library/status", parseKeyValue(exchange.getRequestURI().getRawQuery()));
|
||||||
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
|
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
|
||||||
methodNotAllowed(exchange, "GET");
|
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
|
@Override
|
||||||
public void handle(HttpExchange exchange) throws IOException {
|
public void handle(HttpExchange exchange) throws IOException {
|
||||||
|
if (!requireAuth(exchange, userAuthenticator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
||||||
logApiRequest(exchange, "/api/library/categories", query);
|
logApiRequest(exchange, "/api/library/categories", query);
|
||||||
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
|
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
|
@Override
|
||||||
public void handle(HttpExchange exchange) throws IOException {
|
public void handle(HttpExchange exchange) throws IOException {
|
||||||
|
if (!requireAuth(exchange, userAuthenticator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
||||||
logApiRequest(exchange, "/api/library/items", query);
|
logApiRequest(exchange, "/api/library/items", query);
|
||||||
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
|
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
|
@Override
|
||||||
public void handle(HttpExchange exchange) throws IOException {
|
public void handle(HttpExchange exchange) throws IOException {
|
||||||
|
if (!requireAuth(exchange, userAuthenticator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
||||||
logApiRequest(exchange, "/api/library/search", query);
|
logApiRequest(exchange, "/api/library/search", query);
|
||||||
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
|
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
|
@Override
|
||||||
public void handle(HttpExchange exchange) throws IOException {
|
public void handle(HttpExchange exchange) throws IOException {
|
||||||
|
if (!requireAuth(exchange, userAuthenticator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
||||||
logApiRequest(exchange, "/api/library/series-episodes", query);
|
logApiRequest(exchange, "/api/library/series-episodes", query);
|
||||||
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
|
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
|
@Override
|
||||||
public void handle(HttpExchange exchange) throws IOException {
|
public void handle(HttpExchange exchange) throws IOException {
|
||||||
|
if (!requireAuth(exchange, userAuthenticator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
||||||
logApiRequest(exchange, "/api/library/epg", query);
|
logApiRequest(exchange, "/api/library/epg", query);
|
||||||
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
|
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
|
@Override
|
||||||
public void handle(HttpExchange exchange) throws IOException {
|
public void handle(HttpExchange exchange) throws IOException {
|
||||||
|
if (!requireAuth(exchange, userAuthenticator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
Map<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
|
||||||
String method = exchange.getRequestMethod();
|
String method = exchange.getRequestMethod();
|
||||||
logApiRequest(exchange, "/api/favorites", query);
|
logApiRequest(exchange, "/api/favorites", query);
|
||||||
@ -654,29 +776,37 @@ public final class XtreamPlayerApplication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static final class StaticHandler implements HttpHandler {
|
private static final class StaticHandler implements HttpHandler {
|
||||||
|
StaticHandler(UserAuthenticator userAuthenticator) {
|
||||||
|
// Static handler allows unauthenticated access to public files
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void handle(HttpExchange exchange) throws IOException {
|
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");
|
methodNotAllowed(exchange, "GET");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
serveStaticFile(exchange, "/web/index.html");
|
||||||
|
}
|
||||||
|
|
||||||
String path = exchange.getRequestURI().getPath();
|
private void serveStaticAsset(HttpExchange exchange, String path) throws IOException {
|
||||||
String resourcePath;
|
String resourcePath = "/web" + normalizeAssetPath(path);
|
||||||
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resourcePath.contains("..")) {
|
if (resourcePath.contains("..")) {
|
||||||
writeJson(exchange, 400, errorJson("Invalid path"));
|
writeJson(exchange, 400, errorJson("Invalid path"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try (InputStream inputStream = XtreamPlayerApplication.class.getResourceAsStream(resourcePath)) {
|
try (InputStream inputStream = XtreamPlayerApplication.class.getResourceAsStream(resourcePath)) {
|
||||||
if (inputStream == null) {
|
if (inputStream == null) {
|
||||||
writeJson(exchange, 404, errorJson("Not found"));
|
writeJson(exchange, 404, errorJson("Not found"));
|
||||||
@ -694,6 +824,48 @@ public final class XtreamPlayerApplication {
|
|||||||
exchange.close();
|
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 {
|
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 hlsInstance = null;
|
||||||
let embeddedSubtitleScanTimer = null;
|
let embeddedSubtitleScanTimer = null;
|
||||||
let hlsSubtitleTracks = [];
|
let hlsSubtitleTracks = [];
|
||||||
@ -104,9 +220,17 @@
|
|||||||
epgList: document.getElementById("epg-list")
|
epgList: document.getElementById("epg-list")
|
||||||
};
|
};
|
||||||
|
|
||||||
init().catch((error) => {
|
// Initialize app only if authenticated
|
||||||
setSettingsMessage(error.message || String(error), "err");
|
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() {
|
async function init() {
|
||||||
bindTabs();
|
bindTabs();
|
||||||
@ -3001,6 +3125,11 @@
|
|||||||
if (!headers.has("Pragma")) {
|
if (!headers.has("Pragma")) {
|
||||||
headers.set("Pragma", "no-cache");
|
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;
|
fetchOptions.headers = headers;
|
||||||
|
|
||||||
const response = await fetch(url, fetchOptions);
|
const response = await fetch(url, fetchOptions);
|
||||||
@ -3012,6 +3141,11 @@
|
|||||||
parsed = {raw: text};
|
parsed = {raw: text};
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
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}`);
|
throw new Error(parsed.error || parsed.raw || `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
|
|||||||
@ -499,6 +499,152 @@ progress::-moz-progress-bar {
|
|||||||
color: var(--danger);
|
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 {
|
@keyframes rise {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@ -8,12 +8,40 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="bg-glow"></div>
|
<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">
|
<header class="app-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Selfhosted IPTV</p>
|
<p class="eyebrow">Selfhosted IPTV</p>
|
||||||
<h1>Xtream HTML5 Player</h1>
|
<h1>Xtream HTML5 Player</h1>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
<nav class="tabs" id="tabs">
|
<nav class="tabs" id="tabs">
|
||||||
@ -218,6 +246,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js"></script>
|
||||||
<script src="/assets/app.20260304b.js"></script>
|
<script src="/assets/app.20260304b.js"></script>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user