├── .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 |
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 |
--------------------------------------------------------------------------------