// import { UserDataSchema, UserData, DB } from '../db'; import { UserDataSchema, UserData } from './schemas'; import { TransactionQueue } from './queue'; import { DB } from './db'; import { decryptString, deriveKey, encryptString, generateUUID, getTextHash, maskSensitiveInfo, createLogger, constants, Env, verifyHash, validateConfig, formatZodError, } from '../utils'; const APIError = constants.APIError; const logger = createLogger('users'); const db = DB.getInstance(); const txQueue = TransactionQueue.getInstance(); export class UserRepository { static async createUser( config: UserData, password: string ): Promise<{ uuid: string; encryptedPassword: string }> { return txQueue.enqueue(async () => { if (password.length < 6) { return Promise.reject( new APIError(constants.ErrorCode.USER_NEW_PASSWORD_TOO_SHORT) ); } let validatedConfig: UserData; if (Env.ADDON_PASSWORD && config.addonPassword !== Env.ADDON_PASSWORD) { return Promise.reject( new APIError(constants.ErrorCode.USER_INVALID_PASSWORD) ); } config.trusted = false; try { // don't skip errors, but don't decrypt credentials // as we need to store the encrypted version validatedConfig = await validateConfig(config, false, false); } catch (error: any) { logger.error(`Invalid config for new user: ${error.message}`); return Promise.reject( new APIError( constants.ErrorCode.USER_INVALID_CONFIG, undefined, error.message ) ); } const uuid = await this.generateUUID(); const { encryptedConfig, salt: configSalt } = await this.encryptConfig( validatedConfig, password ); const hashedPassword = await getTextHash(password); const { success, data } = encryptString(password); if (success === false) { return Promise.reject(constants.ErrorCode.USER_ERROR); } const encryptedPassword = data; let tx; let committed = false; try { tx = await db.begin(); await tx.execute( 'INSERT INTO users (uuid, password_hash, config, config_salt) VALUES (?, ?, ?, ?)', [uuid, hashedPassword, encryptedConfig, configSalt] ); await tx.commit(); committed = true; logger.info(`Created a new user with UUID: ${uuid}`); return { uuid, encryptedPassword }; } catch (error) { logger.error( `Failed to create user: ${error instanceof Error ? error.message : String(error)}` ); if (error instanceof APIError) { throw error; } throw new APIError(constants.ErrorCode.INTERNAL_SERVER_ERROR); } finally { if (tx && !committed) { await tx.rollback(); } } }); } static async checkUserExists(uuid: string): Promise { try { const result = await db.query('SELECT uuid FROM users WHERE uuid = ?', [ uuid, ]); return result.length > 0; } catch (error) { logger.error(`Error checking user existence: ${error}`); return Promise.reject(constants.ErrorCode.USER_ERROR); } } // with stremio auth, we are given the encrypted password // with api use, we are given the password // GET /user should also return static async getUser( uuid: string, password: string ): Promise { try { const result = await db.query( 'SELECT config, config_salt, password_hash FROM users WHERE uuid = ?', [uuid] ); if (!result.length || !result[0].config) { return Promise.reject(new APIError(constants.ErrorCode.USER_NOT_FOUND)); } await db.execute( 'UPDATE users SET accessed_at = CURRENT_TIMESTAMP WHERE uuid = ?', [uuid] ); const isValid = await this.verifyUserPassword( password, result[0].password_hash ); if (!isValid) { return Promise.reject( new APIError(constants.ErrorCode.USER_INVALID_PASSWORD) ); } const decryptedConfig = await this.decryptConfig( result[0].config, password, result[0].config_salt ); // try { // // skip errors, and dont decrypt credentials either, as this would make // // encryption pointless // validatedConfig = await validateConfig(decryptedConfig, true, false); // } catch (error: any) { // return Promise.reject( // new APIError( // constants.ErrorCode.USER_INVALID_CONFIG, // undefined, // error.message // ) // ); // } // const { // success, // data: validatedConfig, // error, // } = UserDataSchema.safeParse(decryptedConfig); // if (!success) { // return Promise.reject( // new APIError( // constants.ErrorCode.USER_INVALID_CONFIG, // undefined, // formatZodError(error) // ) // ); // } decryptedConfig.trusted = Env.TRUSTED_UUIDS?.split(',').some((u) => new RegExp(u).test(uuid)) ?? false; logger.info(`Retrieved configuration for user ${uuid}`); return decryptedConfig; } catch (error) { logger.error( `Error retrieving user ${uuid}: ${error instanceof Error ? error.message : String(error)}` ); return Promise.reject( new APIError(constants.ErrorCode.INTERNAL_SERVER_ERROR) ); } } static async updateUser( uuid: string, password: string, config: UserData ): Promise { return txQueue.enqueue(async () => { let tx; let committed = false; try { tx = await db.begin(); const currentUser = await tx.execute( 'SELECT config_salt, password_hash FROM users WHERE uuid = ?', [uuid] ); if (!currentUser.rows.length) { throw new APIError(constants.ErrorCode.USER_NOT_FOUND); } if (Env.ADDON_PASSWORD && config.addonPassword !== Env.ADDON_PASSWORD) { throw new APIError( constants.ErrorCode.USER_INVALID_PASSWORD, undefined, 'Invalid password' ); } let validatedConfig: UserData; try { validatedConfig = await validateConfig(config, false, false); } catch (error: any) { throw new APIError( constants.ErrorCode.USER_INVALID_CONFIG, undefined, error.message ); } const storedHash = currentUser.rows[0].password_hash; const isValid = await this.verifyUserPassword(password, storedHash); if (!isValid) { throw new APIError(constants.ErrorCode.USER_INVALID_PASSWORD); } const { encryptedConfig } = await this.encryptConfig( validatedConfig, password, currentUser.rows[0].config_salt ); await tx.execute( 'UPDATE users SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE uuid = ?', [encryptedConfig, uuid] ); await tx.commit(); committed = true; logger.info(`Updated user ${uuid} with an updated configuration`); } catch (error) { logger.error( `Failed to update user ${uuid}: ${error instanceof Error ? error.message : String(error)}` ); if (error instanceof APIError) { throw error; } throw new APIError(constants.ErrorCode.INTERNAL_SERVER_ERROR); } finally { if (tx && !committed) { await tx.rollback(); } } }); } static async getUserCount(): Promise { try { const result = await db.query('SELECT * FROM users'); return result.length; } catch (error) { logger.error(`Error getting user count: ${error}`); return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); } } static async deleteUser(uuid: string): Promise { return txQueue.enqueue(async () => { let tx; let committed = false; try { tx = await db.begin(); const result = await tx.execute('DELETE FROM users WHERE uuid = ?', [ uuid, ]); if (result.rowCount === 0) { throw new APIError(constants.ErrorCode.USER_NOT_FOUND); } await tx.commit(); committed = true; logger.info(`Deleted user ${uuid}`); } catch (error) { logger.error( `Failed to delete user ${uuid}: ${error instanceof Error ? error.message : String(error)}` ); if (error instanceof APIError) { throw error; } throw new APIError(constants.ErrorCode.INTERNAL_SERVER_ERROR); } finally { if (tx && !committed) { await tx.rollback(); } } }); } static async pruneUsers(maxDays: number = 30): Promise { if (maxDays < 0) { return 0; } try { const query = db.getDialect() === 'postgres' ? `DELETE FROM users WHERE accessed_at < NOW() - INTERVAL '${maxDays} days'` : `DELETE FROM users WHERE accessed_at < datetime('now', '-' || ${maxDays} || ' days')`; const result = await db.execute(query); const deletedCount = result.changes || result.rowCount || 0; logger.info(`Pruned ${deletedCount} users older than ${maxDays} days`); return deletedCount; } catch (error) { logger.error('Failed to prune users:', error); return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); } } private static async verifyUserPassword( password: string, storedHash: string ): Promise { return verifyHash(password, storedHash); } private static async encryptConfig( config: UserData, password: string, salt?: string ): Promise<{ encryptedConfig: string; salt: string; }> { const { key, salt: saltUsed } = await deriveKey( `${password}:${Env.SECRET_KEY}`, salt ); const configString = JSON.stringify(config); const { success, data, error } = encryptString(configString, key); if (!success) { return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); } return { encryptedConfig: data, salt: saltUsed }; } private static async decryptConfig( encryptedConfig: string, password: string, salt: string ): Promise { const { key } = await deriveKey(`${password}:${Env.SECRET_KEY}`, salt); const { success, data: decryptedString, error, } = decryptString(encryptedConfig, key); if (!success || !decryptedString) { return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); } return JSON.parse(decryptedString); } private static async generateUUID(count: number = 1): Promise { if (count > 10) { return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); } const uuid = generateUUID(); const existingUser = await this.checkUserExists(uuid); if (existingUser) { return this.generateUUID(count + 1); } return uuid; } }