streamflow/backend/database/db.js

407 lines
15 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) {
console.error('Error checking user count:', err);
return;
}
console.log(`Database initialized. Current users: ${result.count}`);
if (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('');
console.log('═══════════════════════════════════════════════════════');
console.log('✅ Default admin user created successfully!');
console.log('');
console.log(' Username: admin');
console.log(' Password: admin');
console.log('');
console.log('⚠️ SECURITY WARNING: Change this password immediately!');
console.log('═══════════════════════════════════════════════════════');
console.log('');
}
}
);
} else {
console.log(' Users already exist. Skipping default admin creation.');
}
});
// 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
};