From 88195a6afb6c6b4e5f9b3541b8c1fff189e86e9c Mon Sep 17 00:00:00 2001
From: Bubka <858858+Bubka@users.noreply.github.com>
Date: Tue, 13 Dec 2022 15:57:33 +0100
Subject: [PATCH] Add export feature to the Edit mode - Complete #100

---
 .../v1/Controllers/TwoFAccountController.php  | 29 ++++++++++--
 .../Resources/TwoFAccountExportCollection.php | 26 +++++++++++
 .../Resources/TwoFAccountExportResource.php   | 46 +++++++++++++++++++
 app/Services/TwoFAccountService.php           | 14 ++++++
 package-lock.json                             | 11 +++++
 package.json                                  |  1 +
 resources/js/packages/fontawesome.js          |  8 ++--
 resources/js/views/Accounts.vue               | 30 +++++++++---
 routes/api/v1.php                             |  1 +
 9 files changed, 153 insertions(+), 13 deletions(-)
 create mode 100644 app/Api/v1/Resources/TwoFAccountExportCollection.php
 create mode 100644 app/Api/v1/Resources/TwoFAccountExportResource.php

diff --git a/app/Api/v1/Controllers/TwoFAccountController.php b/app/Api/v1/Controllers/TwoFAccountController.php
index b1c2de16..c402bdc6 100644
--- a/app/Api/v1/Controllers/TwoFAccountController.php
+++ b/app/Api/v1/Controllers/TwoFAccountController.php
@@ -10,6 +10,7 @@
 use App\Api\v1\Requests\TwoFAccountUpdateRequest;
 use App\Api\v1\Requests\TwoFAccountUriRequest;
 use App\Api\v1\Resources\TwoFAccountCollection;
+use App\Api\v1\Resources\TwoFAccountExportCollection;
 use App\Api\v1\Resources\TwoFAccountReadResource;
 use App\Api\v1\Resources\TwoFAccountStoreResource;
 use App\Facades\Groups;
@@ -70,8 +71,8 @@ public function store(TwoFAccountDynamicRequest $request)
         Groups::assign($twofaccount->id);
 
         return (new TwoFAccountReadResource($twofaccount->refresh()))
-                ->response()
-                ->setStatusCode(201);
+            ->response()
+            ->setStatusCode(201);
     }
 
     /**
@@ -89,8 +90,8 @@ public function update(TwoFAccountUpdateRequest $request, TwoFAccount $twofaccou
         $twofaccount->save();
 
         return (new TwoFAccountReadResource($twofaccount))
-                ->response()
-                ->setStatusCode(200);
+            ->response()
+            ->setStatusCode(200);
     }
 
     /**
@@ -143,6 +144,26 @@ public function preview(TwoFAccountUriRequest $request)
         return new TwoFAccountStoreResource($twofaccount);
     }
 
+    /**
+     * Export accounts
+     *
+     * @param  \App\Api\v1\Requests\TwoFAccountBatchRequest  $request
+     * @return TwoFAccountExportCollection|\Illuminate\Http\JsonResponse
+     */
+    public function export(TwoFAccountBatchRequest $request)
+    {
+        $validated = $request->validated();
+
+        if ($this->tooManyIds($validated['ids'])) {
+            return response()->json([
+                'message' => 'bad request',
+                'reason'  => [__('errors.too_many_ids')],
+            ], 400);
+        }
+
+        return new TwoFAccountExportCollection(TwoFAccounts::export($validated['ids']));
+    }
+
     /**
      * Get a One-Time Password
      *
diff --git a/app/Api/v1/Resources/TwoFAccountExportCollection.php b/app/Api/v1/Resources/TwoFAccountExportCollection.php
new file mode 100644
index 00000000..3ce03ebc
--- /dev/null
+++ b/app/Api/v1/Resources/TwoFAccountExportCollection.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace App\Api\v1\Resources;
+
+use Illuminate\Http\Resources\Json\ResourceCollection;
+
+class TwoFAccountExportCollection extends ResourceCollection
+{
+    /**
+     * The resource that this resource collects.
+     *
+     * @var string
+     */
+    public $collects = TwoFAccountExportResource::class;
+
+    /**
+     * Transform the resource collection into an array.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Support\Collection<int|string, TwoFAccountExportResource>
+     */
+    public function toArray($request)
+    {
+        return $this->collection;
+    }
+}
diff --git a/app/Api/v1/Resources/TwoFAccountExportResource.php b/app/Api/v1/Resources/TwoFAccountExportResource.php
new file mode 100644
index 00000000..8acf1844
--- /dev/null
+++ b/app/Api/v1/Resources/TwoFAccountExportResource.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Api\v1\Resources;
+
+use Illuminate\Http\Resources\Json\JsonResource;
+use Illuminate\Support\Facades\Storage;
+
+/**
+ * @property mixed $otp_type
+ * @property string $account
+ * @property string $service
+ * @property string|null $icon
+ * @property string|null $icon_file
+ * @property string $secret
+ * @property int $digits
+ * @property string $algorithm
+ * @property int|null $period
+ * @property int|null $counter
+ * @property string $legacy_uri
+ */
+class TwoFAccountExportResource extends JsonResource
+{
+    /**
+     * Transform the resource into an array.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return array
+     */
+    public function toArray($request)
+    {
+        return [
+            'otp_type'   => $this->otp_type,
+            'account'    => $this->account,
+            'service'    => $this->service,
+            'icon'       => $this->icon,
+            'icon_mime'  => $this->icon ? Storage::disk('icons')->mimeType((string) $this->icon) : null,
+            'icon_file'  => $this->icon ? base64_encode(Storage::disk('icons')->get((string) $this->icon)) : null,
+            'secret'     => $this->secret,
+            'digits'     => (int) $this->digits,
+            'algorithm'  => $this->algorithm,
+            'period'     => is_null($this->period) ? null : (int) $this->period,
+            'counter'    => is_null($this->counter) ? null : (int) $this->counter,
+            'legacy_uri' => $this->legacy_uri,
+        ];
+    }
+}
diff --git a/app/Services/TwoFAccountService.php b/app/Services/TwoFAccountService.php
index 6b3886b1..bcdbb57b 100644
--- a/app/Services/TwoFAccountService.php
+++ b/app/Services/TwoFAccountService.php
@@ -58,6 +58,20 @@ public function migrate(string $migrationPayload) : Collection
         return self::markAsDuplicate($twofaccounts);
     }
 
