391 lines
14 KiB
JavaScript
391 lines
14 KiB
JavaScript
|
|
const sqlite3 = require('sqlite3').verbose();
|
||
|
|
const path = require('path');
|
||
|
|
const fs = require('fs');
|
||
|
|
const logger = require('../utils/logger');
|
||
|
|
|
||
|
|
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../../data/streamflow.db');
|
||
|
|
|
||
|
|
// Ensure data directory exists
|
||
|
|
const dataDir = path.dirname(DB_PATH);
|
||
|
|
if (!fs.existsSync(dataDir)) {
|
||
|
|
fs.mkdirSync(dataDir, { recursive: true, mode: 0o755 });
|
||
|
|
}
|
||
|
|
|
||
|
|
const db = new sqlite3.Database(DB_PATH, (err) => {
|
||
|
|
if (err) {
|
||
|
|
logger.error('Database connection error:', err);
|
||
|
|
} else {
|
||
|
|
logger.info('Connected to SQLite database');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
const initialize = () => {
|
||
|
|
// Initialize RBAC roles first (must be before user creation)
|
||
|
|
const { initializeRoles } = require('../middleware/rbac');
|
||
|
|
initializeRoles();
|
||
|
|
db.serialize(() => {
|
||
|
|
// Users table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS users (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
username TEXT UNIQUE NOT NULL,
|
||
|
|
email TEXT UNIQUE NOT NULL,
|
||
|
|
password TEXT NOT NULL,
|
||
|
|
role TEXT DEFAULT 'user',
|
||
|
|
must_change_password BOOLEAN DEFAULT 0,
|
||
|
|
is_active BOOLEAN DEFAULT 1,
|
||
|
|
two_factor_enabled BOOLEAN DEFAULT 0,
|
||
|
|
two_factor_secret TEXT,
|
||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
created_by INTEGER,
|
||
|
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// 2FA backup codes table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS two_factor_backup_codes (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
user_id INTEGER NOT NULL,
|
||
|
|
code TEXT NOT NULL,
|
||
|
|
used BOOLEAN DEFAULT 0,
|
||
|
|
used_at DATETIME,
|
||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// Password history table (prevent password reuse)
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS password_history (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
user_id INTEGER NOT NULL,
|
||
|
|
password_hash TEXT NOT NULL,
|
||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// Login attempts tracking table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS login_attempts (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
username TEXT NOT NULL,
|
||
|
|
ip_address TEXT NOT NULL,
|
||
|
|
user_agent TEXT,
|
||
|
|
success BOOLEAN NOT NULL,
|
||
|
|
failure_reason TEXT,
|
||
|
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// Create indexes for login_attempts
|
||
|
|
db.run('CREATE INDEX IF NOT EXISTS idx_username_timestamp ON login_attempts(username, timestamp)');
|
||
|
|
db.run('CREATE INDEX IF NOT EXISTS idx_ip_timestamp ON login_attempts(ip_address, timestamp)');
|
||
|
|
|
||
|
|
// Account lockouts table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS account_lockouts (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
user_id INTEGER NOT NULL,
|
||
|
|
locked_until DATETIME NOT NULL,
|
||
|
|
locked_by TEXT DEFAULT 'system',
|
||
|
|
reason TEXT,
|
||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// Active sessions table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS active_sessions (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
user_id INTEGER NOT NULL,
|
||
|
|
session_token TEXT UNIQUE NOT NULL,
|
||
|
|
ip_address TEXT,
|
||
|
|
user_agent TEXT,
|
||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
last_activity DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
expires_at DATETIME NOT NULL,
|
||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// Security audit log table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS security_audit_log (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
event_type TEXT NOT NULL,
|
||
|
|
user_id INTEGER,
|
||
|
|
ip_address TEXT,
|
||
|
|
user_agent TEXT,
|
||
|
|
success BOOLEAN NOT NULL,
|
||
|
|
failure_reason TEXT,
|
||
|
|
metadata TEXT,
|
||
|
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// Create indexes for security_audit_log
|
||
|
|
db.run('CREATE INDEX IF NOT EXISTS idx_event_timestamp ON security_audit_log(event_type, timestamp)');
|
||
|
|
db.run('CREATE INDEX IF NOT EXISTS idx_user_timestamp ON security_audit_log(user_id, timestamp)');
|
||
|
|
|
||
|
|
// Add new columns if they don't exist (migration)
|
||
|
|
db.all("PRAGMA table_info(users)", [], (err, columns) => {
|
||
|
|
if (!err) {
|
||
|
|
if (!columns.some(col => col.name === 'must_change_password')) {
|
||
|
|
db.run("ALTER TABLE users ADD COLUMN must_change_password BOOLEAN DEFAULT 0");
|
||
|
|
}
|
||
|
|
if (!columns.some(col => col.name === 'is_active')) {
|
||
|
|
db.run("ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT 1");
|
||
|
|
}
|
||
|
|
if (!columns.some(col => col.name === 'created_by')) {
|
||
|
|
db.run("ALTER TABLE users ADD COLUMN created_by INTEGER REFERENCES users(id) ON DELETE SET NULL");
|
||
|
|
}
|
||
|
|
if (!columns.some(col => col.name === 'two_factor_enabled')) {
|
||
|
|
db.run("ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN DEFAULT 0");
|
||
|
|
}
|
||
|
|
if (!columns.some(col => col.name === 'two_factor_secret')) {
|
||
|
|
db.run("ALTER TABLE users ADD COLUMN two_factor_secret TEXT");
|
||
|
|
}
|
||
|
|
if (!columns.some(col => col.name === 'password_changed_at')) {
|
||
|
|
db.run("ALTER TABLE users ADD COLUMN password_changed_at DATETIME DEFAULT CURRENT_TIMESTAMP");
|
||
|
|
}
|
||
|
|
if (!columns.some(col => col.name === 'password_expires_at')) {
|
||
|
|
db.run("ALTER TABLE users ADD COLUMN password_expires_at DATETIME");
|
||
|
|
}
|
||
|
|
if (!columns.some(col => col.name === 'failed_login_attempts')) {
|
||
|
|
db.run("ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER DEFAULT 0");
|
||
|
|
}
|
||
|
|
if (!columns.some(col => col.name === 'last_failed_login')) {
|
||
|
|
db.run("ALTER TABLE users ADD COLUMN last_failed_login DATETIME");
|
||
|
|
}
|
||
|
|
if (!columns.some(col => col.name === 'locked_until')) {
|
||
|
|
db.run("ALTER TABLE users ADD COLUMN locked_until DATETIME");
|
||
|
|
}
|
||
|
|
if (!columns.some(col => col.name === 'last_login_at')) {
|
||
|
|
db.run("ALTER TABLE users ADD COLUMN last_login_at DATETIME");
|
||
|
|
}
|
||
|
|
if (!columns.some(col => col.name === 'last_login_ip')) {
|
||
|
|
db.run("ALTER TABLE users ADD COLUMN last_login_ip TEXT");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Create default admin user if no users exist
|
||
|
|
db.get("SELECT COUNT(*) as count FROM users", [], (err, result) => {
|
||
|
|
if (!err && result.count === 0) {
|
||
|
|
const bcrypt = require('bcrypt');
|
||
|
|
const defaultPassword = bcrypt.hashSync('admin', 10);
|
||
|
|
|
||
|
|
db.run(
|
||
|
|
`INSERT INTO users (username, email, password, role, must_change_password)
|
||
|
|
VALUES (?, ?, ?, ?, ?)`,
|
||
|
|
['admin', 'admin@streamflow.local', defaultPassword, 'admin', 1],
|
||
|
|
(err) => {
|
||
|
|
if (err) {
|
||
|
|
console.error('Failed to create default admin:', err);
|
||
|
|
} else {
|
||
|
|
// CWE-532: Never log passwords - even default ones
|
||
|
|
console.log('✓ Default admin user created (username: admin)');
|
||
|
|
console.log('⚠ SECURITY: Change the default admin password immediately!');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Profiles table (multi-user support)
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS profiles (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
user_id INTEGER NOT NULL,
|
||
|
|
name TEXT NOT NULL,
|
||
|
|
avatar TEXT,
|
||
|
|
is_child BOOLEAN DEFAULT 0,
|
||
|
|
age_restriction INTEGER DEFAULT 18,
|
||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// Playlists table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS playlists (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
user_id INTEGER NOT NULL,
|
||
|
|
name TEXT NOT NULL,
|
||
|
|
url TEXT,
|
||
|
|
type TEXT DEFAULT 'live',
|
||
|
|
filename TEXT,
|
||
|
|
category TEXT,
|
||
|
|
is_active BOOLEAN DEFAULT 1,
|
||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// Channels table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS channels (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
playlist_id INTEGER NOT NULL,
|
||
|
|
name TEXT NOT NULL,
|
||
|
|
url TEXT NOT NULL,
|
||
|
|
logo TEXT,
|
||
|
|
custom_logo TEXT,
|
||
|
|
group_name TEXT,
|
||
|
|
tvg_id TEXT,
|
||
|
|
tvg_name TEXT,
|
||
|
|
language TEXT,
|
||
|
|
country TEXT,
|
||
|
|
is_radio BOOLEAN DEFAULT 0,
|
||
|
|
is_active BOOLEAN DEFAULT 1,
|
||
|
|
health_status TEXT DEFAULT 'unknown',
|
||
|
|
last_checked DATETIME,
|
||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// Add custom_logo column if it doesn't exist (migration)
|
||
|
|
db.all("PRAGMA table_info(channels)", [], (err, columns) => {
|
||
|
|
if (!err && !columns.some(col => col.name === 'custom_logo')) {
|
||
|
|
db.run("ALTER TABLE channels ADD COLUMN custom_logo TEXT", (err) => {
|
||
|
|
if (err) console.error('Migration error:', err);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Custom groups table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS custom_groups (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
user_id INTEGER NOT NULL,
|
||
|
|
name TEXT NOT NULL,
|
||
|
|
icon TEXT,
|
||
|
|
order_index INTEGER DEFAULT 0,
|
||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// Group channels (many-to-many)
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS group_channels (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
group_id INTEGER NOT NULL,
|
||
|
|
channel_id INTEGER NOT NULL,
|
||
|
|
order_index INTEGER DEFAULT 0,
|
||
|
|
FOREIGN KEY (group_id) REFERENCES custom_groups(id) ON DELETE CASCADE,
|
||
|
|
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
|
||
|
|
UNIQUE(group_id, channel_id)
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// Recordings table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS recordings (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
user_id INTEGER NOT NULL,
|
||
|
|
profile_id INTEGER,
|
||
|
|
channel_id INTEGER NOT NULL,
|
||
|
|
title TEXT NOT NULL,
|
||
|
|
description TEXT,
|
||
|
|
start_time DATETIME NOT NULL,
|
||
|
|
end_time DATETIME NOT NULL,
|
||
|
|
duration INTEGER,
|
||
|
|
file_path TEXT,
|
||
|
|
file_size INTEGER,
|
||
|
|
status TEXT DEFAULT 'scheduled',
|
||
|
|
is_series BOOLEAN DEFAULT 0,
|
||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
|
|
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE SET NULL,
|
||
|
|
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// Watch history table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS watch_history (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
user_id INTEGER NOT NULL,
|
||
|
|
profile_id INTEGER,
|
||
|
|
channel_id INTEGER NOT NULL,
|
||
|
|
watched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
duration INTEGER,
|
||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
|
|
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE SET NULL,
|
||
|
|
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// Favorites table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS favorites (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
user_id INTEGER NOT NULL,
|
||
|
|
profile_id INTEGER,
|
||
|
|
channel_id INTEGER NOT NULL,
|
||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
|
|
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE SET NULL,
|
||
|
|
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
|
||
|
|
UNIQUE(user_id, profile_id, channel_id)
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// Settings table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS settings (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
user_id INTEGER NOT NULL,
|
||
|
|
key TEXT NOT NULL,
|
||
|
|
value TEXT,
|
||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
|
|
UNIQUE(user_id, key)
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// API tokens table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS api_tokens (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
user_id INTEGER NOT NULL,
|
||
|
|
token TEXT UNIQUE NOT NULL,
|
||
|
|
name TEXT,
|
||
|
|
expires_at DATETIME,
|
||
|
|
usage_count INTEGER DEFAULT 0,
|
||
|
|
usage_limit INTEGER,
|
||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// Channel logos cache table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS logo_cache (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
channel_name TEXT UNIQUE NOT NULL,
|
||
|
|
logo_url TEXT,
|
||
|
|
local_path TEXT,
|
||
|
|
last_updated DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// M3U files library table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS m3u_files (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
user_id INTEGER NOT NULL,
|
||
|
|
name TEXT NOT NULL,
|
||
|
|
original_filename TEXT NOT NULL,
|
||
|
|
file_path TEXT NOT NULL,
|
||
|
|
size INTEGER NOT NULL,
|
||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// Create VPN configs table for multiple configuration files
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS vpn_configs (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
user_id INTEGER NOT NULL,
|
||
|
|
name TEXT NOT NULL,
|
||
|
|
config_type TEXT NOT NULL CHECK(config_type IN ('openvpn', 'wireguard')),
|
||
|
|
config_data TEXT NOT NULL,
|
||
|
|
country TEXT,
|
||
|
|
server_name TEXT,
|
||
|
|
endpoint TEXT,
|
||
|
|
is_active BOOLEAN DEFAULT 0,
|
||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
|
|
)`);
|
||
|
|
|
||
|
|
// Create indexes for performance
|
||
|
|
db.run('CREATE INDEX IF NOT EXISTS idx_channels_playlist ON channels(playlist_id)');
|
||
|
|
db.run('CREATE INDEX IF NOT EXISTS idx_channels_tvg_id ON channels(tvg_id)');
|
||
|
|
db.run('CREATE INDEX IF NOT EXISTS idx_watch_history_user ON watch_history(user_id, profile_id)');
|
||
|
|
db.run('CREATE INDEX IF NOT EXISTS idx_favorites_user ON favorites(user_id, profile_id)');
|
||
|
|
db.run('CREATE INDEX IF NOT EXISTS idx_recordings_user ON recordings(user_id, profile_id)');
|
||
|
|
db.run('CREATE INDEX IF NOT EXISTS idx_m3u_files_user ON m3u_files(user_id)');
|
||
|
|
|
||
|
|
logger.info('Database tables initialized');
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
db,
|
||
|
|
initialize
|
||
|
|
};
|