1
0
Fork 0

feat(org/backend): Added account data export

This commit is contained in:
joostdecock 2023-08-20 17:32:07 +02:00
parent ac5a441d7b
commit 5d1249c419
11 changed files with 220 additions and 19 deletions

View file

@ -101,6 +101,10 @@ const baseConfig = {
newsletter: [true, false], newsletter: [true, false],
}, },
}, },
exports: {
dir: process.env.BACKEND_EXPORTS_DIR || '/tmp',
url: process.env.BACKEND_EXPORTS_URL || 'https://static3.freesewing.org/export/',
},
github: { github: {
token: process.env.BACKEND_GITHUB_TOKEN, token: process.env.BACKEND_GITHUB_TOKEN,
}, },
@ -237,6 +241,7 @@ export const forwardmx = config.forwardmx || {}
export const website = config.website export const website = config.website
export const githubToken = config.github.token export const githubToken = config.github.token
export const instance = config.instance export const instance = config.instance
export const exports = config.exports
const vars = { const vars = {
BACKEND_DB_URL: ['required', 'db.url'], BACKEND_DB_URL: ['required', 'db.url'],

View file

@ -129,6 +129,18 @@ UsersController.prototype.allData = async (req, res, tools) => {
return User.sendResponse(res) 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 * Checks whether a submitted username is available
* *

View file

@ -2,7 +2,7 @@ import jwt from 'jsonwebtoken'
import { log } from '../utils/log.mjs' import { log } from '../utils/log.mjs'
import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs' import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs'
import { replaceImage, importImage } from '../utils/cloudflare-images.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' 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 * Loads a user from the database based on the where clause you pass it
* In addition prepares it for returning all account data * In addition prepares it for returning all account data
* This is guarded so it enforces access control and validates input * 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 * @param {params} object - The request (URL) parameters
* @returns {UserModel} object - The UserModel * @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 * Loads a user from the database based on the where clause you pass it
* In addition prepares it for returning the account data * 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 * Returns a list of records as search results
* Typically used by admin search * Typically used by admin search

View file

@ -66,6 +66,13 @@ export function usersRoutes(tools) {
// Load a user profile // Load a user profile
app.get('/users/:id', (req, res) => Users.profile(req, res, tools)) 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 // Remove account

View file

@ -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 * Capitalizes a string
@ -30,3 +34,18 @@ export const i18nUrl = (lang, path) => {
return url + 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'
}

View file

@ -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 (
<PageWrapper {...page} title={t('export')}>
<DynamicAuthWrapper>
<DynamicExport title />
</DynamicAuthWrapper>
</PageWrapper>
)
}
export default AccountExportPage
export async function getStaticProps({ locale }) {
return {
props: {
...(await serverSideTranslations(locale, ns)),
page: {
locale,
path: ['account', 'export'],
},
},
}
}

View file

@ -48,6 +48,9 @@ developer: Developer
reload: Reload account reload: Reload account
export: Export your data export: Export your data
exportMsg: Click below to export your personal data
exportNote: The EU's General Data Protection Regulation (GDPR) ensures your so-called right to data portability — the right to obtain and reuse your personal data for your own purposes, or across different services.
exportDownload: "Your data was exported and is available for download at the following location:"
review: Review your consent review: Review your consent
restrict: Restrict processing of your data restrict: Restrict processing of your data
disable: Disable your account disable: Disable your account
@ -208,5 +211,6 @@ generateANewThing: "Generate a new { thing }"
website: Website website: Website
linkedIdentities: Linked Identities linkedIdentities: Linked Identities
websiteTitle: Do you have a website or other URL you'd like to add?
platformTitle: Who are you on { platform }? platformTitle: Who are you on { platform }?
platformWhy: We do not use this data in any way. This is only here so FreeSewing users can connect the dots across platforms. platformWhy: We do not use this data in any way. This is only here so FreeSewing users can connect the dots across platforms.

View file

@ -0,0 +1,52 @@
// Dependencies
import { useTranslation } from 'next-i18next'
// Hooks
import { useState } from 'react'
import { useAccount } from 'shared/hooks/use-account.mjs'
import { useBackend } from 'shared/hooks/use-backend.mjs'
import { useLoadingStatus } from 'shared/hooks/use-loading-status.mjs'
// Components
import { BackToAccountButton } from './shared.mjs'
import { Popout } from 'shared/components/popout/index.mjs'
import { WebLink } from 'shared/components/web-link.mjs'
export const ns = ['account', 'status']
export const ExportAccount = () => {
// Hooks
const { setAccount, token } = useAccount()
const backend = useBackend(token)
const { t } = useTranslation(ns)
const { setLoadingStatus, LoadingStatus } = useLoadingStatus()
const [link, setLink] = useState()
// Helper method to export account
const exportData = async () => {
setLoadingStatus([true, 'processingUpdate'])
const result = await backend.exportAccount()
if (result.success) {
setLink(result.data.data)
setLoadingStatus([true, 'nailedIt', true, true])
} else setLoadingStatus([true, 'backendError', true, false])
}
return (
<div className="max-w-xl">
<LoadingStatus />
{link ? (
<Popout link>
<h5>{t('exportDownload')}</h5>
<p className="text-lg">
<WebLink href={link} txt={link} />
</p>
</Popout>
) : null}
<p>{t('exportMsg')}</p>
<button className="btn btn-primary capitalize w-full my-2" onClick={exportData}>
{t('export')}
</button>
<BackToAccountButton />
</div>
)
}

View file

@ -4,25 +4,20 @@ import { useTranslation } from 'next-i18next'
// Hooks // Hooks
import { useAccount } from 'shared/hooks/use-account.mjs' import { useAccount } from 'shared/hooks/use-account.mjs'
import { useBackend } from 'shared/hooks/use-backend.mjs' import { useBackend } from 'shared/hooks/use-backend.mjs'
import { useToast } from 'shared/hooks/use-toast.mjs' import { useLoadingStatus } from 'shared/hooks/use-loading-status.mjs'
// Context
import { LoadingContext } from 'shared/context/loading-context.mjs'
// Components // Components
import { BackToAccountButton } from './shared.mjs' import { BackToAccountButton } from './shared.mjs'
import { SaveSettingsButton } from 'shared/components/buttons/save-settings-button.mjs' import { SaveSettingsButton } from 'shared/components/buttons/save-settings-button.mjs'
import { Popout } from 'shared/components/popout/index.mjs' import { Popout } from 'shared/components/popout/index.mjs'
export const ns = ['account', 'toast'] export const ns = ['account', 'status']
export const GithubSettings = ({ title = false, welcome = false }) => {
// Context
const { loading, startLoading, stopLoading } = useContext(LoadingContext)
export const GithubSettings = () => {
// Hooks // Hooks
const { account, setAccount, token } = useAccount() const { account, setAccount, token } = useAccount()
const backend = useBackend(token) const backend = useBackend(token)
const { t } = useTranslation(ns) const { t } = useTranslation(ns)
const toast = useToast() const { setLoadingStatus, LoadingStatus } = useLoadingStatus()
// State // State
const [githubUsername, setGithubUsername] = useState(account.data.githubUsername || '') const [githubUsername, setGithubUsername] = useState(account.data.githubUsername || '')
@ -30,18 +25,18 @@ export const GithubSettings = ({ title = false, welcome = false }) => {
// Helper method to save changes // Helper method to save changes
const save = async () => { const save = async () => {
startLoading() setLoadingStatus([true, 'processingUpdate'])
const result = await backend.updateAccount({ data: { githubUsername, githubEmail } }) const result = await backend.updateAccount({ data: { githubUsername, githubEmail } })
if (result.success) { if (result.success) {
setAccount(result.data.account) setAccount(result.data.account)
toast.for.settingsSaved() setLoadingStatus([true, 'settingsSaved', true, true])
} else toast.for.backendError() } else setLoadingStatus([true, 'backendError', true, false])
stopLoading()
} }
return ( return (
<div className="max-w-xl"> <div className="max-w-xl">
{title ? <h2 className="text-4xl">{t('githubTitle')}</h2> : null} <LoadingStatus />
<h2 className="text-4xl">{t('githubTitle')}</h2>
<label className="font-bold">{t('username')}</label> <label className="font-bold">{t('username')}</label>
<div className="flex flex-row items-center mb-4"> <div className="flex flex-row items-center mb-4">
<input <input
@ -63,7 +58,7 @@ export const GithubSettings = ({ title = false, welcome = false }) => {
/> />
</div> </div>
<SaveSettingsButton btnProps={{ onClick: save }} /> <SaveSettingsButton btnProps={{ onClick: save }} />
{!welcome && <BackToAccountButton loading={loading} />} <BackToAccountButton />
<Popout note> <Popout note>
<p className="text-sm font-bold">{t('githubWhy1')}</p> <p className="text-sm font-bold">{t('githubWhy1')}</p>
<p className="text-sm">{t('githubWhy2')}</p> <p className="text-sm">{t('githubWhy2')}</p>

View file

@ -37,7 +37,11 @@ export const PlatformSettings = ({ platform }) => {
return ( return (
<div className="max-w-xl"> <div className="max-w-xl">
<LoadingStatus /> <LoadingStatus />
<h2 className="text-4xl">{t('account:platformTitle', { platform: platform })}</h2> <h2 className="text-4xl">
{t(platform === 'website' ? 'account:websiteTitle' : 'account:platformTitle', {
platform: platform,
})}
</h2>
<div className="flex flex-row items-center mb-4"> <div className="flex flex-row items-center mb-4">
<input <input
value={platformId} value={platformId}

View file

@ -164,6 +164,13 @@ Backend.prototype.reloadAccount = async function () {
return responseHandler(await await api.get(`/whoami/jwt`, this.auth)) return responseHandler(await await api.get(`/whoami/jwt`, this.auth))
} }
/*
* Export account data
*/
Backend.prototype.exportAccount = async function () {
return responseHandler(await await api.get(`/account/export/jwt`, this.auth))
}
/* /*
* Load all user data * Load all user data
*/ */