+    /**
+     * Export one or more twofaccounts
+     *
+     * @param  int|array|string  $ids twofaccount ids to delete
+     * @return \Illuminate\Support\Collection<int, TwoFAccount> The converted accounts
+     */
+    public static function export($ids) : Collection
+    {
+        $ids          = self::commaSeparatedToArray($ids);
+        $twofaccounts = TwoFAccount::whereIn('id', $ids)->get();
+
+        return $twofaccounts;
+    }
+
     /**
      * Delete one or more twofaccounts
      *
diff --git a/package-lock.json b/package-lock.json
index caca4d86..bcc45836 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
                 "bulma": "^0.9.3",
                 "bulma-checkradio": "^2.1.2",
                 "bulma-switch": "^2.0.0",
+                "file-saver": "^2.0.5",
                 "object-equals": "^0.3.0",
                 "v-clipboard": "^2.2.3",
                 "vue": "^2.6.14",
@@ -4696,6 +4697,11 @@
                 "url": "https://opencollective.com/webpack"
             }
         },
+        "node_modules/file-saver": {
+            "version": "2.0.5",
+            "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
+            "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
+        },
         "node_modules/file-type": {
             "version": "12.4.2",
             "resolved": "https://registry.npmjs.org/file-type/-/file-type-12.4.2.tgz",
@@ -13330,6 +13336,11 @@
                 }
             }
         },
+        "file-saver": {
+            "version": "2.0.5",
+            "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
+            "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
+        },
         "file-type": {
             "version": "12.4.2",
             "resolved": "https://registry.npmjs.org/file-type/-/file-type-12.4.2.tgz",
diff --git a/package.json b/package.json
index 68f5e8e4..bcd09fea 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
         "bulma": "^0.9.3",
         "bulma-checkradio": "^2.1.2",
         "bulma-switch": "^2.0.0",
+        "file-saver": "^2.0.5",
         "object-equals": "^0.3.0",
         "v-clipboard": "^2.2.3",
         "vue": "^2.6.14",
diff --git a/resources/js/packages/fontawesome.js b/resources/js/packages/fontawesome.js
index 3bd6f683..81371f98 100644
--- a/resources/js/packages/fontawesome.js
+++ b/resources/js/packages/fontawesome.js
@@ -1,4 +1,4 @@
-import Vue  from 'vue'
+import Vue from 'vue'
 
 import { library } from '@fortawesome/fontawesome-svg-core'
 import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
@@ -37,7 +37,8 @@ import {
     faEye,
     faEyeSlash,
     faExternalLinkAlt,
-    faCamera
+    faCamera,
+    faFileDownload
 } from '@fortawesome/free-solid-svg-icons'
 
 import {
@@ -79,7 +80,8 @@ library.add(
     faEye,
     faEyeSlash,
     faExternalLinkAlt,
-    faCamera
+    faCamera,
+    faFileDownload
 );
 
 Vue.component('font-awesome-icon', FontAwesomeIcon)
\ No newline at end of file
diff --git a/resources/js/views/Accounts.vue b/resources/js/views/Accounts.vue
index 7dad0cc0..6d4d9178 100644
--- a/resources/js/views/Accounts.vue
+++ b/resources/js/views/Accounts.vue
@@ -99,20 +99,23 @@
                             <div v-if="selectedAccounts.length > 0" class="control">
                                 <div tabindex="0" role="button" class="tag-button tag-button-link tags are-medium has-addons is-clickable" @click="showGroupSelector = true" @keyup.enter="showGroupSelector = true">
                                     <span class="tag is-dark mb-0">
-                                        {{ $t('groups.change_group') }}
-                                    </span>
-                                    <span class="tag is-link mb-0">
+                                        {{ $t('groups.change_group') }}&nbsp;&nbsp;
                                         <font-awesome-icon :icon="['fas', 'layer-group']" />
                                     </span>
                                 </div>
                             </div>
+                            <!-- export selected button -->
+                            <div v-if="selectedAccounts.length > 0" class="control">
+                                <div tabindex="0" role="button" class="tag-button tags are-medium has-addons is-clickable" @click="exportAccounts" @keyup.enter="exportAccounts">
+                                    <span class="tag is-dark mb-0">
+                                        <font-awesome-icon :icon="['fas', 'file-download']" />
+                                    </span>
+                                </div>
+                            </div>
                             <!-- delete selected button -->
                             <div v-if="selectedAccounts.length > 0" class="control">
                                 <div tabindex="0" role="button" class="tag-button tag-button-danger tags are-medium has-addons is-clickable" @click="destroyAccounts" @keyup.enter="destroyAccounts">
                                     <span class="tag is-dark mb-0">
-                                        {{ $t('commons.delete') }}
-                                    </span>
-                                    <span class="tag is-danger mb-0">
                                         <font-awesome-icon :icon="['fas', 'trash']" />
                                     </span>
                                 </div>
@@ -272,6 +275,7 @@
     import draggable from 'vuedraggable'
     import Form from './../components/Form'
     import objectEquals from 'object-equals'
+    import { saveAs } from 'file-saver';
 
     export default {
         data(){
@@ -485,6 +489,20 @@
                 }
             },
 
+            /**
+             * Export selected accounts
+             */
+            exportAccounts() {
+                let ids = []
+                this.selectedAccounts.forEach(id => ids.push(id))
+
+                this.axios.get('/api/v1/twofaccounts/export?ids=' + ids.join(), {responseType: 'blob'})
+                    .then((response) => {
+                        var blob = new Blob([response.data], {type: "application/json;charset=utf-8"});
+                        saveAs.saveAs(blob, "2fauth_export.json");
+                    })
+            },
+
             /**
              * Move accounts selected from the Edit mode to another group or withdraw them
              */
diff --git a/routes/api/v1.php b/routes/api/v1.php
index e1ba53bd..843dd5c4 100644
--- a/routes/api/v1.php
+++ b/routes/api/v1.php
@@ -36,6 +36,7 @@
     Route::post('twofaccounts/reorder', [TwoFAccountController::class, 'reorder'])->name('twofaccounts.reorder');
     Route::post('twofaccounts/migration', [TwoFAccountController::class, 'migrate'])->name('twofaccounts.migrate');
     Route::post('twofaccounts/preview', [TwoFAccountController::class, 'preview'])->name('twofaccounts.preview');
+    Route::get('twofaccounts/export', [TwoFAccountController::class, 'export'])->name('twofaccounts.export');
     Route::get('twofaccounts/{twofaccount}/qrcode', [QrCodeController::class, 'show'])->name('twofaccounts.show.qrcode');
     Route::get('twofaccounts/count', [TwoFAccountController::class, 'count'])->name('twofaccounts.count');
     Route::get('twofaccounts/{id}/otp', [TwoFAccountController::class, 'otp'])->where('id', '[0-9]+')->name('twofaccounts.show.otp');