├── .gitignore ├── src ├── types.d.ts ├── utils.ts ├── api.ts └── app.ts ├── package.json ├── tsconfig.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | api.js 5 | app.js 6 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | interface IConnection { 2 | id?: string 3 | name?: string 4 | host: string 5 | port: number 6 | database: string 7 | user: string 8 | password: string 9 | ssl: boolean 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "directus-backup-operation", 3 | "description": "Custom operation to backup postgres database and upload the file to Directus.", 4 | "version": "1.0.0", 5 | "keywords": [ 6 | "directus", 7 | "directus-extension", 8 | "directus-custom-operation" 9 | ], 10 | "directus:extension": { 11 | "type": "operation", 12 | "path": { 13 | "app": "app.js", 14 | "api": "api.js" 15 | }, 16 | "source": { 17 | "app": "src/app.ts", 18 | "api": "src/api.ts" 19 | }, 20 | "host": "^9.18.0" 21 | }, 22 | "scripts": { 23 | "build": "directus-extension build", 24 | "dev": "directus-extension build -w --no-minify" 25 | }, 26 | "devDependencies": { 27 | "@directus/extensions-sdk": "9.22.1", 28 | "@types/node": "18.7.18", 29 | "directus": "9.22.1", 30 | "typescript": "4.8.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import internal from "stream" 2 | 3 | export const getFileName = (connection: IConnection) => { 4 | const date = new Date() 5 | const currentDate = `${date.getFullYear()}.${ 6 | date.getMonth() + 1 7 | }.${date.getDate()}.${date.getHours()}.${date.getMinutes()}` 8 | return connection.name 9 | ? `${slug(connection.name)}-${currentDate}.dump` 10 | : `database-backup-${currentDate}.dump` 11 | } 12 | 13 | export const slug = (test: string) => 14 | test 15 | .toLowerCase() 16 | .replace(/ /g, "-") 17 | .replace(/[^\w-]+/g, "") 18 | 19 | export const checkStreamError = (stream: internal.Readable | null) => 20 | new Promise((res, rej) => { 21 | const errors: string[] = [] 22 | stream?.on("data", (e) => errors.push(e)) 23 | stream?.on("end", () => { 24 | if (errors.length) { 25 | rej(errors.join(". ")) 26 | } 27 | 28 | res(undefined) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "lib": ["ES2019", "DOM"], 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "noFallthroughCasesInSwitch": true, 8 | "esModuleInterop": true, 9 | "noImplicitAny": true, 10 | "noImplicitThis": true, 11 | "noImplicitReturns": true, 12 | "noUnusedLocals": true, 13 | "noUncheckedIndexedAccess": true, 14 | "noUnusedParameters": true, 15 | "alwaysStrict": true, 16 | "strictNullChecks": true, 17 | "strictFunctionTypes": true, 18 | "strictBindCallApply": true, 19 | "strictPropertyInitialization": true, 20 | "skipLibCheck": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "allowSyntheticDefaultImports": true, 23 | "isolatedModules": true, 24 | "rootDir": ".", 25 | "sourceMap": true, 26 | "resolveJsonModule": true 27 | }, 28 | "include": ["./src/**/*.ts"] 29 | } 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Directus Backup Operation 2 | 3 |
4 |

5 | 35190391-removebg-preview 6 |

7 | Custom Directus operation to backup Postgres database using pg_dump and upload the .dump file into Directus storage. 8 |
9 |

10 |

11 | 12 | ## Prerequisites 13 | 14 | Make sure you have installed the following prerequisites on your Directus machine. 15 | 16 | - PostgreSQL Client - [Install PSQL](https://packages.ubuntu.com/bionic/any/postgresql-client). Needs to be installed so Node can spawn `pg_dump` process. If running in Docker, you can check this [example repo](https://github.com/Guiqft/directus-psql-docker-example). 17 | 18 | ## Usage 19 | 20 | Clone this project inside your `/extensions/operations` folder, then: 21 | 22 | ```bash 23 | cd directus-backup-operation/ 24 | ``` 25 | 26 | ```bash 27 | yarn && yarn build 28 | ``` 29 | 30 | 31 | After activate the extension, you can create a new Directus flow and choose how to trigger the database backup 🚀 32 | 33 | ## Configuration 34 | 35 | You can choose which folder to upload the database `.dump` file, just type the folder name on operation register. Make sure to type a valid folder name on your Directus storage. 36 | 37 | > For now, this extension supports only `local` as Directus storage 38 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { defineOperationApi } from "@directus/extensions-sdk" 2 | import { FilesService } from "directus" 3 | import childProccess from "child_process" 4 | 5 | import pkg from "../package.json" 6 | import { getFileName, checkStreamError } from "./utils" 7 | 8 | export default defineOperationApi<{ 9 | storage?: string 10 | folder?: string 11 | customConnection?: IConnection 12 | }>({ 13 | id: "backup", 14 | handler: async ( 15 | { storage, folder, customConnection }, 16 | { database: db, services, getSchema, logger } 17 | ) => { 18 | const start = performance.now() 19 | 20 | const schema = await getSchema() 21 | 22 | const filesService: FilesService = new services.FilesService({ 23 | schema, 24 | knex: db, 25 | }) 26 | 27 | const connection: IConnection = 28 | customConnection ?? db.client.connectionSettings 29 | 30 | const fileName = getFileName(connection) 31 | 32 | try { 33 | logger.info( 34 | `[${pkg.name}] Backing up connection to database ${connection.database} on host ${connection.host}` 35 | ) 36 | 37 | const { stdout, stderr } = childProccess.exec( 38 | `PGHOST=${connection.host} PGPORT=${connection.port} PGDATABASE=${connection.database} PGUSER=${connection.user} PGPASSWORD=${connection.password} pg_dump --format=c` 39 | ) 40 | 41 | const upload = filesService.uploadOne(stdout!, { 42 | title: fileName, 43 | type: "application/octet-stream", 44 | filename_download: fileName, 45 | storage: storage ?? "local", 46 | folder: folder ?? undefined, 47 | }) 48 | const errors = checkStreamError(stderr) 49 | 50 | await Promise.race([upload, errors]).catch((e) => { 51 | throw new Error(e) 52 | }) 53 | 54 | logger.info( 55 | `[${pkg.name}] New database backup created: ${fileName}` 56 | ) 57 | 58 | const time = (performance.now() - start) / 1000 59 | const size = (await filesService.readOne(await upload)).filesize 60 | 61 | return { 62 | connection, 63 | time, 64 | size, 65 | } 66 | } catch (e) { 67 | await filesService.deleteByQuery({ 68 | filter: { 69 | title: { 70 | _eq: fileName, 71 | }, 72 | }, 73 | }) 74 | 75 | const message = JSON.stringify((e as Error).message).replace( 76 | /(?:PGPASSWORD\=)[^\s]+/, 77 | "PGPASSWORD=********" 78 | ) 79 | 80 | logger.error( 81 | `[${pkg.name}] Error on database backup: ${ 82 | (e as Error).message 83 | }` 84 | ) 85 | throw { 86 | connection, 87 | error: message, 88 | } 89 | } 90 | }, 91 | }) 92 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { defineOperationApp } from "@directus/extensions-sdk" 2 | 3 | const storageOptions = [ 4 | { text: "Local", value: "local" }, 5 | { text: "S3", value: "s3" }, 6 | { text: "Google Cloud Storage", value: "gcs" }, 7 | { text: "Azure", value: "azure" }, 8 | ] 9 | 10 | export default defineOperationApp({ 11 | id: "backup", 12 | name: "Backup", 13 | icon: "backup", 14 | description: "Backup your Postgres DB", 15 | overview: ({ storage, folder }) => { 16 | const labels = [ 17 | { 18 | label: "Backup Storage", 19 | text: 20 | storageOptions.find(({ value }) => value === storage) 21 | ?.text ?? 22 | storage ?? 23 | "Invalid Storage", 24 | }, 25 | ] 26 | if (folder) { 27 | labels.push({ 28 | label: "Local Folder", 29 | text: folder, 30 | }) 31 | } 32 | return labels 33 | }, 34 | options: [ 35 | { 36 | field: "storage", 37 | name: "Storage", 38 | type: "string", 39 | meta: { 40 | width: "half", 41 | interface: "select-dropdown", 42 | note: "Make sure you have the required configs", 43 | required: true, 44 | options: { 45 | choices: storageOptions, 46 | allowOther: true, 47 | }, 48 | }, 49 | }, 50 | { 51 | field: "folder", 52 | name: "$t:interfaces.system-folder.folder", 53 | type: "uuid", 54 | meta: { 55 | width: "half", 56 | interface: "system-folder", 57 | note: "Where to show your backups on Directus", 58 | }, 59 | }, 60 | { 61 | field: "customConnection", 62 | name: "Custom Connection", 63 | type: "json", 64 | meta: { 65 | width: "full", 66 | interface: "input-code", 67 | options: { 68 | language: "json", 69 | placeholder: JSON.stringify( 70 | { 71 | host: "localhost", 72 | port: 8432, 73 | database: "directus", 74 | user: "directus", 75 | password: "password", 76 | }, 77 | null, 78 | 2 79 | ), 80 | template: JSON.stringify( 81 | { 82 | host: "localhost", 83 | port: 8432, 84 | database: "directus", 85 | user: "directus", 86 | password: "password", 87 | }, 88 | null, 89 | 2 90 | ), 91 | }, 92 | }, 93 | }, 94 | ], 95 | }) 96 | --------------------------------------------------------------------------------