From 90f413ea797dff62da54b35b01fbdf074aeac566 Mon Sep 17 00:00:00 2001 From: Bram Borggreve Date: Thu, 25 Jan 2024 05:19:21 +0000 Subject: [PATCH] feat: add backup feature --- api-schema.graphql | 6 + libs/api/backup/data-access/.eslintrc.json | 18 ++ libs/api/backup/data-access/README.md | 7 + libs/api/backup/data-access/jest.config.ts | 11 + libs/api/backup/data-access/project.json | 23 ++ libs/api/backup/data-access/src/index.ts | 2 + .../src/lib/api-backup-data-access.module.ts | 10 + .../data-access/src/lib/api-backup.service.ts | 203 +++++++++++++++++ libs/api/backup/data-access/tsconfig.json | 22 ++ libs/api/backup/data-access/tsconfig.lib.json | 16 ++ .../api/backup/data-access/tsconfig.spec.json | 9 + libs/api/backup/feature/.eslintrc.json | 18 ++ libs/api/backup/feature/README.md | 7 + libs/api/backup/feature/jest.config.ts | 11 + libs/api/backup/feature/project.json | 23 ++ libs/api/backup/feature/src/index.ts | 1 + .../src/lib/api-admin-backup.resolver.ts | 41 ++++ .../src/lib/api-backup-feature.module.ts | 11 + .../feature/src/lib/api-backup.controller.ts | 15 ++ libs/api/backup/feature/tsconfig.json | 22 ++ libs/api/backup/feature/tsconfig.lib.json | 16 ++ libs/api/backup/feature/tsconfig.spec.json | 9 + .../src/lib/api-core-feature.module.ts | 2 + libs/sdk/src/generated/graphql-sdk.ts | 210 ++++++++++++++++++ libs/sdk/src/graphql/feature-backup.graphql | 23 ++ .../feature/src/lib/web-dev-admin-routes.tsx | 2 + .../src/lib/web-dev-backup-feature.tsx | 156 +++++++++++++ tsconfig.base.json | 2 + 28 files changed, 896 insertions(+) create mode 100644 libs/api/backup/data-access/.eslintrc.json create mode 100644 libs/api/backup/data-access/README.md create mode 100644 libs/api/backup/data-access/jest.config.ts create mode 100644 libs/api/backup/data-access/project.json create mode 100644 libs/api/backup/data-access/src/index.ts create mode 100644 libs/api/backup/data-access/src/lib/api-backup-data-access.module.ts create mode 100644 libs/api/backup/data-access/src/lib/api-backup.service.ts create mode 100644 libs/api/backup/data-access/tsconfig.json create mode 100644 libs/api/backup/data-access/tsconfig.lib.json create mode 100644 libs/api/backup/data-access/tsconfig.spec.json create mode 100644 libs/api/backup/feature/.eslintrc.json create mode 100644 libs/api/backup/feature/README.md create mode 100644 libs/api/backup/feature/jest.config.ts create mode 100644 libs/api/backup/feature/project.json create mode 100644 libs/api/backup/feature/src/index.ts create mode 100644 libs/api/backup/feature/src/lib/api-admin-backup.resolver.ts create mode 100644 libs/api/backup/feature/src/lib/api-backup-feature.module.ts create mode 100644 libs/api/backup/feature/src/lib/api-backup.controller.ts create mode 100644 libs/api/backup/feature/tsconfig.json create mode 100644 libs/api/backup/feature/tsconfig.lib.json create mode 100644 libs/api/backup/feature/tsconfig.spec.json create mode 100644 libs/sdk/src/graphql/feature-backup.graphql create mode 100644 libs/web/dev/feature/src/lib/web-dev-backup-feature.tsx diff --git a/api-schema.graphql b/api-schema.graphql index 28c0940..be900fb 100644 --- a/api-schema.graphql +++ b/api-schema.graphql @@ -377,6 +377,7 @@ type Mutation { adminAddDiscordRoleConditionCollection(collectionId: String!, conditionId: String!): Boolean adminAddDiscordRoleConditionCombo(comboId: String!, conditionId: String!): Boolean adminCleanQueue(type: QueueType!): Boolean + adminCreateBackup: Boolean! adminCreateCollection(input: AdminCreateCollectionInput!): Collection adminCreateCollectionCombo(input: AdminCreateCollectionComboInput!): CollectionCombo adminCreateDiscordRole(input: AdminCreateDiscordRoleInput!): Boolean @@ -387,6 +388,7 @@ type Mutation { adminCreateNetworkToken(input: AdminCreateNetworkTokenInput!): NetworkToken adminCreateUser(input: AdminCreateUserInput!): User adminDeleteAsset(assetId: String!): Boolean + adminDeleteBackup(name: String!): Boolean! adminDeleteCollection(collectionId: String!): Boolean adminDeleteCollectionCombo(collectionComboId: String!): Boolean adminDeleteDiscordRole(input: AdminDeleteDiscordRoleInput!): Boolean @@ -398,10 +400,12 @@ type Mutation { adminDeleteNetworkToken(networkTokenId: String!): Boolean adminDeleteQueueJob(jobId: String!, type: QueueType!): Boolean adminDeleteUser(userId: String!): Boolean + adminFetchBackup(url: String!): Boolean! adminPauseQueue(type: QueueType!): Boolean adminRemoveCollectionComboAttribute(assetAttributeId: String!, collectionComboId: String!): CollectionCombo adminRemoveDiscordRoleConditionCollection(collectionId: String!, conditionId: String!): Boolean adminRemoveDiscordRoleConditionCombo(comboId: String!, conditionId: String!): Boolean + adminRestoreBackup(name: String!): Boolean! adminResumeQueue(type: QueueType!): Boolean adminSyncCollection(collectionId: String!): String adminSyncDiscordRoles(serverId: String!): Boolean @@ -483,6 +487,8 @@ type Query { adminFindOneDiscordServer(serverId: String!): DiscordServer adminFindOneNetwork(networkId: String!): Network adminFindOneUser(userId: String!): User + adminGetBackup(name: String!): JSON + adminGetBackups: [String!]! adminGetBotInviteUrl: String adminGetQueue(type: QueueType!): Queue adminGetQueueJobs(statuses: [JobStatus!]!, type: QueueType!): [Job!] diff --git a/libs/api/backup/data-access/.eslintrc.json b/libs/api/backup/data-access/.eslintrc.json new file mode 100644 index 0000000..632e9b0 --- /dev/null +++ b/libs/api/backup/data-access/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/api/backup/data-access/README.md b/libs/api/backup/data-access/README.md new file mode 100644 index 0000000..10299d7 --- /dev/null +++ b/libs/api/backup/data-access/README.md @@ -0,0 +1,7 @@ +# api-backup-data-access + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test api-backup-data-access` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/api/backup/data-access/jest.config.ts b/libs/api/backup/data-access/jest.config.ts new file mode 100644 index 0000000..4fd38fb --- /dev/null +++ b/libs/api/backup/data-access/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'api-backup-data-access', + preset: '../../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../../coverage/libs/api/backup/data-access', +} diff --git a/libs/api/backup/data-access/project.json b/libs/api/backup/data-access/project.json new file mode 100644 index 0000000..eb4137e --- /dev/null +++ b/libs/api/backup/data-access/project.json @@ -0,0 +1,23 @@ +{ + "name": "api-backup-data-access", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/api/backup/data-access/src", + "projectType": "library", + "targets": { + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/api/backup/data-access/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/api/backup/data-access/jest.config.ts" + } + } + }, + "tags": ["api", "data-access"] +} diff --git a/libs/api/backup/data-access/src/index.ts b/libs/api/backup/data-access/src/index.ts new file mode 100644 index 0000000..10e9443 --- /dev/null +++ b/libs/api/backup/data-access/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/api-backup-data-access.module' +export * from './lib/api-backup.service' diff --git a/libs/api/backup/data-access/src/lib/api-backup-data-access.module.ts b/libs/api/backup/data-access/src/lib/api-backup-data-access.module.ts new file mode 100644 index 0000000..fee6f9c --- /dev/null +++ b/libs/api/backup/data-access/src/lib/api-backup-data-access.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { ApiCoreDataAccessModule } from '@pubkey-link/api/core/data-access' +import { ApiBackupService } from './api-backup.service' + +@Module({ + imports: [ApiCoreDataAccessModule], + providers: [ApiBackupService], + exports: [ApiBackupService], +}) +export class ApiBackupDataAccessModule {} diff --git a/libs/api/backup/data-access/src/lib/api-backup.service.ts b/libs/api/backup/data-access/src/lib/api-backup.service.ts new file mode 100644 index 0000000..da0dbf8 --- /dev/null +++ b/libs/api/backup/data-access/src/lib/api-backup.service.ts @@ -0,0 +1,203 @@ +import { Injectable, Logger } from '@nestjs/common' +import { ApiCoreService } from '@pubkey-link/api/core/data-access' +import { existsSync } from 'node:fs' +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' + +@Injectable() +export class ApiBackupService { + private readonly logger = new Logger(ApiBackupService.name) + private readonly backupLocation: string + constructor(private readonly core: ApiCoreService) { + this.backupLocation = process.env['BACKUP_LOCATION'] || '/tmp' + } + + async createBackup(adminId: string) { + await this.core.ensureUserAdmin(adminId) + if (!existsSync(this.backupLocation)) { + try { + await mkdir(this.backupLocation) + this.logger.verbose(`Created backup directory at ${this.backupLocation}`) + } catch (error) { + this.logger.error(`Failed to create backup directory: ${error}`) + return false + } + } + + // Generate a secret that will be used to download the backup + const secret = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) + const timestamp = getBackupTimestamp() + const backupName = `${timestamp}${'.backup.json'}` + const backupPath = `${this.backupLocation}/${backupName}` + // await this.core.exec(`pg_dump -Fc > ${backupPath}`) + this.logger.verbose(`Backup created at ${backupPath}`) + + const users = await this.core.data.user.findMany({ + where: {}, + orderBy: { username: 'asc' }, + select: { + username: true, + name: true, + avatarUrl: true, + id: true, + role: true, + developer: true, + status: true, + createdAt: true, + updatedAt: true, + identities: { + select: { + id: true, + provider: true, + providerId: true, + profile: true, + createdAt: true, + updatedAt: true, + }, + }, + }, + }) + + await writeFile( + backupPath, + JSON.stringify( + { + meta: { secret, timestamp, backupName, backupPath }, + data: { users, usersCount: users.length }, + }, + null, + 2, + ), + ) + return true + } + + async deleteBackup(adminId: string, name: string) { + await this.core.ensureUserAdmin(adminId) + const file = this.getBackupPath(name) + if (!existsSync(file)) { + return false + } + try { + await rm(file) + this.logger.verbose(`Deleted backup at ${file}`) + return true + } catch (error) { + this.logger.error(`Failed to delete backup: ${error}`) + return false + } + } + + async fetchBackup(adminId: string, url: string) { + await this.core.ensureUserAdmin(adminId) + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to fetch backup: ${response.statusText}`) + } + const backup = await response.json() + const backupPath = `${this.backupLocation}/${backup.meta.backupName}` + if (existsSync(backupPath)) { + throw new Error(`Backup already exists`) + } + await writeFile(backupPath, JSON.stringify(backup)) + this.logger.verbose(`Backup fetched at ${backupPath}`) + return true + } + + async adminGetBackup(adminId: string, name: string) { + await this.core.ensureUserAdmin(adminId) + // Parse the json file + const backup = await this.readBackupFile(name) + + return { + meta: backup.meta, + usersCount: backup.data.usersCount, + download: this.core.config.apiUrl + `/backup/download?name=${name}&secret=${backup.meta.secret}`, + } + } + + async adminGetBackups(adminId: string): Promise { + await this.core.ensureUserAdmin(adminId) + if (!existsSync(this.backupLocation)) { + return [] + } + const items = await readdir(this.backupLocation) + + return items.filter((item) => item.endsWith('.backup.json')) + } + + async restoreBackup(adminId: string, name: string) { + await this.core.ensureUserAdmin(adminId) + const backup = await this.readBackupFile(name) + + const userIds = await this.core.data.user + .findMany({ select: { id: true } }) + .then((users) => users.map((user) => user.id)) + + const toCreate = backup.data.users.filter((user: { id: string }) => !userIds.includes(user.id)) + if (!toCreate.length) { + this.logger.verbose(`No new users to create`) + return true + } + for (const user of toCreate) { + const { identities, ...userData } = user + const newUser = await this.core.data.user.create({ + data: { ...userData, identities: { create: identities } }, + }) + + this.logger.verbose(`Created user ${newUser.username} with id ${newUser.id} and ${identities.length} identities`) + } + return true + } + + private async readBackupFile(name: string) { + const backupPath = this.ensureBackupFile(name) + // Read the json file + const contents = await readFile(backupPath, 'utf-8') + // Parse the json file + return JSON.parse(contents) + } + + private ensureBackupFile(name: string) { + const backupPath = this.getBackupPath(name) + if (!existsSync(backupPath)) { + throw new Error('Backup file does not exist') + } + + return backupPath + } + + private getBackupPath(name: string) { + if (!isValidFilename(name)) { + throw new Error('Invalid filename') + } + + return `${this.backupLocation}/${name}` + } + + async downloadBackup(name: string, secret: string) { + const backup = await this.readBackupFile(name) + + if (backup.meta.secret !== secret) { + throw new Error('Invalid secret') + } + + return backup + } +} + +function isValidFilename(filename: string) { + // Regular expression for the date-time format + const regexPattern = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z.backup.json$/ + + // Check if the filename matches the pattern + if (!regexPattern.test(filename)) { + return false + } + + // Check for common path traversal patterns + return !/(\.\.\/|\.\.\\)/.test(filename) +} + +function getBackupTimestamp() { + return new Date().toISOString().replace(/:/g, '-').replace(/\./, '-') +} diff --git a/libs/api/backup/data-access/tsconfig.json b/libs/api/backup/data-access/tsconfig.json new file mode 100644 index 0000000..4022fd4 --- /dev/null +++ b/libs/api/backup/data-access/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/api/backup/data-access/tsconfig.lib.json b/libs/api/backup/data-access/tsconfig.lib.json new file mode 100644 index 0000000..e6b7732 --- /dev/null +++ b/libs/api/backup/data-access/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "types": ["node"], + "target": "es2021", + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/api/backup/data-access/tsconfig.spec.json b/libs/api/backup/data-access/tsconfig.spec.json new file mode 100644 index 0000000..56497b8 --- /dev/null +++ b/libs/api/backup/data-access/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/api/backup/feature/.eslintrc.json b/libs/api/backup/feature/.eslintrc.json new file mode 100644 index 0000000..632e9b0 --- /dev/null +++ b/libs/api/backup/feature/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/api/backup/feature/README.md b/libs/api/backup/feature/README.md new file mode 100644 index 0000000..e8bfbcc --- /dev/null +++ b/libs/api/backup/feature/README.md @@ -0,0 +1,7 @@ +# api-backup-feature + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test api-backup-feature` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/api/backup/feature/jest.config.ts b/libs/api/backup/feature/jest.config.ts new file mode 100644 index 0000000..5544224 --- /dev/null +++ b/libs/api/backup/feature/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'api-backup-feature', + preset: '../../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../../coverage/libs/api/backup/feature', +} diff --git a/libs/api/backup/feature/project.json b/libs/api/backup/feature/project.json new file mode 100644 index 0000000..87b005e --- /dev/null +++ b/libs/api/backup/feature/project.json @@ -0,0 +1,23 @@ +{ + "name": "api-backup-feature", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/api/backup/feature/src", + "projectType": "library", + "targets": { + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/api/backup/feature/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/api/backup/feature/jest.config.ts" + } + } + }, + "tags": ["api", "feature"] +} diff --git a/libs/api/backup/feature/src/index.ts b/libs/api/backup/feature/src/index.ts new file mode 100644 index 0000000..1890680 --- /dev/null +++ b/libs/api/backup/feature/src/index.ts @@ -0,0 +1 @@ +export * from './lib/api-backup-feature.module' diff --git a/libs/api/backup/feature/src/lib/api-admin-backup.resolver.ts b/libs/api/backup/feature/src/lib/api-admin-backup.resolver.ts new file mode 100644 index 0000000..0b06e83 --- /dev/null +++ b/libs/api/backup/feature/src/lib/api-admin-backup.resolver.ts @@ -0,0 +1,41 @@ +import { UseGuards } from '@nestjs/common' +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql' +import { ApiAuthGraphqlGuard, CtxUser } from '@pubkey-link/api/auth/data-access' +import { ApiBackupService } from '@pubkey-link/api/backup/data-access' +import { User } from '@pubkey-link/api/user/data-access' +import { GraphQLJSON } from 'graphql-scalars' + +@Resolver() +@UseGuards(ApiAuthGraphqlGuard) +export class ApiAdminBackupResolver { + constructor(private readonly service: ApiBackupService) {} + + @Mutation(() => Boolean) + adminCreateBackup(@CtxUser() user: User) { + return this.service.createBackup(user.id) + } + + @Mutation(() => Boolean) + adminDeleteBackup(@CtxUser() user: User, @Args('name') name: string) { + return this.service.deleteBackup(user.id, name) + } + + @Mutation(() => Boolean) + adminFetchBackup(@CtxUser() user: User, @Args('url') url: string) { + return this.service.fetchBackup(user.id, url) + } + + @Query(() => GraphQLJSON, { nullable: true }) + adminGetBackup(@CtxUser() user: User, @Args('name') name: string) { + return this.service.adminGetBackup(user.id, name) + } + @Query(() => [String]) + adminGetBackups(@CtxUser() user: User) { + return this.service.adminGetBackups(user.id) + } + + @Mutation(() => Boolean) + adminRestoreBackup(@CtxUser() user: User, @Args('name') name: string) { + return this.service.restoreBackup(user.id, name) + } +} diff --git a/libs/api/backup/feature/src/lib/api-backup-feature.module.ts b/libs/api/backup/feature/src/lib/api-backup-feature.module.ts new file mode 100644 index 0000000..46989c4 --- /dev/null +++ b/libs/api/backup/feature/src/lib/api-backup-feature.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { ApiBackupDataAccessModule } from '@pubkey-link/api/backup/data-access' +import { ApiAdminBackupResolver } from './api-admin-backup.resolver' +import { ApiBackupController } from './api-backup.controller' + +@Module({ + controllers: [ApiBackupController], + imports: [ApiBackupDataAccessModule], + providers: [ApiAdminBackupResolver], +}) +export class ApiBackupFeatureModule {} diff --git a/libs/api/backup/feature/src/lib/api-backup.controller.ts b/libs/api/backup/feature/src/lib/api-backup.controller.ts new file mode 100644 index 0000000..d745932 --- /dev/null +++ b/libs/api/backup/feature/src/lib/api-backup.controller.ts @@ -0,0 +1,15 @@ +import { BadRequestException, Controller, Get, Query } from '@nestjs/common' +import { ApiBackupService } from '@pubkey-link/api/backup/data-access' + +@Controller('backup') +export class ApiBackupController { + constructor(private readonly service: ApiBackupService) {} + + @Get('download') + async downloadBackup(@Query('name') name: string, @Query('secret') secret: string) { + if (!name || !secret) { + throw new BadRequestException('Missing name or secret') + } + return this.service.downloadBackup(name, secret) + } +} diff --git a/libs/api/backup/feature/tsconfig.json b/libs/api/backup/feature/tsconfig.json new file mode 100644 index 0000000..4022fd4 --- /dev/null +++ b/libs/api/backup/feature/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/api/backup/feature/tsconfig.lib.json b/libs/api/backup/feature/tsconfig.lib.json new file mode 100644 index 0000000..e6b7732 --- /dev/null +++ b/libs/api/backup/feature/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "types": ["node"], + "target": "es2021", + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/api/backup/feature/tsconfig.spec.json b/libs/api/backup/feature/tsconfig.spec.json new file mode 100644 index 0000000..56497b8 --- /dev/null +++ b/libs/api/backup/feature/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/api/core/feature/src/lib/api-core-feature.module.ts b/libs/api/core/feature/src/lib/api-core-feature.module.ts index 5ff1e4b..823cb19 100644 --- a/libs/api/core/feature/src/lib/api-core-feature.module.ts +++ b/libs/api/core/feature/src/lib/api-core-feature.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common' import { ApiAssetFeatureModule } from '@pubkey-link/api/asset/feature' import { ApiAuthFeatureModule } from '@pubkey-link/api/auth/feature' +import { ApiBackupFeatureModule } from '@pubkey-link/api/backup/feature' import { ApiCollectionComboFeatureModule } from '@pubkey-link/api/collection-combo/feature' import { ApiCollectionFeatureModule } from '@pubkey-link/api/collection/feature' import { ApiCoreDataAccessModule } from '@pubkey-link/api/core/data-access' @@ -19,6 +20,7 @@ import { ApiCoreResolver } from './api-core.resolver' const imports = [ ApiAssetFeatureModule, ApiAuthFeatureModule, + ApiBackupFeatureModule, ApiCollectionComboFeatureModule, ApiCollectionFeatureModule, ApiCoreDataAccessModule, diff --git a/libs/sdk/src/generated/graphql-sdk.ts b/libs/sdk/src/generated/graphql-sdk.ts index 6a66942..4f7e2c0 100644 --- a/libs/sdk/src/generated/graphql-sdk.ts +++ b/libs/sdk/src/generated/graphql-sdk.ts @@ -407,6 +407,7 @@ export type Mutation = { adminAddDiscordRoleConditionCollection?: Maybe adminAddDiscordRoleConditionCombo?: Maybe adminCleanQueue?: Maybe + adminCreateBackup: Scalars['Boolean']['output'] adminCreateCollection?: Maybe adminCreateCollectionCombo?: Maybe adminCreateDiscordRole?: Maybe @@ -417,6 +418,7 @@ export type Mutation = { adminCreateNetworkToken?: Maybe adminCreateUser?: Maybe adminDeleteAsset?: Maybe + adminDeleteBackup: Scalars['Boolean']['output'] adminDeleteCollection?: Maybe adminDeleteCollectionCombo?: Maybe adminDeleteDiscordRole?: Maybe @@ -428,10 +430,12 @@ export type Mutation = { adminDeleteNetworkToken?: Maybe adminDeleteQueueJob?: Maybe adminDeleteUser?: Maybe + adminFetchBackup: Scalars['Boolean']['output'] adminPauseQueue?: Maybe adminRemoveCollectionComboAttribute?: Maybe adminRemoveDiscordRoleConditionCollection?: Maybe adminRemoveDiscordRoleConditionCombo?: Maybe + adminRestoreBackup: Scalars['Boolean']['output'] adminResumeQueue?: Maybe adminSyncCollection?: Maybe adminSyncDiscordRoles?: Maybe @@ -510,6 +514,10 @@ export type MutationAdminDeleteAssetArgs = { assetId: Scalars['String']['input'] } +export type MutationAdminDeleteBackupArgs = { + name: Scalars['String']['input'] +} + export type MutationAdminDeleteCollectionArgs = { collectionId: Scalars['String']['input'] } @@ -555,6 +563,10 @@ export type MutationAdminDeleteUserArgs = { userId: Scalars['String']['input'] } +export type MutationAdminFetchBackupArgs = { + url: Scalars['String']['input'] +} + export type MutationAdminPauseQueueArgs = { type: QueueType } @@ -574,6 +586,10 @@ export type MutationAdminRemoveDiscordRoleConditionComboArgs = { conditionId: Scalars['String']['input'] } +export type MutationAdminRestoreBackupArgs = { + name: Scalars['String']['input'] +} + export type MutationAdminResumeQueueArgs = { type: QueueType } @@ -711,6 +727,8 @@ export type Query = { adminFindOneDiscordServer?: Maybe adminFindOneNetwork?: Maybe adminFindOneUser?: Maybe + adminGetBackup?: Maybe + adminGetBackups: Array adminGetBotInviteUrl?: Maybe adminGetQueue?: Maybe adminGetQueueJobs?: Maybe> @@ -801,6 +819,10 @@ export type QueryAdminFindOneUserArgs = { userId: Scalars['String']['input'] } +export type QueryAdminGetBackupArgs = { + name: Scalars['String']['input'] +} + export type QueryAdminGetQueueArgs = { type: QueueType } @@ -1352,6 +1374,38 @@ export type MeQuery = { } | null } +export type AdminCreateBackupMutationVariables = Exact<{ [key: string]: never }> + +export type AdminCreateBackupMutation = { __typename?: 'Mutation'; created: boolean } + +export type AdminDeleteBackupMutationVariables = Exact<{ + name: Scalars['String']['input'] +}> + +export type AdminDeleteBackupMutation = { __typename?: 'Mutation'; deleted: boolean } + +export type AdminFetchBackupMutationVariables = Exact<{ + url: Scalars['String']['input'] +}> + +export type AdminFetchBackupMutation = { __typename?: 'Mutation'; fetched: boolean } + +export type AdminGetBackupQueryVariables = Exact<{ + name: Scalars['String']['input'] +}> + +export type AdminGetBackupQuery = { __typename?: 'Query'; item?: any | null } + +export type AdminGetBackupsQueryVariables = Exact<{ [key: string]: never }> + +export type AdminGetBackupsQuery = { __typename?: 'Query'; items: Array } + +export type AdminRestoreBackupMutationVariables = Exact<{ + name: Scalars['String']['input'] +}> + +export type AdminRestoreBackupMutation = { __typename?: 'Mutation'; restored: boolean } + export type CollectionComboDetailsFragment = { __typename?: 'CollectionCombo' createdAt: Date @@ -3562,6 +3616,36 @@ export const MeDocument = gql` } ${UserDetailsFragmentDoc} ` +export const AdminCreateBackupDocument = gql` + mutation adminCreateBackup { + created: adminCreateBackup + } +` +export const AdminDeleteBackupDocument = gql` + mutation adminDeleteBackup($name: String!) { + deleted: adminDeleteBackup(name: $name) + } +` +export const AdminFetchBackupDocument = gql` + mutation adminFetchBackup($url: String!) { + fetched: adminFetchBackup(url: $url) + } +` +export const AdminGetBackupDocument = gql` + query adminGetBackup($name: String!) { + item: adminGetBackup(name: $name) + } +` +export const AdminGetBackupsDocument = gql` + query adminGetBackups { + items: adminGetBackups + } +` +export const AdminRestoreBackupDocument = gql` + mutation adminRestoreBackup($name: String!) { + restored: adminRestoreBackup(name: $name) + } +` export const AdminFindManyCollectionComboDocument = gql` query adminFindManyCollectionCombo($input: AdminFindManyCollectionComboInput!) { paging: adminFindManyCollectionCombo(input: $input) { @@ -4150,6 +4234,12 @@ const LoginDocumentString = print(LoginDocument) const LogoutDocumentString = print(LogoutDocument) const RegisterDocumentString = print(RegisterDocument) const MeDocumentString = print(MeDocument) +const AdminCreateBackupDocumentString = print(AdminCreateBackupDocument) +const AdminDeleteBackupDocumentString = print(AdminDeleteBackupDocument) +const AdminFetchBackupDocumentString = print(AdminFetchBackupDocument) +const AdminGetBackupDocumentString = print(AdminGetBackupDocument) +const AdminGetBackupsDocumentString = print(AdminGetBackupsDocument) +const AdminRestoreBackupDocumentString = print(AdminRestoreBackupDocument) const AdminFindManyCollectionComboDocumentString = print(AdminFindManyCollectionComboDocument) const AdminFindOneCollectionComboDocumentString = print(AdminFindOneCollectionComboDocument) const AdminCreateCollectionComboDocumentString = print(AdminCreateCollectionComboDocument) @@ -4383,6 +4473,126 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = 'query', ) }, + adminCreateBackup( + variables?: AdminCreateBackupMutationVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise<{ + data: AdminCreateBackupMutation + errors?: GraphQLError[] + extensions?: any + headers: Headers + status: number + }> { + return withWrapper( + (wrappedRequestHeaders) => + client.rawRequest(AdminCreateBackupDocumentString, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + 'adminCreateBackup', + 'mutation', + ) + }, + adminDeleteBackup( + variables: AdminDeleteBackupMutationVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise<{ + data: AdminDeleteBackupMutation + errors?: GraphQLError[] + extensions?: any + headers: Headers + status: number + }> { + return withWrapper( + (wrappedRequestHeaders) => + client.rawRequest(AdminDeleteBackupDocumentString, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + 'adminDeleteBackup', + 'mutation', + ) + }, + adminFetchBackup( + variables: AdminFetchBackupMutationVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise<{ + data: AdminFetchBackupMutation + errors?: GraphQLError[] + extensions?: any + headers: Headers + status: number + }> { + return withWrapper( + (wrappedRequestHeaders) => + client.rawRequest(AdminFetchBackupDocumentString, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + 'adminFetchBackup', + 'mutation', + ) + }, + adminGetBackup( + variables: AdminGetBackupQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise<{ + data: AdminGetBackupQuery + errors?: GraphQLError[] + extensions?: any + headers: Headers + status: number + }> { + return withWrapper( + (wrappedRequestHeaders) => + client.rawRequest(AdminGetBackupDocumentString, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + 'adminGetBackup', + 'query', + ) + }, + adminGetBackups( + variables?: AdminGetBackupsQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise<{ + data: AdminGetBackupsQuery + errors?: GraphQLError[] + extensions?: any + headers: Headers + status: number + }> { + return withWrapper( + (wrappedRequestHeaders) => + client.rawRequest(AdminGetBackupsDocumentString, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + 'adminGetBackups', + 'query', + ) + }, + adminRestoreBackup( + variables: AdminRestoreBackupMutationVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise<{ + data: AdminRestoreBackupMutation + errors?: GraphQLError[] + extensions?: any + headers: Headers + status: number + }> { + return withWrapper( + (wrappedRequestHeaders) => + client.rawRequest(AdminRestoreBackupDocumentString, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + 'adminRestoreBackup', + 'mutation', + ) + }, adminFindManyCollectionCombo( variables: AdminFindManyCollectionComboQueryVariables, requestHeaders?: GraphQLClientRequestHeaders, diff --git a/libs/sdk/src/graphql/feature-backup.graphql b/libs/sdk/src/graphql/feature-backup.graphql new file mode 100644 index 0000000..4614c50 --- /dev/null +++ b/libs/sdk/src/graphql/feature-backup.graphql @@ -0,0 +1,23 @@ +mutation adminCreateBackup { + created: adminCreateBackup +} + +mutation adminDeleteBackup($name: String!) { + deleted: adminDeleteBackup(name: $name) +} + +mutation adminFetchBackup($url: String!) { + fetched: adminFetchBackup(url: $url) +} + +query adminGetBackup($name: String!) { + item: adminGetBackup(name: $name) +} + +query adminGetBackups { + items: adminGetBackups +} + +mutation adminRestoreBackup($name: String!) { + restored: adminRestoreBackup(name: $name) +} diff --git a/libs/web/dev/feature/src/lib/web-dev-admin-routes.tsx b/libs/web/dev/feature/src/lib/web-dev-admin-routes.tsx index 233bc11..4c89d0a 100644 --- a/libs/web/dev/feature/src/lib/web-dev-admin-routes.tsx +++ b/libs/web/dev/feature/src/lib/web-dev-admin-routes.tsx @@ -1,4 +1,5 @@ import { UiContainer, UiTabRoutes } from '@pubkey-link/web/ui/core' +import { WebDevBackupFeature } from './web-dev-backup-feature' import { WebDevCheckAccountFeature } from './web-dev-check-account-feature' import { WebDevCheckIdentityFeature } from './web-dev-check-identity-feature' import { WebDevProfileFeature } from './web-dev-profile-feature' @@ -11,6 +12,7 @@ export default function WebDevAdminRoutes() { { value: 'check-identity', label: 'Check Identity', component: }, { value: 'check-account', label: 'Check Account', component: }, { value: 'profile', label: 'Profile', component: }, + { value: 'backup', label: 'Backup', component: }, ]} /> diff --git a/libs/web/dev/feature/src/lib/web-dev-backup-feature.tsx b/libs/web/dev/feature/src/lib/web-dev-backup-feature.tsx new file mode 100644 index 0000000..eac02f5 --- /dev/null +++ b/libs/web/dev/feature/src/lib/web-dev-backup-feature.tsx @@ -0,0 +1,156 @@ +import { Accordion, Button, Group, Text } from '@mantine/core' +import { useWebSdk } from '@pubkey-link/web/shell/data-access' +import { UiAlert, UiCard, UiCardTitle, UiDebug, UiGroup, UiLoader, UiStack } from '@pubkey-link/web/ui/core' +import { showNotificationError, showNotificationSuccess } from '@pubkey-link/web/ui/notifications' + +import { useMutation, useQuery } from '@tanstack/react-query' + +export function WebDevBackupFeature() { + const sdk = useWebSdk() + const query = useQuery({ + queryKey: ['backup', 'get-backups'], + queryFn: () => sdk.adminGetBackups(), + }) + const mutationCreate = useMutation({ + mutationKey: ['backup', 'create'], + mutationFn: () => sdk.adminCreateBackup(), + }) + const mutationFetch = useMutation({ + mutationKey: ['backup', 'fetch'], + mutationFn: (url: string) => sdk.adminFetchBackup({ url }), + }) + + return ( + + + + Backup + + + + + + {query.isLoading ? ( + + ) : query.data?.data.items ? ( + + {query.data?.data.items.map((name) => ( + + + {name} + + + query.refetch()} /> + + + ))} + + ) : ( + No backups found} /> + )} + + + ) +} + +export function DevBackupPanel({ name, refresh }: { name: string; refresh: () => void }) { + const sdk = useWebSdk() + const query = useQuery({ + queryKey: ['backup', 'get-backup', { name }], + queryFn: () => sdk.adminGetBackup({ name }).then((res) => res.data), + enabled: false, + }) + const mutationDelete = useMutation({ + mutationKey: ['backup', 'delete', { name }], + mutationFn: () => sdk.adminDeleteBackup({ name }).then((res) => res.data), + }) + + const mutationRestore = useMutation({ + mutationKey: ['backup', 'restore', { name }], + mutationFn: () => sdk.adminRestoreBackup({ name }).then((res) => res.data), + }) + + return ( + + + + + + + + + ) +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 62f7971..039accf 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -20,6 +20,8 @@ "@pubkey-link/api/asset/util": ["libs/api/asset/util/src/index.ts"], "@pubkey-link/api/auth/data-access": ["libs/api/auth/data-access/src/index.ts"], "@pubkey-link/api/auth/feature": ["libs/api/auth/feature/src/index.ts"], + "@pubkey-link/api/backup/data-access": ["libs/api/backup/data-access/src/index.ts"], + "@pubkey-link/api/backup/feature": ["libs/api/backup/feature/src/index.ts"], "@pubkey-link/api/collection-combo/data-access": ["libs/api/collection-combo/data-access/src/index.ts"], "@pubkey-link/api/collection-combo/feature": ["libs/api/collection-combo/feature/src/index.ts"], "@pubkey-link/api/collection/data-access": ["libs/api/collection/data-access/src/index.ts"],