diff --git a/sites/backend/src/config.mjs b/sites/backend/src/config.mjs
index f71650097d3..850399b29e9 100644
--- a/sites/backend/src/config.mjs
+++ b/sites/backend/src/config.mjs
@@ -101,6 +101,10 @@ const baseConfig = {
newsletter: [true, false],
},
},
+ exports: {
+ dir: process.env.BACKEND_EXPORTS_DIR || '/tmp',
+ url: process.env.BACKEND_EXPORTS_URL || 'https://static3.freesewing.org/export/',
+ },
github: {
token: process.env.BACKEND_GITHUB_TOKEN,
},
@@ -237,6 +241,7 @@ export const forwardmx = config.forwardmx || {}
export const website = config.website
export const githubToken = config.github.token
export const instance = config.instance
+export const exports = config.exports
const vars = {
BACKEND_DB_URL: ['required', 'db.url'],
diff --git a/sites/backend/src/controllers/users.mjs b/sites/backend/src/controllers/users.mjs
index 02ead8b7d4d..ca8c0dc6ae3 100644
--- a/sites/backend/src/controllers/users.mjs
+++ b/sites/backend/src/controllers/users.mjs
@@ -129,6 +129,18 @@ UsersController.prototype.allData = async (req, res, tools) => {
return User.sendResponse(res)
}
+/*
+ * Exports all account data
+ *
+ * See: https://freesewing.dev/reference/backend/api
+ */
+UsersController.prototype.exportAccount = async (req, res, tools) => {
+ const User = new UserModel(tools)
+ await User.exportAccount(req)
+
+ return User.sendResponse(res)
+}
+
/*
* Checks whether a submitted username is available
*
diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs
index 5d7cd61e516..23ae817e1e4 100644
--- a/sites/backend/src/models/user.mjs
+++ b/sites/backend/src/models/user.mjs
@@ -2,7 +2,7 @@ import jwt from 'jsonwebtoken'
import { log } from '../utils/log.mjs'
import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs'
import { replaceImage, importImage } from '../utils/cloudflare-images.mjs'
-import { clean, asJson, i18nUrl } from '../utils/index.mjs'
+import { clean, asJson, i18nUrl, writeExportedData } from '../utils/index.mjs'
import { decorateModel } from '../utils/model-decorator.mjs'
/*
@@ -55,7 +55,6 @@ UserModel.prototype.profile = async function ({ params }) {
* Loads a user from the database based on the where clause you pass it
* In addition prepares it for returning all account data
* This is guarded so it enforces access control and validates input
- * This is an anonymous route returning limited info (profile data)
*
* @param {params} object - The request (URL) parameters
* @returns {UserModel} object - The UserModel
@@ -88,6 +87,29 @@ UserModel.prototype.allData = async function ({ params }) {
})
}
+/*
+ * Exports all account data
+ *
+ * @param {user} object - The user as loaded by the authentication middleware
+ * @returns {UserModel} object - The UserModel
+ */
+UserModel.prototype.exportAccount = async function ({ user }) {
+ /*
+ * Read the record from the database
+ */
+ await this.read({ id: user.uid }, { apikeys: true, bookmarks: true, patterns: true, sets: true })
+
+ /*
+ * If it does not exist, return 404
+ */
+ if (!this.exists) return this.setResponse(404)
+
+ return this.setResponse200({
+ result: 'success',
+ data: writeExportedData(this.asExport()),
+ })
+}
+
/*
* Loads a user from the database based on the where clause you pass it
* In addition prepares it for returning the account data
@@ -1281,6 +1303,21 @@ UserModel.prototype.asData = function () {
}
}
+/*
+ * Returns all user data to be exported
+ *
+ * @return {account} object - The account data as a plain object
+ */
+UserModel.prototype.asExport = function () {
+ /*
+ * Get both account data and all data
+ */
+ return {
+ ...this.asAccount(),
+ ...this.asData(),
+ }
+}
+
/*
* Returns a list of records as search results
* Typically used by admin search
diff --git a/sites/backend/src/routes/users.mjs b/sites/backend/src/routes/users.mjs
index b4f8e6e5842..836679a6259 100644
--- a/sites/backend/src/routes/users.mjs
+++ b/sites/backend/src/routes/users.mjs
@@ -66,6 +66,13 @@ export function usersRoutes(tools) {
// Load a user profile
app.get('/users/:id', (req, res) => Users.profile(req, res, tools))
+ // Export account data
+ app.get('/account/export/jwt', passport.authenticate(...jwt), (req, res) =>
+ Users.exportAccount(req, res, tools)
+ )
+ app.get('/account/export/key', passport.authenticate(...bsc), (req, res) =>
+ Users.exportAccount(req, res, tools)
+ )
/*
// Remove account
diff --git a/sites/backend/src/utils/index.mjs b/sites/backend/src/utils/index.mjs
index 4c72ea9451a..55826b8dd6c 100644
--- a/sites/backend/src/utils/index.mjs
+++ b/sites/backend/src/utils/index.mjs
@@ -1,4 +1,8 @@
-import { website } from '../config.mjs'
+import { log } from './log.mjs'
+import { website, exports } from '../config.mjs'
+import { randomString } from './crypto.mjs'
+import path from 'path'
+import fs from 'fs'
/*
* Capitalizes a string
@@ -30,3 +34,18 @@ export const i18nUrl = (lang, path) => {
return url + path
}
+
+/*
+ * Writes a pojo to disk as JSON under a random name
+ * It is used to export account data
+ */
+export const writeExportedData = (data) => {
+ const name = randomString()
+ try {
+ fs.writeFileSync(`${exports.dir}${name}.json`, JSON.stringify(data, null, 2))
+ } catch (err) {
+ log.warn(err, 'Failed to write export file')
+ }
+
+ return exports.url + name + '.json'
+}
diff --git a/sites/org/pages/account/export.mjs b/sites/org/pages/account/export.mjs
new file mode 100644
index 00000000000..af4eeea480d
--- /dev/null
+++ b/sites/org/pages/account/export.mjs
@@ -0,0 +1,59 @@
+// Dependencies
+import dynamic from 'next/dynamic'
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
+import { nsMerge } from 'shared/utils.mjs'
+// Hooks
+import { useTranslation } from 'next-i18next'
+// Components
+import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs'
+import { ns as authNs } from 'shared/components/wrappers/auth/index.mjs'
+import { ns as reloadNs } from 'shared/components/account/reload.mjs'
+
+// Translation namespaces used on this page
+const ns = nsMerge(reloadNs, authNs, pageNs)
+
+/*
+ * Some things should never generated as SSR
+ * So for these, we run a dynamic import and disable SSR rendering
+ */
+const DynamicAuthWrapper = dynamic(
+ () => import('shared/components/wrappers/auth/index.mjs').then((mod) => mod.AuthWrapper),
+ { ssr: false }
+)
+
+const DynamicExport = dynamic(
+ () => import('shared/components/account/export.mjs').then((mod) => mod.ExportAccount),
+ { ssr: false }
+)
+
+/*
+ * Each page MUST be wrapped in the PageWrapper component.
+ * You also MUST spread props.page into this wrapper component
+ * when path and locale come from static props (as here)
+ * or set them manually.
+ */
+const AccountExportPage = ({ page }) => {
+ const { t } = useTranslation(ns)
+
+ return (
+
+
{t('exportMsg')}
+ +{t('githubWhy1')}
{t('githubWhy2')}
diff --git a/sites/shared/components/account/platform.mjs b/sites/shared/components/account/platform.mjs index ed193738967..387f5753dfc 100644 --- a/sites/shared/components/account/platform.mjs +++ b/sites/shared/components/account/platform.mjs @@ -37,7 +37,11 @@ export const PlatformSettings = ({ platform }) => { return (