├── .gitignore ├── LICENSE.md ├── README.md ├── migrations └── 010120222RT-realtime-channels.js ├── realtime-endpoints ├── .gitignore ├── package-lock.json ├── package.json └── src │ ├── helpers.js │ └── index.js └── realtime-hooks ├── .gitignore ├── package-lock.json ├── package.json └── src ├── index.js └── lib └── realtime-service.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Thiery Laverdure 4 | Copyright (c) Renoki Co. & Soketi 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Directus Realtime Extension 2 | 3 | #### _Add realtime capabilities to your [Directus App](https://directus.io/)_ 4 | 5 | --- 6 | ## Description 7 | Realtime extension simply adds very thing layer between your [pusher](https://pusher.com) / [soketi](https://github.com/soketi/soketi) server and your directus app. 8 | 9 | 10 | --- 11 | 12 | Any Pusher-maintained or compatible client can connect to it.You have total control of your channels (What to publish or who to authorize) with pure javascript that you can edit in your admin panel per channel. 13 | 14 | ## Project Status 15 | 16 | - [x] Alpha: Under heavy development 17 | - [x] Public Alpha: Ready for use. But go easy on me, there may be a few kinks. 18 | - [ ] Public Beta: Stable enough for most non-enterprise use-cases 19 | - [ ] Public: Production-ready 20 | 21 | 22 | **You can use [soketi](https://github.com/soketi/soketi) for free pusher alternative, its open-source.** 23 | 24 | 25 | ## Installing 26 | 27 | * Clone this repo 28 | * run `npm install && npm run build` in both "realtime-endpoints" and "realtime-hooks" directories. 29 | * Both extensions are outputs to `dist/` folder and you have to move the output from the `dist/` folder and `/migrations` folder into your project's `./extensions/` folder 30 | * and set ENV variables to your needs 31 | * restart your direcuts app 32 | 33 | > Note: Migration has `Data Model` for channels table for fast start if you don't want this simply remove it from migration file or set `RT_DONT_PRESET`env variable on first start. (There is no need to disable it but its your call.) 34 | 35 | > Note : If you disabled `Data Model` presets you have te setup for yourself 36 | 37 | ### Env variables 38 | 39 | 40 | | ENV NAME | REQUIRED | DEFAULT | 41 | |-------------------------- |----------------------------------------------------- |------------------------- | 42 | | RT_APP_ID | YES | "app-id" | 43 | | RT_APP_KEY | YES | "app-key" | 44 | | RT_APP_SECRET | YES | "app-secret" | 45 | | RT_APP_HOST | YES | "soketi" | 46 | | RT_APP_CLUSTER | NOT REQUIRED FOR SOKETI | (undefined \| disabled) | 47 | | RT_ENCRYPTION_MASTER_KEY | NOT REQUIRED IF YOR NOT NEED ENCRYPTION ON CHANNELS | (undefined \| disabled) | 48 | | RT_APP_PORT | YES | 6001 | 49 | | RT_USE_TLS | NO | | 50 | | RT_USE_STATS | NO | (undefined \| disabled) | 51 | 52 | 53 | ### What's Next? 54 | 55 | * You can create your channels like normal collection items from dashboard. 56 | * I try to fill all note sections for all fileds, its should be a self-explanatory. 57 | 58 | 59 | ## Example Channel 60 | 61 | 62 | > channel name accepts value like express.js route variables (See [path-to-regexp](https://www.npmjs.com/package/path-to-regexp)) 63 | 64 | Channel name : `private-chat-:userId` 65 | 66 | Collection : `chat` 67 | 68 | ### Authorizer Context: 69 | ```js 70 | { 71 | socket_id: "pusher socket id", 72 | // requested channel name 73 | channel_name : "private-chat-cd6feea9-bcb2-45bb-a664-6fea3fea88b8", 74 | 75 | params : { 76 | userId:"cd6feea9-bcb2-45bb-a664-6fea3fea88b8" 77 | }, 78 | 79 | auth: { 80 | user: { // All user fields 81 | id: "cd6feea9-bcb2-45bb-a664-6fea3fea88b8", 82 | first_name: "Admin", 83 | last_name: "User", 84 | email: "a@a.com", 85 | password: "$argon2i$v=19$m=4096,t=3,p=1$h+TgmA2455KVE52jizvyMw$1T/DOdSRpxDlGBz/Uft7QzkpeWIIITZulIS82tu7TAw", 86 | location: null, 87 | title: null, 88 | description: null, 89 | tags: null, 90 | avatar: null, 91 | language: "en-US", 92 | theme: "dark", 93 | tfa_secret: null, 94 | status: "active", 95 | role: "8bb80a0b-0467-4351-bd29-4ad16ffc7f92", 96 | token: null, 97 | last_access: "2022-01-16T23:50:23.737Z", 98 | last_page: "/content/realtime_channels/4e7b81a7-0e94-4b93-abc6-8ef7a40ab97d", 99 | provider: "default", 100 | external_identifier: null, 101 | auth_data: null, 102 | email_notifications: false 103 | }, 104 | role: "8bb80a0b-0467-4351-bd29-4ad16ffc7f92", 105 | admin: true, 106 | app: true, 107 | ip: "172.18.0.1", 108 | userAgent: "insomnia/2021.7.2", 109 | permissions: [ 110 | // ... user permissions 111 | ] 112 | } 113 | } 114 | ``` 115 | 116 | ### Example Authorizer Script 117 | ```js 118 | // For private channels 119 | if (auth.user.id === params.userId) { 120 | 121 | return true; 122 | } 123 | // For presence Channels 124 | if(auth.user.id === params.userId) 125 | { 126 | return {user_id:auth.user.id,user_info:{name:auth.user.first_name}}; // Check Pusher presence channel docs. 127 | } 128 | ``` 129 | 130 | ### Publisher Context 131 | ```js 132 | { 133 | collection:"chat", 134 | action:"create", // update,delete 135 | trigger: (channelParams,eventName,payload,exclude?) => {}, // trigger event on channel with given parameters,exclude is optional 136 | broadcastTo: (channelName, channelParams, eventName, payload, exclude?) => {}, // exclude is optional 137 | 138 | } 139 | ``` 140 | ### Example Publisher Script 141 | ```js 142 | // Remember this is PURE JAVASCRIPT 143 | // YOU ARE RESPONSIBLE FOR WHAT IS SHARED WITH THE USER 144 | // You can select which fields you want to see in meta.payload object (resolves relations with dot notation). 145 | // 146 | // in this situation fields can be : *,recipient.id,recipient.first_name 147 | // to get all top-level fields and all second-level relational fields : *.* 148 | trigger({userId:meta.payload.recipient.id},"message",meta.payload); 149 | // if you want to trigger event on another channel use broadcastTo 150 | 151 | // if it doesn't have any dynamic parameters in channel name,pass empty object on channelParams. 152 | broadcastTo("another-channel",{},"somethingHappend",meta.payload); 153 | 154 | // or with params 155 | 156 | broadcastTo("private-chat-notification-:userId",{userId:meta.payload.recipient.id},"message",meta.payload); 157 | 158 | 159 | ``` 160 | 161 | ### When to trigger publisher 162 | 163 | Just check in which action should publisher be called. 164 | Useful for avoid unnecessary 'if's in script. 165 | 166 | ### Example Client 167 | 168 | auth endpoint : {APP_PUBLIC_URL}/realtime/auth 169 | 170 | ```js 171 | const client = new Pusher('app-key', { 172 | wsHost: 'localhost', 173 | wsPort: 6001, 174 | forceTLS: false, 175 | disableStats: true, 176 | authEndpoint: `http://localhost:8055/realtime/auth`, 177 | auth: { 178 | headers: { 179 | Authorization: `Bearer ${directus.auth && directus.auth.token}` 180 | } 181 | }, 182 | enabledTransports: ['ws', 'wss'], 183 | }); 184 | ``` 185 | 186 | --- 187 | ## Limits 188 | 189 | except Pusher / Soketi server limits,extension doesn't have limit for implementations.Authorizer and Publisher fields are just a javascript functions with a useful parameters and functions. 190 | 191 | 192 | 193 | ## Contributing 194 | 195 | Contributions are welcome. 196 | 197 | ## Reporting a Vulnerability 198 | If you discover any security-related issues, please open a issue. 199 | 200 | ## Authors 201 | 202 | Contributors names and contact info 203 | 204 | * [@Erdem Özveren](https://github.com/erdemozveren) 205 | 206 | ## License 207 | 208 | Its free and open-source do whatever you want with it. 209 | This project is licensed under the MIT License - see the LICENSE.md file for details 210 | 211 | --- 212 | >Sorry for any mistakes. English is not my native language -------------------------------------------------------------------------------- /migrations/010120222RT-realtime-channels.js: -------------------------------------------------------------------------------- 1 | const CHANNEL_TABLE = "realtime_channels"; 2 | module.exports = { 3 | async up(knex) { 4 | const dr_field_presets = [ 5 | { 6 | "collection": CHANNEL_TABLE, 7 | "field": "trigger_on_create", 8 | "special": null, 9 | "interface": "boolean", 10 | "options": null, 11 | "display": "boolean", 12 | "display_options": { 13 | "labelOn": "Enabled", 14 | "labelOff": "Disabled" 15 | }, 16 | "readonly": false, 17 | "hidden": false, 18 | "sort": 8, 19 | "width": "full", 20 | "translations": null, 21 | "note": null, 22 | "conditions": null, 23 | "required": true, 24 | "group": null 25 | }, 26 | { 27 | "collection": CHANNEL_TABLE, 28 | "field": "fields", 29 | "special": "json", 30 | "interface": "tags", 31 | "options": { 32 | "presets": [ 33 | "*" 34 | ], 35 | "placeholder": "Add Fields" 36 | }, 37 | "display": "labels", 38 | "display_options": null, 39 | "readonly": false, 40 | "hidden": false, 41 | "sort": 4, 42 | "width": "full", 43 | "translations": null, 44 | "note": "Specify the fields of the \"payload\" || This parameter supports dot notation to request nested relational fields", 45 | "conditions": null, 46 | "required": true, 47 | "group": null 48 | }, 49 | { 50 | "collection": CHANNEL_TABLE, 51 | "field": "trigger_on_update", 52 | "special": null, 53 | "interface": "boolean", 54 | "options": null, 55 | "display": "boolean", 56 | "display_options": { 57 | "labelOn": "Enabled", 58 | "labelOff": "Disabled" 59 | }, 60 | "readonly": false, 61 | "hidden": false, 62 | "sort": 9, 63 | "width": "full", 64 | "translations": null, 65 | "note": null, 66 | "conditions": null, 67 | "required": true, 68 | "group": null 69 | }, 70 | { 71 | "collection": CHANNEL_TABLE, 72 | "field": "channel_name", 73 | "special": null, 74 | "interface": "input", 75 | "options": { 76 | "iconLeft": "signal_cellular_alt", 77 | "trim": true 78 | }, 79 | "display": "raw", 80 | "display_options": null, 81 | "readonly": false, 82 | "hidden": false, 83 | "sort": 5, 84 | "width": "full", 85 | "translations": null, 86 | "note": "Example: accept any \"private-notification-:userId\" , accept only numeric roomId \"private-room-:roomId(\\d+)\"", 87 | "conditions": null, 88 | "required": true, 89 | "group": null 90 | }, 91 | { 92 | "collection": CHANNEL_TABLE, 93 | "field": "collection", 94 | "special": null, 95 | "interface": "input", 96 | "options": { 97 | "url": "/collections", 98 | "trim": true, 99 | "placeholder": "any collection name...", 100 | "iconLeft": "dns" 101 | }, 102 | "display": "raw", 103 | "display_options": null, 104 | "readonly": false, 105 | "hidden": false, 106 | "sort": 3, 107 | "width": "full", 108 | "translations": null, 109 | "note": null, 110 | "conditions": null, 111 | "required": true, 112 | "group": null 113 | }, 114 | { 115 | "collection": CHANNEL_TABLE, 116 | "field": "authorizer", 117 | "special": null, 118 | "interface": "input-code", 119 | "options": { 120 | "language": "javascript", 121 | "template": "if (auth.user === params.userId) {\n return true;\n}" 122 | }, 123 | "display": null, 124 | "display_options": { 125 | "conditionalFormatting": null 126 | }, 127 | "readonly": false, 128 | "hidden": false, 129 | "sort": 6, 130 | "width": "full", 131 | "translations": null, 132 | "note": "Context ; socket_id, channel_name, params, auth || NEVER LEAVE THIS EMPTY EXCEPT FOR PUBLIC CHANNELS", 133 | "conditions": null, 134 | "required": false, 135 | "group": null 136 | }, 137 | { 138 | "collection": CHANNEL_TABLE, 139 | "field": "trigger_on_delete", 140 | "special": null, 141 | "interface": "boolean", 142 | "options": null, 143 | "display": "boolean", 144 | "display_options": { 145 | "labelOn": "Enabled", 146 | "labelOff": "Disabled" 147 | }, 148 | "readonly": false, 149 | "hidden": false, 150 | "sort": 10, 151 | "width": "full", 152 | "translations": null, 153 | "note": null, 154 | "conditions": null, 155 | "required": true, 156 | "group": null 157 | }, 158 | { 159 | "collection": CHANNEL_TABLE, 160 | "field": "publisher", 161 | "special": null, 162 | "interface": "input-code", 163 | "options": { 164 | "language": "javascript", 165 | "template": "// Remember this is PURE JAVASCRIPT\n// YOU ARE RESPONSIBLE FOR WHAT IS SHARED WITH THE USER\n\ntrigger({userId:meta.payload.recipient.id},'new_notification',meta.payload);" 166 | }, 167 | "display": null, 168 | "display_options": null, 169 | "readonly": false, 170 | "hidden": false, 171 | "sort": 7, 172 | "width": "full", 173 | "translations": null, 174 | "note": "Context: collection, action, trigger, broadcastTo, meta", 175 | "conditions": null, 176 | "required": true, 177 | "group": null 178 | }, 179 | { 180 | "collection": CHANNEL_TABLE, 181 | "field": "id", 182 | "special": "uuid", 183 | "interface": null, 184 | "options": null, 185 | "display": null, 186 | "display_options": null, 187 | "readonly": true, 188 | "hidden": true, 189 | "sort": 1, 190 | "width": "full", 191 | "translations": null, 192 | "note": null, 193 | "conditions": null, 194 | "required": false, 195 | "group": null 196 | }, 197 | { 198 | "collection": CHANNEL_TABLE, 199 | "field": "enabled", 200 | "special": null, 201 | "interface": "boolean", 202 | "options": { 203 | "label": "Is channel active?" 204 | }, 205 | "display": "boolean", 206 | "display_options": { 207 | "labelOn": "Enabled", 208 | "labelOff": "Disabled" 209 | }, 210 | "readonly": false, 211 | "hidden": false, 212 | "sort": 2, 213 | "width": "full", 214 | "translations": null, 215 | "note": null, 216 | "conditions": null, 217 | "required": true, 218 | "group": null 219 | } 220 | ]; 221 | 222 | 223 | await knex.schema.createTable(CHANNEL_TABLE, (table) => { 224 | table.uuid('id').primary(); 225 | table.boolean('enabled').defaultTo(false).notNullable(); 226 | table.string('collection').notNullable(); 227 | table.json('fields').defaultTo(['*']).notNullable(); 228 | table.string('channel_name', 164).unique().notNullable(); 229 | table.text('authorizer').nullable(); 230 | table.text('publisher').notNullable(); 231 | table.boolean('trigger_on_create').defaultTo(true).notNullable(); 232 | table.boolean('trigger_on_update').defaultTo(true).notNullable(); 233 | table.boolean('trigger_on_delete').defaultTo(true).notNullable(); 234 | }); 235 | if (process.env.RT_DONT_PRESET) return; 236 | const checkCollections = await knex.from("directus_collections").where({ "collection": CHANNEL_TABLE }).limit(1).select("*"); 237 | if (checkCollections.length <= 0) { 238 | await knex.insert({ 239 | "collection": CHANNEL_TABLE, 240 | "icon": 'settings_input_antenna', 241 | "note": 'Realtime Channels for Websockets [Extension]', 242 | "display_template": "{{collection}} - {{channel_name}}", 243 | "hidden": false, 244 | "singleton": false, 245 | "translations": null, 246 | "archive_field": null, 247 | "archive_app_filter": false, 248 | "archive_value": null, 249 | "unarchive_value": null, 250 | "sort_field": null, 251 | "accountability": "all", 252 | "color": "#5F7BCE", 253 | "item_duplication_fields": null, 254 | "sort": null, 255 | "group": null, 256 | "collapse": "open" 257 | }).into('directus_collections'); 258 | } 259 | const checkPresets = await knex.from("directus_fields").where({ "collection": CHANNEL_TABLE }).limit(1).select("*"); 260 | if (checkPresets.length <= 0) { 261 | await knex.insert(dr_field_presets).into('directus_fields'); 262 | } 263 | }, 264 | 265 | async down(knex) { 266 | await knex.schema.dropTable(CHANNEL_TABLE); 267 | await knex.raw(` 268 | DELETE FROM public.directus_collections 269 | WHERE collection='${CHANNEL_TABLE}'; 270 | 271 | DELETE FROM public.directus_fields 272 | WHERE collection = '${CHANNEL_TABLE}'; 273 | 274 | DELETE FROM public.directus_migrations 275 | WHERE version = '010120222RT';`); 276 | }, 277 | }; -------------------------------------------------------------------------------- /realtime-endpoints/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist -------------------------------------------------------------------------------- /realtime-endpoints/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "directus-extension-realtime-endpoints", 3 | "version": "1.0.0", 4 | "keywords": [ 5 | "directus", 6 | "directus-extension", 7 | "directus-custom-endpoint" 8 | ], 9 | "directus:extension": { 10 | "type": "endpoint", 11 | "path": "../dist/endpoints/realtime/index.js", 12 | "source": "src/index.js", 13 | "host": "^9.4.3" 14 | }, 15 | "scripts": { 16 | "build": "directus-extension build" 17 | }, 18 | "devDependencies": { 19 | "@directus/extensions-sdk": "9.4.3" 20 | }, 21 | "dependencies": { 22 | "body-parser": "^1.19.1", 23 | "path-to-regexp": "^6.2.0", 24 | "pusher": "^5.0.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /realtime-endpoints/src/helpers.js: -------------------------------------------------------------------------------- 1 | import { getCache } from 'directus/cache'; 2 | import ms from 'ms'; 3 | 4 | const CHANNELS_CACHE_KEY = 'realtime-channel-list'; 5 | const CHANNEL_TABLE_NAME = "realtime_channels"; 6 | 7 | 8 | export function runChannelAuthorizer(authorizer, socket_id, channelName, params, auth) { 9 | try { 10 | return (new Function('socket_id', 'channel_name', 'params', 'auth', authorizer))(socket_id, channelName, params, auth); 11 | } catch (_cae) { 12 | return false; 13 | } 14 | } 15 | 16 | export async function getChannels(database, env, log) { 17 | try { 18 | const { cache } = getCache(); 19 | const cachedChannels = await cache?.get(CHANNELS_CACHE_KEY); 20 | if (cachedChannels) { 21 | return cachedChannels; 22 | } 23 | const channels = await database.from(CHANNEL_TABLE_NAME).where({ 24 | enabled: true, 25 | }).select("*"); 26 | cache?.set(CHANNELS_CACHE_KEY, channels, ms(env.CACHE_TTL ?? '15m')); 27 | return channels; 28 | } catch (databaseError) { 29 | log.error(`Database Error on loadingChannels ; ${databaseError}`); 30 | return []; 31 | } 32 | } -------------------------------------------------------------------------------- /realtime-endpoints/src/index.js: -------------------------------------------------------------------------------- 1 | import Pusher from 'pusher'; 2 | import bodyParser from 'body-parser'; 3 | import { getChannels, runChannelAuthorizer } from './helpers.js'; 4 | import { match } from 'path-to-regexp'; 5 | 6 | 7 | 8 | export default (router, { env, database, logger }) => { 9 | const log = logger.child({ extension: 'realtime/endpoints' }); 10 | router.use(bodyParser.json()); 11 | router.use(bodyParser.urlencoded({ extended: false })); 12 | const { 13 | RT_APP_ID, 14 | RT_APP_KEY, 15 | RT_APP_SECRET, 16 | RT_APP_HOST, 17 | RT_APP_CLUSTER, 18 | RT_APP_PORT, 19 | RT_USE_TLS, 20 | RT_ENCRYPTION_MASTER_KEY, 21 | RT_USE_STATS, 22 | } = env; 23 | 24 | const pusher = new Pusher({ 25 | appId: RT_APP_ID ?? "app-id", 26 | key: RT_APP_KEY ?? "app-key", 27 | secret: RT_APP_SECRET ?? "app-secret", 28 | host: RT_APP_HOST ?? 'soketi', 29 | cluster: RT_APP_CLUSTER, 30 | port: RT_APP_PORT ?? 6001, 31 | useTLS: RT_USE_TLS ?? false, 32 | encryptionMasterKeyBase64: RT_ENCRYPTION_MASTER_KEY, 33 | disableStats: RT_USE_STATS ?? true, 34 | }); 35 | 36 | router.post('/auth', async (req, res) => { 37 | try { 38 | const auth = req.accountability; 39 | const socketId = req.body.socket_id; 40 | const channelName = req.body.channel_name; 41 | let channelType = null; 42 | 43 | if (channelName.indexOf("private-") !== -1) { 44 | // auth endpoint is only for private and presence channels 45 | channelType = "private"; 46 | } else if (channelName.indexOf("presence-") !== -1) { 47 | channelType = "presence"; 48 | } else { 49 | // channel is public (its invalid request) 50 | res.status(401).json({ success: false }); 51 | return; 52 | } 53 | 54 | if (!auth.user) { 55 | // private channels only for authenticated users 56 | res.status(401).json({ success: false }); 57 | return; 58 | } 59 | 60 | const userInfo = await database.from("directus_users").where("id", auth.user).limit(1).select("*"); 61 | auth.user = userInfo?.[0]; 62 | if (!auth.user) { 63 | // we cannot find user 64 | res.status(401).json({ success: false }); 65 | return; 66 | } 67 | const channels = await getChannels(database, env, log); 68 | 69 | for (const channel of channels) { 70 | const isChannelMatch = match(channel.channel_name, { sensitive: true })(channelName); 71 | 72 | if (isChannelMatch !== false) { 73 | // if authorizer is null,its must be a public channel 74 | // public channels never make request to auth endpoint (they don't need auth) 75 | // so we reject to auth in case of "user" error (like accidentally lefts the field blank) or malicious requests 76 | const authorizedData = channel.authorizer === null ? false : runChannelAuthorizer(channel.authorizer, socketId, channelName, isChannelMatch.params, auth); 77 | if (channelType === "presence") { 78 | if (typeof authorizedData !== 'object') { 79 | // Presence Channels authorizer function must return json/object user data on success 80 | continue; 81 | } 82 | const authPresence = pusher.authenticate(socketId, channelName, authorizedData); 83 | res.send(authPresence); 84 | return; 85 | } else if (authorizedData === true) { 86 | const authPrivate = pusher.authenticate(socketId, channelName); 87 | res.send(authPrivate); 88 | return; 89 | } 90 | } 91 | } 92 | } catch (unexpectedError) { 93 | log.error("Realtime : Unexpected Error " + unexpectedError.message); 94 | } 95 | // if there is no match or error happends ; 96 | res.status(401).json({ success: false }); 97 | return; 98 | }); 99 | 100 | 101 | log.info("Realtime Endpoints initialized..."); 102 | 103 | }; 104 | -------------------------------------------------------------------------------- /realtime-hooks/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist -------------------------------------------------------------------------------- /realtime-hooks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "directus-extension-realtime-hooks", 3 | "version": "1.0.0", 4 | "keywords": [ 5 | "directus", 6 | "directus-extension", 7 | "directus-custom-hook" 8 | ], 9 | "directus:extension": { 10 | "type": "hook", 11 | "path": "../dist/hooks/realtime/index.js", 12 | "source": "src/index.js", 13 | "host": "^9.4.3" 14 | }, 15 | "scripts": { 16 | "build": "directus-extension build" 17 | }, 18 | "devDependencies": { 19 | "@directus/extensions-sdk": "9.4.3" 20 | }, 21 | "dependencies": { 22 | "path-to-regexp": "^6.2.0", 23 | "pusher": "^5.0.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /realtime-hooks/src/index.js: -------------------------------------------------------------------------------- 1 | import RealtimeService from './lib/realtime-service.js'; 2 | export default ({ action, init }, context) => { 3 | init("app.before", () => { 4 | const service = new RealtimeService(context); 5 | 6 | service.registerActions(action); 7 | context.logger.child({ extension: 'realtime/hooks' }).info("Realtime Extension listening..."); 8 | }); 9 | 10 | }; 11 | -------------------------------------------------------------------------------- /realtime-hooks/src/lib/realtime-service.js: -------------------------------------------------------------------------------- 1 | import Pusher from "pusher" 2 | import { compile } from 'path-to-regexp'; 3 | import { getCache } from 'directus/cache'; 4 | 5 | export const CHANNEL_TABLE_NAME = "realtime_channels"; 6 | const CHANNELS_CACHE_KEY = 'realtime-channel-list'; 7 | const SCHEMA_CACHE_KEY = 'realtime-tables-schema'; 8 | 9 | 10 | export default class RealtimeService { 11 | logger = null; 12 | pusher = null; 13 | constructor(context) { 14 | this.context = context; 15 | this.logger = context.logger.child({ extension: 'realtime/hooks' }); 16 | const { 17 | RT_APP_ID, 18 | RT_APP_KEY, 19 | RT_APP_SECRET, 20 | RT_APP_HOST, 21 | RT_APP_CLUSTER, 22 | RT_ENCRYPTION_MASTER_KEY, 23 | RT_APP_PORT, 24 | RT_USE_TLS, 25 | RT_USE_STATS, 26 | } = context.env; 27 | this.pusher = new Pusher({ 28 | appId: RT_APP_ID ?? "app-id", 29 | key: RT_APP_KEY ?? "app-key", 30 | secret: RT_APP_SECRET ?? "app-secret", 31 | host: RT_APP_HOST ?? 'soketi', 32 | cluster: RT_APP_CLUSTER, 33 | port: RT_APP_PORT ?? 6001, 34 | useTLS: RT_USE_TLS ?? false, 35 | encryptionMasterKeyBase64: RT_ENCRYPTION_MASTER_KEY, 36 | disableStats: RT_USE_STATS ?? true, 37 | }); 38 | } 39 | 40 | async getChannels() { 41 | try { 42 | const { cache } = getCache(); 43 | const cachedChannels = await cache?.get(CHANNELS_CACHE_KEY); 44 | if (cachedChannels) { 45 | return cachedChannels; 46 | } 47 | const channels = await this.context.database.from(CHANNEL_TABLE_NAME).where({ 48 | enabled: true, 49 | }).select("*"); 50 | cache?.set(CHANNELS_CACHE_KEY, channels); 51 | return channels; 52 | } catch (databaseError) { 53 | this.logger.error("Database Error on loadingChannels ", databaseError.message); 54 | } 55 | return []; 56 | } 57 | 58 | registerActions(action) { 59 | const { cache } = getCache(); 60 | action("items.create", ({ payload, key, collection }) => { 61 | if (collection === CHANNEL_TABLE_NAME) { 62 | cache?.delete(CHANNELS_CACHE_KEY); 63 | } 64 | this.onAction(collection, "create", { payload, key }); 65 | }); 66 | action("items.update", ({ payload, keys, collection }) => { 67 | if (collection === CHANNEL_TABLE_NAME) { 68 | cache?.delete(CHANNELS_CACHE_KEY); 69 | } 70 | this.onAction(collection, "update", { payload, keys }); 71 | }); 72 | action("items.delete", ({ payload, collection }) => { 73 | if (collection === CHANNEL_TABLE_NAME) { 74 | cache?.delete(CHANNELS_CACHE_KEY); 75 | } 76 | this.onAction(collection, "delete", payload); 77 | }); 78 | } 79 | 80 | async getSchema() { 81 | const { cache } = getCache(); 82 | const cachedSchema = await cache?.get(SCHEMA_CACHE_KEY); 83 | if (cachedSchema) { 84 | return cachedSchema; 85 | } 86 | try { 87 | const schema = await this.context.getSchema(); 88 | cache?.set(SCHEMA_CACHE_KEY, schema); 89 | return schema; 90 | } catch (unexpectedError) { 91 | this.logger.error("Realtime Extension unable to get schema of collections"); 92 | this.logger.error(unexpectedError.message); 93 | } 94 | return null; 95 | } 96 | 97 | async onAction(collection, action, meta) { 98 | const channels = await this.getChannels(); 99 | for (const ch of channels) { 100 | if (ch.collection !== collection || ch[`trigger_on_${action}`] !== true) continue; 101 | try { 102 | if (action === "update") { 103 | const schema = await this.context.getSchema(); 104 | const primaryKeyField = schema.collections[ch.collection].primary; 105 | const clService = new this.context.services.ItemsService(ch.collection, { schema }); 106 | const affectedItems = await clService.readMany(meta.keys, { fields: ch.fields }); 107 | affectedItems.forEach(item => { 108 | this.run_channel_publisher(ch, action, { collection, payload: item, changed: meta.payload, key: item[primaryKeyField] }) 109 | }); 110 | } else if (action === "create") { 111 | const schema = await this.context.getSchema(); 112 | const clService = new this.context.services.ItemsService(ch.collection, { schema }); 113 | const newPayload = await clService.readOne(meta.key, { fields: ch.fields }); 114 | meta.payload = newPayload; 115 | this.run_channel_publisher(ch, action, meta) 116 | } else { 117 | this.run_channel_publisher(ch, action, meta) 118 | } 119 | } catch (unexpectedError) { 120 | this.logger.error("Something went wrong on publisher of ", ch.channel_name, " ERROR "); 121 | this.logger.error(unexpectedError.message); 122 | } 123 | 124 | } 125 | } 126 | 127 | trigger(channel, options) { 128 | const { channelParams, event, data, exclude } = options; 129 | if (typeof channelParams !== 'object' || typeof event !== 'string' || typeof data !== 'object') { 130 | this.logger.error(`#[${channel.id}] ${channel.channel_name} - Missing Parameater on Trigger, Check your 'publisher' code`); 131 | return; 132 | } 133 | const broadcastTo = compile(channel.channel_name)(channelParams); 134 | this.pusher.trigger(broadcastTo, event, data, exclude); 135 | } 136 | 137 | run_channel_publisher(channel, action, meta) { 138 | try { 139 | const trigger = (channelParams, event, data, exclude) => this.trigger(channel, { channelParams, event, data, exclude }); 140 | const broadcastTo = (channelName, channelParams, event, data, exclude) => this.trigger({ id: 'broadcastTo', channel_name: channelName }, { channelParams, event, data, exclude }); 141 | return (new Function('collection', 'action', 'trigger', 'broadcastTo', 'meta', channel.publisher))(channel.collection, action, trigger, broadcastTo, meta); 142 | } catch (_cp) { 143 | this.logger.error(`REALTIME CHANNEL PUBLISHER ERROR [CHECK YOUR 'PUBLISHER' CODE, CHANNEL NAME ${channel.name}] ; ${_cp.message}`); 144 | return false; 145 | } 146 | } 147 | } 148 | --------------------------------------------------------------------------------