Browse Source

feat: add workflow protection

Ahmad Kholid 3 years ago
parent
commit
c1bcd2decd

+ 1 - 0
.gitignore

@@ -23,5 +23,6 @@
 # secrets
 secrets.production.js
 secrets.development.js
+get-pass-key.js
 
 .idea

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "0.16.2",
+  "version": "0.16.5",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {

+ 12 - 1
src/background/index.js

@@ -1,11 +1,13 @@
 import browser from 'webextension-polyfill';
 import { MessageListener } from '@/utils/message';
 import { registerSpecificDay } from '../utils/workflow-trigger';
+import { parseJSON } from '@/utils/helper';
 import WorkflowState from './workflow-state';
 import CollectionEngine from './collection-engine';
 import WorkflowEngine from './workflow-engine/engine';
 import blocksHandler from './workflow-engine/blocks-handler';
 import WorkflowLogger from './workflow-logger';
+import decryptFlow, { getWorkflowPass } from '@/utils/decrypt-flow';
 
 const storage = {
   async get(key) {
@@ -36,6 +38,16 @@ const workflow = {
     return findWorkflow;
   },
   execute(workflowData, options) {
+    if (workflowData.isProtected) {
+      const flow = parseJSON(workflowData.drawflow, null);
+
+      if (!flow) {
+        const pass = getWorkflowPass(workflowData.pass);
+
+        workflowData.drawflow = decryptFlow(workflowData, pass);
+      }
+    }
+
     const engine = new WorkflowEngine(workflowData, {
       ...options,
       blocksHandler,
@@ -190,7 +202,6 @@ message.on('get:sender', (_, sender) => {
   return sender;
 });
 message.on('get:tab-screenshot', (options) => {
-  console.log(browser.tabs.captureVisibleTab(options), 'aaa');
   return browser.tabs.captureVisibleTab(options);
 });
 message.on('get:file', (path) => {

+ 11 - 1
src/components/newtab/workflow/WorkflowActions.vue

@@ -11,11 +11,20 @@
     </button>
   </ui-card>
   <ui-card padding="p-1 ml-4">
+    <button
+      v-tooltip.group="
+        t(`workflow.protect.${workflow.isProtected ? 'remove' : 'title'}`)
+      "
+      :class="{ 'text-green-600': workflow.isProtected }"
+      class="hoverable p-2 rounded-lg"
+      @click="$emit('protect')"
+    >
+      <v-remixicon name="riShieldKeyholeLine" />
+    </button>
     <button
       v-if="!workflow.isDisabled"
       v-tooltip.group="t('common.execute')"
       :title="shortcuts['editor:execute-workflow'].readable"
-      icon
       class="hoverable p-2 rounded-lg"
       @click="$emit('execute')"
     >
@@ -100,6 +109,7 @@ const emit = defineEmits([
   'rename',
   'delete',
   'save',
+  'protect',
   'export',
   'update',
 ]);

+ 71 - 0
src/components/newtab/workflow/WorkflowProtect.vue

@@ -0,0 +1,71 @@
+<template>
+  <div>
+    <form
+      class="mb-4 flex items-center w-full"
+      @submit.prevent="protectWorkflow"
+    >
+      <ui-input
+        v-model="state.password"
+        :placeholder="t('common.password')"
+        :type="state.showPassword ? 'text' : 'password'"
+        input-class="pr-10"
+        autofocus
+        class="flex-1 mr-6"
+      >
+        <template #append>
+          <v-remixicon
+            :name="state.showPassword ? 'riEyeOffLine' : 'riEyeLine'"
+            class="absolute right-2"
+            @click="state.showPassword = !state.showPassword"
+          />
+        </template>
+      </ui-input>
+      <ui-button variant="accent">
+        {{ t('workflow.protect.button') }}
+      </ui-button>
+    </form>
+    <p>
+      {{ t('workflow.protect.note') }}
+    </p>
+  </div>
+</template>
+<script setup>
+import { shallowReactive } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { nanoid } from 'nanoid';
+import AES from 'crypto-js/aes';
+import hmacSHA256 from 'crypto-js/hmac-sha256';
+import getPassKey from '@/utils/get-pass-key';
+
+const props = defineProps({
+  workflow: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update', 'close']);
+
+const { t } = useI18n();
+
+const state = shallowReactive({
+  password: '',
+  showPassword: false,
+});
+
+async function protectWorkflow() {
+  const key = getPassKey(nanoid());
+  const encryptedPass = AES.encrypt(state.password, key).toString();
+  const hmac = hmacSHA256(encryptedPass, state.password).toString();
+
+  const { drawflow } = props.workflow;
+  const flow =
+    typeof drawflow === 'string' ? drawflow : JSON.stringify(drawflow);
+
+  emit('update', {
+    isProtected: true,
+    pass: hmac + encryptedPass,
+    drawflow: AES.encrypt(flow, state.password).toString(),
+  });
+  emit('close');
+}
+</script>

+ 44 - 35
src/components/ui/UiInput.vue

@@ -1,48 +1,50 @@
 <template>
   <div class="inline-block input-ui">
-    <label class="relative">
-      <span
-        v-if="label || $slots.label"
-        class="text-sm dark:text-gray-200 text-gray-600 mb-1 ml-1"
-      >
+    <label v-if="label || $slots.label" :for="componentId">
+      <span class="text-sm dark:text-gray-200 text-gray-600 mb-1 ml-1">
         <slot name="label">{{ label }}</slot>
       </span>
-      <div class="flex items-center">
-        <slot name="prepend">
-          <v-remixicon
-            v-if="prependIcon"
-            class="ml-2 dark:text-gray-200 text-gray-600 absolute left-0"
-            :name="prependIcon"
-          ></v-remixicon>
-        </slot>
-        <input
-          v-autofocus="autofocus"
-          v-bind="{
-            readonly: disabled || readonly || null,
-            placeholder,
-            type,
-            autofocus,
-            min,
-            max,
-            list,
-          }"
-          :class="{
+    </label>
+    <div class="flex items-center relative w-full">
+      <slot name="prepend">
+        <v-remixicon
+          v-if="prependIcon"
+          class="ml-2 dark:text-gray-200 text-gray-600 absolute left-0"
+          :name="prependIcon"
+        ></v-remixicon>
+      </slot>
+      <input
+        v-bind="{
+          readonly: disabled || readonly || null,
+          placeholder,
+          type,
+          autofocus,
+          min,
+          max,
+          list,
+        }"
+        :id="componentId"
+        v-autofocus="autofocus"
+        :class="[
+          inputClass,
+          {
             'opacity-75 pointer-events-none': disabled,
             'pl-10': prependIcon || $slots.prepend,
             'appearance-none': list,
-          }"
-          :value="modelValue"
-          class="py-2 px-4 rounded-lg w-full bg-input bg-transparent transition"
-          @keydown="$emit('keydown', $event)"
-          @blur="$emit('blur', $event)"
-          @input="emitValue"
-        />
-        <slot name="append" />
-      </div>
-    </label>
+          },
+        ]"
+        :value="modelValue"
+        class="py-2 px-4 rounded-lg w-full bg-input bg-transparent transition"
+        @keydown="$emit('keydown', $event)"
+        @blur="$emit('blur', $event)"
+        @input="emitValue"
+      />
+      <slot name="append" />
+    </div>
   </div>
 </template>
 <script>
+import { useComponentId } from '@/composable/componentId';
 /* eslint-disable vue/require-prop-types */
 export default {
   props: {
@@ -65,6 +67,10 @@ export default {
       type: [String, Number],
       default: '',
     },
+    inputClass: {
+      type: String,
+      default: '',
+    },
     prependIcon: {
       type: String,
       default: '',
@@ -96,6 +102,8 @@ export default {
   },
   emits: ['update:modelValue', 'change', 'keydown', 'blur'],
   setup(props, { emit }) {
+    const componentId = useComponentId('ui-input');
+
     function emitValue(event) {
       let { value } = event.target;
 
@@ -111,6 +119,7 @@ export default {
 
     return {
       emitValue,
+      componentId,
     };
   },
 };

+ 2 - 0
src/lib/v-remixicon.js

@@ -22,6 +22,7 @@ import {
   riRecordCircleLine,
   riErrorWarningLine,
   riEyeLine,
+  riEyeOffLine,
   riCalendarLine,
   riFileTextLine,
   riFilter2Line,
@@ -104,6 +105,7 @@ export const icons = {
   riRecordCircleLine,
   riErrorWarningLine,
   riEyeLine,
+  riEyeOffLine,
   riCalendarLine,
   riFileTextLine,
   riFilter2Line,

+ 2 - 1
src/locales/en/common.json

@@ -32,7 +32,8 @@
     "enable": "Enable",
     "fallback": "Fallback",
     "update": "Update",
-    "duplicate": "Duplicate"
+    "duplicate": "Duplicate",
+    "password": "Password"
   },
   "message": {
     "noBlock": "No block",

+ 14 - 0
src/locales/en/newtab.json

@@ -31,6 +31,20 @@
     "add": "Add workflow",
     "clickToEnable": "Click to enable",
     "toggleSidebar": "Toggle sidebar",
+    "protect": {
+      "title": "Protect workflow",
+      "remove": "Remove protection",
+      "button": "Protect",
+      "note": "Note: you must remember this password, this password will be required to edit and delete the workflow later on."
+    },
+    "locked": {
+      "title": "This Workflow is Protected",
+      "body": "Input the password to unlock it",
+      "unlock": "Unlock",
+      "messages": {
+        "incorrect-password": "Incorrect password"
+      }
+    },
     "state": {
       "executeBy": "Executed by: \"{name}\""
     },

+ 2 - 0
src/models/workflow.js

@@ -19,6 +19,8 @@ class Workflow extends Model {
       drawflow: this.attr(''),
       dataColumns: this.attr([]),
       description: this.string(''),
+      pass: this.string(''),
+      isProtected: this.boolean(false),
       version: this.string(''),
       globalData: this.string('[{ "key": "value" }]'),
       createdAt: this.number(),

+ 0 - 3
src/newtab/App.vue

@@ -33,7 +33,6 @@ import { compare } from 'compare-versions';
 import browser from 'webextension-polyfill';
 import { loadLocaleMessages, setI18nLanguage } from '@/lib/vue-i18n';
 import AppSidebar from '@/components/newtab/app/AppSidebar.vue';
-import { sendMessage } from '@/utils/message';
 
 const store = useStore();
 const { t } = useI18n();
@@ -81,8 +80,6 @@ onMounted(async () => {
     await loadLocaleMessages(store.state.settings.locale, 'newtab');
     await setI18nLanguage(store.state.settings.locale);
 
-    await sendMessage('workflow:check-state', {}, 'background');
-
     retrieved.value = true;
   } catch (error) {
     retrieved.value = true;

+ 6 - 1
src/newtab/pages/Workflows.vue

@@ -103,7 +103,12 @@
             >
               <v-remixicon name="riPlayLine" />
             </button>
-            <ui-popover class="h-6 ml-2">
+            <v-remixicon
+              v-if="workflow.isProtected"
+              name="riShieldKeyholeLine"
+              class="text-green-600 ml-2"
+            />
+            <ui-popover v-if="!workflow.isProtected" class="h-6 ml-2">
               <template #trigger>
                 <button>
                   <v-remixicon name="riMoreLine" />

+ 120 - 17
src/newtab/pages/workflows/[id].vue

@@ -1,5 +1,39 @@
 <template>
-  <div class="flex h-screen">
+  <div v-if="protectionState.needed" class="my-12 mx-auto max-w-md w-full">
+    <div class="inline-block p-4 bg-green-200 mb-4 rounded-full">
+      <v-remixicon name="riShieldKeyholeLine" size="52" />
+    </div>
+    <h1 class="text-2xl font-semibold">
+      {{ t('workflow.locked.title') }}
+    </h1>
+    <p class="text-gray-600 text-lg">{{ t('workflow.locked.body') }}</p>
+    <form class="flex items-center mt-6" @submit.prevent="unlockWorkflow">
+      <ui-input
+        v-model="protectionState.password"
+        :placeholder="t('common.password')"
+        :type="protectionState.showPassword ? 'text' : 'password'"
+        autofocus
+        class="flex-1 mr-4"
+      >
+        <template #append>
+          <v-remixicon
+            :name="protectionState.showPassword ? 'riEyeOffLine' : 'riEyeLine'"
+            class="absolute right-2"
+            @click="
+              protectionState.showPassword = !protectionState.showPassword
+            "
+          />
+        </template>
+      </ui-input>
+      <ui-button variant="accent">
+        {{ t('workflow.locked.unlock') }}
+      </ui-button>
+    </form>
+    <p v-if="protectionState.message" class="ml-2 text-red-500">
+      {{ t(`workflow.locked.messages.${protectionState.message}`) }}
+    </p>
+  </div>
+  <div v-else class="flex h-screen">
     <div
       v-if="state.showSidebar"
       class="w-80 bg-white py-6 relative border-l border-gray-100 flex flex-col"
@@ -60,13 +94,14 @@
           @rename="renameWorkflow"
           @update="updateWorkflow"
           @delete="deleteWorkflow"
+          @protect="toggleProtection"
         />
       </div>
       <keep-alive>
         <workflow-builder
-          v-if="activeTab === 'editor'"
+          v-if="activeTab === 'editor' && state.drawflow"
           class="h-full w-full"
-          :data="workflow.drawflow"
+          :data="state.drawflow"
           :version="workflow.version"
           @update="updateWorkflow"
           @load="editor = $event"
@@ -164,17 +199,25 @@ import { useStore } from 'vuex';
 import { useToast } from 'vue-toastification';
 import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router';
 import { useI18n } from 'vue-i18n';
+import { nanoid } from 'nanoid';
+import defu from 'defu';
+import AES from 'crypto-js/aes';
+import encUtf8 from 'crypto-js/enc-utf8';
 import emitter from '@/lib/mitt';
 import { useDialog } from '@/composable/dialog';
 import { useShortcut } from '@/composable/shortcut';
 import { sendMessage } from '@/utils/message';
 import { debounce, isObject } from '@/utils/helper';
 import { exportWorkflow } from '@/utils/workflow-data';
+import { tasks } from '@/utils/shared';
 import Log from '@/models/log';
+import getPassKey from '@/utils/get-pass-key';
+import decryptFlow, { getWorkflowPass } from '@/utils/decrypt-flow';
 import Workflow from '@/models/workflow';
 import workflowTrigger from '@/utils/workflow-trigger';
 import WorkflowActions from '@/components/newtab/workflow/WorkflowActions.vue';
 import WorkflowBuilder from '@/components/newtab/workflow/WorkflowBuilder.vue';
+import WorkflowProtect from '@/components/newtab/workflow/WorkflowProtect.vue';
 import WorkflowSettings from '@/components/newtab/workflow/WorkflowSettings.vue';
 import WorkflowEditBlock from '@/components/newtab/workflow/WorkflowEditBlock.vue';
 import WorkflowDetailsCard from '@/components/newtab/workflow/WorkflowDetailsCard.vue';
@@ -203,6 +246,11 @@ const workflowModals = {
     component: WorkflowGlobalData,
     title: t('common.globalData'),
   },
+  'protect-workflow': {
+    icon: 'riShieldKeyholeLine',
+    component: WorkflowProtect,
+    title: t('workflow.protect.title'),
+  },
   settings: {
     icon: 'riSettings3Line',
     component: WorkflowSettings,
@@ -215,6 +263,7 @@ const activeTab = shallowRef('editor');
 const state = reactive({
   blockData: {},
   modalName: '',
+  drawflow: null,
   showModal: false,
   showSidebar: true,
   isEditBlock: false,
@@ -225,6 +274,12 @@ const renameModal = reactive({
   name: '',
   description: '',
 });
+const protectionState = reactive({
+  message: '',
+  password: '',
+  needed: false,
+  showPassword: false,
+});
 
 const workflowState = computed(() =>
   store.getters.getWorkflowState(workflowId)
@@ -266,6 +321,39 @@ function deleteLog(logId) {
     store.dispatch('saveToStorage', 'logs');
   });
 }
+function toggleProtection() {
+  if (workflow.value.isProtected) {
+    const pass = getPassKey(nanoid());
+    const password = AES.decrypt(
+      workflow.value.pass.substring(64),
+      pass
+    ).toString(encUtf8);
+    const decryptedFlow = decryptFlow(workflow.value, password);
+
+    updateWorkflow({
+      pass: '',
+      isProtected: false,
+      drawflow: decryptedFlow,
+    });
+  } else {
+    state.showModal = true;
+    state.modalName = 'protect-workflow';
+  }
+}
+function unlockWorkflow() {
+  protectionState.message = '';
+
+  const decryptedFlow = decryptFlow(workflow.value, protectionState.password);
+
+  if (decryptedFlow.isError) {
+    protectionState.message = decryptedFlow.message;
+    return;
+  }
+
+  state.drawflow = decryptedFlow;
+  protectionState.password = '';
+  protectionState.needed = false;
+}
 function toggleSidebar() {
   state.showSidebar = !state.showSidebar;
   localStorage.setItem('workflow:sidebar', state.showSidebar);
@@ -302,25 +390,33 @@ function updateNameAndDesc() {
     });
   });
 }
-function saveWorkflow() {
-  const data = editor.value.export();
+async function saveWorkflow() {
+  try {
+    let flow = JSON.stringify(editor.value.export());
 
-  updateWorkflow({ drawflow: JSON.stringify(data) }).then(() => {
-    const [triggerBlockId] = editor.value.getNodesFromName('trigger');
-
-    if (triggerBlockId) {
-      workflowTrigger.register(
-        workflowId,
-        editor.value.getNodeFromId(triggerBlockId)
-      );
+    if (workflow.value.isProtected) {
+      flow = AES.encrypt(flow, getWorkflowPass(workflow.value.pass)).toString();
     }
 
-    state.isDataChanged = false;
-  });
+    updateWorkflow({ drawflow: flow }).then(() => {
+      const [triggerBlockId] = editor.value.getNodesFromName('trigger');
+
+      if (triggerBlockId) {
+        workflowTrigger.register(
+          workflowId,
+          editor.value.getNodeFromId(triggerBlockId)
+        );
+      }
+
+      state.isDataChanged = false;
+    });
+  } catch (error) {
+    console.error(error);
+  }
 }
 function editBlock(data) {
   state.isEditBlock = true;
-  state.blockData = data;
+  state.blockData = defu(data, tasks[data.id] || {});
 }
 function executeWorkflow() {
   if (editor.value.getNodesFromName('trigger').length === 0) {
@@ -331,8 +427,8 @@ function executeWorkflow() {
 
   const payload = {
     ...workflow.value,
-    drawflow: editor.value.export(),
     isTesting: state.isDataChanged,
+    drawflow: JSON.stringify(editor.value.export()),
   };
 
   sendMessage('workflow:execute', payload, 'background');
@@ -382,6 +478,13 @@ onMounted(() => {
 
   if (!isWorkflowExists) {
     router.push('/workflows');
+    return;
+  }
+
+  if (workflow.value.isProtected) {
+    protectionState.needed = true;
+  } else {
+    state.drawflow = workflow.value.drawflow;
   }
 
   state.showSidebar =

+ 25 - 0
src/utils/decrypt-flow.js

@@ -0,0 +1,25 @@
+import { nanoid } from 'nanoid';
+import hmacSHA256 from 'crypto-js/hmac-sha256';
+import AES from 'crypto-js/aes';
+import encUtf8 from 'crypto-js/enc-utf8';
+import getPassKey from './get-pass-key';
+
+export function getWorkflowPass(pass) {
+  const key = getPassKey(nanoid());
+  const decryptedPass = AES.decrypt(pass.substring(64), key).toString(encUtf8);
+
+  return decryptedPass;
+}
+
+export default function ({ pass, drawflow }, password) {
+  const hmac = pass.substring(0, 64);
+  const decryptedHmac = hmacSHA256(pass.substring(64), password).toString();
+
+  if (hmac !== decryptedHmac)
+    return {
+      isError: true,
+      message: 'incorrect-password',
+    };
+
+  return AES.decrypt(drawflow, password).toString(encUtf8);
+}

+ 14 - 2
src/utils/workflow-data.js

@@ -1,10 +1,15 @@
-import { parseJSON, fileSaver, openFilePicker } from './helper';
+import { parseJSON, fileSaver, openFilePicker, isObject } from './helper';
 import Workflow from '@/models/workflow';
 
 export function importWorkflow() {
   openFilePicker(['application/json'])
     .then((file) => {
       const reader = new FileReader();
+      const getDrawflow = ({ drawflow }) => {
+        if (isObject(drawflow)) return JSON.stringify(drawflow);
+
+        return drawflow;
+      };
 
       reader.onload = ({ target }) => {
         const workflow = JSON.parse(target.result);
@@ -20,6 +25,7 @@ export function importWorkflow() {
             Workflow.insert({
               data: {
                 ...workflow.includedWorkflows[workflowId],
+                drawflow: getDrawflow(workflow.includedWorkflows[workflowId]),
                 id: workflowId,
                 createdAt: Date.now(),
               },
@@ -29,7 +35,13 @@ export function importWorkflow() {
           delete workflow.includedWorkflows;
         }
 
-        Workflow.insert({ data: { ...workflow, createdAt: Date.now() } });
+        Workflow.insert({
+          data: {
+            ...workflow,
+            drawflow: getDrawflow(workflow),
+            createdAt: Date.now(),
+          },
+        });
       };
 
       reader.readAsText(file);

+ 0 - 1
src/utils/workflow-trigger.js

@@ -142,7 +142,6 @@ export async function registerOnStartup(workflowId) {
   const startupTriggers = onStartupTriggers || [];
 
   startupTriggers.push(workflowId);
-  console.log(startupTriggers);
 
   await browser.storage.local.set({ onStartupTriggers: startupTriggers });
 }