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 ( + + + + + + ) +} + +export default AccountExportPage + +export async function getStaticProps({ locale }) { + return { + props: { + ...(await serverSideTranslations(locale, ns)), + page: { + locale, + path: ['account', 'export'], + }, + }, + } +} diff --git a/sites/shared/components/account/en.yaml b/sites/shared/components/account/en.yaml index 43b36f98ea1..2d922275260 100644 --- a/sites/shared/components/account/en.yaml +++ b/sites/shared/components/account/en.yaml @@ -48,6 +48,9 @@ developer: Developer reload: Reload account 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 restrict: Restrict processing of your data disable: Disable your account @@ -208,5 +211,6 @@ generateANewThing: "Generate a new { thing }" website: Website linkedIdentities: Linked Identities +websiteTitle: Do you have a website or other URL you'd like to add? 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. diff --git a/sites/shared/components/account/export.mjs b/sites/shared/components/account/export.mjs new file mode 100644 index 00000000000..a3b8071cbc1 --- /dev/null +++ b/sites/shared/components/account/export.mjs @@ -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 ( +
+ + {link ? ( + +
{t('exportDownload')}
+

+ +

+
+ ) : null} +

{t('exportMsg')}

+ + +
+ ) +} diff --git a/sites/shared/components/account/github.mjs b/sites/shared/components/account/github.mjs index c57360a77e9..2207f4bb76c 100644 --- a/sites/shared/components/account/github.mjs +++ b/sites/shared/components/account/github.mjs @@ -4,25 +4,20 @@ import { useTranslation } from 'next-i18next' // Hooks import { useAccount } from 'shared/hooks/use-account.mjs' import { useBackend } from 'shared/hooks/use-backend.mjs' -import { useToast } from 'shared/hooks/use-toast.mjs' -// Context -import { LoadingContext } from 'shared/context/loading-context.mjs' +import { useLoadingStatus } from 'shared/hooks/use-loading-status.mjs' // Components import { BackToAccountButton } from './shared.mjs' import { SaveSettingsButton } from 'shared/components/buttons/save-settings-button.mjs' import { Popout } from 'shared/components/popout/index.mjs' -export const ns = ['account', 'toast'] - -export const GithubSettings = ({ title = false, welcome = false }) => { - // Context - const { loading, startLoading, stopLoading } = useContext(LoadingContext) +export const ns = ['account', 'status'] +export const GithubSettings = () => { // Hooks const { account, setAccount, token } = useAccount() const backend = useBackend(token) const { t } = useTranslation(ns) - const toast = useToast() + const { setLoadingStatus, LoadingStatus } = useLoadingStatus() // State const [githubUsername, setGithubUsername] = useState(account.data.githubUsername || '') @@ -30,18 +25,18 @@ export const GithubSettings = ({ title = false, welcome = false }) => { // Helper method to save changes const save = async () => { - startLoading() + setLoadingStatus([true, 'processingUpdate']) const result = await backend.updateAccount({ data: { githubUsername, githubEmail } }) if (result.success) { setAccount(result.data.account) - toast.for.settingsSaved() - } else toast.for.backendError() - stopLoading() + setLoadingStatus([true, 'settingsSaved', true, true]) + } else setLoadingStatus([true, 'backendError', true, false]) } return (
- {title ?

{t('githubTitle')}

: null} + +

{t('githubTitle')}

{ />
- {!welcome && } +

{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 (
-

{t('account:platformTitle', { platform: platform })}

+

+ {t(platform === 'website' ? 'account:websiteTitle' : 'account:platformTitle', { + platform: platform, + })} +