diff --git a/maxun-core/package.json b/maxun-core/package.json index d0211414..2f04d42a 100644 --- a/maxun-core/package.json +++ b/maxun-core/package.json @@ -1,6 +1,6 @@ { "name": "maxun-core", - "version": "0.0.11", + "version": "0.0.12", "description": "Core package for Maxun, responsible for data extraction", "main": "build/index.js", "typings": "build/index.d.ts", diff --git a/maxun-core/src/interpret.ts b/maxun-core/src/interpret.ts index 7fc4d17a..88047128 100644 --- a/maxun-core/src/interpret.ts +++ b/maxun-core/src/interpret.ts @@ -286,6 +286,12 @@ export default class Interpreter extends EventEmitter { ? arrayToObject(superset[key]) : superset[key]; + if (key === 'selectors' && Array.isArray(value) && Array.isArray(superset[key])) { + return value.some(selector => + (superset[key] as any[]).includes(selector) + ); + } + // Every `subset` key must exist in the `superset` and // have the same value (strict equality), or subset[key] <= superset[key] return parsedSuperset[key] diff --git a/package.json b/package.json index b238a679..0580d2de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maxun", - "version": "0.0.9", + "version": "0.0.10", "author": "Maxun", "license": "AGPL-3.0-or-later", "dependencies": { @@ -47,7 +47,7 @@ "lodash": "^4.17.21", "loglevel": "^1.8.0", "loglevel-plugin-remote": "^0.6.8", - "maxun-core": "^0.0.11", + "maxun-core": "^0.0.12", "minio": "^8.0.1", "moment-timezone": "^0.5.45", "node-cron": "^3.0.3", diff --git a/public/locales/de.json b/public/locales/de.json index a4c02b1d..07cf45bd 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -50,7 +50,7 @@ "new": "Roboter erstellen", "modal": { "title": "Geben Sie die URL ein", - "login_title": "Erfordert diese Seite eine Anmeldung?", + "login_title": "Ist für diese Website eine Anmeldung erforderlich?", "label": "URL", "button": "Aufnahme starten" }, @@ -139,12 +139,41 @@ "no_key_message": "Sie haben noch keinen API-Schlüssel generiert.", "generate_button": "API-Schlüssel generieren", "notifications": { - "fetch_error": "API-Schlüssel konnte nicht abgerufen werden - ${error}", - "generate_success": "API-Schlüssel erfolgreich generiert", - "generate_error": "API-Schlüssel konnte nicht generiert werden - ${error}", - "delete_success": "API-Schlüssel erfolgreich gelöscht", - "delete_error": "API-Schlüssel konnte nicht gelöscht werden - ${error}", - "copy_success": "API-Schlüssel erfolgreich kopiert" + "errors": { + "fetch": { + "network": "Netzwerkfehler beim Abrufen des API-Schlüssels: ${error}", + "unauthorized": "Sie müssen angemeldet sein, um auf den API-Schlüssel zuzugreifen", + "not_found": "API-Schlüssel für Ihr Konto wurde nicht gefunden", + "server": "Serverfehler beim Abrufen des API-Schlüssels. Bitte versuchen Sie es später erneut", + "unknown": "Unbekannter Fehler beim Abrufen des API-Schlüssels: ${error}" + }, + "generate": { + "network": "Netzwerkfehler bei der Generierung des API-Schlüssels: ${error}", + "unauthorized": "Sie müssen angemeldet sein, um einen API-Schlüssel zu generieren", + "key_exists": "Sie haben bereits einen API-Schlüssel. Bitte löschen Sie zuerst den vorhandenen", + "not_found": "Benutzerkonto nicht gefunden", + "server": "Serverfehler bei der Generierung des API-Schlüssels. Bitte versuchen Sie es später erneut", + "unknown": "Unbekannter Fehler bei der Generierung des API-Schlüssels: ${error}" + }, + "delete": { + "network": "Netzwerkfehler beim Löschen des API-Schlüssels: ${error}", + "unauthorized": "Sie müssen angemeldet sein, um den API-Schlüssel zu löschen", + "not_found": "Benutzerkonto nicht gefunden", + "key_not_found": "Kein API-Schlüssel zum Löschen gefunden", + "server": "Serverfehler beim Löschen des API-Schlüssels. Bitte versuchen Sie es später erneut", + "unknown": "Unbekannter Fehler beim Löschen des API-Schlüssels: ${error}" + }, + "copy": { + "failed": "Fehler beim Kopieren des API-Schlüssels in die Zwischenablage", + "no_key": "Kein API-Schlüssel zum Kopieren verfügbar" + } + }, + "success": { + "fetch": "API-Schlüssel erfolgreich abgerufen", + "generate": "Neuer API-Schlüssel erfolgreich generiert", + "delete": "API-Schlüssel erfolgreich gelöscht", + "copy": "API-Schlüssel in die Zwischenablage kopiert" + } } }, "action_description": { @@ -487,6 +516,19 @@ }, "navbar": { "project_name": "Maxun", + "notifications": { + "success": { + "logout": "Erfolgreich abgemeldet" + }, + "errors": { + "logout": { + "unauthorized": "Sie sind nicht berechtigt, diese Aktion durchzuführen", + "server": "Serverfehler beim Abmelden", + "network": "Netzwerkfehler beim Abmelden", + "unknown": "Ein unerwarteter Fehler ist beim Abmelden aufgetreten" + } + } + }, "upgrade": { "button": "Upgrade", "modal": { diff --git a/public/locales/en.json b/public/locales/en.json index f072dcc9..e847d832 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -20,7 +20,7 @@ } }, "register": { - "title": "Register Account", + "title": "Create An Account", "email": "Enter Work Email", "password": "Password", "button": "Register", @@ -50,7 +50,7 @@ "new":"Create Robot", "modal":{ "title":"Enter the URL", - "login_title": "Does this website require login?", + "login_title": "Does this website require logging in?", "label":"URL", "button":"Start Recording" }, @@ -140,12 +140,41 @@ "no_key_message": "You haven't generated an API key yet.", "generate_button": "Generate API Key", "notifications": { - "fetch_error": "Failed to fetch API Key - ${error}", - "generate_success": "Generated API Key successfully", - "generate_error": "Failed to generate API Key - ${error}", - "delete_success": "API Key deleted successfully", - "delete_error": "Failed to delete API Key - ${error}", - "copy_success": "Copied API Key successfully" + "errors": { + "fetch": { + "network": "Network error while fetching API key: ${error}", + "unauthorized": "You must be logged in to access API key", + "not_found": "Unable to find API key for your account", + "server": "Server error while fetching API key. Please try again later", + "unknown": "Unknown error occurred while fetching API key: ${error}" + }, + "generate": { + "network": "Network error while generating API key: ${error}", + "unauthorized": "You must be logged in to generate an API key", + "key_exists": "You already have an API key. Please delete the existing one first", + "not_found": "User account not found", + "server": "Server error while generating API key. Please try again later", + "unknown": "Unknown error occurred while generating API key: ${error}" + }, + "delete": { + "network": "Network error while deleting API key: ${error}", + "unauthorized": "You must be logged in to delete API key", + "not_found": "User account not found", + "key_not_found": "No API key found to delete", + "server": "Server error while deleting API key. Please try again later", + "unknown": "Unknown error occurred while deleting API key: ${error}" + }, + "copy": { + "failed": "Failed to copy API key to clipboard", + "no_key": "No API key available to copy" + } + }, + "success": { + "fetch": "API key retrieved successfully", + "generate": "New API key generated successfully", + "delete": "API key deleted successfully", + "copy": "API key copied to clipboard" + } } }, "action_description": { @@ -498,6 +527,19 @@ }, "navbar": { "project_name": "Maxun", + "notifications": { + "success": { + "logout": "Logged out successfully" + }, + "errors": { + "logout": { + "unauthorized": "You are not authorized to perform this action", + "server": "Server error occurred during logout", + "network": "Network error while logging out", + "unknown": "An unexpected error occurred during logout" + } + } + }, "upgrade": { "button": "Upgrade", "modal": { diff --git a/public/locales/es.json b/public/locales/es.json index 09f6d703..895ab9bd 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -50,7 +50,7 @@ "new": "Crear Robot", "modal": { "title": "Ingresa la URL", - "login_title": "¿Este sitio requiere inicio de sesión?", + "login_title": "¿Este sitio web requiere iniciar sesión?", "label": "URL", "button": "Comenzar grabación" }, @@ -140,12 +140,41 @@ "no_key_message": "Aún no has generado una clave API.", "generate_button": "Generar Clave API", "notifications": { - "fetch_error": "Error al obtener la clave API - ${error}", - "generate_success": "Clave API generada con éxito", - "generate_error": "Error al generar la clave API - ${error}", - "delete_success": "Clave API eliminada con éxito", - "delete_error": "Error al eliminar la clave API - ${error}", - "copy_success": "Clave API copiada con éxito" + "errors": { + "fetch": { + "network": "Error de red al obtener la clave API: ${error}", + "unauthorized": "Debes iniciar sesión para acceder a la clave API", + "not_found": "No se pudo encontrar la clave API para tu cuenta", + "server": "Error del servidor al obtener la clave API. Por favor, inténtalo más tarde", + "unknown": "Error desconocido al obtener la clave API: ${error}" + }, + "generate": { + "network": "Error de red al generar la clave API: ${error}", + "unauthorized": "Debes iniciar sesión para generar una clave API", + "key_exists": "Ya tienes una clave API. Por favor, elimina la existente primero", + "not_found": "Cuenta de usuario no encontrada", + "server": "Error del servidor al generar la clave API. Por favor, inténtalo más tarde", + "unknown": "Error desconocido al generar la clave API: ${error}" + }, + "delete": { + "network": "Error de red al eliminar la clave API: ${error}", + "unauthorized": "Debes iniciar sesión para eliminar la clave API", + "not_found": "Cuenta de usuario no encontrada", + "key_not_found": "No se encontró ninguna clave API para eliminar", + "server": "Error del servidor al eliminar la clave API. Por favor, inténtalo más tarde", + "unknown": "Error desconocido al eliminar la clave API: ${error}" + }, + "copy": { + "failed": "Error al copiar la clave API al portapapeles", + "no_key": "No hay clave API disponible para copiar" + } + }, + "success": { + "fetch": "Clave API obtenida exitosamente", + "generate": "Nueva clave API generada exitosamente", + "delete": "Clave API eliminada exitosamente", + "copy": "Clave API copiada al portapapeles" + } } }, "action_description": { @@ -488,6 +517,19 @@ }, "navbar": { "project_name": "Maxun", + "notifications": { + "success": { + "logout": "Sesión cerrada exitosamente" + }, + "errors": { + "logout": { + "unauthorized": "No estás autorizado para realizar esta acción", + "server": "Error del servidor durante el cierre de sesión", + "network": "Error de red al cerrar sesión", + "unknown": "Ocurrió un error inesperado al cerrar sesión" + } + } + }, "upgrade": { "button": "Actualizar", "modal": { diff --git a/public/locales/ja.json b/public/locales/ja.json index d2fe42ea..ee18c96c 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -140,12 +140,41 @@ "no_key_message": "APIキーはまだ生成されていません。", "generate_button": "APIキーを生成", "notifications": { - "fetch_error": "APIキーの取得に失敗しました - ${error}", - "generate_success": "APIキーが正常に生成されました", - "generate_error": "APIキーの生成に失敗しました - ${error}", - "delete_success": "APIキーが正常に削除されました", - "delete_error": "APIキーの削除に失敗しました - ${error}", - "copy_success": "APIキーがコピーされました" + "errors": { + "fetch": { + "network": "APIキーの取得中にネットワークエラーが発生しました:${error}", + "unauthorized": "APIキーにアクセスするにはログインが必要です", + "not_found": "アカウントのAPIキーが見つかりません", + "server": "APIキーの取得中にサーバーエラーが発生しました。後でもう一度お試しください", + "unknown": "APIキーの取得中に不明なエラーが発生しました:${error}" + }, + "generate": { + "network": "APIキーの生成中にネットワークエラーが発生しました:${error}", + "unauthorized": "APIキーを生成するにはログインが必要です", + "key_exists": "APIキーが既に存在します。既存のキーを先に削除してください", + "not_found": "ユーザーアカウントが見つかりません", + "server": "APIキーの生成中にサーバーエラーが発生しました。後でもう一度お試しください", + "unknown": "APIキーの生成中に不明なエラーが発生しました:${error}" + }, + "delete": { + "network": "APIキーの削除中にネットワークエラーが発生しました:${error}", + "unauthorized": "APIキーを削除するにはログインが必要です", + "not_found": "ユーザーアカウントが見つかりません", + "key_not_found": "削除するAPIキーが見つかりません", + "server": "APIキーの削除中にサーバーエラーが発生しました。後でもう一度お試しください", + "unknown": "APIキーの削除中に不明なエラーが発生しました:${error}" + }, + "copy": { + "failed": "APIキーのクリップボードへのコピーに失敗しました", + "no_key": "コピーできるAPIキーがありません" + } + }, + "success": { + "fetch": "APIキーの取得に成功しました", + "generate": "新しいAPIキーの生成に成功しました", + "delete": "APIキーの削除に成功しました", + "copy": "APIキーをクリップボードにコピーしました" + } } }, "action_description": { @@ -488,6 +517,19 @@ }, "navbar": { "project_name": "Maxun", + "notifications": { + "success": { + "logout": "ログアウトに成功しました" + }, + "errors": { + "logout": { + "unauthorized": "この操作を実行する権限がありません", + "server": "ログアウト中にサーバーエラーが発生しました", + "network": "ログアウト中にネットワークエラーが発生しました", + "unknown": "ログアウト中に予期せぬエラーが発生しました" + } + } + }, "upgrade": { "button": "アップグレード", "modal": { diff --git a/public/locales/zh.json b/public/locales/zh.json index e7c58660..5cec8268 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -140,12 +140,41 @@ "no_key_message": "您还未生成API密钥。", "generate_button": "生成API密钥", "notifications": { - "fetch_error": "获取API密钥失败 - ${error}", - "generate_success": "API密钥生成成功", - "generate_error": "生成API密钥失败 - ${error}", - "delete_success": "API密钥删除成功", - "delete_error": "删除API密钥失败 - ${error}", - "copy_success": "API密钥复制成功" + "errors": { + "fetch": { + "network": "获取API密钥时发生网络错误:${error}", + "unauthorized": "您必须登录才能访问API密钥", + "not_found": "找不到您账户的API密钥", + "server": "获取API密钥时发生服务器错误。请稍后重试", + "unknown": "获取API密钥时发生未知错误:${error}" + }, + "generate": { + "network": "生成API密钥时发生网络错误:${error}", + "unauthorized": "您必须登录才能生成API密钥", + "key_exists": "您已经有一个API密钥。请先删除现有的密钥", + "not_found": "找不到用户账户", + "server": "生成API密钥时发生服务器错误。请稍后重试", + "unknown": "生成API密钥时发生未知错误:${error}" + }, + "delete": { + "network": "删除API密钥时发生网络错误:${error}", + "unauthorized": "您必须登录才能删除API密钥", + "not_found": "找不到用户账户", + "key_not_found": "找不到要删除的API密钥", + "server": "删除API密钥时发生服务器错误。请稍后重试", + "unknown": "删除API密钥时发生未知错误:${error}" + }, + "copy": { + "failed": "复制API密钥到剪贴板失败", + "no_key": "没有可复制的API密钥" + } + }, + "success": { + "fetch": "成功获取API密钥", + "generate": "成功生成新的API密钥", + "delete": "成功删除API密钥", + "copy": "已将API密钥复制到剪贴板" + } } }, "action_description": { @@ -488,6 +517,19 @@ }, "navbar": { "project_name": "Maxun", + "notifications": { + "success": { + "logout": "退出登录成功" + }, + "errors": { + "logout": { + "unauthorized": "您没有执行此操作的权限", + "server": "退出登录时发生服务器错误", + "network": "退出登录时发生网络错误", + "unknown": "退出登录时发生未知错误" + } + } + }, "upgrade": { "button": "升级", "modal": { diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index b7d884a4..728cc42c 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -17,7 +17,6 @@ router.post("/register", async (req, res) => { try { const { email, password } = req.body; - // Validation checks with translation codes if (!email) { return res.status(400).json({ error: "VALIDATION_ERROR", @@ -32,7 +31,6 @@ router.post("/register", async (req, res) => { }); } - // Check if user exists let userExist = await User.findOne({ raw: true, where: { email } }); if (userExist) { return res.status(400).json({ @@ -43,7 +41,6 @@ router.post("/register", async (req, res) => { const hashedPassword = await hashPassword(password); - // Create user let user: any; try { user = await User.create({ email, password: hashedPassword }); @@ -55,7 +52,6 @@ router.post("/register", async (req, res) => { }); } - // Check JWT secret if (!process.env.JWT_SECRET) { console.log("JWT_SECRET is not defined in the environment"); return res.status(500).json({ @@ -64,7 +60,6 @@ router.post("/register", async (req, res) => { }); } - // Success path const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET as string); user.password = undefined as unknown as string; res.cookie("token", token, { @@ -124,7 +119,6 @@ router.post("/login", async (req, res) => { const token = jwt.sign({ id: user?.id }, process.env.JWT_SECRET as string); - // return user and token to client, exclude hashed password if (user) { user.password = undefined as unknown as string; } @@ -147,24 +141,34 @@ router.post("/login", async (req, res) => { }); router.get("/logout", async (req, res) => { - try { - res.clearCookie("token"); - return res.json({ message: "Logout successful" }); - } catch (error: any) { - res.status(500).send(`Could not logout user - ${error.message}`); + try { + res.clearCookie("token"); + return res.status(200).json({ + ok: true, + message: "Logged out successfully", + code: "success" + }); + } catch (error) { + console.error('Logout error:', error); + return res.status(500).json({ + ok: false, + message: "Error during logout", + code: "server", + error: process.env.NODE_ENV === 'development' ? error : undefined + }); + } } -}); +); router.get( "/current-user", requireSignIn, - async (req: Request, res) => { - const authenticatedReq = req as AuthenticatedRequest; + async (req: AuthenticatedRequest, res) => { try { - if (!authenticatedReq.user) { + if (!req.user) { return res.status(401).json({ ok: false, error: "Unauthorized" }); } - const user = await User.findByPk(authenticatedReq.user.id, { + const user = await User.findByPk(req.user.id, { attributes: { exclude: ["password"] }, }); if (!user) { @@ -187,7 +191,7 @@ router.get( router.get( "/user/:id", requireSignIn, - async (req: Request, res) => { + async (req: AuthenticatedRequest, res) => { try { const { id } = req.params; if (!id) { @@ -216,13 +220,12 @@ router.get( router.post( "/generate-api-key", requireSignIn, - async (req: Request, res) => { - const authenticatedReq = req as AuthenticatedRequest; + async (req: AuthenticatedRequest, res) => { try { - if (!authenticatedReq.user) { + if (!req.user) { return res.status(401).json({ ok: false, error: "Unauthorized" }); } - const user = await User.findByPk(authenticatedReq.user.id, { + const user = await User.findByPk(req.user.id, { attributes: { exclude: ["password"] }, }); @@ -257,14 +260,13 @@ router.post( router.get( "/api-key", requireSignIn, - async (req: Request, res) => { - const authenticatedReq = req as AuthenticatedRequest; + async (req: AuthenticatedRequest, res) => { try { - if (!authenticatedReq.user) { + if (!req.user) { return res.status(401).json({ ok: false, error: "Unauthorized" }); } - const user = await User.findByPk(authenticatedReq.user.id, { + const user = await User.findByPk(req.user.id, { raw: true, attributes: ["api_key"], }); @@ -286,14 +288,13 @@ router.get( router.delete( "/delete-api-key", requireSignIn, - async (req: Request, res) => { - const authenticatedReq = req as AuthenticatedRequest; - if (!authenticatedReq.user) { + async (req: AuthenticatedRequest, res) => { + if (!req.user) { return res.status(401).send({ error: "Unauthorized" }); } try { - const user = await User.findByPk(authenticatedReq.user.id, { raw: true }); + const user = await User.findByPk(req.user.id, { raw: true }); if (!user) { return res.status(404).json({ message: "User not found" }); @@ -303,7 +304,7 @@ router.delete( return res.status(404).json({ message: "API Key not found" }); } - await User.update({ api_key: null }, { where: { id: authenticatedReq.user.id } }); + await User.update({ api_key: null }, { where: { id: req.user.id } }); capture("maxun-oss-api-key-deleted", { user_id: user.id, @@ -349,8 +350,7 @@ router.get("/google", (req, res) => { router.get( "/google/callback", requireSignIn, - async (req: Request, res) => { - const authenticatedReq = req as AuthenticatedRequest; + async (req: AuthenticatedRequest, res) => { const { code, state } = req.query; try { if (!state) { @@ -376,12 +376,12 @@ router.get( return res.status(400).json({ message: "Email not found" }); } - if (!authenticatedReq.user) { + if (!req.user) { return res.status(401).send({ error: "Unauthorized" }); } // Get the currently authenticated user (from `requireSignIn`) - let user = await User.findOne({ where: { id: authenticatedReq.user.id } }); + let user = await User.findOne({ where: { id: req.user.id } }); if (!user) { return res.status(400).json({ message: "User not found" }); @@ -459,13 +459,12 @@ router.get( router.post( "/gsheets/data", requireSignIn, - async (req: Request, res) => { - const authenticatedReq = req as AuthenticatedRequest; + async (req: AuthenticatedRequest, res) => { const { spreadsheetId, robotId } = req.body; - if (!authenticatedReq.user) { + if (!req.user) { return res.status(401).send({ error: "Unauthorized" }); } - const user = await User.findByPk(authenticatedReq.user.id, { raw: true }); + const user = await User.findByPk(req.user.id, { raw: true }); if (!user) { return res.status(400).json({ message: "User not found" }); @@ -577,14 +576,13 @@ router.post("/gsheets/update", requireSignIn, async (req, res) => { router.post( "/gsheets/remove", requireSignIn, - async (req: Request, res) => { - const authenticatedReq = req as AuthenticatedRequest; + async (req: AuthenticatedRequest, res) => { const { robotId } = req.body; if (!robotId) { return res.status(400).json({ message: "Robot ID is required" }); } - if (!authenticatedReq.user) { + if (!req.user) { return res.status(401).send({ error: "Unauthorized" }); } @@ -606,7 +604,7 @@ router.post( }); capture("maxun-oss-google-sheet-integration-removed", { - user_id: authenticatedReq.user.id, + user_id: req.user.id, robot_id: robotId, deleted_at: new Date().toISOString(), }); @@ -620,4 +618,4 @@ router.post( }); } } -); +); \ No newline at end of file diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index e159d8cb..5a46dedd 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -25,35 +25,43 @@ chromium.use(stealthPlugin()); export const router = Router(); -export const decryptWorkflowActions = async (workflow: any[],): Promise => { - // Create a deep copy to avoid mutating the original workflow - const processedWorkflow = JSON.parse(JSON.stringify(workflow)); +export const processWorkflowActions = async (workflow: any[], checkLimit: boolean = false): Promise => { + const processedWorkflow = JSON.parse(JSON.stringify(workflow)); - // Process each step in the workflow - for (const step of processedWorkflow) { - if (!step.what) continue; - - // Process each action in the step - for (const action of step.what) { - // Only process type and press actions - if ((action.action === 'type' || action.action === 'press') && Array.isArray(action.args) && action.args.length > 1) { - // The second argument contains the encrypted value - const encryptedValue = action.args[1]; - if (typeof encryptedValue === 'string') { - try { - // Decrypt the value and update the args array - action.args[1] = await decrypt(encryptedValue); - } catch (error) { - console.error('Failed to decrypt value:', error); - // Keep the encrypted value if decryption fails + processedWorkflow.workflow.forEach((pair: any) => { + pair.what.forEach((action: any) => { + // Handle limit validation for scrapeList action + if (action.action === 'scrapeList' && checkLimit && Array.isArray(action.args) && action.args.length > 0) { + const scrapeConfig = action.args[0]; + if (scrapeConfig && typeof scrapeConfig === 'object' && 'limit' in scrapeConfig) { + if (typeof scrapeConfig.limit === 'number' && scrapeConfig.limit > 5) { + scrapeConfig.limit = 5; } } } - } - } + + // Handle decryption for type and press actions + if ((action.action === 'type' || action.action === 'press') && Array.isArray(action.args) && action.args.length > 1) { + try { + const encryptedValue = action.args[1]; + if (typeof encryptedValue === 'string') { + const decryptedValue = decrypt(encryptedValue); + action.args[1] = decryptedValue; + } else { + logger.log('error', 'Encrypted value is not a string'); + action.args[1] = ''; + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.log('error', `Failed to decrypt input value: ${errorMessage}`); + action.args[1] = ''; + } + } + }); + }); return processedWorkflow; -}; +} /** * Logs information about recordings API. @@ -88,7 +96,7 @@ router.get('/recordings/:id', requireSignIn, async (req, res) => { ); if (data?.recording?.workflow) { - data.recording.workflow = await decryptWorkflowActions( + data.recording.workflow = await processWorkflowActions( data.recording.workflow, ); } @@ -163,54 +171,82 @@ interface Credentials { [key: string]: CredentialInfo; } -function updateTypeActionsInWorkflow(workflow: any[], credentials: Credentials) { +function handleWorkflowActions(workflow: any[], credentials: Credentials) { return workflow.map(step => { if (!step.what) return step; - const indicesToRemove = new Set(); - step.what.forEach((action: any, index: number) => { - if (!action.action || !action.args?.[0]) return; + const newWhat: any[] = []; + const processedSelectors = new Set(); + + for (let i = 0; i < step.what.length; i++) { + const action = step.what[i]; - if ((action.action === 'type' || action.action === 'press') && credentials[action.args[0]]) { - indicesToRemove.add(index); - - if (step.what[index + 1]?.action === 'waitForLoadState') { - indicesToRemove.add(index + 1); - } + if (!action?.action || !action?.args?.[0]) { + newWhat.push(action); + continue; } - }); - const filteredWhat = step.what.filter((_: any, index: number) => !indicesToRemove.has(index)); + const selector = action.args[0]; + const credential = credentials[selector]; - Object.entries(credentials).forEach(([selector, credentialInfo]) => { - const clickIndex = filteredWhat.findIndex((action: any) => - action.action === 'click' && action.args?.[0] === selector - ); + if (!credential) { + newWhat.push(action); + continue; + } - if (clickIndex !== -1) { - const chars = credentialInfo.value.split(''); + if (action.action === 'click') { + newWhat.push(action); - chars.forEach((char, i) => { - filteredWhat.splice(clickIndex + 1 + (i * 2), 0, { + if (!processedSelectors.has(selector) && + i + 1 < step.what.length && + (step.what[i + 1].action === 'type' || step.what[i + 1].action === 'press')) { + + newWhat.push({ action: 'type', - args: [ - selector, - encrypt(char), - credentialInfo.type - ] + args: [selector, encrypt(credential.value), credential.type] }); - filteredWhat.splice(clickIndex + 2 + (i * 2), 0, { + newWhat.push({ action: 'waitForLoadState', args: ['networkidle'] }); + + processedSelectors.add(selector); + + while (i + 1 < step.what.length && + (step.what[i + 1].action === 'type' || + step.what[i + 1].action === 'press' || + step.what[i + 1].action === 'waitForLoadState')) { + i++; + } + } + } else if ((action.action === 'type' || action.action === 'press') && + !processedSelectors.has(selector)) { + newWhat.push({ + action: 'type', + args: [selector, encrypt(credential.value), credential.type] }); + + newWhat.push({ + action: 'waitForLoadState', + args: ['networkidle'] + }); + + processedSelectors.add(selector); + + // Skip subsequent type/press/waitForLoadState actions for this selector + while (i + 1 < step.what.length && + (step.what[i + 1].action === 'type' || + step.what[i + 1].action === 'press' || + step.what[i + 1].action === 'waitForLoadState')) { + i++; + } } - }); + } return { ...step, - what: filteredWhat + what: newWhat }; }); } @@ -243,7 +279,7 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r let workflow = [...robot.recording.workflow]; // Create a copy of the workflow if (credentials) { - workflow = updateTypeActionsInWorkflow(workflow, credentials); + workflow = handleWorkflowActions(workflow, credentials); } // Update the limit @@ -281,9 +317,23 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r } } - robot.set('recording', { ...robot.recording, workflow }); + const updates: any = { + recording: { + ...robot.recording, + workflow + } + }; - await robot.save(); + if (name) { + updates.recording_meta = { + ...robot.recording_meta, + name + }; + } + + await Robot.update(updates, { + where: { 'recording_meta.id': id } + }); const updatedRobot = await Robot.findOne({ where: { 'recording_meta.id': id } }); diff --git a/server/src/workflow-management/classes/Generator.ts b/server/src/workflow-management/classes/Generator.ts index 509c0177..2eb5f130 100644 --- a/server/src/workflow-management/classes/Generator.ts +++ b/server/src/workflow-management/classes/Generator.ts @@ -22,7 +22,7 @@ import { getBestSelectorForAction } from "../utils"; import { browserPool } from "../../server"; import { uuid } from "uuidv4"; import { capture } from "../../utils/analytics" -import { encrypt } from "../../utils/auth"; +import { decrypt, encrypt } from "../../utils/auth"; interface PersistedGeneratedData { lastUsedSelector: string; @@ -42,6 +42,13 @@ interface MetaData { isLogin?: boolean; } +interface InputState { + selector: string; + value: string; + type: string; + cursorPosition: number; +} + /** * Workflow generator is used to transform the user's interactions into an automatically * generated correct workflows, using the ability of internal state persistence and @@ -428,26 +435,86 @@ export class WorkflowGenerator { } if ((elementInfo?.tagName === 'INPUT' || elementInfo?.tagName === 'TEXTAREA') && selector) { - // Calculate the exact position within the element - const elementPos = await page.evaluate((selector) => { - const element = document.querySelector(selector); - if (!element) return null; - const rect = element.getBoundingClientRect(); - return { - x: rect.left, - y: rect.top - }; - }, selector); + const positionAndCursor = await page.evaluate( + ({ selector, coords }) => { + const getCursorPosition = (element: any, clickX: any) => { + const text = element.value; + + const mirror = document.createElement('div'); + + const style = window.getComputedStyle(element); + mirror.style.cssText = ` + font: ${style.font}; + line-height: ${style.lineHeight}; + padding: ${style.padding}; + border: ${style.border}; + box-sizing: ${style.boxSizing}; + white-space: ${style.whiteSpace}; + overflow-wrap: ${style.overflowWrap}; + position: absolute; + top: -9999px; + left: -9999px; + width: ${element.offsetWidth}px; + `; + + document.body.appendChild(mirror); + + const paddingLeft = parseFloat(style.paddingLeft); + const borderLeft = parseFloat(style.borderLeftWidth); + + const adjustedClickX = clickX - (paddingLeft + borderLeft); + + let bestIndex = 0; + let bestDiff = Infinity; + + for (let i = 0; i <= text.length; i++) { + const textBeforeCursor = text.substring(0, i); + const span = document.createElement('span'); + span.textContent = textBeforeCursor; + mirror.innerHTML = ''; + mirror.appendChild(span); + + const textWidth = span.getBoundingClientRect().width; + + const diff = Math.abs(adjustedClickX - textWidth); + + if (diff < bestDiff) { + bestIndex = i; + bestDiff = diff; + } + } + + document.body.removeChild(mirror); + + return bestIndex; + }; - if (elementPos) { - const relativeX = coordinates.x - elementPos.x; - const relativeY = coordinates.y - elementPos.y; + const element = document.querySelector(selector) as HTMLInputElement | HTMLTextAreaElement; + if (!element) return null; + + const rect = element.getBoundingClientRect(); + const relativeX = coords.x - rect.left; + + return { + rect: { + x: rect.left, + y: rect.top + }, + cursorIndex: getCursorPosition(element, relativeX) + }; + }, + { selector, coords: coordinates } + ); + + if (positionAndCursor) { + const relativeX = coordinates.x - positionAndCursor.rect.x; + const relativeY = coordinates.y - positionAndCursor.rect.y; const pair: WhereWhatPair = { where, what: [{ action: 'click', - args: [selector, { position: { x: relativeX, y: relativeY } }] + args: [selector, { position: { x: relativeX, y: relativeY } }, { cursorIndex: positionAndCursor.cursorIndex }], }] }; @@ -1026,77 +1093,107 @@ export class WorkflowGenerator { * @param workflow The workflow to be optimized. */ private optimizeWorkflow = (workflow: WorkflowFile) => { - - // replace a sequence of press actions by a single fill action - let input = { - selector: '', - value: '', - type: '', - actionCounter: 0, - }; - - const pushTheOptimizedAction = (pair: WhereWhatPair, index: number) => { - if (input.value.length === 1) { - // when only one press action is present, keep it and add a waitForLoadState action - pair.what.splice(index + 1, 0, { - action: 'waitForLoadState', - args: ['networkidle'], - }) - } else { - // when more than one press action is present, add a type action - pair.what.splice(index - input.actionCounter, input.actionCounter, { - action: 'type', - args: [input.selector, encrypt(input.value), input.type], - }, { - action: 'waitForLoadState', - args: ['networkidle'], - }); + const inputStates = new Map(); + + for (const pair of workflow.workflow) { + let currentIndex = 0; + + while (currentIndex < pair.what.length) { + const condition = pair.what[currentIndex]; + + if (condition.action === 'click' && condition.args?.[2]?.cursorIndex !== undefined) { + const selector = condition.args[0]; + const cursorIndex = condition.args[2].cursorIndex; + + let state = inputStates.get(selector) || { + selector, + value: '', + type: 'text', + cursorPosition: -1 + }; + + state.cursorPosition = cursorIndex; + inputStates.set(selector, state); + + pair.what.splice(currentIndex, 1); + continue; + } + + if (condition.action === 'press' && condition.args?.[1]) { + const [selector, encryptedKey, type] = condition.args; + const key = decrypt(encryptedKey); + + let state = inputStates.get(selector); + if (!state) { + state = { + selector, + value: '', + type: type || 'text', + cursorPosition: -1 + }; + } else { + state.type = type || state.type; + } + + if (key.length === 1) { + if (state.cursorPosition === -1) { + state.value += key; + } else { + state.value = + state.value.slice(0, state.cursorPosition) + + key + + state.value.slice(state.cursorPosition); + state.cursorPosition++; + } + } else if (key === 'Backspace') { + if (state.cursorPosition > 0) { + state.value = + state.value.slice(0, state.cursorPosition - 1) + + state.value.slice(state.cursorPosition); + state.cursorPosition--; + } else if (state.cursorPosition === -1 && state.value.length > 0) { + state.value = state.value.slice(0, -1); + } + } else if (key === 'Delete') { + if (state.cursorPosition >= 0 && state.cursorPosition < state.value.length) { + state.value = + state.value.slice(0, state.cursorPosition) + + state.value.slice(state.cursorPosition + 1); + } else if (state.cursorPosition === -1 && state.value.length > 0) { + state.value = state.value.slice(0, -1); + } + } + + inputStates.set(selector, state); + + pair.what.splice(currentIndex, 1); + continue; + } + + currentIndex++; } } - - - for (const pair of workflow.workflow) { - pair.what.forEach((condition, index) => { - if (condition.action === 'press') { - if (condition.args && condition.args[1]) { - if (!input.selector) { - input.selector = condition.args[0]; - } - if (input.selector === condition.args[0]) { - input.actionCounter++; - if (condition.args[1].length === 1) { - input.value = input.value + condition.args[1]; - } else if (condition.args[1] === 'Backspace') { - input.value = input.value.slice(0, -1); - } else if (condition.args[1] !== 'Shift') { - pushTheOptimizedAction(pair, index); - pair.what.splice(index + 1, 0, { - action: 'waitForLoadState', - args: ['networkidle'], - }) - input = { selector: '', value: '', type: '', actionCounter: 0 }; - } - } else { - pushTheOptimizedAction(pair, index); - input = { - selector: condition.args[0], - value: condition.args[1], - type: condition.args[2], - actionCounter: 1, - }; - } - } - } else { - if (input.value.length !== 0) { - pushTheOptimizedAction(pair, index); - // clear the input - input = { selector: '', value: '', type: '', actionCounter: 0 }; - } + + for (const [selector, state] of inputStates.entries()) { + if (state.value) { + for (let i = workflow.workflow.length - 1; i >= 0; i--) { + const pair = workflow.workflow[i]; + + pair.what.push({ + action: 'type', + args: [selector, encrypt(state.value), state.type] + }, { + action: 'waitForLoadState', + args: ['networkidle'] + }); + + break; } - }); + } } + return workflow; - } + }; /** * Returns workflow params from the stored metadata. diff --git a/server/src/workflow-management/selector.ts b/server/src/workflow-management/selector.ts index 3f44a01e..20a6a529 100644 --- a/server/src/workflow-management/selector.ts +++ b/server/src/workflow-management/selector.ts @@ -1092,12 +1092,16 @@ export const getSelectors = async (page: Page, coordinates: Coordinates) => { newPath.splice(i, 1); const newPathKey = selector(newPath); if (scope.visited.has(newPathKey)) { - return; + continue; } - if (unique(newPath) && same(newPath, input)) { - yield newPath; - scope.visited.set(newPathKey, true); - yield* optimize(newPath, input, scope); + try { + if (unique(newPath) && same(newPath, input)) { + yield newPath; + scope.visited.set(newPathKey, true); + yield* optimize(newPath, input, scope); + } + } catch (e: any) { + continue; } } } diff --git a/src/components/api/ApiKey.tsx b/src/components/api/ApiKey.tsx index 9d54fe5c..f4904574 100644 --- a/src/components/api/ApiKey.tsx +++ b/src/components/api/ApiKey.tsx @@ -14,7 +14,7 @@ import { Tooltip, Paper, } from '@mui/material'; -import { ContentCopy, Visibility, Delete } from '@mui/icons-material'; +import { ContentCopy, Visibility, VisibilityOff, Delete } from '@mui/icons-material'; import styled from 'styled-components'; import axios from 'axios'; import { useGlobalInfoStore } from '../../context/globalInfo'; @@ -43,8 +43,33 @@ const ApiKeyManager = () => { try { const { data } = await axios.get(`${apiUrl}/auth/api-key`); setApiKey(data.api_key); + notify('success', t('apikey.notifications.success.fetch')); } catch (error: any) { - notify('error', t('apikey.notifications.fetch_error', { error: error.message })); + const status = error.response?.status; + let errorKey = 'unknown'; + + switch (status) { + case 401: + errorKey = 'unauthorized'; + break; + case 404: + errorKey = 'not_found'; + break; + case 500: + errorKey = 'server'; + break; + default: + if (error.message?.includes('Network Error')) { + errorKey = 'network'; + } + } + + notify( + 'error', + t(`apikey.notifications.errors.fetch.${errorKey}`, { + error: error.response?.data?.message || error.message + }) + ); } finally { setLoading(false); } @@ -58,11 +83,36 @@ const ApiKeyManager = () => { setLoading(true); try { const { data } = await axios.post(`${apiUrl}/auth/generate-api-key`); - setApiKey(data.api_key); - - notify('success', t('apikey.notifications.generate_success')); + if (data.ok && data.api_key) { + setApiKey(data.api_key); + notify('success', t('apikey.notifications.success.generate')); + } } catch (error: any) { - notify('error', t('apikey.notifications.generate_error', { error: error.message })); + const status = error.response?.status; + let errorKey = 'unknown'; + + switch (status) { + case 401: + errorKey = 'unauthorized'; + break; + case 403: + errorKey = 'limit_reached'; + break; + case 500: + errorKey = 'server'; + break; + default: + if (error.message?.includes('Network Error')) { + errorKey = 'network'; + } + } + + notify( + 'error', + t(`apikey.notifications.errors.generate.${errorKey}`, { + error: error.response?.data?.message || error.message + }) + ); } finally { setLoading(false); } @@ -71,22 +121,54 @@ const ApiKeyManager = () => { const deleteApiKey = async () => { setLoading(true); try { - await axios.delete(`${apiUrl}/auth/delete-api-key`); - setApiKey(null); - notify('success', t('apikey.notifications.delete_success')); + const response = await axios.delete(`${apiUrl}/auth/delete-api-key`); + if (response.data.ok) { + setApiKey(null); + notify('success', t('apikey.notifications.success.delete')); + } } catch (error: any) { - notify('error', t('apikey.notifications.delete_error', { error: error.message })); + const status = error.response?.status; + let errorKey = 'unknown'; + + switch (status) { + case 401: + errorKey = 'unauthorized'; + break; + case 404: + errorKey = 'not_found'; + break; + case 500: + errorKey = 'server'; + break; + default: + if (error.message?.includes('Network Error')) { + errorKey = 'network'; + } + } + + notify( + 'error', + t(`apikey.notifications.errors.delete.${errorKey}`, { + error: error.response?.data?.message || error.message + }) + ); } finally { setLoading(false); } }; - const copyToClipboard = () => { - if (apiKey) { - navigator.clipboard.writeText(apiKey); + const copyToClipboard = async () => { + if (!apiKey) return; + + try { + await navigator.clipboard.writeText(apiKey); setCopySuccess(true); + notify('success', t('apikey.notifications.success.copy')); + + // Reset copy success state after 2 seconds setTimeout(() => setCopySuccess(false), 2000); - notify('info', t('apikey.notifications.copy_success')); + } catch (error) { + notify('error', t('apikey.notifications.errors.copy.failed')); } }; @@ -137,7 +219,7 @@ const ApiKeyManager = () => { setShowKey(!showKey)}> - + {showKey ? : } diff --git a/src/components/dashboard/NavBar.tsx b/src/components/dashboard/NavBar.tsx index 710e46e6..591b7624 100644 --- a/src/components/dashboard/NavBar.tsx +++ b/src/components/dashboard/NavBar.tsx @@ -108,11 +108,39 @@ export const NavBar: React.FC = ({ }; const logout = async () => { - dispatch({ type: "LOGOUT" }); - window.localStorage.removeItem("user"); - const { data } = await axios.get(`${apiUrl}/auth/logout`); - notify("success", data.message); - navigate("/login"); + try { + const { data } = await axios.get(`${apiUrl}/auth/logout`); + if (data.ok) { + dispatch({ type: "LOGOUT" }); + window.localStorage.removeItem("user"); + notify('success', t('navbar.notifications.success.logout')); + navigate("/login"); + } + } catch (error: any) { + const status = error.response?.status; + let errorKey = 'unknown'; + + switch (status) { + case 401: + errorKey = 'unauthorized'; + break; + case 500: + errorKey = 'server'; + break; + default: + if (error.message?.includes('Network Error')) { + errorKey = 'network'; + } + } + + notify( + 'error', + t(`navbar.notifications.errors.logout.${errorKey}`, { + error: error.response?.data?.message || error.message + }) + ); + navigate("/login"); + } }; const goToMainMenu = async () => { @@ -205,7 +233,9 @@ export const NavBar: React.FC = ({
+ cursor: 'pointer' + }} + onClick={() => navigate('/')}>
{t('navbar.project_name')}
= ({
{!isRecording ? ( <> - + + {t('navbar.upgrade.button')} + { + function extractInitialCredentials(workflow: any[]): Credentials { const credentials: Credentials = {}; - - // Helper function to check if a character is printable + const isPrintableCharacter = (char: string): boolean => { return char.length === 1 && !!char.match(/^[\x20-\x7E]$/); }; - - // Process each step in the workflow + workflow.forEach(step => { if (!step.what) return; - - // Keep track of the current input field being processed + let currentSelector = ''; let currentValue = ''; let currentType = ''; - - // Process actions in sequence to maintain correct text state - step.what.forEach((action: any) => { - if ( - (action.action === 'type' || action.action === 'press') && - action.args?.length >= 2 && - typeof action.args[1] === 'string' - ) { - const selector: string = action.args[0]; - const character: string = action.args[1]; - const inputType: string = action.args[2] || ''; - - // Detect `input[type="password"]` - if (!currentType && inputType.toLowerCase() === 'password') { - currentType = 'password'; + let i = 0; + + while (i < step.what.length) { + const action = step.what[i]; + + if (!action.action || !action.args?.[0]) { + i++; + continue; + } + + const selector = action.args[0]; + + // Handle full word type actions first + if (action.action === 'type' && + action.args?.length >= 2 && + typeof action.args[1] === 'string' && + action.args[1].length > 1) { + + if (!credentials[selector]) { + credentials[selector] = { + value: action.args[1], + type: action.args[2] || 'text' + }; } - - // If we're dealing with a new selector, store the previous one - if (currentSelector && selector !== currentSelector) { - if (!credentials[currentSelector]) { + i++; + continue; + } + + // Handle character-by-character sequences (both type and press) + if ((action.action === 'type' || action.action === 'press') && + action.args?.length >= 2 && + typeof action.args[1] === 'string') { + + if (selector !== currentSelector) { + if (currentSelector && currentValue) { credentials[currentSelector] = { value: currentValue, - type: currentType + type: currentType || 'text' }; - } else { - credentials[currentSelector].value = currentValue; } - } - - // Update current tracking variables - if (selector !== currentSelector) { currentSelector = selector; currentValue = credentials[selector]?.value || ''; - currentType = inputType || credentials[selector]?.type || ''; + currentType = action.args[2] || credentials[selector]?.type || 'text'; } - - // Handle different types of key actions - if (character === 'Backspace') { - // Remove the last character when backspace is pressed - currentValue = currentValue.slice(0, -1); - } else if (isPrintableCharacter(character)) { - // Add the character to the current value + + const character = action.args[1]; + + if (isPrintableCharacter(character)) { currentValue += character; + } else if (character === 'Backspace') { + currentValue = currentValue.slice(0, -1); } - // Note: We ignore other special keys like 'Shift', 'Enter', etc. + + if (!currentType && action.args[2]?.toLowerCase() === 'password') { + currentType = 'password'; + } + + let j = i + 1; + while (j < step.what.length) { + const nextAction = step.what[j]; + if (!nextAction.action || !nextAction.args?.[0] || + nextAction.args[0] !== selector || + (nextAction.action !== 'type' && nextAction.action !== 'press')) { + break; + } + if (nextAction.args[1] === 'Backspace') { + currentValue = currentValue.slice(0, -1); + } else if (isPrintableCharacter(nextAction.args[1])) { + currentValue += nextAction.args[1]; + } + j++; + } + + credentials[currentSelector] = { + value: currentValue, + type: currentType + }; + + i = j; + } else { + i++; } - }); - - // Store the final state of the last processed selector - if (currentSelector) { + } + + if (currentSelector && currentValue) { credentials[currentSelector] = { value: currentValue, - type: currentType + type: currentType || 'text' }; } }); - + return credentials; - }; + } const groupCredentialsByType = (credentials: Credentials): GroupedCredentials => { return Object.entries(credentials).reduce((acc: GroupedCredentials, [selector, info]) => {