Skip to content

Commit

Permalink
feat: add backup feature
Browse files Browse the repository at this point in the history
  • Loading branch information
beeman committed Jan 25, 2024
1 parent 7c8deea commit 90f413e
Show file tree
Hide file tree
Showing 28 changed files with 896 additions and 0 deletions.
6 changes: 6 additions & 0 deletions api-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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!]
Expand Down
18 changes: 18 additions & 0 deletions libs/api/backup/data-access/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"extends": ["../../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}
7 changes: 7 additions & 0 deletions libs/api/backup/data-access/README.md
Original file line number Diff line number Diff line change
@@ -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).
11 changes: 11 additions & 0 deletions libs/api/backup/data-access/jest.config.ts
Original file line number Diff line number Diff line change
@@ -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: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../../coverage/libs/api/backup/data-access',
}
23 changes: 23 additions & 0 deletions libs/api/backup/data-access/project.json
Original file line number Diff line number Diff line change
@@ -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"]
}
2 changes: 2 additions & 0 deletions libs/api/backup/data-access/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './lib/api-backup-data-access.module'
export * from './lib/api-backup.service'
Original file line number Diff line number Diff line change
@@ -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 {}
203 changes: 203 additions & 0 deletions libs/api/backup/data-access/src/lib/api-backup.service.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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(/\./, '-')
}
22 changes: 22 additions & 0 deletions libs/api/backup/data-access/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
16 changes: 16 additions & 0 deletions libs/api/backup/data-access/tsconfig.lib.json
Original file line number Diff line number Diff line change
@@ -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"]
}
9 changes: 9 additions & 0 deletions libs/api/backup/data-access/tsconfig.spec.json
Original file line number Diff line number Diff line change
@@ -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"]
}
18 changes: 18 additions & 0 deletions libs/api/backup/feature/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"extends": ["../../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}
7 changes: 7 additions & 0 deletions libs/api/backup/feature/README.md
Original file line number Diff line number Diff line change
@@ -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).
Loading

0 comments on commit 90f413e

Please sign in to comment.