From 8a91c9b89bf44af6f3612560656ef61313473939 Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Mon, 13 Feb 2023 12:41:30 +0000 Subject: [PATCH] Add all repos --- .devcontainer/devcontainer.json | 7 + compose-dev.yaml | 12 - docker-compose.yml | 32 +- elasticsearch/Dockerfile | 1 + label-studio/Dockerfile | 1 + memcached/Dockerfile | 1 + metamigo-api/.eslintrc.js | 1 + metamigo-api/app/index.ts | 26 + metamigo-api/app/lib/whatsapp-key-store.ts | 198 + metamigo-api/app/plugins/cloudflare-jwt.ts | 114 + metamigo-api/app/plugins/hapi-nextauth.ts | 26 + metamigo-api/app/plugins/index.ts | 32 + metamigo-api/app/plugins/nextauth-jwt.ts | 100 + metamigo-api/app/plugins/swagger.ts | 32 + metamigo-api/app/routes/helpers/index.ts | 21 + metamigo-api/app/routes/index.ts | 33 + metamigo-api/app/routes/signal/index.ts | 249 + metamigo-api/app/routes/users/index.ts | 59 + metamigo-api/app/routes/voice/index.ts | 124 + metamigo-api/app/routes/voice/twilio/index.ts | 230 + metamigo-api/app/routes/whatsapp/index.ts | 195 + metamigo-api/app/services/index.ts | 14 + metamigo-api/app/services/settings.ts | 16 + metamigo-api/app/services/signald.ts | 200 + metamigo-api/app/services/whatsapp.ts | 247 + metamigo-api/app/types/index.ts | 27 + metamigo-api/babel.config.json | 6 + metamigo-api/config.ts | 10 + metamigo-api/logger.ts | 8 + metamigo-api/package.json | 77 + metamigo-api/server/index.ts | 28 + metamigo-api/server/manifest.ts | 79 + metamigo-api/tsconfig.json | 10 + metamigo-api/worker-utils.ts | 21 + metamigo-cli/.eslintrc.js | 1 + metamigo-cli/config.ts | 18 + metamigo-cli/index.ts | 67 + metamigo-cli/jwks.ts | 68 + metamigo-cli/package.json | 39 + metamigo-cli/postgraphile.ts | 37 + metamigo-cli/tsconfig.json | 8 + metamigo-common/.eslintrc.js | 13 + metamigo-common/.gitignore | 13 + metamigo-common/.npmignore | 13 + metamigo-common/.prettierignore | 2 + metamigo-common/babel.config.json | 5 + metamigo-common/config/app-meta.ts | 31 + metamigo-common/config/auth.ts | 23 + metamigo-common/config/cors.ts | 32 + metamigo-common/config/formats.ts | 58 + metamigo-common/config/generate.ts | 44 + metamigo-common/config/index.ts | 142 + metamigo-common/config/logging.ts | 90 + metamigo-common/config/metrics-server.ts | 22 + metamigo-common/config/print.ts | 41 + metamigo-common/config/server.ts | 21 + metamigo-common/config/types.ts | 26 + .../controllers/crud-controller.ts | 295 + .../controllers/nextauth-adapter.ts | 185 + metamigo-common/db/types.ts | 8 + metamigo-common/hapi.ts | 123 + metamigo-common/helpers/index.ts | 44 + metamigo-common/helpers/response.ts | 59 + metamigo-common/helpers/validation-error.ts | 62 + metamigo-common/index.ts | 23 + metamigo-common/logger.ts | 22 + metamigo-common/package.json | 65 + metamigo-common/plugins/config.ts | 23 + metamigo-common/plugins/request-id.ts | 37 + metamigo-common/plugins/status.ts | 60 + metamigo-common/records/account.ts | 30 + metamigo-common/records/base.ts | 57 + metamigo-common/records/crud-repository.ts | 321 ++ metamigo-common/records/index.ts | 16 + metamigo-common/records/record-info.d.ts | 54 + metamigo-common/records/record-info.ts | 133 + metamigo-common/records/session.ts | 26 + metamigo-common/records/user.ts | 40 + metamigo-common/tsconfig.json | 13 + metamigo-config/.eslintrc.js | 18 + metamigo-config/index.ts | 376 ++ metamigo-config/package.json | 35 + metamigo-config/tsconfig.json | 8 + metamigo-db/.eslintrc.js | 1 + metamigo-db/.gmrc | 117 + metamigo-db/Dockerfile | 2 + metamigo-db/helpers.ts | 67 + metamigo-db/index.ts | 89 + metamigo-db/migrations/committed/000001.sql | 650 +++ metamigo-db/migrations/committed/000002.sql | 10 + metamigo-db/migrations/current.sql | 1 + metamigo-db/package.json | 39 + metamigo-db/records/index.ts | 9 + metamigo-db/records/settings.ts | 104 + metamigo-db/records/signal/bots.ts | 35 + metamigo-db/records/voice/voice-line.ts | 62 + metamigo-db/records/voice/voice-provider.ts | 52 + metamigo-db/records/webhooks.ts | 50 + metamigo-db/records/whatsapp/attachments.ts | 24 + metamigo-db/records/whatsapp/bots.ts | 48 + metamigo-db/records/whatsapp/messages.ts | 26 + metamigo-db/scripts/afterCurrent.sh | 28 + metamigo-db/scripts/afterReset.sql | 12 + metamigo-db/scripts/bootstrap.sh | 69 + metamigo-db/scripts/dump-db | 17 + metamigo-db/scripts/dump-db.js | 31 + metamigo-db/tsconfig.json | 8 + metamigo-frontend/.eslintignore | 7 + metamigo-frontend/.eslintrc | 3 + metamigo-frontend/components/AdminLogin.tsx | 85 + metamigo-frontend/components/Auth.tsx | 20 + .../DigitInput/DigitInput.module.css | 27 + .../components/DigitInput/index.tsx | 60 + .../components/MetamigoAdmin.tsx | 64 + .../components/accounts/AccountEdit.tsx | 59 + .../components/accounts/AccountList.tsx | 43 + .../components/accounts/index.ts | 10 + .../components/layout/AppBar.tsx | 54 + .../components/layout/Layout.tsx | 21 + metamigo-frontend/components/layout/Logo.tsx | 106 + metamigo-frontend/components/layout/Menu.tsx | 133 + .../components/layout/SubMenu.tsx | 83 + metamigo-frontend/components/layout/index.ts | 5 + metamigo-frontend/components/layout/themes.ts | 71 + .../components/signal/bots/Digits.module.css | 27 + .../signal/bots/SignalBotCreate.tsx | 34 + .../components/signal/bots/SignalBotEdit.tsx | 12 + .../components/signal/bots/SignalBotList.tsx | 23 + .../components/signal/bots/SignalBotShow.tsx | 474 ++ .../components/signal/bots/index.ts | 14 + .../components/signal/bots/shared.tsx | 24 + .../components/users/UserCreate.tsx | 27 + .../components/users/UserEdit.tsx | 64 + .../components/users/UserList.tsx | 28 + metamigo-frontend/components/users/index.ts | 12 + metamigo-frontend/components/users/shared.tsx | 14 + .../voice/providers/ProviderCreate.tsx | 33 + .../voice/providers/ProviderEdit.tsx | 31 + .../voice/providers/ProviderList.tsx | 14 + .../components/voice/providers/index.ts | 11 + .../components/voice/providers/shared.tsx | 9 + .../voice/voicelines/MicInput.module.css | 43 + .../components/voice/voicelines/MicInput.tsx | 147 + .../voice/voicelines/VoiceLineCreate.tsx | 54 + .../voice/voicelines/VoiceLineEdit.tsx | 51 + .../voice/voicelines/VoiceLineList.tsx | 30 + .../components/voice/voicelines/index.ts | 12 + .../voice/voicelines/recorder.module.css | 149 + .../components/voice/voicelines/shared.tsx | 296 + .../voice/voicelines/twilio-languages.ts | 65 + .../components/webhooks/WebhookCreate.tsx | 56 + .../components/webhooks/WebhookEdit.tsx | 52 + .../components/webhooks/WebhookList.tsx | 23 + .../components/webhooks/index.ts | 12 + .../components/webhooks/shared.tsx | 68 + .../attachments/WhatsappAttachmentList.tsx | 11 + .../attachments/WhatsappAttachmentShow.tsx | 11 + .../components/whatsapp/attachments/index.ts | 10 + .../whatsapp/bots/WhatsappBotCreate.tsx | 38 + .../whatsapp/bots/WhatsappBotEdit.tsx | 12 + .../whatsapp/bots/WhatsappBotList.tsx | 22 + .../whatsapp/bots/WhatsappBotShow.tsx | 177 + .../components/whatsapp/bots/index.ts | 14 + .../components/whatsapp/bots/shared.tsx | 24 + .../whatsapp/messages/WhatsappMessageList.tsx | 23 + .../whatsapp/messages/WhatsappMessageShow.tsx | 29 + .../components/whatsapp/messages/index.ts | 10 + metamigo-frontend/i18n/en.ts | 82 + metamigo-frontend/lib/absolute-url.ts | 35 + metamigo-frontend/lib/apollo-client.ts | 40 + metamigo-frontend/lib/cloudflare.ts | 210 + metamigo-frontend/lib/dataprovider.ts | 17 + metamigo-frontend/lib/nextauth-adapter.ts | 232 + metamigo-frontend/lib/phone-numbers.ts | 31 + metamigo-frontend/next-env.d.ts | 5 + metamigo-frontend/next.config.js | 23 + metamigo-frontend/package.json | 51 + metamigo-frontend/pages/_app.tsx | 13 + metamigo-frontend/pages/admin.tsx | 15 + .../pages/api/auth/[...nextauth].ts | 106 + .../pages/api/graphql/[[...path]].ts | 38 + .../pages/api/proxy/[[...path]].js | 38 + metamigo-frontend/pages/index.tsx | 29 + metamigo-frontend/pages/login.tsx | 27 + metamigo-frontend/public/silence.mp3 | Bin 0 -> 477 bytes metamigo-frontend/styles/Home.module.css | 123 + metamigo-frontend/styles/globals.css | 16 + metamigo-frontend/tsconfig.json | 20 + metamigo-worker/.eslintrc.js | 1 + metamigo-worker/.npmrc | 2 + metamigo-worker/babel.config.json | 5 + metamigo-worker/common.ts | 69 + metamigo-worker/db.ts | 47 + metamigo-worker/index.ts | 53 + metamigo-worker/lib/media-convert.ts | 84 + metamigo-worker/logger.ts | 8 + metamigo-worker/package.json | 55 + metamigo-worker/tasks/notify-webhook.ts | 57 + metamigo-worker/tasks/signal-message.ts | 76 + metamigo-worker/tasks/signald-message.ts | 87 + metamigo-worker/tasks/twilio-recording.ts | 101 + .../tasks/voice-line-audio-update.ts | 48 + metamigo-worker/tasks/voice-line-delete.ts | 41 + .../tasks/voice-line-provider-update.ts | 38 + metamigo-worker/tasks/whatsapp-message.ts | 94 + metamigo-worker/tsconfig.json | 8 + metamigo-worker/utils.ts | 21 + metamigo-worker/zammad.ts | 106 + nginx-proxy/Dockerfile | 1 + postgresql/Dockerfile | 1 + redis/Dockerfile | 1 + signald/Dockerfile | 1 + zammad-addon-hardening/.gitignore | 10 + zammad-addon-hardening/.gitlab-ci.yml | 36 + zammad-addon-hardening/.ruby-version | 1 + zammad-addon-hardening/LICENSE.md | 616 +++ zammad-addon-hardening/Makefile | 27 + zammad-addon-hardening/README.md | 55 + .../hardening.szpm.template | 29 + zammad-addon-hardening/new-migration.py | 43 + zammad-addon-hardening/package.py | 75 + .../views/mailer/ticket_create/cs.html.erb | 9 + .../views/mailer/ticket_create/de.html.erb | 8 + .../views/mailer/ticket_create/en.html.erb | 9 + .../views/mailer/ticket_create/es.html.erb | 9 + .../views/mailer/ticket_create/fr.html.erb | 9 + .../views/mailer/ticket_create/it.html.erb | 8 + .../views/mailer/ticket_create/pl.html.erb | 9 + .../views/mailer/ticket_create/pt-br.html.erb | 9 + .../views/mailer/ticket_create/zh-cn.html.erb | 9 + .../views/mailer/ticket_create/zh-tw.html.erb | 8 + .../mailer/ticket_escalation/cs.html.erb | 8 + .../mailer/ticket_escalation/de.html.erb | 8 + .../mailer/ticket_escalation/en.html.erb | 9 + .../mailer/ticket_escalation/es.html.erb | 9 + .../mailer/ticket_escalation/fr.html.erb | 9 + .../mailer/ticket_escalation/it.html.erb | 18 + .../mailer/ticket_escalation/pl.html.erb | 8 + .../mailer/ticket_escalation/pt-br.html.erb | 9 + .../mailer/ticket_escalation/zh-cn.html.erb | 9 + .../mailer/ticket_escalation/zh-tw.html.erb | 9 + .../ticket_escalation_warning/cs.html.erb | 9 + .../ticket_escalation_warning/de.html.erb | 9 + .../ticket_escalation_warning/en.html.erb | 9 + .../ticket_escalation_warning/es.html.erb | 9 + .../ticket_escalation_warning/fr.html.erb | 9 + .../ticket_escalation_warning/it.html.erb | 9 + .../ticket_escalation_warning/pl.html.erb | 9 + .../ticket_escalation_warning/pt-br.html.erb | 10 + .../ticket_escalation_warning/zh-cn.html.erb | 9 + .../ticket_escalation_warning/zh-tw.html.erb | 9 + .../ticket_reminder_reached/cs.html.erb | 9 + .../ticket_reminder_reached/de.html.erb | 9 + .../ticket_reminder_reached/en.html.erb | 9 + .../ticket_reminder_reached/es.html.erb | 9 + .../ticket_reminder_reached/fr.html.erb | 9 + .../ticket_reminder_reached/it.html.erb | 9 + .../ticket_reminder_reached/pt-br.html.erb | 9 + .../ticket_reminder_reached/zh-cn.html.erb | 9 + .../ticket_reminder_reached/zh-tw.html.erb | 9 + .../views/mailer/ticket_update/cs.html.erb | 11 + .../views/mailer/ticket_update/de.html.erb | 11 + .../views/mailer/ticket_update/en.html.erb | 11 + .../views/mailer/ticket_update/es.html.erb | 11 + .../views/mailer/ticket_update/fr.html.erb | 11 + .../views/mailer/ticket_update/it.html.erb | 11 + .../views/mailer/ticket_update/pt-br.html.erb | 11 + .../views/mailer/ticket_update/zh-cn.html.erb | 11 + .../views/mailer/ticket_update/zh-tw.html.erb | 11 + .../src/db/addon/hardening/.gitkeep | 0 ...0200211123028_hardening_harden_settings.rb | 46 + zammad-addon-metamigo/.gitignore | 12 + zammad-addon-metamigo/.gitlab-ci.yml | 25 + zammad-addon-metamigo/.ruby-version | 1 + zammad-addon-metamigo/LICENSE.md | 616 +++ zammad-addon-metamigo/Makefile | 20 + zammad-addon-metamigo/README.md | 70 + zammad-addon-metamigo/metamigo.szpm.template | 24 + zammad-addon-metamigo/new-migration.py | 46 + zammad-addon-metamigo/package.py | 75 + .../controllers/_channel/cdr_signal.coffee | 249 + .../app/controllers/_channel/cdr_voice.coffee | 249 + .../controllers/_channel/cdr_whatsapp.coffee | 249 + .../article_action/cdr_signal.coffee | 79 + .../article_action/cdr_whatsapp.coffee | 79 + .../app/views/cdr_signal/form_add.jst.eco | 47 + .../app/views/cdr_signal/form_edit.jst.eco | 55 + .../app/views/cdr_signal/index.jst.eco | 49 + .../app/views/cdr_voice/form_add.jst.eco | 29 + .../app/views/cdr_voice/form_edit.jst.eco | 37 + .../app/views/cdr_voice/index.jst.eco | 49 + .../app/views/cdr_whatsapp/form_add.jst.eco | 47 + .../app/views/cdr_whatsapp/form_edit.jst.eco | 55 + .../app/views/cdr_whatsapp/index.jst.eco | 49 + .../assets/stylesheets/addons/cdr_signal.css | 4 + .../stylesheets/addons/cdr_whatsapp.css | 4 + .../channels_cdr_signal_controller.rb | 268 + .../channels_cdr_voice_controller.rb | 253 + .../channels_cdr_whatsapp_controller.rb | 270 + .../app/models/channel/driver/cdr_signal.rb | 55 + .../app/models/channel/driver/cdr_whatsapp.rb | 55 + .../ticket/article/communicate_cdr_signal.rb | 25 + .../communicate_cdr_signal/background_job.rb | 137 + .../article/communicate_cdr_whatsapp.rb | 25 + .../background_job.rb | 137 + .../channels_cdr_signal_controller_policy.rb | 7 + .../channels_cdr_voice_controller_policy.rb | 7 + ...channels_cdr_whatsapp_controller_policy.rb | 7 + .../src/config/initializers/cdr_signal.rb | 15 + .../src/config/initializers/cdr_whatsapp.rb | 15 + .../src/config/routes/channel_cdr_signal.rb | 14 + .../src/config/routes/channel_cdr_voice.rb | 14 + .../src/config/routes/channel_cdr_whatsapp.rb | 14 + .../20210525091356_cdr_signal_channel.rb | 27 + .../20210525091357_cdr_voice_channel.rb | 28 + .../20210525091358_cdr_whatsapp_channel.rb | 27 + zammad-addon-metamigo/src/lib/cdr_signal.rb | 319 ++ .../src/lib/cdr_signal_api.rb | 41 + zammad-addon-metamigo/src/lib/cdr_whatsapp.rb | 319 ++ .../src/lib/cdr_whatsapp_api.rb | 41 + .../public/assets/images/icons/cdr_signal.svg | 3 + .../assets/images/icons/cdr_whatsapp.svg | 6 + zammad-addon-pgp/.gitignore | 6 + zammad-addon-pgp/.gitlab-ci.yml | 25 + zammad-addon-pgp/.ruby-version | 1 + zammad-addon-pgp/Gemfile | 10 + zammad-addon-pgp/Gemfile.lock | 60 + zammad-addon-pgp/LICENSE.md | 616 +++ zammad-addon-pgp/Makefile | 28 + zammad-addon-pgp/README.md | 51 + zammad-addon-pgp/Rakefile | 0 zammad-addon-pgp/new-migration.py | 43 + zammad-addon-pgp/package.py | 75 + .../app/controllers/_integration/pgp.coffee | 256 + .../_ui_element/ticket_perform_action.coffee | 614 +++ .../app/lib/mixins/security_options.coffee | 99 + .../app/views/integration/pgp.jst.eco | 14 + .../app/views/integration/pgp_group.jst.eco | 25 + .../app/views/integration/pgp_list.jst.eco | 44 + .../integration/pgp_private_key_add.jst.eco | 38 + .../integration/pgp_public_key_add.jst.eco | 27 + .../assets/stylesheets/addons/pgp-support.css | 1 + .../controllers/integration/pgp_controller.rb | 154 + .../src/app/models/pgp_keypair.rb | 64 + zammad-addon-pgp/src/app/models/ticket.rb | 1909 +++++++ .../integration/pgp_controller_policy.rb | 6 + .../src/config/initializers/inflections.rb | 29 + .../src/config/initializers/pgp_support.rb | 18 + .../src/config/routes/integration_pgp.rb | 14 + .../pgpsupport/20220403000001_pgpsupport.rb | 65 + zammad-addon-pgp/src/db/seeds/settings.rb | 4832 +++++++++++++++++ .../src/lib/secure_mailing/pgp.rb | 7 + .../src/lib/secure_mailing/pgp/incoming.rb | 170 + .../src/lib/secure_mailing/pgp/outgoing.rb | 120 + .../src/lib/secure_mailing/pgp/retry.rb | 93 + .../src/public/assets/images/icons/pgp.svg | 1 + .../zammad-addon-pgp.szpm.template | 24 + zammad/.dockerignore | 1 + zammad/.gitignore | 4 + zammad/.gitlab-ci.yml | 35 + zammad/Dockerfile | 124 + zammad/LICENSE.md | 660 +++ zammad/Makefile | 77 + zammad/README.md | 54 + zammad/auto_install/.gitkeep | 0 zammad/docker-entrypoint.sh | 186 + zammad/fetch_locales.rb | 30 + zammad/keys.asc | 559 ++ zammad/package-auto-reinstall.patch | 34 + 369 files changed, 29047 insertions(+), 28 deletions(-) create mode 100644 .devcontainer/devcontainer.json delete mode 100644 compose-dev.yaml create mode 100644 elasticsearch/Dockerfile create mode 100644 label-studio/Dockerfile create mode 100644 memcached/Dockerfile create mode 100644 metamigo-api/.eslintrc.js create mode 100644 metamigo-api/app/index.ts create mode 100644 metamigo-api/app/lib/whatsapp-key-store.ts create mode 100644 metamigo-api/app/plugins/cloudflare-jwt.ts create mode 100644 metamigo-api/app/plugins/hapi-nextauth.ts create mode 100644 metamigo-api/app/plugins/index.ts create mode 100644 metamigo-api/app/plugins/nextauth-jwt.ts create mode 100644 metamigo-api/app/plugins/swagger.ts create mode 100644 metamigo-api/app/routes/helpers/index.ts create mode 100644 metamigo-api/app/routes/index.ts create mode 100644 metamigo-api/app/routes/signal/index.ts create mode 100644 metamigo-api/app/routes/users/index.ts create mode 100644 metamigo-api/app/routes/voice/index.ts create mode 100644 metamigo-api/app/routes/voice/twilio/index.ts create mode 100644 metamigo-api/app/routes/whatsapp/index.ts create mode 100644 metamigo-api/app/services/index.ts create mode 100644 metamigo-api/app/services/settings.ts create mode 100644 metamigo-api/app/services/signald.ts create mode 100644 metamigo-api/app/services/whatsapp.ts create mode 100644 metamigo-api/app/types/index.ts create mode 100644 metamigo-api/babel.config.json create mode 100644 metamigo-api/config.ts create mode 100644 metamigo-api/logger.ts create mode 100644 metamigo-api/package.json create mode 100644 metamigo-api/server/index.ts create mode 100644 metamigo-api/server/manifest.ts create mode 100644 metamigo-api/tsconfig.json create mode 100644 metamigo-api/worker-utils.ts create mode 100644 metamigo-cli/.eslintrc.js create mode 100644 metamigo-cli/config.ts create mode 100644 metamigo-cli/index.ts create mode 100644 metamigo-cli/jwks.ts create mode 100644 metamigo-cli/package.json create mode 100644 metamigo-cli/postgraphile.ts create mode 100644 metamigo-cli/tsconfig.json create mode 100644 metamigo-common/.eslintrc.js create mode 100644 metamigo-common/.gitignore create mode 100644 metamigo-common/.npmignore create mode 100644 metamigo-common/.prettierignore create mode 100644 metamigo-common/babel.config.json create mode 100644 metamigo-common/config/app-meta.ts create mode 100644 metamigo-common/config/auth.ts create mode 100644 metamigo-common/config/cors.ts create mode 100644 metamigo-common/config/formats.ts create mode 100644 metamigo-common/config/generate.ts create mode 100644 metamigo-common/config/index.ts create mode 100644 metamigo-common/config/logging.ts create mode 100644 metamigo-common/config/metrics-server.ts create mode 100644 metamigo-common/config/print.ts create mode 100644 metamigo-common/config/server.ts create mode 100644 metamigo-common/config/types.ts create mode 100644 metamigo-common/controllers/crud-controller.ts create mode 100644 metamigo-common/controllers/nextauth-adapter.ts create mode 100644 metamigo-common/db/types.ts create mode 100644 metamigo-common/hapi.ts create mode 100644 metamigo-common/helpers/index.ts create mode 100644 metamigo-common/helpers/response.ts create mode 100644 metamigo-common/helpers/validation-error.ts create mode 100644 metamigo-common/index.ts create mode 100644 metamigo-common/logger.ts create mode 100644 metamigo-common/package.json create mode 100644 metamigo-common/plugins/config.ts create mode 100644 metamigo-common/plugins/request-id.ts create mode 100644 metamigo-common/plugins/status.ts create mode 100644 metamigo-common/records/account.ts create mode 100644 metamigo-common/records/base.ts create mode 100644 metamigo-common/records/crud-repository.ts create mode 100644 metamigo-common/records/index.ts create mode 100644 metamigo-common/records/record-info.d.ts create mode 100644 metamigo-common/records/record-info.ts create mode 100644 metamigo-common/records/session.ts create mode 100644 metamigo-common/records/user.ts create mode 100644 metamigo-common/tsconfig.json create mode 100644 metamigo-config/.eslintrc.js create mode 100644 metamigo-config/index.ts create mode 100644 metamigo-config/package.json create mode 100644 metamigo-config/tsconfig.json create mode 100644 metamigo-db/.eslintrc.js create mode 100644 metamigo-db/.gmrc create mode 100644 metamigo-db/Dockerfile create mode 100644 metamigo-db/helpers.ts create mode 100644 metamigo-db/index.ts create mode 100644 metamigo-db/migrations/committed/000001.sql create mode 100644 metamigo-db/migrations/committed/000002.sql create mode 100644 metamigo-db/migrations/current.sql create mode 100644 metamigo-db/package.json create mode 100644 metamigo-db/records/index.ts create mode 100644 metamigo-db/records/settings.ts create mode 100644 metamigo-db/records/signal/bots.ts create mode 100644 metamigo-db/records/voice/voice-line.ts create mode 100644 metamigo-db/records/voice/voice-provider.ts create mode 100644 metamigo-db/records/webhooks.ts create mode 100644 metamigo-db/records/whatsapp/attachments.ts create mode 100644 metamigo-db/records/whatsapp/bots.ts create mode 100644 metamigo-db/records/whatsapp/messages.ts create mode 100755 metamigo-db/scripts/afterCurrent.sh create mode 100644 metamigo-db/scripts/afterReset.sql create mode 100755 metamigo-db/scripts/bootstrap.sh create mode 100644 metamigo-db/scripts/dump-db create mode 100644 metamigo-db/scripts/dump-db.js create mode 100644 metamigo-db/tsconfig.json create mode 100644 metamigo-frontend/.eslintignore create mode 100644 metamigo-frontend/.eslintrc create mode 100644 metamigo-frontend/components/AdminLogin.tsx create mode 100644 metamigo-frontend/components/Auth.tsx create mode 100644 metamigo-frontend/components/DigitInput/DigitInput.module.css create mode 100644 metamigo-frontend/components/DigitInput/index.tsx create mode 100644 metamigo-frontend/components/MetamigoAdmin.tsx create mode 100644 metamigo-frontend/components/accounts/AccountEdit.tsx create mode 100644 metamigo-frontend/components/accounts/AccountList.tsx create mode 100644 metamigo-frontend/components/accounts/index.ts create mode 100644 metamigo-frontend/components/layout/AppBar.tsx create mode 100644 metamigo-frontend/components/layout/Layout.tsx create mode 100644 metamigo-frontend/components/layout/Logo.tsx create mode 100644 metamigo-frontend/components/layout/Menu.tsx create mode 100644 metamigo-frontend/components/layout/SubMenu.tsx create mode 100644 metamigo-frontend/components/layout/index.ts create mode 100644 metamigo-frontend/components/layout/themes.ts create mode 100644 metamigo-frontend/components/signal/bots/Digits.module.css create mode 100644 metamigo-frontend/components/signal/bots/SignalBotCreate.tsx create mode 100644 metamigo-frontend/components/signal/bots/SignalBotEdit.tsx create mode 100644 metamigo-frontend/components/signal/bots/SignalBotList.tsx create mode 100644 metamigo-frontend/components/signal/bots/SignalBotShow.tsx create mode 100644 metamigo-frontend/components/signal/bots/index.ts create mode 100644 metamigo-frontend/components/signal/bots/shared.tsx create mode 100644 metamigo-frontend/components/users/UserCreate.tsx create mode 100644 metamigo-frontend/components/users/UserEdit.tsx create mode 100644 metamigo-frontend/components/users/UserList.tsx create mode 100644 metamigo-frontend/components/users/index.ts create mode 100644 metamigo-frontend/components/users/shared.tsx create mode 100644 metamigo-frontend/components/voice/providers/ProviderCreate.tsx create mode 100644 metamigo-frontend/components/voice/providers/ProviderEdit.tsx create mode 100644 metamigo-frontend/components/voice/providers/ProviderList.tsx create mode 100644 metamigo-frontend/components/voice/providers/index.ts create mode 100644 metamigo-frontend/components/voice/providers/shared.tsx create mode 100644 metamigo-frontend/components/voice/voicelines/MicInput.module.css create mode 100644 metamigo-frontend/components/voice/voicelines/MicInput.tsx create mode 100644 metamigo-frontend/components/voice/voicelines/VoiceLineCreate.tsx create mode 100644 metamigo-frontend/components/voice/voicelines/VoiceLineEdit.tsx create mode 100644 metamigo-frontend/components/voice/voicelines/VoiceLineList.tsx create mode 100644 metamigo-frontend/components/voice/voicelines/index.ts create mode 100644 metamigo-frontend/components/voice/voicelines/recorder.module.css create mode 100644 metamigo-frontend/components/voice/voicelines/shared.tsx create mode 100644 metamigo-frontend/components/voice/voicelines/twilio-languages.ts create mode 100644 metamigo-frontend/components/webhooks/WebhookCreate.tsx create mode 100644 metamigo-frontend/components/webhooks/WebhookEdit.tsx create mode 100644 metamigo-frontend/components/webhooks/WebhookList.tsx create mode 100644 metamigo-frontend/components/webhooks/index.ts create mode 100644 metamigo-frontend/components/webhooks/shared.tsx create mode 100644 metamigo-frontend/components/whatsapp/attachments/WhatsappAttachmentList.tsx create mode 100644 metamigo-frontend/components/whatsapp/attachments/WhatsappAttachmentShow.tsx create mode 100644 metamigo-frontend/components/whatsapp/attachments/index.ts create mode 100644 metamigo-frontend/components/whatsapp/bots/WhatsappBotCreate.tsx create mode 100644 metamigo-frontend/components/whatsapp/bots/WhatsappBotEdit.tsx create mode 100644 metamigo-frontend/components/whatsapp/bots/WhatsappBotList.tsx create mode 100644 metamigo-frontend/components/whatsapp/bots/WhatsappBotShow.tsx create mode 100644 metamigo-frontend/components/whatsapp/bots/index.ts create mode 100644 metamigo-frontend/components/whatsapp/bots/shared.tsx create mode 100644 metamigo-frontend/components/whatsapp/messages/WhatsappMessageList.tsx create mode 100644 metamigo-frontend/components/whatsapp/messages/WhatsappMessageShow.tsx create mode 100644 metamigo-frontend/components/whatsapp/messages/index.ts create mode 100644 metamigo-frontend/i18n/en.ts create mode 100644 metamigo-frontend/lib/absolute-url.ts create mode 100644 metamigo-frontend/lib/apollo-client.ts create mode 100644 metamigo-frontend/lib/cloudflare.ts create mode 100644 metamigo-frontend/lib/dataprovider.ts create mode 100644 metamigo-frontend/lib/nextauth-adapter.ts create mode 100644 metamigo-frontend/lib/phone-numbers.ts create mode 100644 metamigo-frontend/next-env.d.ts create mode 100644 metamigo-frontend/next.config.js create mode 100644 metamigo-frontend/package.json create mode 100644 metamigo-frontend/pages/_app.tsx create mode 100644 metamigo-frontend/pages/admin.tsx create mode 100644 metamigo-frontend/pages/api/auth/[...nextauth].ts create mode 100644 metamigo-frontend/pages/api/graphql/[[...path]].ts create mode 100644 metamigo-frontend/pages/api/proxy/[[...path]].js create mode 100644 metamigo-frontend/pages/index.tsx create mode 100644 metamigo-frontend/pages/login.tsx create mode 100644 metamigo-frontend/public/silence.mp3 create mode 100644 metamigo-frontend/styles/Home.module.css create mode 100644 metamigo-frontend/styles/globals.css create mode 100644 metamigo-frontend/tsconfig.json create mode 100644 metamigo-worker/.eslintrc.js create mode 100644 metamigo-worker/.npmrc create mode 100644 metamigo-worker/babel.config.json create mode 100644 metamigo-worker/common.ts create mode 100644 metamigo-worker/db.ts create mode 100644 metamigo-worker/index.ts create mode 100644 metamigo-worker/lib/media-convert.ts create mode 100644 metamigo-worker/logger.ts create mode 100644 metamigo-worker/package.json create mode 100644 metamigo-worker/tasks/notify-webhook.ts create mode 100644 metamigo-worker/tasks/signal-message.ts create mode 100644 metamigo-worker/tasks/signald-message.ts create mode 100644 metamigo-worker/tasks/twilio-recording.ts create mode 100644 metamigo-worker/tasks/voice-line-audio-update.ts create mode 100644 metamigo-worker/tasks/voice-line-delete.ts create mode 100644 metamigo-worker/tasks/voice-line-provider-update.ts create mode 100644 metamigo-worker/tasks/whatsapp-message.ts create mode 100644 metamigo-worker/tsconfig.json create mode 100644 metamigo-worker/utils.ts create mode 100644 metamigo-worker/zammad.ts create mode 100644 nginx-proxy/Dockerfile create mode 100644 postgresql/Dockerfile create mode 100644 redis/Dockerfile create mode 100644 signald/Dockerfile create mode 100644 zammad-addon-hardening/.gitignore create mode 100644 zammad-addon-hardening/.gitlab-ci.yml create mode 100644 zammad-addon-hardening/.ruby-version create mode 100644 zammad-addon-hardening/LICENSE.md create mode 100644 zammad-addon-hardening/Makefile create mode 100644 zammad-addon-hardening/README.md create mode 100644 zammad-addon-hardening/hardening.szpm.template create mode 100755 zammad-addon-hardening/new-migration.py create mode 100755 zammad-addon-hardening/package.py create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_create/cs.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_create/de.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_create/en.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_create/es.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_create/fr.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_create/it.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_create/pl.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_create/pt-br.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_create/zh-cn.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_create/zh-tw.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation/cs.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation/de.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation/en.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation/es.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation/fr.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation/it.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation/pl.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation/pt-br.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation/zh-cn.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation/zh-tw.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/cs.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/de.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/en.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/es.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/fr.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/it.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/pl.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/pt-br.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/zh-cn.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/zh-tw.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/cs.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/de.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/en.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/es.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/fr.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/it.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/pt-br.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/zh-cn.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/zh-tw.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_update/cs.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_update/de.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_update/en.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_update/es.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_update/fr.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_update/it.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_update/pt-br.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_update/zh-cn.html.erb create mode 100644 zammad-addon-hardening/src/app/views/mailer/ticket_update/zh-tw.html.erb create mode 100644 zammad-addon-hardening/src/db/addon/hardening/.gitkeep create mode 100644 zammad-addon-hardening/src/db/addon/hardening/20200211123028_hardening_harden_settings.rb create mode 100644 zammad-addon-metamigo/.gitignore create mode 100644 zammad-addon-metamigo/.gitlab-ci.yml create mode 100644 zammad-addon-metamigo/.ruby-version create mode 100644 zammad-addon-metamigo/LICENSE.md create mode 100644 zammad-addon-metamigo/Makefile create mode 100644 zammad-addon-metamigo/README.md create mode 100644 zammad-addon-metamigo/metamigo.szpm.template create mode 100755 zammad-addon-metamigo/new-migration.py create mode 100755 zammad-addon-metamigo/package.py create mode 100644 zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/_channel/cdr_signal.coffee create mode 100644 zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/_channel/cdr_voice.coffee create mode 100644 zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/_channel/cdr_whatsapp.coffee create mode 100644 zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/ticket_zoom/article_action/cdr_signal.coffee create mode 100644 zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/ticket_zoom/article_action/cdr_whatsapp.coffee create mode 100644 zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_signal/form_add.jst.eco create mode 100644 zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_signal/form_edit.jst.eco create mode 100644 zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_signal/index.jst.eco create mode 100644 zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_voice/form_add.jst.eco create mode 100644 zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_voice/form_edit.jst.eco create mode 100644 zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_voice/index.jst.eco create mode 100644 zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_whatsapp/form_add.jst.eco create mode 100644 zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_whatsapp/form_edit.jst.eco create mode 100644 zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_whatsapp/index.jst.eco create mode 100644 zammad-addon-metamigo/src/app/assets/stylesheets/addons/cdr_signal.css create mode 100644 zammad-addon-metamigo/src/app/assets/stylesheets/addons/cdr_whatsapp.css create mode 100644 zammad-addon-metamigo/src/app/controllers/channels_cdr_signal_controller.rb create mode 100644 zammad-addon-metamigo/src/app/controllers/channels_cdr_voice_controller.rb create mode 100644 zammad-addon-metamigo/src/app/controllers/channels_cdr_whatsapp_controller.rb create mode 100644 zammad-addon-metamigo/src/app/models/channel/driver/cdr_signal.rb create mode 100644 zammad-addon-metamigo/src/app/models/channel/driver/cdr_whatsapp.rb create mode 100644 zammad-addon-metamigo/src/app/models/observer/ticket/article/communicate_cdr_signal.rb create mode 100644 zammad-addon-metamigo/src/app/models/observer/ticket/article/communicate_cdr_signal/background_job.rb create mode 100644 zammad-addon-metamigo/src/app/models/observer/ticket/article/communicate_cdr_whatsapp.rb create mode 100644 zammad-addon-metamigo/src/app/models/observer/ticket/article/communicate_cdr_whatsapp/background_job.rb create mode 100644 zammad-addon-metamigo/src/app/policies/controllers/channels_cdr_signal_controller_policy.rb create mode 100644 zammad-addon-metamigo/src/app/policies/controllers/channels_cdr_voice_controller_policy.rb create mode 100644 zammad-addon-metamigo/src/app/policies/controllers/channels_cdr_whatsapp_controller_policy.rb create mode 100644 zammad-addon-metamigo/src/config/initializers/cdr_signal.rb create mode 100644 zammad-addon-metamigo/src/config/initializers/cdr_whatsapp.rb create mode 100644 zammad-addon-metamigo/src/config/routes/channel_cdr_signal.rb create mode 100644 zammad-addon-metamigo/src/config/routes/channel_cdr_voice.rb create mode 100644 zammad-addon-metamigo/src/config/routes/channel_cdr_whatsapp.rb create mode 100644 zammad-addon-metamigo/src/db/addon/cdr_signal/20210525091356_cdr_signal_channel.rb create mode 100644 zammad-addon-metamigo/src/db/addon/cdr_voice/20210525091357_cdr_voice_channel.rb create mode 100644 zammad-addon-metamigo/src/db/addon/cdr_whatsapp/20210525091358_cdr_whatsapp_channel.rb create mode 100644 zammad-addon-metamigo/src/lib/cdr_signal.rb create mode 100644 zammad-addon-metamigo/src/lib/cdr_signal_api.rb create mode 100644 zammad-addon-metamigo/src/lib/cdr_whatsapp.rb create mode 100644 zammad-addon-metamigo/src/lib/cdr_whatsapp_api.rb create mode 100644 zammad-addon-metamigo/src/public/assets/images/icons/cdr_signal.svg create mode 100644 zammad-addon-metamigo/src/public/assets/images/icons/cdr_whatsapp.svg create mode 100644 zammad-addon-pgp/.gitignore create mode 100644 zammad-addon-pgp/.gitlab-ci.yml create mode 100644 zammad-addon-pgp/.ruby-version create mode 100644 zammad-addon-pgp/Gemfile create mode 100644 zammad-addon-pgp/Gemfile.lock create mode 100644 zammad-addon-pgp/LICENSE.md create mode 100644 zammad-addon-pgp/Makefile create mode 100644 zammad-addon-pgp/README.md create mode 100644 zammad-addon-pgp/Rakefile create mode 100644 zammad-addon-pgp/new-migration.py create mode 100755 zammad-addon-pgp/package.py create mode 100644 zammad-addon-pgp/src/app/assets/javascripts/app/controllers/_integration/pgp.coffee create mode 100644 zammad-addon-pgp/src/app/assets/javascripts/app/controllers/_ui_element/ticket_perform_action.coffee create mode 100644 zammad-addon-pgp/src/app/assets/javascripts/app/lib/mixins/security_options.coffee create mode 100644 zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp.jst.eco create mode 100644 zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp_group.jst.eco create mode 100644 zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp_list.jst.eco create mode 100644 zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp_private_key_add.jst.eco create mode 100644 zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp_public_key_add.jst.eco create mode 100644 zammad-addon-pgp/src/app/assets/stylesheets/addons/pgp-support.css create mode 100644 zammad-addon-pgp/src/app/controllers/integration/pgp_controller.rb create mode 100644 zammad-addon-pgp/src/app/models/pgp_keypair.rb create mode 100644 zammad-addon-pgp/src/app/models/ticket.rb create mode 100644 zammad-addon-pgp/src/app/policies/controllers/integration/pgp_controller_policy.rb create mode 100644 zammad-addon-pgp/src/config/initializers/inflections.rb create mode 100644 zammad-addon-pgp/src/config/initializers/pgp_support.rb create mode 100644 zammad-addon-pgp/src/config/routes/integration_pgp.rb create mode 100644 zammad-addon-pgp/src/db/addon/pgpsupport/20220403000001_pgpsupport.rb create mode 100644 zammad-addon-pgp/src/db/seeds/settings.rb create mode 100644 zammad-addon-pgp/src/lib/secure_mailing/pgp.rb create mode 100644 zammad-addon-pgp/src/lib/secure_mailing/pgp/incoming.rb create mode 100644 zammad-addon-pgp/src/lib/secure_mailing/pgp/outgoing.rb create mode 100644 zammad-addon-pgp/src/lib/secure_mailing/pgp/retry.rb create mode 100644 zammad-addon-pgp/src/public/assets/images/icons/pgp.svg create mode 100644 zammad-addon-pgp/zammad-addon-pgp.szpm.template create mode 100644 zammad/.dockerignore create mode 100644 zammad/.gitignore create mode 100644 zammad/.gitlab-ci.yml create mode 100644 zammad/Dockerfile create mode 100644 zammad/LICENSE.md create mode 100644 zammad/Makefile create mode 100644 zammad/README.md create mode 100644 zammad/auto_install/.gitkeep create mode 100644 zammad/docker-entrypoint.sh create mode 100755 zammad/fetch_locales.rb create mode 100644 zammad/keys.asc create mode 100644 zammad/package-auto-reinstall.patch diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..8570436 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,7 @@ +{ + "image": "mcr.microsoft.com/devcontainers/universal:2", + "runArgs": ["--device=/dev/net/tun"], + "features": { + "ghcr.io/tailscale/codespace/tailscale": {} + } +} diff --git a/compose-dev.yaml b/compose-dev.yaml deleted file mode 100644 index a0f0979..0000000 --- a/compose-dev.yaml +++ /dev/null @@ -1,12 +0,0 @@ -services: - app: - entrypoint: - - sleep - - infinity - image: registry.gitlab.com/redaranj/dev-environment:latest - init: true - volumes: - - type: bind - source: /var/run/docker.sock - target: /var/run/docker.sock - diff --git a/docker-compose.yml b/docker-compose.yml index c54e452..3b32b85 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,7 +40,7 @@ services: zammad-elasticsearch: environment: - discovery.type=single-node - image: ${ZAMMAD_ELASTICSEARCH_IMAGE} + build: ./elasticsearch restart: ${RESTART} volumes: - elasticsearch-data:/usr/share/elasticsearch/data @@ -53,14 +53,14 @@ services: <<: *common-zammad-variables POSTGRESQL_USER: zammad POSTGRESQL_PASS: ${ZAMMAD_DATABASE_PASSWORD} - image: ${ZAMMAD_IMAGE} + build: ./zammad restart: on-failure volumes: - zammad-data:/opt/zammad zammad-memcached: command: memcached -m 256M - image: memcached:1.6.10-alpine + build: ./memcached restart: ${RESTART} zammad-nginx: @@ -69,7 +69,7 @@ services: - "80" depends_on: - zammad-railsserver - image: ${ZAMMAD_IMAGE} + build: ./zammad restart: ${RESTART} environment: VIRTUAL_HOST: ${ZAMMAD_VIRTUAL_HOST} @@ -82,7 +82,7 @@ services: environment: - POSTGRES_USER=zammad - POSTGRES_PASSWORD=${ZAMMAD_DATABASE_PASSWORD} - image: ${ZAMMAD_POSTGRES_IMAGE} + build: ./postgres restart: ${RESTART} volumes: - postgresql-data:/var/lib/postgresql/data @@ -94,13 +94,13 @@ services: - zammad-postgresql - zammad-redis environment: *common-zammad-variables - image: ${ZAMMAD_IMAGE} + build: ./zammad restart: ${RESTART} volumes: - zammad-data:/opt/zammad zammad-redis: - image: redis:6.2.5-alpine + build: ./redis restart: ${RESTART} zammad-scheduler: @@ -110,7 +110,7 @@ services: - zammad-railsserver - zammad-redis environment: *common-zammad-variables - image: ${ZAMMAD_IMAGE} + build: ./zammad restart: ${RESTART} volumes: - zammad-data:/opt/zammad @@ -122,13 +122,13 @@ services: - zammad-railsserver - zammad-redis environment: *common-zammad-variables - image: ${ZAMMAD_IMAGE} + build: ./zammad restart: ${RESTART} volumes: - zammad-data:/opt/zammad metamigo-api: - image: ${METAMIGO_IMAGE} + build: ./metamigo-api container_name: metamigo-api restart: ${RESTART} command: [ "api" ] @@ -139,7 +139,7 @@ services: - ./signald:/signald metamigo-frontend: - image: ${METAMIGO_IMAGE} + build: ./metamigo-frontend container_name: metamigo-frontend restart: ${RESTART} command: [ "frontend" ] @@ -153,14 +153,14 @@ services: VIRTUAL_PORT: 3000 metamigo-worker: - image: ${METAMIGO_IMAGE} + build: ./metamigo-worker container_name: metamigo-worker restart: ${RESTART} command: [ "worker" ] environment: *common-metamigo-variables metamigo-postgresql: - image: ${METAMIGO_POSTGRES_IMAGE} + build: ./postgresql container_name: metamigo-postgresql restart: ${RESTART} volumes: @@ -177,14 +177,14 @@ services: - 127.0.0.1:5432:5432 signald: - image: ${SIGNALD_IMAGE} + build: ./signald restart: ${RESTART} user: ${CURRENT_UID} volumes: - - ./signald:/signald + - ../signald:/signald nginx-proxy: - image: ${NGINX_IMAGE} + build: ./nginx-proxy restart: ${RESTART} ports: - "80:80" diff --git a/elasticsearch/Dockerfile b/elasticsearch/Dockerfile new file mode 100644 index 0000000..3a8f8c2 --- /dev/null +++ b/elasticsearch/Dockerfile @@ -0,0 +1 @@ +FROM elasticsearch/elasticsearch:8.6.1 diff --git a/label-studio/Dockerfile b/label-studio/Dockerfile new file mode 100644 index 0000000..d410ed3 --- /dev/null +++ b/label-studio/Dockerfile @@ -0,0 +1 @@ +FROM heartexlabs/label-studio:1.7.1 diff --git a/memcached/Dockerfile b/memcached/Dockerfile new file mode 100644 index 0000000..769c162 --- /dev/null +++ b/memcached/Dockerfile @@ -0,0 +1 @@ +FROM memcached:1.6.10-alpine diff --git a/metamigo-api/.eslintrc.js b/metamigo-api/.eslintrc.js new file mode 100644 index 0000000..d8da07c --- /dev/null +++ b/metamigo-api/.eslintrc.js @@ -0,0 +1 @@ +require("../.eslintrc.js"); diff --git a/metamigo-api/app/index.ts b/metamigo-api/app/index.ts new file mode 100644 index 0000000..6452a38 --- /dev/null +++ b/metamigo-api/app/index.ts @@ -0,0 +1,26 @@ +import type * as Hapi from "@hapi/hapi"; +import * as Joi from "joi"; +import type { IAppConfig } from "../config"; +import * as Services from "./services"; +import * as Routes from "./routes"; +import * as Plugins from "./plugins"; + +const AppPlugin = { + name: "App", + register: async ( + server: Hapi.Server, + options: { config: IAppConfig } + ): Promise => { + // declare our **run-time** plugin dependencies + // these are runtime only deps, not registration time + // ref: https://hapipal.com/best-practices/handling-plugin-dependencies + server.dependency(["config", "hapi-pino"]); + + server.validator(Joi); + await Plugins.register(server, options.config); + await Services.register(server); + await Routes.register(server); + }, +}; + +export default AppPlugin; diff --git a/metamigo-api/app/lib/whatsapp-key-store.ts b/metamigo-api/app/lib/whatsapp-key-store.ts new file mode 100644 index 0000000..bf38e71 --- /dev/null +++ b/metamigo-api/app/lib/whatsapp-key-store.ts @@ -0,0 +1,198 @@ +import { Boom } from "@hapi/boom"; +import { Server } from "@hapi/hapi"; +import { randomBytes } from "crypto"; +import type { Logger } from "pino"; +import { + proto, + BufferJSON, + generateRegistrationId, + Curve, + signedKeyPair, + AuthenticationCreds, + AuthenticationState, + AccountSettings, + SignalDataSet, + SignalDataTypeMap, + SignalKeyStore, + SignalKeyStoreWithTransaction, +} from "@adiwajshing/baileys"; +import { SavedWhatsappBot as Bot } from "db"; + +const KEY_MAP: { [T in keyof SignalDataTypeMap]: string } = { + "pre-key": "preKeys", + session: "sessions", + "sender-key": "senderKeys", + "app-state-sync-key": "appStateSyncKeys", + "app-state-sync-version": "appStateVersions", + "sender-key-memory": "senderKeyMemory", +}; + +export const addTransactionCapability = ( + state: SignalKeyStore, + logger: Logger +): SignalKeyStoreWithTransaction => { + let inTransaction = false; + let transactionCache: SignalDataSet = {}; + let mutations: SignalDataSet = {}; + + const prefetch = async (type: keyof SignalDataTypeMap, ids: string[]) => { + if (!inTransaction) { + throw new Boom("Cannot prefetch without transaction"); + } + + const dict = transactionCache[type]; + const idsRequiringFetch = dict + ? ids.filter((item) => !(item in dict)) + : ids; + // only fetch if there are any items to fetch + if (idsRequiringFetch.length) { + const result = await state.get(type, idsRequiringFetch); + + transactionCache[type] = transactionCache[type] || {}; + // @ts-expect-error + Object.assign(transactionCache[type], result); + } + }; + + return { + get: async (type, ids) => { + if (inTransaction) { + await prefetch(type, ids); + return ids.reduce((dict, id) => { + const value = transactionCache[type]?.[id]; + if (value) { + // @ts-expect-error + dict[id] = value; + } + + return dict; + }, {}); + } else { + return state.get(type, ids); + } + }, + set: (data) => { + if (inTransaction) { + logger.trace({ types: Object.keys(data) }, "caching in transaction"); + for (const key in data) { + // @ts-expect-error + transactionCache[key] = transactionCache[key] || {}; + // @ts-expect-error + Object.assign(transactionCache[key], data[key]); + // @ts-expect-error + mutations[key] = mutations[key] || {}; + // @ts-expect-error + Object.assign(mutations[key], data[key]); + } + } else { + return state.set(data); + } + }, + isInTransaction: () => inTransaction, + // @ts-expect-error + prefetch: (type, ids) => { + logger.trace({ type, ids }, "prefetching"); + return prefetch(type, ids); + }, + transaction: async (work) => { + if (inTransaction) { + await work(); + } else { + logger.debug("entering transaction"); + inTransaction = true; + try { + await work(); + if (Object.keys(mutations).length) { + logger.debug("committing transaction"); + await state.set(mutations); + } else { + logger.debug("no mutations in transaction"); + } + } finally { + inTransaction = false; + transactionCache = {}; + mutations = {}; + } + } + }, + }; +}; + +export const initAuthCreds = (): AuthenticationCreds => { + const identityKey = Curve.generateKeyPair(); + return { + noiseKey: Curve.generateKeyPair(), + signedIdentityKey: identityKey, + signedPreKey: signedKeyPair(identityKey, 1), + registrationId: generateRegistrationId(), + advSecretKey: randomBytes(32).toString("base64"), + nextPreKeyId: 1, + firstUnuploadedPreKeyId: 1, + + processedHistoryMessages: [], + accountSettings: { + unarchiveChats: false, + }, + } as any; +}; + +export const useDatabaseAuthState = ( + bot: Bot, + server: Server +): { state: AuthenticationState; saveState: () => void } => { + let { logger }: any = server; + let creds: AuthenticationCreds; + let keys: any = {}; + + const saveState = async () => { + logger && logger.trace("saving auth state"); + const authInfo = JSON.stringify({ creds, keys }, BufferJSON.replacer, 2); + await server.db().whatsappBots.updateAuthInfo(bot, authInfo); + }; + + if (bot.authInfo) { + console.log("Auth info exists"); + const result = JSON.parse(bot.authInfo, BufferJSON.reviver); + creds = result.creds; + keys = result.keys; + } else { + console.log("Auth info does not exist"); + creds = initAuthCreds(); + keys = {}; + } + + return { + state: { + creds, + keys: { + get: (type, ids) => { + const key = KEY_MAP[type]; + return ids.reduce((dict, id) => { + let value = keys[key]?.[id]; + if (value) { + if (type === "app-state-sync-key") { + // @ts-expect-error + value = proto.AppStateSyncKeyData.fromObject(value); + } + // @ts-expect-error + dict[id] = value; + } + + return dict; + }, {}); + }, + set: (data) => { + for (const _key in data) { + const key = KEY_MAP[_key as keyof SignalDataTypeMap]; + keys[key] = keys[key] || {}; + // @ts-expect-error + Object.assign(keys[key], data[_key]); + } + + saveState(); + }, + }, + }, + saveState, + }; +}; diff --git a/metamigo-api/app/plugins/cloudflare-jwt.ts b/metamigo-api/app/plugins/cloudflare-jwt.ts new file mode 100644 index 0000000..05cddf4 --- /dev/null +++ b/metamigo-api/app/plugins/cloudflare-jwt.ts @@ -0,0 +1,114 @@ +import * as Boom from "@hapi/boom"; +import * as Hoek from "@hapi/hoek"; +import * as Hapi from "@hapi/hapi"; +import { promisify } from "util"; +import jwt from "jsonwebtoken"; +import jwksClient, { hapiJwt2KeyAsync } from "jwks-rsa"; +import type { IAppConfig } from "../../config"; + +const CF_JWT_HEADER_NAME = "cf-access-jwt-assertion"; +const CF_JWT_ALGOS = ["RS256"]; + +const verifyToken = (settings: any) => { + const { audience, issuer } = settings; + const client = jwksClient({ + jwksUri: `${issuer}/cdn-cgi/access/certs`, + }); + + return async (token: any) => { + const getKey = (header: any, callback: any) => { + client.getSigningKey(header.kid, (err, key) => { + if (err) + throw Boom.serverUnavailable( + "failed to fetch cloudflare access jwks" + ); + callback(undefined, key?.getPublicKey()); + }); + }; + + const opts = { + algorithms: CF_JWT_ALGOS, + audience, + issuer, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (promisify(jwt.verify) as any)(token, getKey, opts); + }; +}; + +const handleCfJwt = (verify: any) => async ( + request: Hapi.Request, + h: Hapi.ResponseToolkit +) => { + const token = request.headers[CF_JWT_HEADER_NAME]; + if (token) { + try { + await verify(token); + } catch (error) { + console.error(error); + return Boom.unauthorized("invalid cloudflare access token"); + } + } + + return h.continue; +}; + +const defaultOpts = { + issuer: undefined, + audience: undefined, + strategyName: "clouflareaccess", + validate: undefined, +}; + +const cfJwtRegister = async (server: Hapi.Server, options: any): Promise => { + server.dependency(["hapi-auth-jwt2"]); + const settings = Hoek.applyToDefaults(defaultOpts, options); + const verify = verifyToken(settings); + + const { validate, strategyName, audience, issuer } = settings; + server.ext("onPreAuth", handleCfJwt(verify)); + + server.auth.strategy(strategyName!, "jwt", { + key: hapiJwt2KeyAsync({ + jwksUri: `${issuer}/cdn-cgi/access/certs`, + }), + cookieKey: false, + urlKey: false, + headerKey: CF_JWT_HEADER_NAME, + validate, + verifyOptions: { + audience, + issuer, + algorithms: ["RS256"], + }, + }); +}; + +export const registerCloudflareAccessJwt = async ( + server: Hapi.Server, + config: IAppConfig +): Promise => { + const { audience, domain } = config.cfaccess; + // only enable this plugin if cloudflare access config is configured + if (audience && domain) { + server.log(["auth"], "cloudflare access authorization enabled"); + await server.register({ + plugin: { + name: "cloudflare-jwt", + version: "0.0.1", + register: cfJwtRegister, + }, + options: { + issuer: `https://${domain}`, + audience, + validate: (decoded: any, _request: any) => { + const { email, name } = decoded; + return { + isValid: true, + credentials: { user: { email, name } }, + }; + }, + }, + }); + } +}; diff --git a/metamigo-api/app/plugins/hapi-nextauth.ts b/metamigo-api/app/plugins/hapi-nextauth.ts new file mode 100644 index 0000000..92b6737 --- /dev/null +++ b/metamigo-api/app/plugins/hapi-nextauth.ts @@ -0,0 +1,26 @@ +import type * as Hapi from "@hapi/hapi"; +import NextAuthPlugin, { AdapterFactory } from "@digiresilience/hapi-nextauth"; +import { NextAuthAdapter } from "common"; +import type { SavedUser, UnsavedUser, SavedSession } from "common"; +import { IAppConfig } from "config"; + +export const registerNextAuth = async ( + server: Hapi.Server, + config: IAppConfig +): Promise => { + // I'm not sure why I need to be so explicit with the generic types here + // I thought ts could figure out the generics based on the concrete params, but apparently not + const nextAuthAdapterFactory: AdapterFactory< + SavedUser, + UnsavedUser, + SavedSession + > = (request: Hapi.Request) => new NextAuthAdapter(request.db()); + + await server.register({ + plugin: NextAuthPlugin, + options: { + nextAuthAdapterFactory, + sharedSecret: config.nextAuth.secret, + }, + }); +}; diff --git a/metamigo-api/app/plugins/index.ts b/metamigo-api/app/plugins/index.ts new file mode 100644 index 0000000..3a66547 --- /dev/null +++ b/metamigo-api/app/plugins/index.ts @@ -0,0 +1,32 @@ +import type * as Hapi from "@hapi/hapi"; +import Schmervice from "@hapipal/schmervice"; +import PgPromisePlugin from "@digiresilience/hapi-pg-promise"; + +import type { IAppConfig } from "../../config"; +import { dbInitOptions } from "db"; +import { registerNextAuth } from "./hapi-nextauth"; +import { registerSwagger } from "./swagger"; +import { registerNextAuthJwt } from "./nextauth-jwt"; +import { registerCloudflareAccessJwt } from "./cloudflare-jwt"; + +export const register = async ( + server: Hapi.Server, + config: IAppConfig +): Promise => { + await server.register(Schmervice); + + await server.register({ + plugin: PgPromisePlugin, + options: { + // the only required parameter is the connection string + connection: config.db.connection, + // ... and the pg-promise initialization options + pgpInit: dbInitOptions(config), + }, + }); + + await registerNextAuth(server, config); + await registerSwagger(server); + await registerNextAuthJwt(server, config); + await registerCloudflareAccessJwt(server, config); +}; diff --git a/metamigo-api/app/plugins/nextauth-jwt.ts b/metamigo-api/app/plugins/nextauth-jwt.ts new file mode 100644 index 0000000..bef23f3 --- /dev/null +++ b/metamigo-api/app/plugins/nextauth-jwt.ts @@ -0,0 +1,100 @@ +import * as Hoek from "@hapi/hoek"; +import * as Hapi from "@hapi/hapi"; +import type { IAppConfig } from "../../config"; + +// hapi-auth-jwt2 expects the key to be a raw key +const jwkToHapiAuthJwt2 = (jwkString) => { + try { + const jwk = JSON.parse(jwkString); + return Buffer.from(jwk.k, "base64"); + } catch { + throw new Error( + "Failed to parse key for JWT verification. This is probably an application configuration error." + ); + } +}; + +const jwtDefaults = { + jwkeysB64: undefined, + validate: undefined, + strategyName: "nextauth-jwt", +}; + +const jwtRegister = async (server: Hapi.Server, options): Promise => { + server.dependency(["hapi-auth-jwt2"]); + const settings: any = Hoek.applyToDefaults(jwtDefaults, options); + const key = settings.jwkeysB64.map((k) => jwkToHapiAuthJwt2(k)); + + server.auth.strategy(settings.strategyName!, "jwt", { + key, + cookieKey: false, + urlKey: false, + validate: settings.validate, + }); +}; + +export const registerNextAuthJwt = async ( + server: Hapi.Server, + config: IAppConfig +): Promise => { + if (config.nextAuth.signingKey) { + await server.register({ + plugin: { + name: "nextauth-jwt", + version: "0.0.2", + register: jwtRegister, + }, + options: { + jwkeysB64: [config.nextAuth.signingKey], + validate: async (decoded, request: Hapi.Request) => { + const { email, name, role } = decoded; + const user = await request.db().users.findBy({ email }); + if (!config.isProd) { + server.logger.info( + { + email, + name, + role, + }, + "nextauth-jwt authorizing request" + ); + // server.logger.info({ user }, "nextauth-jwt user result"); + } + + return { + isValid: Boolean(user && user.isActive), + // this credentials object is made available in every request + // at `request.auth.credentials` + credentials: { email, name, role }, + }; + }, + }, + }); + } else if (config.isProd) { + throw new Error("Missing nextauth.signingKey configuration value."); + } else { + server.log( + ["warn"], + "Missing nextauth.signingKey configuration value. Authentication of nextauth endpoints disabled!" + ); + } +}; + +// @hapi/jwt expects the key in its own format +/* UNUSED +const _jwkToHapiJwt = (jwkString) => { + try { + const jwk = JSON.parse(jwkString); + const rawKey = Buffer.from(jwk.k, "base64"); + return { + key: rawKey, + algorithms: [jwk.alg], + kid: jwk.kid, + }; + } catch { + throw new Error( + "Failed to parse key for JWT verification. This is probably an application configuration error." + ); + } +}; +*/ diff --git a/metamigo-api/app/plugins/swagger.ts b/metamigo-api/app/plugins/swagger.ts new file mode 100644 index 0000000..e51fef0 --- /dev/null +++ b/metamigo-api/app/plugins/swagger.ts @@ -0,0 +1,32 @@ +import * as Inert from "@hapi/inert"; +import * as Vision from "@hapi/vision"; +import type * as Hapi from "@hapi/hapi"; +import * as HapiSwagger from "hapi-swagger"; + +export const registerSwagger = async (server: Hapi.Server): Promise => { + const swaggerOptions: HapiSwagger.RegisterOptions = { + info: { + title: "Metamigo API Docs", + description: "part of CDR Link", + version: "0.1", + }, + // group sets of endpoints by tag + tags: [ + { + name: "users", + description: "API for Users", + }, + ], + documentationRouteTags: ["swagger"], + documentationPath: "/api-docs", + }; + + await server.register([ + { plugin: Inert }, + { plugin: Vision }, + { + plugin: HapiSwagger, + options: swaggerOptions, + }, + ]); +}; diff --git a/metamigo-api/app/routes/helpers/index.ts b/metamigo-api/app/routes/helpers/index.ts new file mode 100644 index 0000000..c34b7db --- /dev/null +++ b/metamigo-api/app/routes/helpers/index.ts @@ -0,0 +1,21 @@ +import * as Metamigo from "common"; +import Toys from "@hapipal/toys"; + +export const withDefaults = Toys.withRouteDefaults({ + options: { + cors: true, + auth: "nextauth-jwt", + validate: { + failAction: Metamigo.validatingFailAction, + }, + }, +}); + +export const noAuth = Toys.withRouteDefaults({ + options: { + cors: true, + validate: { + failAction: Metamigo.validatingFailAction, + }, + }, +}); diff --git a/metamigo-api/app/routes/index.ts b/metamigo-api/app/routes/index.ts new file mode 100644 index 0000000..b0d54f4 --- /dev/null +++ b/metamigo-api/app/routes/index.ts @@ -0,0 +1,33 @@ +import isFunction from "lodash/isFunction"; +import type * as Hapi from "@hapi/hapi"; +import * as RandomRoutes from "./random"; +import * as UserRoutes from "./users"; +import * as VoiceRoutes from "./voice"; +import * as WhatsappRoutes from "./whatsapp"; +import * as SignalRoutes from "./signal"; + +const loadRouteIndex = async (server, index) => { + const routes = []; + for (const exported in index) { + if (Object.prototype.hasOwnProperty.call(index, exported)) { + const route = index[exported]; + routes.push(route); + } + } + + routes.forEach(async (route) => { + if (isFunction(route)) server.route(await route(server)); + else server.route(route); + }); +}; + +export const register = async (server: Hapi.Server): Promise => { + // Load your routes here. + // routes are loaded from the list of exported vars + // a route file should export routes directly or an async function that returns the routes. + loadRouteIndex(server, RandomRoutes); + loadRouteIndex(server, UserRoutes); + loadRouteIndex(server, VoiceRoutes); + loadRouteIndex(server, WhatsappRoutes); + loadRouteIndex(server, SignalRoutes); +}; diff --git a/metamigo-api/app/routes/signal/index.ts b/metamigo-api/app/routes/signal/index.ts new file mode 100644 index 0000000..735ac2a --- /dev/null +++ b/metamigo-api/app/routes/signal/index.ts @@ -0,0 +1,249 @@ +import * as Hapi from "@hapi/hapi"; +import * as Joi from "joi"; +import * as Helpers from "../helpers"; +import Boom from "boom"; + +const getSignalService = (request) => { + return request.services().signaldService; +}; + +export const GetAllSignalBotsRoute = Helpers.withDefaults({ + method: "get", + path: "/api/signal/bots", + options: { + description: "Get all bots", + handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { + const signalService = getSignalService(request); + const bots = await signalService.findAll(); + + if (bots) { + // with the pino logger the first arg is an object of data to log + // the second arg is a message + // all other args are formated args for the msg + request.logger.info({ bots }, "Retrieved bot(s) at %s", new Date()); + + return { bots }; + } + + return _h.response().code(204); + }, + }, +}); + +export const GetBotsRoute = Helpers.noAuth({ + method: "get", + path: "/api/signal/bots/{token}", + options: { + description: "Get one bot", + handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { + const { token } = request.params; + const signalService = getSignalService(request); + + const bot = await signalService.findByToken(token); + + if (bot) { + // with the pino logger the first arg is an object of data to log + // the second arg is a message + // all other args are formated args for the msg + request.logger.info({ bot }, "Retrieved bot(s) at %s", new Date()); + + return bot; + } + + throw Boom.notFound("Bot not found"); + }, + }, +}); + +interface MessageRequest { + phoneNumber: string; + message: string; +} + +export const SendBotRoute = Helpers.noAuth({ + method: "post", + path: "/api/signal/bots/{token}/send", + options: { + description: "Send a message", + handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { + const { token } = request.params; + const { phoneNumber, message } = request.payload as MessageRequest; + const signalService = getSignalService(request); + + const bot = await signalService.findByToken(token); + + if (bot) { + request.logger.info({ bot }, "Sent a message at %s", new Date()); + + await signalService.send(bot, phoneNumber, message as string); + return _h + .response({ + result: { + recipient: phoneNumber, + timestamp: new Date().toISOString(), + source: bot.phoneNumber, + }, + }) + .code(200); // temp + } + + throw Boom.notFound("Bot not found"); + }, + }, +}); + +interface ResetSessionRequest { + phoneNumber: string; +} + +export const ResetSessionBotRoute = Helpers.noAuth({ + method: "post", + path: "/api/signal/bots/{token}/resetSession", + options: { + description: "Reset a session with another user", + handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { + const { token } = request.params; + const { phoneNumber } = request.payload as ResetSessionRequest; + const signalService = getSignalService(request); + + const bot = await signalService.findByToken(token); + + if (bot) { + await signalService.resetSession(bot, phoneNumber); + return _h + .response({ + result: { + recipient: phoneNumber, + timestamp: new Date().toISOString(), + source: bot.phoneNumber, + }, + }) + .code(200); // temp + } + + throw Boom.notFound("Bot not found"); + }, + }, +}); + +export const ReceiveBotRoute = Helpers.withDefaults({ + method: "get", + path: "/api/signal/bots/{token}/receive", + options: { + description: "Receive messages", + handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { + const { token } = request.params; + const signalService = getSignalService(request); + + const bot = await signalService.findByToken(token); + + if (bot) { + request.logger.info({ bot }, "Received messages at %s", new Date()); + + return signalService.receive(bot); + } + + throw Boom.notFound("Bot not found"); + }, + }, +}); + +export const RegisterBotRoute = Helpers.withDefaults({ + method: "get", + path: "/api/signal/bots/{id}/register", + options: { + description: "Register a bot", + handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => { + const { id } = request.params; + const signalService = getSignalService(request); + const { code } = request.query; + + const bot = await signalService.findById(id); + if (!bot) throw Boom.notFound("Bot not found"); + + try { + request.logger.info({ bot }, "Create bot at %s", new Date()); + await signalService.register(bot, code); + return h.response(bot).code(200); + } catch (error) { + return h.response().code(error.code); + } + }, + }, +}); + +interface BotRequest { + phoneNumber: string; + description: string; +} + +export const CreateBotRoute = Helpers.withDefaults({ + method: "post", + path: "/api/signal/bots", + options: { + description: "Register a bot", + handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { + const { phoneNumber, description } = request.payload as BotRequest; + const signalService = getSignalService(request); + console.log("request.auth.credentials:", request.auth.credentials); + + const bot = await signalService.create( + phoneNumber, + description, + request.auth.credentials.email as string + ); + if (bot) { + request.logger.info({ bot }, "Create bot at %s", new Date()); + return bot; + } + + throw Boom.notFound("Bot not found"); + }, + }, +}); + +export const RequestCodeRoute = Helpers.withDefaults({ + method: "get", + path: "/api/signal/bots/{id}/requestCode", + options: { + description: "Register a bot", + validate: { + params: Joi.object({ + id: Joi.string().uuid().required(), + }), + query: Joi.object({ + mode: Joi.string().valid("sms", "voice").required(), + captcha: Joi.string(), + }), + }, + handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => { + const { id } = request.params; + const { mode, captcha } = request.query; + const signalService = getSignalService(request); + + const bot = await signalService.findById(id); + + if (!bot) { + throw Boom.notFound("Bot not found"); + } + + try { + if (mode === "sms") { + await signalService.requestSMSVerification(bot, captcha); + } else if (mode === "voice") { + await signalService.requestVoiceVerification(bot, captcha); + } + return h.response().code(200); + } catch (error) { + console.log(error); + if (error.name === "CaptchaRequiredException") { + return h.response().code(402); + } else if (error.code) { + return h.response().code(error.code); + } else { + return h.response().code(500); + } + } + }, + }, +}); diff --git a/metamigo-api/app/routes/users/index.ts b/metamigo-api/app/routes/users/index.ts new file mode 100644 index 0000000..008dab2 --- /dev/null +++ b/metamigo-api/app/routes/users/index.ts @@ -0,0 +1,59 @@ +import * as Joi from "joi"; +import * as Hapi from "@hapi/hapi"; +import { UserRecord, crudRoutesFor, CrudControllerBase } from "common"; +import * as RouteHelpers from "../helpers"; + +class UserRecordController extends CrudControllerBase(UserRecord) { } + +const validator = (): Record => ({ + create: { + payload: Joi.object({ + name: Joi.string().required(), + email: Joi.string().email().required(), + emailVerified: Joi.string().isoDate().required(), + createdBy: Joi.string().required(), + avatar: Joi.string() + .uri({ scheme: ["http", "https"] }) + .optional(), + userRole: Joi.string().optional(), + isActive: Joi.boolean().optional(), + }).label("UserCreate"), + }, + updateById: { + params: { + userId: Joi.string().uuid().required(), + }, + payload: Joi.object({ + name: Joi.string().optional(), + email: Joi.string().email().optional(), + emailVerified: Joi.string().isoDate().optional(), + createdBy: Joi.boolean().optional(), + avatar: Joi.string() + .uri({ scheme: ["http", "https"] }) + .optional(), + userRole: Joi.string().optional(), + isActive: Joi.boolean().optional(), + createdAt: Joi.string().isoDate().optional(), + updatedAt: Joi.string().isoDate().optional(), + }).label("UserUpdate"), + }, + deleteById: { + params: { + userId: Joi.string().uuid().required(), + }, + }, + getById: { + params: { + userId: Joi.string().uuid().required(), + }, + }, +}); + +export const UserRoutes = async ( + _server: Hapi.Server +): Promise => { + const controller = new UserRecordController("users", "userId"); + return RouteHelpers.withDefaults( + crudRoutesFor("user", "/api/users", controller, "userId", validator()) + ); +}; diff --git a/metamigo-api/app/routes/voice/index.ts b/metamigo-api/app/routes/voice/index.ts new file mode 100644 index 0000000..fc3bc50 --- /dev/null +++ b/metamigo-api/app/routes/voice/index.ts @@ -0,0 +1,124 @@ +import * as Hapi from "@hapi/hapi"; +import * as Joi from "joi"; +import * as Boom from "@hapi/boom"; +import * as R from "remeda"; +import * as Helpers from "../helpers"; +import Twilio from "twilio"; +import { crudRoutesFor, CrudControllerBase } from "common"; +import { VoiceLineRecord, SavedVoiceLine } from "db"; + +const TwilioHandlers = { + freeNumbers: async (provider, request: Hapi.Request) => { + const { accountSid, apiKeySid, apiKeySecret } = provider.credentials; + const client = Twilio(apiKeySid, apiKeySecret, { + accountSid, + }); + const numbers = R.pipe( + await client.incomingPhoneNumbers.list({ limit: 100 }), + R.filter((n) => n.capabilities.voice), + R.map(R.pick(["sid", "phoneNumber"])) + ); + const numberSids = R.map(numbers, R.prop("sid")); + const voiceLineRepo = request.db().voiceLines; + const voiceLines: SavedVoiceLine[] = + await voiceLineRepo.findAllByProviderLineSids(numberSids); + const voiceLineSids = new Set(R.map(voiceLines, R.prop("providerLineSid"))); + + return R.pipe( + numbers, + R.reject((n) => voiceLineSids.has(n.sid)), + R.map((n) => ({ id: n.sid, name: n.phoneNumber })) + ); + }, +}; + +export const VoiceProviderRoutes = Helpers.withDefaults([ + { + method: "GET", + path: "/api/voice/providers/{providerId}/freeNumbers", + options: { + description: + "get a list of the incoming numbers for a provider account that aren't assigned to a voice line", + validate: { + params: { + providerId: Joi.string().uuid().required(), + }, + }, + handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { + const { providerId } = request.params; + const voiceProvidersRepo = request.db().voiceProviders; + const provider = await voiceProvidersRepo.findById(providerId); + if (!provider) return Boom.notFound(); + switch (provider.kind) { + case "TWILIO": + return TwilioHandlers.freeNumbers(provider, request); + default: + return Boom.badImplementation(); + } + }, + }, + }, +]); + +class VoiceLineRecordController extends CrudControllerBase(VoiceLineRecord) { } + +const validator = (): Record => ({ + create: { + payload: Joi.object({ + providerType: Joi.string().required(), + providerId: Joi.string().required(), + number: Joi.string().required(), + language: Joi.string().required(), + voice: Joi.string().required(), + promptText: Joi.string().optional(), + promptRecording: Joi.binary() + .encoding("base64") + .max(50 * 1000 * 1000) + .optional(), + }).label("VoiceLineCreate"), + }, + updateById: { + params: { + id: Joi.string().uuid().required(), + }, + payload: Joi.object({ + providerType: Joi.string().optional(), + providerId: Joi.string().optional(), + number: Joi.string().optional(), + language: Joi.string().optional(), + voice: Joi.string().optional(), + promptText: Joi.string().optional(), + promptRecording: Joi.binary() + .encoding("base64") + .max(50 * 1000 * 1000) + .optional(), + }).label("VoiceLineUpdate"), + }, + deleteById: { + params: { + id: Joi.string().uuid().required(), + }, + }, + getById: { + params: { + id: Joi.string().uuid().required(), + }, + }, +}); + +export const VoiceLineRoutes = async ( + _server: Hapi.Server +): Promise => { + const controller = new VoiceLineRecordController("voiceLines", "id"); + return Helpers.withDefaults( + crudRoutesFor( + "voice-line", + "/api/voice/voice-line", + controller, + "id", + validator() + ) + ); +}; + +export * from "./twilio"; diff --git a/metamigo-api/app/routes/voice/twilio/index.ts b/metamigo-api/app/routes/voice/twilio/index.ts new file mode 100644 index 0000000..4d4779e --- /dev/null +++ b/metamigo-api/app/routes/voice/twilio/index.ts @@ -0,0 +1,230 @@ +import * as Hapi from "@hapi/hapi"; +import * as Joi from "joi"; +import * as Boom from "@hapi/boom"; +import Twilio from "twilio"; +import { SavedVoiceProvider } from "db"; +import pMemoize from "p-memoize"; +import ms from "ms"; +import * as Helpers from "../../helpers"; +import workerUtils from "../../../../worker-utils"; +import { SayLanguage, SayVoice } from "twilio/lib/twiml/VoiceResponse"; + +const queueRecording = async (meta) => { + return workerUtils.addJob("twilio-recording", meta, { jobKey: meta.callSid }); +}; + +const twilioClientFor = (provider: SavedVoiceProvider): Twilio.Twilio => { + const { accountSid, apiKeySid, apiKeySecret } = provider.credentials; + if (!accountSid || !apiKeySid || !apiKeySecret) + throw new Error( + `twilio provider ${provider.name} does not have credentials` + ); + + return Twilio(apiKeySid, apiKeySecret, { + accountSid, + }); +}; + +const _getOrCreateTTSTestApplication = async ( + url, + name, + client: Twilio.Twilio +) => { + const application = await client.applications.list({ friendlyName: name }); + + if (application[0] && application[0].voiceUrl === url) { + return application[0]; + } + + return client.applications.create({ + voiceMethod: "POST", + voiceUrl: url, + friendlyName: name, + }); +}; + +const getOrCreateTTSTestApplication = pMemoize(_getOrCreateTTSTestApplication, { + maxAge: ms("1h"), +}); + +export const TwilioRoutes = Helpers.noAuth([ + { + method: "get", + path: "/api/voice/twilio/prompt/{voiceLineId}", + options: { + description: "download the mp3 file to play as a prompt for the user", + validate: { + params: { + voiceLineId: Joi.string().uuid().required(), + }, + }, + handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => { + const { voiceLineId } = request.params; + const voiceLine = await request + .db() + .voiceLines.findById({ id: voiceLineId }); + + if (!voiceLine) return Boom.notFound(); + if (!voiceLine.audioPromptEnabled) return Boom.badRequest(); + + const mp3 = voiceLine.promptAudio["audio/mpeg"]; + if (!mp3) { + return Boom.serverUnavailable(); + } + + return h + .response(Buffer.from(mp3, "base64")) + .header("Content-Type", "audio/mpeg") + .header("Content-Disposition", "attachment; filename=prompt.mp3"); + }, + }, + }, + { + method: "post", + path: "/api/voice/twilio/record/{voiceLineId}", + options: { + description: "webhook for twilio to handle an incoming call", + validate: { + params: { + voiceLineId: Joi.string().uuid().required(), + }, + }, + handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { + const { voiceLineId } = request.params; + const { To } = request.payload as { To: string }; + const voiceLine = await request.db().voiceLines.findBy({ number: To }); + if (!voiceLine) return Boom.notFound(); + if (voiceLine.id !== voiceLineId) return Boom.badRequest(); + + const frontendUrl = request.server.config().frontend.url; + const useTextPrompt = !voiceLine.audioPromptEnabled; + + const twiml = new Twilio.twiml.VoiceResponse(); + if (useTextPrompt) { + let prompt = voiceLine.promptText; + if (!prompt || prompt.length === 0) + prompt = + "The grabadora text prompt is unconfigured. Please set a prompt in the administration screen."; + twiml.say( + { + language: voiceLine.language as SayLanguage, + voice: voiceLine.voice as SayVoice, + }, + prompt + ); + } else { + const promptUrl = `${frontendUrl}/api/v1/voice/twilio/prompt/${voiceLineId}`; + twiml.play({ loop: 1 }, promptUrl); + } + + twiml.record({ + playBeep: true, + finishOnKey: "1", + recordingStatusCallback: `${frontendUrl}/api/v1/voice/twilio/recording-ready/${voiceLineId}`, + }); + return twiml.toString(); + }, + }, + }, + { + method: "post", + path: "/api/voice/twilio/recording-ready/{voiceLineId}", + options: { + description: "webhook for twilio to handle a recording", + validate: { + params: { + voiceLineId: Joi.string().uuid().required(), + }, + }, + handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => { + const { voiceLineId } = request.params; + const voiceLine = await request + .db() + .voiceLines.findById({ id: voiceLineId }); + if (!voiceLine) return Boom.notFound(); + + const { AccountSid, RecordingSid, CallSid } = request.payload as { + AccountSid: string; + RecordingSid: string; + CallSid: string; + }; + + await queueRecording({ + voiceLineId, + accountSid: AccountSid, + callSid: CallSid, + recordingSid: RecordingSid, + }); + return h.response().code(203); + }, + }, + }, + { + method: "post", + path: "/api/voice/twilio/text-to-speech/{providerId}", + options: { + description: "webook for twilio to test the twilio text-to-speech", + validate: { + params: { + providerId: Joi.string().uuid().required(), + }, + }, + handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { + const { language, voice, prompt } = request.payload as { + language: SayLanguage; + voice: SayVoice; + prompt: string; + }; + const twiml = new Twilio.twiml.VoiceResponse(); + twiml.say({ language, voice }, prompt); + return twiml.toString(); + }, + }, + }, + { + method: "get", + path: "/api/voice/twilio/text-to-speech-token/{providerId}", + options: { + description: + "generates a one time token to test the twilio text-to-speech", + validate: { + params: { + providerId: Joi.string().uuid().required(), + }, + }, + handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => { + const { providerId } = request.params as { providerId: string }; + const provider: SavedVoiceProvider = await request + .db() + .voiceProviders.findById({ id: providerId }); + if (!provider) return Boom.notFound(); + + const frontendUrl = request.server.config().frontend.url; + const url = `${frontendUrl}/api/v1/voice/twilio/text-to-speech/${providerId}`; + const name = `Grabadora text-to-speech tester: ${providerId}`; + const app = await getOrCreateTTSTestApplication( + url, + name, + twilioClientFor(provider) + ); + + const { accountSid, apiKeySecret, apiKeySid } = provider.credentials; + const token = new Twilio.jwt.AccessToken( + accountSid, + apiKeySid, + apiKeySecret, + { identity: "tts-test" } + ); + + const grant = new Twilio.jwt.AccessToken.VoiceGrant({ + outgoingApplicationSid: app.sid, + incomingAllow: true, + }); + token.addGrant(grant); + return h.response({ + token: token.toJwt(), + }); + }, + }, + }, +]); diff --git a/metamigo-api/app/routes/whatsapp/index.ts b/metamigo-api/app/routes/whatsapp/index.ts new file mode 100644 index 0000000..8283a6e --- /dev/null +++ b/metamigo-api/app/routes/whatsapp/index.ts @@ -0,0 +1,195 @@ +import * as Hapi from "@hapi/hapi"; +import * as Helpers from "../helpers"; +import Boom from "boom"; + +export const GetAllWhatsappBotsRoute = Helpers.withDefaults({ + method: "get", + path: "/api/whatsapp/bots", + options: { + description: "Get all bots", + handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { + const { whatsappService } = request.services(); + + const bots = await whatsappService.findAll(); + + if (bots) { + // with the pino logger the first arg is an object of data to log + // the second arg is a message + // all other args are formated args for the msg + request.logger.info({ bots }, "Retrieved bot(s) at %s", new Date()); + + return { bots }; + } + + return _h.response().code(204); + }, + }, +}); + +export const GetBotsRoute = Helpers.noAuth({ + method: "get", + path: "/api/whatsapp/bots/{token}", + options: { + description: "Get one bot", + handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { + const { token } = request.params; + const { whatsappService } = request.services(); + + const bot = await whatsappService.findByToken(token); + + if (bot) { + // with the pino logger the first arg is an object of data to log + // the second arg is a message + // all other args are formated args for the msg + request.logger.info({ bot }, "Retrieved bot(s) at %s", new Date()); + + return bot; + } + + throw Boom.notFound("Bot not found"); + }, + }, +}); + +interface MessageRequest { + phoneNumber: string; + message: string; +} + +export const SendBotRoute = Helpers.noAuth({ + method: "post", + path: "/api/whatsapp/bots/{token}/send", + options: { + description: "Send a message", + handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { + const { token } = request.params; + const { phoneNumber, message } = request.payload as MessageRequest; + const { whatsappService } = request.services(); + + const bot = await whatsappService.findByToken(token); + + if (bot) { + request.logger.info({ bot }, "Sent a message at %s", new Date()); + + await whatsappService.send(bot, phoneNumber, message as string); + return _h + .response({ + result: { + recipient: phoneNumber, + timestamp: new Date().toISOString(), + source: bot.phoneNumber, + }, + }) + .code(200); // temp + } + + throw Boom.notFound("Bot not found"); + }, + }, +}); + +export const ReceiveBotRoute = Helpers.withDefaults({ + method: "get", + path: "/api/whatsapp/bots/{token}/receive", + options: { + description: "Receive messages", + handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { + const { token } = request.params; + const { whatsappService } = request.services(); + + const bot = await whatsappService.findByToken(token); + + if (bot) { + request.logger.info({ bot }, "Received messages at %s", new Date()); + + // temp + const date = new Date(); + const twoDaysAgo = new Date(date.getTime()); + twoDaysAgo.setDate(date.getDate() - 2); + return whatsappService.receive(bot, twoDaysAgo); + } + + throw Boom.notFound("Bot not found"); + }, + }, +}); + +export const RegisterBotRoute = Helpers.withDefaults({ + method: "get", + path: "/api/whatsapp/bots/{id}/register", + options: { + description: "Register a bot", + handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { + const { id } = request.params; + const { whatsappService } = request.services(); + + const bot = await whatsappService.findById(id); + + if (bot) { + await whatsappService.register(bot, (error: string) => { + if (error) { + return _h.response(error).code(500); + } + + request.logger.info({ bot }, "Register bot at %s", new Date()); + return _h.response().code(200); + }); + } + + throw Boom.notFound("Bot not found"); + }, + }, +}); + +export const RefreshBotRoute = Helpers.withDefaults({ + method: "get", + path: "/api/whatsapp/bots/{id}/refresh", + options: { + description: "Refresh messages", + handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { + const { id } = request.params; + const { whatsappService } = request.services(); + + const bot = await whatsappService.findById(id); + + if (bot) { + request.logger.info({ bot }, "Refreshed messages at %s", new Date()); + + // await whatsappService.refresh(bot); + return; + } + + throw Boom.notFound("Bot not found"); + }, + }, +}); + +interface BotRequest { + phoneNumber: string; + description: string; +} + +export const CreateBotRoute = Helpers.withDefaults({ + method: "post", + path: "/api/whatsapp/bots", + options: { + description: "Register a bot", + handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { + const { phoneNumber, description } = request.payload as BotRequest; + const { whatsappService } = request.services(); + console.log("request.auth.credentials:", request.auth.credentials); + + const bot = await whatsappService.create( + phoneNumber, + description, + request.auth.credentials.email as string + ); + if (bot) { + request.logger.info({ bot }, "Register bot at %s", new Date()); + return bot; + } + + throw Boom.notFound("Bot not found"); + }, + }, +}); diff --git a/metamigo-api/app/services/index.ts b/metamigo-api/app/services/index.ts new file mode 100644 index 0000000..c26198b --- /dev/null +++ b/metamigo-api/app/services/index.ts @@ -0,0 +1,14 @@ +import type * as Hapi from "@hapi/hapi"; +import SettingsService from "./settings"; +import RandomService from "./random"; +import WhatsappService from "./whatsapp"; +import SignaldService from "./signald"; + +export const register = async (server: Hapi.Server): Promise => { + // register your services here + // don't forget to add them to the AppServices interface in ../types/index.ts + server.registerService(RandomService); + server.registerService(SettingsService); + server.registerService(WhatsappService); + server.registerService(SignaldService); +}; diff --git a/metamigo-api/app/services/settings.ts b/metamigo-api/app/services/settings.ts new file mode 100644 index 0000000..f052c07 --- /dev/null +++ b/metamigo-api/app/services/settings.ts @@ -0,0 +1,16 @@ +import * as Hapi from "@hapi/hapi"; +import * as Schmervice from "@hapipal/schmervice"; +import { settingInfo, SettingsService } from "db"; + +export const VoicemailPrompt = settingInfo("voicemail-prompt"); +export const VoicemailMinLength = settingInfo("voicemail-min-length"); +export const VoicemailUseTextPrompt = settingInfo( + "voicemail-use-text-prompt" +); + +export { ISettingsService } from "db"; +// @ts-expect-error +const service = (server: Hapi.Server): Schmervice.ServiceFunctionalInterface => + SettingsService(server.db().settings); + +export default service; diff --git a/metamigo-api/app/services/signald.ts b/metamigo-api/app/services/signald.ts new file mode 100644 index 0000000..620e74c --- /dev/null +++ b/metamigo-api/app/services/signald.ts @@ -0,0 +1,200 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Server } from "@hapi/hapi"; +import { Service } from "@hapipal/schmervice"; +import { + SignaldAPI, + IncomingMessagev1, + ClientMessageWrapperv1 +} from "@digiresilience/node-signald"; +import { SavedSignalBot as Bot } from "db"; +import workerUtils from "../../worker-utils"; + +export default class SignaldService extends Service { + signald: SignaldAPI; + subscriptions: Set; + + constructor(server: Server, options: never) { + super(server, options); + + if (this.server.config().signald.enabled) { + this.signald = new SignaldAPI(); + this.signald.setLogger((level, msg, extra?) => { + this.server.logger[level]({ extra }, msg); + }); + this.subscriptions = new Set(); + } + } + + async initialize(): Promise { + if (this.server.config().signald.enabled && this.signald) { + this.setupListeners(); + this.connect(); + } + } + + async teardown(): Promise { + if (this.server.config().signald.enabled && this.signald) + this.signald.disconnect(); + } + + private connect() { + const { enabled, socket } = this.server.config().signald; + if (!enabled) return; + this.signald.connectWithBackoff(socket); + } + + private async onConnected() { + await this.subscribeAll(); + } + + private setupListeners() { + this.signald.on("transport_error", async (error) => { + this.server.logger.info({ error }, "signald transport error"); + }); + this.signald.on("transport_connected", async () => { + this.onConnected(); + }); + this.signald.on("transport_received_payload", async (payload: ClientMessageWrapperv1) => { + this.server.logger.debug({ payload }, "signald payload received"); + if (payload.type === "IncomingMessage") { + this.receiveMessage(payload.data) + } + }); + this.signald.on("transport_sent_payload", async (payload) => { + this.server.logger.debug({ payload }, "signald payload sent"); + }); + } + + private async subscribeAll() { + const result = await this.signald.listAccounts(); + const accounts = result.accounts.map((account) => account.address.number); + await Promise.all( + accounts.map(async (account) => { + await this.signald.subscribe(account); + this.subscriptions.add(account); + }) + ); + } + + private async unsubscribeAll() { + await Promise.all( + [...this.subscriptions].map(async (account) => { + await this.signald.unsubscribe(account); + this.subscriptions.delete(account); + }) + ); + } + + async create( + phoneNumber: string, + description: string, + email: string + ): Promise { + const db = this.server.db(); + const user = await db.users.findBy({ email }); + const row = await db.signalBots.insert({ + phoneNumber, + description, + userId: user.id, + }); + return row; + } + + async findAll(): Promise { + const db = this.server.db(); + return db.signalBots.findAll(); + } + + async findById(id: string): Promise { + const db = this.server.db(); + return db.signalBots.findById({ id }); + } + + async findByToken(token: string): Promise { + const db = this.server.db(); + return db.signalBots.findBy({ token }); + } + + async register(bot: Bot, code: string): Promise { + const address = await this.signald.verify(bot.phoneNumber, code); + this.server.db().signalBots.updateAuthInfo(bot, address.address.uuid); + } + + async send(bot: Bot, phoneNumber: string, message: string): Promise { + this.server.logger.debug( + { us: bot.phoneNumber, then: phoneNumber, message }, + "signald send" + ); + return await this.signald.send( + bot.phoneNumber, + { number: phoneNumber }, + undefined, + message + ); + } + + async resetSession(bot: Bot, phoneNumber: string): Promise { + return await this.signald.resetSession(bot.phoneNumber, { + number: phoneNumber, + }); + } + + async requestVoiceVerification(bot: Bot, captcha?: string): Promise { + this.server.logger.debug( + { number: bot.phoneNumber, captcha }, + "requesting voice verification for" + ); + + await this.signald.register(bot.phoneNumber, true, captcha); + } + + async requestSMSVerification(bot: Bot, captcha?: string): Promise { + this.server.logger.debug( + { number: bot.phoneNumber, captcha }, + "requesting sms verification for" + ); + await this.signald.register(bot.phoneNumber, false, captcha); + } + + private async receiveMessage(message: IncomingMessagev1) { + const { account } = message; + if (!account) { + this.server.logger.debug({ message }, "invalid message received"); + this.server.logger.error("invalid message received"); + } + + const bot = await this.server.db().signalBots.findBy({ phoneNumber: account }); + if (!bot) { + this.server.logger.info("message received for unknown bot", { + account, + message, + }); + return; + } + + await this.queueMessage(bot, message); + } + + private async queueMessage(bot: Bot, message: IncomingMessagev1) { + const { timestamp, account, data_message: dataMessage } = message; + if (!dataMessage?.body && !dataMessage?.attachments) { + this.server.logger.info({ message }, "message received with no content"); + return; + } + + if (!timestamp || !account) { + this.server.logger.debug({ message }, "invalid message received"); + } + + const receivedMessage = { + message, + botId: bot.id, + botPhoneNumber: bot.phoneNumber, + }; + + workerUtils.addJob("signald-message", receivedMessage, { + jobKey: `signal-bot-${bot.id}-${timestamp}`, + queueName: `signal-bot-${bot.id}`, + }); + } +} diff --git a/metamigo-api/app/services/whatsapp.ts b/metamigo-api/app/services/whatsapp.ts new file mode 100644 index 0000000..306652a --- /dev/null +++ b/metamigo-api/app/services/whatsapp.ts @@ -0,0 +1,247 @@ +import { Server } from "@hapi/hapi"; +import { Service } from "@hapipal/schmervice"; +import { SavedWhatsappBot as Bot } from "db"; +import makeWASocket, { DisconnectReason, proto, downloadContentFromMessage, MediaType } from "@adiwajshing/baileys"; +import workerUtils from "../../worker-utils"; +import { useDatabaseAuthState } from "../lib/whatsapp-key-store"; +import { connect } from "pg-monitor"; + +export type AuthCompleteCallback = (error?: string) => void; + +export default class WhatsappService extends Service { + connections: { [key: string]: any } = {}; + loginConnections: { [key: string]: any } = {}; + + static browserDescription: [string, string, string] = [ + "Metamigo", + "Chrome", + "2.0", + ]; + + constructor(server: Server, options: never) { + super(server, options); + } + + async initialize(): Promise { + this.updateConnections(); + } + + async teardown(): Promise { + this.resetConnections(); + } + + private async sleep(ms: number): Promise { + console.log(`pausing ${ms}`) + return new Promise(resolve => setTimeout(resolve, ms)); + } + + private async resetConnections() { + for (const connection of Object.values(this.connections)) { + try { + connection.end(null) + } catch (error) { + console.log(error); + } + } + this.connections = {}; + } + + private createConnection(bot: Bot, server: Server, options: any, authCompleteCallback?: any) { + const { state, saveState } = useDatabaseAuthState(bot, server) + const connection = makeWASocket({ ...options, auth: state }); + let pause = 5000; + connection.ev.on('connection.update', async (update) => { + console.log(`Connection updated ${JSON.stringify(update, null, 2)}`) + const { connection: connectionState, lastDisconnect, qr, isNewLogin } = update + if (qr) { + console.log('got qr code') + await this.server.db().whatsappBots.updateQR(bot, qr); + } else if (isNewLogin) { + console.log("got new login") + } else if (connectionState === 'open') { + console.log('opened connection') + } else if (connectionState === "close") { + console.log('connection closed due to ', lastDisconnect.error) + const disconnectStatusCode = (lastDisconnect?.error as any)?.output?.statusCode + if (disconnectStatusCode === DisconnectReason.restartRequired) { + console.log('reconnecting after got new login') + const updatedBot = await this.findById(bot.id); + this.createConnection(updatedBot, server, options) + authCompleteCallback() + } else if (disconnectStatusCode !== DisconnectReason.loggedOut) { + console.log('reconnecting') + await this.sleep(pause) + pause = pause * 2 + this.createConnection(bot, server, options) + } + } + }) + + connection.ev.on('chats.set', item => console.log(`recv ${item.chats.length} chats (is latest: ${item.isLatest})`)) + connection.ev.on('messages.set', item => console.log(`recv ${item.messages.length} messages (is latest: ${item.isLatest})`)) + connection.ev.on('contacts.set', item => console.log(`recv ${item.contacts.length} contacts`)) + connection.ev.on('messages.upsert', async m => { + console.log("messages upsert") + const { messages } = m; + if (messages) { + await this.queueUnreadMessages(bot, messages); + } + }) + connection.ev.on('messages.update', m => console.log(m)) + connection.ev.on('message-receipt.update', m => console.log(m)) + connection.ev.on('presence.update', m => console.log(m)) + connection.ev.on('chats.update', m => console.log(m)) + connection.ev.on('contacts.upsert', m => console.log(m)) + connection.ev.on('creds.update', saveState) + + this.connections[bot.id] = connection; + } + + private async updateConnections() { + this.resetConnections(); + + const bots = await this.server.db().whatsappBots.findAll(); + for await (const bot of bots) { + if (bot.isVerified) { + this.createConnection( + bot, + this.server, + { + browser: WhatsappService.browserDescription, + printQRInTerminal: false, + version: [2, 2204, 13], + }) + } + } + } + + private async queueMessage(bot: Bot, webMessageInfo: proto.WebMessageInfo) { + const { key, message, messageTimestamp } = webMessageInfo; + const { remoteJid } = key; + + if (!key.fromMe && message && remoteJid !== "status@broadcast") { + const isMediaMessage = + message.audioMessage || + message.documentMessage || + message.imageMessage || + message.videoMessage; + + let messageContent = Object.values(message)[0] + let messageType: MediaType; + let attachment: string; + let filename: string; + let mimetype: string; + if (isMediaMessage) { + if (message.audioMessage) { + messageType = "audio"; + filename = + key.id + "." + message.audioMessage.mimetype.split("/").pop(); + mimetype = message.audioMessage.mimetype; + } else if (message.documentMessage) { + messageType = "document"; + filename = message.documentMessage.fileName; + mimetype = message.documentMessage.mimetype; + } else if (message.imageMessage) { + messageType = "image"; + filename = + key.id + "." + message.imageMessage.mimetype.split("/").pop(); + mimetype = message.imageMessage.mimetype; + } else if (message.videoMessage) { + messageType = "video" + filename = + key.id + "." + message.videoMessage.mimetype.split("/").pop(); + mimetype = message.videoMessage.mimetype; + } + + const stream = await downloadContentFromMessage(messageContent, messageType) + let buffer = Buffer.from([]) + for await (const chunk of stream) { + buffer = Buffer.concat([buffer, chunk]) + } + attachment = buffer.toString("base64"); + } + + if (messageContent || attachment) { + const receivedMessage = { + waMessageId: key.id, + waMessage: JSON.stringify(webMessageInfo), + waTimestamp: new Date((messageTimestamp as number) * 1000), + attachment, + filename, + mimetype, + whatsappBotId: bot.id, + botPhoneNumber: bot.phoneNumber, + }; + + workerUtils.addJob("whatsapp-message", receivedMessage, { + jobKey: key.id, + }); + } + } + } + + private async queueUnreadMessages(bot: Bot, messages: any[]) { + for await (const message of messages) { + await this.queueMessage(bot, message); + } + } + + async create( + phoneNumber: string, + description: string, + email: string + ): Promise { + const db = this.server.db(); + const user = await db.users.findBy({ email }); + const row = await db.whatsappBots.insert({ + phoneNumber, + description, + userId: user.id, + }); + return row; + } + + async findAll(): Promise { + return this.server.db().whatsappBots.findAll(); + } + + async findById(id: string): Promise { + return this.server.db().whatsappBots.findById({ id }); + } + + async findByToken(token: string): Promise { + return this.server.db().whatsappBots.findBy({ token }); + } + + async register(bot: Bot, callback: AuthCompleteCallback): Promise { + await this.createConnection(bot, this.server, { version: [2, 2204, 13] }, callback); + } + + async send(bot: Bot, phoneNumber: string, message: string): Promise { + const connection = this.connections[bot.id]; + const recipient = `${phoneNumber.replace(/\D+/g, "")}@s.whatsapp.net`; + await connection.sendMessage(recipient, { text: message }); + } + + async receiveSince(bot: Bot, lastReceivedDate: Date): Promise { + const connection = this.connections[bot.id]; + const messages = await connection.messagesReceivedAfter( + lastReceivedDate, + false + ); + for (const message of messages) { + this.queueMessage(bot, message); + } + } + + async receive(bot: Bot, lastReceivedDate: Date): Promise { + const connection = this.connections[bot.id]; + // const messages = await connection.messagesReceivedAfter( + // lastReceivedDate, + // false + // ); + + const messages = await connection.loadAllUnreadMessages(); + return messages; + } +} diff --git a/metamigo-api/app/types/index.ts b/metamigo-api/app/types/index.ts new file mode 100644 index 0000000..9bc41f9 --- /dev/null +++ b/metamigo-api/app/types/index.ts @@ -0,0 +1,27 @@ +import type { IMain } from "pg-promise"; +import type { ISettingsService } from "../services/settings"; +import type WhatsappService from "../services/whatsapp"; +import type SignaldService from "../services/signald"; +import type { IAppConfig } from "../../config"; +import type { AppDatabase } from "db"; + +// add your service interfaces here +interface AppServices { + settingsService: ISettingsService; + whatsappService: WhatsappService; + signaldService: SignaldService; +} + +// extend the hapi types with our services and config +declare module "@hapi/hapi" { + export interface Request { + services(): AppServices; + db(): AppDatabase; + pgp: IMain; + } + export interface Server { + config(): IAppConfig; + db(): AppDatabase; + pgp: IMain; + } +} diff --git a/metamigo-api/babel.config.json b/metamigo-api/babel.config.json new file mode 100644 index 0000000..c0993b5 --- /dev/null +++ b/metamigo-api/babel.config.json @@ -0,0 +1,6 @@ +{ + "presets": [ + "@babel/preset-env", + "@babel/preset-typescript" + ] +} diff --git a/metamigo-api/config.ts b/metamigo-api/config.ts new file mode 100644 index 0000000..3f31611 --- /dev/null +++ b/metamigo-api/config.ts @@ -0,0 +1,10 @@ +import config, { + loadConfig, + loadConfigRaw, + IAppConfig, + IAppConvict, +} from "config"; + +export { IAppConvict, IAppConfig, loadConfig, loadConfigRaw }; + +export default config; diff --git a/metamigo-api/logger.ts b/metamigo-api/logger.ts new file mode 100644 index 0000000..66de7b4 --- /dev/null +++ b/metamigo-api/logger.ts @@ -0,0 +1,8 @@ +import { defState } from "@digiresilience/montar"; +import { configureLogger } from "common"; +import config from "config"; + +export const logger = defState("apiLogger", { + start: async () => configureLogger(config), +}); +export default logger; diff --git a/metamigo-api/package.json b/metamigo-api/package.json new file mode 100644 index 0000000..b96798b --- /dev/null +++ b/metamigo-api/package.json @@ -0,0 +1,77 @@ +{ + "name": "api", + "version": "0.2.0", + "main": "build/main/cli/index.js", + "author": "Abel Luck ", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@adiwajshing/baileys": "5.0.0", + "@adiwajshing/keyed-db": "0.2.4", + "@digiresilience/hapi-nextauth": "0.2.1", + "@digiresilience/hapi-pg-promise": "^0.0.3", + "@digiresilience/montar": "^0.1.6", + "@digiresilience/node-signald": "0.0.3", + "@graphile-contrib/pg-simplify-inflector": "^6.1.0", + "@hapi/basic": "^7.0.0", + "@hapi/boom": "^10.0.0", + "@hapi/wreck": "^18.0.0", + "@hapipal/schmervice": "^2.1.0", + "@hapipal/toys": "^3.2.0", + "blipp": "^4.0.2", + "camelcase-keys": "^8.0.2", + "fluent-ffmpeg": "^2.1.2", + "graphile-migrate": "^1.4.1", + "graphile-worker": "^0.13.0", + "hapi-auth-jwt2": "^10.4.0", + "hapi-postgraphile": "^0.11.0", + "hapi-swagger": "^15.0.0", + "joi": "^17.7.0", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.0.1", + "long": "^5.2.1", + "p-memoize": "^7.1.1", + "pg-monitor": "^2.0.0", + "pg-promise": "^11.0.2", + "postgraphile-plugin-connection-filter": "^2.3.0", + "remeda": "^1.6.0", + "twilio": "^3.84.1" + }, + "devDependencies": { + "@babel/core": "7.20.12", + "@babel/preset-env": "7.20.2", + "@babel/preset-typescript": "7.18.6", + "@types/jest": "^29.2.5", + "eslint": "^8.32.0", + "pino-pretty": "^9.1.1", + "prettier": "^2.8.3", + "ts-node": "^10.9.1", + "typescript": "4.9.4", + "@types/hapi__wreck": "^17.0.1", + "@types/long": "^4.0.2", + "nodemon": "^2.0.20", + "@types/node": "*", + "camelcase-keys": "^8.0.2", + "pg-monitor": "^2.0.0", + "typedoc": "^0.23.24" + }, + "nodemonConfig": { + "ignore": [ + "docs/*" + ], + "ext": "ts,json,js" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "fix:lint": "eslint src --ext .ts --fix", + "fix:prettier": "prettier \"src/**/*.ts\" --write", + "cli": "NODE_ENV=development nodemon --unhandled-rejections=strict build/main/cli/index.js", + "serve": "NODE_ENV=development npm run cli server", + "serve:prod": "NODE_ENV=production npm run cli server", + "worker": "NODE_ENV=development npm run cli worker", + "worker:prod": "NODE_ENV=production npm run cli worker", + "lint:lint": "eslint src --ext .ts", + "lint:prettier": "prettier \"src/**/*.ts\" --list-different", + "lint": "npm run lint:lint && npm run lint:prettier", + "watch:build": "tsc -p tsconfig.json -w" + } +} diff --git a/metamigo-api/server/index.ts b/metamigo-api/server/index.ts new file mode 100644 index 0000000..4b96456 --- /dev/null +++ b/metamigo-api/server/index.ts @@ -0,0 +1,28 @@ +import * as Metamigo from "common"; +import { defState } from "@digiresilience/montar"; +import Manifest from "./manifest"; +import config, { IAppConfig } from "../config"; + +export const deployment = async ( + config: IAppConfig, + start = false +): Promise => { + // Build the manifest, which describes all the plugins needed for our application server + const manifest = await Manifest.build(config); + + // Create the server and optionally start it + const server = Metamigo.deployment(manifest, config, start); + + return server; +}; + +export const stopDeployment = async (server: Metamigo.Server): Promise => { + return Metamigo.stopDeployment(server); +}; + +const server = defState("server", { + start: () => deployment(config, true), + stop: () => stopDeployment(server), +}); + +export default server; diff --git a/metamigo-api/server/manifest.ts b/metamigo-api/server/manifest.ts new file mode 100644 index 0000000..dcb2740 --- /dev/null +++ b/metamigo-api/server/manifest.ts @@ -0,0 +1,79 @@ +import * as Glue from "@hapi/glue"; +import * as Metamigo from "common"; +import * as Blipp from "blipp"; +import HapiBasic from "@hapi/basic"; +import HapiJwt from "hapi-auth-jwt2"; +import HapiPostgraphile from "hapi-postgraphile"; +import { getPostGraphileOptions } from "db"; +import AppPlugin from "../app"; +import type { IAppConfig } from "../config"; + +const build = async (config: IAppConfig): Promise => { + const { port, address } = config.server; + const metamigoPlugins = Metamigo.defaultPlugins(config); + return { + server: { + port, + address, + debug: false, // We use pino not the built-in hapi logger + routes: { + validate: { + failAction: Metamigo.validatingFailAction, + }, + }, + }, + register: { + plugins: [ + // jwt plugin, required for our jwt auth plugin + { plugin: HapiJwt }, + + // Blipp prints the nicely formatted list of endpoints at app boot + { plugin: Blipp }, + + // load the metamigo base plugins + ...metamigoPlugins, + + // basic authentication, required by hapi-nextauth + { plugin: HapiBasic }, + + // load our main app + { + plugin: AppPlugin, + options: { + config, + }, + }, + // load Postgraphile + { + plugin: HapiPostgraphile, + options: { + route: { + path: "/graphql", + options: { + auth: { + strategies: ["nextauth-jwt"], + mode: "optional", + }, + }, + }, + pgConfig: config.postgraphile.authConnection, + schemaName: "app_public", + schemaOptions: { + ...getPostGraphileOptions(), + jwtAudiences: [config.nextAuth.audience], + jwtSecret: "", + // unauthenticated users will hit the database with this role + pgDefaultRole: "app_anonymous", + }, + }, + }, + ], + }, + }; +}; + +const Manifest = { + build, +}; + +export default Manifest; diff --git a/metamigo-api/tsconfig.json b/metamigo-api/tsconfig.json new file mode 100644 index 0000000..f0e98f3 --- /dev/null +++ b/metamigo-api/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "build/main", + "types": ["long", "jest", "node"], + "lib": ["es2020", "DOM"] + }, + "include": ["**/*.ts", "**/.*.ts"], + "exclude": ["node_modules"] +} diff --git a/metamigo-api/worker-utils.ts b/metamigo-api/worker-utils.ts new file mode 100644 index 0000000..2f67370 --- /dev/null +++ b/metamigo-api/worker-utils.ts @@ -0,0 +1,21 @@ +import * as Worker from "graphile-worker"; +import { defState } from "@digiresilience/montar"; +import config from "./config"; + +const startWorkerUtils = async (): Promise => { + const workerUtils = await Worker.makeWorkerUtils({ + connectionString: config.worker.connection, + }); + return workerUtils; +}; + +const stopWorkerUtils = async (): Promise => { + return workerUtils.release(); +}; + +const workerUtils = defState("apiWorkerUtils", { + start: startWorkerUtils, + stop: stopWorkerUtils, +}); + +export default workerUtils; diff --git a/metamigo-cli/.eslintrc.js b/metamigo-cli/.eslintrc.js new file mode 100644 index 0000000..0320887 --- /dev/null +++ b/metamigo-cli/.eslintrc.js @@ -0,0 +1 @@ +require('../.eslintrc.js"); diff --git a/metamigo-cli/config.ts b/metamigo-cli/config.ts new file mode 100644 index 0000000..f4418b9 --- /dev/null +++ b/metamigo-cli/config.ts @@ -0,0 +1,18 @@ +import { generateConfig, printConfigOptions } from "common"; +import { loadConfigRaw } from "config"; + +export const genConf = async (): Promise => { + const c = await loadConfigRaw(); + const generated = generateConfig(c); + console.log(generated); +}; + +export const genSchema = async (): Promise => { + const c = await loadConfigRaw(); + console.log(c.getSchemaString()); +}; + +export const listConfig = async (): Promise => { + const c = await loadConfigRaw(); + printConfigOptions(c); +}; diff --git a/metamigo-cli/index.ts b/metamigo-cli/index.ts new file mode 100644 index 0000000..a369586 --- /dev/null +++ b/metamigo-cli/index.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +import { Command } from "commander"; +import { startWithout } from "@digiresilience/montar"; +import { migrateWrapper } from "db"; +import { loadConfig } from "config"; +import { genConf, listConfig } from "./config"; +import { createTokenForTesting, generateJwks } from "./jwks"; +import { exportGraphqlSchema } from "./postgraphile"; +import "api/build/main/server"; +import "api/build/main/logger"; +import "worker/build/main"; + +const program = new Command(); + +export async function runServer(): Promise { + await startWithout(["worker"]); +} + +export async function runWorker(): Promise { + await startWithout(["server"]); +} + +program + .command("config-generate") + .description("Generate a sample JSON configuration file (to stdout)") + .action(genConf); + +program + .command("config-help") + .description("Prints the entire convict config ") + .action(listConfig); + +program + .command("api") + .description("Run the application api server") + .action(runServer); + +program + .command("worker") + .description("Run the worker to process jobs") + .action(runWorker); + +program + .command("db ") + .description("Run graphile-migrate commands with your app's config loaded.") + .action(async (args) => { + const config = await loadConfig(); + return migrateWrapper(args, config); + }); + +program + .command("gen-jwks") + .description("Generate the JWKS") + .action(generateJwks); + +program + .command("gen-testing-jwt") + .description("Generate a JWT for the test suite") + .action(createTokenForTesting); + +program + .command("export-graphql-schema") + .description("Export the graphql schema") + .action(exportGraphqlSchema); + +program.parse(process.argv); diff --git a/metamigo-cli/jwks.ts b/metamigo-cli/jwks.ts new file mode 100644 index 0000000..5c12da6 --- /dev/null +++ b/metamigo-cli/jwks.ts @@ -0,0 +1,68 @@ +import jose from "node-jose"; +import * as jwt from "jsonwebtoken"; + +const generateKeystore = async () => { + const keystore = jose.JWK.createKeyStore(); + await keystore.generate("oct", 256, { + alg: "A256GCM", + use: "enc", + }); + await keystore.generate("oct", 256, { + alg: "HS512", + use: "sig", + }); + return keystore; +}; + +const safeString = (input) => { + return Buffer.from(JSON.stringify(input)).toString("base64"); +}; + +const stringify = (v) => JSON.stringify(v, undefined, 2); + +const _generateJwks = async () => { + const keystore = await generateKeystore(); + const encryption = keystore.all({ use: "enc" })[0].toJSON(true); + const signing = keystore.all({ use: "sig" })[0].toJSON(true); + + return { + nextAuth: { + signingKeyB64: safeString(signing), + encryptionKeyB64: safeString(encryption), + }, + }; +}; + +export const generateJwks = async (): Promise => { + console.log(stringify(await _generateJwks())); +}; + +export const createTokenForTesting = async (): Promise => { + const keys = await _generateJwks(); + const signingKey = Buffer.from( + JSON.parse( + Buffer.from(keys.nextAuth.signingKeyB64, "base64").toString("utf-8") + ).k, + "base64" + ); + + const token = jwt.sign( + { + iss: "Test Env", + iat: 1606893960, + aud: "metamigo", + sub: "abel@guardianproject.info", + name: "Abel Luck", + email: "abel@guardianproject.info", + userRole: "admin", + }, + signingKey, + { expiresIn: "100y", algorithm: "HS512" } + ); + console.log("CONFIG"); + console.log(stringify(keys)); + console.log(); + console.log("TOKEN"); + console.log(token); + console.log(); +}; diff --git a/metamigo-cli/package.json b/metamigo-cli/package.json new file mode 100644 index 0000000..a3be0e7 --- /dev/null +++ b/metamigo-cli/package.json @@ -0,0 +1,39 @@ +{ + "name": "cli", + "version": "0.2.0", + "main": "build/main/index.js", + "author": "Abel Luck ", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@digiresilience/montar": "^0.1.6", + "commander": "^10.0.0", + "graphile-migrate": "^1.4.1", + "graphile-worker": "^0.13.0", + "node-jose": "^2.1.1" + }, + "devDependencies": { + "@babel/core": "7.20.12", + "@babel/preset-env": "7.20.2", + "@babel/preset-typescript": "7.18.6", + "@types/jest": "^29.2.5", + "eslint": "^8.32.0", + "jest": "^29.3.1", + "jest-junit": "^15.0.0", + "pino-pretty": "^9.1.1", + "prettier": "^2.8.3", + "ts-node": "^10.9.1", + "typedoc": "^0.23.24", + "typescript": "4.9.4", + "nodemon": "^2.0.20" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "cli": "NODE_ENV=development node --unhandled-rejections=strict build/main/index.js", + "fix:lint": "eslint src --ext .ts --fix", + "fix:prettier": "prettier \"src/**/*.ts\" --write", + "lint:lint": "eslint src --ext .ts", + "lint:prettier": "prettier \"src/**/*.ts\" --list-different", + "test": "echo no tests", + "lint": "yarn lint:lint && yarn lint:prettier" + } +} diff --git a/metamigo-cli/postgraphile.ts b/metamigo-cli/postgraphile.ts new file mode 100644 index 0000000..996314c --- /dev/null +++ b/metamigo-cli/postgraphile.ts @@ -0,0 +1,37 @@ +import { writeFileSync } from "fs"; +import { + getIntrospectionQuery, + graphqlSync, + lexicographicSortSchema, + printSchema, +} from "graphql"; +import { createPostGraphileSchema } from "postgraphile"; +import { Pool } from "pg"; +import { loadConfig } from "config"; +import { getPostGraphileOptions } from "db"; + +export const exportGraphqlSchema = async (): Promise => { + const config = await loadConfig(); + + const rootPgPool = new Pool({ + connectionString: config.db.connection, + }); + const exportSchema = `../../data/schema.graphql`; + const exportJson = `../../frontend/lib/graphql-schema.json`; + try { + const schema = await createPostGraphileSchema( + config.postgraphile.authConnection, + "app_public", + getPostGraphileOptions() + ); + const sorted = lexicographicSortSchema(schema); + const json = graphqlSync(schema, getIntrospectionQuery()); + writeFileSync(exportSchema, printSchema(sorted)); + writeFileSync(exportJson, JSON.stringify(json)); + + console.log(`GraphQL schema exported to ${exportSchema}`); + console.log(`GraphQL schema json exported to ${exportJson}`); + } finally { + rootPgPool.end(); + } +}; diff --git a/metamigo-cli/tsconfig.json b/metamigo-cli/tsconfig.json new file mode 100644 index 0000000..ebcf7f2 --- /dev/null +++ b/metamigo-cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "build/main" + }, + "include": ["**/*.ts", "**/.*.ts", "config.ts"], + "exclude": ["node_modules", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/metamigo-common/.eslintrc.js b/metamigo-common/.eslintrc.js new file mode 100644 index 0000000..2a01087 --- /dev/null +++ b/metamigo-common/.eslintrc.js @@ -0,0 +1,13 @@ +require('@digiresilience/eslint-config-metamigo/patch/modern-module-resolution'); +module.exports = { + extends: [ + "@digiresilience/eslint-config-metamigo/profile/node", + "@digiresilience/eslint-config-metamigo/profile/typescript" + ], + rules: { + // TODO: enable this after jest fixes this issue https://github.com/nodejs/node/issues/38343 + "unicorn/prefer-node-protocol": "off" + }, + parserOptions: { tsconfigRootDir: __dirname } +}; + diff --git a/metamigo-common/.gitignore b/metamigo-common/.gitignore new file mode 100644 index 0000000..defce06 --- /dev/null +++ b/metamigo-common/.gitignore @@ -0,0 +1,13 @@ +.idea/* +.nyc_output +build +node_modules +test +src/*/*.js +coverage +*.log +package-lock.json +.npmrc +.yalc +yalc.lock +junit.xml diff --git a/metamigo-common/.npmignore b/metamigo-common/.npmignore new file mode 100644 index 0000000..6b22019 --- /dev/null +++ b/metamigo-common/.npmignore @@ -0,0 +1,13 @@ +.eslintrc.js +.editorconfig +.prettierignore +.versionrc +Makefile +.gitlab-ci.yml +coverage +jest* +tsconfig* +*.log +test* +.yalc +yalc.lock diff --git a/metamigo-common/.prettierignore b/metamigo-common/.prettierignore new file mode 100644 index 0000000..0e80a3c --- /dev/null +++ b/metamigo-common/.prettierignore @@ -0,0 +1,2 @@ +# package.json is formatted by package managers, so we ignore it here +package.json \ No newline at end of file diff --git a/metamigo-common/babel.config.json b/metamigo-common/babel.config.json new file mode 100644 index 0000000..37798db --- /dev/null +++ b/metamigo-common/babel.config.json @@ -0,0 +1,5 @@ +{ + "presets": [ + "@digiresilience/babel-preset-metamigo" + ] +} diff --git a/metamigo-common/config/app-meta.ts b/metamigo-common/config/app-meta.ts new file mode 100644 index 0000000..ea360b3 --- /dev/null +++ b/metamigo-common/config/app-meta.ts @@ -0,0 +1,31 @@ +import { ConvictSchema } from "./types"; + +export interface IAppMetaConfig { + name: string; + version: string; + figletFont: string; +} + +export const AppMetaConfig: ConvictSchema = { + version: { + doc: "The current application version", + format: String, + env: "npm_package_version", + default: null, + skipGenerate: true, + }, + name: { + doc: "Application name", + format: String, + env: "npm_package_name", + default: null, + skipGenerate: true, + }, + figletFont: { + doc: "The figlet font name used to print the site name on boot", + format: String, + env: "FIGLET_FONT", + default: "Sub-Zero", + skipGenerate: true, + }, +}; diff --git a/metamigo-common/config/auth.ts b/metamigo-common/config/auth.ts new file mode 100644 index 0000000..cf568d2 --- /dev/null +++ b/metamigo-common/config/auth.ts @@ -0,0 +1,23 @@ +import { ConvictSchema } from "./types"; + +export interface ISessionConfig { + sessionMaxAgeSeconds: number; + sessionUpdateAgeSeconds: number; +} + +export const SessionConfig: ConvictSchema = { + sessionMaxAgeSeconds: { + doc: "How long in seconds until an idle session expires and is no longer valid.", + format: "positiveInt", + default: 30 * 24 * 60 * 60, // 30 days + env: "SESSION_MAX_AGE_SECONDS", + }, + sessionUpdateAgeSeconds: { + doc: `Throttle how frequently in seconds to write to database to extend a session. + Use it to limit write operations. Set to 0 to always update the database. + Note: This option is ignored if using JSON Web Tokens`, + format: "positiveInt", + default: 24 * 60 * 60, // 24 hours + env: "SESSION_UPDATE_AGE_SECONDS", + }, +}; diff --git a/metamigo-common/config/cors.ts b/metamigo-common/config/cors.ts new file mode 100644 index 0000000..d3dee5e --- /dev/null +++ b/metamigo-common/config/cors.ts @@ -0,0 +1,32 @@ +import { ConvictSchema } from "./types"; + +export interface ICorsConfig { + allowedMethods: Array; + allowedOrigins: Array; + allowedHeaders: Array; +} + +export const CorsConfig: ConvictSchema = { + allowedMethods: { + doc: "The allowed CORS methods", + format: "Array", + env: "CORS_ALLOWED_METHODS", + default: ["GET", "PUT", "POST", "PATCH", "DELETE", "HEAD", "OPTIONS"], + }, + allowedOrigins: { + doc: "The allowed origins", + format: "Array", + env: "CORS_ALLOWED_ORIGINS", + default: [], + }, + allowedHeaders: { + doc: "The allowed headers", + format: "Array", + env: "CORS_ALLOWED_HEADERS", + default: [ + "content-type", + "authorization", + "cf-access-authenticated-user-email", + ], + }, +}; diff --git a/metamigo-common/config/formats.ts b/metamigo-common/config/formats.ts new file mode 100644 index 0000000..d1af899 --- /dev/null +++ b/metamigo-common/config/formats.ts @@ -0,0 +1,58 @@ +import * as Joi from "joi"; +import type { Format } from "convict"; + +const coerceString = (v: any): string => v.toString(); +const validator = (s: any) => (v: any) => Joi.assert(v, s); + +const url = Joi.string().uri({ + scheme: ["http", "https"], +}); +const ip = Joi.string().ip({ version: ["ipv4", "ipv6"], cidr: "optional" }); + +/** + * Additional configuration value formats for convict. + * + * You can use these to achieve richer validation for your configuration. + */ +export const MetamigoConvictFormats: { [index: string]: Format } = { + positiveInt: { + name: "positveInt", + coerce: (n: string): number => Number.parseInt(n, 10), + validate: validator(Joi.number().positive().integer()), + }, + port: { + name: "port", + coerce: (n: string): number => Number.parseInt(n, 10), + validate: validator(Joi.number().port()), + }, + ipaddress: { + name: "ipaddress", + coerce: coerceString, + validate: validator(ip), + }, + url: { + name: "url", + coerce: coerceString, + validate: validator(url), + }, + uri: { + name: "uri", + coerce: coerceString, + validate: validator(Joi.string().uri()), + }, + optionalUri: { + name: "uri", + coerce: coerceString, + validate: validator(Joi.string().uri().allow("")), + }, + email: { + name: "email", + coerce: coerceString, + validate: validator(Joi.string().email()), + }, + uuid: { + name: "uuid", + coerce: coerceString, + validate: validator(Joi.string().guid()), + }, +}; diff --git a/metamigo-common/config/generate.ts b/metamigo-common/config/generate.ts new file mode 100644 index 0000000..bfaadb9 --- /dev/null +++ b/metamigo-common/config/generate.ts @@ -0,0 +1,44 @@ +import convict from "convict"; + +const visitLeaf = (acc: any, key: any, leaf: any) => { + if (leaf.skipGenerate) { + return; + } + + if (leaf.default === undefined) { + acc[key] = undefined; + } else { + acc[key] = leaf.default; + } +}; + +const visitNode = (acc: any, node: any, key = "") => { + if (node._cvtProperties) { + const keys = Object.keys(node._cvtProperties); + let subacc: any; + if (key === "") { + subacc = acc; + } else { + subacc = {}; + acc[key] = subacc; + } + + keys.forEach((key) => { + visitNode(subacc, node._cvtProperties[key], key); + }); + // In the case that the entire sub-tree specified skipGenerate, remove the empty node + if (Object.keys(subacc).length === 0) { + delete acc[key]; + } + } else { + visitLeaf(acc, key, node); + } +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const generateConfig = (conf: convict.Config): unknown => { + const schema = conf.getSchema(); + const generated = {}; + visitNode(generated, schema); + return JSON.stringify(generated, undefined, 1); +}; diff --git a/metamigo-common/config/index.ts b/metamigo-common/config/index.ts new file mode 100644 index 0000000..9af67a0 --- /dev/null +++ b/metamigo-common/config/index.ts @@ -0,0 +1,142 @@ +import process from "process"; +import convict, { SchemaObj } from "convict"; +import { IServerConfig, ServerConfig } from "./server"; +import { IMetricsConfig, MetricsConfig } from "./metrics-server"; +import { IAppMetaConfig, AppMetaConfig } from "./app-meta"; +import { ICorsConfig, CorsConfig } from "./cors"; +import { ILoggingConfig, LoggingConfig } from "./logging"; +import { ExtendedConvict } from "./types"; +import { MetamigoConvictFormats } from "./formats"; + +type IEnvConfig = "production" | "development" | "test"; + +const EnvConfig: SchemaObj = { + doc: "The application environment", + format: ["production", "development", "test"], + default: "development", + env: "NODE_ENV", +}; + +export const configBaseSchema = { + env: EnvConfig, + server: ServerConfig, + meta: AppMetaConfig, + cors: CorsConfig, + metrics: MetricsConfig, + logging: LoggingConfig, +}; + +/** + * + * The metamigo base configuration object. Use this for easy typed access to your + * config. + * + */ +interface IMetamigoConfig { + env: IEnvConfig; + server: IServerConfig; + meta: IAppMetaConfig; + cors: ICorsConfig; + metrics: IMetricsConfig; + logging: ILoggingConfig; + isProd?: boolean; + isTest?: boolean; + isDev?: boolean; + frontend: any; + nextAuth: any; +} +export type IMetamigoConvict = ExtendedConvict; + +export type { + IServerConfig, + IMetricsConfig, + IAppMetaConfig, + ICorsConfig, + ILoggingConfig, + IMetamigoConfig, +}; + +export * from "./formats"; +export * from "./generate"; +export * from "./print"; +export * from "./types"; + +/** + * Loads your applications configuration from environment variables and configuration files (see METAMIGO_CONFIG). + * + * @param schema your schema definition + * @param override an optional object with config value that will override defaults but not config files and env vars (see [convict precedence docs](https://github.com/mozilla/node-convict/tree/master/packages/convict#precedence-order )) + * @returns the raw convict config object + */ +export const loadConfigurationRaw = async ( + schema: convict.Schema, + override?: Partial +): Promise> => { + convict.addFormats(MetamigoConvictFormats); + const config: ExtendedConvict = convict(schema); + + const env = config.get("env"); + + config.isProd = env === "production"; + config.isTest = env === "test"; + config.isDev = env === "development"; + + try { + if (process.env.METAMIGO_CONFIG) { + config.loadFile(process.env.METAMIGO_CONFIG); + } + } catch (error) { + const msg = ` + + +🚫 Your application's configuration is invalid JSON. 🚫 + +${error} + +`; + throw new Error(msg); + } + + if (override) { + config.load(override); + } + + try { + config.validate({ allowed: "strict" }); + } catch (error: any) { + const msg = ` + + +🚫 Your application's configuration is invalid. 🚫 + +${error.message} + +`; + throw new Error(msg); + } + + // set our helpers + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const configDirty = config as any; + + configDirty.set("isProd", config.isProd); + configDirty.set("isTest", config.isTest); + configDirty.set("isDev", config.isDev); + + return config; +}; + +/** + * Loads your applications configuration from environment variables and configuration files (see METAMIGO_CONFIG). + * + * @param schema your schema definition + * @param override an optional object with config value that will override defaults but not config files and env vars (see [convict precedence docs](https://github.com/mozilla/node-convict/tree/master/packages/convict#precedence-order )) + * @returns a vanilla javascript object with the config loaded values + */ +export const loadConfiguration = async ( + schema: convict.Schema, + override?: Partial +): Promise => { + const c = await loadConfigurationRaw(schema, override); + return c.getProperties(); +}; diff --git a/metamigo-common/config/logging.ts b/metamigo-common/config/logging.ts new file mode 100644 index 0000000..0b5f2fd --- /dev/null +++ b/metamigo-common/config/logging.ts @@ -0,0 +1,90 @@ +import { ConvictSchema } from "./types"; + +export interface ILoggingConfig { + level: string; + sql: boolean; + redact: string[]; + ignorePaths: string[]; + ignoreTags: string[]; + requestIdHeader: string; + logRequestStart: boolean; + logRequestComplete: boolean; + logRequestPayload: boolean; + logRequestQueryParams: boolean; + prettyPrint: boolean | "auto"; +} + +export const LoggingConfig: ConvictSchema = { + level: { + doc: "The logging level", + format: ["trace", "debug", "info", "warn", "error"], + default: "info", + env: "LOG_LEVEL", + }, + sql: { + doc: "Whether to log sql statements", + format: "Boolean", + default: false, + env: "LOG_SQL", + }, + redact: { + doc: "Pino redaction array. These are always redacted. see https://getpino.io/#/docs/redaction", + format: "Array", + default: [ + "req.remoteAddress", + "req.headers.authorization", + `req.headers["cf-access-jwt-assertion"]`, + `req.headers["cf-access-authenticated-user-email"]`, + `req.headers["cf-connecting-ip"]`, + `req.headers["cf-ipcountry"]`, + `req.headers["x-forwarded-for"]`, + "req.headers.cookie", + ], + }, + ignorePaths: { + doc: "Ignore http paths (exact) when logging requests", + format: "Array", + default: ["/graphql"], + }, + ignoreTags: { + doc: "Ignore routes tagged with these tags when logging requests", + format: "Array", + default: ["status", "swagger", "nolog"], + }, + requestIdHeader: { + doc: "The header where the request id lives", + format: String, + default: "x-request-id", + env: "REQUEST_ID_HEADER", + }, + logRequestStart: { + doc: "Whether hapi-pino should add a log.info() at the beginning of Hapi requests for the given Request.", + format: "Boolean", + default: false, + env: "LOG_REQUEST_START", + }, + logRequestComplete: { + doc: "Whether hapi-pino should add a log.info() at the completion of Hapi requests for the given Request.", + format: "Boolean", + default: true, + env: "LOG_REQUEST_COMPLETE", + }, + logRequestPayload: { + doc: "When enabled, add the request payload as payload to the response event log.", + format: "Boolean", + default: false, + env: "LOG_REQUEST_PAYLOAD", + }, + logRequestQueryParams: { + doc: "When enabled, add the request query as queryParams to the response event log.", + format: "Boolean", + default: false, + env: "LOG_REQUEST_QUERY_PARAMS", + }, + prettyPrint: { + doc: "Pretty print the logs", + format: ["auto", true, false], + default: "auto", + env: "LOG_PRETTY_PRINT", + }, +}; diff --git a/metamigo-common/config/metrics-server.ts b/metamigo-common/config/metrics-server.ts new file mode 100644 index 0000000..9f7e6a4 --- /dev/null +++ b/metamigo-common/config/metrics-server.ts @@ -0,0 +1,22 @@ +import { ConvictSchema } from "./types"; + +export interface IMetricsConfig { + address: string; + port: number; +} + +export const MetricsConfig: ConvictSchema = { + address: { + doc: "The ip address to bind the prometheus metrics to", + format: "ipaddress", + default: "127.0.0.1", + env: "METRICS_ADDRESS", + }, + port: { + doc: "The port to bind the prometheus metrics to", + format: "port", + default: 3002, + env: "METRICS_PORT", + arg: "port", + }, +}; diff --git a/metamigo-common/config/print.ts b/metamigo-common/config/print.ts new file mode 100644 index 0000000..3cb3c97 --- /dev/null +++ b/metamigo-common/config/print.ts @@ -0,0 +1,41 @@ +import chalk from "chalk"; +import convict from "convict"; + +const visitLeaf = (path: any, key: any, leaf: any) => { + if (leaf.skipGenerate) { + return; + } + + let name = `${path}.${key}`; + if (path.length === 0) name = key; + console.log(chalk.green(name)); + console.log(leaf.doc); + if (leaf.default === undefined) { + console.log(chalk.red("\t required")); + } else { + console.log(`\tdefault: ${JSON.stringify(leaf.default)}`); + } + + console.log(`\tformat: ${leaf.format}`); + console.log(`\tenv: ${leaf.env}`); +}; + +const visitNode = (path: any, node: any, key = "") => { + if (node._cvtProperties) { + const keys = Object.keys(node._cvtProperties); + const subpath = key === "" ? path : `${key}`; + + keys.forEach((key) => { + visitNode(subpath, node._cvtProperties[key], key); + }); + console.log(); + } else { + visitLeaf(path, key, node); + } +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const printConfigOptions = (conf: convict.Config): void => { + const schema = conf.getSchema(); + visitNode("", schema); +}; diff --git a/metamigo-common/config/server.ts b/metamigo-common/config/server.ts new file mode 100644 index 0000000..cfda0d9 --- /dev/null +++ b/metamigo-common/config/server.ts @@ -0,0 +1,21 @@ +import { ConvictSchema } from "./types"; + +export interface IServerConfig { + address: string; + port: number; +} + +export const ServerConfig: ConvictSchema = { + address: { + doc: "The IP address to bind the server to", + format: "ipaddress", + default: "0.0.0.0", + env: "SERVER_ADDRESS", + }, + port: { + doc: "The port to bind the server to", + format: "port", + default: 3001, + env: "SERVER_PORT", + }, +}; diff --git a/metamigo-common/config/types.ts b/metamigo-common/config/types.ts new file mode 100644 index 0000000..33984b5 --- /dev/null +++ b/metamigo-common/config/types.ts @@ -0,0 +1,26 @@ +import convict from "convict"; + +/* +interface SSMObj { + path: string; +} + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +interface ConvictSchemaObj extends convict.SchemaObj { + // ssm?: SSMObj; + /** + * The config item will be ignored for purposes of config file generation + */ + skipGenerate?: boolean; +} + +export type ConvictSchema = { + [P in keyof T]: convict.Schema | ConvictSchemaObj; +}; + +export interface ExtendedConvict extends convict.Config { + isProd?: boolean; + isTest?: boolean; + isDev?: boolean; +} diff --git a/metamigo-common/controllers/crud-controller.ts b/metamigo-common/controllers/crud-controller.ts new file mode 100644 index 0000000..bb0e3f0 --- /dev/null +++ b/metamigo-common/controllers/crud-controller.ts @@ -0,0 +1,295 @@ +/* eslint-disable @typescript-eslint/ban-types,@typescript-eslint/no-explicit-any,max-params */ +import * as Boom from "@hapi/boom"; +import * as Hapi from "@hapi/hapi"; +import { CrudRepository } from "../records/crud-repository"; +import { createResponse } from "../helpers/response"; +import { + PgRecordInfo, + UnsavedR, + SavedR, + KeyType, +} from "../records/record-info"; + +/** + * + * A generic controller that handles exposes a [[CrudRepository]] as HTTP + * endpoints with full POST, PUT, GET, DELETE semantics. + * + * The controller yanks the instance of the crud repository out of the request at runtime. + * This assumes you're following the pattern exposed with the hapi-pg-promise plugin. + * + * @typeParam ID The type of the id column + * @typeParam T The type of the record + */ +export abstract class AbstractCrudController< + TUnsavedR, + TSavedR extends TUnsavedR & IdKeyT, + IdKeyT extends object +> { + /** + * @param repoName the key at which the repository for the record can be accessed (that is, request.db[repoName]) + * @param paramsIdField the placeholder used in the Hapi route for the id of the record + * @param dbDecoration the decorated function on the request to use (defaults to request.db()) + */ + + abstract repoName: string; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + abstract paramsIdField = "id"; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + abstract dbDecoration = "db"; + abstract recordType: PgRecordInfo; + + repo(request: Hapi.Request): CrudRepository { + // @ts-expect-error + const db = request[this.dbDecoration]; + if (!db) + throw Boom.badImplementation( + `CrudController for table ${this.recordType.tableName} could not find request decoration '${this.dbDecoration}'` + ); + const repo = db()[this.repoName]; + if (!repo) + throw Boom.badImplementation( + `CrudController for table ${this.recordType.tableName} could not find repository for '${this.dbDecoration}().${this.repoName}'` + ); + return repo; + } + + /** + * Creates a new record + */ + public create = async ( + request: Hapi.Request, + toolkit: Hapi.ResponseToolkit + ): Promise => { + try { + // would love to know how to get rid of this double cast hack + const payload: TSavedR = (request.payload); + const data: TSavedR = await this.repo(request).insert(payload); + + return toolkit.response( + createResponse(request, { + value: data, + }) + ); + } catch (error: any) { + return toolkit.response( + createResponse(request, { + boom: Boom.badImplementation(error), + }) + ); + } + }; + + /** + * Updates a record by ID. This method can accept partial updates. + */ + public updateById = async ( + request: Hapi.Request, + toolkit: Hapi.ResponseToolkit + ): Promise => { + try { + const payload: Partial = request.payload; + const id: IdKeyT = request.params[this.paramsIdField]; + const updatedRow: TSavedR = await this.repo(request).updateById( + id, + payload + ); + + if (!updatedRow) { + return toolkit.response( + createResponse(request, { + boom: Boom.notFound(), + }) + ); + } + + return toolkit.response( + createResponse(request, { + value: updatedRow, + }) + ); + } catch (error: any) { + return toolkit.response( + createResponse(request, { + boom: Boom.badImplementation(error), + }) + ); + } + }; + + /** + * Return a record given its id. + */ + public getById = async ( + request: Hapi.Request, + toolkit: Hapi.ResponseToolkit + ): Promise => { + try { + const id: IdKeyT = request.params[this.paramsIdField]; + // @ts-expect-error + const row: TSavedR = await this.repo(request).findById(id); + + if (!row) { + return toolkit.response( + createResponse(request, { + boom: Boom.notFound(), + }) + ); + } + + return toolkit.response( + createResponse(request, { + value: row, + }) + ); + } catch (error: any) { + return toolkit.response( + createResponse(request, { + boom: Boom.badImplementation(error), + }) + ); + } + }; + + /** + * Return all records. + */ + public getAll = async ( + request: Hapi.Request, + toolkit: Hapi.ResponseToolkit + ): Promise => { + try { + const rows: TSavedR[] = await this.repo(request).findAll(); + + return toolkit.response( + createResponse(request, { + value: rows, + }) + ); + } catch (error: any) { + return toolkit.response( + createResponse(request, { + boom: Boom.badImplementation(error), + }) + ); + } + }; + + /** + * Delete a record given its id. + */ + public deleteById = async ( + request: Hapi.Request, + toolkit: Hapi.ResponseToolkit + ): Promise => { + try { + const id: IdKeyT = request.params[this.paramsIdField]; + + const count = await this.repo(request).removeById(id); + + if (count === 0) { + return createResponse(request, { boom: Boom.notFound() }); + } + + return toolkit.response( + createResponse(request, { + value: { id }, + }) + ); + } catch (error: any) { + return toolkit.response( + createResponse(request, { + boom: Boom.badImplementation(error), + }) + ); + } + }; +} + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export function unboundCrudController( + aRecordType: TRecordInfo +) { + return class CrudController extends AbstractCrudController< + UnsavedR, + SavedR, + KeyType + > { + public readonly repoName: string; + public readonly paramsIdField; + public readonly dbDecoration; + public readonly recordType = aRecordType; + + constructor(repoName: string, paramsIdField = "id", dbDecoration = "db") { + super(); + this.repoName = repoName; + this.paramsIdField = paramsIdField; + this.dbDecoration = dbDecoration; + } + }; +} + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export function CrudControllerBase(recordType: Rec) { + return unboundCrudController(recordType); +} + +export const crudRoutesFor = ( + name: string, + path: string, + controller: AbstractCrudController, + idParam: string, + validate: Record +): Hapi.ServerRoute[] => [ + { + method: "POST", + path: `${path}`, + options: { + handler: controller.create, + validate: validate.create, + description: `Method that creates a new ${name}.`, + tags: ["api", name], + }, + }, + { + method: "PUT", + path: `${path}/{${idParam}}`, + options: { + handler: controller.updateById, + validate: validate.updateById, + description: `Method that updates a ${name} by its id.`, + tags: ["api", name], + }, + }, + { + method: "GET", + path: `${path}/{${idParam}}`, + options: { + handler: controller.getById, + validate: validate.getById, + description: `Method that gets a ${name} by its id.`, + tags: ["api", name], + }, + }, + { + method: "GET", + path: `${path}`, + options: { + handler: controller.getAll, + description: `Method that gets all ${name}s.`, + tags: ["api", name], + }, + }, + { + method: "DELETE", + path: `${path}/{${idParam}}`, + options: { + handler: controller.deleteById, + validate: validate.deleteById, + description: `Method that deletes a ${name} by its id.`, + tags: ["api", name], + }, + }, + ]; diff --git a/metamigo-common/controllers/nextauth-adapter.ts b/metamigo-common/controllers/nextauth-adapter.ts new file mode 100644 index 0000000..55c3fb4 --- /dev/null +++ b/metamigo-common/controllers/nextauth-adapter.ts @@ -0,0 +1,185 @@ +/* eslint-disable unicorn/no-null,max-params */ +import { createHash, randomBytes } from "crypto"; +import type { AdapterInstance } from "next-auth/adapters"; +import omit from "lodash/omit"; +import type { IMetamigoRepositories } from "../records"; +import type { UnsavedAccount, SavedAccount } from "../records/account"; +import type { UserId, UnsavedUser, SavedUser } from "../records/user"; +import type { UnsavedSession, SavedSession } from "../records/session"; + +// Sessions expire after 30 days of being idle +export const defaultSessionMaxAge = 30 * 24 * 60 * 60 * 1000; +// Sessions updated only if session is greater than this value (0 = always) +export const defaulteSessionUpdateAge = 24 * 60 * 60 * 1000; + +const getCompoundId = (providerId: any, providerAccountId: any) => + createHash("sha256") + .update(`${providerId}:${providerAccountId}`) + .digest("hex"); + +const randomToken = () => randomBytes(32).toString("hex"); + +export class NextAuthAdapter + implements AdapterInstance +{ + constructor( + private repos: TRepositories, + private readonly sessionMaxAge = defaultSessionMaxAge, + private readonly sessionUpdateAge = defaulteSessionUpdateAge + ) { } + + async createUser(profile: UnsavedUser): Promise { + // @ts-expect-error + return this.repos.users.upsert(omit(profile, ["isActive", "id"])); + } + + async getUser(id: UserId): Promise { + const user = await this.repos.users.findById({ id }); + if (!user) return null; + // if a user has no linked accounts, then we do not return it + // see: https://github.com/nextauthjs/next-auth/issues/876 + const accounts = await this.repos.accounts.findAllBy({ + userId: user.id, + }); + + if (!accounts || accounts.length === 0) return null; + return user; + } + + async getUserByEmail(email: string): Promise { + const user = await this.repos.users.findBy({ email }); + if (!user) return null; + // if a user has no linked accounts, then we do not return it + // see: https://github.com/nextauthjs/next-auth/issues/876 + const accounts = await this.repos.accounts.findAllBy({ + userId: user.id, + }); + + if (!accounts || accounts.length === 0) return null; + return user; + } + + async getUserByProviderAccountId( + providerId: string, + providerAccountId: string + ): Promise { + const account = await this.repos.accounts.findBy({ + compoundId: getCompoundId(providerId, providerAccountId), + }); + if (!account) return null; + + return this.repos.users.findById({ id: account.userId }); + } + + async updateUser(user: SavedUser): Promise { + return this.repos.users.update(user); + } + + // @ts-expect-error + async linkAccount( + userId: string, + providerId: string, + providerType: string, + providerAccountId: string, + refreshToken: string, + accessToken: string, + accessTokenExpires: number + ): Promise { + const exists = await this.repos.users.existsById({ id: userId }); + if (!exists) return; + const account: UnsavedAccount = { + accessToken, + refreshToken, + compoundId: getCompoundId(providerId, providerAccountId), + providerAccountId, + providerId, + providerType, + accessTokenExpires: accessTokenExpires + ? new Date(accessTokenExpires) + : new Date(), + userId, + }; + await this.repos.accounts.insert(account); + } + + async unlinkAccount( + userId: string, + providerId: string, + providerAccountId: string + ): Promise { + await this.repos.accounts.removeBy({ + userId, + compoundId: getCompoundId(providerId, providerAccountId), + }); + } + + createSession(user: SavedUser): Promise { + let expires; + if (this.sessionMaxAge) { + const dateExpires = new Date(Date.now() + this.sessionMaxAge); + expires = dateExpires.toISOString(); + } + + const session: UnsavedSession = { + // @ts-expect-error + expires, + userId: user.id, + sessionToken: randomToken(), + accessToken: randomToken(), + }; + + return this.repos.sessions.insert(session); + } + + async getSession(sessionToken: string): Promise { + const session = await this.repos.sessions.findBy({ sessionToken }); + if (session && session.expires && new Date() > session.expires) { + this.repos.sessions.remove(session); + return null; + } + + return session; + } + + async updateSession( + session: SavedSession, + force?: boolean + ): Promise { + if ( + this.sessionMaxAge && + (this.sessionUpdateAge || this.sessionUpdateAge === 0) && + session.expires + ) { + // Calculate last updated date, to throttle write updates to database + // Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge + // e.g. ({expiry date} - 30 days) + 1 hour + // + // Default for sessionMaxAge is 30 days. + // Default for sessionUpdateAge is 1 hour. + const dateSessionIsDueToBeUpdated = new Date( + session.expires.getTime() - this.sessionMaxAge + this.sessionUpdateAge + ); + + // Trigger update of session expiry date and write to database, only + // if the session was last updated more than {sessionUpdateAge} ago + if (new Date() > dateSessionIsDueToBeUpdated) { + const newExpiryDate = new Date(); + newExpiryDate.setTime(newExpiryDate.getTime() + this.sessionMaxAge); + session.expires = newExpiryDate; + } else if (!force) { + return null; + } + } else if (!force) { + // If session MaxAge, session UpdateAge or session.expires are + // missing then don't even try to save changes, unless force is set. + return null; + } + + const { expires } = session; + return this.repos.sessions.update({ ...session, expires }); + } + + async deleteSession(sessionToken: string): Promise { + await this.repos.sessions.removeBy({ sessionToken }); + } +} diff --git a/metamigo-common/db/types.ts b/metamigo-common/db/types.ts new file mode 100644 index 0000000..c12e282 --- /dev/null +++ b/metamigo-common/db/types.ts @@ -0,0 +1,8 @@ +import * as PGP from "pg-promise"; +import * as PGPTS from "pg-promise/typescript/pg-subset"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type IDatabase = PGP.IDatabase; +export type IMain = PGP.IMain; +export type IResult = PGPTS.IResult; +export type IInitOptions = PGP.IInitOptions; diff --git a/metamigo-common/hapi.ts b/metamigo-common/hapi.ts new file mode 100644 index 0000000..1beee15 --- /dev/null +++ b/metamigo-common/hapi.ts @@ -0,0 +1,123 @@ +import * as Hapi from "@hapi/hapi"; +import * as http from "http"; +import type { HttpTerminator } from "http-terminator"; +import * as Glue from "@hapi/glue"; +import * as Promster from "@promster/hapi"; +import figlet from "figlet"; +import PinoPlugin from "hapi-pino"; +import { createServer as createPrometheusServer } from "@promster/server"; +import { createHttpTerminator } from "http-terminator"; + +import { getPrettyPrint } from "./logger"; +import RequestIdPlugin from "./plugins/request-id"; +import StatusPlugin from "./plugins/status"; +import ConfigPlugin from "./plugins/config"; +import { IMetamigoConfig } from "./config"; + +export interface Server { + hapiServer: Hapi.Server; + promServer?: http.Server; + promTerminator?: HttpTerminator; +} +export const deployment = async ( + manifest: Glue.Manifest, + config: T, + start = false +): Promise => { + const hapiServer: Hapi.Server = await Glue.compose(manifest); + + await hapiServer.initialize(); + + if (!start) return { hapiServer }; + + await announce(config); + + await hapiServer.start(); + + const { port, address } = config.metrics; + const promServer = await createPrometheusServer({ + port, + hostname: address, + }); + const promTerminator = createHttpTerminator({ + server: promServer, + }); + + console.log(` +🚀 Server listening on http://${hapiServer.info.address}:${hapiServer.info.port} + Metrics listening on http://${address}:${port} +`); + + return { + hapiServer, + promServer, + promTerminator, + }; +}; + +export const stopDeployment = async (server: Server): Promise => { + await server.hapiServer.stop(); + if (server.promTerminator) await server.promTerminator.terminate(); +}; + +export const defaultPlugins = ( + config: T +): string[] | Glue.PluginObject[] | Array => { + const { + logRequestStart, + logRequestComplete, + logRequestPayload, + logRequestQueryParams, + level, + redact, + ignorePaths, + ignoreTags, + requestIdHeader, + } = config.logging; + const plugins = [ + { plugin: ConfigPlugin, options: { config } }, + { + plugin: PinoPlugin, + options: { + prettyPrint: getPrettyPrint(config), + level, + logRequestStart, + logRequestComplete, + logPayload: logRequestPayload, + logQueryParams: logRequestQueryParams, + redact: { + paths: redact, + remove: true, + }, + ignorePaths, + ignoreTags, + }, + }, + { + plugin: RequestIdPlugin, + options: { + header: requestIdHeader, + }, + }, + { plugin: StatusPlugin }, + { plugin: Promster.createPlugin() }, + ]; + // @ts-ignore + return plugins; +}; + +export const announce = async ( + config: T +): Promise => + new Promise((resolve, reject) => { + // @ts-expect-error + figlet.text( + config.meta.name, + { font: config.meta.figletFont }, + (err, text) => { + if (err) reject(err); + console.log(`${text}`); + resolve(); + } + ); + }); diff --git a/metamigo-common/helpers/index.ts b/metamigo-common/helpers/index.ts new file mode 100644 index 0000000..ba87721 --- /dev/null +++ b/metamigo-common/helpers/index.ts @@ -0,0 +1,44 @@ +/** + * Used by Flavor to mark a type in a readable way. + */ +export interface Flavoring { + _type?: FlavorT; +} +/** + * + * Create a "flavored" version of a type. TypeScript will disallow mixing + * flavors, but will allow unflavored values of that type to be passed in where + * a flavored version is expected. This is a less restrictive form of branding. + * + */ +export type Flavor = T & Flavoring; + +export type UUID = Flavor; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const deepFreeze = (o: unknown): any => { + Object.freeze(o); + + const oIsFunction = typeof o === "function"; + const hasOwnProp = Object.prototype.hasOwnProperty; + + Object.getOwnPropertyNames(o).forEach((prop) => { + if ( + hasOwnProp.call(o, prop) && + (oIsFunction + ? prop !== "caller" && prop !== "callee" && prop !== "arguments" + : true) && + // @ts-expect-error + o[prop] !== null && + // @ts-expect-error + (typeof o[prop] === "object" || typeof o[prop] === "function") && + // @ts-expect-error + !Object.isFrozen(o[prop]) + ) { + // @ts-expect-error + deepFreeze(o[prop]); + } + }); + + return o; +}; diff --git a/metamigo-common/helpers/response.ts b/metamigo-common/helpers/response.ts new file mode 100644 index 0000000..0071553 --- /dev/null +++ b/metamigo-common/helpers/response.ts @@ -0,0 +1,59 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as Boom from "@hapi/boom"; +import * as Hapi from "@hapi/hapi"; + +interface IResponseMeta { + operation?: string; + method?: string; + paging?: string | null; +} + +interface IResponseError { + code?: string | number; + message?: string; + error?: string; +} + +interface IResponse { + meta: IResponseMeta; + data: T[]; + errors: IResponseError[]; +} + +interface IResponseOptions { + value?: T | null | undefined; + boom?: Boom.Boom | null | undefined; +} + +export function createResponse( + request: Hapi.Request, + { value = undefined, boom = undefined }: IResponseOptions +): IResponse { + const errors: IResponseError[] = []; + const data: any = []; + + if (boom) { + errors.push({ + code: boom.output.payload.statusCode, + error: boom.output.payload.error, + message: boom.output.payload.message, + }); + } + + if (value && data) { + if (Array.isArray(value)) { + data.push(...value); + } else { + data.push(value); + } + } + + return { + meta: { + method: request.method.toUpperCase(), + operation: request.url.pathname, + }, + data, + errors, + }; +} diff --git a/metamigo-common/helpers/validation-error.ts b/metamigo-common/helpers/validation-error.ts new file mode 100644 index 0000000..a9c161e --- /dev/null +++ b/metamigo-common/helpers/validation-error.ts @@ -0,0 +1,62 @@ +import process from "process"; +import * as Hapi from "@hapi/hapi"; +import * as Joi from "joi"; +import Hoek from "@hapi/hoek"; +import * as Boom from "@hapi/boom"; + +export interface HapiValidationError extends Joi.ValidationError { + output: { + statusCode: number; + headers: Hapi.Utils.Dictionary; + payload: { + statusCode: number; + error: string; + message?: string; + validation: { + source: string; + keys: string[]; + }; + }; + }; +} +export function defaultValidationErrorHandler( + request: Hapi.Request, + h: Hapi.ResponseToolkit, + err?: Error +): Hapi.Lifecycle.ReturnValue { + // Newer versions of Joi don't format the key for missing params the same way. This shim + // provides backwards compatibility. Unfortunately, Joi doesn't export it's own Error class + // in JS so we have to rely on the `name` key before we can cast it. + // + // The Hapi code we're 'overwriting' can be found here: + // https://github.com/hapijs/hapi/blob/master/lib/validation.js#L102 + if (err && err.name === "ValidationError" && err.hasOwnProperty("output")) { + const validationError: HapiValidationError = err as HapiValidationError; + const validationKeys: string[] = []; + + validationError.details.forEach((detail) => { + if (detail.path.length > 0) { + validationKeys.push(Hoek.escapeHtml(detail.path.join("."))); + } else { + // If no path, use the value sigil to signal the entire value had an issue. + validationKeys.push("value"); + } + }); + + validationError.output.payload.validation.keys = validationKeys; + } + + throw err; +} + +export const validatingFailAction = async ( + request: Hapi.Request, + h: Hapi.ResponseToolkit, + err: Error +): Promise => { + if (process.env.NODE_ENV === "production") { + throw Boom.badRequest("Invalid request payload input"); + } else { + defaultValidationErrorHandler(request, h, err); + } +}; diff --git a/metamigo-common/index.ts b/metamigo-common/index.ts new file mode 100644 index 0000000..54252cb --- /dev/null +++ b/metamigo-common/index.ts @@ -0,0 +1,23 @@ +export * from "./config"; +export * from "./controllers/crud-controller"; +export * from "./controllers/nextauth-adapter"; +export * from "./hapi"; +export * from "./helpers"; +export * from "./helpers/response"; +export * from "./helpers/validation-error"; +export * from "./logger"; +export * from "./records"; + +import * as pino from "pino"; + +declare module "@hapi/hapi" { + interface Server { + // @ts-ignore + logger: pino.Logger; + } + + interface Request { + // @ts-ignore + logger: pino.Logger; + } +} diff --git a/metamigo-common/logger.ts b/metamigo-common/logger.ts new file mode 100644 index 0000000..58c9367 --- /dev/null +++ b/metamigo-common/logger.ts @@ -0,0 +1,22 @@ +import pino, { LoggerOptions } from "pino"; +import { IMetamigoConfig } from "./config"; + +export const getPrettyPrint = (config: T): boolean => { + const { prettyPrint } = config.logging; + if (prettyPrint === "auto") return config?.isDev || false; + return prettyPrint === true; +}; + +export const configureLogger = ( + config: T +): pino.Logger => { + const { level, redact } = config.logging; + const options: LoggerOptions = { + level, + redact: { + paths: redact, + remove: true, + }, + }; + return pino(options); +}; diff --git a/metamigo-common/package.json b/metamigo-common/package.json new file mode 100644 index 0000000..9683502 --- /dev/null +++ b/metamigo-common/package.json @@ -0,0 +1,65 @@ +{ + "name": "common", + "version": "0.2.0", + "description": "", + "main": "build/main/index.js", + "types": "build/main/index.d.ts", + "author": "Abel Luck ", + "license": "AGPL-3.0-or-later", + "private": false, + "scripts": { + "build": "tsc -p tsconfig.json", + "fix:lint": "eslint src --ext .ts --fix", + "fix:prettier": "prettier \"src/**/*.ts\" --write", + "test": "yarn test:jest && yarn test:lint && yarn test:prettier", + "test:lint": "eslint src --ext .ts", + "test:prettier": "prettier \"src/**/*.ts\" --list-different", + "test:jest": "jest --coverage --forceExit --detectOpenHandles --reporters=default --reporters=jest-junit", + "doc": "yarn run doc:html", + "doc:html": "typedoc src/ --exclude '**/*.test.ts' --exclude '**/*.spec.ts' --name $npm_package_name --readme README.md --target es2019 --mode file --out build/docs", + "watch:build": "tsc -p tsconfig.json -w" + }, + "devDependencies": { + "@types/figlet": "^1.5.5", + "@types/lodash": "^4.14.191", + "@types/node": "*", + "@types/uuid": "^9.0.0", + "camelcase-keys": "^8.0.2", + "pg-monitor": "^2.0.0", + "typedoc": "^0.23.24" + }, + "dependencies": { + "@digiresilience/hapi-nextauth": "0.2.1", + "@hapi/boom": "^10.0.0", + "@hapi/glue": "^9.0.0", + "@hapi/hapi": "^21.2.0", + "@hapi/hoek": "^11.0.2", + "@hapi/inert": "^7.0.0", + "@hapi/vision": "^7.0.0", + "@hapipal/schmervice": "^2.1.0", + "@promster/hapi": "^8.0.6", + "@promster/server": "^7.0.8", + "@promster/types": "^3.2.5", + "@types/convict": "^6.1.1", + "@types/hapi__glue": "^6.1.6", + "@types/hapi__hapi": "^20.0.13", + "@types/hapi__inert": "^5.2.4", + "@types/hapi__vision": "^5.5.4", + "@types/hapi-pino": "^9.1.2", + "@types/hapipal__schmervice": "^2.0.3", + "@types/joi": "^17.2.3", + "chalk": "^5.2.0", + "commander": "^10.0.0", + "convict": "^6.2.4", + "decamelcase-keys": "^1.1.1", + "figlet": "^1.5.2", + "hapi-pino": "^11.0.1", + "http-terminator": "^3.2.0", + "joi": "^17.7.0", + "lodash": "^4.17.21", + "pg-promise": "^11.0.2", + "pino": "^8.8.0", + "prom-client": "^14.x.x", + "uuid": "^9.0.0" + } +} diff --git a/metamigo-common/plugins/config.ts b/metamigo-common/plugins/config.ts new file mode 100644 index 0000000..aa9cb37 --- /dev/null +++ b/metamigo-common/plugins/config.ts @@ -0,0 +1,23 @@ +import { Server } from "@hapi/hapi"; +import cloneDeep from "lodash/cloneDeep"; +import { deepFreeze } from "../helpers"; + +interface ConfigOptions { + config: unknown; +} + +const register = async ( + server: Server, + options: ConfigOptions +): Promise => { + const safeConfig = deepFreeze(cloneDeep(options.config)); + server.decorate("server", "config", () => safeConfig); +}; + +const ConfigPlugin = { + register, + name: "config", + version: "0.0.1", +}; + +export default ConfigPlugin; diff --git a/metamigo-common/plugins/request-id.ts b/metamigo-common/plugins/request-id.ts new file mode 100644 index 0000000..d008fae --- /dev/null +++ b/metamigo-common/plugins/request-id.ts @@ -0,0 +1,37 @@ +import { Server } from "@hapi/hapi"; +import { v4 as uuid } from "uuid"; + +interface RequestIdOptions { + header?: string; +} + +const register = async ( + server: Server, + options?: RequestIdOptions +): Promise => { + const header = options?.header || "x-request-id"; + server.ext("onPreResponse", async (request, h) => { + if (!request.response) { + return h.continue; + } + + if ("isBoom" in request.response) { + const id = request.response.output.headers[header] || uuid(); + request.response.output.headers[header] = id; + } else { + const id = request.headers[header] || uuid(); + // @ts-ignore + request.response.header(header, id); + } + + return h.continue; + }); +}; + +const RequestIdPlugin = { + register, + name: "request-id", + version: "0.0.1", +}; + +export default RequestIdPlugin; diff --git a/metamigo-common/plugins/status.ts b/metamigo-common/plugins/status.ts new file mode 100644 index 0000000..97bed0c --- /dev/null +++ b/metamigo-common/plugins/status.ts @@ -0,0 +1,60 @@ +import { Server, RouteOptionsAccess } from "@hapi/hapi"; +import { Prometheus } from "@promster/hapi"; + +interface StatusOptions { + path?: string; + auth?: RouteOptionsAccess; +} + +const count = (statusCounter: any) => async () => { + statusCounter.inc(); + return "Incremented metamigo_status_test counter"; +}; + +const ping = async () => "OK"; + +const statusRoutes = (server: Server, opt?: StatusOptions) => { + const path = opt?.path || "/status"; + const statusCounter = new Prometheus.Counter({ + name: "metamigo_status_test", + help: "Test counter", + }); + + return [ + { + method: "GET", + path: `${path}/ping`, + handler: ping, + options: { + auth: opt?.auth, + tags: ["api", "status", "ping"], + description: "Returns 200 and OK as the response.", + }, + }, + { + method: "GET", + path: `${path}/inc`, + handler: count(statusCounter), + options: { + auth: opt?.auth, + tags: ["api", "status", "prometheus"], + description: "Increments a test counter, for testing prometheus.", + }, + }, + ]; +}; + +const register = async ( + server: Server, + options: StatusOptions +): Promise => { + server.route(statusRoutes(server, options)); +}; + +const StatusPlugin = { + register, + name: "status", + version: "0.0.1", +}; + +export default StatusPlugin; diff --git a/metamigo-common/records/account.ts b/metamigo-common/records/account.ts new file mode 100644 index 0000000..8023b25 --- /dev/null +++ b/metamigo-common/records/account.ts @@ -0,0 +1,30 @@ +import { recordInfo } from "./record-info"; +import { RepositoryBase } from "./base"; +import { Flavor, UUID } from "../helpers"; +import { UserId } from "./user"; + +export type AccountId = Flavor; + +export interface UnsavedAccount { + compoundId: string; + userId: UserId; + providerType: string; + providerId: string; + providerAccountId: string; + refreshToken: string; + accessToken: string; + accessTokenExpires: Date; +} + +export interface SavedAccount extends UnsavedAccount { + id: AccountId; + createdAt: Date; + updatedAt: Date; +} + +export const AccountRecord = recordInfo( + "app_public", + "accounts" +); + +export class AccountRecordRepository extends RepositoryBase(AccountRecord) {} diff --git a/metamigo-common/records/base.ts b/metamigo-common/records/base.ts new file mode 100644 index 0000000..e4ed2a8 --- /dev/null +++ b/metamigo-common/records/base.ts @@ -0,0 +1,57 @@ +import { TableName } from "pg-promise"; +import { IMain } from "../db/types"; +import { CrudRepository } from "./crud-repository"; +import { PgRecordInfo, UnsavedR, SavedR, KeyType } from "./record-info"; +import type { IDatabase } from "pg-promise"; + +export type PgProtocol = IDatabase & T; + +/** + * This function returns a constructor for a repository class for [[TRecordInfo]] + * + * @param aRecordType the record type runtime definition + */ +// haven't figured out a good return type for this function +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export function unboundRepositoryBase< + TRecordInfo extends PgRecordInfo, + TDatabaseExtension +>(aRecordType: TRecordInfo) { + return class Repository extends CrudRepository< + UnsavedR, + SavedR, + KeyType + > { + _recordType!: TRecordInfo; + static readonly recordType = aRecordType; + static readonly schemaName = aRecordType.schemaName; + static readonly tableName = aRecordType.tableName; + public readonly recordType = aRecordType; + public readonly schemaTable: TableName; + public db: PgProtocol; + public pgp: IMain; + + constructor(db: PgProtocol) { + super(); + + this.pgp = db.$config.pgp; + this.schemaTable = new this.pgp.helpers.TableName({ + schema: aRecordType.schemaName, + table: aRecordType.tableName, + }); + + this.db = db; + if (!this.db) { + throw new Error("Missing database in repository"); + } + } + }; +} + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export function RepositoryBase< + Rec extends PgRecordInfo, + TDatabaseExtension = unknown +>(recordType: Rec) { + return unboundRepositoryBase(recordType); +} diff --git a/metamigo-common/records/crud-repository.ts b/metamigo-common/records/crud-repository.ts new file mode 100644 index 0000000..97712c6 --- /dev/null +++ b/metamigo-common/records/crud-repository.ts @@ -0,0 +1,321 @@ +/* eslint-disable @typescript-eslint/ban-types,@typescript-eslint/no-explicit-any */ +import { TableName } from "pg-promise"; +import decamelcaseKeys from "decamelcase-keys"; +import isObject from "lodash/isObject"; +import isArray from "lodash/isArray"; +import zipObject from "lodash/zipObject"; +import isEmpty from "lodash/isEmpty"; +import omit from "lodash/omit"; +import { IDatabase, IMain, IResult } from "../db/types"; +import { PgRecordInfo, idKeysOf } from "./record-info"; + +export interface ICrudRepository< + TUnsavedR, + TSavedR extends TUnsavedR & IdKeyT, + IdKeyT extends object +> { + findById(id: IdKeyT): Promise; + findBy(example: Partial): Promise; + findAll(): Promise; + findAllBy(example: Partial): Promise; + existsById(id: IdKeyT): Promise; + countBy(example: Partial): Promise; + count(): Promise; + insert(record: TUnsavedR): Promise; + insertAll(toInsert: TUnsavedR[]): Promise; + updateById(id: IdKeyT, attrs: Partial): Promise; + update(record: TSavedR): Promise; + updateAll(toUpdate: TSavedR[]): Promise; + remove(record: TSavedR): Promise; + removeAll(toRemove: TSavedR[]): Promise; + removeBy(example: Partial): Promise; + removeById(id: IdKeyT): Promise; +} + +// The snake cased object going into the db +type DatabaseRow = Record; + +/** + * Base class for generic CRUD operations on a repository for a specific type. + * + * Several assumptions are made about your environment for this generic CRUD repository to work: + * + * - the underlying column names are snake_cased (this behavior can be changed, see [[columnize]]) + * - the rows have only a single primary key (composite keys are not supported) + * + * @typeParam ID The type of the id column + * @typeParam T The type of the record + */ +export abstract class CrudRepository< + TUnsavedR, + TSavedR extends TUnsavedR & IdKeyT, + IdKeyT extends object +> implements ICrudRepository +{ + /** + * the fully qualified table name + */ + abstract schemaTable: TableName; + abstract recordType: PgRecordInfo; + abstract db: IDatabase; + abstract pgp: IMain; + + /** + * Converts the record's columns into snake_case + * + * @param record the record of type T to convert + */ + columnize(record: TSavedR | Partial): DatabaseRow { + return decamelcaseKeys(record); + } + + /* + * Creates a simple where clause with each key-value in `example` is + * formatted as KEY=VALUE and all kv-pairs are ANDed together. + * + * @param example key value pair of column names and values + */ + where(example: Partial): string { + const snaked = this.columnize(example); + const clauses = Object.keys(snaked).reduce((acc, cur) => { + const colName = this.pgp.as.format("$1:name", cur); + return `${acc} and ${colName} = $<${cur}>`; + }, ""); + const where = this.pgp.as.format(`WHERE 1=1 ${clauses}`, { ...snaked }); // Pre-format WHERE condition + return where; + } + + /** + * Converts a value containing the id of the record (which could be a primitive type, a composite object, or an array of values) + * into an object which can be safely passed to [[where]]. + */ + idsObj(idValues: IdKeyT): IdKeyT { + if (isEmpty(idValues)) { + throw new Error(`idsObj(${this.schemaTable}): passed empty id(s)`); + } + + let ids = {}; + const idKeys = idKeysOf(this.recordType as any); + if (isArray(idValues)) { + ids = zipObject(idKeys, idValues); + } else if (isObject(idValues)) { + ids = idValues; + } else { + if (idKeys.length !== 1) { + throw new Error( + `idsObj(${this.schemaTable}): passed record has multiple primary keys. the ids must be passed as an object or array. ${idValues}` + ); + } + // @ts-ignore + ids[idKeys[0]] = idValues; + } + + // this is a sanity check so we don't do something like + // deleting all the data if a WHERE slips in with no ids + if (isEmpty(ids)) { + throw new Error(`idsObj(${this.schemaTable}): passed empty ids`); + } + + return ids as IdKeyT; + } + + /** + * Returns all rows in the table + */ + async findAll(): Promise { + return this.db.any("SELECT * FROM $1", [this.schemaTable]); + } + + /** + * Returns the number of rows in the table + */ + async count(): Promise { + return this.db.one( + "SELECT count(*) FROM $1", + [this.schemaTable], + (a: { count: string }) => Number(a.count) + ); + } + + /** + * Returns the number of rows in the table matching the example + */ + async countBy(example: Partial): Promise { + return this.db.one( + "SELECT count(*) FROM $1 $2:raw ", + [this.schemaTable, this.where(example)], + (a: { count: string }) => Number(a.count) + ); + } + + /** + * Find a single row where the example are true. + * @param example key-value pairs of column names and values + */ + async findBy(example: Partial): Promise { + return this.db.oneOrNone("SELECT * FROM $1 $2:raw LIMIT 1", [ + this.schemaTable, + this.where(example), + ]); + } + + /** + * Retrieves a row by ID + * @param id + */ + async findById(id: IdKeyT): Promise { + const where = this.idsObj(id); + return this.db.oneOrNone("SELECT * FROM $1 $2:raw", [ + this.schemaTable, + this.where(where), + ]); + } + + /** + * Returns whether a given row with id exists + * @param id + */ + async existsById(id: IdKeyT): Promise { + return this.db.one( + "SELECT EXISTS(SELECT 1 FROM $1 $2:raw)", + [this.schemaTable, this.where(this.idsObj(id))], + (a: { exists: boolean }) => a.exists + ); + } + + /** + * Find all rows where the example are true. + * @param example key-value pairs of column names and values + */ + async findAllBy(example: Partial): Promise { + return this.db.any("SELECT * FROM $1 $2:raw", [ + this.schemaTable, + this.where(example), + ]); + } + + /** + * Creates a new row + * @param record + * @return the new row + */ + async insert(record: TUnsavedR): Promise { + return this.db.one("INSERT INTO $1 ($2:name) VALUES ($2:csv) RETURNING *", [ + this.schemaTable, + this.columnize(record as any), + ]); + } + + /** + * Like `insert` but will insert/update a batch of rows at once + */ + async insertAll(toInsert: TUnsavedR[]): Promise { + return this.db.tx((t) => { + const insertCommands: any[] = []; + + toInsert.forEach((record) => { + insertCommands.push(this.insert(record)); + }); + + return t.batch(insertCommands); + }); + } + + /** + * Deletes a row by id + * @param id + * @return the number of rows affected + */ + async removeById(id: IdKeyT): Promise { + return this.db.result( + "DELETE FROM $1 $2:raw", + [this.schemaTable, this.where(this.idsObj(id))], + (r: IResult) => r.rowCount + ); + } + + /** + * Delete records matching the query + * @param example key-value pairs of column names and values + */ + async removeBy(example: Partial): Promise { + if (isEmpty(example)) + throw new Error( + `removeBy(${this.schemaTable}): passed empty constraint!` + ); + return this.db.result("DELETE FROM $1 $2:raw", [ + this.schemaTable, + this.where(example), + ]); + } + + /** + * Deletes the given row + * + * @param record to remove + * @return the number of rows affected + */ + async remove(record: TSavedR): Promise { + return this.removeById(this.recordType.idOf(record)); + } + + /** + * Deletes all rows + * @param toRemove a list of rows to remove, if empty, DELETES ALL ROWS + * @return the number of rows affected + */ + async removeAll(toRemove: TSavedR[] = []): Promise { + if (toRemove.length === 0) { + return this.db.result( + "DELETE FROM $1 WHERE 1=1;", + [this.schemaTable], + (r: IResult) => r.rowCount + ); + } + + const results = await this.db.tx((t) => { + const delCommands: any[] = []; + + toRemove.forEach((record) => { + delCommands.push(this.remove(record)); + }); + + return t.batch(delCommands); + }); + return results.length; + } + + /** + * Updates an existing row + * @param id + * @param attrs + * @return the updated row + */ + async updateById(id: IdKeyT, attrs: Partial): Promise { + const idKeys = idKeysOf(this.recordType as any); + const attrsSafe = omit(attrs, idKeys); + return this.db.one( + "UPDATE $1 SET ($2:name) = ROW($2:csv) $3:raw RETURNING *", + [this.schemaTable, this.columnize(attrsSafe), this.where(this.idsObj(id))] + ); + } + + async update(record: TSavedR): Promise { + return this.updateById(this.recordType.idOf(record), record); + } + + /** + * Update a batch of records at once + */ + async updateAll(toUpdate: TSavedR[]): Promise { + return this.db.tx((t) => { + const updateCommands: any[] = []; + + toUpdate.forEach((record) => { + updateCommands.push(this.update(record)); + }); + + return t.batch(updateCommands); + }); + } +} diff --git a/metamigo-common/records/index.ts b/metamigo-common/records/index.ts new file mode 100644 index 0000000..6bc4f4a --- /dev/null +++ b/metamigo-common/records/index.ts @@ -0,0 +1,16 @@ +export * from "./base"; +export * from "./record-info"; +export * from "./crud-repository"; +export * from "./user"; +export * from "./session"; +export * from "./account"; + +import type { AccountRecordRepository } from "./account"; +import type { UserRecordRepository } from "./user"; +import type { SessionRecordRepository } from "./session"; + +export interface IMetamigoRepositories { + users: UserRecordRepository; + sessions: SessionRecordRepository; + accounts: AccountRecordRepository; +} diff --git a/metamigo-common/records/record-info.d.ts b/metamigo-common/records/record-info.d.ts new file mode 100644 index 0000000..f712d6f --- /dev/null +++ b/metamigo-common/records/record-info.d.ts @@ -0,0 +1,54 @@ +export interface EntityType { + _saved: TSaved; + _unsaved: TUnsaved; + _idKeys: TIds; + idOf: (rec: TSaved) => TIds; +} +export declare type UnsavedR = T["_unsaved"]; +export declare type SavedR = T["_saved"]; +export declare type KeyType = R["_idKeys"]; +export interface PgRecordInfo extends EntityType { + tableName: string; + schemaName: string; + idKeys: (keyof Saved)[]; +} +/** + * Extract the runtime key name from a recordInfo + */ +export declare function idKeysOf(recordInfoWithIdKey: RI): string[]; +/** + * Turns a record type with possibly more fields than "id" into an array + */ +export declare function collectIdValues(idObj: KeyType, knexRecordType: RecordT): string[]; +/** + * + * Creates a record descriptor that captures the table name, primary key name, + * unsaved type, and saved type of a database record type. Assumes "id" as the + * primary key name + * + */ +export declare function recordInfo(schemaName: string, tableName: string): PgRecordInfo>; +export declare function recordInfo(schemaName: string, tableName: string): PgRecordInfo>; +/** + * + * Creates a record descriptor that captures the table name, primary key name, + * unsaved type, and saved type of a database record type. + * + */ +export declare function recordInfo(schemaName: string, tableName: string, idKey: Id[]): PgRecordInfo>; +/** + * + * Creates a record descriptor for records with composite primary keys + * + */ +export declare function compositeRecordType(schemaName: string, tableName: string): { + withCompositeKeys(keys: TKeys[]): PgRecordInfo>; +}; diff --git a/metamigo-common/records/record-info.ts b/metamigo-common/records/record-info.ts new file mode 100644 index 0000000..5eb6a4b --- /dev/null +++ b/metamigo-common/records/record-info.ts @@ -0,0 +1,133 @@ +/* eslint-disable @typescript-eslint/ban-types,@typescript-eslint/no-explicit-any,@typescript-eslint/explicit-module-boundary-types */ +import at from "lodash/at"; +import pick from "lodash/pick"; + +export interface EntityType< + TUnsaved = any, + TSaved = any, + TIds extends object = any +> { + _saved: TSaved; + _unsaved: TUnsaved; + _idKeys: TIds; + idOf: (rec: TSaved) => TIds; +} + +export type UnsavedR = T["_unsaved"]; +export type SavedR = T["_saved"]; +export type KeyType = R["_idKeys"]; + +export interface PgRecordInfo< + Unsaved = any, + Saved extends Unsaved & IdType = any, + IdType extends object = any +> extends EntityType { + tableName: string; + schemaName: string; + idKeys: (keyof Saved)[]; +} + +/** + * Extract the runtime key name from a recordInfo + */ +export function idKeysOf( + recordInfoWithIdKey: RI +): string[] { + return recordInfoWithIdKey.idKeys as any; +} + +/** + * Turns a record type with possibly more fields than "id" into an array + */ +export function collectIdValues( + idObj: KeyType, + knexRecordType: RecordT +): string[] { + return at(idObj, idKeysOf(knexRecordType)); +} + +function castToRecordInfo( + runtimeData: Omit +): PgRecordInfo { + return runtimeData as PgRecordInfo; +} + +/** + * + * Creates a record descriptor that captures the table name, primary key name, + * unsaved type, and saved type of a database record type. Assumes "id" as the + * primary key name + * + */ +export function recordInfo( + schemaName: string, + tableName: string +): PgRecordInfo>; + +export function recordInfo( + schemaName: string, + tableName: string +): PgRecordInfo>; + +/** + * + * Creates a record descriptor that captures the table name, primary key name, + * unsaved type, and saved type of a database record type. + * + */ +export function recordInfo< + Unsaved, + Saved extends Unsaved, + Id extends keyof Saved +>( + schemaName: string, + tableName: string, + idKey: Id[] +): PgRecordInfo>; + +/** + * + * Don't use this signature be sure to provide unsaved and saved types. + * + */ +export function recordInfo( + schemaName: string, + tableName: string, + idKeys?: string[] +) { + idKeys = idKeys || ["id"]; + return castToRecordInfo({ + schemaName, + tableName, + idKeys, + idOf: (rec) => pick(rec, idKeys as any), + }); +} + +/** + * + * Creates a record descriptor for records with composite primary keys + * + */ +export function compositeRecordType< + TUnsaved, + TSaved extends TUnsaved = TUnsaved +>( + schemaName: string, + tableName: string +): { + withCompositeKeys( + keys: TKeys[] + ): PgRecordInfo>; +} { + return { + withCompositeKeys(keys) { + return castToRecordInfo({ + schemaName, + tableName, + idKeys: keys, + idOf: (rec) => pick(rec, keys), + }); + }, + }; +} diff --git a/metamigo-common/records/session.ts b/metamigo-common/records/session.ts new file mode 100644 index 0000000..4018947 --- /dev/null +++ b/metamigo-common/records/session.ts @@ -0,0 +1,26 @@ +import { recordInfo } from "./record-info"; +import { RepositoryBase } from "./base"; +import { Flavor, UUID } from "../helpers"; +import { UserId } from "./user"; + +export type SessionId = Flavor; + +export interface UnsavedSession { + userId: UserId; + expires: Date; + sessionToken: string; + accessToken: string; +} + +export interface SavedSession extends UnsavedSession { + id: SessionId; + createdAt: Date; + updatedAt: Date; +} + +export const SessionRecord = recordInfo( + "app_private", + "sessions" +); + +export class SessionRecordRepository extends RepositoryBase(SessionRecord) {} diff --git a/metamigo-common/records/user.ts b/metamigo-common/records/user.ts new file mode 100644 index 0000000..fcb66d1 --- /dev/null +++ b/metamigo-common/records/user.ts @@ -0,0 +1,40 @@ +import { recordInfo } from "./record-info"; +import { RepositoryBase } from "./base"; +import { Flavor, UUID } from "../helpers"; + +export type UserId = Flavor; + +export interface UnsavedUser { + name: string; + email: string; + emailVerified: Date; + avatar: string; + isActive: boolean; + userRole: string; +} + +export interface SavedUser extends UnsavedUser { + id: UserId; + createdAt: Date; + updatedAt: Date; +} + +export const UserRecord = recordInfo( + "app_public", + "users" +); + +export class UserRecordRepository extends RepositoryBase(UserRecord) { + async upsert(record: UnsavedUser | SavedUser): Promise { + return this.db.one( + `INSERT INTO $1 ($2:name) VALUES ($2:csv) + ON CONFLICT (email) + DO UPDATE SET + name = EXCLUDED.name, + avatar = EXCLUDED.avatar, + email_verified = EXCLUDED.email_verified + RETURNING *`, + [this.schemaTable, this.columnize(record)] + ); + } +} diff --git a/metamigo-common/tsconfig.json b/metamigo-common/tsconfig.json new file mode 100644 index 0000000..91b0c40 --- /dev/null +++ b/metamigo-common/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "incremental": true, + "outDir": "build/main", + "paths": { + "@hapipal/schmervice": ["vendor/hapipal__schmervice"], + "*": ["node_modules/@types/*", "*"] + } + }, + "include": ["**/*.ts"], + "exclude": ["node_modules/**"] +} diff --git a/metamigo-config/.eslintrc.js b/metamigo-config/.eslintrc.js new file mode 100644 index 0000000..523f982 --- /dev/null +++ b/metamigo-config/.eslintrc.js @@ -0,0 +1,18 @@ +require('@digiresilience/eslint-config-metamigo/patch/modern-module-resolution'); +module.exports = { + extends: [ + "@digiresilience/eslint-config-metamigo/profile/node", + "@digiresilience/eslint-config-metamigo/profile/typescript" + ], + parserOptions: { tsconfigRootDir: __dirname }, + rules: { + "import/no-extraneous-dependencies": [ + // enable this when this is fixed + // https://github.com/benmosher/eslint-plugin-import/pull/1696 + "off", + { packageDir: [".", "node_modules/@digiresilience/metamigo", "node_modules/@digiresilience/metamigo-dev"] }, + ], + // TODO: enable this after jest fixes this issue https://github.com/nodejs/node/issues/38343 + "unicorn/prefer-node-protocol": "off" + } +}; diff --git a/metamigo-config/index.ts b/metamigo-config/index.ts new file mode 100644 index 0000000..3e3d77f --- /dev/null +++ b/metamigo-config/index.ts @@ -0,0 +1,376 @@ +import * as process from "process"; +import * as convict from "convict"; +import * as Metamigo from "common"; +import { defState } from "@digiresilience/montar"; + +export const configSchema = { + db: { + connection: { + doc: "The postgres connection url.", + format: "uri", + default: "postgresql://metamigo:metamigo@127.0.0.1:5435/metamigo_dev", + env: "DATABASE_URL", + sensitive: true, + }, + name: { + doc: "The name of the postgres database", + format: String, + default: "metamigo_dev", + env: "DATABASE_NAME", + }, + owner: { + doc: "The username of the postgres database owner", + format: String, + default: "metamigo", + env: "DATABASE_OWNER", + }, + }, + worker: { + connection: { + doc: "The postgres connection url for the worker database.", + format: "uri", + default: "postgresql://metamigo:metamigo@127.0.0.1:5435/metamigo_dev", + env: "WORKER_DATABASE_URL", + }, + concurrency: { + doc: "The number of jobs to run concurrently", + default: 1, + format: "positiveInt", + env: "WORKER_CONCURRENT_JOBS", + }, + pollInterval: { + doc: "How long to wait between polling for jobs in milliseconds (for jobs scheduled in the future/retries)", + default: 2000, + format: "positiveInt", + env: "WORKER_POLL_INTERVAL_MS", + }, + }, + postgraphile: { + auth: { + doc: "The postgres role that postgraphile logs in with", + format: String, + default: "metamigo_graphile_auth", + env: "DATABASE_AUTHENTICATOR", + }, + appRootConnection: { + doc: "The postgres root/superuser connection url for development mode so PG can watch the schema changes, this is strangely named in the postgraphile API 'ownerConnectionString'", + format: String, + default: "postgresql://postgres:metamigo@127.0.0.1:5435/metamigo_dev", + env: "APP_ROOT_DATABASE_URL", + }, + authConnection: { + doc: "The postgres connection URL for postgraphile, must not be superuser and must have limited privs.", + format: String, + default: + "postgresql://metamigo_graphile_auth:metamigo@127.0.0.1:5435/metamigo_dev", + env: "DATABASE_AUTH_URL", + }, + visitor: { + doc: "The postgres role that postgraphile switches to", + format: String, + default: "app_postgraphile", + env: "DATABASE_VISITOR", + }, + schema: { + doc: "The schema postgraphile should expose with graphql", + format: String, + default: "app_public", + }, + enableGraphiql: { + doc: "Whether to enable the graphiql web interface or not", + format: "Boolean", + default: false, + env: "ENABLE_GRAPHIQL", + }, + }, + + dev: { + shadowConnection: { + doc: "The shadow databse connection url used by postgraphile-migrate. Not needed in production.", + format: "uri", + default: "postgresql://metamigo:metamigo@127.0.0.1:5435/metamigo_shadow", + env: "SHADOW_DATABASE_URL", + sensitive: true, + }, + rootConnection: { + doc: "The postgres root/superuser connection url for testing only, database must NOT be the app database. Not needed in production.", + format: "uri", + default: "postgresql://postgres:metamigo@127.0.0.1:5435/template1", + env: "ROOT_DATABASE_URL", + sensitive: true, + }, + }, + frontend: { + url: { + doc: "The url the frontend can be accessed at", + format: "url", + default: "http://localhost:3000", + env: "FRONTEND_URL", + }, + apiUrl: { + doc: "The url the api backend can be accessed at from the frontend server", + format: "url", + default: "http://localhost:3001", + env: "API_URL", + }, + }, + nextAuth: { + secret: { + doc: "A random string used to hash tokens, sign cookies and generate crytographic keys. Shared with the api backend.", + format: String, + default: undefined, + env: "NEXTAUTH_SECRET", + sensitive: true, + }, + audience: { + doc: "We will add this string as the `aud` claim to our JWT token, if empty or not present defaults to `frontend.url`", + format: String, + default: "", + env: "NEXTAUTH_AUDIENCE", + }, + signingKeyB64: { + doc: "A base64 encoded JWK.Key used for JWT signing", + format: String, + default: undefined, + env: "NEXTAUTH_SIGNING_KEY_B64", + sensitive: true, + }, + encryptionKeyB64: { + doc: "A base64 encoded JWK.Key used for JWT encryption", + format: String, + default: undefined, + env: "NEXTAUTH_ENCRYPTION_KEY_B64", + sensitive: true, + }, + signingKey: { + doc: "", + format: String, + default: undefined, + sensitive: true, + skipGenerate: true, + }, + encryptionKey: { + doc: "", + format: String, + default: undefined, + sensitive: true, + skipGenerate: true, + }, + google: { + id: { + doc: "reference https://next-auth.js.org/providers/google", + format: String, + default: undefined, + env: "GOOGLE_ID", + sensitive: true, + }, + secret: { + doc: "reference https://next-auth.js.org/providers/google", + format: String, + default: undefined, + env: "GOOGLE_SECRET", + sensitive: true, + }, + }, + github: { + id: { + doc: "reference https://next-auth.js.org/providers/github", + format: String, + default: undefined, + env: "GITHUB_ID", + sensitive: true, + }, + secret: { + doc: "reference https://next-auth.js.org/providers/github", + format: String, + default: undefined, + env: "GITHUB_SECRET", + sensitive: true, + }, + }, + gitlab: { + id: { + doc: "reference https://next-auth.js.org/providers/gitlab", + format: String, + default: undefined, + env: "GITLAB_ID", + sensitive: true, + }, + secret: { + doc: "reference https://next-auth.js.org/providers/gitlab", + format: String, + default: undefined, + env: "GITLAB_SECRET", + sensitive: true, + }, + }, + cognito: { + id: { + doc: "reference https://next-auth.js.org/providers/cognito", + format: String, + default: undefined, + env: "COGNITO_ID", + sensitive: true, + }, + secret: { + doc: "reference https://next-auth.js.org/providers/cognito", + format: String, + default: undefined, + env: "COGNITO_SECRET", + sensitive: true, + }, + domain: { + doc: "reference https://next-auth.js.org/providers/cognito", + format: String, + default: undefined, + env: "COGNITO_DOMAIN", + sensitive: true, + }, + }, + }, + cfaccess: { + audience: { + doc: "the cloudflare access audience id", + format: String, + default: undefined, + env: "CFACCESS_AUDIENCE", + }, + + domain: { + doc: "the cloudflare access domain, something like `YOURAPP.cloudflareaccess.com`", + format: String, + default: undefined, + env: "CFACCESS_DOMAIN", + }, + }, + signald: { + enabled: { + doc: "Whether to enable the signald signal backend", + format: "Boolean", + default: false, + env: "SIGNALD_ENABLED", + }, + socket: { + doc: "the unix domain socket signald is listening on", + format: String, + default: `${process.cwd()}/signald/signald.sock`, + env: "SIGNALD_SOCKET", + }, + }, +}; + +// define the interfaces for the concrete config objects +export interface IDBConfig { + connection: string; + name: string; + owner: string; +} + +export interface IWorkerConfig { + connection: string; + concurrency: number; + pollInterval: number; +} + +export interface IPostgraphileConfig { + auth: string; + visitor: string; + appRootConnection: string; + authConnection: string; + schema: string; + enableGraphiql: boolean; +} + +export interface IDevConfig { + shadowConnection: string; + rootConnection: string; +} + +export interface IFrontendConfig { + url: string; + apiUrl: string; +} + +export interface INextAuthConfig { + secret: string; + audience: string; + signingKey: string; + encryptionKey: string; + signingKeyB64: string; + encryptionKeyB64: string; + google?: { id: string; secret: string }; + github?: { id: string; secret: string }; + gitlab?: { id: string; secret: string }; + cognito?: { id: string; secret: string; domain: string }; +} + +export interface ICFAccessConfig { + audience: string; + domain: string; +} + +export interface ISignaldConifg { + enabled: boolean; + socket: string; +} + +// Extend the metamigo base type to add your app's custom config along side the out +// of the box Metamigo config +export interface IAppConfig extends Metamigo.IMetamigoConfig { + db: IDBConfig; + worker: IWorkerConfig; + postgraphile: IPostgraphileConfig; + dev: IDevConfig; + frontend: IFrontendConfig; + nextAuth: INextAuthConfig; + cfaccess: ICFAccessConfig; + signald: ISignaldConifg; +} + +export type IAppConvict = Metamigo.ExtendedConvict; + +// Merge the Metamigo base schema with your app's schmea +// @ts-ignore +export const schema: convict.Schema = { + ...Metamigo.configBaseSchema, + ...configSchema, +}; + +export const loadConfig = async (): Promise => { + const config = await Metamigo.loadConfiguration(schema); + + if (!config.frontend.url || config.frontend.url === "") + throw new Error( + "configuration value frontend.url is missing. Add to config or set NEXTAUTH_URL env var" + ); + + // nextauth expects the url to be provided with this environment variable, so we will munge it in place here + process.env.NEXTAUTH_URL = config.frontend.url; + + if (config.nextAuth.signingKeyB64) + config.nextAuth.signingKey = Buffer.from( + config.nextAuth.signingKeyB64, + "base64" + ).toString("utf-8"); + + if (config.nextAuth.encryptionKeyB64) + config.nextAuth.encryptionKey = Buffer.from( + config.nextAuth.encryptionKeyB64, + "base64" + ).toString("utf-8"); + + if (!config.nextAuth.audience || config.nextAuth.audience === "") + config.nextAuth.audience = config.frontend.url; + + return config as any; +}; + +export const loadConfigRaw = async (): Promise => { + return Metamigo.loadConfigurationRaw(schema); +}; + +const config = defState("config", { + start: loadConfig, +}); + +export default config; diff --git a/metamigo-config/package.json b/metamigo-config/package.json new file mode 100644 index 0000000..bcdf11f --- /dev/null +++ b/metamigo-config/package.json @@ -0,0 +1,35 @@ +{ + "name": "config", + "version": "0.2.0", + "main": "build/main/index.js", + "author": "Abel Luck ", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@digiresilience/montar": "^0.1.6" + }, + "devDependencies": { + "@babel/core": "7.20.12", + "@babel/preset-env": "7.20.2", + "@babel/preset-typescript": "7.18.6", + "eslint": "^8.32.0", + "pino-pretty": "^9.1.1", + "prettier": "^2.8.3", + "ts-node": "^10.9.1", + "typedoc": "^0.23.24", + "typescript": "4.9.4" + + }, + "files": ["build", "src"], + "scripts": { + "build": "tsc -p tsconfig.json", + "doc:html": "typedoc src/ --exclude '**/*.test.ts' --exclude '**/*.spec.ts' --name $npm_package_name --readme README.md --target es2019 --mode file --out build/docs", + "doc": "yarn run doc:html", + "fix:lint": "eslint src --ext .ts --fix", + "fix:prettier": "prettier \"src/**/*.ts\" --write", + "lint:lint": "eslint src --ext .ts", + "lint:prettier": "prettier \"src/**/*.ts\" --list-different", + "test": "echo no tests", + "lint": "yarn lint:lint && yarn lint:prettier", + "watch:build": "tsc -p tsconfig.json -w" + } +} diff --git a/metamigo-config/tsconfig.json b/metamigo-config/tsconfig.json new file mode 100644 index 0000000..36f636f --- /dev/null +++ b/metamigo-config/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "build/main" + }, + "include": ["**/*.ts", "**/.*.ts", "index.ts"], + "exclude": ["node_modules", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/metamigo-db/.eslintrc.js b/metamigo-db/.eslintrc.js new file mode 100644 index 0000000..d8da07c --- /dev/null +++ b/metamigo-db/.eslintrc.js @@ -0,0 +1 @@ +require("../.eslintrc.js"); diff --git a/metamigo-db/.gmrc b/metamigo-db/.gmrc new file mode 100644 index 0000000..22511de --- /dev/null +++ b/metamigo-db/.gmrc @@ -0,0 +1,117 @@ +/* + * Graphile Migrate configuration. + * + * MUST NOT CONTAIN SECRETS/PASSWORDS + + * This file is in JSON5 format. + */ + +{ + /* + * Database connections strings are sourced from the DATABASE_URL, + * SHADOW_DATABASE_URL and ROOT_DATABASE_URL environmental variables. + */ + + /* + * pgSettings: key-value settings to be automatically loaded into PostgreSQL + * before running migrations, using an equivalent of `SET LOCAL TO + * ` + */ + "pgSettings": { + "search_path": "public", + }, + + /* + * placeholders: substituted in SQL files when compiled/executed. Placeholder + * keys should be prefixed with a colon and in all caps, like + * `:COLON_PREFIXED_ALL_CAPS`. Placeholder values should be strings. They + * will be replaced verbatim with NO ESCAPING AT ALL (this differs from how + * psql handles placeholders) so should only be used with "safe" values. This + * is useful for committing migrations where certain parameters can change + * between environments (development, staging, production) but you wish to + * use the same signed migration files for all. + * + * The special value "!ENV" can be used to indicate an environmental variable + * of the same name should be used. + * + * Graphile Migrate automatically sets the `:DATABASE_NAME` and + * `:DATABASE_OWNER` placeholders, and you should not attempt to override + * these. + */ + "placeholders": { + ":DATABASE_VISITOR": "!ENV", + ":DATABASE_AUTHENTICATOR": "!ENV", + }, + + /* + * Actions allow you to run scripts or commands at certain points in the + * migration lifecycle. SQL files are ran against the database directly. + * "command" actions are ran with the following environmental variables set: + * + * - GM_DBURL: the PostgreSQL URL of the database being migrated + * - GM_DBNAME: the name of the database from GM_DBURL + * - GM_DBUSER: the user from GM_DBURL + * - GM_SHADOW: set to 1 if the shadow database is being migrated, left unset + * otherwise + * + * If "shadow" is unspecified, the actions will run on events to both shadow + * and normal databases. If "shadow" is true the action will only run on + * actions to the shadow DB, and if false only on actions to the main DB. + */ + + /* + * afterReset: actions executed after a `graphile-migrate reset` command. + */ + +"afterReset": [ + "!../scripts/afterReset.sql", + ], + + /* + * afterAllMigrations: actions executed once all migrations are complete. + */ + "afterAllMigrations": [ + { + "_": "command", + "shadow": true, + "command": "node scripts/dump-db.js" + }, + ], + + /* + * afterCurrent: actions executed once the current migration has been + * evaluated (i.e. in watch mode). + */ + "afterCurrent": [ + { + "_": "command", + "command": "./scripts/afterCurrent.sh", + } + ], + + /* + * blankMigrationContent: content to be written to the current migration + * after commit. NOTE: this should only contain comments. + */ + // "blankMigrationContent": "-- Write your migration here\n", + + /****************************************************************************\ + *** *** + *** You probably don't want to edit anything below here. *** + *** *** + \****************************************************************************/ + + /* + * manageGraphileMigrateSchema: if you set this false, you must be sure to + * keep the graphile_migrate schema up to date yourself. We recommend you + * leave it at its default. + */ + // "manageGraphileMigrateSchema": true, + + /* + * migrationsFolder: path to the folder in which to store your migrations. + */ + // migrationsFolder: "./migrations", + + "//generatedWith": "1.0.2" +} diff --git a/metamigo-db/Dockerfile b/metamigo-db/Dockerfile new file mode 100644 index 0000000..bab581c --- /dev/null +++ b/metamigo-db/Dockerfile @@ -0,0 +1,2 @@ +FROM postgres:13 +COPY scripts/bootstrap.sh /docker-entrypoint-initdb.d/bootstrap.sh diff --git a/metamigo-db/helpers.ts b/metamigo-db/helpers.ts new file mode 100644 index 0000000..5a63cf5 --- /dev/null +++ b/metamigo-db/helpers.ts @@ -0,0 +1,67 @@ +import process from "process"; +import { existsSync } from "fs"; +import { exec } from "child_process"; +import type { IAppConfig } from "config"; + +/** + * We use graphile-migrate for managing database migrations. + * + * However we also use convict as the sole source of truth for our app's configuration. We do not want to have to configure + * separate env files or config files for graphile-migrate and yet again others for convict. + * + * So we wrap the graphile-migrate cli tool here. We parse our convict config, set necessary env vars, and then shell out to + * graphile-migrate. + * + * Commander eats all args starting with --, so you must use the -- escape to indicate the arguments have finished + * + * Example: + * ./cli db -- --help // will show graphile migrate help + * ./cli db -- watch // will watch the current sql for changes + * ./cli db -- watch --once // will apply the current sql once + */ +export const migrateWrapper = async ( + commands: string[], + config: IAppConfig, + silent = false +): Promise => { + const env = { + DATABASE_URL: config.db.connection, + SHADOW_DATABASE_URL: config.dev.shadowConnection, + ROOT_DATABASE_URL: config.dev.rootConnection, + DATABASE_NAME: config.db.name, + DATABASE_OWNER: config.db.owner, + DATABASE_AUTHENTICATOR: config.postgraphile.auth, + DATABASE_VISITOR: config.postgraphile.visitor, + }; + const cmd = `npx --no-install graphile-migrate ${commands.join(" ")}`; + const dbDir = `../../db`; + const gmrc = `${dbDir}/.gmrc`; + if (!existsSync(gmrc)) { + throw new Error(`graphile migrate config not found at ${gmrc}`); + } + + if (!silent) console.log("executing:", cmd); + + return new Promise((resolve, reject) => { + const proc = exec(cmd, { + env: { ...process.env, ...env }, + cwd: dbDir, + }); + + proc.stdout.on("data", (data) => { + if (!silent) console.log("MIGRATE:", data); + }); + + proc.stderr.on("data", (data) => { + console.error("MIGRATE", data); + }); + proc.on("close", (code) => { + if (code !== 0) { + reject(new Error(`graphile-migrate exited with code ${code}`)); + return; + } + + resolve(); + }); + }); +}; diff --git a/metamigo-db/index.ts b/metamigo-db/index.ts new file mode 100644 index 0000000..0d0b272 --- /dev/null +++ b/metamigo-db/index.ts @@ -0,0 +1,89 @@ +import { IAppConfig } from "config"; +import camelcaseKeys from "camelcase-keys"; +import PgSimplifyInflectorPlugin from "@graphile-contrib/pg-simplify-inflector"; +// import PgManyToManyPlugin from "@graphile-contrib/pg-many-to-many"; +import * as ConnectionFilterPlugin from "postgraphile-plugin-connection-filter"; +import type { PostGraphileCoreOptions } from "postgraphile-core"; + +import { + UserRecordRepository, + AccountRecordRepository, + SessionRecordRepository, +} from "common"; + +import { + SettingRecordRepository, + VoiceProviderRecordRepository, + VoiceLineRecordRepository, + WebhookRecordRepository, + WhatsappBotRecordRepository, + WhatsappMessageRecordRepository, + WhatsappAttachmentRecordRepository, + SignalBotRecordRepository, +} from "./records"; + +import type { IInitOptions, IDatabase } from "pg-promise"; + +export interface IRepositories { + users: UserRecordRepository; + sessions: SessionRecordRepository; + accounts: AccountRecordRepository; + settings: SettingRecordRepository; + voiceLines: VoiceLineRecordRepository; + voiceProviders: VoiceProviderRecordRepository; + webhooks: WebhookRecordRepository; + whatsappBots: WhatsappBotRecordRepository; + whatsappMessages: WhatsappMessageRecordRepository; + whatsappAttachments: WhatsappAttachmentRecordRepository; + signalBots: SignalBotRecordRepository; +} + +export type AppDatabase = IDatabase & IRepositories; + +export const dbInitOptions = ( + _config: IAppConfig +): IInitOptions => { + return { + noWarnings: true, + receive(data, result) { + if (result) result.rows = camelcaseKeys(data); + }, + + // Extending the database protocol with our custom repositories; + // API: http://vitaly-t.github.io/pg-promise/global.html#event:extend + extend(obj: any, _dc) { // AppDatase was obj type + // Database Context (_dc) is mainly needed for extending multiple databases with different access API. + + // NOTE: + // This event occurs for every task and transaction being executed (which could be every request!) + // so it should be as fast as possible. Do not use 'require()' or do any other heavy lifting. + obj.users = new UserRecordRepository(obj); + obj.sessions = new SessionRecordRepository(obj); + obj.accounts = new AccountRecordRepository(obj); + obj.settings = new SettingRecordRepository(obj); + obj.voiceLines = new VoiceLineRecordRepository(obj); + obj.voiceProviders = new VoiceProviderRecordRepository(obj); + obj.webhooks = new WebhookRecordRepository(obj); + obj.whatsappBots = new WhatsappBotRecordRepository(obj); + obj.whatsappMessages = new WhatsappMessageRecordRepository(obj); + obj.whatsappAttachments = new WhatsappAttachmentRecordRepository(obj); + obj.signalBots = new SignalBotRecordRepository(obj); + }, + }; +}; + +export const getPostGraphileOptions = (): PostGraphileCoreOptions => { + return { + ignoreRBAC: false, + dynamicJson: true, + ignoreIndexes: false, + appendPlugins: [ + PgSimplifyInflectorPlugin, + // PgManyToManyPlugin, + ConnectionFilterPlugin as any, + ], + }; +}; + +export * from "./helpers"; +export * from "./records"; diff --git a/metamigo-db/migrations/committed/000001.sql b/metamigo-db/migrations/committed/000001.sql new file mode 100644 index 0000000..1a8ba63 --- /dev/null +++ b/metamigo-db/migrations/committed/000001.sql @@ -0,0 +1,650 @@ +--! Previous: - +--! Hash: sha1:b13a5217288f5d349d8d9e3afbd7bb30c0dbad21 + +-- region Bootstrap +drop schema if exists app_public cascade; +alter default privileges revoke all on sequences from public; +alter default privileges revoke all on functions from public; + +-- By default the public schema is owned by `postgres`; we need superuser privileges to change this :( +-- alter schema public owner to waterbear; +revoke all on schema public from public; +grant all on schema public to :DATABASE_OWNER; + + +create schema app_public; +grant usage on schema + public, + app_public + to + :DATABASE_VISITOR, + app_admin, + app_anonymous, + app_user; + +/**********/ + +drop schema if exists app_hidden cascade; +create schema app_hidden; +grant usage on schema app_hidden to :DATABASE_VISITOR; + +alter default privileges in schema app_hidden grant usage, select on sequences to :DATABASE_VISITOR; + +/**********/ + +alter default privileges in schema public, app_public, app_hidden grant usage, select on sequences to :DATABASE_VISITOR; +alter default privileges in schema public, app_public, app_hidden + grant execute on functions to + :DATABASE_VISITOR, + app_admin, + app_user; + +/**********/ + +drop schema if exists app_private cascade; +create schema app_private; + + +-- endregion +-- region UtilFunctions +create function app_private.tg__add_job() returns trigger as +$$ +begin + perform graphile_worker.add_job(tg_argv[0], json_build_object('id', NEW.id), + coalesce(tg_argv[1], public.gen_random_uuid()::text)); + return NEW; +end; +$$ language plpgsql volatile + security definer + set search_path to pg_catalog, public, pg_temp; +comment on function app_private.tg__add_job() is + E'Useful shortcut to create a job on insert/update. Pass the task name as the first trigger argument, and optionally the queue name as the second argument. The record id will automatically be available on the JSON payload.'; + +/* ------------------------------------------------------------------ */ + +create function app_private.tg__timestamps() returns trigger as +$$ +begin + NEW.created_at = (case when TG_OP = 'INSERT' then NOW() else OLD.created_at end); + NEW.updated_at = (case + when TG_OP = 'UPDATE' and OLD.updated_at >= NOW() + then OLD.updated_at + interval '1 millisecond' + else NOW() end); + return NEW; +end; +$$ language plpgsql volatile + set search_path to pg_catalog, public, pg_temp; +comment on function app_private.tg__timestamps() is + E'This trigger should be called on all tables with created_at, updated_at - it ensures that they cannot be manipulated and that updated_at will always be larger than the previous updated_at.'; + +-- endregion + +-- region Users, Sessions, and Accounts +/* ------------------------------------------------------------------ */ +create table app_private.sessions +( + id uuid not null default gen_random_uuid() primary key, + user_id uuid not null, + expires timestamptz not null, + session_token text not null, + access_token text not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + last_active_at timestamptz not null default now() +); + +create unique index session_token on app_private.sessions(session_token); +create unique index access_token on app_private.sessions(access_token); + +alter table app_private.sessions + enable row level security; + + + +/* ------------------------------------------------------------------ */ + +create function app_public.current_session_id() returns uuid as +$$ +-- note the jwt.claims.session_id doesn't mean you have to use jwt, it is just where this function will always look for the session id. +select nullif(pg_catalog.current_setting('jwt.claims.session_id', true), '')::uuid; +$$ language sql stable; +comment on function app_public.current_session_id() is + E'Handy method to get the current session ID.'; + +/* + * A less secure but more performant version of this function would be just: + * + * select nullif(pg_catalog.current_setting('jwt.claims.user_id', true), '')::int; + * + * The increased security of this implementation is because even if someone gets + * the ability to run SQL within this transaction they cannot impersonate + * another user without knowing their session_id (which should be closely + * guarded). + */ +create function app_public.current_user_id() returns uuid as +$$ +select user_id +from app_private.sessions +where id = app_public.current_session_id(); +$$ language sql stable + security definer + set search_path to pg_catalog, public, pg_temp; +comment on function app_public.current_user_id() is + E'Handy method to get the current user ID for use in RLS policies, etc; in GraphQL, use `currentUser{id}` instead.'; +-- We've put this in public, but omitted it, because it's often useful for debugging auth issues. + +/* ------------------------------------------------------------------ */ + +-- These are the user roles for our application +create type app_public.role_type as + ENUM ('none','admin', 'user'); + +/* ------------------------------------------------------------------ */ + +create table app_public.users +( + id uuid not null default uuid_generate_v1mc() primary key, + email citext not null, + email_verified timestamptz, + name text not null, + avatar text, + user_role app_public.role_type not null default 'none', + is_active boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + created_by text not null, + constraint users_email_validity check (email ~* '^[A-Za-z0-9._%-]+@[A-Za-z0-9.-]+[.][A-Za-z]+$'), + constraint users_avatar_validity check (avatar ~ '^https?://[^/]+'), + constraint users_email_unique unique (email) +); +comment on table app_public.users is + E'A user who can log in to the application.'; +comment on column app_public.users.id is + E'Unique identifier for the user.'; +comment on column app_public.users.email is + E'The email address of the user.'; +comment on column app_public.users.email_verified is + E'The time at which the email address was verified'; +comment on column app_public.users.name is + E'Public-facing name (or pseudonym) of the user.'; +comment on column app_public.users.avatar is + E'Optional avatar URL.'; +comment on column app_public.users.user_role is + E'The role that defines the user''s privileges.'; +comment on column app_public.users.is_active is + E'If false, the user is not allowed to login or access the application'; + +alter table app_public.users + enable row level security; + +alter table app_private.sessions + add constraint sessions_user_id_fkey foreign key ("user_id") references app_public.users on delete cascade; + +create index on app_private.sessions (user_id); + +-- app_public perms default +create policy access_self on app_public.users to app_anonymous using (id = app_public.current_user_id()); + +--create policy update_self on app_public.users for update using (id = app_public.current_user_id()); +grant select on app_public.users to app_anonymous; +grant update (name, avatar) on app_public.users to :DATABASE_VISITOR, app_user; + +-- app_public perms for app_admin +create policy access_all on app_public.users to app_admin using (true); +grant update (email, name, avatar, is_active, user_role) on app_public.users to app_admin; +grant select on app_public.users to app_admin; +grant insert (email, name, avatar, user_role, is_active, created_by) on app_public.users to app_admin; +grant update (email, name, avatar, user_role, is_active, created_by) on app_public.users to app_admin; + +create trigger _100_timestamps + before insert or update + on app_public.users + for each row +execute procedure app_private.tg__timestamps(); + +/* ------------------------------------------------------------------ */ + +create function app_public.current_user() returns app_public.users as +$$ +select users.* +from app_public.users +where id = app_public.current_user_id(); +$$ language sql stable; +comment on function app_public.current_user() is + E'The currently logged in user (or null if not logged in).'; + +/* ------------------------------------------------------------------ */ + +create function app_public.logout() returns void as +$$ +begin + -- Delete the session + delete from app_private.sessions where id = app_public.current_session_id(); + -- Clear the identifier from the transaction + perform set_config('jwt.claims.session_id', '', true); +end; +$$ language plpgsql security definer + volatile + set search_path to pg_catalog, public, pg_temp; + + +/* ------------------------------------------------------------------ */ + +create table app_public.accounts +( + id uuid not null default uuid_generate_v1mc() primary key, + compound_id text not null, + user_id uuid not null, + provider_type text not null, + provider_id text not null, + provider_account_id text not null, + refresh_token text, + access_token text, + access_token_expires timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table app_public.accounts + enable row level security; + +alter table app_public.accounts + add constraint accounts_user_id_fkey foreign key ("user_id") references app_public.users on delete cascade; + +create unique index accounts_compound_id on app_public.accounts(compound_id); +create index accounts_provider_account_id on app_public.accounts(provider_account_id); +create index accounts_provider_id on app_public.accounts(provider_id); +create index accounts_user_id on app_public.accounts (user_id); + +create policy access_self on app_public.accounts to app_anonymous using (user_id = app_public.current_user_id()); + +grant select on app_public.accounts to app_anonymous; +grant update (compound_id, provider_type, provider_id, provider_account_id, refresh_token, access_token, access_token_expires) on app_public.accounts to app_user; + +create policy access_all on app_public.accounts to app_admin using (true); +grant update (compound_id, provider_type, provider_id, provider_account_id, refresh_token, access_token, access_token_expires) on app_public.accounts to app_admin; +grant select on app_public.accounts to app_admin; +grant insert (user_id, compound_id, provider_type, provider_id, provider_account_id, refresh_token, access_token, access_token_expires) on app_public.accounts to app_admin; +grant update (compound_id, provider_type, provider_id, provider_account_id, refresh_token, access_token, access_token_expires) on app_public.accounts to app_admin; + +create trigger _100_timestamps + before insert or update + on app_public.accounts + for each row +execute procedure app_private.tg__timestamps(); + +-- endregion + +-- region Create first user function + +create or replace function app_public.create_first_user (user_email text, user_name text) + returns setof app_public.users +as +$$ +declare + user_count int; +begin + + user_count := (select count(id) from app_public.users); + + if (user_count != 0) then + raise exception 'Admin user already created'; + end if; + + return query insert into app_public.users (email, email_verified, name, user_role, is_active, created_by) + values (user_email, now(), user_name, 'admin', true, 'first user hook') returning *; +end ; +$$ LANGUAGE plpgsql VOLATILE + SECURITY DEFINER; + + +comment on function app_public.create_first_user(user_email text, user_name text) is + E'Creates the first user with an admin role. Only possible when there are no other users in the database.'; + +grant execute on function app_public.create_first_user(user_email text, user_name text) to app_anonymous; + +create function app_private.tg__first_user() returns trigger as +$$ +declare + user_count int; +begin + user_count := (select count(id) from app_public.users); + + if (user_count = 0) then + NEW.user_role = 'admin'; + end if; + return NEW; +end; +$$ language plpgsql volatile + set search_path to pg_catalog, public, pg_temp; +comment on function app_private.tg__first_user() is + E'This trigger is called to ensure the first user created is an admin'; + + +create trigger _101_first_user + before insert + on app_public.users + for each row +execute procedure app_private.tg__first_user(); +-- endregion + +-- region Settings + +create table app_public.settings +( + id uuid not null default uuid_generate_v1mc() primary key, + name text not null, + value jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + +); + +create unique index setting_name on app_public.settings(name); + +alter table app_public.settings + enable row level security; + +create policy access_all on app_public.settings to app_admin using (true); +grant update (name, value) on app_public.settings to app_admin; +grant select on app_public.settings to app_admin; +grant insert (name, value) on app_public.settings to app_admin; + +create trigger _100_timestamps + before insert or update + on app_public.settings + for each row +execute procedure app_private.tg__timestamps(); + +-- endregion + +-- region Provider + +create table app_public.voice_providers +( + id uuid not null default uuid_generate_v1mc() primary key, + kind text not null, + name text not null, + credentials jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index voice_providers_number on app_public.voice_providers(name); + +alter table app_public.voice_providers + enable row level security; + +create policy access_all on app_public.voice_providers to app_admin using (true); +grant update (name, credentials) on app_public.voice_providers to app_admin; +grant select on app_public.voice_providers to app_admin; +grant insert (kind, name, credentials) on app_public.voice_providers to app_admin; +grant delete on app_public.voice_providers to app_admin; + +create trigger _100_timestamps + before insert or update + on app_public.voice_providers + for each row +execute procedure app_private.tg__timestamps(); +-- endregion + +-- region Voice Line + +create table app_public.voice_lines +( + id uuid not null default uuid_generate_v1mc() primary key, + provider_id uuid not null, + provider_line_sid text not null, + number text not null, + language text not null, + voice text not null, + prompt_text text, + prompt_audio jsonb, + audio_prompt_enabled boolean not null default false, + audio_converted_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table app_public.voice_lines + add constraint voice_lines_provider_id_fkey foreign key ("provider_id") references app_public.voice_providers on delete cascade; + +create index on app_public.voice_lines (provider_id); +create index on app_public.voice_lines (provider_line_sid); +create unique index voice_lines_number on app_public.voice_lines(number); + +alter table app_public.voice_lines + enable row level security; + +create policy access_all on app_public.voice_lines to app_admin using (true); +grant update (prompt_text, prompt_audio, audio_prompt_enabled, language, voice) on app_public.voice_lines to app_admin; +grant select on app_public.voice_lines to app_admin; +grant insert (provider_id, provider_line_sid, number, prompt_text, prompt_audio, audio_prompt_enabled, language, voice) on app_public.voice_lines to app_admin; +grant delete on app_public.voice_lines to app_admin; + +create trigger _100_timestamps + before insert or update + on app_public.voice_lines + for each row +execute procedure app_private.tg__timestamps(); + + +create function app_private.tg__voice_line_provider_update() returns trigger as $$ +begin + if (TG_OP = 'DELETE') then + perform graphile_worker.add_job('voice-line-delete', json_build_object('voiceLineId', OLD.id, 'providerId', OLD.provider_id, 'providerLineSid', OLD.provider_line_sid)); + else + perform graphile_worker.add_job('voice-line-provider-update', json_build_object('voiceLineId', NEW.id)); + end if; + + return null; +end; +$$ language plpgsql volatile security definer set search_path to pg_catalog, public, pg_temp; + +comment on function app_private.tg__voice_line_provider_update() is + E'This trigger is called to ensure a voice line is connected to twilio properly'; + + +create trigger _101_voice_line_provider_update + after insert or update of provider_line_sid or delete + on app_public.voice_lines + for each row +execute procedure app_private.tg__voice_line_provider_update(); + +create function app_private.tg__voice_line_prompt_audio_update() returns trigger as $$ +begin + perform graphile_worker.add_job('voice-line-audio-update', json_build_object('voiceLineId', NEW.id)); + return null; +end; +$$ language plpgsql volatile security definer set search_path to pg_catalog, public, pg_temp; + +comment on function app_private.tg__voice_line_prompt_audio_update() is + E'This trigger is called to ensure a voice line is connected to twilio properly'; + + +create trigger _101_voice_line_prompt_audio_update + after insert or update of prompt_audio + on app_public.voice_lines + for each row +execute procedure app_private.tg__voice_line_prompt_audio_update(); +-- endregion + +-- region Webhooks +create table app_public.webhooks +( + id uuid not null default uuid_generate_v1mc() primary key, + backend_type text not null, + backend_id uuid not null, + name text not null, + endpoint_url text not null, + http_method text not null default 'post', + headers jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint webhook_http_method_validity check (http_method in ('post', 'put')), + constraint webhook_endpoint_url_validity check (endpoint_url ~ '^https?://[^/]+') +); + +create index on app_public.webhooks (backend_type, backend_id); + +alter table app_public.webhooks + enable row level security; + +create policy access_all on app_public.webhooks to app_admin using (true); +grant update (name, endpoint_url, http_method, headers) on app_public.webhooks to app_admin; +grant select on app_public.webhooks to app_admin; +grant insert (backend_type, backend_id, name, endpoint_url, http_method, headers) on app_public.webhooks to app_admin; +grant delete on app_public.webhooks to app_admin; + +create trigger _100_timestamps + before insert or update + on app_public.webhooks + for each row +execute procedure app_private.tg__timestamps(); +-- endregion + +-- region WhatsappBots +set transform_null_equals to true; +create table app_public.whatsapp_bots +( + id uuid not null default uuid_generate_v1mc() primary key, + phone_number text not null, + token uuid not null default uuid_generate_v1mc(), + user_id uuid not null, + description text, + auth_info text, + qr_code text, + is_verified boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + +); + +create unique index whatsapp_bot_token on app_public.whatsapp_bots(token); + +alter table app_public.whatsapp_bots + add constraint whatsapp_bots_user_id_fkey foreign key ("user_id") references app_public.users on delete cascade; + +alter table app_public.whatsapp_bots + enable row level security; + +create policy access_all on app_public.whatsapp_bots to app_admin using (true); +grant update (phone_number, token, user_id, description, auth_info, qr_code, is_verified) on app_public.whatsapp_bots to app_admin; +grant select on app_public.whatsapp_bots to app_admin; +grant insert (phone_number, token, user_id, description, auth_info, qr_code, is_verified) on app_public.whatsapp_bots to app_admin; + +create trigger _100_timestamps + before insert or update + on app_public.whatsapp_bots + for each row +execute procedure app_private.tg__timestamps(); + +-- endregion +-- region WhatsappMessages + +create table app_public.whatsapp_messages +( + id uuid not null default uuid_generate_v1mc() primary key, + whatsapp_bot_id uuid not null, + wa_message_id text, + wa_message text, + wa_timestamp timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + +); + +create unique index whatsapp_message_whatsapp_bot_id on app_public.whatsapp_messages(whatsapp_bot_id); + +alter table app_public.whatsapp_messages + add constraint whatsapp_messages_whatsapp_bot_id_fkey foreign key ("whatsapp_bot_id") references app_public.whatsapp_bots on delete cascade; + +alter table app_public.whatsapp_messages + enable row level security; + +create policy access_all on app_public.whatsapp_messages to app_admin using (true); +grant update (whatsapp_bot_id, wa_message_id, wa_message, wa_timestamp) on app_public.whatsapp_messages to app_admin; +grant select on app_public.whatsapp_messages to app_admin; +grant insert (whatsapp_bot_id, wa_message_id, wa_message, wa_timestamp) on app_public.whatsapp_messages to app_admin; + +create trigger _100_timestamps + before insert or update + on app_public.whatsapp_messages + for each row +execute procedure app_private.tg__timestamps(); + +-- endregion +-- region WhatsappAttachments + +create table app_public.whatsapp_attachments +( + id uuid not null default uuid_generate_v1mc() primary key, + whatsapp_bot_id uuid not null, + whatsapp_message_id uuid, + attachment bytea, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + +); + +create unique index whatsapp_attachment_whatsapp_bot_id on app_public.whatsapp_attachments(whatsapp_bot_id); +create unique index whatsapp_attachment_whatsapp_message_id on app_public.whatsapp_attachments(whatsapp_message_id); + +alter table app_public.whatsapp_attachments + add constraint whatsapp_attachments_whatsapp_bot_id_fkey foreign key ("whatsapp_bot_id") references app_public.whatsapp_bots on delete cascade; +alter table app_public.whatsapp_attachments + add constraint whatsapp_attachments_whatsapp_message_id_fkey foreign key ("whatsapp_message_id") references app_public.whatsapp_messages on delete cascade; + +alter table app_public.whatsapp_attachments + enable row level security; + +create policy access_all on app_public.whatsapp_attachments to app_admin using (true); +grant update (whatsapp_bot_id, whatsapp_message_id, attachment) on app_public.whatsapp_attachments to app_admin; +grant select on app_public.whatsapp_attachments to app_admin; +grant insert (whatsapp_bot_id, whatsapp_message_id, attachment) on app_public.whatsapp_attachments to app_admin; + +create trigger _100_timestamps + before insert or update + on app_public.whatsapp_attachments + for each row +execute procedure app_private.tg__timestamps(); + +-- endregion + +-- region SignalBots +set transform_null_equals to true; +create table app_public.signal_bots +( + id uuid not null default uuid_generate_v1mc() primary key, + phone_number text not null, + token uuid not null default uuid_generate_v1mc(), + user_id uuid not null, + description text, + auth_info text, + is_verified boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + +); + +create unique index signal_bot_token on app_public.signal_bots(token); + +alter table app_public.signal_bots + add constraint signal_bots_user_id_fkey foreign key ("user_id") references app_public.users on delete cascade; + +alter table app_public.signal_bots + enable row level security; + +create policy access_all on app_public.signal_bots to app_admin using (true); +grant update (phone_number, token, user_id, description, auth_info, is_verified) on app_public.signal_bots to app_admin; +grant select on app_public.signal_bots to app_admin; +grant insert (phone_number, token, user_id, description, auth_info, is_verified) on app_public.signal_bots to app_admin; + +create trigger _100_timestamps + before insert or update + on app_public.signal_bots + for each row +execute procedure app_private.tg__timestamps(); + + +-- endregion diff --git a/metamigo-db/migrations/committed/000002.sql b/metamigo-db/migrations/committed/000002.sql new file mode 100644 index 0000000..56bcfd1 --- /dev/null +++ b/metamigo-db/migrations/committed/000002.sql @@ -0,0 +1,10 @@ +--! Previous: sha1:b13a5217288f5d349d8d9e3afbd7bb30c0dbad21 +--! Hash: sha1:8659f815ff013a793f2e01113a9a61a98c7bd8d5 + +-- Enter migration here + +drop table if exists app_public.whatsapp_attachments cascade; +drop table if exists app_public.whatsapp_messages cascade; + +grant delete on app_public.whatsapp_bots to app_admin; +grant delete on app_public.signal_bots to app_admin; diff --git a/metamigo-db/migrations/current.sql b/metamigo-db/migrations/current.sql new file mode 100644 index 0000000..8da5339 --- /dev/null +++ b/metamigo-db/migrations/current.sql @@ -0,0 +1 @@ +-- Enter migration here diff --git a/metamigo-db/package.json b/metamigo-db/package.json new file mode 100644 index 0000000..ebfbf0f --- /dev/null +++ b/metamigo-db/package.json @@ -0,0 +1,39 @@ +{ + "name": "db", + "private": true, + "version": "0.2.0", + "main": "build/main/db/src/index.js", + "author": "Abel Luck ", + "license": "AGPL-3.0-or-later", + "dependencies": { + "graphile-migrate": "^1.4.1" + }, + "devDependencies": { + "common": "0.2.5", + "@babel/core": "7.20.12", + "@babel/preset-env": "7.20.2", + "@babel/preset-typescript": "7.18.6", + "@types/jest": "^29.2.5", + "eslint": "^8.32.0", + "jest": "^29.3.1", + "jest-junit": "^15.0.0", + "pino-pretty": "^9.1.1", + "prettier": "^2.8.3", + "ts-node": "^10.9.1", + "typedoc": "^0.23.24", + "typescript": "4.9.4" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "build-test": "tsc -p tsconfig.json", + "doc:html": "typedoc src/ --exclude '**/*.test.ts' --exclude '**/*.spec.ts' --name $npm_package_name --readme README.md --target es2019 --mode file --out build/docs", + "doc": "yarn run doc:html", + "fix:lint": "eslint src --ext .ts --fix", + "fix:prettier": "prettier \"src/**/*.ts\" --write", + "worker": "NODE_ENV=development yarn cli worker", + "lint:lint": "eslint src --ext .ts", + "lint:prettier": "prettier \"src/**/*.ts\" --list-different", + "lint": "npm run lint:lint && npm run lint:prettier", + "watch:build": "tsc -p tsconfig.json -w" + } +} diff --git a/metamigo-db/records/index.ts b/metamigo-db/records/index.ts new file mode 100644 index 0000000..da3e9e3 --- /dev/null +++ b/metamigo-db/records/index.ts @@ -0,0 +1,9 @@ +export * from "./settings"; +export * from "./signal/bots"; +export * from "./whatsapp/bots"; +export * from "./whatsapp/messages"; +export * from "./whatsapp/attachments"; +export * from "./settings"; +export * from "./voice/voice-line"; +export * from "./voice/voice-provider"; +export * from "./webhooks"; diff --git a/metamigo-db/records/settings.ts b/metamigo-db/records/settings.ts new file mode 100644 index 0000000..2213a2f --- /dev/null +++ b/metamigo-db/records/settings.ts @@ -0,0 +1,104 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-unused-vars,@typescript-eslint/no-explicit-any,prefer-destructuring */ +import { RepositoryBase, recordInfo, UUID, Flavor } from "common"; + +export type SettingId = Flavor; + +export interface UnsavedSetting { + name: string; + value: T; +} + +export interface SavedSetting extends UnsavedSetting { + id: SettingId; + createdAt: Date; + updatedAt: Date; +} + +export const SettingRecord = recordInfo, SavedSetting>( + "app_public", + "settings" +); + +export class SettingRecordRepository extends RepositoryBase(SettingRecord) { + async findByName(name: string): Promise | null> { + return this.db.oneOrNone("SELECT * FROM $1 $2:raw LIMIT 1", [ + this.schemaTable, + this.where({ name }), + ]); + } + + async upsert(name: string, value: T): Promise> { + return this.db.one( + `INSERT INTO $1 ($2:name) VALUES ($2:csv) + ON CONFLICT (name) + DO UPDATE SET value = EXCLUDED.value RETURNING *`, + [this.schemaTable, this.columnize({ name, value })] + ); + } +} + +// these helpers let us create type safe setting constants +export interface SettingType { + _type: T; +} + +export interface SettingInfo extends SettingType { + name: string; +} + +export function castToSettingInfo( + runtimeData: Omit +): SettingInfo { + return runtimeData as SettingInfo; +} + +export function settingInfo(name: string): SettingInfo; + +// don't use this signature, use the explicit typed signature +export function settingInfo(name: string) { + return castToSettingInfo({ + name, + }); +} + +export interface ISettingsService { + name: string; + lookup(settingInfo: SettingInfo): Promise; + save(settingInfo: SettingInfo, value: T): Promise; +} + +export const SettingsService = ( + repo: SettingRecordRepository +): ISettingsService => ({ + name: "settingService", + lookup: async (settingInfo: SettingInfo): Promise => { + const s = await repo.findByName(settingInfo.name); + return s.value; + }, + + save: async (settingInfo: SettingInfo, value: T): Promise => { + const s = await repo.upsert(settingInfo.name, value); + return s.value; + }, +}); + +const _test = async () => { + // here is an example of how to use this module + // it also serves as a compile-time test case + const repo = new SettingRecordRepository({} as any); + + // create your own custom setting types! + // the value is serialized as json in the database + type Custom = { foo: string; bar: string }; + type CustomUnsavedSetting = UnsavedSetting; + type CustomSetting = SavedSetting; + + const s3: CustomSetting = await repo.findByName("test"); + + const customValue = { foo: "monkeys", bar: "eggplants" }; + let customSetting = { name: "custom", value: customValue }; + customSetting = await repo.insert(customSetting); + const value: Custom = customSetting.value; + + const MySetting = settingInfo("my-setting"); +}; diff --git a/metamigo-db/records/signal/bots.ts b/metamigo-db/records/signal/bots.ts new file mode 100644 index 0000000..8061020 --- /dev/null +++ b/metamigo-db/records/signal/bots.ts @@ -0,0 +1,35 @@ +import { RepositoryBase, recordInfo, UUID, Flavor } from "common"; + +export type SignalBotId = Flavor; + +export interface UnsavedSignalBot { + phoneNumber: string; + userId: string; + description: string; +} + +export interface SavedSignalBot extends UnsavedSignalBot { + id: SignalBotId; + createdAt: Date; + updatedAt: Date; + token: string; + authInfo: string; + isVerified: boolean; +} + +export const SignalBotRecord = recordInfo( + "app_public", + "signal_bots" +); + +export class SignalBotRecordRepository extends RepositoryBase(SignalBotRecord) { + async updateAuthInfo( + bot: SavedSignalBot, + authInfo: string | undefined + ): Promise { + return this.db.one( + "UPDATE $1 SET (auth_info, is_verified) = ROW($2, true) WHERE id = $3 RETURNING *", + [this.schemaTable, authInfo, bot.id] + ); + } +} diff --git a/metamigo-db/records/voice/voice-line.ts b/metamigo-db/records/voice/voice-line.ts new file mode 100644 index 0000000..2cae4eb --- /dev/null +++ b/metamigo-db/records/voice/voice-line.ts @@ -0,0 +1,62 @@ +import { + RepositoryBase, + recordInfo, + UUID, + Flavor, +} from "common"; +import type { } from "pg-promise"; + +export type VoiceLineId = Flavor; + +export type VoiceLineAudio = { + "audio/webm": string; + "audio/mpeg"?: string; + checksum?: string; +}; + +export interface UnsavedVoiceLine { + providerId: string; + providerLineSid: string; + number: string; + language: string; + voice: string; + promptText?: string; + promptAudio?: VoiceLineAudio; + audioPromptEnabled: boolean; + audioConvertedAt?: Date; +} + +export interface SavedVoiceLine extends UnsavedVoiceLine { + id: VoiceLineId; + createdAt: Date; + updatedAt: Date; +} + +export const VoiceLineRecord = recordInfo( + "app_public", + "voice_lines" +); + +export class VoiceLineRecordRepository extends RepositoryBase(VoiceLineRecord) { + /** + * Fetch all voice lines given the numbers + * @param numbers + */ + async findAllByNumbers(numbers: string[]): Promise { + return this.db.any( + "SELECT id,provider_id,provider_line_sid,number FROM $1 WHERE number in ($2:csv)", + [this.schemaTable, numbers] + ); + } + + /** + * Fetch all voice lines given a list of provider line ids + * @param ids + */ + async findAllByProviderLineSids(ids: string[]): Promise { + return this.db.any( + "SELECT id,provider_id,provider_line_sid,number FROM $1 WHERE provider_line_sid in ($2:csv)", + [this.schemaTable, ids] + ); + } +} diff --git a/metamigo-db/records/voice/voice-provider.ts b/metamigo-db/records/voice/voice-provider.ts new file mode 100644 index 0000000..1d32a89 --- /dev/null +++ b/metamigo-db/records/voice/voice-provider.ts @@ -0,0 +1,52 @@ +import { RepositoryBase, recordInfo, UUID, Flavor } from "common"; + +/* + * VoiceProvider + * + * A provider is a company that provides incoming voice call services + */ + +export type VoiceProviderId = Flavor; + +export enum VoiceProviderKinds { + TWILIO = "TWILIO", +} + +export type TwilioCredentials = { + accountSid: string; + apiKeySid: string; + apiKeySecret: string; +}; + +// expand this type later when we support more providers +export type VoiceProviderCredentials = TwilioCredentials; + +export interface UnsavedVoiceProvider { + kind: VoiceProviderKinds; + name: string; + credentials: VoiceProviderCredentials; +} + +export interface SavedVoiceProvider extends UnsavedVoiceProvider { + id: VoiceProviderId; + createdAt: Date; + updatedAt: Date; +} + +export const VoiceProviderRecord = recordInfo< + UnsavedVoiceProvider, + SavedVoiceProvider +>("app_public", "voice_providers"); + +export class VoiceProviderRecordRepository extends RepositoryBase( + VoiceProviderRecord +) { + async findByTwilioAccountSid( + accountSid: string + ): Promise { + return this.db.oneOrNone( + "select * from $1 where credentials->>'accountSid' = $2", + [this.schemaTable, accountSid] + ); + } +} diff --git a/metamigo-db/records/webhooks.ts b/metamigo-db/records/webhooks.ts new file mode 100644 index 0000000..4b6a226 --- /dev/null +++ b/metamigo-db/records/webhooks.ts @@ -0,0 +1,50 @@ +import { + RepositoryBase, + recordInfo, + UUID, + Flavor, +} from "common"; + +/* + * Webhook + * + * A webhook allows external services to be notified when a recorded call is available + */ + +export type WebhookId = Flavor; + +export interface HttpHeaders { + header: string; + value: string; +} + +export interface UnsavedWebhook { + name: string; + voiceLineId: string; + endpointUrl: string; + httpMethod: "post" | "put"; + headers?: HttpHeaders[]; +} + +export interface SavedWebhook extends UnsavedWebhook { + id: WebhookId; + createdAt: Date; + updatedAt: Date; +} + +export const WebhookRecord = recordInfo( + "app_public", + "webhooks" +); + +export class WebhookRecordRepository extends RepositoryBase(WebhookRecord) { + async findAllByBackendId( + backendType: string, + backendId: string + ): Promise { + return this.db.any( + "select * from $1 where backend_type = $2 and backend_id = $3", + [this.schemaTable, backendType, backendId] + ); + } +} diff --git a/metamigo-db/records/whatsapp/attachments.ts b/metamigo-db/records/whatsapp/attachments.ts new file mode 100644 index 0000000..c1ce3a7 --- /dev/null +++ b/metamigo-db/records/whatsapp/attachments.ts @@ -0,0 +1,24 @@ +import { RepositoryBase, recordInfo, UUID, Flavor } from "common"; + +export type WhatsappAttachmentId = Flavor; + +export interface UnsavedWhatsappAttachment { + whatsappBotId: string; + whatsappMessageId: string; + attachment: Buffer; +} + +export interface SavedWhatsappAttachment extends UnsavedWhatsappAttachment { + id: WhatsappAttachmentId; + createdAt: Date; + updatedAt: Date; +} + +export const WhatsappAttachmentRecord = recordInfo< + UnsavedWhatsappAttachment, + SavedWhatsappAttachment +>("app_public", "whatsapp_attachments"); + +export class WhatsappAttachmentRecordRepository extends RepositoryBase( + WhatsappAttachmentRecord +) { } diff --git a/metamigo-db/records/whatsapp/bots.ts b/metamigo-db/records/whatsapp/bots.ts new file mode 100644 index 0000000..b21b7a5 --- /dev/null +++ b/metamigo-db/records/whatsapp/bots.ts @@ -0,0 +1,48 @@ +import { RepositoryBase, recordInfo, UUID, Flavor } from "common"; + +export type WhatsappBotId = Flavor; + +export interface UnsavedWhatsappBot { + phoneNumber: string; + userId: string; + description: string; +} + +export interface SavedWhatsappBot extends UnsavedWhatsappBot { + id: WhatsappBotId; + createdAt: Date; + updatedAt: Date; + token: string; + authInfo: string; + qrCode: string; + isVerified: boolean; +} + +export const WhatsappBotRecord = recordInfo< + UnsavedWhatsappBot, + SavedWhatsappBot +>("app_public", "whatsapp_bots"); + +export class WhatsappBotRecordRepository extends RepositoryBase( + WhatsappBotRecord +) { + async updateQR( + bot: SavedWhatsappBot, + qrCode: string | undefined + ): Promise { + return this.db.one( + "UPDATE $1 SET (qr_code) = ROW($2) WHERE id = $3 RETURNING *", + [this.schemaTable, qrCode, bot.id] + ); + } + + async updateAuthInfo( + bot: SavedWhatsappBot, + authInfo: string | undefined + ): Promise { + return this.db.one( + "UPDATE $1 SET (auth_info, is_verified) = ROW($2, true) WHERE id = $3 RETURNING *", + [this.schemaTable, authInfo, bot.id] + ); + } +} diff --git a/metamigo-db/records/whatsapp/messages.ts b/metamigo-db/records/whatsapp/messages.ts new file mode 100644 index 0000000..96fcccb --- /dev/null +++ b/metamigo-db/records/whatsapp/messages.ts @@ -0,0 +1,26 @@ +import { RepositoryBase, recordInfo, UUID, Flavor } from "common"; + +export type WhatsappMessageId = Flavor; + +export interface UnsavedWhatsappMessage { + whatsappBotId: string; + waMessageId: string; + waTimestamp: Date; + waMessage: string; + attachments?: string[]; +} + +export interface SavedWhatsappMessage extends UnsavedWhatsappMessage { + id: WhatsappMessageId; + createdAt: Date; + updatedAt: Date; +} + +export const WhatsappMessageRecord = recordInfo< + UnsavedWhatsappMessage, + SavedWhatsappMessage +>("app_public", "whatsapp_messages"); + +export class WhatsappMessageRecordRepository extends RepositoryBase( + WhatsappMessageRecord +) { } diff --git a/metamigo-db/scripts/afterCurrent.sh b/metamigo-db/scripts/afterCurrent.sh new file mode 100755 index 0000000..43b830e --- /dev/null +++ b/metamigo-db/scripts/afterCurrent.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +set -eu + +psql -Xv ON_ERROR_STOP=1 "${GM_DBURL}" < { + if (o.ok) return Promise.resolve(); + return Promise.reject(); + }, + logout: async () => { + const session = await getSession(); + if (session) { + await signOut(); + } + }, + checkError: (e: any) => { + if (e.graphQLErrors && e.graphQLErrors.length > 0) { + const permDenied = + e.graphQLErrors.filter((e: any) => + e.message.match(/.*permission denied.*/) + ).length > 0; + if (permDenied) + // eslint-disable-next-line prefer-promise-reject-errors + return Promise.reject({ message: "auth.permissionDenied" }); + } + + if (e.networkError && e.networkError.statusCode === 401) { + return Promise.reject(); + } + }, + checkAuth: async () => { + const session = await getSession(); + if (!session) { + return Promise.reject(); + } + }, + getIdentity: async () => { + const session = await getSession(); + if (!session) return Promise.reject(new Error("Invalid session")); + + return { + id: session.user?.email, + fullName: session.user?.name, + avatar: session.user?.image, + }; + }, + getPermissions: () => Promise.resolve(), +}; + +export const AdminLogin: FC = () => { + const reactAdminLogin = useLogin(); + const translate = useTranslate(); + + useEffect(() => { + (async () => { + const session = await getSession(); + if (!session) { + signIn(); + } else { + reactAdminLogin({ ok: true }); + } + })(); + }); + + return ( + + + + {translate("auth.loggingIn")} + + + + + + + ); +}; diff --git a/metamigo-frontend/components/Auth.tsx b/metamigo-frontend/components/Auth.tsx new file mode 100644 index 0000000..9828f8a --- /dev/null +++ b/metamigo-frontend/components/Auth.tsx @@ -0,0 +1,20 @@ +import { FC, PropsWithChildren, useEffect } from "react"; +import { CircularProgress } from "@material-ui/core"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/router"; + +export const Auth: FC = ({ children }) => { + const router = useRouter(); + const { data: session, status: loading } = useSession(); + useEffect(() => { + if (!session && !loading) { + router.push("/login"); + } + }, [session, loading]); + + if (loading) { + return ; + } + + return <>{children}; +}; diff --git a/metamigo-frontend/components/DigitInput/DigitInput.module.css b/metamigo-frontend/components/DigitInput/DigitInput.module.css new file mode 100644 index 0000000..270fc93 --- /dev/null +++ b/metamigo-frontend/components/DigitInput/DigitInput.module.css @@ -0,0 +1,27 @@ +.input { + width: 40px; + height: 60px; + margin: 5px; + font-size: 1.4rem; + padding: 0 9px 0 12px; + border: none; + outline: none; + background: white; + font-weight: 400; + color: rgba(59, 59, 59, 0.788); + -webkit-box-shadow: 2px 2px 2px 1px #d6d6d6, -1px -1px 1px #e6e6e6; + box-shadow: 2px 2px 2px 1px #d6d6d6, -1px -1px 1px #e6e6e6; + border-radius: 5px; +} + +.group { + margin-top: 1.6rem; +} + +.hyphen { + background: black; + height: 0.1em; + width: 1em; + margin: 0 0.5em; + display: inline-block; +} diff --git a/metamigo-frontend/components/DigitInput/index.tsx b/metamigo-frontend/components/DigitInput/index.tsx new file mode 100644 index 0000000..b11aade --- /dev/null +++ b/metamigo-frontend/components/DigitInput/index.tsx @@ -0,0 +1,60 @@ +import { forwardRef } from "react"; +import useDigitInput, { InputAttributes } from "react-digit-input"; +import styles from "./DigitInput.module.css"; + +const DigitInputElement = forwardRef< + HTMLInputElement, + Omit & { + autoFocus?: boolean; + } +>(({ ...props }, ref) => { + return ( + <> + + + ); +}); + +const DigitSeparator = forwardRef< + HTMLInputElement, + Omit & { + autoFocus?: boolean; + } +>(({ ...props }, ref) => { + return ( + <> + + + ); +}); + +export const SixDigitInput = ({ value, onChange }: any) => { + const digits = useDigitInput({ + acceptedCharacters: /^[0-9]$/, + length: 6, + value, + onChange, + }); + return ( +
+
+ + + + + + + +
+ +
+ ); +}; diff --git a/metamigo-frontend/components/MetamigoAdmin.tsx b/metamigo-frontend/components/MetamigoAdmin.tsx new file mode 100644 index 0000000..5929f2d --- /dev/null +++ b/metamigo-frontend/components/MetamigoAdmin.tsx @@ -0,0 +1,64 @@ +import { FC, useEffect, useState } from "react"; +import { Admin, Resource } from "react-admin"; +import { useApolloClient } from "@apollo/client"; +import polyglotI18nProvider from "ra-i18n-polyglot"; +import { ThemeProvider, createMuiTheme } from "@material-ui/core/styles"; +import { metamigoDataProvider } from "../lib/dataprovider"; +import { theme } from "./layout/themes"; +import { Layout } from "./layout"; +import englishMessages from "../i18n/en"; +import users from "./users"; +import accounts from "./accounts"; +import whatsappBots from "./whatsapp/bots"; +import whatsappMessages from "./whatsapp/messages"; +import whatsappAttachments from "./whatsapp/attachments"; +import voiceLines from "./voice/voicelines"; +import signalBots from "./signal/bots"; +import voiceProviders from "./voice/providers"; +import webhooks from "./webhooks"; +import { AdminLogin, authProvider } from "./AdminLogin"; + +const i18nProvider = polyglotI18nProvider((_locale) => { + return englishMessages; +}, "en"); + +const MetamigoAdmin: FC = () => { + const [dataProvider, setDataProvider] = useState(null); + const client = useApolloClient(); + + useEffect(() => { + (async () => { + const dataProvider = await metamigoDataProvider(client); + // @ts-ignore + setDataProvider(() => dataProvider); + })(); + }, [client]); + return ( + dataProvider && ( + + + + + + + + + + + + + + + ) + ); +}; + +export default MetamigoAdmin; diff --git a/metamigo-frontend/components/accounts/AccountEdit.tsx b/metamigo-frontend/components/accounts/AccountEdit.tsx new file mode 100644 index 0000000..bdbc463 --- /dev/null +++ b/metamigo-frontend/components/accounts/AccountEdit.tsx @@ -0,0 +1,59 @@ +import { FC } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import { + SimpleForm, + TextInput, + Edit, + ReferenceInput, + SelectInput, + DateInput, + Toolbar, + DeleteButton, + EditProps, +} from "react-admin"; +import { useSession } from "next-auth/react"; + +const useStyles = makeStyles((_theme) => ({ + defaultToolbar: { + flex: 1, + display: "flex", + justifyContent: "space-between", + }, +})); + +type AccountEditToolbarProps = { + record?: any; +}; + +const AccountEditToolbar: FC = (props) => { + const { data: session } = useSession(); + const classes = useStyles(props); + return ( + + + + ); +}; + +const AccountTitle = ({ record }: { record?: any }) => { + let title = ""; + if (record) title = record.name ? record.name : record.email; + return Account {title}; +}; + +export const AccountEdit = (props: EditProps) => ( + } {...props}> + }> + + + + + + + + + + + +); +export default AccountEdit; diff --git a/metamigo-frontend/components/accounts/AccountList.tsx b/metamigo-frontend/components/accounts/AccountList.tsx new file mode 100644 index 0000000..1ce42bf --- /dev/null +++ b/metamigo-frontend/components/accounts/AccountList.tsx @@ -0,0 +1,43 @@ +import { FC } from "react"; +import { + List, + Datagrid, + DateField, + TextField, + ReferenceField, + DeleteButton, + ListProps, +} from "react-admin"; +import { useSession } from "next-auth/react"; + +type DeleteNotSelfButtonProps = { + record?: any; +}; + +const DeleteNotSelfButton: FC = (props) => { + const { data: session } = useSession(); + return ( + + ); +}; + +export const AccountList = (props: ListProps) => ( + + + + + + + + + + + + + +); + +export default AccountList; diff --git a/metamigo-frontend/components/accounts/index.ts b/metamigo-frontend/components/accounts/index.ts new file mode 100644 index 0000000..e9af8d6 --- /dev/null +++ b/metamigo-frontend/components/accounts/index.ts @@ -0,0 +1,10 @@ +import AccountIcon from "@material-ui/icons/AccountTree"; +import AccountList from "./AccountList"; +import AccountEdit from "./AccountEdit"; + +// eslint-disable-next-line import/no-anonymous-default-export +export default { + list: AccountList, + edit: AccountEdit, + icon: AccountIcon, +}; diff --git a/metamigo-frontend/components/layout/AppBar.tsx b/metamigo-frontend/components/layout/AppBar.tsx new file mode 100644 index 0000000..6574117 --- /dev/null +++ b/metamigo-frontend/components/layout/AppBar.tsx @@ -0,0 +1,54 @@ +import { forwardRef } from "react"; +import { AppBar, UserMenu, MenuItemLink, useTranslate } from "react-admin"; +import Typography from "@material-ui/core/Typography"; +import SettingsIcon from "@material-ui/icons/Settings"; +import { makeStyles } from "@material-ui/core/styles"; + +const useStyles = makeStyles({ + title: { + flex: 1, + textOverflow: "ellipsis", + whiteSpace: "nowrap", + overflow: "hidden", + }, + spacer: { + flex: 1, + }, +}); + +const ConfigurationMenu = forwardRef((props, ref) => { + const translate = useTranslate(); + return ( + } + onClick={props.onClick} + sidebarIsOpen + /> + ); +}); + +const CustomUserMenu = (props: any) => ( + + + +); + +const CustomAppBar = (props: any) => { + const classes = useStyles(); + return ( + }> + + + + ); +}; + +export default CustomAppBar; diff --git a/metamigo-frontend/components/layout/Layout.tsx b/metamigo-frontend/components/layout/Layout.tsx new file mode 100644 index 0000000..94db44a --- /dev/null +++ b/metamigo-frontend/components/layout/Layout.tsx @@ -0,0 +1,21 @@ +import { Layout as RaLayout, LayoutProps, Sidebar } from "react-admin"; +import AppBar from "./AppBar"; +import Menu from "./Menu"; +import { theme } from "./themes"; + +const CustomSidebar = (props: any) => ; + +const Layout = (props: LayoutProps) => { + return ( + + ); +}; + +export default Layout; diff --git a/metamigo-frontend/components/layout/Logo.tsx b/metamigo-frontend/components/layout/Logo.tsx new file mode 100644 index 0000000..6ea0d4a --- /dev/null +++ b/metamigo-frontend/components/layout/Logo.tsx @@ -0,0 +1,106 @@ +import { SVGProps } from "react"; + +const Logo = (props: SVGProps) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default Logo; diff --git a/metamigo-frontend/components/layout/Menu.tsx b/metamigo-frontend/components/layout/Menu.tsx new file mode 100644 index 0000000..837f9af --- /dev/null +++ b/metamigo-frontend/components/layout/Menu.tsx @@ -0,0 +1,133 @@ +/* eslint-disable camelcase */ +import { FC, useState } from "react"; +import { useSelector } from "react-redux"; +import SecurityIcon from "@material-ui/icons/Security"; +import VoiceIcon from "@material-ui/icons/PhoneInTalk"; +import { Box } from "@material-ui/core"; +import { useTheme } from "@material-ui/core/styles"; +import useMediaQuery from "@material-ui/core/useMediaQuery"; +import { useTranslate, MenuItemLink, MenuProps } from "react-admin"; +import users from "../users"; +import accounts from "../accounts"; +import webhooks from "../webhooks"; +import voiceLines from "../voice/voicelines"; +import voiceProviders from "../voice/providers"; +import whatsappBots from "../whatsapp/bots"; +import signalBots from "../signal/bots"; +import { SubMenu } from "./SubMenu"; + +type MenuName = "menuVoice" | "menuSecurity"; + +export const Menu: FC = ({ onMenuClick, logout, dense = false }: any) => { + const [state, setState] = useState({ + menuVoice: false, + menuSecurity: false, + }); + const translate = useTranslate(); + const theme = useTheme(); + const isXSmall = useMediaQuery(theme.breakpoints.down("xs")); + const open = useSelector((state: any) => state.admin.ui.sidebarOpen); + useSelector((state: any) => state.theme); // force rerender on theme change + + const handleToggle = (menu: MenuName) => { + setState((state) => ({ ...state, [menu]: !state[menu] })); + }; + + return
; +}; +/* + + } + onClick={onMenuClick} + sidebarIsOpen={open} + dense={dense} + /> + } + onClick={onMenuClick} + sidebarIsOpen={open} + dense={dense} + /> + handleToggle("menuVoice")} + isOpen={state.menuVoice} + sidebarIsOpen={open} + name="pos.menu.voice" + icon={} + dense={dense} + > + } + onClick={onMenuClick} + sidebarIsOpen={open} + dense={dense} + /> + } + onClick={onMenuClick} + sidebarIsOpen={open} + dense={dense} + /> + + } + onClick={onMenuClick} + sidebarIsOpen={open} + dense={dense} + /> + handleToggle("menuSecurity")} + isOpen={state.menuSecurity} + sidebarIsOpen={open} + name="pos.menu.security" + icon={} + dense={dense} + > + } + onClick={onMenuClick} + sidebarIsOpen={open} + dense={dense} + /> + } + onClick={onMenuClick} + sidebarIsOpen={open} + dense={dense} + /> + + {isXSmall && logout} + + ); +}; +*/ +export default Menu; diff --git a/metamigo-frontend/components/layout/SubMenu.tsx b/metamigo-frontend/components/layout/SubMenu.tsx new file mode 100644 index 0000000..98f524e --- /dev/null +++ b/metamigo-frontend/components/layout/SubMenu.tsx @@ -0,0 +1,83 @@ +import { FC, PropsWithChildren, Fragment, ReactElement } from "react"; +import ExpandMore from "@material-ui/icons/ExpandMore"; +import List from "@material-ui/core/List"; +import MenuItem from "@material-ui/core/MenuItem"; +import ListItemIcon from "@material-ui/core/ListItemIcon"; +import Typography from "@material-ui/core/Typography"; +import Collapse from "@material-ui/core/Collapse"; +import Tooltip from "@material-ui/core/Tooltip"; +import { makeStyles } from "@material-ui/core/styles"; +import { useTranslate } from "react-admin"; + +const useStyles = makeStyles((theme) => ({ + icon: { minWidth: theme.spacing(5) }, + sidebarIsOpen: { + "& a": { + paddingLeft: theme.spacing(4), + transition: "padding-left 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms", + }, + }, + sidebarIsClosed: { + "& a": { + paddingLeft: theme.spacing(2), + transition: "padding-left 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms", + }, + }, +})); + +type SubMenuProps = PropsWithChildren<{ + dense: boolean; + handleToggle: () => void; + icon: ReactElement; + isOpen: boolean; + name: string; + sidebarIsOpen: boolean; +}>; + +export const SubMenu: FC = ({ + handleToggle, + sidebarIsOpen, + isOpen, + name, + icon, + children, + dense, +}) => { + const translate = useTranslate(); + const classes = useStyles(); + + const header = ( + + + {isOpen ? : icon} + + + {translate(name)} + + + ); + + return ( + + {sidebarIsOpen || isOpen ? ( + header + ) : ( + + {header} + + )} + + + {children} + + + + ); +}; diff --git a/metamigo-frontend/components/layout/index.ts b/metamigo-frontend/components/layout/index.ts new file mode 100644 index 0000000..61652af --- /dev/null +++ b/metamigo-frontend/components/layout/index.ts @@ -0,0 +1,5 @@ +import AppBar from "./AppBar"; +import Layout from "./Layout"; +import Menu from "./Menu"; + +export { AppBar, Layout, Menu }; diff --git a/metamigo-frontend/components/layout/themes.ts b/metamigo-frontend/components/layout/themes.ts new file mode 100644 index 0000000..6e5352c --- /dev/null +++ b/metamigo-frontend/components/layout/themes.ts @@ -0,0 +1,71 @@ +export const theme = { + palette: { + primary: { + main: "#337799", + }, + secondary: { + light: "#5f5fc4", + main: "#283593", + dark: "#001064", + contrastText: "#fff", + }, + background: { + default: "#fff", + }, + }, + shape: { + borderRadius: 5, + }, + typography: { + h6: { fontSize: 16, fontWeight: 600, color: "#1bb1bb" }, + }, + overrides: { + RaMenuItemLink: { + root: { + borderLeft: "3px solid #fff", + }, + active: { + borderLeft: "3px solid #ef7706", + }, + }, + MuiPaper: { + elevation1: { + boxShadow: "none", + }, + root: { + border: "1px solid #e0e0e3", + backgroundClip: "padding-box", + }, + }, + MuiButton: { + contained: { + backgroundColor: "#fff", + color: "#4f3cc9", + boxShadow: "none", + }, + }, + MuiAppBar: { + colorSecondary: { + color: "#fff", + backgroundColor: "#337799", + border: 0, + }, + }, + MuiLinearProgress: { + colorPrimary: { + backgroundColor: "#f5f5f5", + }, + barColorPrimary: { + backgroundColor: "#d7d7d7", + }, + }, + MuiFilledInput: { + root: { + backgroundColor: "rgba(0, 0, 0, 0.04)", + "&$disabled": { + backgroundColor: "rgba(0, 0, 0, 0.04)", + }, + }, + }, + }, +}; diff --git a/metamigo-frontend/components/signal/bots/Digits.module.css b/metamigo-frontend/components/signal/bots/Digits.module.css new file mode 100644 index 0000000..270fc93 --- /dev/null +++ b/metamigo-frontend/components/signal/bots/Digits.module.css @@ -0,0 +1,27 @@ +.input { + width: 40px; + height: 60px; + margin: 5px; + font-size: 1.4rem; + padding: 0 9px 0 12px; + border: none; + outline: none; + background: white; + font-weight: 400; + color: rgba(59, 59, 59, 0.788); + -webkit-box-shadow: 2px 2px 2px 1px #d6d6d6, -1px -1px 1px #e6e6e6; + box-shadow: 2px 2px 2px 1px #d6d6d6, -1px -1px 1px #e6e6e6; + border-radius: 5px; +} + +.group { + margin-top: 1.6rem; +} + +.hyphen { + background: black; + height: 0.1em; + width: 1em; + margin: 0 0.5em; + display: inline-block; +} diff --git a/metamigo-frontend/components/signal/bots/SignalBotCreate.tsx b/metamigo-frontend/components/signal/bots/SignalBotCreate.tsx new file mode 100644 index 0000000..a1981d3 --- /dev/null +++ b/metamigo-frontend/components/signal/bots/SignalBotCreate.tsx @@ -0,0 +1,34 @@ +import { + SimpleForm, + Create, + TextInput, + required, + CreateProps, +} from "react-admin"; +import { useSession } from "next-auth/react"; +import { validateE164Number } from "../../../lib/phone-numbers"; + +const SignalBotCreate = (props: CreateProps) => { + const { data: session } = useSession(); + + return ( + + + + + + + + ); +}; + +export default SignalBotCreate; diff --git a/metamigo-frontend/components/signal/bots/SignalBotEdit.tsx b/metamigo-frontend/components/signal/bots/SignalBotEdit.tsx new file mode 100644 index 0000000..8ac33dd --- /dev/null +++ b/metamigo-frontend/components/signal/bots/SignalBotEdit.tsx @@ -0,0 +1,12 @@ +import { SimpleForm, Edit, TextInput, required, EditProps } from "react-admin"; + +const SignalBotEdit = (props: EditProps) => ( + + + + + + +); + +export default SignalBotEdit; diff --git a/metamigo-frontend/components/signal/bots/SignalBotList.tsx b/metamigo-frontend/components/signal/bots/SignalBotList.tsx new file mode 100644 index 0000000..f7b9e19 --- /dev/null +++ b/metamigo-frontend/components/signal/bots/SignalBotList.tsx @@ -0,0 +1,23 @@ +import { + List, + Datagrid, + DateField, + TextField, + BooleanField, + ListProps, +} from "react-admin"; + +const SignalBotList = (props: ListProps) => ( + + + + + + + + + + +); + +export default SignalBotList; diff --git a/metamigo-frontend/components/signal/bots/SignalBotShow.tsx b/metamigo-frontend/components/signal/bots/SignalBotShow.tsx new file mode 100644 index 0000000..254b3f0 --- /dev/null +++ b/metamigo-frontend/components/signal/bots/SignalBotShow.tsx @@ -0,0 +1,474 @@ +import React, { useState } from "react"; +import { + Show, + SimpleShowLayout, + BooleanField, + TextField, + ShowProps, + EditButton, + TopToolbar, + useTranslate, + useRefresh, +} from "react-admin"; +import { + TextField as MuiTextField, + Button, + Card, + Grid, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Typography, + Box, + CircularProgress, +} from "@material-ui/core"; +import { SixDigitInput } from "../../DigitInput"; +import { + sanitizeE164Number, + isValidE164Number, +} from "../../../lib/phone-numbers"; + +const Sidebar = ({ record }: any) => { + const [phoneNumber, setPhoneNumber] = useState(""); + const [errorNumber, setErrorNumber] = useState(false); + const handlePhoneNumberChange = (event: any) => { + setPhoneNumber(event.target.value); + }; + + const [message, setMessage] = useState(""); + const handleMessageChange = (event: any) => { + setMessage(event.target.value); + }; + + const sendMessage = async (phoneNumber: string, message: string) => { + await fetch(`/api/v1/signal/bots/${record.token}/send`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ phoneNumber, message }), + }); + }; + + const resetSession = async (phoneNumber: string) => { + await fetch(`/api/v1/signal/bots/${record.token}/resetSession`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ phoneNumber }), + }); + }; + + const handleBlurNumber = () => { + setErrorNumber(!isValidE164Number(sanitizeE164Number(phoneNumber))); + }; + + const handleSend = () => { + const sanitized = sanitizeE164Number(phoneNumber); + if (isValidE164Number(sanitized)) { + setErrorNumber(false); + sendMessage(sanitized, message); + } else setErrorNumber(false); + }; + + const handleResetSession = () => { + const sanitized = sanitizeE164Number(phoneNumber); + if (isValidE164Number(sanitized)) { + setErrorNumber(false); + resetSession(sanitized); + } else setErrorNumber(false); + }; + + return ( + + + + Send message + + + + + + + + + + + + + + ); +}; + +const MODE = { + SMS: "SMS", + VOICE: "VOICE", +}; + +const handleRequestCode = async ({ + verifyMode, + id, + onSuccess, + onFailure, + captchaCode = undefined, +}: any) => { + if (verifyMode === MODE.SMS) console.log("REQUESTING sms"); + else if (verifyMode === MODE.VOICE) console.log("REQUESTING voice"); + let response: Response; + let url = `/api/v1/signal/bots/${id}/requestCode?mode=${verifyMode.toLowerCase()}`; + if (captchaCode) { + url += `&captcha=${captchaCode}`; + } + + try { + response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + } catch (error: any) { + console.error("Failed to request verification code:", error); + } + + if (response && response.ok) { + onSuccess(); + } else { + onFailure(response.status || 400); + } +}; + +const VerificationCodeRequest = ({ + verifyMode, + data, + onSuccess, + onFailure, +}: any) => { + React.useEffect(() => { + (async () => { + await handleRequestCode({ + verifyMode, + id: data.id, + onSuccess, + onFailure, + }); + })(); + }, []); + + return ( + <> + + Requesting code for {data.phoneNumber} + + + + + + + + + + ); +}; + +const VerificationCaptcha = ({ + verifyMode, + data, + onSuccess, + onFailure, + handleClose, +}: any) => { + const [code, setCode] = React.useState(undefined); + const [isSubmitting, setSubmitting] = React.useState(false); + + const handleSubmitVerification = async () => { + setSubmitting(true); + await handleRequestCode({ + verifyMode, + id: data.id, + onSuccess, + onFailure, + captchaCode: code, + }); + setSubmitting(false); + }; + + const handleCaptchaChange = (value) => { + if (value) + setCode( + value + .replace(/signalcaptcha:\/\//, "") + .replace("“", "") + .replace("”", "") + .trim() + ); + else setCode(value); + }; + + return ( + <> + + Captcha for {data.phoneNumber} + + + handleCaptchaChange(ev.target.value)} + /> + + + {isSubmitting && } + {!isSubmitting && ( + + )} + {!isSubmitting && ( + + )} + + + ); +}; + +const VerificationCodeInput = ({ + data, + verifyMode, + handleClose, + handleRestartVerification, + confirmVerification, +}) => { + const [code, setValue] = React.useState(""); + const [isSubmitting, setSubmitting] = React.useState(false); + const [isValid, setValid] = React.useState(false); + const [submissionError, setSubmissionError] = React.useState(undefined); + const translate = useTranslate(); + + const validator = (v) => v.trim().length === 6; + + const handleValueChange = (newValue) => { + setValue(newValue); + setValid(validator(newValue)); + }; + + const handleSubmitVerification = async () => { + setSubmitting(true); + // await sleep(2000) + const response = await fetch( + `/api/v1/signal/bots/${data.id}/register?code=${code}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + } + ); + setSubmitting(false); + const responseBody = await response.json(); + console.log(responseBody); + if (response.status === 200) { + confirmVerification(); + } else if (responseBody.message) + setSubmissionError(`Error: ${responseBody.message}`); + else + setSubmissionError( + "There was an error, sorry about that. Please try again later or contact support." + ); + }; + + const title = + verifyMode === MODE.SMS + ? translate("resources.signalBots.verifyDialog.sms", { + phoneNumber: data.phoneNumber, + }) + : translate("resources.signalBots.verifyDialog.voice", { + phoneNumber: data.phoneNumber, + }); + return ( + <> + + Verify {data.phoneNumber} + + + {title} + + {submissionError && ( + + {submissionError} + + )} + + + {isSubmitting && } + {!isSubmitting && ( + + )} + {!isSubmitting && ( + + )} + {!isSubmitting && ( + + )} + + + ); +}; + +const VerificationCodeDialog = (props) => { + const [stage, setStage] = React.useState("request"); + const onRequestSuccess = () => setStage("verify"); + const onRestartVerification = () => setStage("request"); + const handleClose = () => { + setStage("request"); + props.handleClose(); + }; + + const onFailure = (code: number) => { + if (code === 402 || code === 500) { + setStage("captcha"); + } else { + setStage("request"); + } + }; + + return ( + + {props.open && stage === "request" && ( + + )} + {props.open && stage === "verify" && ( + + )} + {props.open && stage === "captcha" && ( + + )} + + ); +}; + +const SignalBotShowActions = ({ basePath, data }) => { + const [open, setOpen] = React.useState(false); + const [verifyMode, setVerifyMode] = React.useState(""); + const refresh = useRefresh(); + + const handleOpenSMS = () => { + setVerifyMode(MODE.SMS); + setOpen(true); + }; + + const handleOpenVoice = () => { + setVerifyMode(MODE.VOICE); + setOpen(true); + }; + + const handleClose = () => setOpen(false); + const confirmVerification = () => { + setOpen(false); + refresh(); + }; + + return ( + + + {data && !data.isVerified && ( + + )} + {data && !data.isVerified && ( + + )} + {data && !data.isVerified && ( + + )} + + ); +}; + +const SignalBotShow = (props: ShowProps) => ( + } + {...props} + title="Signal Bot" + // @ts-expect-error: Missing props + aside={} + > + + + + + + + +); + +export default SignalBotShow; diff --git a/metamigo-frontend/components/signal/bots/index.ts b/metamigo-frontend/components/signal/bots/index.ts new file mode 100644 index 0000000..88df6b3 --- /dev/null +++ b/metamigo-frontend/components/signal/bots/index.ts @@ -0,0 +1,14 @@ +import SignalBotIcon from "@material-ui/icons/ChatOutlined"; +import SignalBotList from "./SignalBotList"; +import SignalBotEdit from "./SignalBotEdit"; +import SignalBotCreate from "./SignalBotCreate"; +import SignalBotShow from "./SignalBotShow"; + +// eslint-disable-next-line import/no-anonymous-default-export +export default { + list: SignalBotList, + create: SignalBotCreate, + edit: SignalBotEdit, + show: SignalBotShow, + icon: SignalBotIcon, +}; diff --git a/metamigo-frontend/components/signal/bots/shared.tsx b/metamigo-frontend/components/signal/bots/shared.tsx new file mode 100644 index 0000000..cd97111 --- /dev/null +++ b/metamigo-frontend/components/signal/bots/shared.tsx @@ -0,0 +1,24 @@ +import { + SelectInput, + required, + ReferenceInput, + ReferenceField, + TextField, +} from "react-admin"; + +export const SignalBotSelectInput = (source: string) => () => ( + + + +); + +export const SignalBotField = (source: string) => () => ( + + + +); diff --git a/metamigo-frontend/components/users/UserCreate.tsx b/metamigo-frontend/components/users/UserCreate.tsx new file mode 100644 index 0000000..33c4743 --- /dev/null +++ b/metamigo-frontend/components/users/UserCreate.tsx @@ -0,0 +1,27 @@ +import { FC } from "react"; +import { + SimpleForm, + TextInput, + BooleanInput, + Create, + CreateProps, +} from "react-admin"; +import { useSession } from "next-auth/react"; +import { UserRoleInput } from "./shared"; + +const UserCreate: FC = (props) => { + const { data: session } = useSession(); + return ( + + + + + + + + + + ); +}; + +export default UserCreate; diff --git a/metamigo-frontend/components/users/UserEdit.tsx b/metamigo-frontend/components/users/UserEdit.tsx new file mode 100644 index 0000000..ae53cb4 --- /dev/null +++ b/metamigo-frontend/components/users/UserEdit.tsx @@ -0,0 +1,64 @@ +import { makeStyles } from "@material-ui/core/styles"; +import { + SimpleForm, + TextInput, + BooleanInput, + DateInput, + Edit, + Toolbar, + SaveButton, + DeleteButton, + EditProps, + useRedirect, +} from "react-admin"; +import { useSession } from "next-auth/react"; +import { UserRoleInput } from "./shared"; + +const useStyles = makeStyles((_theme) => ({ + defaultToolbar: { + flex: 1, + display: "flex", + justifyContent: "space-between", + }, +})); + +const UserEditToolbar = (props) => { + const classes = useStyles(props); + const redirect = useRedirect(); + + return ( + + redirect("/users") }} + /> + + + ); +}; + +const UserTitle = ({ record }: { record?: any }) => { + let title = ""; + if (record) title = record.name ? record.name : record.email; + return User {title}; +}; + +const UserEdit = (props: EditProps) => { + const { data: session } = useSession(); + + return ( + } {...props}> + }> + + + + + + + + + + ); +}; + +export default UserEdit; diff --git a/metamigo-frontend/components/users/UserList.tsx b/metamigo-frontend/components/users/UserList.tsx new file mode 100644 index 0000000..af2faf0 --- /dev/null +++ b/metamigo-frontend/components/users/UserList.tsx @@ -0,0 +1,28 @@ +import { + List, + Datagrid, + ImageField, + DateField, + TextField, + EmailField, + BooleanField, + ListProps, +} from "react-admin"; + +const UserList = (props: ListProps) => ( + + + + + + + + + + + + + +); + +export default UserList; diff --git a/metamigo-frontend/components/users/index.ts b/metamigo-frontend/components/users/index.ts new file mode 100644 index 0000000..15f1b5b --- /dev/null +++ b/metamigo-frontend/components/users/index.ts @@ -0,0 +1,12 @@ +import UserIcon from "@material-ui/icons/People"; +import UserList from "./UserList"; +import UserEdit from "./UserEdit"; +import UserCreate from "./UserCreate"; + +// eslint-disable-next-line import/no-anonymous-default-export +export default { + list: UserList, + create: UserCreate, + edit: UserEdit, + icon: UserIcon, +}; diff --git a/metamigo-frontend/components/users/shared.tsx b/metamigo-frontend/components/users/shared.tsx new file mode 100644 index 0000000..8fb832f --- /dev/null +++ b/metamigo-frontend/components/users/shared.tsx @@ -0,0 +1,14 @@ +import { SelectInput } from "react-admin"; + +export const UserRoleInput = (props) => ( + +); diff --git a/metamigo-frontend/components/voice/providers/ProviderCreate.tsx b/metamigo-frontend/components/voice/providers/ProviderCreate.tsx new file mode 100644 index 0000000..b93f524 --- /dev/null +++ b/metamigo-frontend/components/voice/providers/ProviderCreate.tsx @@ -0,0 +1,33 @@ +import { + SimpleForm, + TextInput, + Create, + PasswordInput, + CreateProps, +} from "react-admin"; +import { ProviderKindInput } from "./shared"; + +import TextField from "@material-ui/core/TextField"; + +const TwilioCredentialsInput = () => ( + + + + +); + +const ProviderCreate = (props: CreateProps) => { + return ( + + + + + + + + + + ); +}; + +export default ProviderCreate; diff --git a/metamigo-frontend/components/voice/providers/ProviderEdit.tsx b/metamigo-frontend/components/voice/providers/ProviderEdit.tsx new file mode 100644 index 0000000..dce93a8 --- /dev/null +++ b/metamigo-frontend/components/voice/providers/ProviderEdit.tsx @@ -0,0 +1,31 @@ +import { + SimpleForm, + TextInput, + PasswordInput, + Edit, + EditProps, +} from "react-admin"; +import { ProviderKindInput } from "./shared"; + +const ProviderTitle = ({ record }: { record?: any }) => { + let title = ""; + if (record) title = record.name ? record.name : record.email; + return Provider {title}; +}; + +const ProviderEdit = (props: EditProps) => { + return ( + } {...props}> + + + + + + + + + + ); +}; + +export default ProviderEdit; diff --git a/metamigo-frontend/components/voice/providers/ProviderList.tsx b/metamigo-frontend/components/voice/providers/ProviderList.tsx new file mode 100644 index 0000000..00cd366 --- /dev/null +++ b/metamigo-frontend/components/voice/providers/ProviderList.tsx @@ -0,0 +1,14 @@ +import { List, Datagrid, DateField, TextField, ListProps } from "react-admin"; + +const ProviderList = (props: ListProps) => ( + + + + + + + + +); + +export default ProviderList; diff --git a/metamigo-frontend/components/voice/providers/index.ts b/metamigo-frontend/components/voice/providers/index.ts new file mode 100644 index 0000000..3dbaa81 --- /dev/null +++ b/metamigo-frontend/components/voice/providers/index.ts @@ -0,0 +1,11 @@ +import ProviderIcon from "@material-ui/icons/Business"; +import ProviderList from "./ProviderList"; +import ProviderEdit from "./ProviderEdit"; +import ProviderCreate from "./ProviderCreate"; + +export default { + list: ProviderList, + create: ProviderCreate, + edit: ProviderEdit, + icon: ProviderIcon, +}; diff --git a/metamigo-frontend/components/voice/providers/shared.tsx b/metamigo-frontend/components/voice/providers/shared.tsx new file mode 100644 index 0000000..b490381 --- /dev/null +++ b/metamigo-frontend/components/voice/providers/shared.tsx @@ -0,0 +1,9 @@ +import { SelectInput } from "react-admin"; + +export const ProviderKindInput = (props) => ( + +); diff --git a/metamigo-frontend/components/voice/voicelines/MicInput.module.css b/metamigo-frontend/components/voice/voicelines/MicInput.module.css new file mode 100644 index 0000000..45a3927 --- /dev/null +++ b/metamigo-frontend/components/voice/voicelines/MicInput.module.css @@ -0,0 +1,43 @@ +.voiceWaveWrapper { + width: 100%; + max-height: 50px; + display: flex; + justify-content: center; +} +.hidden { + display: none; +} + +.visible { + display: block; +} +.buttonWrapper { + display: flex; + align-items: center; + justify-content: center; +} + +.playerWrapper { + display: flex; + justify-content: center; + margin-top: 20px; +} + +.recordTime { + align-self: center; + width: 66px; + height: 18px; + margin-top: 10px; + font-family: "sans"; + font-style: normal; + font-weight: normal; + font-size: 15px; + line-height: 18px; + color: #000; +} +.content { + width: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; +} diff --git a/metamigo-frontend/components/voice/voicelines/MicInput.tsx b/metamigo-frontend/components/voice/voicelines/MicInput.tsx new file mode 100644 index 0000000..9fbd561 --- /dev/null +++ b/metamigo-frontend/components/voice/voicelines/MicInput.tsx @@ -0,0 +1,147 @@ +import { useInput } from "react-admin"; +import React, { useState } from "react"; +import dynamic from "next/dynamic"; +import MicIcon from "@material-ui/icons/Mic"; +import StopIcon from "@material-ui/icons/Stop"; +import Button from "@material-ui/core/Button"; +import { makeStyles, useTheme } from "@material-ui/core/styles"; +import AudioPlayer from "material-ui-audio-player"; +import { useStopwatch } from "react-timer-hook"; +import style from "./MicInput.module.css"; +import type { ReactMicProps } from "react-mic"; + +const ReactMic = dynamic( + // eslint-disable-next-line promise/prefer-await-to-then + () => import("react-mic").then((mod) => mod.ReactMic), + { ssr: false } +); + +const blobToDataUri = (blob) => { + const reader = new FileReader(); + reader.readAsDataURL(blob); + return new Promise((resolve) => { + reader.onloadend = () => { + resolve(reader.result); + }; + }); +}; + +const dataUriToObj = (dataUri) => { + const [prefix, base64] = dataUri.split(","); + const mime = prefix.slice(5, prefix.indexOf(";")); + + const result = {}; + result[mime] = base64; + return result; +}; + +const blobToResult = async (blob) => { + const result = dataUriToObj(await blobToDataUri(blob)); + return result; +}; + +const resultToDataUri = (result): string => { + if (!result || !result["audio/webm"]) return ""; + const base64 = result["audio/webm"]; + const r = `data:audio/webm;base64,${base64}`; + return r; +}; + +const MicInput = (props) => { + const { seconds, minutes, hours, start, reset, pause } = useStopwatch(); + const theme = useTheme(); + const { + input: { value, onChange }, + } = useInput(props); + + let [record, setRecorder] = useState({ record: false }); + const decodedValue = resultToDataUri(value); + const startRecording = () => { + setRecorder({ record: true }); + reset(); + start(); + }; + + const stopRecording = () => { + setRecorder({ record: false }); + pause(); + }; + + async function onData(recordedBlob) {} + + async function onStop(recordedBlob) { + const result = await blobToResult(recordedBlob.blob); + onChange(result); + } + + const isRecording = record.record; + const canPlay = !isRecording && decodedValue; + const duration = `${hours + .toString() + .padStart(2, "0")}:${minutes + .toString() + .padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + + const useStyles = makeStyles((theme) => { + return { + volumeIcon: { + display: "none", + }, + mainSlider: { + display: "none", + }, + }; + }); + + return ( +
+
+
+ +
+
{isRecording ?

Recording... {duration}

: ""}
+ +
+ {isRecording ? ( + + ) : ( + + )} +
+
+ {canPlay && ( + + )} +
+
+
+ ); +}; + +export default MicInput; diff --git a/metamigo-frontend/components/voice/voicelines/VoiceLineCreate.tsx b/metamigo-frontend/components/voice/voicelines/VoiceLineCreate.tsx new file mode 100644 index 0000000..c9f4e06 --- /dev/null +++ b/metamigo-frontend/components/voice/voicelines/VoiceLineCreate.tsx @@ -0,0 +1,54 @@ +import { + SimpleForm, + Create, + FormDataConsumer, + SelectInput, + BooleanInput, + ReferenceInput, + required, + CreateProps, +} from "react-admin"; +import TwilioLanguages from "./twilio-languages"; +import { + PromptInput, + VoiceInput, + AvailableNumbersInput, + populateNumber, +} from "./shared"; +import MicInput from "./MicInput"; + +const VoiceLineCreate = (props: CreateProps) => { + return ( + + + + `${p.kind}: ${p.name}`} /> + + + {AvailableNumbersInput} + + + + {VoiceInput} + + + + {PromptInput} + + + + + + ); +}; + +export default VoiceLineCreate; diff --git a/metamigo-frontend/components/voice/voicelines/VoiceLineEdit.tsx b/metamigo-frontend/components/voice/voicelines/VoiceLineEdit.tsx new file mode 100644 index 0000000..012c7f7 --- /dev/null +++ b/metamigo-frontend/components/voice/voicelines/VoiceLineEdit.tsx @@ -0,0 +1,51 @@ +import { + SimpleForm, + TextInput, + Edit, + FormDataConsumer, + SelectInput, + BooleanInput, + ReferenceInput, + required, + EditProps, +} from "react-admin"; +import TwilioLanguages from "./twilio-languages"; +import { VoiceInput, PromptInput } from "./shared"; +import MicInput from "./MicInput"; + +const VoiceLineTitle = ({ record }: { record?: any }) => { + let title = ""; + if (record) title = record.name ? record.name : record.email; + return VoiceLine {title}; +}; + +const VoiceLineEdit = (props: EditProps) => { + return ( + } {...props}> + + + `${p.kind}: ${p.name}`} /> + + + + + + {VoiceInput} + + + {PromptInput} + + + + + + ); +}; + +export default VoiceLineEdit; diff --git a/metamigo-frontend/components/voice/voicelines/VoiceLineList.tsx b/metamigo-frontend/components/voice/voicelines/VoiceLineList.tsx new file mode 100644 index 0000000..ba66345 --- /dev/null +++ b/metamigo-frontend/components/voice/voicelines/VoiceLineList.tsx @@ -0,0 +1,30 @@ +import { + List, + ListProps, + Datagrid, + DateField, + FunctionField, + TextField, + ReferenceField, +} from "react-admin"; + +const VoiceLineList = (props: ListProps) => ( + + + + `${p.kind}: ${p.name}`} /> + + + + + + + + +); + +export default VoiceLineList; diff --git a/metamigo-frontend/components/voice/voicelines/index.ts b/metamigo-frontend/components/voice/voicelines/index.ts new file mode 100644 index 0000000..272754b --- /dev/null +++ b/metamigo-frontend/components/voice/voicelines/index.ts @@ -0,0 +1,12 @@ +import VoiceLineIcon from "@material-ui/icons/PhoneCallback"; +import VoiceLineList from "./VoiceLineList"; +import VoiceLineEdit from "./VoiceLineEdit"; +import VoiceLineCreate from "./VoiceLineCreate"; + +// eslint-disable-next-line import/no-anonymous-default-export +export default { + list: VoiceLineList, + create: VoiceLineCreate, + edit: VoiceLineEdit, + icon: VoiceLineIcon, +}; diff --git a/metamigo-frontend/components/voice/voicelines/recorder.module.css b/metamigo-frontend/components/voice/voicelines/recorder.module.css new file mode 100644 index 0000000..2a6be74 --- /dev/null +++ b/metamigo-frontend/components/voice/voicelines/recorder.module.css @@ -0,0 +1,149 @@ +/* add css module styles here (optional) */ +@import url("https://fonts.googleapis.com/css?family=Lato:400,700&display=swap"); +.recorder_library_box, +.recorder_library_box * { + box-sizing: border-box; + padding: 0; + margin: 0; + font-family: "Lato", sans-serif; +} +.recorder_library_box .recorder_box { + width: 100%; + margin: 0 auto; + padding: 30px 0; +} +.recorder_library_box .recorder_box_inner { + min-height: 400px; + background: #212121; + border-radius: 0 0 3px 3px; + display: flex; + flex-direction: column; +} +.recorder_library_box .mic_icon { + width: 60px; + display: flex; + height: 60px; + position: fixed; + justify-content: center; + align-items: center; + background: rgb(245, 0, 87); + border-radius: 50%; + bottom: 65px; + right: 20%; + color: #fff; + font-size: 25px; +} +.recorder_library_box .reco_header { + display: flex; + justify-content: space-between; + background: #bd9f61; + align-items: center; + padding: 20px 20px; + color: #fff; + border-radius: 3px 3px 0 0; +} +.recorder_library_box .reco_header .h2 { + font-weight: 400; +} +.recorder_library_box .reco_header .close_icons { + font-size: 20px; + width: 40px; + height: 40px; + border-radius: 50%; + justify-content: center; + align-items: center; + display: flex; + cursor: pointer; + transition: 0.5s ease all; +} +.recorder_library_box .reco_header .close_icons:hover { + background: rgba(123, 118, 106, 0.21); +} + +.recorder_library_box .record_section { + position: relative; + flex: 1; +} +.recorder_library_box .record_section .mic_icon { + position: absolute; + left: 50%; + transform: translateX(-50%); + bottom: 20px; +} +.recorder_library_box .record_section .duration_section { + position: absolute; + left: 50%; + transform: translate(-50%); + bottom: 100px; +} + +.recorder_library_box .btn_wrapper { + margin: 20px 30px; +} +.recorder_library_box .btn_wrapper .btn { + border: 0; + outline: 0; + padding: 10px 20px; + border-radius: 20px; + background: #185fec; + color: #fff; + cursor: pointer; + border: 1px solid #185fec; + transition: 0.3s ease all; +} +.recorder_library_box .btn_wrapper .btn:hover { + background: #fff; + color: #185fec; +} +.recorder_library_box .btn_wrapper .clear_btn { + background: #fff; + color: #185fec; + margin-left: 15px; +} +.recorder_library_box .btn_wrapper .clear_btn:hover { + background: #185fec; + color: #fff; +} +.recorder_library_box .duration { + text-align: center; +} +.recorder_library_box .recorder_page_box { + min-height: calc(100vh - 128px); + background: #fff; +} + +.recorder_library_box .duration * { + color: #fff; + font-size: 60px; +} +.recorder_library_box .duration_section .help { + color: #fff; +} + +.recorder_library_box .record_controller { + position: absolute; + left: 50%; + transform: translate(-50%); + bottom: 0px; + padding: 20px 0; + display: flex; +} + +.recorder_library_box .record_controller .icons { + width: 50px; + display: flex; + height: 50px; + justify-content: center; + align-items: center; + border-radius: 50%; + color: #fff; + margin-right: 15px; + font-size: 20px; +} + +.recorder_library_box .record_controller .stop { + background: #940505; +} +.recorder_library_box .record_controller .pause { + background: #9c6702; +} diff --git a/metamigo-frontend/components/voice/voicelines/shared.tsx b/metamigo-frontend/components/voice/voicelines/shared.tsx new file mode 100644 index 0000000..81f9c5f --- /dev/null +++ b/metamigo-frontend/components/voice/voicelines/shared.tsx @@ -0,0 +1,296 @@ +import React, { useState, useEffect } from "react"; +import PlayIcon from "@material-ui/icons/PlayCircleFilled"; +import { + TextInput, + SelectInput, + required, + useTranslate, + useNotify, + ReferenceInput, + ReferenceField, + TextField, +} from "react-admin"; +import { IconButton, CircularProgress } from "@material-ui/core"; +import absoluteUrl from "../../../lib/absolute-url"; +import TwilioLanguages from "./twilio-languages"; + +type TTSProvider = (voice: any, language: any, prompt: any) => Promise; + +const tts = async (providerId): Promise => { + const r = await fetch( + `/api/v1/voice/twilio/text-to-speech-token/${providerId}` + ); + const { token } = await r.json(); + const twilioClient = await import("twilio-client"); + return (voice, language, prompt): Promise => + new Promise((resolve) => { + if (!voice || !language || !prompt) resolve(); + const Device = twilioClient.Device; + const device = new Device(); + const silence = `${absoluteUrl().origin}/static/silence.mp3`; + device.setup(token, { + codecPrefences: ["opus", "pcmu"], + enableRingingState: false, + fakeLocalDTMF: true, + disableAudioContextSounds: true, + sounds: { + disconnect: silence, + incoming: silence, + outgoing: silence, + }, + }); + device.on("ready", function (device) { + device.connect({ language, voice, prompt }); + }); + device.on("disconnect", () => resolve()); + device.on("error", () => resolve()); + }); +}; + +export const TextToSpeechButton = ({ form }) => { + const { providerId, language, voice, promptText: prompt } = form.formData; + const [loading, setLoading] = useState(false); + const [ttsProvider, setTTSProvider] = useState< + undefined | { provider: TTSProvider } + >(undefined); + const [playText, setPlayText] = useState< + undefined | { func: () => Promise } + >(undefined); + useEffect(() => { + (async () => { + if (providerId) { + setLoading(true); + setTTSProvider({ provider: await tts(providerId) }); + setLoading(false); + } + })(); + }, [providerId]); + + useEffect(() => { + (async () => { + setPlayText({ + func: async () => { + setLoading(true); + if (ttsProvider) await ttsProvider.provider(voice, language, prompt); + setLoading(false); + }, + }); + })(); + }, [prompt, language, voice, ttsProvider?.provider]); + + const disabled = !(providerId && prompt?.length >= 2 && voice && language); + /* TODO add this back to IconButtonwhen we know how to extend MUI theme and appease typescript + variant="contained" + */ + return ( + + {!loading && } + {loading && } + + ); +}; + +export const PromptInput = (form, ...rest) => { + return ( + }} + {...rest} + /> + ); +}; + +const validateVoice = (args, values) => { + if (!values.language) return "validation.language"; + if (!values.voice) return "validation.voice"; + + const availableVoices = TwilioLanguages.voices[values.language]; + const found = + availableVoices.filter((v) => v.id === values.voice).length === 1; + if (!found) return "validation.voice"; +}; + +export const VoiceInput = (form, ...rest) => { + const voice = TwilioLanguages.voices[form.formData.language] || []; + return ( + + ); +}; + +let noAvailableNumbers = false; +let availableNumbers = []; + +const getAvailableNumbers = async (providerId) => { + try { + const r = await fetch(`/api/v1/voice/providers/${providerId}/freeNumbers`); + availableNumbers = await r.json(); + noAvailableNumbers = availableNumbers.length === 0; + return availableNumbers; + } catch (error) { + console.error( + `Could not fetch available numbers for provider ${providerId} - ${error}` + ); + return []; + } +}; + +const sidToNumber = (sid) => { + return availableNumbers + .filter(({ id }) => id === sid) + .map(({ name }) => name)[0]; +}; + +export const populateNumber = (data) => { + return { + ...data, + number: sidToNumber(data.providerLineSid), + }; +}; + +const hasNumbers = (args, value, values, translate, ...props) => { + if (noAvailableNumbers) return "validation.noAvailableNumbers"; +}; + +export const AvailableNumbersInput = (form, ...rest) => { + const { + // @ts-expect-error: non-existent property + meta: { touched, error } = {}, + // @ts-expect-error: non-existent property + input: { ...inputProps }, + ...props + } = rest; + const translate = useTranslate(); + const notify = useNotify(); + const [loading, setLoading] = useState(false); + const [choices, setChoices] = useState({}); + // @ts-expect-error: Invalid return type + useEffect(async () => { + if (form && form.formData && form.formData.providerId) { + setLoading(true); + const choices = await getAvailableNumbers(form.formData.providerId); + setChoices({ + choices, + helperText: noAvailableNumbers + ? translate("validation.noAvailableNumbers") + : "", + }); + if (noAvailableNumbers) notify("validation.noAvailableNumbers", "error"); + setLoading(false); + } + }, [form && form.formData ? form.formData.providerId : undefined]); + + return ( + <> + + {loading && } + + ); +}; + +/* +const voiceLineName = voiceLine => { + return voiceLine.number +} +const getVoiceLineChoices = async ():Promise => { + try { + const r = await fetch(`/api/v1/voice/voice-line`); + const lines = await r.json(); + if(lines.data?.length > 0) { + return lines.data.map(voiceLine => ({"id": voiceLine.id, "name": voiceLineName(voiceLine)})) + } + return []; + } catch (error) { + console.error( + `Could not fetch voice lines error: ${error}` + ); + return []; + } +} + +export const AsyncSelectInput = (choiceLoader: () => Promise, label, source, translationEmpty,) => (form, ...rest) => { + const { + meta: { touched, error } = {}, + input: { ...inputProps }, + ...props + } = rest; + + const translate = useTranslate(); + const notify = useNotify(); + const [loading, setLoading] = useState(false); + const [choices, setChoices] = useState({choices: []}); + useEffect(() => { + (async () => { + setLoading(true); + //const items = await choiceLoader() + const items = [{"id": "testing", "name": "OMG"}] + setChoices({ + choices: items, + helperText: items.length === 0 + ? translate(translationEmpty) + : "", + }); + if (items.length === 0) notify(translationEmpty, "error"); + setLoading(false); + })()}, [form && form.formData ? form.formData.providerId : undefined]); + + const isNotEmpty = () => { + if (choices.choices.length === 0) return translationEmpty; + return undefined; + }; + return ( + <> + {choices.choices.length > 0 && + } + {loading && } + + ) +} +export const VoiceLineSelectInput = AsyncSelectInput(getVoiceLineChoices, "Voice Line", "backendId", "validation.noVoiceLines" ) +*/ + +export const VoiceLineSelectInput = (source: string) => () => ( + + + +); + +export const VoiceLineField = (source: string) => () => ( + + + +); diff --git a/metamigo-frontend/components/voice/voicelines/twilio-languages.ts b/metamigo-frontend/components/voice/voicelines/twilio-languages.ts new file mode 100644 index 0000000..0679f14 --- /dev/null +++ b/metamigo-frontend/components/voice/voicelines/twilio-languages.ts @@ -0,0 +1,65 @@ +const languages = { + languages: [ + { id: "arb", name: "Arabic" }, + { id: "cy-GB", name: "Welsh" }, + { id: "da-DK", name: "Danish" }, + { id: "de-DE", name: "German" }, + { id: "en-US", name: "English (US)" }, + { id: "en-AU", name: "English (Australian)" }, + { id: "en-GB", name: "English (British)" }, + { id: "en-GB-WLS", name: "English (Welsh)" }, + { id: "en-IN", name: "English (Indian)" }, + { id: "es-ES", name: "Spanish (Castilian)" }, + { id: "es-MX", name: "Spanish (Mexico)" }, + { id: "es-US", name: "Spanish (Latin American)" }, + { id: "fr-CA", name: "French (Canadian)" }, + { id: "fr-FR", name: "French" }, + { id: "hi-IN", name: "Hindi" }, + { id: "is-IS", name: "Icelandic" }, + { id: "it-IT", name: "Italian" }, + { id: "ja-JP", name: "Japanese" }, + { id: "ko-KR", name: "Korean" }, + { id: "nb-NO", name: "Norwegian" }, + { id: "nl-NL", name: "Dutch" }, + { id: "pl-PL", name: "Polish" }, + { id: "pt-BR", name: "Portuguese (Brazilian)" }, + { id: "pt-PT", name: "Portuguese (European)" }, + { id: "ro-RO", name: "Romanian" }, + { id: "ru-RU", name: "Russian" }, + { id: "sv-SE", name: "Swedish" }, + { id: "tr-TR", name: "Turkish" }, + { id: "zh-CN", name: "Chinese (Mandarin)" }, + ], + voices: { + arb: [{ id: "Polly.Zeina", name: "Zeina" }], + "cy-GB": [{ id: "Polly.Gwyneth", name: "Gwyneth" }], + "da-DK": [{ id: "Polly.Naja", name: "Naja" }], + "de-DE": [{ id: "Polly.Marlene", name: "Marlene" }], + "en-US": [{ id: "Polly.Salli", name: "Salli" }], + "en-AU": [{ id: "Polly.Nicole", name: "Nicole" }], + "en-GB": [{ id: "Polly.Amy", name: "Amy" }], + "en-GB-WLS": [{ id: "Polly.Geraint", name: "Geraint" }], + "en-IN": [{ id: "Polly.Aditi", name: "Aditi" }], + "es-ES": [{ id: "Polly.Conchita", name: "Conchita" }], + "es-MX": [{ id: "Polly.Mia", name: "Mia" }], + "es-US": [{ id: "Polly.Penelope", name: "Penelope" }], + "fr-CA": [{ id: "Polly.Chantal", name: "Chantal" }], + "fr-FR": [{ id: "Polly.Celine", name: "Celine" }], + "hi-IN": [{ id: "Polly.Aditi", name: "Aditi" }], + "is-IS": [{ id: "Polly.Dora", name: "Dora" }], + "it-IT": [{ id: "Polly.Carla", name: "Carla" }], + "ja-JP": [{ id: "Polly.Mizuki", name: "Mizuki" }], + "ko-KR": [{ id: "Polly.Seoyeon", name: "Seoyeon" }], + "nb-NO": [{ id: "Polly.Liv", name: "Liv" }], + "nl-NL": [{ id: "Polly.Lotte", name: "Lotte" }], + "pl-PL": [{ id: "Polly.Ewa", name: "Ewa" }], + "pt-BR": [{ id: "Polly.Vitoria", name: "Vitoria" }], + "pt-PT": [{ id: "Polly.Ines", name: "Ines" }], + "ro-RO": [{ id: "Polly.Carmen", name: "Carmen" }], + "ru-RU": [{ id: "Polly.Tatyana", name: "Tatyana" }], + "sv-SE": [{ id: "Polly.Astrid", name: "Astrid" }], + "tr-TR": [{ id: "Polly.Filiz", name: "Filiz" }], + "zh-CN": [{ id: "Polly.Zhiyu", name: "Zhiyu" }], + }, +}; +export default languages; diff --git a/metamigo-frontend/components/webhooks/WebhookCreate.tsx b/metamigo-frontend/components/webhooks/WebhookCreate.tsx new file mode 100644 index 0000000..d70a42f --- /dev/null +++ b/metamigo-frontend/components/webhooks/WebhookCreate.tsx @@ -0,0 +1,56 @@ +import { + SimpleForm, + FormDataConsumer, + TextInput, + Create, + ArrayInput, + SimpleFormIterator, + regex, + required, + CreateProps, +} from "react-admin"; +import { BackendTypeInput, BackendIdInput, HttpMethodInput } from "./shared"; +/* + + + + + +*/ +const WebhookCreate = (props: CreateProps) => { + return ( + + + + + + {BackendIdInput} + + + + + + + + + + + + ); +}; + +export default WebhookCreate; diff --git a/metamigo-frontend/components/webhooks/WebhookEdit.tsx b/metamigo-frontend/components/webhooks/WebhookEdit.tsx new file mode 100644 index 0000000..fc7b3a1 --- /dev/null +++ b/metamigo-frontend/components/webhooks/WebhookEdit.tsx @@ -0,0 +1,52 @@ +import { + SimpleForm, + TextInput, + Edit, + ArrayInput, + SimpleFormIterator, + regex, + required, + EditProps, + FormDataConsumer, +} from "react-admin"; +import { BackendTypeInput, BackendIdInput, HttpMethodInput } from "./shared"; + +const WebhookTitle = ({ record }) => { + let title = ""; + if (record) title = record.name ? record.name : record.email; + return Webhook {title}; +}; + +const WebhookEdit = (props: EditProps) => { + return ( + // @ts-expect-error: Missing props + } {...props}> + + + + + {BackendIdInput} + + + + + + + + + + + + ); +}; + +export default WebhookEdit; diff --git a/metamigo-frontend/components/webhooks/WebhookList.tsx b/metamigo-frontend/components/webhooks/WebhookList.tsx new file mode 100644 index 0000000..b00f990 --- /dev/null +++ b/metamigo-frontend/components/webhooks/WebhookList.tsx @@ -0,0 +1,23 @@ +import { + List, + Datagrid, + DateField, + TextField, + ReferenceField, + ListProps, +} from "react-admin"; +import { BackendIdField } from "./shared"; + +const WebhookList = (props: ListProps) => ( + + + + + + + + + +); + +export default WebhookList; diff --git a/metamigo-frontend/components/webhooks/index.ts b/metamigo-frontend/components/webhooks/index.ts new file mode 100644 index 0000000..2fb69da --- /dev/null +++ b/metamigo-frontend/components/webhooks/index.ts @@ -0,0 +1,12 @@ +import WebhookIcon from "@material-ui/icons/Send"; +import WebhookList from "./WebhookList"; +import WebhookEdit from "./WebhookEdit"; +import WebhookCreate from "./WebhookCreate"; + +// eslint-disable-next-line import/no-anonymous-default-export +export default { + list: WebhookList, + create: WebhookCreate, + edit: WebhookEdit, + icon: WebhookIcon, +}; diff --git a/metamigo-frontend/components/webhooks/shared.tsx b/metamigo-frontend/components/webhooks/shared.tsx new file mode 100644 index 0000000..413310d --- /dev/null +++ b/metamigo-frontend/components/webhooks/shared.tsx @@ -0,0 +1,68 @@ +import { SelectInput, required } from "react-admin"; + +import { + VoiceLineField, + VoiceLineSelectInput, +} from "../voice/voicelines/shared"; +import { + WhatsAppBotField, + WhatsAppBotSelectInput, +} from "../whatsapp/bots/shared"; +import { SignalBotField, SignalBotSelectInput } from "../signal/bots/shared"; + +const httpChoices = [ + { id: "post", name: "POST" }, + { id: "put", name: "PUT" }, +]; +export const HttpMethodInput = (props) => ( + +); + +const backendChoices = [ + { id: "signal", name: "Signal" }, + { id: "whatsapp", name: "WhatsApp" }, + { id: "voice", name: "Voice" }, +]; + +const backendInputComponents = { + whatsapp: WhatsAppBotSelectInput("backendId"), + signal: SignalBotSelectInput("backendId"), + voice: VoiceLineSelectInput("backendId"), +}; + +const backendFieldComponents = { + whatsapp: WhatsAppBotField("backendId"), + signal: SignalBotField("backendId"), + voice: VoiceLineField("backendId"), +}; + +export const BackendTypeInput = (props) => ( + +); + +export const BackendIdInput = (form, ...rest) => { + const Component = form.formData.backendType + ? backendInputComponents[form.formData.backendType] + : false; + return <>{Component && }; +}; + +export const BackendIdField = (form, ...rest) => { + console.log(form); + + const Component = form.record.backendType + ? backendFieldComponents[form.record.backendType] + : false; + return <>{Component && }; +}; diff --git a/metamigo-frontend/components/whatsapp/attachments/WhatsappAttachmentList.tsx b/metamigo-frontend/components/whatsapp/attachments/WhatsappAttachmentList.tsx new file mode 100644 index 0000000..4c16247 --- /dev/null +++ b/metamigo-frontend/components/whatsapp/attachments/WhatsappAttachmentList.tsx @@ -0,0 +1,11 @@ +import { List, Datagrid, TextField } from "react-admin"; + +const WhatsappAttachmentList = (props) => ( + + + + + +); + +export default WhatsappAttachmentList; diff --git a/metamigo-frontend/components/whatsapp/attachments/WhatsappAttachmentShow.tsx b/metamigo-frontend/components/whatsapp/attachments/WhatsappAttachmentShow.tsx new file mode 100644 index 0000000..670d8d9 --- /dev/null +++ b/metamigo-frontend/components/whatsapp/attachments/WhatsappAttachmentShow.tsx @@ -0,0 +1,11 @@ +import { Show, ShowProps, SimpleShowLayout, TextField } from "react-admin"; + +const WhatsappAttachmentShow = (props: ShowProps) => ( + + + + + +); + +export default WhatsappAttachmentShow; diff --git a/metamigo-frontend/components/whatsapp/attachments/index.ts b/metamigo-frontend/components/whatsapp/attachments/index.ts new file mode 100644 index 0000000..4400a46 --- /dev/null +++ b/metamigo-frontend/components/whatsapp/attachments/index.ts @@ -0,0 +1,10 @@ +import WhatsappAttachmentIcon from "@material-ui/icons/AttachFile"; +import WhatsappAttachmentList from "./WhatsappAttachmentList"; +import WhatsappAttachmentShow from "./WhatsappAttachmentShow"; + +// eslint-disable-next-line import/no-anonymous-default-export +export default { + list: WhatsappAttachmentList, + show: WhatsappAttachmentShow, + icon: WhatsappAttachmentIcon, +}; diff --git a/metamigo-frontend/components/whatsapp/bots/WhatsappBotCreate.tsx b/metamigo-frontend/components/whatsapp/bots/WhatsappBotCreate.tsx new file mode 100644 index 0000000..40d48a5 --- /dev/null +++ b/metamigo-frontend/components/whatsapp/bots/WhatsappBotCreate.tsx @@ -0,0 +1,38 @@ +// import dynamic from "next/dynamic"; +import { SimpleForm, Create, TextInput, required } from "react-admin"; +import { useSession } from "next-auth/react"; +import { validateE164Number } from "../../../lib/phone-numbers"; + +const WhatsappBotCreate = (props) => { + // const MuiPhoneNumber = dynamic(() => import("material-ui-phone-number"), { + // ssr: false, + // }); + + const { data: session } = useSession(); + + return ( + + + + + {/* setFieldValue("phoneNumber", e)} + /> */} + + + + ); +}; + +export default WhatsappBotCreate; diff --git a/metamigo-frontend/components/whatsapp/bots/WhatsappBotEdit.tsx b/metamigo-frontend/components/whatsapp/bots/WhatsappBotEdit.tsx new file mode 100644 index 0000000..9297290 --- /dev/null +++ b/metamigo-frontend/components/whatsapp/bots/WhatsappBotEdit.tsx @@ -0,0 +1,12 @@ +import { SimpleForm, Edit, TextInput, required, EditProps } from "react-admin"; + +const WhatsappBotEdit = (props: EditProps) => ( + + + + + + +); + +export default WhatsappBotEdit; diff --git a/metamigo-frontend/components/whatsapp/bots/WhatsappBotList.tsx b/metamigo-frontend/components/whatsapp/bots/WhatsappBotList.tsx new file mode 100644 index 0000000..06794d6 --- /dev/null +++ b/metamigo-frontend/components/whatsapp/bots/WhatsappBotList.tsx @@ -0,0 +1,22 @@ +import { + List, + Datagrid, + DateField, + TextField, + BooleanField, +} from "react-admin"; + +const WhatsappBotList = (props) => ( + + + + + + + + + + +); + +export default WhatsappBotList; diff --git a/metamigo-frontend/components/whatsapp/bots/WhatsappBotShow.tsx b/metamigo-frontend/components/whatsapp/bots/WhatsappBotShow.tsx new file mode 100644 index 0000000..7dba8a8 --- /dev/null +++ b/metamigo-frontend/components/whatsapp/bots/WhatsappBotShow.tsx @@ -0,0 +1,177 @@ +import React, { useEffect, useState } from "react"; +import { + Card, + Typography, + Grid, + Button, + TextField as MaterialTextField, + IconButton, +} from "@material-ui/core"; +import { + Show, + SimpleShowLayout, + TextField, + ShowProps, + useQuery, + useMutation, + useRefresh, + BooleanField, +} from "react-admin"; +import QRCode from "react-qr-code"; +import useSWR from "swr"; +import RefreshIcon from "@material-ui/icons/Refresh"; + +const Sidebar = ({ record }) => { + const [receivedMessages, setReceivedMessages] = useState([]); + const [phoneNumber, setPhoneNumber] = useState(""); + const handlePhoneNumberChange = (event: any) => { + setPhoneNumber(event.target.value); + }; + + const [message, setMessage] = useState(""); + const handleMessageChange = (event: any) => { + setMessage(event.target.value); + }; + + const sendMessage = async (phoneNumber: string, message: string) => { + await fetch(`/api/v1/whatsapp/bots/${record.token}/send`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ phoneNumber, message }), + }); + }; + + const receiveMessages = async () => { + const result = await fetch(`/api/v1/whatsapp/bots/${record.token}/receive`); + const msgs = await result.json(); + console.log(msgs); + setReceivedMessages(msgs); + }; + + return ( + + + + Send message + + + + + + + + + + + + + Receive messages + + + + + + + + {receivedMessages.map((receivedMessage) => ( + + + {receivedMessage.key.remoteJid.replace("@s.whatsapp.net", "")} + + + {receivedMessage.message.conversation} + + + ))} + + + ); +}; + +const WhatsappBotShow = (props: ShowProps) => { + const refresh = useRefresh(); + const { data } = useQuery({ + type: "getOne", + resource: "whatsappBots", + payload: { id: props.id }, + }); + const [unverify] = useMutation({ + type: "update", + resource: "whatsappBots", + payload: { + id: props.id, + data: { isVerified: false, qrCode: null, authInfo: null }, + }, + }); + + const { data: registerData, error: registerError } = useSWR( + data && !data?.isVerified + ? `/api/v1/whatsapp/bots/${props.id}/register` + : undefined, + { refreshInterval: 59000 } + ); + + console.log({ registerData, registerError }); + + useEffect(() => { + if (data && !data?.isVerified) { + const interval = setInterval(() => { + refresh(); + }, 10000); + return () => clearInterval(interval); + } + }, [refresh, data]); + + return ( + // @ts-expect-error: Missing props + }> + + + + + + + {!data?.isVerified && data?.qrCode && data?.qrCode !== "" && ( + + )} + + + ); +}; + +export default WhatsappBotShow; diff --git a/metamigo-frontend/components/whatsapp/bots/index.ts b/metamigo-frontend/components/whatsapp/bots/index.ts new file mode 100644 index 0000000..47af4f7 --- /dev/null +++ b/metamigo-frontend/components/whatsapp/bots/index.ts @@ -0,0 +1,14 @@ +import WhatsappBotIcon from "@material-ui/icons/WhatsApp"; +import WhatsappBotList from "./WhatsappBotList"; +import WhatsappBotEdit from "./WhatsappBotEdit"; +import WhatsappBotCreate from "./WhatsappBotCreate"; +import WhatsappBotShow from "./WhatsappBotShow"; + +// eslint-disable-next-line import/no-anonymous-default-export +export default { + list: WhatsappBotList, + create: WhatsappBotCreate, + edit: WhatsappBotEdit, + show: WhatsappBotShow, + icon: WhatsappBotIcon, +}; diff --git a/metamigo-frontend/components/whatsapp/bots/shared.tsx b/metamigo-frontend/components/whatsapp/bots/shared.tsx new file mode 100644 index 0000000..57d7772 --- /dev/null +++ b/metamigo-frontend/components/whatsapp/bots/shared.tsx @@ -0,0 +1,24 @@ +import { + SelectInput, + required, + ReferenceInput, + ReferenceField, + TextField, +} from "react-admin"; + +export const WhatsAppBotSelectInput = (source: string) => () => ( + + + +); + +export const WhatsAppBotField = (source: string) => () => ( + + + +); diff --git a/metamigo-frontend/components/whatsapp/messages/WhatsappMessageList.tsx b/metamigo-frontend/components/whatsapp/messages/WhatsappMessageList.tsx new file mode 100644 index 0000000..3c3405b --- /dev/null +++ b/metamigo-frontend/components/whatsapp/messages/WhatsappMessageList.tsx @@ -0,0 +1,23 @@ +import { + List, + ListProps, + Datagrid, + DateField, + TextField, + BooleanField, +} from "react-admin"; + +const WhatsappMessageList = (props: ListProps) => ( + + + + + + + + + + +); + +export default WhatsappMessageList; diff --git a/metamigo-frontend/components/whatsapp/messages/WhatsappMessageShow.tsx b/metamigo-frontend/components/whatsapp/messages/WhatsappMessageShow.tsx new file mode 100644 index 0000000..56f94af --- /dev/null +++ b/metamigo-frontend/components/whatsapp/messages/WhatsappMessageShow.tsx @@ -0,0 +1,29 @@ +import { + Show, + ShowProps, + SimpleShowLayout, + TextField, + ReferenceManyField, + Datagrid, +} from "react-admin"; + +const WhatsappMessageShow = (props: ShowProps) => ( + + + + + + + + + + + + +); + +export default WhatsappMessageShow; diff --git a/metamigo-frontend/components/whatsapp/messages/index.ts b/metamigo-frontend/components/whatsapp/messages/index.ts new file mode 100644 index 0000000..62dc28c --- /dev/null +++ b/metamigo-frontend/components/whatsapp/messages/index.ts @@ -0,0 +1,10 @@ +import WhatsappMessageIcon from "@material-ui/icons/Message"; +import WhatsappMessageList from "./WhatsappMessageList"; +import WhatsappMessageShow from "./WhatsappMessageShow"; + +// eslint-disable-next-line import/no-anonymous-default-export +export default { + list: WhatsappMessageList, + show: WhatsappMessageShow, + icon: WhatsappMessageIcon, +}; diff --git a/metamigo-frontend/i18n/en.ts b/metamigo-frontend/i18n/en.ts new file mode 100644 index 0000000..19a3177 --- /dev/null +++ b/metamigo-frontend/i18n/en.ts @@ -0,0 +1,82 @@ +import { TranslationMessages } from "react-admin"; +import englishMessages from "ra-language-english"; + +const customEnglishMessages: TranslationMessages = { + ...englishMessages, + + auth: { + loggingIn: "Logging in...", + permissionDenied: "Permission denied", + }, + pos: { + configuration: "Configuration", + menu: { + security: "Security", + accounts: "Accounts", + voicelines: "Voice Lines", + providers: "Voice Provider", + webhooks: "Webhooks", + voice: "Voice", + whatsapp: "WhatsApp", + signal: "Signal", + }, + }, + resources: { + signalBots: { + name: "Signal Bot |||| Signal Bots", + verifyDialog: { + sms: + "Please enter the verification code sent via SMS to %{phoneNumber}", + voice: + "Please answer the call from Signal to %{phoneNumber} and enter the verification code", + }, + }, + whatsappBots: { + name: "WhatsApp Bot |||| WhatsApp Bots", + }, + users: { + name: "User |||| Users", + }, + accounts: { + name: "OAuth Account |||| OAuth Accounts", + }, + voicelines: { + name: "Voice Line |||| Voice Lines", + fields: { + providerLineSid: "Provider Line SID", + }, + }, + providers: { + name: "Voice Provider |||| Voice Providers", + fields: { + credentials: { + accountSid: "Twilio Account SID", + apiKeySid: "Twilio API Key SID", + apiKeySecret: "Twilio API Key Secret", + }, + }, + }, + webhooks: { + name: "Webhook |||| Webhooks", + fields: { + endpointUrl: "Endpoint URL", + httpMethod: "HTTP Method", + headers: "HTTP Headers", + header: "Header Name", + value: "Header Value", + }, + }, + }, + validation: { + url: "a valid url starting with https:// is required", + voice: "a voice is required", + language: "a language is required", + headerName: "a valid http header name has only letters, numbers and dashes", + noAvailableNumbers: + "There are no available numbers to assign. Please visit the provider and purchase more numbers.", + noVoiceLines: + "There are no configured voice lines. Visit the Voice Lines admin page to create some.", + }, +}; + +export default customEnglishMessages; diff --git a/metamigo-frontend/lib/absolute-url.ts b/metamigo-frontend/lib/absolute-url.ts new file mode 100644 index 0000000..2bd3c0e --- /dev/null +++ b/metamigo-frontend/lib/absolute-url.ts @@ -0,0 +1,35 @@ +import { IncomingMessage } from "http"; + +function absoluteUrl( + req?: IncomingMessage, + localhostAddress = "localhost:3000" +) { + let host = + (req?.headers ? req.headers.host : window.location.host) || + localhostAddress; + let protocol = /^localhost(:\d+)?$/.test(host) ? "http:" : "https:"; + + if ( + req && + req.headers["x-forwarded-host"] && + typeof req.headers["x-forwarded-host"] === "string" + ) { + host = req.headers["x-forwarded-host"]; + } + + if ( + req && + req.headers["x-forwarded-proto"] && + typeof req.headers["x-forwarded-proto"] === "string" + ) { + protocol = `${req.headers["x-forwarded-proto"]}:`; + } + + return { + protocol, + host, + origin: protocol + "//" + host, + }; +} + +export default absoluteUrl; diff --git a/metamigo-frontend/lib/apollo-client.ts b/metamigo-frontend/lib/apollo-client.ts new file mode 100644 index 0000000..9fc746b --- /dev/null +++ b/metamigo-frontend/lib/apollo-client.ts @@ -0,0 +1,40 @@ +import { + ApolloClient, + InMemoryCache, + ApolloLink, + HttpLink, +} from "@apollo/client"; +import { onError } from "@apollo/client/link/error"; + +const errorLink = onError( + ({ operation, graphQLErrors, networkError, forward }) => { + console.log("ERROR LINK", operation); + if (graphQLErrors) + graphQLErrors.map(({ message, locations, path, ...rest }) => + console.log( + `[GraphQL error]: Message: ${message}`, + locations, + path, + rest + ) + ); + if (networkError) console.log(`[Network error]: ${networkError}`); + forward(operation); + } +); + +export const apolloClient = new ApolloClient({ + link: ApolloLink.from([errorLink, new HttpLink({ uri: "/graphql" })]), + cache: new InMemoryCache(), + /* + defaultOptions: { + watchQuery: { + fetchPolicy: "no-cache", + errorPolicy: "ignore", + }, + query: { + fetchPolicy: "no-cache", + errorPolicy: "all", + }, + },*/ +}); diff --git a/metamigo-frontend/lib/cloudflare.ts b/metamigo-frontend/lib/cloudflare.ts new file mode 100644 index 0000000..71c31ed --- /dev/null +++ b/metamigo-frontend/lib/cloudflare.ts @@ -0,0 +1,210 @@ +import { promisify } from "util"; +import jwt from "jsonwebtoken"; +import jwksClient from "jwks-rsa"; +import * as Boom from "@hapi/boom"; +import * as Wreck from "@hapi/wreck"; +import Providers from "next-auth/providers"; +import type { Adapter } from "next-auth/adapters"; +import type { IncomingMessage } from "http"; + +const CF_JWT_HEADER_NAME = "cf-access-jwt-assertion"; +const CF_JWT_ALGOS = ["RS256"]; + +export type VerifyFn = (token: string) => Promise; + +/** + * Returns a function that will accept a jwt and verify it against the cloudflare access details + * + * @param audience the cloudflare access audience id + * @param domain the cloudflare access domain + */ +export const cfVerifier = (audience: string, domain: string): VerifyFn => { + if (!audience || !domain) + throw Boom.badImplementation( + "Cloudflare configuration is missing. See project documentation." + ); + const issuer = `https://${domain}`; + const client = jwksClient({ + jwksUri: `${issuer}/cdn-cgi/access/certs`, + }); + + return async (token) => { + const getKey = (header, callback) => { + client.getSigningKey(header.kid, function (err, key) { + if (err) + throw Boom.serverUnavailable( + "failed to fetch cloudflare access jwks" + ); + callback(undefined, key.getPublicKey()); + }); + }; + + const opts = { + algorithms: CF_JWT_ALGOS, + audience, + issuer, + }; + // @ts-expect-error: Too many args + return promisify(jwt.verify)(token, getKey, opts); + }; +}; + +/** + * Verifies the Cloudflare Access JWT and returns the decoded token's contents. + * Throws if the token is missing or invalid. + * + * @param verifier the verification function + * @param req the incoming http request to verify + * @return the original token and the decoded contents. + */ +export const verifyRequest = async ( + verifier: VerifyFn, + req: IncomingMessage +): Promise<{ token: string; decoded: any }> => { + const token = req.headers[CF_JWT_HEADER_NAME] as string; + if (token) { + try { + const decoded = await verifier(token); + return { token, decoded }; + } catch (error) { + console.error(error); + throw Boom.unauthorized("invalid cloudflare access token"); + } + } + + throw Boom.unauthorized("cloudflare access token missing"); +}; + +/** + * Fetches user identity information from cloudflare. + * + * @param domain the cloudflare access domain + * @param token the encoded jwt token for the user + * @see https://developers.cloudflare.com/access/setting-up-access/json-web-token#groups-within-a-jwt + */ +export const getIdentity = async ( + domain: string, + token: string +): Promise => { + const { payload } = await Wreck.get( + `https://${domain}/cdn-cgi/access/get-identity`, + { + headers: { + Cookie: `CF_Authorization=${token}`, + }, + json: true, + } + ); + return payload; +}; + +const cloudflareAccountProvider = "cloudflare-access"; + +const cloudflareAuthorizeCallback = ( + req: IncomingMessage, + domain: string, + verifier: VerifyFn, + adapter: Adapter +): (() => Promise) => async () => { + /* + + lots of little variables in here. + + token: the encoded jwt from cloudflare access + decoded: the decoded jwt containing the content cloudflare gives us + identity: we call the cloudflare access identity endpoint to retrieve more user identity information + this data is identity provider specific, so the format is unknown + it would be possible to support specific identity providers and have roles/groups + profile: this is the accumulated user information we have that we will fetch/build the user record with + */ + + const { token, decoded } = await verifyRequest(verifier, req); + + const profile = { + email: undefined, + name: undefined, + avatar: undefined, + }; + if (decoded.email) profile.email = decoded.email; + if (decoded.name) profile.name = decoded.name; + const identity = await getIdentity(domain, token); + + if (identity.email) profile.email = identity.email; + if (identity.name) profile.name = identity.name; + + if (!profile.email) + throw new Error("cloudflare access authorization: email not found"); + + const providerId = `cfaccess|${identity.idp.type}|${identity.idp.id}`; + const providerAccountId = identity.user_uuid; + + if (!providerAccountId) + throw new Error( + "cloudflare access authorization: missing provider account id" + ); + + const { + getUserByProviderAccountId, + getUserByEmail, + createUser, + linkAccount, + } = + // @ts-expect-error: non-existent property + await adapter.getAdapter({} as any); + + const userByProviderAccountId = await getUserByProviderAccountId( + providerId, + providerAccountId + ); + if (userByProviderAccountId) { + return userByProviderAccountId; + } + + const userByEmail = await getUserByEmail(profile.email); + if (userByEmail) { + // we will not explicitly link accounts + throw new Error( + "cloudflare access authorization: user exists for email address, but is not linked." + ); + } + + const user = await createUser(profile); + + // between the previous line and the next line exists a transactional bug + // https://github.com/nextauthjs/next-auth/issues/876 + // hopefully we don't experience it + + await linkAccount( + user.id, + providerId, + cloudflareAccountProvider, + providerAccountId, + // the following are unused but are specified for completness + undefined, + undefined, + undefined + ); + + return user; +}; + +/** + * @param audience the cloudflare access audience id + * @param domain the cloudflare access domain (including the .cloudflareaccess.com bit) + * @param adapter the next-auth adapter used to talk to the backend + * @param req the incoming request object used to parse the jwt from + */ +export const CloudflareAccessProvider = ( + audience: string, + domain: string, + adapter: Adapter, + req: IncomingMessage +) => { + const verifier = cfVerifier(audience, domain); + return Providers.Credentials({ + id: cloudflareAccountProvider, + name: "Cloudflare Access", + credentials: {}, + authorize: cloudflareAuthorizeCallback(req, domain, verifier, adapter), + }); +}; diff --git a/metamigo-frontend/lib/dataprovider.ts b/metamigo-frontend/lib/dataprovider.ts new file mode 100644 index 0000000..889854b --- /dev/null +++ b/metamigo-frontend/lib/dataprovider.ts @@ -0,0 +1,17 @@ +import pgDataProvider from "ra-postgraphile"; +import schema from "./graphql-schema.json"; + +export const metamigoDataProvider = async (client) => { + const graphqlDataProvider = await pgDataProvider( + client, + // @ts-expect-error: Missing property + {}, + { introspection: { schema: schema.data.__schema } } + ); + + const dataProvider = async (type, resource, params) => { + return graphqlDataProvider(type, resource, params); + }; + + return dataProvider; +}; diff --git a/metamigo-frontend/lib/nextauth-adapter.ts b/metamigo-frontend/lib/nextauth-adapter.ts new file mode 100644 index 0000000..0e428e8 --- /dev/null +++ b/metamigo-frontend/lib/nextauth-adapter.ts @@ -0,0 +1,232 @@ +/* eslint-disable unicorn/no-null */ +import type { Adapter } from "next-auth/adapters"; +// @ts-expect-error: Missing export +import type { AppOptions } from "next-auth"; +import * as Wreck from "@hapi/wreck"; +import * as Boom from "@hapi/boom"; + +import type { IAppConfig } from "config"; + +export interface Profile { + name: string; + email: string; + emailVerified: string; + userRole: string; + avatar?: string; + image?: string; + createdBy: string; +} + +export type User = Profile & { id: string; createdAt: Date; updatedAt: Date }; + +export interface Session { + userId: string; + expires: Date; + sessionToken: string; + accessToken: string; + createdAt: Date; + updatedAt: Date; +} + +// from https://github.com/nextauthjs/next-auth/blob/main/src/lib/errors.js +class UnknownError extends Error { + constructor(message) { + super(message); + this.name = "UnknownError"; + this.message = message; + } + + toJSON() { + return { + error: { + name: this.name, + message: this.message, + // stack: this.stack + }, + }; + } +} + +class CreateUserError extends UnknownError { + constructor(message) { + super(message); + this.name = "CreateUserError"; + this.message = message; + } +} + +const basicHeader = (secret) => + "Basic " + Buffer.from(secret + ":", "utf8").toString("base64"); + +export const MetamigoAdapter = (config: IAppConfig): Adapter => { + if (!config) throw new Error("MetamigoAdapter: config is not defined."); + const wreck = Wreck.defaults({ + headers: { + authorization: basicHeader(config.nextAuth.secret), + }, + baseUrl: `${config.frontend.apiUrl}/api/nextauth/`, + maxBytes: 1024 * 1024, + json: "force", + }); + + async function getAdapter(_appOptions: AppOptions) { + async function createUser(profile: Profile) { + try { + if (!profile.createdBy) profile = { ...profile, createdBy: "nextauth" }; + profile.avatar = profile.image; + delete profile.image; + const { payload } = await wreck.post("createUser", { + payload: profile, + }); + return payload; + } catch { + throw new CreateUserError("CREATE_USER_ERROR"); + } + } + + async function getUser(id: string) { + try { + const { payload } = await wreck.get(`getUser/${id}`); + + return payload; + } catch (error) { + if (Boom.isBoom(error, 404)) return null; + throw new Error("GET_USER_BY_ID_ERROR"); + } + } + + async function getUserByEmail(email: string) { + try { + const { payload } = await wreck.get(`getUserByEmail/${email}`); + return payload; + } catch (error) { + if (Boom.isBoom(error, 404)) return null; + throw new Error("GET_USER_BY_EMAIL_ERROR"); + } + } + + async function getUserByProviderAccountId( + providerId: string, + providerAccountId: string + ) { + try { + const { payload } = await wreck.get( + `getUserByProviderAccountId/${providerId}/${providerAccountId}` + ); + + return payload; + } catch (error) { + if (Boom.isBoom(error, 404)) return null; + throw new Error("GET_USER_BY_PROVIDER_ACCOUNT_ID"); + } + } + + async function updateUser(user: User) { + try { + const { payload } = await wreck.put("updateUser", { + payload: user, + }); + + return payload; + } catch { + throw new Error("UPDATE_USER"); + } + } + + async function linkAccount( + userId: string, + providerId: string, + providerType: string, + providerAccountId: string, + refreshToken: string, + accessToken: string, + accessTokenExpires: number + ) { + try { + const payload = { + userId, + providerId, + providerType, + providerAccountId: `${providerAccountId}`, // must be a string + refreshToken, + accessToken, + accessTokenExpires, + }; + await wreck.put("linkAccount", { + payload, + }); + } catch (error) { + throw new Error("LINK_ACCOUNT_ERROR"); + } + } + + async function createSession(user: User) { + try { + const { payload } = await wreck.post("createSession", { + payload: user, + }); + + return payload; + } catch { + throw new Error("CREATE_SESSION_ERROR"); + } + } + + async function getSession(sessionToken: string) { + try { + const { payload } = await wreck.get(`getSession/${sessionToken}`); + return payload; + } catch (error) { + if (Boom.isBoom(error, 404)) return null; + throw new Error("GET_SESSION_ERROR"); + } + } + + async function updateSession(session: Session, force: boolean) { + try { + const payload = { + ...session, + expires: new Date(session.expires).getTime(), + }; + const { payload: result } = await wreck.put( + `updateSession?force=${Boolean(force)}`, + { + payload, + } + ); + return result; + } catch { + throw new Error("UPDATE_SESSION_ERROR"); + } + } + + async function deleteSession(sessionToken: string) { + try { + await wreck.delete(`deleteSession/${sessionToken}`); + } catch { + throw new Error("DELETE_SESSION_ERROR"); + } + } + + return Promise.resolve({ + createUser, + getUser, + getUserByEmail, + getUserByProviderAccountId, + updateUser, + // deleteUser, + linkAccount, + // unlinkAccount, + createSession, + getSession, + updateSession, + deleteSession, + // @ts-expect-error: Type error + } as AdapterInstance); + } + + return { + // @ts-expect-error: non-existent property + getAdapter, + }; +}; diff --git a/metamigo-frontend/lib/phone-numbers.ts b/metamigo-frontend/lib/phone-numbers.ts new file mode 100644 index 0000000..c92f4e1 --- /dev/null +++ b/metamigo-frontend/lib/phone-numbers.ts @@ -0,0 +1,31 @@ +import { regex } from "react-admin"; + +export const E164Regex = /^\+[1-9]\d{1,14}$/; +/** + * Returns true if the number is a valid E164 number + */ +export const isValidE164Number = (phoneNumber) => { + return E164Regex.test(phoneNumber); +}; + +/** + * Given a phone number approximation, will clean out whitespace and punctuation. + */ +export const sanitizeE164Number = (phoneNumber) => { + if (!phoneNumber) return ""; + if (!phoneNumber.trim()) return ""; + const sanitized = phoneNumber + .replace(/\s/g, "") + .replace(/\./g, "") + .replace(/-/g, "") + .replace(/\(/g, "") + .replace(/\)/g, ""); + + if (sanitized[0] !== "+") return `+${sanitized}`; + return sanitized; +}; + +export const validateE164Number = regex( + E164Regex, + "Must start with a + and have no punctunation and no spaces." +); diff --git a/metamigo-frontend/next-env.d.ts b/metamigo-frontend/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/metamigo-frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/metamigo-frontend/next.config.js b/metamigo-frontend/next.config.js new file mode 100644 index 0000000..053de5a --- /dev/null +++ b/metamigo-frontend/next.config.js @@ -0,0 +1,23 @@ +module.exports = { + async redirects() { + return [{ source: "/", destination: "/admin", permanent: true }]; + }, + async rewrites() { + return [ + /* + { + source: "/api/v1/:path*", + destination: "http://localhost:3001/api/:path*", + }, +*/ + { + source: "/api/v1/:path*", + destination: "/api/proxy/:path*", + }, + { + source: "/graphql", + destination: "/api/graphql", + }, + ]; + }, +}; diff --git a/metamigo-frontend/package.json b/metamigo-frontend/package.json new file mode 100644 index 0000000..d1450bc --- /dev/null +++ b/metamigo-frontend/package.json @@ -0,0 +1,51 @@ +{ + "name": "frontend", + "version": "0.2.0", + "private": true, + "dependencies": { + "@apollo/client": "^3.7.4", + "@hapi/boom": "^10.0.0", + "@hapi/wreck": "^18.0.0", + "@mui/icons-material": "^5.11.0", + "@mui/material": "^5.11.4", + "@mui/styles": "^5.11.2", + "@twilio/voice-sdk": "^2.2.0", + "http-proxy-middleware": "^2.0.6", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.0.1", + "material-ui-audio-player": "^1.7.1", + "next": "13.1.2", + "next-auth": "4.18.8", + "ra-data-graphql": "^4.6.0", + "ra-i18n-polyglot": "^4.7.0", + "ra-input-rich-text": "^4.7.1", + "ra-language-english": "^4.7.0", + "ra-postgraphile": "^6.1.0", + "react": "^18", + "react-admin": "^4.7.1", + "react-digit-input": "^2.1.0", + "react-dom": "^18", + "react-mic": "^12.4.6", + "react-qr-code": "^2.0.11", + "react-timer-hook": "^3.0.5", + "swr": "^2.0.0" + }, + "scripts": { + "dev": "next dev -p 2999", + "build": "next build", + "start": "next start", + "fix:lint": "eslint --ext .js,.jsx,.ts,.tsx,.graphql --fix", + "fix:prettier": "prettier --ignore-path .eslintignore \"**/*.{js,jsx,ts,tsx,graphql,md}\" --write", + "test": "echo no tests", + "lint": "next lint", + "lint:lint": "eslint --ext .js,.jsx,.ts,.tsx,.graphql", + "lint:prettier": "prettier --ignore-path .eslintignore \"**/*.{js,jsx,ts,tsx,graphql,md}\" --list-different" + }, + "devDependencies": { + "@next/eslint-plugin-next": "^13.1.2", + "@types/hapi__wreck": "17.0.1", + "@types/react": "^18", + "@types/react-mic": "12.4.3", + "typescript": "^4.9.4" + } +} diff --git a/metamigo-frontend/pages/_app.tsx b/metamigo-frontend/pages/_app.tsx new file mode 100644 index 0000000..5573caa --- /dev/null +++ b/metamigo-frontend/pages/_app.tsx @@ -0,0 +1,13 @@ +import "../styles/globals.css"; +import { AppProps } from "next/app"; +import { SessionProvider } from "next-auth/react"; + +function MetamigoStarter({ Component, pageProps }: AppProps) { + return ( + + + + ); +} + +export default MetamigoStarter; diff --git a/metamigo-frontend/pages/admin.tsx b/metamigo-frontend/pages/admin.tsx new file mode 100644 index 0000000..4492d6e --- /dev/null +++ b/metamigo-frontend/pages/admin.tsx @@ -0,0 +1,15 @@ +import { ApolloProvider } from "@apollo/client"; +import { apolloClient } from "../lib/apollo-client"; +import dynamic from "next/dynamic"; + +const MetamigoAdmin = dynamic(() => import("../components/MetamigoAdmin"), { + ssr: false, +}); + +export default function Home() { + return ( + + + + ); +} diff --git a/metamigo-frontend/pages/api/auth/[...nextauth].ts b/metamigo-frontend/pages/api/auth/[...nextauth].ts new file mode 100644 index 0000000..ff62997 --- /dev/null +++ b/metamigo-frontend/pages/api/auth/[...nextauth].ts @@ -0,0 +1,106 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import NextAuth from "next-auth"; +import Google from "next-auth/providers/google"; +import GitHub from "next-auth/providers/github"; +import GitLab from "next-auth/providers/gitlab"; +import Cognito from "next-auth/providers/cognito"; +import { loadConfig, IAppConfig } from "config"; +import { MetamigoAdapter } from "../../../lib/nextauth-adapter"; +import { CloudflareAccessProvider } from "../../../lib/cloudflare"; + +const nextAuthOptions = (config: IAppConfig, req: NextApiRequest) => { + const { nextAuth, cfaccess } = config; + const adapter = MetamigoAdapter(config); + const providers = []; + + const { audience, domain } = cfaccess; + const cloudflareAccessEnabled = audience && domain; + if (cloudflareAccessEnabled) + providers.push(CloudflareAccessProvider(audience, domain, adapter, req)); + else { + if (nextAuth.google?.id) + providers.push( + Google({ + clientId: nextAuth.google.id, + clientSecret: nextAuth.google.secret, + }) + ); + + if (nextAuth.github?.id) + providers.push( + GitHub({ + clientId: nextAuth.github.id, + clientSecret: nextAuth.github.secret, + }) + ); + + if (nextAuth.gitlab?.id) + providers.push( + GitLab({ + clientId: nextAuth.gitlab.id, + clientSecret: nextAuth.gitlab.secret, + }) + ); + + if (nextAuth.cognito?.id) + providers.push( + Cognito({ + clientId: nextAuth.cognito.id, + clientSecret: nextAuth.cognito.secret, + // domain: nextAuth.cognito.domain, + }) + ); + } + + if (providers.length === 0) + throw new Error( + "No next-auth providers configured. See Metamigo configuration docs." + ); + + return { + secret: nextAuth.secret, + session: { + jwt: true, + maxAge: 8 * 60 * 60, // 8 hours + }, + jwt: { + secret: nextAuth.secret, + encryption: false, + signingKey: nextAuth.signingKey, + encryptionKey: nextAuth.encryptionKey, + }, + providers, + adapter, + callbacks: { + session: async (session: any, token: any) => { + // make the user id available in the react client + session.user.id = token.userId; + return session; + }, + jwt: async (token: any, user: any) => { + const isSignIn = Boolean(user); + // Add auth_time to token on signin in + if (isSignIn) { + // not sure what this does + // if (!token.aud) token.aud; + + token.aud = nextAuth.audience; + token.picture = user.avatar; + token.userId = user.id; + token.role = user.userRole ? `app_${user.userRole}` : "app_anonymous"; + } + + return token; + }, + }, + }; +}; + +const nextAuth = async ( + req: NextApiRequest, + res: NextApiResponse +): Promise => + // @ts-expect-error: Type mismatch + NextAuth(req, res, nextAuthOptions(await loadConfig(), req)); + +export default nextAuth; diff --git a/metamigo-frontend/pages/api/graphql/[[...path]].ts b/metamigo-frontend/pages/api/graphql/[[...path]].ts new file mode 100644 index 0000000..1b1ca26 --- /dev/null +++ b/metamigo-frontend/pages/api/graphql/[[...path]].ts @@ -0,0 +1,38 @@ +import { createProxyMiddleware } from "http-proxy-middleware"; + +export default createProxyMiddleware({ + target: + process.env.NODE_ENV === "production" + ? "http://metamigo-api:3001" + : "http://localhost:3001", + changeOrigin: true, + pathRewrite: { "^/graphql": "/graphql" }, + xfwd: true, + onProxyReq: function (proxyReq, req, _res) { + const auth = proxyReq.getHeader("authorization"); + if (auth) { + // pass along user provided authorization header + return; + } + + // Else extract the session token from the cookie and pass + // as bearer token to the proxy target + let token = req.cookies["__Secure-next-auth.session-token"]; + if (!token) token = req.cookies["next-auth.session-token"]; + + //console.log(req.body); + //if (req.body.query) console.log(req.body.query); + if (token) { + proxyReq.setHeader("authorization", `Bearer ${token}`); + proxyReq.removeHeader("cookie"); + } else { + console.error("no token found. proxied request to backend will fail."); + } + }, +}); + +export const config = { + api: { + bodyParser: false, + }, +}; diff --git a/metamigo-frontend/pages/api/proxy/[[...path]].js b/metamigo-frontend/pages/api/proxy/[[...path]].js new file mode 100644 index 0000000..097c267 --- /dev/null +++ b/metamigo-frontend/pages/api/proxy/[[...path]].js @@ -0,0 +1,38 @@ +import { createProxyMiddleware } from "http-proxy-middleware"; + +export default createProxyMiddleware({ + target: + process.env.NODE_ENV === "production" + ? "http://metamigo-api:3001" + : "http://localhost:3001", + changeOrigin: true, + pathRewrite: { "^/api/v1": "/api" }, + xfwd: true, + onProxyReq: function (proxyReq, req, res) { + const auth = proxyReq.getHeader("authorization"); + if (auth) { + // pass along user provided authorization header + return; + } + + // Else extract the session token from the cookie and pass + // as bearer token to the proxy target + //const token = req.cookies["next-auth.session-token"]; + let token = req.cookies["__Secure-next-auth.session-token"]; + if (!token) token = req.cookies["next-auth.session-token"]; + + if (token) { + proxyReq.setHeader("authorization", `Bearer ${token}`); + proxyReq.removeHeader("cookie"); + } else { + console.error("no token found. proxied request to backend will fail."); + } + return; + }, +}); + +export const config = { + api: { + bodyParser: false, + }, +}; diff --git a/metamigo-frontend/pages/index.tsx b/metamigo-frontend/pages/index.tsx new file mode 100644 index 0000000..4cfd335 --- /dev/null +++ b/metamigo-frontend/pages/index.tsx @@ -0,0 +1,29 @@ +import { NextPage } from "next"; +import { Typography, Box, Button, Grid, Link } from "@material-ui/core"; +import { FC, PropsWithChildren, useEffect } from "react"; +import { useRouter } from "next/router"; + +export const RedirectToAdmin: FC = ({ children }) => { + const router = useRouter(); + useEffect(() => { + router.push("/admin"); + }); + + return <>{children}; +}; + +const Home: NextPage = () => ( + + Metamigo + + + + + + + + + +); + +export default Home; diff --git a/metamigo-frontend/pages/login.tsx b/metamigo-frontend/pages/login.tsx new file mode 100644 index 0000000..b7260a3 --- /dev/null +++ b/metamigo-frontend/pages/login.tsx @@ -0,0 +1,27 @@ +import { Button } from "@material-ui/core"; +import { signIn, signOut, useSession } from "next-auth/react"; + +export default function myComponent() { + const { data: session } = useSession(); + + return ( + <> + {!session && ( + <> + Not signed in
+ + + )} + {session && ( + <> + Signed in as {session.user?.email}
+ + + )} + + ); +} diff --git a/metamigo-frontend/public/silence.mp3 b/metamigo-frontend/public/silence.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..740287c0cd16fe40aff2bb16b2a5808b5b6c6e33 GIT binary patch literal 477 zcmeZtF=k-^0p*b3U{@f`&%nU!lUSB!YN2Ojpl4`c0G5Ri{~uc*@t{o4ytI5E6R3}Y znSp_E1H&pp;3^^TpAZ094RmBO(2>TbAPx+uumnPwj2rlt*(19hrUD!N|FJ|0P@HiI jM3UJ9MEf}Ux*CH5Ae3kTGRGL*9HMn26oSl2qk=gA7W187 literal 0 HcmV?d00001 diff --git a/metamigo-frontend/styles/Home.module.css b/metamigo-frontend/styles/Home.module.css new file mode 100644 index 0000000..b55cee6 --- /dev/null +++ b/metamigo-frontend/styles/Home.module.css @@ -0,0 +1,123 @@ +.container { + min-height: 100vh; + padding: 0 0.5rem; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.main { + padding: 5rem 0; + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.footer { + width: 100%; + height: 100px; + border-top: 1px solid #eaeaea; + display: flex; + justify-content: center; + align-items: center; +} + +.footer img { + margin-left: 0.5rem; +} + +.footer a { + display: flex; + justify-content: center; + align-items: center; +} + +.title a { + color: #0070f3; + text-decoration: none; +} + +.title a:hover, +.title a:focus, +.title a:active { + text-decoration: underline; +} + +.title { + margin: 0; + line-height: 1.15; + font-size: 4rem; +} + +.title, +.description { + text-align: center; +} + +.description { + line-height: 1.5; + font-size: 1.5rem; +} + +.code { + background: #fafafa; + border-radius: 5px; + padding: 0.75rem; + font-size: 1.1rem; + font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, + Bitstream Vera Sans Mono, Courier New, monospace; +} + +.grid { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + + max-width: 800px; + margin-top: 3rem; +} + +.card { + margin: 1rem; + flex-basis: 45%; + padding: 1.5rem; + text-align: left; + color: inherit; + text-decoration: none; + border: 1px solid #eaeaea; + border-radius: 10px; + transition: color 0.15s ease, border-color 0.15s ease; +} + +.card:hover, +.card:focus, +.card:active { + color: #0070f3; + border-color: #0070f3; +} + +.card h3 { + margin: 0 0 1rem 0; + font-size: 1.5rem; +} + +.card p { + margin: 0; + font-size: 1.25rem; + line-height: 1.5; +} + +.logo { + height: 1em; +} + +@media (max-width: 600px) { + .grid { + width: 100%; + flex-direction: column; + } +} diff --git a/metamigo-frontend/styles/globals.css b/metamigo-frontend/styles/globals.css new file mode 100644 index 0000000..e5e2dcc --- /dev/null +++ b/metamigo-frontend/styles/globals.css @@ -0,0 +1,16 @@ +html, +body { + padding: 0; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, + Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; +} + +a { + color: inherit; + text-decoration: none; +} + +* { + box-sizing: border-box; +} diff --git a/metamigo-frontend/tsconfig.json b/metamigo-frontend/tsconfig.json new file mode 100644 index 0000000..253069d --- /dev/null +++ b/metamigo-frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "skipLibCheck": true, + "strict": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/metamigo-worker/.eslintrc.js b/metamigo-worker/.eslintrc.js new file mode 100644 index 0000000..d8da07c --- /dev/null +++ b/metamigo-worker/.eslintrc.js @@ -0,0 +1 @@ +require("../.eslintrc.js"); diff --git a/metamigo-worker/.npmrc b/metamigo-worker/.npmrc new file mode 100644 index 0000000..11e9bd1 --- /dev/null +++ b/metamigo-worker/.npmrc @@ -0,0 +1,2 @@ +@digiresilience:registry=https://gitlab.com/api/v4/packages/npm/ +@guardianproject-ops:registry=https://gitlab.com/api/v4/packages/npm/ diff --git a/metamigo-worker/babel.config.json b/metamigo-worker/babel.config.json new file mode 100644 index 0000000..37798db --- /dev/null +++ b/metamigo-worker/babel.config.json @@ -0,0 +1,5 @@ +{ + "presets": [ + "@digiresilience/babel-preset-metamigo" + ] +} diff --git a/metamigo-worker/common.ts b/metamigo-worker/common.ts new file mode 100644 index 0000000..464ae57 --- /dev/null +++ b/metamigo-worker/common.ts @@ -0,0 +1,69 @@ +/* eslint-disable camelcase */ +import { SavedVoiceProvider } from "db"; +import Twilio from "twilio"; +import { CallInstance } from "twilio/lib/rest/api/v2010/account/call"; +import { Zammad, getOrCreateUser } from "./zammad"; + +export const twilioClientFor = ( + provider: SavedVoiceProvider +): Twilio.Twilio => { + const { accountSid, apiKeySid, apiKeySecret } = provider.credentials; + if (!accountSid || !apiKeySid || !apiKeySecret) + throw new Error( + `twilio provider ${provider.name} does not have credentials` + ); + + return Twilio(apiKeySid, apiKeySecret, { + accountSid, + }); +}; + +export const createZammadTicket = async ( + call: CallInstance, + mp3: Buffer +): Promise => { + const title = `Call from ${call.fromFormatted} at ${call.startTime}`; + const body = `
    +
  • Caller: ${call.fromFormatted}
  • +
  • Service Number: ${call.toFormatted}
  • +
  • Call Duration: ${call.duration} seconds
  • +
  • Start Time: ${call.startTime}
  • +
  • End Time: ${call.endTime}
  • +
+

See the attached recording.

`; + const filename = `${call.sid}-${call.startTime}.mp3`; + const zammad = Zammad( + { + token: "EviH_WL0p6YUlCoIER7noAZEAPsYA_fVU4FZCKdpq525Vmzzvl8d7dNuP_8d-Amb", + }, + "https://demo.digiresilience.org" + ); + try { + const customer = await getOrCreateUser(zammad, call.fromFormatted); + await zammad.ticket.create({ + title, + group: "Finances", + note: "This ticket was created automaticaly from a recorded phone call.", + customer_id: customer.id, + article: { + body, + subject: title, + content_type: "text/html", + type: "note", + attachments: [ + { + filename, + data: mp3.toString("base64"), + "mime-type": "audio/mpeg", + }, + ], + }, + }); + } catch (error: any) { + console.log(Object.keys(error)); + if (error.isBoom) { + console.log(error.output); + throw new Error("Failed to create zamamd ticket"); + } + } +}; diff --git a/metamigo-worker/db.ts b/metamigo-worker/db.ts new file mode 100644 index 0000000..4240a86 --- /dev/null +++ b/metamigo-worker/db.ts @@ -0,0 +1,47 @@ +import pgPromise from "pg-promise"; +import * as pgMonitor from "pg-monitor"; +import { dbInitOptions, IRepositories, AppDatabase } from "db"; +import config from "config"; +import type { IInitOptions } from "pg-promise"; + +export const initDiagnostics = ( + logSql: boolean, + initOpts: IInitOptions +): void => { + if (logSql) { + pgMonitor.attach(initOpts); + } else { + pgMonitor.attach(initOpts, ["error"]); + } +}; + +export const stopDiagnostics = (): void => pgMonitor.detach(); + +let pgp: any; +let pgpInitOptions: any; + +export const initPgp = (): void => { + pgpInitOptions = dbInitOptions(config); + pgp = pgPromise(pgpInitOptions); +}; + +const initDb = (): AppDatabase => { + const db = pgp(config.db.connection); + return db; +}; + +export const stopDb = async (db: AppDatabase): Promise => { + return db.$pool.end(); +}; + +export const withDb = (f: (db: AppDatabase) => Promise): Promise => { + const db = initDb(); + initDiagnostics(config.logging.sql, pgpInitOptions); + try { + return f(db); + } finally { + stopDiagnostics(); + } +}; + +export type { AppDatabase } from "db"; diff --git a/metamigo-worker/index.ts b/metamigo-worker/index.ts new file mode 100644 index 0000000..0d1fb5c --- /dev/null +++ b/metamigo-worker/index.ts @@ -0,0 +1,53 @@ +import * as Worker from "graphile-worker"; +import { defState } from "@digiresilience/montar"; +import config from "config"; +import { initPgp } from "./db"; +import logger from "./logger"; +import workerUtils from "./utils"; +import { assertFfmpegAvailable } from "./lib/media-convert"; + +const logFactory = (scope: any) => (level: any, message: any, meta: any) => { + const pinoLevel = level === "warning" ? "warn" : level; + const childLogger = logger.child({ scope }); + if (meta) childLogger[pinoLevel](meta, message); + else childLogger[pinoLevel](message); +}; + +export const configWorker = async (): Promise => { + const { connection, concurrency, pollInterval } = config.worker; + logger.info({ concurrency, pollInterval }, "Starting worker"); + return { + concurrency, + pollInterval, + logger: new Worker.Logger(logFactory), + connectionString: connection, + // eslint-disable-next-line unicorn/prefer-module + taskDirectory: `${__dirname}/tasks`, + }; +}; + +export const startWorker = async (): Promise => { + // ensure ffmpeg is installed and working + await assertFfmpegAvailable(); + logger.info("ffmpeg found"); + + await workerUtils.migrate(); + logger.info("worker database migrated"); + + initPgp(); + + const workerConfig = await configWorker(); + const worker = await Worker.run(workerConfig); + return worker; +}; + +export const stopWorker = async (): Promise => { + await worker.stop(); +}; + +const worker = defState("worker", { + start: startWorker, + stop: stopWorker, +}); + +export default worker; diff --git a/metamigo-worker/lib/media-convert.ts b/metamigo-worker/lib/media-convert.ts new file mode 100644 index 0000000..bc6a22a --- /dev/null +++ b/metamigo-worker/lib/media-convert.ts @@ -0,0 +1,84 @@ +import { Readable } from "stream"; +import ffmpeg from "fluent-ffmpeg"; +import * as R from "remeda"; + +const requiredCodecs = ["mp3", "webm", "wav"]; + +export interface AudioConvertOpts { + bitrate?: string; + audioCodec?: string; + format?: string; +} + +const defaultAudioConvertOpts = { + bitrate: "32k", + audioCodec: "libmp3lame", + format: "mp3", +}; + +/** + * Converts an audio file to a different format. defaults to converting to mp3 with a 32k bitrate using the libmp3lame codec + * + * @param input the buffer containing the binary data of the input file + * @param opts options to control how the audio file is converted + * @return resolves to a buffer containing the binary data of the converted file + **/ +export const convert = ( + input: Buffer, + opts?: AudioConvertOpts +): Promise => { + const settings = { ...defaultAudioConvertOpts, ...opts }; + return new Promise((resolve, reject) => { + const stream = Readable.from(input); + let out = Buffer.alloc(0); + const cmd = ffmpeg(stream) + .audioCodec(settings.audioCodec) + .audioBitrate(settings.bitrate) + .toFormat(settings.format) + .on("error", (err, stdout, stderr) => { + console.error(err.message); + console.log("FFMPEG OUTPUT"); + console.log(stdout); + console.log("FFMPEG ERROR"); + console.log(stderr); + reject(err); + }) + .on("end", () => { + resolve(out); + }); + const outstream = cmd.pipe(); + outstream.on("data", (chunk: Buffer) => { + out = Buffer.concat([out, chunk]); + }); + }); +}; + +/** + * Check if ffmpeg is installed and usable. Checks for required codecs and a working ffmpeg installation. + * + * @return resolves to true if ffmpeg is installed and usable + * */ +export const selfCheck = (): Promise => { + return new Promise((resolve) => { + ffmpeg.getAvailableFormats((err, codecs) => { + if (err) { + console.error("FFMPEG error:", err); + resolve(false); + } + + const preds = R.map(requiredCodecs, (codec) => (available: any) => + available[codec] && available[codec].canDemux && available[codec].canMux + ); + + resolve(R.allPass(codecs, preds)); + }); + }); +}; + +export const assertFfmpegAvailable = async (): Promise => { + const r = await selfCheck(); + if (!r) + throw new Error( + `ffmpeg is not installed, could not be located, or does not support the required codecs: ${requiredCodecs}` + ); +}; diff --git a/metamigo-worker/logger.ts b/metamigo-worker/logger.ts new file mode 100644 index 0000000..f0b5302 --- /dev/null +++ b/metamigo-worker/logger.ts @@ -0,0 +1,8 @@ +import { defState } from "@digiresilience/montar"; +import { configureLogger } from "common"; +import config from "config"; + +export const logger = defState("workerLogger", { + start: async () => configureLogger(config), +}); +export default logger; diff --git a/metamigo-worker/package.json b/metamigo-worker/package.json new file mode 100644 index 0000000..e0969b5 --- /dev/null +++ b/metamigo-worker/package.json @@ -0,0 +1,55 @@ +{ + "name": "worker", + "version": "0.2.0", + "main": "build/main/index.js", + "author": "Abel Luck ", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@digiresilience/montar": "^0.1.6", + "graphile-worker": "^0.13.0", + "remeda": "^1.6.0" + }, + "devDependencies": { + "@babel/core": "7.20.12", + "@babel/preset-env": "7.20.2", + "@babel/preset-typescript": "7.18.6", + "@types/fluent-ffmpeg": "^2.1.20", + "@types/jest": "^29.2.5", + "common": "0.2.5", + "config": "3.3.8", + "db": "3.4.0", + "eslint": "^8.32.0", + "jest": "^29.3.1", + "jest-circus": "^29.3.1", + "jest-junit": "^15.0.0", + "nodemon": "^2.0.20", + "pino-pretty": "^9.1.1", + "prettier": "^2.8.3", + "ts-node": "^10.9.1", + "typedoc": "^0.23.24", + "typescript": "4.9.4" + }, + "nodemonConfig": { + "ignore": [ + "docs/*" + ], + "ext": "ts,json,js" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "build-test": "tsc -p tsconfig.json", + "doc:html": "typedoc src/ --exclude '**/*.test.ts' --exclude '**/*.spec.ts' --name $npm_package_name --readme README.md --target es2019 --mode file --out build/docs", + "doc": "yarn run doc:html", + "fix:lint": "eslint src --ext .ts --fix", + "fix:prettier": "prettier \"src/**/*.ts\" --write", + "worker": "NODE_ENV=development yarn cli worker", + "test:jest": "JEST_CIRCUS=1 jest --coverage --forceExit --detectOpenHandles --reporters=default --reporters=jest-junit", + "test:jest-verbose": "yarn test:jest --verbose --silent=false", + "test": "yarn test:jest", + "lint": "yarn lint:lint && yarn lint:prettier", + "lint:lint": "eslint src --ext .ts", + "lint:prettier": "prettier \"src/**/*.ts\" --list-different", + "watch:build": "tsc -p tsconfig.json -w", + "watch:test": "yarn test:jest --watchAll" + } +} diff --git a/metamigo-worker/tasks/notify-webhook.ts b/metamigo-worker/tasks/notify-webhook.ts new file mode 100644 index 0000000..b2f6db8 --- /dev/null +++ b/metamigo-worker/tasks/notify-webhook.ts @@ -0,0 +1,57 @@ +import Wreck from "@hapi/wreck"; +import * as R from "remeda"; +import { withDb, AppDatabase } from "../db"; +import logger from "../logger"; + +export interface WebhookOptions { + webhookId: string; + payload: any; +} + +const notifyWebhooksTask = async (options: WebhookOptions): Promise => + withDb(async (db: AppDatabase) => { + const { webhookId, payload } = options; + + const webhook = await db.webhooks.findById({ id: webhookId }); + if (!webhook) { + logger.debug( + { webhookId }, + "notify-webhook: no webhook registered with id" + ); + return; + } + + const { endpointUrl, httpMethod, headers } = webhook; + const headersFormatted = R.reduce( + headers || [], + (acc: any, h: any) => { + acc[h.header] = h.value; + return acc; + }, + {} + ); + + const wreck = Wreck.defaults({ + json: true, + headers: headersFormatted, + }); + + // http errors will bubble up and cause the job to fail and be retried + try { + logger.debug( + { webhookId, endpointUrl, httpMethod }, + "notify-webhook: notifying" + ); + await (httpMethod === "post" + ? wreck.post(endpointUrl, { payload }) + : wreck.put(endpointUrl, { payload })); + } catch (error: any) { + logger.error( + { webhookId, error: error.output }, + "notify-webhook failed with this error" + ); + throw new Error(`webhook failed webhookId=${webhookId}`); + } + }); + +export default notifyWebhooksTask; diff --git a/metamigo-worker/tasks/signal-message.ts b/metamigo-worker/tasks/signal-message.ts new file mode 100644 index 0000000..d76d807 --- /dev/null +++ b/metamigo-worker/tasks/signal-message.ts @@ -0,0 +1,76 @@ +/* eslint-disable camelcase */ +import { withDb, AppDatabase } from "../db"; +import workerUtils from "../utils"; + +interface WebhookPayload { + to: string; + from: string; + message_id: string; + sent_at: string; + message: string; + attachment: string; + filename: string; + mime_type: string; +} + +interface SignalMessageTaskOptions { + id: string; + source: string; + timestamp: string; + message: string; + attachments: unknown[]; + signalBotId: string; +} + +const formatPayload = ( + messageInfo: SignalMessageTaskOptions +): WebhookPayload => { + const { id, source, message, timestamp } = messageInfo; + + return { + to: "16464229653", + from: source, + message_id: id, + sent_at: timestamp, + message, + attachment: "", + filename: "test.png", + mime_type: "image/png", + }; +}; + +const notifyWebhooks = async ( + db: AppDatabase, + messageInfo: SignalMessageTaskOptions +) => { + const { id: messageID, signalBotId } = messageInfo; + const webhooks = await db.webhooks.findAllByBackendId("signal", signalBotId); + if (webhooks && webhooks.length === 0) return; + + webhooks.forEach(({ id }) => { + const payload = formatPayload(messageInfo); + console.log({ payload }); + workerUtils.addJob( + "notify-webhook", + { + payload, + webhookId: id, + }, + { + // this de-deduplicates the job + jobKey: `webhook-${id}-message-${messageID}`, + } + ); + }); +}; + +const signalMessageTask = async ( + options: SignalMessageTaskOptions +): Promise => { + console.log(options); + withDb(async (db: AppDatabase) => { + await notifyWebhooks(db, options); + }); +}; + +export default signalMessageTask; diff --git a/metamigo-worker/tasks/signald-message.ts b/metamigo-worker/tasks/signald-message.ts new file mode 100644 index 0000000..359bbdd --- /dev/null +++ b/metamigo-worker/tasks/signald-message.ts @@ -0,0 +1,87 @@ +/* eslint-disable camelcase */ +import logger from "../logger"; +import { IncomingMessagev1 } from "@digiresilience/node-signald/dist/generated"; +import { withDb, AppDatabase } from "../db"; +import workerUtils from "../utils"; + +interface WebhookPayload { + to: string; + from: string; + message_id: string; + sent_at: string; + message: string; + attachment: string | null; + filename: string | null; + mime_type: string | null; +} + +interface SignaldMessageTaskOptions { + message: IncomingMessagev1; + botId: string; + botPhoneNumber: string; +} + +const formatPayload = (opts: SignaldMessageTaskOptions): WebhookPayload => { + const { botId, botPhoneNumber, message } = opts; + const { source, timestamp, data_message: dataMessage } = message; + + const { number }: any = source; + + const { body, attachments }: any = dataMessage; + + return { + to: botPhoneNumber, + from: number, + message_id: `${botId}-${timestamp}`, + sent_at: `${timestamp}`, + message: body, + attachment: null, + filename: null, + mime_type: null, + }; +}; + +const notifyWebhooks = async ( + db: AppDatabase, + messageInfo: SignaldMessageTaskOptions +) => { + const { + botId, + message: { timestamp }, + } = messageInfo; + const webhooks = await db.webhooks.findAllByBackendId("signal", botId); + if (webhooks && webhooks.length === 0) { + logger.debug({ botId }, "no webhooks registered for signal bot"); + return; + } + + webhooks.forEach(({ id }) => { + const payload = formatPayload(messageInfo); + logger.debug( + { payload }, + "formatted signal bot payload for notify-webhook" + ); + workerUtils.addJob( + "notify-webhook", + { + payload, + webhookId: id, + }, + { + // this de-deduplicates the job + jobKey: `webhook-${id}-message-${botId}-${timestamp}`, + } + ); + }); +}; + +const signaldMessageTask = async ( + options: SignaldMessageTaskOptions +): Promise => { + console.log(options); + withDb(async (db: AppDatabase) => { + await notifyWebhooks(db, options); + }); +}; + +export default signaldMessageTask; diff --git a/metamigo-worker/tasks/twilio-recording.ts b/metamigo-worker/tasks/twilio-recording.ts new file mode 100644 index 0000000..cfb0e41 --- /dev/null +++ b/metamigo-worker/tasks/twilio-recording.ts @@ -0,0 +1,101 @@ +import Wreck from "@hapi/wreck"; +import { withDb, AppDatabase } from "../db"; +import { twilioClientFor } from "../common"; +import { CallInstance } from "twilio/lib/rest/api/v2010/account/call"; +import workerUtils from "../utils"; + +interface WebhookPayload { + startTime: string; + endTime: string; + to: string; + from: string; + duration: string; + callSid: string; + recording: string; + mimeType: string; +} + +const getTwilioRecording = async (url: string) => { + try { + const { payload } = await Wreck.get(url); + return { recording: payload as Buffer }; + } catch (error: any) { + console.error(error.output); + return { error: error.output }; + } +}; + +const formatPayload = ( + call: CallInstance, + recording: Buffer +): WebhookPayload => { + return { + startTime: call.startTime.toISOString(), + endTime: call.endTime.toISOString(), + to: call.toFormatted, + from: call.fromFormatted, + duration: call.duration, + callSid: call.sid, + recording: recording.toString("base64"), + mimeType: "audio/mpeg", + }; +}; + +const notifyWebhooks = async ( + db: AppDatabase, + voiceLineId: string, + call: CallInstance, + recording: Buffer +) => { + const webhooks = await db.webhooks.findAllByBackendId("voice", voiceLineId); + if (webhooks && webhooks.length === 0) return; + + webhooks.forEach(({ id }) => { + const payload = formatPayload(call, recording); + workerUtils.addJob( + "notify-webhook", + { + payload, + webhookId: id, + }, + { + // this de-depuplicates the job + jobKey: `webhook-${id}-call-${call.sid}`, + } + ); + }); +}; + +interface TwilioRecordingTaskOptions { + accountSid: string; + callSid: string; + recordingSid: string; + voiceLineId: string; +} + +const twilioRecordingTask = async ( + options: TwilioRecordingTaskOptions +): Promise => + withDb(async (db: AppDatabase) => { + const { voiceLineId, accountSid, callSid, recordingSid } = options; + + const voiceLine = await db.voiceLines.findById({ id: voiceLineId }); + if (!voiceLine) return; + + const provider = await db.voiceProviders.findByTwilioAccountSid(accountSid); + if (!provider) return; + + const client = twilioClientFor(provider); + const meta = await client.recordings(recordingSid).fetch(); + + const mp3Url = "https://api.twilio.com/" + meta.uri.slice(0, -4) + "mp3"; + const { recording, error } = await getTwilioRecording(mp3Url); + if (error) { + throw new Error(`failed to get recording for call ${callSid}`); + } + + const call = await client.calls(callSid).fetch(); + await notifyWebhooks(db, voiceLineId, call, recording!); + }); + +export default twilioRecordingTask; diff --git a/metamigo-worker/tasks/voice-line-audio-update.ts b/metamigo-worker/tasks/voice-line-audio-update.ts new file mode 100644 index 0000000..8a4628a --- /dev/null +++ b/metamigo-worker/tasks/voice-line-audio-update.ts @@ -0,0 +1,48 @@ +import { createHash } from "crypto"; +import { withDb, AppDatabase } from "../db"; +import { convert } from "../lib/media-convert"; + +interface VoiceLineAudioUpdateTaskOptions { + voiceLineId: string; +} + +const sha1sum = (v: any) => { + const shasum = createHash("sha1"); + shasum.update(v); + return shasum.digest("hex"); +}; + +const voiceLineAudioUpdateTask = async ( + payload: VoiceLineAudioUpdateTaskOptions +): Promise => + withDb(async (db: AppDatabase) => { + const { voiceLineId } = payload; + const voiceLine = await db.voiceLines.findById({ id: voiceLineId }); + if (!voiceLine) return; + if (!voiceLine?.promptAudio?.["audio/webm"]) return; + + const webm = Buffer.from(voiceLine.promptAudio["audio/webm"], "base64"); + const webmSha1 = sha1sum(webm); + + if ( + voiceLine.promptAudio.checksum && + voiceLine.promptAudio.checksum === webmSha1 + ) { + // already converted + return; + } + + const mp3 = await convert(webm); + await db.voiceLines.updateById( + { id: voiceLine.id }, + { + promptAudio: { + ...voiceLine.promptAudio, + "audio/mpeg": mp3.toString("base64"), + checksum: webmSha1, + }, + } + ); + }); + +export default voiceLineAudioUpdateTask; diff --git a/metamigo-worker/tasks/voice-line-delete.ts b/metamigo-worker/tasks/voice-line-delete.ts new file mode 100644 index 0000000..ed3bc8f --- /dev/null +++ b/metamigo-worker/tasks/voice-line-delete.ts @@ -0,0 +1,41 @@ +import Twilio from "twilio"; +import config from "config"; +import { withDb, AppDatabase } from "../db"; + +interface VoiceLineDeleteTaskOptions { + voiceLineId: string; + providerId: string; + providerLineSid: string; +} + +const voiceLineDeleteTask = async ( + payload: VoiceLineDeleteTaskOptions +): Promise => + withDb(async (db: AppDatabase) => { + const { voiceLineId, providerId, providerLineSid } = payload; + const provider = await db.voiceProviders.findById({ id: providerId }); + if (!provider) return; + + const { accountSid, apiKeySid, apiKeySecret } = provider.credentials; + if (!accountSid || !apiKeySid || !apiKeySecret) + throw new Error( + `twilio provider ${provider.name} does not have credentials` + ); + + const client = Twilio(apiKeySid, apiKeySecret, { + accountSid, + }); + + const number = await client.incomingPhoneNumbers(providerLineSid).fetch(); + if ( + number && + number.voiceUrl === + `${config.frontend.url}/api/v1/voice/twilio/record/${voiceLineId}` + ) + await client.incomingPhoneNumbers(providerLineSid).update({ + voiceUrl: "", + voiceMethod: "POST", + }); + }); + +export default voiceLineDeleteTask; diff --git a/metamigo-worker/tasks/voice-line-provider-update.ts b/metamigo-worker/tasks/voice-line-provider-update.ts new file mode 100644 index 0000000..eeebc68 --- /dev/null +++ b/metamigo-worker/tasks/voice-line-provider-update.ts @@ -0,0 +1,38 @@ +import Twilio from "twilio"; +import config from "config"; +import { withDb, AppDatabase } from "../db"; + +interface VoiceLineUpdateTaskOptions { + voiceLineId: string; +} + +const voiceLineUpdateTask = async ( + payload: VoiceLineUpdateTaskOptions +): Promise => + withDb(async (db: AppDatabase) => { + const { voiceLineId } = payload; + const voiceLine = await db.voiceLines.findById({ id: voiceLineId }); + if (!voiceLine) return; + + const provider = await db.voiceProviders.findById({ + id: voiceLine.providerId, + }); + if (!provider) return; + + const { accountSid, apiKeySid, apiKeySecret } = provider.credentials; + if (!accountSid || !apiKeySid || !apiKeySecret) + throw new Error( + `twilio provider ${provider.name} does not have credentials` + ); + + const client = Twilio(apiKeySid, apiKeySecret, { + accountSid, + }); + + await client.incomingPhoneNumbers(voiceLine.providerLineSid).update({ + voiceUrl: `${config.frontend.url}/api/v1/voice/twilio/record/${voiceLineId}`, + voiceMethod: "POST", + }); + }); + +export default voiceLineUpdateTask; diff --git a/metamigo-worker/tasks/whatsapp-message.ts b/metamigo-worker/tasks/whatsapp-message.ts new file mode 100644 index 0000000..eb40d79 --- /dev/null +++ b/metamigo-worker/tasks/whatsapp-message.ts @@ -0,0 +1,94 @@ +/* eslint-disable camelcase */ +import { withDb, AppDatabase } from "../db"; +import workerUtils from "../utils"; + +interface WebhookPayload { + to: string; + from: string; + message_id: string; + sent_at: string; + message: string; + attachment: string; + filename: string; + mime_type: string; +} + +interface WhatsappMessageTaskOptions { + waMessageId: string; + waMessage: string; + waTimestamp: string; + attachment: string; + filename: string; + mimetype: string; + botPhoneNumber: string; + whatsappBotId: string; +} + +const formatPayload = ( + messageInfo: WhatsappMessageTaskOptions +): WebhookPayload => { + const { + waMessageId, + waMessage, + waTimestamp, + attachment, + filename, + mimetype, + botPhoneNumber, + } = messageInfo; + const parsedMessage = JSON.parse(waMessage); + const message = parsedMessage.message?.conversation ?? + parsedMessage.message?.extendedTextMessage?.text ?? + parsedMessage.message?.imageMessage?.caption ?? + parsedMessage.message?.videoMessage?.caption; + + return { + to: botPhoneNumber, + from: parsedMessage.key.remoteJid, + message_id: waMessageId, + sent_at: waTimestamp, + message, + attachment, + filename, + mime_type: mimetype, + }; +}; + +const notifyWebhooks = async ( + db: AppDatabase, + messageInfo: WhatsappMessageTaskOptions +) => { + const { waMessageId, whatsappBotId } = messageInfo; + const webhooks = await db.webhooks.findAllByBackendId( + "whatsapp", + whatsappBotId + ); + if (webhooks && webhooks.length === 0) return; + + webhooks.forEach(({ id }) => { + const payload = formatPayload(messageInfo); + console.log({ payload }); + workerUtils.addJob( + "notify-webhook", + { + payload, + webhookId: id, + }, + { + // this de-deduplicates the job + jobKey: `webhook-${id}-message-${waMessageId}`, + } + ); + }); +}; + +const whatsappMessageTask = async ( + options: WhatsappMessageTaskOptions +): Promise => { + console.log(options); + withDb(async (db: AppDatabase) => { + await notifyWebhooks(db, options); + }); +}; + +export default whatsappMessageTask; diff --git a/metamigo-worker/tsconfig.json b/metamigo-worker/tsconfig.json new file mode 100644 index 0000000..ca7a09b --- /dev/null +++ b/metamigo-worker/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "build/main" + }, + "include": ["**/*.ts", "**/.*.ts"], + "exclude": ["node_modules"] +} diff --git a/metamigo-worker/utils.ts b/metamigo-worker/utils.ts new file mode 100644 index 0000000..f44457f --- /dev/null +++ b/metamigo-worker/utils.ts @@ -0,0 +1,21 @@ +import * as Worker from "graphile-worker"; +import { defState } from "@digiresilience/montar"; +import config from "config"; + +const startWorkerUtils = async (): Promise => { + const workerUtils = await Worker.makeWorkerUtils({ + connectionString: config.worker.connection, + }); + return workerUtils; +}; + +const stopWorkerUtils = async (): Promise => { + return workerUtils.release(); +}; + +const workerUtils = defState("workerUtils", { + start: startWorkerUtils, + stop: stopWorkerUtils, +}); + +export default workerUtils; diff --git a/metamigo-worker/zammad.ts b/metamigo-worker/zammad.ts new file mode 100644 index 0000000..b3faa82 --- /dev/null +++ b/metamigo-worker/zammad.ts @@ -0,0 +1,106 @@ +/* eslint-disable camelcase,@typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-explicit-any */ +import querystring from "querystring"; +import Wreck from "@hapi/wreck"; + +export interface User { + id: number; + firstname?: string; + lastname?: string; + email?: string; + phone?: string; +} +export interface Ticket { + id: number; + title?: string; + group_id?: number; + customer_id?: number; +} + +export interface ZammadClient { + ticket: { + create: (data: any) => Promise; + }; + user: { + search: (data: any) => Promise; + create: (data: any) => Promise; + }; +} + +export type ZammadCredentials = + | { username: string; password: string } + | { token: string }; + +export interface ZammadClientOpts { + headers?: Record; +} + +const formatAuth = (credentials: any) => { + if (credentials.username) { + return ( + "Basic " + + Buffer.from(`${credentials.username}:${credentials.password}`).toString( + "base64" + ) + ); + } + + if (credentials.token) { + return `Token ${credentials.token}`; + } + + throw new Error("invalid zammad credentials type"); +}; + +export const Zammad = ( + credentials: ZammadCredentials, + host: string, + opts?: ZammadClientOpts +): ZammadClient => { + const extraHeaders = (opts && opts.headers) || {}; + + const wreck = Wreck.defaults({ + baseUrl: `${host}/api/v1/`, + headers: { + authorization: formatAuth(credentials), + ...extraHeaders, + }, + json: true, + }); + + return { + ticket: { + create: async (payload) => { + const { payload: result } = await wreck.post("tickets", { payload }); + return result as Ticket; + }, + }, + user: { + search: async (query) => { + const qp = querystring.stringify({ query }); + const { payload: result } = await wreck.get(`users/search?${qp}`); + return result as User[]; + }, + create: async (payload) => { + const { payload: result } = await wreck.post("users", { payload }); + return result as User; + }, + }, + }; +}; + +export const getUser = async (zammad: ZammadClient, phoneNumber: string) => { + const mungedNumber = phoneNumber.replace("+", ""); + const results = await zammad.user.search(`phone:${mungedNumber}`); + if (results.length > 0) return results[0]; + return undefined; +}; + +export const getOrCreateUser = async (zammad: ZammadClient, phoneNumber: string) => { + const customer = await getUser(zammad, phoneNumber); + if (customer) return customer; + + return zammad.user.create({ + phone: phoneNumber, + note: "User created by Grabadora from incoming voice call", + }); +}; diff --git a/nginx-proxy/Dockerfile b/nginx-proxy/Dockerfile new file mode 100644 index 0000000..be4aa46 --- /dev/null +++ b/nginx-proxy/Dockerfile @@ -0,0 +1 @@ +FROM nginxproxy/nginx-proxy:1.2.1 diff --git a/postgresql/Dockerfile b/postgresql/Dockerfile new file mode 100644 index 0000000..f730c10 --- /dev/null +++ b/postgresql/Dockerfile @@ -0,0 +1 @@ +FROM postgres:15.2-alpine diff --git a/redis/Dockerfile b/redis/Dockerfile new file mode 100644 index 0000000..4e42c3d --- /dev/null +++ b/redis/Dockerfile @@ -0,0 +1 @@ +FROM redis:6.2.5-alpine diff --git a/signald/Dockerfile b/signald/Dockerfile new file mode 100644 index 0000000..3aad927 --- /dev/null +++ b/signald/Dockerfile @@ -0,0 +1 @@ +FROM signald/signald:0.23.2 diff --git a/zammad-addon-hardening/.gitignore b/zammad-addon-hardening/.gitignore new file mode 100644 index 0000000..a39f83f --- /dev/null +++ b/zammad-addon-hardening/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +npm-debug.log* +.idea/ +dist/ +*.szpm +.env* +yarn-error.log +docker-compose.yml +coverage +.pgpass diff --git a/zammad-addon-hardening/.gitlab-ci.yml b/zammad-addon-hardening/.gitlab-ci.yml new file mode 100644 index 0000000..0ce3aed --- /dev/null +++ b/zammad-addon-hardening/.gitlab-ci.yml @@ -0,0 +1,36 @@ +--- +image: registry.gitlab.com/guardianproject-ops/docker-python-node:python3.8-nodejs12 + +stages: + - test + - build + - trigger + +before_script: + - apt-get update && apt-get install -y make ruby + - gem install rufo -v 0.12.0 + - pip install inflection + +test: + stage: test + script: + - make test + +build: + stage: build + artifacts: + paths: + - dist/*.szpm + script: + - make + +trigger-downstream: + stage: trigger + only: + - develop + - main + variables: + UPSTREAM_BRANCH: $CI_COMMIT_REF_NAME + trigger: + project: digiresilience/waterbear/docker-zammad + branch: $CI_COMMIT_REF_NAME diff --git a/zammad-addon-hardening/.ruby-version b/zammad-addon-hardening/.ruby-version new file mode 100644 index 0000000..338a5b5 --- /dev/null +++ b/zammad-addon-hardening/.ruby-version @@ -0,0 +1 @@ +2.6.6 diff --git a/zammad-addon-hardening/LICENSE.md b/zammad-addon-hardening/LICENSE.md new file mode 100644 index 0000000..bfcc3db --- /dev/null +++ b/zammad-addon-hardening/LICENSE.md @@ -0,0 +1,616 @@ +### GNU AFFERO GENERAL PUBLIC LICENSE + +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +### Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains +free software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing +under this license. + +The precise terms and conditions for copying, distribution and +modification follow. + +### TERMS AND CONDITIONS + +#### 0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public +License. + +"Copyright" also means copyright-like laws that apply to other kinds +of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of +an exact copy. The resulting work is called a "modified version" of +the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user +through a computer network, with no transfer of a copy, is not +conveying. + +An interactive user interface displays "Appropriate Legal Notices" to +the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +#### 1. Source Code. + +The "source code" for a work means the preferred form of the work for +making modifications to it. "Object code" means any non-source form of +a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can +regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same +work. + +#### 2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, +without conditions so long as your license otherwise remains in force. +You may convey covered works to others for the sole purpose of having +them make modifications exclusively for you, or provide you with +facilities for running those works, provided that you comply with the +terms of this License in conveying all material for which you do not +control copyright. Those thus making or running the covered works for +you must do so exclusively on your behalf, under your direction and +control, on terms that prohibit them from making any copies of your +copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes +it unnecessary. + +#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such +circumvention is effected by exercising rights under this License with +respect to the covered work, and you disclaim any intention to limit +operation or modification of the work as a means of enforcing, against +the work's users, your or third parties' legal rights to forbid +circumvention of technological measures. + +#### 4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +#### 5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these +conditions: + +- a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. +- b) The work must carry prominent notices stating that it is + released under this License and any conditions added under + section 7. This requirement modifies the requirement in section 4 + to "keep intact all notices". +- c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +#### 6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms of +sections 4 and 5, provided that you also convey the machine-readable +Corresponding Source under the terms of this License, in one of these +ways: + +- a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. +- b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the Corresponding + Source from a network server at no charge. +- c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. +- d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. +- e) Convey the object code using peer-to-peer transmission, + provided you inform other peers where the object code and + Corresponding Source of the work are being offered to the general + public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, +family, or household purposes, or (2) anything designed or sold for +incorporation into a dwelling. In determining whether a product is a +consumer product, doubtful cases shall be resolved in favor of +coverage. For a particular product received by a particular user, +"normally used" refers to a typical or common use of that class of +product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected +to use, the product. A product is a consumer product regardless of +whether the product has substantial commercial, industrial or +non-consumer uses, unless such uses represent the only significant +mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to +install and execute modified versions of a covered work in that User +Product from a modified version of its Corresponding Source. The +information must suffice to ensure that the continued functioning of +the modified object code is in no case prevented or interfered with +solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or +updates for a work that has been modified or installed by the +recipient, or for the User Product in which it has been modified or +installed. Access to a network may be denied when the modification +itself materially and adversely affects the operation of the network +or violates the rules and protocols for communication across the +network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +#### 7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders +of that material) supplement the terms of this License with terms: + +- a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or +- b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or +- c) Prohibiting misrepresentation of the origin of that material, + or requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or +- d) Limiting the use for publicity purposes of names of licensors + or authors of the material; or +- e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or +- f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions + of it) with contractual assumptions of liability to the recipient, + for any liability that these contractual assumptions directly + impose on those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; the +above requirements apply either way. + +#### 8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your license +from a particular copyright holder is reinstated (a) provisionally, +unless and until the copyright holder explicitly and finally +terminates your license, and (b) permanently, if the copyright holder +fails to notify you of the violation by some reasonable means prior to +60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +#### 9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run +a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +#### 10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +#### 11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned +or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within the +scope of its coverage, prohibits the exercise of, or is conditioned on +the non-exercise of one or more of the rights that are specifically +granted under this License. You may not convey a covered work if you +are a party to an arrangement with a third party that is in the +business of distributing software, under which you make payment to the +third party based on the extent of your activity of conveying the +work, and under which the third party grants, to any of the parties +who would receive the covered work from you, a discriminatory patent +license (a) in connection with copies of the covered work conveyed by +you (or copies made from those copies), or (b) primarily for and in +connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +#### 12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under +this License and any other pertinent obligations, then as a +consequence you may not convey it at all. For example, if you agree to +terms that obligate you to collect a royalty for further conveying +from those to whom you convey the Program, the only way you could +satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +#### 13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your +version supports such interaction) an opportunity to receive the +Corresponding Source of your version by providing access to the +Corresponding Source from a network server at no charge, through some +standard or customary means of facilitating copying of software. This +Corresponding Source shall include the Corresponding Source for any +work covered by version 3 of the GNU General Public License that is +incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +#### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU Affero General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever +published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions +of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +#### 15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT +WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND +PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR +CORRECTION. + +#### 16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR +CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT +NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR +LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM +TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER +PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +#### 17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS diff --git a/zammad-addon-hardening/Makefile b/zammad-addon-hardening/Makefile new file mode 100644 index 0000000..bb87200 --- /dev/null +++ b/zammad-addon-hardening/Makefile @@ -0,0 +1,27 @@ +.PHONY: prep clean + +build: prep + @./package.py + @find dist/ -iname "*szpm" + +prep: + @mkdir -p dist + +clean: prep + @rm -rf dist/* + +fmt: + rufo src + +new-migration: + @./new-migration.py + +init: + @echo "Give your addon a name. No spaces." + @echo "Addon name?: "; \ + read NAME; \ + mkdir -p "src/db/addon/$${NAME}"; \ + sed -i "s/NAME/$${NAME}/" base.szpm.template; \ + mv base.szpm.template "$${NAME}.szpm.template" + +test: build diff --git a/zammad-addon-hardening/README.md b/zammad-addon-hardening/README.md new file mode 100644 index 0000000..8df04bc --- /dev/null +++ b/zammad-addon-hardening/README.md @@ -0,0 +1,55 @@ +# zammad-addon-hardening + +A [Zammad](https://zammad.org) addon that hardens a Zammad instance according to CDR's needs. + +The hardening applied consists of: + +* Prevent sensitive information from being leaked in notification emails +* Zammad external services for geoip, maps, image, and calendar are disabled +* Disable client side stats +* Set minimum password length to 10 +* Prevent customers from creating tickets in the web ui +* Disable user registration +* Remove default "nicole.braun@zammad.org" account + +## Development + +1. Edit the files in `src/` + + Migration files should go in `src/db/addon/hardening` ([see this post](https://community.zammad.org/t/automating-creation-of-custom-object-attributes/3831/2?u=abelxluck)) + +2. Update version and changelog in `hardening-skeleton.szpm` +3. Build a new package `make` + + This outputs `dist/hardening-vXXX.szpm` + +4. Install the szpm using the zammad package manager. + +5. Repeat + + +### Create a new migration + +Included is a helper script to create new migrations. You must have the python +`inflection` library installed. + +* debian/ubuntu: `apt install python3-inflection` +* pip: `pip install --user inflection` +* or create your own venv + +To make a new migration simply run: +``` +make new-migration +``` + +## License + +[![License GNU AGPL v3.0](https://img.shields.io/badge/License-AGPL%203.0-lightgrey.svg)](https://gitlab.com/digiresilience/link/zamamd-addon-hardening/blob/master/LICENSE.md) + +This is a free software project licensed under the GNU Affero General +Public License v3.0 (GNU AGPLv3) by [The Center for Digital +Resilience](https://digiresilience.org) and [Guardian +Project](https://guardianproject.info). + + +🤠 diff --git a/zammad-addon-hardening/hardening.szpm.template b/zammad-addon-hardening/hardening.szpm.template new file mode 100644 index 0000000..4e3876d --- /dev/null +++ b/zammad-addon-hardening/hardening.szpm.template @@ -0,0 +1,29 @@ +{ + "name": "Hardening", + "version": "0.1.0", + "vendor": "Center for Digital Resilience", + "license": "AGPL-v3+", + "url": "https://gitlab.com/digiresilience/link/zammad-addon-hardening", + "buildhost": "", + "builddate": "", + "change_log": [ + { + "version": "0.0.1", + "date": "2020-02-11", + "log": "First version with CDR secure defaults" + }, + { + "version": "0.0.2", + "date": "2020-02-11", + "log": "Fix bug when uninstalled" + } + ], + "description": [ + { + "language": "en", + "text": "Change me" + } + ], + "files": [ + ] +} diff --git a/zammad-addon-hardening/new-migration.py b/zammad-addon-hardening/new-migration.py new file mode 100755 index 0000000..f74b323 --- /dev/null +++ b/zammad-addon-hardening/new-migration.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +import os +import json +import glob +import inflection +from datetime import datetime +from collections import OrderedDict + +migration_template = """class {} < ActiveRecord::Migration[5.2] + def self.up + # add your code here + end + + def self.down + # add your code here + end +end +""" + + +def load_skeleton(): + t = glob.glob('*.szpm.template') + if len(t) != 1: + raise Exception("Cannot find szpm template") + with open(t[0], 'r', encoding='utf-8') as f: + skeleton = json.load(f, object_pairs_hook=OrderedDict) + return skeleton + + +def main(): + skeleton = load_skeleton() + name = skeleton["name"].lower() + raw_name = input("Enter migration name: ") + migration_base_name = "{}_{}".format(name, inflection.underscore(raw_name)) + migration_name = inflection.camelize(migration_base_name, uppercase_first_letter=True) + contents = migration_template.format(migration_name) + time = datetime.utcnow().strftime("%Y%m%d%H%M%S") + migration_file_name = "{}_{}.rb".format(time, migration_base_name) + with open(os.path.join("src/db/addon/", skeleton["name"], migration_file_name), 'w') as f: + f.write(contents) + + +main() diff --git a/zammad-addon-hardening/package.py b/zammad-addon-hardening/package.py new file mode 100755 index 0000000..eae19c4 --- /dev/null +++ b/zammad-addon-hardening/package.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +import os +import base64 +import json +import datetime +import platform +import glob +import re +from collections import OrderedDict + +# files matching this pattern are not included in the package +ignored_patterns = [ + "\.gitkeep" +] + + +def encode(fname): + data = open(fname, "r", encoding='utf-8').read().encode('utf-8') + return base64.b64encode(data).decode('utf-8') + + +def read_perm(fname): + return int(oct(os.stat(fname).st_mode & 0o777)[-3:]) + + +def format_file(content, pkg_path, permission): + return OrderedDict( + location=pkg_path, + permission=permission, + encode="base64", + content=content) + + +def pkg_file(actual_path): + print(" Packaging: {}".format(actual_path)) + pkg_path = actual_path[6:] + contents = encode(actual_path) + res = format_file(contents, pkg_path, read_perm(actual_path)) + return res + + +def pkg_files(): + pkged_files = [] + for root, dirs, files in os.walk("./src/"): + for f in files: + if any(re.search(r, f) for r in ignored_patterns): + continue + actual_path = os.path.join(root, f) + pkged_files.append(pkg_file(actual_path)) + return pkged_files + + +def load_skeleton(): + t = glob.glob('*.szpm.template') + if len(t) != 1: + raise Exception("Cannot find szpm template") + with open(t[0], 'r', encoding='utf-8') as f: + skeleton = json.load(f, object_pairs_hook=OrderedDict) + return skeleton + + +def main(): + files = pkg_files() + skeleton = load_skeleton() + skeleton["files"] = files + skeleton["builddate"] = datetime.datetime.utcnow().isoformat() + skeleton["buildhost"] = platform.node() + name = skeleton["name"].lower() + version = skeleton["version"] + pkg = json.dumps(skeleton, indent=2) + with open("dist/{}-v{}.szpm".format(name, version), "w", encoding='utf-8') as f: + f.write(pkg) + + +main() diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_create/cs.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_create/cs.html.erb new file mode 100644 index 0000000..ce0aa2c --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_create/cs.html.erb @@ -0,0 +1,9 @@ +Nový ticket + +
Dobrý den,
+
+
byl vytvořen nový ticket.
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_create/de.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_create/de.html.erb new file mode 100644 index 0000000..dd3d680 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_create/de.html.erb @@ -0,0 +1,8 @@ +Neues Ticket + +
Hallo #{recipient.firstname},
+
+
es wurde ein neues Ticket.
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_create/en.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_create/en.html.erb new file mode 100644 index 0000000..5277068 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_create/en.html.erb @@ -0,0 +1,9 @@ +New Ticket + +
Hi #{recipient.firstname},
+
+
A new ticket has been created.
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_create/es.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_create/es.html.erb new file mode 100644 index 0000000..ac2aa50 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_create/es.html.erb @@ -0,0 +1,9 @@ +Nuevo Ticket + +
Buenos días #{recipient.firstname},
+
+
Se ha creado un nuevo ticket.
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_create/fr.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_create/fr.html.erb new file mode 100644 index 0000000..f299b47 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_create/fr.html.erb @@ -0,0 +1,9 @@ +Nouveau Ticket + +
Bonjour #{recipient.firstname},
+
+
Un nouveau ticket.
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_create/it.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_create/it.html.erb new file mode 100644 index 0000000..1f4a3cf --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_create/it.html.erb @@ -0,0 +1,8 @@ +Nuovo Ticket + +
Buongiorno #{recipient.firstname},
+
+
Un nuovo ticket.
. + diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_create/pl.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_create/pl.html.erb new file mode 100644 index 0000000..eb7e057 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_create/pl.html.erb @@ -0,0 +1,9 @@ +Nowe zgłoszenie + +
Witaj #{recipient.firstname},
+
+
trzymałeś nowe zgłoszenie.
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_create/pt-br.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_create/pt-br.html.erb new file mode 100644 index 0000000..63e0985 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_create/pt-br.html.erb @@ -0,0 +1,9 @@ +Novo Chamado + +
Oi #{recipient.firstname},
+
+
Um novo chamado.
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_create/zh-cn.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_create/zh-cn.html.erb new file mode 100644 index 0000000..b9b946e --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_create/zh-cn.html.erb @@ -0,0 +1,9 @@ +新工单 + +
您好, #{recipient.firstname}:
+
+
用户创建了一张主题为 的新工单.
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_create/zh-tw.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_create/zh-tw.html.erb new file mode 100644 index 0000000..acbc5fe --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_create/zh-tw.html.erb @@ -0,0 +1,8 @@ +新工單 + +
你好, #{recipient.firstname}:
+
+
客戶創建了一張標題為 的新工單.
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/cs.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/cs.html.erb new file mode 100644 index 0000000..011b987 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/cs.html.erb @@ -0,0 +1,8 @@ +Vyeskaloval ticket + +
Dobrý den,
+
+
ticket eskaloval "#{ticket.escalation_at}"!
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/de.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/de.html.erb new file mode 100644 index 0000000..68f8e21 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/de.html.erb @@ -0,0 +1,8 @@ +Ticket ist eskaliert + +
Hallo #{recipient.firstname},
+
+
Ticket ist seit "#{ticket.escalation_at}" eskaliert!
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/en.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/en.html.erb new file mode 100644 index 0000000..6cab453 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/en.html.erb @@ -0,0 +1,9 @@ +Ticket is escalated + +
Hi #{recipient.firstname},
+
+
A ticket is escalated since "#{ticket.escalation_at}"!
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/es.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/es.html.erb new file mode 100644 index 0000000..702a8ca --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/es.html.erb @@ -0,0 +1,9 @@ +El ticket está escalado + +
Buenos días #{recipient.firstname},
+
+
Un ticket se incrementa desde "#{ticket.escalation_at}"!
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/fr.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/fr.html.erb new file mode 100644 index 0000000..74d3443 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/fr.html.erb @@ -0,0 +1,9 @@ +Ticket escaladé + +
Bonjour #{recipient.firstname},
+
+
Le ticket est escaladé depuis "#{ticket.escalation_at}"!
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/it.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/it.html.erb new file mode 100644 index 0000000..3af8ec3 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/it.html.erb @@ -0,0 +1,18 @@ +Il ticket si è intensificato + +
Buongiorno #{recipient.firstname},
+
+
Un ticket si è intensificato a "#{ticket.escalation_at}"!
. +
+<% if @objects[:article] %> +
+ #{t('Information')}: +
+ #{article.body_as_html} +
+
+<% end %> +
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/pl.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/pl.html.erb new file mode 100644 index 0000000..3d0c006 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/pl.html.erb @@ -0,0 +1,8 @@ +Zgłoszenie zostało eskalowane + +
Witaj #{recipient.firstname},
+
+
Zgłosznie jest eskalowane od "#{ticket.escalation_at}"!
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/pt-br.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/pt-br.html.erb new file mode 100644 index 0000000..ab45d34 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/pt-br.html.erb @@ -0,0 +1,9 @@ +Chamado escalado + +
Oi #{recipient.firstname},
+
+
Um chamado foi escalado desde "#{ticket.escalation_at}"!
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/zh-cn.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/zh-cn.html.erb new file mode 100644 index 0000000..e7380b8 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/zh-cn.html.erb @@ -0,0 +1,9 @@ +工单 已被升级 + +
您好, #{recipient.firstname}:
+
+
来自客户 的工单从 "#{ticket.escalation_at}" 起已经被升级, 请尽快处理!
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/zh-tw.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/zh-tw.html.erb new file mode 100644 index 0000000..d0488bc --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation/zh-tw.html.erb @@ -0,0 +1,9 @@ +工單 已被升級 + +
你好, #{recipient.firstname}:
+
+
來自客戶 的工單自 "#{ticket.escalation_at}" 起已經被升級, 請盡速處理!
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/cs.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/cs.html.erb new file mode 100644 index 0000000..af7b21e --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/cs.html.erb @@ -0,0 +1,9 @@ +Bude eskalovat ticket + +
Dobrý den,
+
+
ticket bude "#{ticket.escalation_at}" eskalovat!
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/de.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/de.html.erb new file mode 100644 index 0000000..b77b81b --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/de.html.erb @@ -0,0 +1,9 @@ +Ticket wird eskalieren + +
Hallo #{recipient.firstname},
+
+
Ticket wird um "#{ticket.escalation_at}" eskalieren!
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/en.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/en.html.erb new file mode 100644 index 0000000..ded6531 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/en.html.erb @@ -0,0 +1,9 @@ +Ticket will escalate + +
Hi #{recipient.firstname},
+
+
A ticket will escalate at "#{ticket.escalation_at}"!
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/es.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/es.html.erb new file mode 100644 index 0000000..c35d6db --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/es.html.erb @@ -0,0 +1,9 @@ +El ticket escalará + +
Buenos días #{recipient.firstname},
+
+
Un ticket escalará a "#{ticket.escalation_at}"!
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/fr.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/fr.html.erb new file mode 100644 index 0000000..ade7c66 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/fr.html.erb @@ -0,0 +1,9 @@ +Le ticket va escalader + +
Bojour #{recipient.firstname},
+
+
Le ticket escaladera "#{ticket.escalation_at}"!
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/it.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/it.html.erb new file mode 100644 index 0000000..d32abee --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/it.html.erb @@ -0,0 +1,9 @@ +Il ticket intensificarsi + +
Buongiorno #{recipient.firstname},
+
+
Un ticket intensificarsi a "#{ticket.escalation_at}"!
! +
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/pl.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/pl.html.erb new file mode 100644 index 0000000..8420751 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/pl.html.erb @@ -0,0 +1,9 @@ +Zgłoszenie zostanie eskalowane + +
Witaj #{recipient.firstname},
+
+
Zgłoszenie zostanie eskalowane "#{ticket.escalation_at}"!
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/pt-br.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/pt-br.html.erb new file mode 100644 index 0000000..71d6887 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/pt-br.html.erb @@ -0,0 +1,10 @@ +O Chamado vai ser escalado + + +
Oi #{recipient.firstname},
+
+
Um chamado vai ser escalado em "#{ticket.escalation_at}"!
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/zh-cn.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/zh-cn.html.erb new file mode 100644 index 0000000..86a0213 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/zh-cn.html.erb @@ -0,0 +1,9 @@ +工单 即将升级 + +
您好 #{recipient.firstname},
+
+
来自客户 即将在 "#{ticket.escalation_at}" 开始升级!
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/zh-tw.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/zh-tw.html.erb new file mode 100644 index 0000000..45b200a --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_escalation_warning/zh-tw.html.erb @@ -0,0 +1,9 @@ +工單 即將升級 + +
Hi #{recipient.firstname},
+
+
來自客戶 即將在 "#{ticket.escalation_at}" 開始升級!
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/cs.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/cs.html.erb new file mode 100644 index 0000000..a8382ff --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/cs.html.erb @@ -0,0 +1,9 @@ +Dosažena upomínka pro ticket + +
Dobrý den,
+
+
ticket vyžaduje vaši pozornost a to protože bylo dosaženo doby pro upomínku.
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/de.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/de.html.erb new file mode 100644 index 0000000..2dbdfad --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/de.html.erb @@ -0,0 +1,9 @@ +Warten auf Erinnerung erreicht! + +
Hallo #{recipient.firstname},
+
+
dieses Ticket benötigt Deine Aufmerksamkeit, warten auf Erinnerung für ist erreicht.
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/en.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/en.html.erb new file mode 100644 index 0000000..c13ea39 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/en.html.erb @@ -0,0 +1,9 @@ +Reminder reached + +
Hi #{recipient.firstname},
+
+
A ticket needs attention, reminder reached.
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/es.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/es.html.erb new file mode 100644 index 0000000..58e0196 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/es.html.erb @@ -0,0 +1,9 @@ +Recordatorio alcanzado + +
Buenos días #{recipient.firstname},
+
+
Un ticket necesita atención, recordatorio alcanzado.
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/fr.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/fr.html.erb new file mode 100644 index 0000000..89457ef --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/fr.html.erb @@ -0,0 +1,9 @@ +Rappel atteint + +
Bonjour #{recipient.firstname},
+
+
Un ticket nécessite une attention, un rappel a été atteint.
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/it.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/it.html.erb new file mode 100644 index 0000000..16d44ea --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/it.html.erb @@ -0,0 +1,9 @@ +Promemoria raggiunto + +
Buongiorno #{recipient.firstname},
+
+
Un biglietto ha bisogno di attenzione, promemoria raggiunto.
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/pt-br.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/pt-br.html.erb new file mode 100644 index 0000000..5da2a51 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/pt-br.html.erb @@ -0,0 +1,9 @@ +Lembrete + +
Oi #{recipient.firstname},
+
+
Um chamado precisa de atenção, um lembrete foi.
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/zh-cn.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/zh-cn.html.erb new file mode 100644 index 0000000..0d5f85b --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/zh-cn.html.erb @@ -0,0 +1,9 @@ +有关 的工单提醒 + +
您好, #{recipient.firstname}:
+
+
客户 到达提醒时间, 需要您留意了.
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/zh-tw.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/zh-tw.html.erb new file mode 100644 index 0000000..ede96be --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_reminder_reached/zh-tw.html.erb @@ -0,0 +1,9 @@ +關于 的工單提醒 + +
你好, #{recipient.firstname}:
+
+
客戶 到達提醒時間, 請你留意.
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_update/cs.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_update/cs.html.erb new file mode 100644 index 0000000..4d10ebb --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_update/cs.html.erb @@ -0,0 +1,11 @@ +Aktualizován ticket + +
Dobrý den,
+
+
+Ticket byl aktualizován uživatelem. +
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_update/de.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_update/de.html.erb new file mode 100644 index 0000000..a730fb5 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_update/de.html.erb @@ -0,0 +1,11 @@ +Ticket aktualisiert + +
Hi #{recipient.firstname},
+
+
+Ticket wurde aktualisiert. +
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_update/en.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_update/en.html.erb new file mode 100644 index 0000000..528ca96 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_update/en.html.erb @@ -0,0 +1,11 @@ +Updated Ticket + +
Hi #{recipient.firstname},
+
+
+Ticket has been updated. +
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_update/es.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_update/es.html.erb new file mode 100644 index 0000000..39f4c02 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_update/es.html.erb @@ -0,0 +1,11 @@ +Ticket actualizado + +
Buenos días #{recipient.firstname},
+
+
+Ticket ha sido actualizado. +
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_update/fr.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_update/fr.html.erb new file mode 100644 index 0000000..0fd321e --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_update/fr.html.erb @@ -0,0 +1,11 @@ +Ticket Mis à jour + +
Bonjour #{recipient.firstname},
+
+
+Le ticket a été mis à jour par. +
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_update/it.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_update/it.html.erb new file mode 100644 index 0000000..bb69b3f --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_update/it.html.erb @@ -0,0 +1,11 @@ +Biglietto aggiornato + +
Buongiorno #{recipient.firstname},
+
+
+Ticket è stato aggiornato. +
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_update/pt-br.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_update/pt-br.html.erb new file mode 100644 index 0000000..7bf67f3 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_update/pt-br.html.erb @@ -0,0 +1,11 @@ +Chamado atualizado + +
Oi #{recipient.firstname},
+
+
+O chamado foi atualizado. +
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_update/zh-cn.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_update/zh-cn.html.erb new file mode 100644 index 0000000..22ffdee --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_update/zh-cn.html.erb @@ -0,0 +1,11 @@ +工单 已被更新 + +
您好 #{recipient.firstname},
+
+
+工单 已被 更新. +
+
+ diff --git a/zammad-addon-hardening/src/app/views/mailer/ticket_update/zh-tw.html.erb b/zammad-addon-hardening/src/app/views/mailer/ticket_update/zh-tw.html.erb new file mode 100644 index 0000000..a730360 --- /dev/null +++ b/zammad-addon-hardening/src/app/views/mailer/ticket_update/zh-tw.html.erb @@ -0,0 +1,11 @@ +工單 已被更新 + +
您好 #{recipient.firstname},
+
+
+工單 已被 更新. +
+
+ diff --git a/zammad-addon-hardening/src/db/addon/hardening/.gitkeep b/zammad-addon-hardening/src/db/addon/hardening/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/zammad-addon-hardening/src/db/addon/hardening/20200211123028_hardening_harden_settings.rb b/zammad-addon-hardening/src/db/addon/hardening/20200211123028_hardening_harden_settings.rb new file mode 100644 index 0000000..14bdc31 --- /dev/null +++ b/zammad-addon-hardening/src/db/addon/hardening/20200211123028_hardening_harden_settings.rb @@ -0,0 +1,46 @@ +class HardeningHardenSettings < ActiveRecord::Migration[5.2] + def self.restore_setting(name) + s = Setting.find_by(name: name) + if !s.nil? + s.state_current = s.state_initial + s.save! + end + end + def self.set_setting(name, value) + s = Setting.find_by(name: name) + if !s.nil? + s.state_current = { "value" => value } + s.save! + end + end + def self.up + ["ui_send_client_stats", "geo_ip_backend", "geo_location_backend", "image_backend", "geo_calendar_backend"].each { |n| + self.set_setting(n, "") + } + + # disable customer ticket creation + self.set_setting("customer_ticket_create", false) + + # disable user account registration + self.set_setting("user_create_account", false) + + # bump up min password length + self.set_setting("password_min_size", 10) + + # delete default zammad user + nicole = User.find_by(email: "nicole.braun@zammad.org") + if !nicole.nil? + Ticket.where(customer: nicole).destroy_all + nicole.destroy + end + end + + def self.down + ["ui_send_client_stats", "geo_ip_backend", "geo_location_backend", "image_backend", "geo_calendar_backend"].each { |n| + self.restore_setting(n) + } + ["customer_ticket_create", "user_create_account", "password_min_size"].each { |n| + self.restore_setting(n) + } + end +end diff --git a/zammad-addon-metamigo/.gitignore b/zammad-addon-metamigo/.gitignore new file mode 100644 index 0000000..8b714d5 --- /dev/null +++ b/zammad-addon-metamigo/.gitignore @@ -0,0 +1,12 @@ +.yalc +yalc.lock +node_modules/ +npm-debug.log* +.idea/ +dist/ +.env* +yarn-error.log +docker-compose.yml +coverage +.pgpass +.npmrc diff --git a/zammad-addon-metamigo/.gitlab-ci.yml b/zammad-addon-metamigo/.gitlab-ci.yml new file mode 100644 index 0000000..594afcb --- /dev/null +++ b/zammad-addon-metamigo/.gitlab-ci.yml @@ -0,0 +1,25 @@ +--- +image: registry.gitlab.com/guardianproject-ops/docker-python-node:python3.8-nodejs12 + +stages: + - test + - build + - trigger + +before_script: + - apt-get update && apt-get install -y make ruby + - gem install rufo -v 0.12.0 + - pip install inflection + +test: + stage: test + script: + - echo "Skipping tests" # make test + +build: + stage: build + artifacts: + paths: + - dist/*.szpm + script: + - make diff --git a/zammad-addon-metamigo/.ruby-version b/zammad-addon-metamigo/.ruby-version new file mode 100644 index 0000000..338a5b5 --- /dev/null +++ b/zammad-addon-metamigo/.ruby-version @@ -0,0 +1 @@ +2.6.6 diff --git a/zammad-addon-metamigo/LICENSE.md b/zammad-addon-metamigo/LICENSE.md new file mode 100644 index 0000000..bfcc3db --- /dev/null +++ b/zammad-addon-metamigo/LICENSE.md @@ -0,0 +1,616 @@ +### GNU AFFERO GENERAL PUBLIC LICENSE + +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +### Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains +free software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing +under this license. + +The precise terms and conditions for copying, distribution and +modification follow. + +### TERMS AND CONDITIONS + +#### 0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public +License. + +"Copyright" also means copyright-like laws that apply to other kinds +of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of +an exact copy. The resulting work is called a "modified version" of +the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user +through a computer network, with no transfer of a copy, is not +conveying. + +An interactive user interface displays "Appropriate Legal Notices" to +the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +#### 1. Source Code. + +The "source code" for a work means the preferred form of the work for +making modifications to it. "Object code" means any non-source form of +a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can +regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same +work. + +#### 2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, +without conditions so long as your license otherwise remains in force. +You may convey covered works to others for the sole purpose of having +them make modifications exclusively for you, or provide you with +facilities for running those works, provided that you comply with the +terms of this License in conveying all material for which you do not +control copyright. Those thus making or running the covered works for +you must do so exclusively on your behalf, under your direction and +control, on terms that prohibit them from making any copies of your +copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes +it unnecessary. + +#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such +circumvention is effected by exercising rights under this License with +respect to the covered work, and you disclaim any intention to limit +operation or modification of the work as a means of enforcing, against +the work's users, your or third parties' legal rights to forbid +circumvention of technological measures. + +#### 4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +#### 5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these +conditions: + +- a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. +- b) The work must carry prominent notices stating that it is + released under this License and any conditions added under + section 7. This requirement modifies the requirement in section 4 + to "keep intact all notices". +- c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +#### 6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms of +sections 4 and 5, provided that you also convey the machine-readable +Corresponding Source under the terms of this License, in one of these +ways: + +- a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. +- b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the Corresponding + Source from a network server at no charge. +- c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. +- d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. +- e) Convey the object code using peer-to-peer transmission, + provided you inform other peers where the object code and + Corresponding Source of the work are being offered to the general + public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, +family, or household purposes, or (2) anything designed or sold for +incorporation into a dwelling. In determining whether a product is a +consumer product, doubtful cases shall be resolved in favor of +coverage. For a particular product received by a particular user, +"normally used" refers to a typical or common use of that class of +product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected +to use, the product. A product is a consumer product regardless of +whether the product has substantial commercial, industrial or +non-consumer uses, unless such uses represent the only significant +mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to +install and execute modified versions of a covered work in that User +Product from a modified version of its Corresponding Source. The +information must suffice to ensure that the continued functioning of +the modified object code is in no case prevented or interfered with +solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or +updates for a work that has been modified or installed by the +recipient, or for the User Product in which it has been modified or +installed. Access to a network may be denied when the modification +itself materially and adversely affects the operation of the network +or violates the rules and protocols for communication across the +network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +#### 7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders +of that material) supplement the terms of this License with terms: + +- a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or +- b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or +- c) Prohibiting misrepresentation of the origin of that material, + or requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or +- d) Limiting the use for publicity purposes of names of licensors + or authors of the material; or +- e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or +- f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions + of it) with contractual assumptions of liability to the recipient, + for any liability that these contractual assumptions directly + impose on those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; the +above requirements apply either way. + +#### 8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your license +from a particular copyright holder is reinstated (a) provisionally, +unless and until the copyright holder explicitly and finally +terminates your license, and (b) permanently, if the copyright holder +fails to notify you of the violation by some reasonable means prior to +60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +#### 9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run +a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +#### 10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +#### 11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned +or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within the +scope of its coverage, prohibits the exercise of, or is conditioned on +the non-exercise of one or more of the rights that are specifically +granted under this License. You may not convey a covered work if you +are a party to an arrangement with a third party that is in the +business of distributing software, under which you make payment to the +third party based on the extent of your activity of conveying the +work, and under which the third party grants, to any of the parties +who would receive the covered work from you, a discriminatory patent +license (a) in connection with copies of the covered work conveyed by +you (or copies made from those copies), or (b) primarily for and in +connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +#### 12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under +this License and any other pertinent obligations, then as a +consequence you may not convey it at all. For example, if you agree to +terms that obligate you to collect a royalty for further conveying +from those to whom you convey the Program, the only way you could +satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +#### 13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your +version supports such interaction) an opportunity to receive the +Corresponding Source of your version by providing access to the +Corresponding Source from a network server at no charge, through some +standard or customary means of facilitating copying of software. This +Corresponding Source shall include the Corresponding Source for any +work covered by version 3 of the GNU General Public License that is +incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +#### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU Affero General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever +published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions +of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +#### 15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT +WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND +PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR +CORRECTION. + +#### 16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR +CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT +NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR +LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM +TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER +PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +#### 17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS diff --git a/zammad-addon-metamigo/Makefile b/zammad-addon-metamigo/Makefile new file mode 100644 index 0000000..e97ab25 --- /dev/null +++ b/zammad-addon-metamigo/Makefile @@ -0,0 +1,20 @@ +.PHONY: build prep clean fmt new-migration + +build: prep + @./package.py + @find dist/ -iname "*szpm" + +prep: + @mkdir -p dist + +clean: prep + @rm -rf dist/* + +fmt: + rufo --simple-exit src + +new-migration: + @./new-migration.py + +test: + @echo There are no tests diff --git a/zammad-addon-metamigo/README.md b/zammad-addon-metamigo/README.md new file mode 100644 index 0000000..cbc4c0e --- /dev/null +++ b/zammad-addon-metamigo/README.md @@ -0,0 +1,70 @@ +# zammad-addon-metamigo + +An addon that adds [metamigo](https://gitlab.com/digiresilience/link/metamigo) channels to Zammad. + +## Channels + +This channel creates a three channels: "Voice", "Signal" and "Whatsapp". + +To submit a ticket: make a POST to the Submission Endpoint with the header +`Authorization: SUBMISSION_TOKEN`. + +The payload for the Voice channel must be a json object with the keys: + +- `startTime` - string containing ISO date +- `endTime` - string containing ISO date +- `to` - fully qualified phone number +- `from` - fully qualified phone number +- `duration` - string containing the recording duration +- `callSid` - the unique identifier for the call +- `recording` - string base64 encoded binary of the recording +- `mimeType` - string of the binary mime-type + +The payload for the Signal channel must be a json object with the keys: + +- TBD + +The payload for the Whatsapp channel must be a json object with the keys: + +- TBD + +## Development + +1. Edit the files in `src/` + + Migration files should go in `src/db/addon/CHANNEL_NAME` ([see this post](https://community.zammad.org/t/automating-creation-of-custom-object-attributes/3831/2?u=abelxluck)) + +2. Update version and changelog in `metamigo-skeleton.szpm` +3. Build a new package `make` + + This outputs `dist/metamigo-vXXX.szpm` + +4. Install the szpm using the zammad package manager. + +5. Repeat + +### Create a new migration + +Included is a helper script to create new migrations. You must have the python +`inflection` library installed. + +- debian/ubuntu: `apt install python3-inflection` +- pip: `pip install --user inflection` +- or create your own venv + +To make a new migration simply run: + +``` +make new-migration +``` + +## License + +[![License GNU AGPL v3.0](https://img.shields.io/badge/License-AGPL%203.0-lightgrey.svg)](https://gitlab.com/digiresilience/link/zamamd-addon-metamigo/blob/master/LICENSE.md) + +This is a free software project licensed under the GNU Affero General +Public License v3.0 (GNU AGPLv3) by [The Center for Digital +Resilience](https://digiresilience.org) and [Guardian +Project](https://guardianproject.info). + +🐻 diff --git a/zammad-addon-metamigo/metamigo.szpm.template b/zammad-addon-metamigo/metamigo.szpm.template new file mode 100644 index 0000000..c8fb1d0 --- /dev/null +++ b/zammad-addon-metamigo/metamigo.szpm.template @@ -0,0 +1,24 @@ +{ + "name": "Metamigo", + "version": "0.0.6", + "vendor": "Center for Digital Resilience", + "license": "AGPL-v3+", + "url": "https://digiresilience.org", + "buildhost": "", + "builddate": "", + "change_log": [ + { + "version": "0.0.1", + "date": "", + "log": "Initial version." + } + ], + "description": [ + { + "language": "en", + "text": "Tickets created from voicemails and messages" + } + ], + "files": [ + ] +} diff --git a/zammad-addon-metamigo/new-migration.py b/zammad-addon-metamigo/new-migration.py new file mode 100755 index 0000000..c8e6a7e --- /dev/null +++ b/zammad-addon-metamigo/new-migration.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +import pathlib +import os +import json +import glob +import inflection +from datetime import datetime +from collections import OrderedDict + +migration_template = """class {} < ActiveRecord::Migration[5.2] + def self.up + # add your code here + end + + def self.down + # add your code here + end +end +""" + + +def load_skeleton(): + t = glob.glob('*.szpm.template') + if len(t) != 1: + raise Exception("Cannot find szpm template") + with open(t[0], 'r', encoding='utf-8') as f: + skeleton = json.load(f, object_pairs_hook=OrderedDict) + return skeleton + + +def main(): + skeleton = load_skeleton() + name = skeleton["name"].lower() + raw_name = input("Enter migration name (no spaces, no symbols!): ") + migration_base_name = "{}_{}".format(name, inflection.underscore(raw_name)) + migration_name = inflection.camelize(migration_base_name, uppercase_first_letter=True) + contents = migration_template.format(migration_name) + time = datetime.utcnow().strftime("%Y%m%d%H%M%S") + migration_file_name = "{}_{}.rb".format(time, migration_base_name) + dir_path = os.path.join("src/db/addon", name) + pathlib.Path(dir_path).mkdir(parents=True, exist_ok=True) + with open(os.path.join(dir_path, migration_file_name), 'w') as f: + f.write(contents) + + +main() diff --git a/zammad-addon-metamigo/package.py b/zammad-addon-metamigo/package.py new file mode 100755 index 0000000..e82a5cb --- /dev/null +++ b/zammad-addon-metamigo/package.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +import os +import base64 +import json +import datetime +import platform +import glob +import re +from collections import OrderedDict + +# files matching this pattern are not included in the package +ignored_patterns = [ + "\.gitkeep" +] + + +def encode(fname): + data = open(fname, "rb").read() + return base64.b64encode(data).decode('utf-8') + + +def read_perm(fname): + return int(oct(os.stat(fname).st_mode & 0o777)[-3:]) + + +def format_file(content, pkg_path, permission): + return OrderedDict( + location=pkg_path, + permission=permission, + encode="base64", + content=content) + + +def pkg_file(actual_path): + print(" Packaging: {}".format(actual_path)) + pkg_path = actual_path[6:] + contents = encode(actual_path) + res = format_file(contents, pkg_path, read_perm(actual_path)) + return res + + +def pkg_files(): + pkged_files = [] + for root, dirs, files in os.walk("./src/"): + for f in files: + if any(re.search(r, f) for r in ignored_patterns): + continue + actual_path = os.path.join(root, f) + pkged_files.append(pkg_file(actual_path)) + return pkged_files + + +def load_skeleton(): + t = glob.glob('*.szpm.template') + if len(t) != 1: + raise Exception("Cannot find szpm template") + with open(t[0], 'r', encoding='utf-8') as f: + skeleton = json.load(f, object_pairs_hook=OrderedDict) + return skeleton + + +def main(): + files = pkg_files() + skeleton = load_skeleton() + skeleton["files"] = files + skeleton["builddate"] = datetime.datetime.utcnow().isoformat() + skeleton["buildhost"] = platform.node() + name = skeleton["name"].lower() + version = skeleton["version"] + pkg = json.dumps(skeleton, indent=2) + with open("dist/{}-v{}.szpm".format(name, version), "w", encoding='utf-8') as f: + f.write(pkg) + + +main() diff --git a/zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/_channel/cdr_signal.coffee b/zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/_channel/cdr_signal.coffee new file mode 100644 index 0000000..a4deac2 --- /dev/null +++ b/zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/_channel/cdr_signal.coffee @@ -0,0 +1,249 @@ +class Index extends App.ControllerSubContent + requiredPermission: 'admin.channel_cdr_signal' + events: + 'click .js-new': 'new' + 'click .js-edit': 'edit' + 'click .js-delete': 'delete' + 'click .js-disable': 'disable' + 'click .js-enable': 'enable' + 'click .js-rotate-token': 'rotateToken' + + constructor: -> + super + + #@interval(@load, 60000) + @load() + + load: => + @startLoading() + @ajax( + id: 'cdr_signal_index' + type: 'GET' + url: "#{@apiPath}/channels_cdr_signal" + processData: true + success: (data) => + @stopLoading() + App.Collection.loadAssets(data.assets) + @render(data) + ) + + render: (data) => + + channels = [] + for channel_id in data.channel_ids + channel = App.Channel.find(channel_id) + if channel && channel.options + displayName = '-' + if channel.group_id + group = App.Group.find(channel.group_id) + displayName = group.displayName() + channel.options.groupName = displayName + channels.push channel + @html App.view('cdr_signal/index')( + channels: channels + ) + + new: (e) => + e.preventDefault() + new FormAdd( + container: @el.parents('.content') + load: @load + ) + + edit: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + channel = App.Channel.find(id) + new FormEdit( + channel: channel + container: @el.parents('.content') + load: @load + ) + + delete: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + new App.ControllerConfirm( + message: 'Sure?' + callback: => + @ajax( + id: 'cdr_signal_delete' + type: 'DELETE' + url: "#{@apiPath}/channels_cdr_signal" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + container: @el.closest('.content') + ) + + rotateToken: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + + new App.ControllerConfirm( + message: 'This will break the submission form!' + buttonSubmit: 'Reset token' + head: 'Reset the submission token?' + callback: => + @ajax( + id: 'cdr_signal_disable' + type: 'POST' + url: "#{@apiPath}/channels_cdr_signal_rotate_token" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + container: @el.closest('.content') + ) + disable: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @ajax( + id: 'cdr_signal_disable' + type: 'POST' + url: "#{@apiPath}/channels_cdr_signal_disable" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + + enable: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @ajax( + id: 'cdr_signal_enable' + type: 'POST' + url: "#{@apiPath}/channels_cdr_signal_enable" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + +class FormAdd extends App.ControllerModal + head: 'Add Web Form' + shown: true + button: 'Add' + buttonCancel: true + small: true + + content: -> + content = $(App.view('cdr_signal/form_add')()) + createOrgSelection = (selected_id) -> + return App.UiElement.select.render( + name: 'organization_id' + multiple: false + limit: 100 + null: false + relation: 'Organization' + nulloption: true + value: selected_id + class: 'form-control--small' + ) + createGroupSelection = (selected_id) -> + return App.UiElement.select.render( + name: 'group_id' + multiple: false + limit: 100 + null: false + relation: 'Group' + nulloption: true + value: selected_id + class: 'form-control--small' + ) + + content.find('.js-select').on('click', (e) => + @selectAll(e) + ) + content.find('.js-messagesGroup').replaceWith createGroupSelection(1) + content.find('.js-organization').replaceWith createOrgSelection(null) + content + + onClosed: => + return if !@isChanged + @isChanged = false + @load() + + onSubmit: (e) => + @formDisable(e) + @ajax( + id: 'cdr_signal_app_verify' + type: 'POST' + url: "#{@apiPath}/channels_cdr_signal" + data: JSON.stringify(@formParams()) + processData: true + success: => + @isChanged = true + @close() + error: (xhr) => + data = JSON.parse(xhr.responseText) + @formEnable(e) + error_message = App.i18n.translateContent(data.error || 'Unable to save Web Form.') + @el.find('.alert').removeClass('hidden').text(error_message) + ) + +class FormEdit extends App.ControllerModal + head: 'Web Form Info' + shown: true + buttonCancel: true + + content: -> + content = $(App.view('cdr_signal/form_edit')(channel: @channel)) + + createOrgSelection = (selected_id) -> + return App.UiElement.select.render( + name: 'organization_id' + multiple: false + limit: 100 + null: false + relation: 'Organization' + nulloption: true + value: selected_id + class: 'form-control--small' + ) + createGroupSelection = (selected_id) -> + return App.UiElement.select.render( + name: 'group_id' + multiple: false + limit: 100 + null: false + relation: 'Group' + nulloption: true + value: selected_id + class: 'form-control--small' + ) + + content.find('.js-messagesGroup').replaceWith createGroupSelection(@channel.group_id) + content.find('.js-organization').replaceWith createOrgSelection(@channel.options.organization_id) + content + + onClosed: => + return if !@isChanged + @isChanged = false + @load() + + onSubmit: (e) => + @formDisable(e) + params = @formParams() + @channel.options = params + @ajax( + id: 'channel_cdr_signal_update' + type: 'PUT' + url: "#{@apiPath}/channels_cdr_signal/#{@channel.id}" + data: JSON.stringify(@formParams()) + processData: true + success: => + @isChanged = true + @close() + error: (xhr) => + data = JSON.parse(xhr.responseText) + @formEnable(e) + error_message = App.i18n.translateContent(data.error || 'Unable to save changes.') + @el.find('.alert').removeClass('hidden').text(error_message) + ) + +App.Config.set('cdr_signal', { prio: 5100, name: 'Signal', parent: '#channels', target: '#channels/cdr_signal', controller: Index, permission: ['admin.channel_cdr_signal'] }, 'NavBarAdmin') diff --git a/zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/_channel/cdr_voice.coffee b/zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/_channel/cdr_voice.coffee new file mode 100644 index 0000000..72862ec --- /dev/null +++ b/zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/_channel/cdr_voice.coffee @@ -0,0 +1,249 @@ +class Index extends App.ControllerSubContent + requiredPermission: 'admin.channel_cdr_voice' + events: + 'click .js-new': 'new' + 'click .js-edit': 'edit' + 'click .js-delete': 'delete' + 'click .js-disable': 'disable' + 'click .js-enable': 'enable' + 'click .js-rotate-token': 'rotateToken' + + constructor: -> + super + + #@interval(@load, 60000) + @load() + + load: => + @startLoading() + @ajax( + id: 'cdr_voice_index' + type: 'GET' + url: "#{@apiPath}/channels_cdr_voice" + processData: true + success: (data) => + @stopLoading() + App.Collection.loadAssets(data.assets) + @render(data) + ) + + render: (data) => + + channels = [] + for channel_id in data.channel_ids + channel = App.Channel.find(channel_id) + if channel && channel.options + displayName = '-' + if channel.group_id + group = App.Group.find(channel.group_id) + displayName = group.displayName() + channel.options.groupName = displayName + channels.push channel + @html App.view('cdr_voice/index')( + channels: channels + ) + + new: (e) => + e.preventDefault() + new FormAdd( + container: @el.parents('.content') + load: @load + ) + + edit: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + channel = App.Channel.find(id) + new FormEdit( + channel: channel + container: @el.parents('.content') + load: @load + ) + + delete: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + new App.ControllerConfirm( + message: 'Sure?' + callback: => + @ajax( + id: 'cdr_voice_delete' + type: 'DELETE' + url: "#{@apiPath}/channels_cdr_voice" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + container: @el.closest('.content') + ) + + rotateToken: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + + new App.ControllerConfirm( + message: 'This will break the submission form!' + buttonSubmit: 'Reset token' + head: 'Reset the submission token?' + callback: => + @ajax( + id: 'cdr_voice_disable' + type: 'POST' + url: "#{@apiPath}/channels_cdr_voice_rotate_token" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + container: @el.closest('.content') + ) + disable: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @ajax( + id: 'cdr_voice_disable' + type: 'POST' + url: "#{@apiPath}/channels_cdr_voice_disable" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + + enable: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @ajax( + id: 'cdr_voice_enable' + type: 'POST' + url: "#{@apiPath}/channels_cdr_voice_enable" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + +class FormAdd extends App.ControllerModal + head: 'Add Web Form' + shown: true + button: 'Add' + buttonCancel: true + small: true + + content: -> + content = $(App.view('cdr_voice/form_add')()) + createOrgSelection = (selected_id) -> + return App.UiElement.select.render( + name: 'organization_id' + multiple: false + limit: 100 + null: false + relation: 'Organization' + nulloption: true + value: selected_id + class: 'form-control--small' + ) + createGroupSelection = (selected_id) -> + return App.UiElement.select.render( + name: 'group_id' + multiple: false + limit: 100 + null: false + relation: 'Group' + nulloption: true + value: selected_id + class: 'form-control--small' + ) + + content.find('.js-select').on('click', (e) => + @selectAll(e) + ) + content.find('.js-messagesGroup').replaceWith createGroupSelection(1) + content.find('.js-organization').replaceWith createOrgSelection(null) + content + + onClosed: => + return if !@isChanged + @isChanged = false + @load() + + onSubmit: (e) => + @formDisable(e) + @ajax( + id: 'cdr_voice_app_verify' + type: 'POST' + url: "#{@apiPath}/channels_cdr_voice" + data: JSON.stringify(@formParams()) + processData: true + success: => + @isChanged = true + @close() + error: (xhr) => + data = JSON.parse(xhr.responseText) + @formEnable(e) + error_message = App.i18n.translateContent(data.error || 'Unable to save Web Form.') + @el.find('.alert').removeClass('hidden').text(error_message) + ) + +class FormEdit extends App.ControllerModal + head: 'Web Form Info' + shown: true + buttonCancel: true + + content: -> + content = $(App.view('cdr_voice/form_edit')(channel: @channel)) + + createOrgSelection = (selected_id) -> + return App.UiElement.select.render( + name: 'organization_id' + multiple: false + limit: 100 + null: false + relation: 'Organization' + nulloption: true + value: selected_id + class: 'form-control--small' + ) + createGroupSelection = (selected_id) -> + return App.UiElement.select.render( + name: 'group_id' + multiple: false + limit: 100 + null: false + relation: 'Group' + nulloption: true + value: selected_id + class: 'form-control--small' + ) + + content.find('.js-messagesGroup').replaceWith createGroupSelection(@channel.group_id) + content.find('.js-organization').replaceWith createOrgSelection(@channel.options.organization_id) + content + + onClosed: => + return if !@isChanged + @isChanged = false + @load() + + onSubmit: (e) => + @formDisable(e) + params = @formParams() + @channel.options = params + @ajax( + id: 'channel_cdr_voice_update' + type: 'PUT' + url: "#{@apiPath}/channels_cdr_voice/#{@channel.id}" + data: JSON.stringify(@formParams()) + processData: true + success: => + @isChanged = true + @close() + error: (xhr) => + data = JSON.parse(xhr.responseText) + @formEnable(e) + error_message = App.i18n.translateContent(data.error || 'Unable to save changes.') + @el.find('.alert').removeClass('hidden').text(error_message) + ) + +App.Config.set('cdr_voice', { prio: 5100, name: 'Voice', parent: '#channels', target: '#channels/cdr_voice', controller: Index, permission: ['admin.channel_cdr_voice'] }, 'NavBarAdmin') diff --git a/zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/_channel/cdr_whatsapp.coffee b/zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/_channel/cdr_whatsapp.coffee new file mode 100644 index 0000000..5689e67 --- /dev/null +++ b/zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/_channel/cdr_whatsapp.coffee @@ -0,0 +1,249 @@ +class Index extends App.ControllerSubContent + requiredPermission: 'admin.channel_cdr_whatsapp' + events: + 'click .js-new': 'new' + 'click .js-edit': 'edit' + 'click .js-delete': 'delete' + 'click .js-disable': 'disable' + 'click .js-enable': 'enable' + 'click .js-rotate-token': 'rotateToken' + + constructor: -> + super + + #@interval(@load, 60000) + @load() + + load: => + @startLoading() + @ajax( + id: 'cdr_whatsapp_index' + type: 'GET' + url: "#{@apiPath}/channels_cdr_whatsapp" + processData: true + success: (data) => + @stopLoading() + App.Collection.loadAssets(data.assets) + @render(data) + ) + + render: (data) => + + channels = [] + for channel_id in data.channel_ids + channel = App.Channel.find(channel_id) + if channel && channel.options + displayName = '-' + if channel.group_id + group = App.Group.find(channel.group_id) + displayName = group.displayName() + channel.options.groupName = displayName + channels.push channel + @html App.view('cdr_whatsapp/index')( + channels: channels + ) + + new: (e) => + e.preventDefault() + new FormAdd( + container: @el.parents('.content') + load: @load + ) + + edit: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + channel = App.Channel.find(id) + new FormEdit( + channel: channel + container: @el.parents('.content') + load: @load + ) + + delete: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + new App.ControllerConfirm( + message: 'Sure?' + callback: => + @ajax( + id: 'cdr_whatsapp_delete' + type: 'DELETE' + url: "#{@apiPath}/channels_cdr_whatsapp" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + container: @el.closest('.content') + ) + + rotateToken: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + + new App.ControllerConfirm( + message: 'This will break the submission form!' + buttonSubmit: 'Reset token' + head: 'Reset the submission token?' + callback: => + @ajax( + id: 'cdr_whatsapp_disable' + type: 'POST' + url: "#{@apiPath}/channels_cdr_whatsapp_rotate_token" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + container: @el.closest('.content') + ) + disable: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @ajax( + id: 'cdr_whatsapp_disable' + type: 'POST' + url: "#{@apiPath}/channels_cdr_whatsapp_disable" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + + enable: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @ajax( + id: 'cdr_whatsapp_enable' + type: 'POST' + url: "#{@apiPath}/channels_cdr_whatsapp_enable" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + +class FormAdd extends App.ControllerModal + head: 'Add Web Form' + shown: true + button: 'Add' + buttonCancel: true + small: true + + content: -> + content = $(App.view('cdr_whatsapp/form_add')()) + createOrgSelection = (selected_id) -> + return App.UiElement.select.render( + name: 'organization_id' + multiple: false + limit: 100 + null: false + relation: 'Organization' + nulloption: true + value: selected_id + class: 'form-control--small' + ) + createGroupSelection = (selected_id) -> + return App.UiElement.select.render( + name: 'group_id' + multiple: false + limit: 100 + null: false + relation: 'Group' + nulloption: true + value: selected_id + class: 'form-control--small' + ) + + content.find('.js-select').on('click', (e) => + @selectAll(e) + ) + content.find('.js-messagesGroup').replaceWith createGroupSelection(1) + content.find('.js-organization').replaceWith createOrgSelection(null) + content + + onClosed: => + return if !@isChanged + @isChanged = false + @load() + + onSubmit: (e) => + @formDisable(e) + @ajax( + id: 'cdr_whatsapp_app_verify' + type: 'POST' + url: "#{@apiPath}/channels_cdr_whatsapp" + data: JSON.stringify(@formParams()) + processData: true + success: => + @isChanged = true + @close() + error: (xhr) => + data = JSON.parse(xhr.responseText) + @formEnable(e) + error_message = App.i18n.translateContent(data.error || 'Unable to save Web Form.') + @el.find('.alert').removeClass('hidden').text(error_message) + ) + +class FormEdit extends App.ControllerModal + head: 'Web Form Info' + shown: true + buttonCancel: true + + content: -> + content = $(App.view('cdr_whatsapp/form_edit')(channel: @channel)) + + createOrgSelection = (selected_id) -> + return App.UiElement.select.render( + name: 'organization_id' + multiple: false + limit: 100 + null: false + relation: 'Organization' + nulloption: true + value: selected_id + class: 'form-control--small' + ) + createGroupSelection = (selected_id) -> + return App.UiElement.select.render( + name: 'group_id' + multiple: false + limit: 100 + null: false + relation: 'Group' + nulloption: true + value: selected_id + class: 'form-control--small' + ) + + content.find('.js-messagesGroup').replaceWith createGroupSelection(@channel.group_id) + content.find('.js-organization').replaceWith createOrgSelection(@channel.options.organization_id) + content + + onClosed: => + return if !@isChanged + @isChanged = false + @load() + + onSubmit: (e) => + @formDisable(e) + params = @formParams() + @channel.options = params + @ajax( + id: 'channel_cdr_whatsapp_update' + type: 'PUT' + url: "#{@apiPath}/channels_cdr_whatsapp/#{@channel.id}" + data: JSON.stringify(@formParams()) + processData: true + success: => + @isChanged = true + @close() + error: (xhr) => + data = JSON.parse(xhr.responseText) + @formEnable(e) + error_message = App.i18n.translateContent(data.error || 'Unable to save changes.') + @el.find('.alert').removeClass('hidden').text(error_message) + ) + +App.Config.set('cdr_whatsapp', { prio: 5100, name: 'Whatsapp', parent: '#channels', target: '#channels/cdr_whatsapp', controller: Index, permission: ['admin.channel_cdr_whatsapp'] }, 'NavBarAdmin') diff --git a/zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/ticket_zoom/article_action/cdr_signal.coffee b/zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/ticket_zoom/article_action/cdr_signal.coffee new file mode 100644 index 0000000..1774610 --- /dev/null +++ b/zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/ticket_zoom/article_action/cdr_signal.coffee @@ -0,0 +1,79 @@ +class CdrSignalReply + @action: (actions, ticket, article, ui) -> + return actions if ui.permissionCheck('ticket.customer') + + if article.sender.name is 'Customer' && article.type.name is 'cdr_signal' + actions.push { + name: 'reply' + type: 'cdrSignalMessageReply' + icon: 'reply' + href: '#' + } + + actions + + @perform: (articleContainer, type, ticket, article, ui) -> + return true if type isnt 'cdrSignalMessageReply' + + ui.scrollToCompose() + + # get reference article + type = App.TicketArticleType.find(article.type_id) + + articleNew = { + to: '' + cc: '' + body: '' + in_reply_to: '' + } + + if article.message_id + articleNew.in_reply_to = article.message_id + + # get current body + articleNew.body = ui.el.closest('.ticketZoom').find('.article-add [data-name="body"]').html().trim() || '' + + App.Event.trigger('ui::ticket::setArticleType', { + ticket: ticket + type: type + article: articleNew + position: 'end' + }) + + true + + @articleTypes: (articleTypes, ticket, ui) -> + return articleTypes if !ui.permissionCheck('ticket.agent') + + return articleTypes if !ticket || !ticket.create_article_type_id + + articleTypeCreate = App.TicketArticleType.find(ticket.create_article_type_id).name + + return articleTypes if articleTypeCreate isnt 'cdr_signal' + articleTypes.push { + name: 'cdr_signal' + icon: 'cdr-signal' + attributes: [] + internal: false, + features: ['attachment'] + maxTextLength: 10000 + warningTextLength: 5000 + } + articleTypes + + @setArticleTypePost: (type, ticket, ui) -> + return if type isnt 'cdr_signal' + rawHTML = ui.$('[data-name=body]').html() + cleanHTML = App.Utils.htmlRemoveRichtext(rawHTML) + if cleanHTML && cleanHTML.html() != rawHTML + ui.$('[data-name=body]').html(cleanHTML) + + @params: (type, params, ui) -> + if type is 'cdr_signal' + App.Utils.htmlRemoveRichtext(ui.$('[data-name=body]'), false) + params.content_type = 'text/plain' + params.body = App.Utils.html2text(params.body, true) + + params + +App.Config.set('300-CdrSignalReply', CdrSignalReply, 'TicketZoomArticleAction') diff --git a/zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/ticket_zoom/article_action/cdr_whatsapp.coffee b/zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/ticket_zoom/article_action/cdr_whatsapp.coffee new file mode 100644 index 0000000..7bdf9d6 --- /dev/null +++ b/zammad-addon-metamigo/src/app/assets/javascripts/app/controllers/ticket_zoom/article_action/cdr_whatsapp.coffee @@ -0,0 +1,79 @@ +class CdrWhatsappReply + @action: (actions, ticket, article, ui) -> + return actions if ui.permissionCheck('ticket.customer') + + if article.sender.name is 'Customer' && article.type.name is 'cdr_whatsapp' + actions.push { + name: 'reply' + type: 'cdrWhatsappMessageReply' + icon: 'reply' + href: '#' + } + + actions + + @perform: (articleContainer, type, ticket, article, ui) -> + return true if type isnt 'cdrWhatsappMessageReply' + + ui.scrollToCompose() + + # get reference article + type = App.TicketArticleType.find(article.type_id) + + articleNew = { + to: '' + cc: '' + body: '' + in_reply_to: '' + } + + if article.message_id + articleNew.in_reply_to = article.message_id + + # get current body + articleNew.body = ui.el.closest('.ticketZoom').find('.article-add [data-name="body"]').html().trim() || '' + + App.Event.trigger('ui::ticket::setArticleType', { + ticket: ticket + type: type + article: articleNew + position: 'end' + }) + + true + + @articleTypes: (articleTypes, ticket, ui) -> + return articleTypes if !ui.permissionCheck('ticket.agent') + + return articleTypes if !ticket || !ticket.create_article_type_id + + articleTypeCreate = App.TicketArticleType.find(ticket.create_article_type_id).name + + return articleTypes if articleTypeCreate isnt 'cdr_whatsapp' + articleTypes.push { + name: 'cdr_whatsapp' + icon: 'cdr-whatsapp' + attributes: [] + internal: false, + features: ['attachment'] + maxTextLength: 10000 + warningTextLength: 5000 + } + articleTypes + + @setArticleTypePost: (type, ticket, ui) -> + return if type isnt 'cdr_whatsapp' + rawHTML = ui.$('[data-name=body]').html() + cleanHTML = App.Utils.htmlRemoveRichtext(rawHTML) + if cleanHTML && cleanHTML.html() != rawHTML + ui.$('[data-name=body]').html(cleanHTML) + + @params: (type, params, ui) -> + if type is 'cdr_whatsapp' + App.Utils.htmlRemoveRichtext(ui.$('[data-name=body]'), false) + params.content_type = 'text/plain' + params.body = App.Utils.html2text(params.body, true) + + params + +App.Config.set('300-CdrWhatsappReply', CdrWhatsappReply, 'TicketZoomArticleAction') diff --git a/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_signal/form_add.jst.eco b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_signal/form_add.jst.eco new file mode 100644 index 0000000..4bf488d --- /dev/null +++ b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_signal/form_add.jst.eco @@ -0,0 +1,47 @@ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+
+
diff --git a/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_signal/form_edit.jst.eco b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_signal/form_edit.jst.eco new file mode 100644 index 0000000..fc033df --- /dev/null +++ b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_signal/form_edit.jst.eco @@ -0,0 +1,55 @@ + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ " class="form-control input js-select" readonly> +
+
+
diff --git a/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_signal/index.jst.eco b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_signal/index.jst.eco new file mode 100644 index 0000000..9bd157e --- /dev/null +++ b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_signal/index.jst.eco @@ -0,0 +1,49 @@ + + +
+ +<% if _.isEmpty(@channels): %> +
+

<%- @T('You have no configured %s right now.', 'Signal numbers') %>

+
+<% else: %> + +<% for channel in @channels: %> +
+
+

<%- @Icon('status', 'supergood-color inline') %> <%= channel.options.phone_number %>

+
+
+
+

<%- @T('Group') %>

+ <% if channel.options: %> + <%= channel.options.groupName %> + <% end %> +
+ +
+

<%- @T('Endpoint URL') %>

+ <%- @T('Click the edit button to view the endpoint details ') %> +
+
+
+
<%- @T('Delete') %>
+
<%- @T('Reset Token') %>
+ <% if channel.active is true: %> +
<%- @T('Disable') %>
+ <% else: %> +
<%- @T('Enable') %>
+ <% end %> +
<%- @T('Edit') %>
+
+
+<% end %> +
diff --git a/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_voice/form_add.jst.eco b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_voice/form_add.jst.eco new file mode 100644 index 0000000..6ebde48 --- /dev/null +++ b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_voice/form_add.jst.eco @@ -0,0 +1,29 @@ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+
+
diff --git a/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_voice/form_edit.jst.eco b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_voice/form_edit.jst.eco new file mode 100644 index 0000000..3362b8b --- /dev/null +++ b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_voice/form_edit.jst.eco @@ -0,0 +1,37 @@ + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ " class="form-control input js-select" readonly> +
+
+
diff --git a/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_voice/index.jst.eco b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_voice/index.jst.eco new file mode 100644 index 0000000..7ab8468 --- /dev/null +++ b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_voice/index.jst.eco @@ -0,0 +1,49 @@ + + +
+ +<% if _.isEmpty(@channels): %> +
+

<%- @T('You have no configured %s right now.', 'voice lines') %>

+
+<% else: %> + +<% for channel in @channels: %> +
+
+

<%- @Icon('status', 'supergood-color inline') %> <%= channel.options.phone_number %>

+
+
+
+

<%- @T('Group') %>

+ <% if channel.options: %> + <%= channel.options.groupName %> + <% end %> +
+ +
+

<%- @T('Endpoint URL') %>

+ <%- @T('Click the edit button to view the endpoint details ') %> +
+
+
+
<%- @T('Delete') %>
+
<%- @T('Reset Token') %>
+ <% if channel.active is true: %> +
<%- @T('Disable') %>
+ <% else: %> +
<%- @T('Enable') %>
+ <% end %> +
<%- @T('Edit') %>
+
+
+<% end %> +
diff --git a/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_whatsapp/form_add.jst.eco b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_whatsapp/form_add.jst.eco new file mode 100644 index 0000000..297c8be --- /dev/null +++ b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_whatsapp/form_add.jst.eco @@ -0,0 +1,47 @@ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+
+
diff --git a/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_whatsapp/form_edit.jst.eco b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_whatsapp/form_edit.jst.eco new file mode 100644 index 0000000..7c44c08 --- /dev/null +++ b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_whatsapp/form_edit.jst.eco @@ -0,0 +1,55 @@ + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ " class="form-control input js-select" readonly> +
+
+
diff --git a/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_whatsapp/index.jst.eco b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_whatsapp/index.jst.eco new file mode 100644 index 0000000..3151c60 --- /dev/null +++ b/zammad-addon-metamigo/src/app/assets/javascripts/app/views/cdr_whatsapp/index.jst.eco @@ -0,0 +1,49 @@ + + +
+ +<% if _.isEmpty(@channels): %> +
+

<%- @T('You have no configured %s right now.', 'Whatsapp numbers') %>

+
+<% else: %> + +<% for channel in @channels: %> +
+
+

<%- @Icon('status', 'supergood-color inline') %> <%= channel.options.phone_number %>

+
+
+
+

<%- @T('Group') %>

+ <% if channel.options: %> + <%= channel.options.groupName %> + <% end %> +
+ +
+

<%- @T('Endpoint URL') %>

+ <%- @T('Click the edit button to view the endpoint details ') %> +
+
+
+
<%- @T('Delete') %>
+
<%- @T('Reset Token') %>
+ <% if channel.active is true: %> +
<%- @T('Disable') %>
+ <% else: %> +
<%- @T('Enable') %>
+ <% end %> +
<%- @T('Edit') %>
+
+
+<% end %> +
diff --git a/zammad-addon-metamigo/src/app/assets/stylesheets/addons/cdr_signal.css b/zammad-addon-metamigo/src/app/assets/stylesheets/addons/cdr_signal.css new file mode 100644 index 0000000..7966c0c --- /dev/null +++ b/zammad-addon-metamigo/src/app/assets/stylesheets/addons/cdr_signal.css @@ -0,0 +1,4 @@ +.icon-cdr-signal { + width: 17px; + height: 17px; +} diff --git a/zammad-addon-metamigo/src/app/assets/stylesheets/addons/cdr_whatsapp.css b/zammad-addon-metamigo/src/app/assets/stylesheets/addons/cdr_whatsapp.css new file mode 100644 index 0000000..aa9aa0f --- /dev/null +++ b/zammad-addon-metamigo/src/app/assets/stylesheets/addons/cdr_whatsapp.css @@ -0,0 +1,4 @@ +.icon-cdr-whatsapp { + width: 17px; + height: 17px; +} diff --git a/zammad-addon-metamigo/src/app/controllers/channels_cdr_signal_controller.rb b/zammad-addon-metamigo/src/app/controllers/channels_cdr_signal_controller.rb new file mode 100644 index 0000000..3eb7c94 --- /dev/null +++ b/zammad-addon-metamigo/src/app/controllers/channels_cdr_signal_controller.rb @@ -0,0 +1,268 @@ +# frozen_string_literal: true + +class ChannelsCdrSignalController < ApplicationController + prepend_before_action -> { authentication_check && authorize! }, except: [:webhook] + skip_before_action :verify_csrf_token, only: [:webhook] + + include CreatesTicketArticles + + def index + assets = {} + channel_ids = [] + Channel.where(area: 'Signal::Number').order(:id).each do |channel| + assets = channel.assets(assets) + channel_ids.push channel.id + end + render json: { + assets: assets, + channel_ids: channel_ids + } + end + + def add + begin + errors = {} + errors['group_id'] = 'required' unless params[:group_id].present? + + if errors.present? + render json: { + errors: errors + }, status: :bad_request + return + end + channel = Channel.create( + area: 'Signal::Number', + options: { + adapter: 'cdr_signal', + phone_number: params[:phone_number], + bot_token: params[:bot_token], + bot_endpoint: params[:bot_endpoint], + token: SecureRandom.urlsafe_base64(48), + organization_id: params[:organization_id] + }, + group_id: params[:group_id], + active: true + ) + rescue StandardError => e + raise Exceptions::UnprocessableEntity, e.message + end + render json: channel + end + + def update + errors = {} + errors['group_id'] = 'required' unless params[:group_id].present? + + if errors.present? + render json: { + errors: errors + }, status: :bad_request + return + end + channel = Channel.find_by(id: params[:id], area: 'Signal::Number') + begin + channel.options[:phone_number] = params[:phone_number] + channel.options[:bot_token] = params[:bot_token] + channel.options[:bot_endpoint] = params[:bot_endpoint] + channel.options[:organization_id] = params[:organization_id] + channel.group_id = params[:group_id] + channel.save! + rescue StandardError => e + raise Exceptions::UnprocessableEntity, e.message + end + render json: channel + end + + def rotate_token + channel = Channel.find_by(id: params[:id], area: 'Signal::Number') + channel.options[:token] = SecureRandom.urlsafe_base64(48) + channel.save! + render json: {} + end + + def enable + channel = Channel.find_by(id: params[:id], area: 'Signal::Number') + channel.active = true + channel.save! + render json: {} + end + + def disable + channel = Channel.find_by(id: params[:id], area: 'Signal::Number') + channel.active = false + channel.save! + render json: {} + end + + def destroy + channel = Channel.find_by(id: params[:id], area: 'Signal::Number') + channel.destroy + render json: {} + end + + def channel_for_token(token) + return false unless token + + Channel.where(area: 'Signal::Number').each do |channel| + return channel if channel.options[:token] == token + end + false + end + + def webhook + token = params['token'] + return render json: {}, status: 401 unless token + + channel = channel_for_token(token) + return render json: {}, status: 401 if !channel || !channel.active + return render json: {}, status: 401 if channel.options[:token] != token + + channel_id = channel.id + + # validate input + errors = {} + + # %i[to + # from + # message_id + # sent_at].each | field | + # (errors[field] = 'required' if params[field].blank?) + + if errors.present? + render json: { + errors: errors + }, status: :bad_request + return + end + + message_id = params[:message_id] + + return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}") + + receiver_phone_number = params[:to].strip + sender_phone_number = params[:from].strip + customer = User.find_by(phone: sender_phone_number) + customer ||= User.find_by(mobile: sender_phone_number) + unless customer + role_ids = Role.signup_role_ids + customer = User.create( + firstname: '', + lastname: '', + email: '', + password: '', + phone: sender_phone_number, + note: 'CDR Signal', + active: true, + role_ids: role_ids, + updated_by_id: 1, + created_by_id: 1 + ) + end + + # set current user + UserInfo.current_user_id = customer.id + current_user_set(customer, 'token_auth') + + group = Group.find_by(id: channel.group_id) + if group.blank? + Rails.logger.error "Signal channel #{channel_id} paired with Group #{channel.group_id}, but group does not exist!" + return render json: { error: 'There was an error during Signal submission' }, status: 500 + end + + organization_id = channel.options['organization_id'] + if organization_id.present? + organization = Organization.find_by(id: organization_id) + unless organization.present? + Rails.logger.error "Signal channel #{channel_id} paired with Organization #{organization_id}, but organization does not exist!" + return render json: { error: 'There was an error during Signal submission' }, status: 500 + end + unless customer.organization_id.present? + customer.organization_id = organization.id + customer.save! + end + end + + message = params[:message] ||= 'No text content' + sent_at = params[:sent_at] + attachment_data_base64 = params[:attachment] + attachment_filename = params[:filename] + attachment_mimetype = params[:mime_type] + title = "Message from #{sender_phone_number} at #{sent_at}" + body = message + + # find ticket or create one + state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id) + ticket = Ticket.where(customer_id: customer.id).where.not(state_id: state_ids).order(:updated_at).first + if ticket + # check if title need to be updated + ticket.title = title if ticket.title == '-' + new_state = Ticket::State.find_by(default_create: true) + ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id + else + ticket = Ticket.new( + group_id: channel.group_id, + title: title, + customer_id: customer.id, + preferences: { + channel_id: channel.id, + cdr_signal: { + bot_token: channel.options[:bot_token], # change to bot id + chat_id: sender_phone_number + } + } + ) + end + + ticket.save! + + article_params = { + from: sender_phone_number, + to: receiver_phone_number, + sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id, + subject: title, + body: body, + content_type: 'text/plain', + message_id: "cdr_signal.#{message_id}", + ticket_id: ticket.id, + internal: false, + preferences: { + cdr_signal: { + timestamp: sent_at, + message_id: message_id, + from: sender_phone_number + } + } + } + + if attachment_data_base64.present? + article_params[:attachments] = [ + # i don't even... + # this is necessary because of what's going on in controllers/concerns/creates_ticket_articles.rb + # we need help from the ruby gods + { + 'filename' => attachment_filename, + :filename => attachment_filename, + :data => attachment_data_base64, + 'data' => attachment_data_base64, + 'mime-type' => attachment_mimetype + } + ] + end + + ticket.with_lock do + ta = article_create(ticket, article_params) + ta.update!(type_id: Ticket::Article::Type.find_by(name: 'cdr_signal').id) + end + + ticket.update!(create_article_type_id: Ticket::Article::Type.find_by(name: 'cdr_signal').id) + + result = { + ticket: { + id: ticket.id, + number: ticket.number + } + } + + render json: result, status: :ok + end +end diff --git a/zammad-addon-metamigo/src/app/controllers/channels_cdr_voice_controller.rb b/zammad-addon-metamigo/src/app/controllers/channels_cdr_voice_controller.rb new file mode 100644 index 0000000..627af23 --- /dev/null +++ b/zammad-addon-metamigo/src/app/controllers/channels_cdr_voice_controller.rb @@ -0,0 +1,253 @@ +# Copyright (C) 2012-2014 Zammad Foundation, http://zammad-foundation.org/ + +class ChannelsCdrVoiceController < ApplicationController + prepend_before_action -> { authentication_check && authorize! }, except: [:webhook] + skip_before_action :verify_csrf_token, only: [:webhook] + + include CreatesTicketArticles + + def index + assets = {} + channel_ids = [] + Channel.where(area: 'Voice::Number').order(:id).each do |channel| + assets = channel.assets(assets) + channel_ids.push channel.id + end + render json: { + assets: assets, + channel_ids: channel_ids + } + end + + def add + begin + errors = {} + errors['group_id'] = 'required' unless params[:group_id].present? + + if errors.present? + render json: { + errors: errors + }, status: :bad_request + return + end + channel = Channel.create( + area: 'Voice::Number', + options: { + phone_number: params[:phone_number], + token: SecureRandom.urlsafe_base64(48), + organization_id: params[:organization_id] + }, + group_id: params[:group_id], + active: true + ) + rescue StandardError => e + raise Exceptions::UnprocessableEntity, e.message + end + render json: channel + end + + def update + errors = {} + errors['group_id'] = 'required' unless params[:group_id].present? + + if errors.present? + render json: { + errors: errors + }, status: :bad_request + return + end + channel = Channel.find_by(id: params[:id], area: 'Voice::Number') + begin + channel.options[:phone_number] = params[:phone_number] + channel.options[:organization_id] = params[:organization_id] + channel.group_id = params[:group_id] + channel.save! + rescue StandardError => e + raise Exceptions::UnprocessableEntity, e.message + end + render json: channel + end + + def rotate_token + channel = Channel.find_by(id: params[:id], area: 'Voice::Number') + channel.options[:token] = SecureRandom.urlsafe_base64(48) + channel.save! + render json: {} + end + + def enable + channel = Channel.find_by(id: params[:id], area: 'Voice::Number') + channel.active = true + channel.save! + render json: {} + end + + def disable + channel = Channel.find_by(id: params[:id], area: 'Voice::Number') + channel.active = false + channel.save! + render json: {} + end + + def destroy + channel = Channel.find_by(id: params[:id], area: 'Voice::Number') + channel.destroy + render json: {} + end + + def channel_for_token(token) + return false unless token + + Channel.where(area: 'Voice::Number').each do |channel| + return channel if channel.options[:token] == token + end + false + end + + def webhook + token = params['token'] + return render json: {}, status: 401 unless token + + channel = channel_for_token(token) + return render json: {}, status: 401 if !channel || !channel.active + return render json: {}, status: 401 if channel.options[:token] != token + + channel_id = channel.id + + # validate input + errors = {} + + %i[to + from + duration + startTime + endTime + recording + mimeType + callSid].each do |field| + errors[field] = 'required' if params[field].blank? + end + + valid_mimetypes = ['audio/mpeg'] + unless valid_mimetypes.include?(params[:mimeType]) + errors[:mimeType] = "invalid. must be one of #{valid_mimetypes.join(',')}" + end + + receiver_phone_number = params[:to] + + if errors.present? + render json: { + errors: errors + }, status: :bad_request + return + end + + caller_phone_number = params[:from].strip + + customer = User.find_by(phone: caller_phone_number) + customer ||= User.find_by(mobile: caller_phone_number) + unless customer + role_ids = Role.signup_role_ids + customer = User.create( + firstname: '', + lastname: '', + email: '', + password: '', + phone: caller_phone_number, + active: true, + role_ids: role_ids, + updated_by_id: 1, + created_by_id: 1 + ) + end + + # set current user + UserInfo.current_user_id = customer.id + current_user_set(customer, 'token_auth') + + group = Group.find_by(id: channel.group_id) + unless group.present? + Rails.logger.error "Voice channel #{channel_id} paired with Group #{channel.group_id}, but group does not exist!" + return render json: { error: 'There was an error during voice submission' }, status: 500 + end + + organization_id = channel.options['organization_id'] + if organization_id.present? + organization = Organization.find_by(id: organization_id) + unless organization.present? + Rails.logger.error "Voice channel #{channel_id} paired with Organization #{organization_id}, but organization does not exist!" + return render json: { error: 'There was an error during voice submission' }, status: 500 + end + unless customer.organization_id.present? + customer.organization_id = organization.id + customer.save! + end + end + + call_id = params[:calLSid] + duration = params[:duration] + start_time = params[:startTime] + end_time = params[:endTime] + recording_data_base64 = params[:recording] + recording_filename = "phone-call-#{start_time}-#{call_id}.mp3" + recording_mimetype = params[:mimeType] + + title = "Call from #{caller_phone_number} at #{start_time}" + body = %( +
    +
  • Caller: #{caller_phone_number}
  • +
  • Service Number: #{receiver_phone_number}
  • +
  • Call Duration: #{duration} seconds
  • +
  • Start Time: #{start_time}
  • +
  • End Time: #{end_time}
  • +
+

See the attached recording.

+) + + ticket_params = { + group_id: group.id, + customer_id: customer.id, + title: title, + preferences: {}, + note: 'This ticket was created from a recorded voice message.' + } + + article_params = { + sender: 'Customer', + subject: title, + body: body, + content_type: 'text/html', + type: 'note', + attachments: [ + # i don't even... + # this is necessary because of what's going on in controllers/concerns/creates_ticket_articles.rb + # we need help from the ruby gods + { + 'filename' => recording_filename, + :filename => recording_filename, + :data => recording_data_base64, + 'data' => recording_data_base64, + 'mime-type' => recording_mimetype + } + ] + } + + clean_params = Ticket.param_cleanup(ticket_params, true) + ticket = Ticket.new(clean_params) + + ticket.save! + ticket.with_lock do + article_params[:sender] = 'Customer' + article_create(ticket, article_params) + end + + result = { + ticket: { + id: ticket.id, + number: ticket.number + } + } + + render json: result, status: :ok + end +end diff --git a/zammad-addon-metamigo/src/app/controllers/channels_cdr_whatsapp_controller.rb b/zammad-addon-metamigo/src/app/controllers/channels_cdr_whatsapp_controller.rb new file mode 100644 index 0000000..c30d5ee --- /dev/null +++ b/zammad-addon-metamigo/src/app/controllers/channels_cdr_whatsapp_controller.rb @@ -0,0 +1,270 @@ +# frozen_string_literal: true + +class ChannelsCdrWhatsappController < ApplicationController + prepend_before_action -> { authentication_check && authorize! }, except: [:webhook] + skip_before_action :verify_csrf_token, only: [:webhook] + + include CreatesTicketArticles + + def index + assets = {} + channel_ids = [] + Channel.where(area: 'Whatsapp::Number').order(:id).each do |channel| + assets = channel.assets(assets) + channel_ids.push channel.id + end + render json: { + assets: assets, + channel_ids: channel_ids + } + end + + def add + begin + errors = {} + errors['group_id'] = 'required' if params[:group_id].blank? + + if errors.present? + render json: { + errors: errors + }, status: :bad_request + return + end + channel = Channel.create( + area: 'Whatsapp::Number', + options: { + adapter: 'cdr_whatsapp', + phone_number: params[:phone_number], + bot_token: params[:bot_token], + bot_endpoint: params[:bot_endpoint], + token: SecureRandom.urlsafe_base64(48), + organization_id: params[:organization_id] + }, + group_id: params[:group_id], + active: true + ) + rescue StandardError => e + raise Exceptions::UnprocessableEntity, e.message + end + render json: channel + end + + def update + errors = {} + errors['group_id'] = 'required' if params[:group_id].blank? + + if errors.present? + render json: { + errors: errors + }, status: :bad_request + return + end + channel = Channel.find_by(id: params[:id], area: 'Whatsapp::Number') + begin + channel.options[:phone_number] = params[:phone_number] + channel.options[:bot_token] = params[:bot_token] + channel.options[:bot_endpoint] = params[:bot_endpoint] + channel.options[:organization_id] = params[:organization_id] + channel.group_id = params[:group_id] + channel.save! + rescue StandardError => e + raise Exceptions::UnprocessableEntity, e.message + end + render json: channel + end + + def rotate_token + channel = Channel.find_by(id: params[:id], area: 'Whatsapp::Number') + channel.options[:token] = SecureRandom.urlsafe_base64(48) + channel.save! + render json: {} + end + + def enable + channel = Channel.find_by(id: params[:id], area: 'Whatsapp::Number') + channel.active = true + channel.save! + render json: {} + end + + def disable + channel = Channel.find_by(id: params[:id], area: 'Whatsapp::Number') + channel.active = false + channel.save! + render json: {} + end + + def destroy + channel = Channel.find_by(id: params[:id], area: 'Whatsapp::Number') + channel.destroy + render json: {} + end + + def channel_for_token(token) + return false unless token + + Channel.where(area: 'Whatsapp::Number').each do |channel| + return channel if channel.options[:token] == token + end + false + end + + def webhook + token = params['token'] + return render json: {}, status: :unauthorized unless token + + channel = channel_for_token(token) + return render json: {}, status: :unauthorized if !channel || !channel.active + return render json: {}, status: :unauthorized if channel.options[:token] != token + + channel_id = channel.id + + # validate input + errors = {} + + %i[to + from + message_id + sent_at].each do |field| + errors[field] = 'required' if params[field].blank? + end + + if errors.present? + render json: { + errors: errors + }, status: :bad_request + return + end + + message_id = params[:message_id] + + return if Ticket::Article.exists?(message_id: "cdr_whatsapp.#{message_id}") + + receiver_phone_number = params[:to].strip + sender_phone_number = params[:from].strip + customer = User.find_by(phone: sender_phone_number) + customer ||= User.find_by(mobile: sender_phone_number) + unless customer + role_ids = Role.signup_role_ids + customer = User.create( + firstname: '', + lastname: '', + email: '', + password: '', + phone: sender_phone_number, + note: 'CDR Whatsapp', + active: true, + role_ids: role_ids, + updated_by_id: 1, + created_by_id: 1 + ) + end + + # set current user + UserInfo.current_user_id = customer.id + current_user_set(customer, 'token_auth') + + group = Group.find_by(id: channel.group_id) + if group.blank? + Rails.logger.error "Whatsapp channel #{channel_id} paired with Group #{channel.group_id}, but group does not exist!" + return render json: { error: 'There was an error during Whatsapp submission' }, status: :internal_server_error + end + + organization_id = channel.options['organization_id'] + if organization_id.present? + organization = Organization.find_by(id: organization_id) + if organization.blank? + Rails.logger.error "Whatsapp channel #{channel_id} paired with Organization #{organization_id}, but organization does not exist!" + return render json: { error: 'There was an error during Whatsapp submission' }, status: :internal_server_error + end + if customer.organization_id.blank? + customer.organization_id = organization.id + customer.save! + end + end + + message = params[:message] ||= 'No text content' + sent_at = params[:sent_at] + attachment_data_base64 = params[:attachment] + attachment_filename = params[:filename] + attachment_mimetype = params[:mime_type] + title = "Message from #{sender_phone_number} at #{sent_at}" + body = message + + # find ticket or create one + state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id) + ticket = Ticket.where(customer_id: customer.id).where.not(state_id: state_ids).order(:updated_at).first + if ticket + # check if title need to be updated + ticket.title = title if ticket.title == '-' + new_state = Ticket::State.find_by(default_create: true) + ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id + else + ticket = Ticket.new( + group_id: channel.group_id, + title: title, + customer_id: customer.id, + preferences: { + channel_id: channel.id, + cdr_whatsapp: { + bot_token: channel.options[:bot_token], # change to bot id + chat_id: sender_phone_number + } + } + ) + end + + ticket.save! + + article_params = { + from: sender_phone_number, + to: receiver_phone_number, + sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id, + subject: title, + body: body, + content_type: 'text/plain', + message_id: "cdr_whatsapp.#{message_id}", + ticket_id: ticket.id, + internal: false, + preferences: { + cdr_whatsapp: { + timestamp: sent_at, + message_id: message_id, + from: sender_phone_number + } + } + } + + if attachment_data_base64.present? + article_params[:attachments] = [ + # i don't even... + # this is necessary because of what's going on in controllers/concerns/creates_ticket_articles.rb + # we need help from the ruby gods + { + 'filename' => attachment_filename, + :filename => attachment_filename, + :data => attachment_data_base64, + 'data' => attachment_data_base64, + 'mime-type' => attachment_mimetype + } + ] + end + + # setting the article type after saving seems to be the only way to get it to stick + ticket.with_lock do + ta = article_create(ticket, article_params) + ta.update!(type_id: Ticket::Article::Type.find_by(name: 'cdr_whatsapp').id) + end + + ticket.update!(create_article_type_id: Ticket::Article::Type.find_by(name: 'cdr_whatsapp').id) + + result = { + ticket: { + id: ticket.id, + number: ticket.number + } + } + + render json: result, status: :ok + end +end diff --git a/zammad-addon-metamigo/src/app/models/channel/driver/cdr_signal.rb b/zammad-addon-metamigo/src/app/models/channel/driver/cdr_signal.rb new file mode 100644 index 0000000..ce2f075 --- /dev/null +++ b/zammad-addon-metamigo/src/app/models/channel/driver/cdr_signal.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class Channel + class Driver + class CdrSignal + def fetchable?(_channel) + false + end + + def disconnect; end + + # + # instance = Channel::Driver::CdrSignal.new + # instance.send( + # { + # adapter: 'cdrsignal', + # auth: { + # api_key: api_key + # }, + # }, + # signal_attributes, + # notification + # ) + # + + def send(options, article, _notification = false) + # return if we run import mode + return if Setting.get('import_mode') + + options = check_external_credential(options) + + Rails.logger.debug { 'signal send started' } + Rails.logger.debug { options.inspect } + @signal = ::CdrSignal.new(options[:bot_endpoint], options[:bot_token]) + @signal.from_article(article) + end + + def self.streamable? + false + end + + private + + def check_external_credential(options) + if options[:auth] && options[:auth][:external_credential_id] + external_credential = ExternalCredential.find_by(id: options[:auth][:external_credential_id]) + raise "No such ExternalCredential.find(#{options[:auth][:external_credential_id]})" unless external_credential + + options[:auth][:api_key] = external_credential.credentials['api_key'] + end + options + end + end + end +end diff --git a/zammad-addon-metamigo/src/app/models/channel/driver/cdr_whatsapp.rb b/zammad-addon-metamigo/src/app/models/channel/driver/cdr_whatsapp.rb new file mode 100644 index 0000000..7462eb3 --- /dev/null +++ b/zammad-addon-metamigo/src/app/models/channel/driver/cdr_whatsapp.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class Channel + class Driver + class CdrWhatsapp + def fetchable?(_channel) + false + end + + def disconnect; end + + # + # instance = Channel::Driver::CdrWhatsapp.new + # instance.send( + # { + # adapter: 'cdr_whatsapp', + # auth: { + # api_key: api_key + # }, + # }, + # whatsapp_attributes, + # notification + # ) + # + + def send(options, article, _notification = false) + # return if we run import mode + return if Setting.get('import_mode') + + options = check_external_credential(options) + + Rails.logger.debug { 'whatsapp send started' } + Rails.logger.debug { options.inspect } + @whatsapp = ::CdrWhatsapp.new(options[:bot_endpoint], options[:bot_token]) + @whatsapp.from_article(article) + end + + def self.streamable? + false + end + + private + + def check_external_credential(options) + if options[:auth] && options[:auth][:external_credential_id] + external_credential = ExternalCredential.find_by(id: options[:auth][:external_credential_id]) + raise "No such ExternalCredential.find(#{options[:auth][:external_credential_id]})" unless external_credential + + options[:auth][:api_key] = external_credential.credentials['api_key'] + end + options + end + end + end +end diff --git a/zammad-addon-metamigo/src/app/models/observer/ticket/article/communicate_cdr_signal.rb b/zammad-addon-metamigo/src/app/models/observer/ticket/article/communicate_cdr_signal.rb new file mode 100644 index 0000000..caf56f2 --- /dev/null +++ b/zammad-addon-metamigo/src/app/models/observer/ticket/article/communicate_cdr_signal.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Observer::Ticket::Article::CommunicateCdrSignal < ActiveRecord::Observer + observe 'ticket::_article' + + def after_create(record) + # return if we run import mode + return true if Setting.get('import_mode') + + # if sender is customer, do not communicate + return true unless record.sender_id + + sender = Ticket::Article::Sender.lookup(id: record.sender_id) + return true if sender.nil? + return true if sender.name == 'Customer' + + # only apply on signal messages + return true unless record.type_id + + type = Ticket::Article::Type.lookup(id: record.type_id) + return true if type.name !~ /\Acdr_signal/i + + Delayed::Job.enqueue(Observer::Ticket::Article::CommunicateCdrSignal::BackgroundJob.new(record.id)) + end +end diff --git a/zammad-addon-metamigo/src/app/models/observer/ticket/article/communicate_cdr_signal/background_job.rb b/zammad-addon-metamigo/src/app/models/observer/ticket/article/communicate_cdr_signal/background_job.rb new file mode 100644 index 0000000..38dfa95 --- /dev/null +++ b/zammad-addon-metamigo/src/app/models/observer/ticket/article/communicate_cdr_signal/background_job.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +module Observer + module Ticket + module Article + class CommunicateCdrSignal + class BackgroundJob + def initialize(id) + @article_id = id + end + + def perform + article = ::Ticket::Article.find(@article_id) + + # set retry count + article.preferences['delivery_retry'] ||= 0 + article.preferences['delivery_retry'] += 1 + + ticket = ::Ticket.lookup(id: article.ticket_id) + Rails.logger.debug { 'Signal background job' } + Rails.logger.debug { ticket.inspect } + Rails.logger.debug { article.inspect } + unless ticket.preferences + log_error(article, + "Can't find ticket.preferences for Ticket.find(#{article.ticket_id})") + end + unless ticket.preferences['cdr_signal'] + log_error(article, + "Can't find ticket.preferences['cdr_signal'] for Ticket.find(#{article.ticket_id})") + end + unless ticket.preferences['cdr_signal']['chat_id'] + log_error(article, + "Can't find ticket.preferences['cdr_signal']['chat_id'] for Ticket.find(#{article.ticket_id})") + end + unless ticket.preferences['cdr_signal']['bot_token'] + log_error(article, + "Can't find ticket.preferences['cdr_signal']['bot_token'] for Ticket.find(#{article.ticket_id})") + end + + channel = ::CdrSignal.bot_by_bot_token(ticket.preferences['cdr_signal']['bot_token']) + Rails.logger.debug { "signal got channel for #{channel.inspect}" } + + channel ||= ::Channel.lookup(id: ticket.preferences['channel_id']) + unless channel + log_error(article, + "No such channel for bot #{ticket.preferences['cdr_signal']['bot_token']} or channel id #{ticket.preferences['channel_id']}") + end + if channel.options[:bot_token].blank? + log_error(article, + "Channel.find(#{channel.id}) has no signal api token!") + end + + begin + result = channel.deliver( + to: ticket.preferences[:cdr_signal][:chat_id], + body: article.body + ) + rescue StandardError => e + log_error(article, e.message) + return + end + + Rails.logger.debug { "send result: #{result}" } + + if result.nil? || result[:error].present? + log_error(article, 'Delivering signal message failed!') + return + end + + article.to = result['result']['recipient'] + article.from = result['result']['source'] + + message_id = format('%s@%s', source: result['result']['source'], + timestamp: result['result']['timestamp']) + article.preferences['cdr_signal'] = { + timestamp: result['result']['timestamp'], + message_id: message_id, + from: result['result']['source'], + to: result['result']['recipient'] + } + + # set delivery status + article.preferences['delivery_status_message'] = nil + article.preferences['delivery_status'] = 'success' + article.preferences['delivery_status_date'] = Time.zone.now + + article.message_id = "cdr_signal.#{message_id}" + + article.save! + + Rails.logger.info "Sent signal message to: '#{article.to}' (from #{article.from})" + + article + end + + def log_error(local_record, message) + local_record.preferences['delivery_status'] = 'fail' + local_record.preferences['delivery_status_message'] = + message.encode('UTF-8', 'UTF-8', invalid: :replace, replace: '?') + local_record.preferences['delivery_status_date'] = Time.zone.now + local_record.save + Rails.logger.error message + + if local_record.preferences['delivery_retry'] > 3 + ::Ticket::Article.create( + ticket_id: local_record.ticket_id, + content_type: 'text/plain', + body: "Unable to send signal message: #{message}", + internal: true, + sender: ::Ticket::Article::Sender.find_by(name: 'System'), + type: ::Ticket::Article::Type.find_by(name: 'note'), + preferences: { + delivery_article_id_related: local_record.id, + delivery_message: true + }, + updated_by_id: 1, + created_by_id: 1 + ) + end + + raise message + end + + def max_attempts + 4 + end + + def reschedule_at(current_time, attempts) + return current_time + attempts * 120.seconds if Rails.env.production? + + current_time + 5.seconds + end + end + end + end + end +end diff --git a/zammad-addon-metamigo/src/app/models/observer/ticket/article/communicate_cdr_whatsapp.rb b/zammad-addon-metamigo/src/app/models/observer/ticket/article/communicate_cdr_whatsapp.rb new file mode 100644 index 0000000..0f83692 --- /dev/null +++ b/zammad-addon-metamigo/src/app/models/observer/ticket/article/communicate_cdr_whatsapp.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Observer::Ticket::Article::CommunicateCdrWhatsapp < ActiveRecord::Observer + observe 'ticket::_article' + + def after_create(record) + # return if we run import mode + return true if Setting.get('import_mode') + + # if sender is customer, do not communicate + return true unless record.sender_id + + sender = Ticket::Article::Sender.lookup(id: record.sender_id) + return true if sender.nil? + return true if sender.name == 'Customer' + + # only apply on whatsapp messages + return true unless record.type_id + + type = Ticket::Article::Type.lookup(id: record.type_id) + return true if type.name !~ /\Acdr_whatsapp/i + + Delayed::Job.enqueue(Observer::Ticket::Article::CommunicateCdrWhatsapp::BackgroundJob.new(record.id)) + end +end diff --git a/zammad-addon-metamigo/src/app/models/observer/ticket/article/communicate_cdr_whatsapp/background_job.rb b/zammad-addon-metamigo/src/app/models/observer/ticket/article/communicate_cdr_whatsapp/background_job.rb new file mode 100644 index 0000000..48d3292 --- /dev/null +++ b/zammad-addon-metamigo/src/app/models/observer/ticket/article/communicate_cdr_whatsapp/background_job.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +module Observer + module Ticket + module Article + class CommunicateCdrWhatsapp + class BackgroundJob + def initialize(id) + @article_id = id + end + + def perform + article = ::Ticket::Article.find(@article_id) + + # set retry count + article.preferences['delivery_retry'] ||= 0 + article.preferences['delivery_retry'] += 1 + + ticket = ::Ticket.lookup(id: article.ticket_id) + Rails.logger.debug { 'Whatsapp background job' } + Rails.logger.debug { ticket.inspect } + Rails.logger.debug { article.inspect } + unless ticket.preferences + log_error(article, + "Can't find ticket.preferences for Ticket.find(#{article.ticket_id})") + end + unless ticket.preferences['cdr_whatsapp'] + log_error(article, + "Can't find ticket.preferences['cdr_whatsapp'] for Ticket.find(#{article.ticket_id})") + end + unless ticket.preferences['cdr_whatsapp']['chat_id'] + log_error(article, + "Can't find ticket.preferences['cdr_whatsapp']['chat_id'] for Ticket.find(#{article.ticket_id})") + end + unless ticket.preferences['cdr_whatsapp']['bot_token'] + log_error(article, + "Can't find ticket.preferences['cdr_whatsapp']['bot_token'] for Ticket.find(#{article.ticket_id})") + end + + channel = ::CdrWhatsapp.bot_by_bot_token(ticket.preferences['cdr_whatsapp']['bot_token']) + Rails.logger.debug { "whatsapp got channel for #{channel.inspect}" } + + channel ||= ::Channel.lookup(id: ticket.preferences['channel_id']) + unless channel + log_error(article, + "No such channel for bot #{ticket.preferences['cdr_whatsapp']['bot_token']} or channel id #{ticket.preferences['channel_id']}") + end + if channel.options[:bot_token].blank? + log_error(article, + "Channel.find(#{channel.id}) has no whatsapp api token!") + end + + begin + result = channel.deliver( + to: ticket.preferences[:cdr_whatsapp][:chat_id], + body: article.body + ) + rescue StandardError => e + log_error(article, e.message) + return + end + + Rails.logger.debug { "send result: #{result}" } + + if result.nil? || result[:error].present? + log_error(article, 'Delivering whatsapp message failed!') + return + end + + article.to = result['result']['recipient'] + article.from = result['result']['source'] + + message_id = format('%s@%s', source: result['result']['source'], + timestamp: result['result']['timestamp']) + article.preferences['cdr_whatsapp'] = { + timestamp: result['result']['timestamp'], + message_id: message_id, + from: result['result']['source'], + to: result['result']['recipient'] + } + + # set delivery status + article.preferences['delivery_status_message'] = nil + article.preferences['delivery_status'] = 'success' + article.preferences['delivery_status_date'] = Time.zone.now + + article.message_id = "cdr_whatsapp.#{message_id}" + + article.save! + + Rails.logger.info "Sent whatsapp message to: '#{article.to}' (from #{article.from})" + + article + end + + def log_error(local_record, message) + local_record.preferences['delivery_status'] = 'fail' + local_record.preferences['delivery_status_message'] = + message.encode('UTF-8', 'UTF-8', invalid: :replace, replace: '?') + local_record.preferences['delivery_status_date'] = Time.zone.now + local_record.save + Rails.logger.error message + + if local_record.preferences['delivery_retry'] > 3 + ::Ticket::Article.create( + ticket_id: local_record.ticket_id, + content_type: 'text/plain', + body: "Unable to send whatsapp message: #{message}", + internal: true, + sender: ::Ticket::Article::Sender.find_by(name: 'System'), + type: ::Ticket::Article::Type.find_by(name: 'note'), + preferences: { + delivery_article_id_related: local_record.id, + delivery_message: true + }, + updated_by_id: 1, + created_by_id: 1 + ) + end + + raise message + end + + def max_attempts + 4 + end + + def reschedule_at(current_time, attempts) + return current_time + attempts * 120.seconds if Rails.env.production? + + current_time + 5.seconds + end + end + end + end + end +end diff --git a/zammad-addon-metamigo/src/app/policies/controllers/channels_cdr_signal_controller_policy.rb b/zammad-addon-metamigo/src/app/policies/controllers/channels_cdr_signal_controller_policy.rb new file mode 100644 index 0000000..2694837 --- /dev/null +++ b/zammad-addon-metamigo/src/app/policies/controllers/channels_cdr_signal_controller_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Controllers + class ChannelsCdrSignalControllerPolicy < Controllers::ApplicationControllerPolicy + default_permit!('admin.channel_cdr_signal') + end +end diff --git a/zammad-addon-metamigo/src/app/policies/controllers/channels_cdr_voice_controller_policy.rb b/zammad-addon-metamigo/src/app/policies/controllers/channels_cdr_voice_controller_policy.rb new file mode 100644 index 0000000..087db2c --- /dev/null +++ b/zammad-addon-metamigo/src/app/policies/controllers/channels_cdr_voice_controller_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Controllers + class ChannelsCdrVoiceControllerPolicy < Controllers::ApplicationControllerPolicy + default_permit!('admin.channel_cdr_voice') + end +end diff --git a/zammad-addon-metamigo/src/app/policies/controllers/channels_cdr_whatsapp_controller_policy.rb b/zammad-addon-metamigo/src/app/policies/controllers/channels_cdr_whatsapp_controller_policy.rb new file mode 100644 index 0000000..cc6e8c1 --- /dev/null +++ b/zammad-addon-metamigo/src/app/policies/controllers/channels_cdr_whatsapp_controller_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Controllers + class ChannelsCdrWhatsappControllerPolicy < Controllers::ApplicationControllerPolicy + default_permit!('admin.channel_cdr_whatsapp') + end +end diff --git a/zammad-addon-metamigo/src/config/initializers/cdr_signal.rb b/zammad-addon-metamigo/src/config/initializers/cdr_signal.rb new file mode 100644 index 0000000..752d79f --- /dev/null +++ b/zammad-addon-metamigo/src/config/initializers/cdr_signal.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +Rails.application.config.after_initialize do + Ticket::Article.add_observer Observer::Ticket::Article::CommunicateCdrSignal.instance + + icon = File.read('public/assets/images/icons/cdr_signal.svg') + doc = File.open('public/assets/images/icons.svg') { |f| Nokogiri::XML(f) } + if !doc.at_css('#icon-cdr-signal') + doc.at('svg').add_child(icon) + Rails.logger.debug 'signal icon added to icon set' + else + Rails.logger.debug 'signal icon already in icon set' + end + File.write('public/assets/images/icons.svg', doc.to_xml) +end diff --git a/zammad-addon-metamigo/src/config/initializers/cdr_whatsapp.rb b/zammad-addon-metamigo/src/config/initializers/cdr_whatsapp.rb new file mode 100644 index 0000000..25ade2e --- /dev/null +++ b/zammad-addon-metamigo/src/config/initializers/cdr_whatsapp.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +Rails.application.config.after_initialize do + Ticket::Article.add_observer Observer::Ticket::Article::CommunicateCdrWhatsapp.instance + + icon = File.read('public/assets/images/icons/cdr_whatsapp.svg') + doc = File.open('public/assets/images/icons.svg') { |f| Nokogiri::XML(f) } + if !doc.at_css('#icon-cdr-whatsapp') + doc.at('svg').add_child(icon) + Rails.logger.debug 'whatsapp icon added to icon set' + else + Rails.logger.debug 'whatsapp icon already in icon set' + end + File.write('public/assets/images/icons.svg', doc.to_xml) +end diff --git a/zammad-addon-metamigo/src/config/routes/channel_cdr_signal.rb b/zammad-addon-metamigo/src/config/routes/channel_cdr_signal.rb new file mode 100644 index 0000000..8de4b97 --- /dev/null +++ b/zammad-addon-metamigo/src/config/routes/channel_cdr_signal.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match "#{api_path}/channels_cdr_signal", to: 'channels_cdr_signal#index', via: :get + match "#{api_path}/channels_cdr_signal", to: 'channels_cdr_signal#add', via: :post + match "#{api_path}/channels_cdr_signal/:id", to: 'channels_cdr_signal#update', via: :put + match "#{api_path}/channels_cdr_signal_webhook/:token", to: 'channels_cdr_signal#webhook', via: :post + match "#{api_path}/channels_cdr_signal_disable", to: 'channels_cdr_signal#disable', via: :post + match "#{api_path}/channels_cdr_signal_enable", to: 'channels_cdr_signal#enable', via: :post + match "#{api_path}/channels_cdr_signal", to: 'channels_cdr_signal#destroy', via: :delete + match "#{api_path}/channels_cdr_signal_rotate_token", to: 'channels_cdr_signal#rotate_token', via: :post +end diff --git a/zammad-addon-metamigo/src/config/routes/channel_cdr_voice.rb b/zammad-addon-metamigo/src/config/routes/channel_cdr_voice.rb new file mode 100644 index 0000000..37f5d79 --- /dev/null +++ b/zammad-addon-metamigo/src/config/routes/channel_cdr_voice.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match "#{api_path}/channels_cdr_voice", to: 'channels_cdr_voice#index', via: :get + match "#{api_path}/channels_cdr_voice", to: 'channels_cdr_voice#add', via: :post + match "#{api_path}/channels_cdr_voice/:id", to: 'channels_cdr_voice#update', via: :put + match "#{api_path}/channels_cdr_voice_webhook/:token", to: 'channels_cdr_voice#webhook', via: :post + match "#{api_path}/channels_cdr_voice_disable", to: 'channels_cdr_voice#disable', via: :post + match "#{api_path}/channels_cdr_voice_enable", to: 'channels_cdr_voice#enable', via: :post + match "#{api_path}/channels_cdr_voice", to: 'channels_cdr_voice#destroy', via: :delete + match "#{api_path}/channels_cdr_voice_rotate_token", to: 'channels_cdr_voice#rotate_token', via: :post +end diff --git a/zammad-addon-metamigo/src/config/routes/channel_cdr_whatsapp.rb b/zammad-addon-metamigo/src/config/routes/channel_cdr_whatsapp.rb new file mode 100644 index 0000000..549ed9e --- /dev/null +++ b/zammad-addon-metamigo/src/config/routes/channel_cdr_whatsapp.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match "#{api_path}/channels_cdr_whatsapp", to: 'channels_cdr_whatsapp#index', via: :get + match "#{api_path}/channels_cdr_whatsapp", to: 'channels_cdr_whatsapp#add', via: :post + match "#{api_path}/channels_cdr_whatsapp/:id", to: 'channels_cdr_whatsapp#update', via: :put + match "#{api_path}/channels_cdr_whatsapp_webhook/:token", to: 'channels_cdr_whatsapp#webhook', via: :post + match "#{api_path}/channels_cdr_whatsapp_disable", to: 'channels_cdr_whatsapp#disable', via: :post + match "#{api_path}/channels_cdr_whatsapp_enable", to: 'channels_cdr_whatsapp#enable', via: :post + match "#{api_path}/channels_cdr_whatsapp", to: 'channels_cdr_whatsapp#destroy', via: :delete + match "#{api_path}/channels_cdr_whatsapp_rotate_token", to: 'channels_cdr_whatsapp#rotate_token', via: :post +end diff --git a/zammad-addon-metamigo/src/db/addon/cdr_signal/20210525091356_cdr_signal_channel.rb b/zammad-addon-metamigo/src/db/addon/cdr_signal/20210525091356_cdr_signal_channel.rb new file mode 100644 index 0000000..176faeb --- /dev/null +++ b/zammad-addon-metamigo/src/db/addon/cdr_signal/20210525091356_cdr_signal_channel.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class CdrSignalChannel < ActiveRecord::Migration[5.2] + def self.up + Ticket::Article::Type.create_if_not_exists( + name: 'cdr_signal', + communication: true, + updated_by_id: 1, + created_by_id: 1 + ) + Permission.create_if_not_exists( + name: 'admin.channel_cdr_signal', + note: 'Manage %s', + preferences: { + translations: ['Channel - Signal'] + } + ) + end + + def self.down + t = Ticket::Article::Type.find_by(name: 'cdr_signal') + t&.destroy + + p = Permission.find_by(name: 'admin.channel_cdr_signal') + p&.destroy + end +end diff --git a/zammad-addon-metamigo/src/db/addon/cdr_voice/20210525091357_cdr_voice_channel.rb b/zammad-addon-metamigo/src/db/addon/cdr_voice/20210525091357_cdr_voice_channel.rb new file mode 100644 index 0000000..97f829e --- /dev/null +++ b/zammad-addon-metamigo/src/db/addon/cdr_voice/20210525091357_cdr_voice_channel.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class CdrVoiceChannel < ActiveRecord::Migration[5.2] + def self.up + Ticket::Article::Type.create_if_not_exists( + name: 'cdr_voice', + communication: false, + updated_by_id: 1, + created_by_id: 1 + ) + Permission.create_if_not_exists( + name: 'admin.channel_cdr_voice', + note: 'Manage %s', + preferences: { + translations: ['Channel - Voice'] + } + ) + end + + def self.down + t = Ticket::Article::Type.find_by(name: 'cdr_voice') + + t&.destroy + + p = Permission.find_by(name: 'admin.channel_cdr_voice') + p&.destroy + end +end diff --git a/zammad-addon-metamigo/src/db/addon/cdr_whatsapp/20210525091358_cdr_whatsapp_channel.rb b/zammad-addon-metamigo/src/db/addon/cdr_whatsapp/20210525091358_cdr_whatsapp_channel.rb new file mode 100644 index 0000000..30e18ec --- /dev/null +++ b/zammad-addon-metamigo/src/db/addon/cdr_whatsapp/20210525091358_cdr_whatsapp_channel.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class CdrWhatsappChannel < ActiveRecord::Migration[5.2] + def self.up + Ticket::Article::Type.create_if_not_exists( + name: 'cdr_whatsapp', + communication: true, + updated_by_id: 1, + created_by_id: 1 + ) + Permission.create_if_not_exists( + name: 'admin.channel_cdr_whatsapp', + note: 'Manage %s', + preferences: { + translations: ['Channel - Whatsapp'] + } + ) + end + + def self.down + t = Ticket::Article::Type.find_by(name: 'cdr_whatsapp') + t&.destroy + + p = Permission.find_by(name: 'admin.channel_cdr_whatsapp') + p&.destroy + end +end diff --git a/zammad-addon-metamigo/src/lib/cdr_signal.rb b/zammad-addon-metamigo/src/lib/cdr_signal.rb new file mode 100644 index 0000000..4484efe --- /dev/null +++ b/zammad-addon-metamigo/src/lib/cdr_signal.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true + +require 'cdr_signal_api' + +class CdrSignal + attr_accessor :client + + # + # check token and return bot attributes of token + # + # bot = CdrSignal.check_token('token') + # + + def self.check_token(api_url, token) + api = CdrSignalAPI.new(api_url, token) + begin + bot = api.fetch_self + rescue StandardError => e + raise "invalid api token: #{e.message}" + end + bot + end + + # + # create or update channel, store bot attributes and verify token + # + # channel = CdrSignal.create_or_update_channel('token', params) + # + # returns + # + # channel # instance of Channel + # + + def self.create_or_update_channel(api_url, token, params, channel = nil) + # verify token + bot = CdrSignal.check_token(api_url, token) + + raise 'Bot already exists!' unless channel && CdrSignal.bot_duplicate?(bot['id']) + + raise 'Group needed!' if params[:group_id].blank? + + group = Group.find_by(id: params[:group_id]) + raise 'Group invalid!' unless group + + unless channel + channel = CdrSignal.bot_by_bot_id(bot['id']) + channel ||= Channel.new + end + channel.area = 'Signal::Account' + channel.options = { + adapter: 'cdr_signal', + bot: { + id: bot['id'], + number: bot['number'] + }, + api_token: token, + api_url: api_url, + welcome: params[:welcome] + } + channel.group_id = group.id + channel.active = true + channel.save! + channel + end + + # + # check if bot already exists as channel + # + # success = CdrSignal.bot_duplicate?(bot_id) + # + # returns + # + # channel # instance of Channel + # + + def self.bot_duplicate?(bot_id, channel_id = nil) + Channel.where(area: 'Signal::Account').each do |channel| + next unless channel.options + next unless channel.options[:bot] + next unless channel.options[:bot][:id] + next if channel.options[:bot][:id] != bot_id + next if channel.id.to_s == channel_id.to_s + + return true + end + false + end + + # + # get channel by bot_id + # + # channel = CdrSignal.bot_by_bot_id(bot_id) + # + # returns + # + # true|false + # + + def self.bot_by_bot_token(bot_token) + Channel.where(area: 'Signal::Account').each do |channel| + next unless channel.options + next unless channel.options[:bot_token] + return channel if channel.options[:bot_token].to_s == bot_token.to_s + end + nil + end + + # + # date = CdrSignal.timestamp_to_date('1543414973285') + # + # returns + # + # 2018-11-28T14:22:53.285Z + # + + def self.timestamp_to_date(timestamp_str) + Time.at(timestamp_str.to_i).utc.to_datetime + end + + def self.message_id(message_raw) + format('%s@%s', source: message_raw['source'], timestamp: message_raw['timestamp']) + end + + # + # client = CdrSignal.new('token') + # + + def initialize(api_url, token) + @token = token + @api_url = api_url + @api = CdrSignalAPI.new(api_url, token) + end + + # + # client.send_message(chat_id, 'some message') + # + + def send_message(recipient, message) + return if Rails.env.test? + + @api.send_message(recipient, message) + end + + def user(number) + { + # id: params[:message][:from][:id], + id: number, + username: number + # first_name: params[:message][:from][:first_name], + # last_name: params[:message][:from][:last_name] + } + end + + def to_user(message) + Rails.logger.debug { 'Create user from message...' } + Rails.logger.debug { message.inspect } + + # do message_user lookup + message_user = user(message[:source]) + + # create or update user + login = message_user[:username] || message_user[:id] + + auth = Authorization.find_by(uid: message[:source], provider: 'cdr_signal') + + user_data = { + login: login, + mobile: message[:source] + } + + user = if auth + User.find(auth.user_id) + else + User.where(mobile: message[:source]).order(:updated_at).first + end + if user + user.update!(user_data) + else + user = User.create!( + firstname: message[:source], + mobile: message[:source], + note: "Signal #{message_user[:username]}", + active: true, + role_ids: Role.signup_role_ids + ) + end + + # create or update authorization + auth_data = { + uid: message_user[:id], + username: login, + user_id: user.id, + provider: 'cdr_signal' + } + if auth + auth.update!(auth_data) + else + Authorization.create(auth_data) + end + + user + end + + def to_ticket(message, user, group_id, channel) + UserInfo.current_user_id = user.id + + Rails.logger.debug { 'Create ticket from message...' } + Rails.logger.debug { message.inspect } + Rails.logger.debug { user.inspect } + Rails.logger.debug { group_id.inspect } + + # prepare title + title = '-' + title = message[:message][:body] unless message[:message][:body].nil? + title = "#{title[0, 60]}..." if title.length > 60 + + # find ticket or create one + state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id) + ticket = Ticket.where(customer_id: user.id).where.not(state_id: state_ids).order(:updated_at).first + if ticket + + # check if title need to be updated + ticket.title = title if ticket.title == '-' + new_state = Ticket::State.find_by(default_create: true) + ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id + ticket.save! + return ticket + end + + ticket = Ticket.new( + group_id: group_id, + title: title, + state_id: Ticket::State.find_by(default_create: true).id, + priority_id: Ticket::Priority.find_by(default_create: true).id, + customer_id: user.id, + preferences: { + channel_id: channel.id, + cdr_signal: { + bot_token: channel.options[:bot_token], + chat_id: message[:source] + } + } + ) + ticket.save! + ticket + end + + def to_article(message, user, ticket, channel) + Rails.logger.debug { 'Create article from message...' } + Rails.logger.debug { message.inspect } + Rails.logger.debug { user.inspect } + Rails.logger.debug { ticket.inspect } + + UserInfo.current_user_id = user.id + + article = Ticket::Article.new( + from: message[:source], + to: channel[:options][:bot][:number], + body: message[:message][:body], + content_type: 'text/plain', + message_id: "cdr_signal.#{message[:id]}", + ticket_id: ticket.id, + type_id: Ticket::Article::Type.find_by(name: 'cdr_signal').id, + sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id, + internal: false, + preferences: { + cdr_signal: { + timestamp: message[:timestamp], + message_id: message[:id], + from: message[:source] + } + } + ) + + # TODO: attachments + # TODO voice + # TODO emojis + # + if message[:message][:body] + Rails.logger.debug { article.inspect } + article.save! + + Store.remove( + object: 'Ticket::Article', + o_id: article.id + ) + + return article + end + raise 'invalid action' + end + + def to_group(message, group_id, channel) + # begin import + Rails.logger.debug { 'signal import message' } + + # TODO: handle messages in group chats + + return if Ticket::Article.find_by(message_id: message[:id]) + + ticket = nil + # use transaction + Transaction.execute(reset_user_id: true) do + user = to_user(message) + ticket = to_ticket(message, user, group_id, channel) + to_article(message, user, ticket, channel) + end + + ticket + end + + def from_article(article) + # sends a message from a zammad article + + Rails.logger.debug { "Create signal message from article to '#{article[:to]}'..." } + + @api.send_message(article[:to], article[:body]) + end +end diff --git a/zammad-addon-metamigo/src/lib/cdr_signal_api.rb b/zammad-addon-metamigo/src/lib/cdr_signal_api.rb new file mode 100644 index 0000000..cc6935b --- /dev/null +++ b/zammad-addon-metamigo/src/lib/cdr_signal_api.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'json' +require 'net/http' +require 'net/https' +require 'uri' +require 'rest-client' + +class CdrSignalAPI + def initialize(api_url, token) + @token = token + @last_update = 0 + @api = api_url + end + + def parse_hash(hash) + ret = {} + hash.map do |k, v| + ret[k] = CGI.encode(v.to_s.gsub('\\\'', '\'')) + end + ret + end + + def get(api) + url = "#{@api}/bots/#{@token}/#{api}" + JSON.parse(RestClient.get(url, { accept: :json }).body) + end + + def post(api, params = {}) + url = "#{@api}/bots/#{@token}/#{api}" + JSON.parse(RestClient.post(url, params, { accept: :json }).body) + end + + def fetch_self + get('') + end + + def send_message(recipient, text, options = {}) + post('send', { phoneNumber: recipient.to_s, message: text }.merge(parse_hash(options))) + end +end diff --git a/zammad-addon-metamigo/src/lib/cdr_whatsapp.rb b/zammad-addon-metamigo/src/lib/cdr_whatsapp.rb new file mode 100644 index 0000000..dc81e92 --- /dev/null +++ b/zammad-addon-metamigo/src/lib/cdr_whatsapp.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true + +require 'cdr_whatsapp_api' + +class CdrWhatsapp + attr_accessor :client + + # + # check token and return bot attributes of token + # + # bot = CdrWhatsapp.check_token('token') + # + + def self.check_token(api_url, token) + api = CdrWhatsappAPI.new(api_url, token) + begin + bot = api.fetch_self + rescue StandardError => e + raise "invalid api token: #{e.message}" + end + bot + end + + # + # create or update channel, store bot attributes and verify token + # + # channel = CdrWhatsapp.create_or_update_channel('token', params) + # + # returns + # + # channel # instance of Channel + # + + def self.create_or_update_channel(api_url, token, params, channel = nil) + # verify token + bot = CdrWhatsapp.check_token(api_url, token) + + raise 'Bot already exists!' unless channel && CdrWhatsapp.bot_duplicate?(bot['id']) + + raise 'Group needed!' if params[:group_id].blank? + + group = Group.find_by(id: params[:group_id]) + raise 'Group invalid!' unless group + + unless channel + channel = CdrWhatsapp.bot_by_bot_id(bot['id']) + channel ||= Channel.new + end + channel.area = 'Whatsapp::Account' + channel.options = { + adapter: 'cdr_whatsapp', + bot: { + id: bot['id'], + number: bot['number'] + }, + api_token: token, + api_url: api_url, + welcome: params[:welcome] + } + channel.group_id = group.id + channel.active = true + channel.save! + channel + end + + # + # check if bot already exists as channel + # + # success = CdrWhatsapp.bot_duplicate?(bot_id) + # + # returns + # + # channel # instance of Channel + # + + def self.bot_duplicate?(bot_id, channel_id = nil) + Channel.where(area: 'Whatsapp::Account').each do |channel| + next unless channel.options + next unless channel.options[:bot] + next unless channel.options[:bot][:id] + next if channel.options[:bot][:id] != bot_id + next if channel.id.to_s == channel_id.to_s + + return true + end + false + end + + # + # get channel by bot_id + # + # channel = CdrWhatsapp.bot_by_bot_id(bot_id) + # + # returns + # + # true|false + # + + def self.bot_by_bot_token(bot_token) + Channel.where(area: 'Whatsapp::Account').each do |channel| + next unless channel.options + next unless channel.options[:bot_token] + return channel if channel.options[:bot_token].to_s == bot_token.to_s + end + nil + end + + # + # date = CdrWhatsapp.timestamp_to_date('1543414973285') + # + # returns + # + # 2018-11-28T14:22:53.285Z + # + + def self.timestamp_to_date(timestamp_str) + Time.at(timestamp_str.to_i).utc.to_datetime + end + + def self.message_id(message_raw) + format('%s@%s', source: message_raw['source'], timestamp: message_raw['timestamp']) + end + + # + # client = CdrWhatsapp.new('token') + # + + def initialize(api_url, token) + @token = token + @api_url = api_url + @api = CdrWhatsappAPI.new(api_url, token) + end + + # + # client.send_message(chat_id, 'some message') + # + + def send_message(recipient, message) + return if Rails.env.test? + + @api.send_message(recipient, message) + end + + def user(number) + { + # id: params[:message][:from][:id], + id: number, + username: number + # first_name: params[:message][:from][:first_name], + # last_name: params[:message][:from][:last_name] + } + end + + def to_user(message) + Rails.logger.debug { 'Create user from message...' } + Rails.logger.debug { message.inspect } + + # do message_user lookup + message_user = user(message[:source]) + + # create or update user + login = message_user[:username] || message_user[:id] + + auth = Authorization.find_by(uid: message[:source], provider: 'whatsapp') + + user_data = { + login: login, + mobile: message[:source] + } + + user = if auth + User.find(auth.user_id) + else + User.where(mobile: message[:source]).order(:updated_at).first + end + if user + user.update!(user_data) + else + user = User.create!( + firstname: message[:source], + mobile: message[:source], + note: "Whatsapp #{message_user[:username]}", + active: true, + role_ids: Role.signup_role_ids + ) + end + + # create or update authorization + auth_data = { + uid: message_user[:id], + username: login, + user_id: user.id, + provider: 'cdr_whatsapp' + } + if auth + auth.update!(auth_data) + else + Authorization.create(auth_data) + end + + user + end + + def to_ticket(message, user, group_id, channel) + UserInfo.current_user_id = user.id + + Rails.logger.debug { 'Create ticket from message...' } + Rails.logger.debug { message.inspect } + Rails.logger.debug { user.inspect } + Rails.logger.debug { group_id.inspect } + + # prepare title + title = '-' + title = message[:message][:body] unless message[:message][:body].nil? + title = "#{title[0, 60]}..." if title.length > 60 + + # find ticket or create one + state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id) + ticket = Ticket.where(customer_id: user.id).where.not(state_id: state_ids).order(:updated_at).first + if ticket + + # check if title need to be updated + ticket.title = title if ticket.title == '-' + new_state = Ticket::State.find_by(default_create: true) + ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id + ticket.save! + return ticket + end + + ticket = Ticket.new( + group_id: group_id, + title: title, + state_id: Ticket::State.find_by(default_create: true).id, + priority_id: Ticket::Priority.find_by(default_create: true).id, + customer_id: user.id, + preferences: { + channel_id: channel.id, + cdr_whatsapp: { + bot_id: channel.options[:bot][:id], + chat_id: message[:source] + } + } + ) + ticket.save! + ticket + end + + def to_article(message, user, ticket, channel) + Rails.logger.debug { 'Create article from message...' } + Rails.logger.debug { message.inspect } + Rails.logger.debug { user.inspect } + Rails.logger.debug { ticket.inspect } + + UserInfo.current_user_id = user.id + + article = Ticket::Article.new( + from: message[:source], + to: channel[:options][:bot][:number], + body: message[:message][:body], + content_type: 'text/plain', + message_id: "cdr_whatsapp.#{message[:id]}", + ticket_id: ticket.id, + type_id: Ticket::Article::Type.find_by(name: 'cdr_whatsapp').id, + sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id, + internal: false, + preferences: { + cdr_whatsapp: { + timestamp: message[:timestamp], + message_id: message[:id], + from: message[:source] + } + } + ) + + # TODO: attachments + # TODO voice + # TODO emojis + # + if message[:message][:body] + Rails.logger.debug { article.inspect } + article.save! + + Store.remove( + object: 'Ticket::Article', + o_id: article.id + ) + + return article + end + raise 'invalid action' + end + + def to_group(message, group_id, channel) + # begin import + Rails.logger.debug { 'whatsapp import message' } + + # TODO: handle messages in group chats + + return if Ticket::Article.find_by(message_id: message[:id]) + + ticket = nil + # use transaction + Transaction.execute(reset_user_id: true) do + user = to_user(message) + ticket = to_ticket(message, user, group_id, channel) + to_article(message, user, ticket, channel) + end + + ticket + end + + def from_article(article) + # sends a message from a zammad article + + Rails.logger.debug { "Create whatsapp message from article to '#{article[:to]}'..." } + + @api.send_message(article[:to], article[:body]) + end +end diff --git a/zammad-addon-metamigo/src/lib/cdr_whatsapp_api.rb b/zammad-addon-metamigo/src/lib/cdr_whatsapp_api.rb new file mode 100644 index 0000000..2ea0c7f --- /dev/null +++ b/zammad-addon-metamigo/src/lib/cdr_whatsapp_api.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'json' +require 'net/http' +require 'net/https' +require 'uri' +require 'rest-client' + +class CdrWhatsappAPI + def initialize(api_url, token) + @token = token + @last_update = 0 + @api_url = api_url + end + + def parse_hash(hash) + ret = {} + hash.map do |k, v| + ret[k] = CGI.encode(v.to_s.gsub('\\\'', '\'')) + end + ret + end + + def get(api) + url = "#{@api_url}/bots/#{@token}/#{api}" + JSON.parse(RestClient.get(url, { accept: :json }).body) + end + + def post(api, params = {}) + url = "#{@api_url}/bots/#{@token}/#{api}" + JSON.parse(RestClient.post(url, params, { accept: :json }).body) + end + + def fetch_self + get('') + end + + def send_message(recipient, text, options = {}) + post('send', { phoneNumber: recipient.to_s, message: text }.merge(parse_hash(options))) + end +end diff --git a/zammad-addon-metamigo/src/public/assets/images/icons/cdr_signal.svg b/zammad-addon-metamigo/src/public/assets/images/icons/cdr_signal.svg new file mode 100644 index 0000000..65e8843 --- /dev/null +++ b/zammad-addon-metamigo/src/public/assets/images/icons/cdr_signal.svg @@ -0,0 +1,3 @@ +signal + + diff --git a/zammad-addon-metamigo/src/public/assets/images/icons/cdr_whatsapp.svg b/zammad-addon-metamigo/src/public/assets/images/icons/cdr_whatsapp.svg new file mode 100644 index 0000000..9f19260 --- /dev/null +++ b/zammad-addon-metamigo/src/public/assets/images/icons/cdr_whatsapp.svg @@ -0,0 +1,6 @@ +whatsapp + + Svg Vector Icons : http://www.onlinewebfonts.com/icon + + + diff --git a/zammad-addon-pgp/.gitignore b/zammad-addon-pgp/.gitignore new file mode 100644 index 0000000..33f5ec6 --- /dev/null +++ b/zammad-addon-pgp/.gitignore @@ -0,0 +1,6 @@ +dist/ +.env* +node_modules/ +npm-debug.log* +*.szpm +.DS_Store diff --git a/zammad-addon-pgp/.gitlab-ci.yml b/zammad-addon-pgp/.gitlab-ci.yml new file mode 100644 index 0000000..594afcb --- /dev/null +++ b/zammad-addon-pgp/.gitlab-ci.yml @@ -0,0 +1,25 @@ +--- +image: registry.gitlab.com/guardianproject-ops/docker-python-node:python3.8-nodejs12 + +stages: + - test + - build + - trigger + +before_script: + - apt-get update && apt-get install -y make ruby + - gem install rufo -v 0.12.0 + - pip install inflection + +test: + stage: test + script: + - echo "Skipping tests" # make test + +build: + stage: build + artifacts: + paths: + - dist/*.szpm + script: + - make diff --git a/zammad-addon-pgp/.ruby-version b/zammad-addon-pgp/.ruby-version new file mode 100644 index 0000000..338a5b5 --- /dev/null +++ b/zammad-addon-pgp/.ruby-version @@ -0,0 +1 @@ +2.6.6 diff --git a/zammad-addon-pgp/Gemfile b/zammad-addon-pgp/Gemfile new file mode 100644 index 0000000..c857820 --- /dev/null +++ b/zammad-addon-pgp/Gemfile @@ -0,0 +1,10 @@ +source 'https://rubygems.org' +# git_source(:git) { |repo| "https://github.com/#{repo}.git" } + +ruby '2.6.5' + +# Use Sequoia for PGP encryption +# gem "sequoia-ruby-ffi", git: "https://gitlab.com/dorle/sequoia-ruby-ffi" + +# Use ruby_openpgp implementation of Sequoia for PGP encryption +gem 'ruby_openpgp', git: "https://github.com/throneless-tech/ruby_openpgp" diff --git a/zammad-addon-pgp/Gemfile.lock b/zammad-addon-pgp/Gemfile.lock new file mode 100644 index 0000000..f59365e --- /dev/null +++ b/zammad-addon-pgp/Gemfile.lock @@ -0,0 +1,60 @@ +PATH + remote: /Users/rae/Sites/Throneless/ruby_openpgp + specs: + ruby_openpgp (0.1.0) + ffi (~> 1) + rake (~> 13) + rspec (~> 3) + rubocop (~> 1.7) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.2) + diff-lcs (1.4.4) + ffi (1.15.0) + parallel (1.20.1) + parser (3.0.1.1) + ast (~> 2.4.1) + rainbow (3.0.0) + rake (13.0.3) + regexp_parser (2.1.1) + rexml (3.2.5) + rspec (3.10.0) + rspec-core (~> 3.10.0) + rspec-expectations (~> 3.10.0) + rspec-mocks (~> 3.10.0) + rspec-core (3.10.1) + rspec-support (~> 3.10.0) + rspec-expectations (3.10.1) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.10.0) + rspec-mocks (3.10.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.10.0) + rspec-support (3.10.2) + rubocop (1.14.0) + parallel (~> 1.10) + parser (>= 3.0.0.0) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml + rubocop-ast (>= 1.5.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 3.0) + rubocop-ast (1.5.0) + parser (>= 3.0.1.1) + ruby-progressbar (1.11.0) + unicode-display_width (2.0.0) + +PLATFORMS + x86_64-darwin-18 + +DEPENDENCIES + ruby_openpgp! + +RUBY VERSION + ruby 2.6.5p114 + +BUNDLED WITH + 2.2.9 diff --git a/zammad-addon-pgp/LICENSE.md b/zammad-addon-pgp/LICENSE.md new file mode 100644 index 0000000..bfcc3db --- /dev/null +++ b/zammad-addon-pgp/LICENSE.md @@ -0,0 +1,616 @@ +### GNU AFFERO GENERAL PUBLIC LICENSE + +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +### Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains +free software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing +under this license. + +The precise terms and conditions for copying, distribution and +modification follow. + +### TERMS AND CONDITIONS + +#### 0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public +License. + +"Copyright" also means copyright-like laws that apply to other kinds +of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of +an exact copy. The resulting work is called a "modified version" of +the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user +through a computer network, with no transfer of a copy, is not +conveying. + +An interactive user interface displays "Appropriate Legal Notices" to +the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +#### 1. Source Code. + +The "source code" for a work means the preferred form of the work for +making modifications to it. "Object code" means any non-source form of +a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can +regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same +work. + +#### 2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, +without conditions so long as your license otherwise remains in force. +You may convey covered works to others for the sole purpose of having +them make modifications exclusively for you, or provide you with +facilities for running those works, provided that you comply with the +terms of this License in conveying all material for which you do not +control copyright. Those thus making or running the covered works for +you must do so exclusively on your behalf, under your direction and +control, on terms that prohibit them from making any copies of your +copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes +it unnecessary. + +#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such +circumvention is effected by exercising rights under this License with +respect to the covered work, and you disclaim any intention to limit +operation or modification of the work as a means of enforcing, against +the work's users, your or third parties' legal rights to forbid +circumvention of technological measures. + +#### 4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +#### 5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these +conditions: + +- a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. +- b) The work must carry prominent notices stating that it is + released under this License and any conditions added under + section 7. This requirement modifies the requirement in section 4 + to "keep intact all notices". +- c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +#### 6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms of +sections 4 and 5, provided that you also convey the machine-readable +Corresponding Source under the terms of this License, in one of these +ways: + +- a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. +- b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the Corresponding + Source from a network server at no charge. +- c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. +- d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. +- e) Convey the object code using peer-to-peer transmission, + provided you inform other peers where the object code and + Corresponding Source of the work are being offered to the general + public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, +family, or household purposes, or (2) anything designed or sold for +incorporation into a dwelling. In determining whether a product is a +consumer product, doubtful cases shall be resolved in favor of +coverage. For a particular product received by a particular user, +"normally used" refers to a typical or common use of that class of +product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected +to use, the product. A product is a consumer product regardless of +whether the product has substantial commercial, industrial or +non-consumer uses, unless such uses represent the only significant +mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to +install and execute modified versions of a covered work in that User +Product from a modified version of its Corresponding Source. The +information must suffice to ensure that the continued functioning of +the modified object code is in no case prevented or interfered with +solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or +updates for a work that has been modified or installed by the +recipient, or for the User Product in which it has been modified or +installed. Access to a network may be denied when the modification +itself materially and adversely affects the operation of the network +or violates the rules and protocols for communication across the +network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +#### 7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders +of that material) supplement the terms of this License with terms: + +- a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or +- b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or +- c) Prohibiting misrepresentation of the origin of that material, + or requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or +- d) Limiting the use for publicity purposes of names of licensors + or authors of the material; or +- e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or +- f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions + of it) with contractual assumptions of liability to the recipient, + for any liability that these contractual assumptions directly + impose on those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; the +above requirements apply either way. + +#### 8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your license +from a particular copyright holder is reinstated (a) provisionally, +unless and until the copyright holder explicitly and finally +terminates your license, and (b) permanently, if the copyright holder +fails to notify you of the violation by some reasonable means prior to +60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +#### 9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run +a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +#### 10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +#### 11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned +or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within the +scope of its coverage, prohibits the exercise of, or is conditioned on +the non-exercise of one or more of the rights that are specifically +granted under this License. You may not convey a covered work if you +are a party to an arrangement with a third party that is in the +business of distributing software, under which you make payment to the +third party based on the extent of your activity of conveying the +work, and under which the third party grants, to any of the parties +who would receive the covered work from you, a discriminatory patent +license (a) in connection with copies of the covered work conveyed by +you (or copies made from those copies), or (b) primarily for and in +connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +#### 12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under +this License and any other pertinent obligations, then as a +consequence you may not convey it at all. For example, if you agree to +terms that obligate you to collect a royalty for further conveying +from those to whom you convey the Program, the only way you could +satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +#### 13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your +version supports such interaction) an opportunity to receive the +Corresponding Source of your version by providing access to the +Corresponding Source from a network server at no charge, through some +standard or customary means of facilitating copying of software. This +Corresponding Source shall include the Corresponding Source for any +work covered by version 3 of the GNU General Public License that is +incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +#### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU Affero General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever +published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions +of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +#### 15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT +WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND +PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR +CORRECTION. + +#### 16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR +CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT +NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR +LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM +TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER +PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +#### 17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS diff --git a/zammad-addon-pgp/Makefile b/zammad-addon-pgp/Makefile new file mode 100644 index 0000000..7fd4c89 --- /dev/null +++ b/zammad-addon-pgp/Makefile @@ -0,0 +1,28 @@ +.PHONY: prep clean + +build: prep + @./package.py + @find dist/ -iname "*szpm" + +prep: + @mkdir -p dist + +clean: prep + @rm -rf dist/* + +fmt: + rufo src + +new-migration: + @./new-migration.py + +init: + @echo "Give your addon a name. No spaces." + @echo "Addon name?: "; \ + read NAME; \ + mkdir -p "src/db/addon/$${NAME}"; \ + sed -i "s/NAME/$${NAME}/" base.szpm.template; \ + mv base.szpm.template "$${NAME}.szpm.template" + +test: + @echo "there are no tests yet" diff --git a/zammad-addon-pgp/README.md b/zammad-addon-pgp/README.md new file mode 100644 index 0000000..b59f33e --- /dev/null +++ b/zammad-addon-pgp/README.md @@ -0,0 +1,51 @@ +# zammad-addon-pgp + +Adds PGP integration into [Zammad](https://zammad.org) via [Sequoia](https://sequoia-pgp.org). + +## Configuration + +Once PGP addon has been successfully installed, there are a few steps required to set it up for use. This is also assuming that Zammad has already been correctly configured for sending and receiving email, and that you have command-line access to a system with the [gnupg](https://gnupg.org) client installed for generating and manipulating keys. + +### For Thunderbird users + +If you generated and manage your key through a current version of Thunderbird (see [here](https://support.mozilla.org/en-US/kb/openpgp-thunderbird-howto-and-faq) for general information on how to generate keys and use PGP in versions of Thunderbird 78 and above), you will need to use the following steps to prepare for the instructions below assuming you already have a key: + +1. Go to `Account Settings -> End-To-End Encryption` for the relevant account and click the 'OpenPGP Key Manager' button to access the key manager. +2. Click the relevant key and select `File -> Backup Secret Key(s) to File` and select a place to save the key file. +3. It will ask you to set a passphrase for the key, make sure to note this down for use below. +4. After the key has been saved to a file, go to that file in a terminal and run `gpg --import `. It will ask you for the passphrase you just set. +5. Go to #2 below and continue with the instructions. + +### Generate helpdesk key + +In order to receive encrypted email, the Zammad helpdesk must have a PGP key associated with its email address. You can follow the instructions [here](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key) in order to generate such a key if you do not already have one; when you get to the step where you enter your email address, use the email address associated with your Zammad installation. + +1. Generate the key, using the instructions above. Make a note of the corresponding keyid. +2. You'll need to remove the passphrase from the private key before adding it to Zammad. To do this, edit the key: `gpg --edit-key ` +3. In the resulting prompt, type `passwd`. Enter the passphrasse you set during key generation, and when you are prompted for a new passphrase just leave it blank and hit 'enter' twice and confirm. +4. As an admin user, go to the Zammad settings panel and under `Channels -> PGP Support` and click the `Add Key` button at the top. +5. In your terminal, export the private key with `gpg --export-secret-key --armor ` and paste the entire resulting text block including the header and footer into the box for the private key. Do the same for the public key box by exporting with `gpg --export --armor `. Select the group the key is associated with ('Users' by default). +6. Submit the changes + +Now your helpdesk is configured to accept encrypted email! + +### Set user keys + +In order to send an encrypted reply to a user who has submitted a ticket, they must have a public key configured. Either that user or an admin can go to settings under `Manage -> Users`, select the user account, and paste their public key in the `PGP Public Key` box. The key can be exported by the user by running `gpg --export --armor `, where is the keyid of the key associated with the email address with which they are sending an email to the helpdesk. Then submit the changes. + +The helpdesk can now send encrypted email to that user! + +## Help and Support + +Join us in our public matrix channel [#cdr-link-dev-support:matrix.org](https://matrix.to/#/#cdr-link-dev-support:matrix.org?via=matrix.org&via=neo.keanu.im). + +## License + +[![License GNU AGPL v3.0](https://img.shields.io/badge/License-AGPL%203.0-lightgrey.svg)](https://gitlab.com/digiresilience/link/zamamd-addon-sigarillo/blob/master/LICENSE.md) + +This is a free software project licensed under the GNU Affero General +Public License v3.0 (GNU AGPLv3) by [The Center for Digital +Resilience](https://digiresilience.org) and [Guardian +Project](https://guardianproject.info). + +🤸 diff --git a/zammad-addon-pgp/Rakefile b/zammad-addon-pgp/Rakefile new file mode 100644 index 0000000..e69de29 diff --git a/zammad-addon-pgp/new-migration.py b/zammad-addon-pgp/new-migration.py new file mode 100644 index 0000000..f74b323 --- /dev/null +++ b/zammad-addon-pgp/new-migration.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +import os +import json +import glob +import inflection +from datetime import datetime +from collections import OrderedDict + +migration_template = """class {} < ActiveRecord::Migration[5.2] + def self.up + # add your code here + end + + def self.down + # add your code here + end +end +""" + + +def load_skeleton(): + t = glob.glob('*.szpm.template') + if len(t) != 1: + raise Exception("Cannot find szpm template") + with open(t[0], 'r', encoding='utf-8') as f: + skeleton = json.load(f, object_pairs_hook=OrderedDict) + return skeleton + + +def main(): + skeleton = load_skeleton() + name = skeleton["name"].lower() + raw_name = input("Enter migration name: ") + migration_base_name = "{}_{}".format(name, inflection.underscore(raw_name)) + migration_name = inflection.camelize(migration_base_name, uppercase_first_letter=True) + contents = migration_template.format(migration_name) + time = datetime.utcnow().strftime("%Y%m%d%H%M%S") + migration_file_name = "{}_{}.rb".format(time, migration_base_name) + with open(os.path.join("src/db/addon/", skeleton["name"], migration_file_name), 'w') as f: + f.write(contents) + + +main() diff --git a/zammad-addon-pgp/package.py b/zammad-addon-pgp/package.py new file mode 100755 index 0000000..eae19c4 --- /dev/null +++ b/zammad-addon-pgp/package.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +import os +import base64 +import json +import datetime +import platform +import glob +import re +from collections import OrderedDict + +# files matching this pattern are not included in the package +ignored_patterns = [ + "\.gitkeep" +] + + +def encode(fname): + data = open(fname, "r", encoding='utf-8').read().encode('utf-8') + return base64.b64encode(data).decode('utf-8') + + +def read_perm(fname): + return int(oct(os.stat(fname).st_mode & 0o777)[-3:]) + + +def format_file(content, pkg_path, permission): + return OrderedDict( + location=pkg_path, + permission=permission, + encode="base64", + content=content) + + +def pkg_file(actual_path): + print(" Packaging: {}".format(actual_path)) + pkg_path = actual_path[6:] + contents = encode(actual_path) + res = format_file(contents, pkg_path, read_perm(actual_path)) + return res + + +def pkg_files(): + pkged_files = [] + for root, dirs, files in os.walk("./src/"): + for f in files: + if any(re.search(r, f) for r in ignored_patterns): + continue + actual_path = os.path.join(root, f) + pkged_files.append(pkg_file(actual_path)) + return pkged_files + + +def load_skeleton(): + t = glob.glob('*.szpm.template') + if len(t) != 1: + raise Exception("Cannot find szpm template") + with open(t[0], 'r', encoding='utf-8') as f: + skeleton = json.load(f, object_pairs_hook=OrderedDict) + return skeleton + + +def main(): + files = pkg_files() + skeleton = load_skeleton() + skeleton["files"] = files + skeleton["builddate"] = datetime.datetime.utcnow().isoformat() + skeleton["buildhost"] = platform.node() + name = skeleton["name"].lower() + version = skeleton["version"] + pkg = json.dumps(skeleton, indent=2) + with open("dist/{}-v{}.szpm".format(name, version), "w", encoding='utf-8') as f: + f.write(pkg) + + +main() diff --git a/zammad-addon-pgp/src/app/assets/javascripts/app/controllers/_integration/pgp.coffee b/zammad-addon-pgp/src/app/assets/javascripts/app/controllers/_integration/pgp.coffee new file mode 100644 index 0000000..ebe41bc --- /dev/null +++ b/zammad-addon-pgp/src/app/assets/javascripts/app/controllers/_integration/pgp.coffee @@ -0,0 +1,256 @@ +class Index extends App.ControllerIntegrationBase + featureIntegration: 'pgp_integration' + featureName: 'PGP' + featureConfig: 'pgp_config' + description: [ + ['PGP (Pretty Good Privacy) is a widely accepted method (or more precisely, a protocol) for sending digitally signed and encrypted messages.'] + ] + events: + 'change .js-switch input': 'switch' + + render: => + super + new Form( + el: @$('.js-form') + ) + + new App.HttpLog( + el: @$('.js-log') + facility: 'PGP' + ) + +class Form extends App.Controller + events: + 'click .js-addPublicKey': 'addPublicKey' + 'click .js-addPrivateKey': 'addPrivateKey' + 'click .js-updateGroup': 'updateGroup' + + constructor: -> + super + @render() + + currentConfig: -> + App.Setting.get('pgp_config') + + setConfig: (value) -> + App.Setting.set('pgp_config', value, {notify: true}) + + render: => + @config = @currentConfig() + + @html App.view('integration/pgp')( + config: @config + ) + @keyList() + @groupList() + + keyList: => + new List(el: @$('.js-keyList')) + + groupList: => + new Group( + el: @$('.js-groupList') + config: @config + ) + + addPublicKey: => + new PublicKey( + callback: @keyList + ) + + addPrivateKey: => + new PrivateKey( + callback: @keyList + ) + + updateGroup: (e) => + params = App.ControllerForm.params(e) + @setConfig(params) + +class PublicKey extends App.ControllerModal + buttonClose: true + buttonCancel: true + buttonSubmit: 'Add' + autoFocusOnFirstInput: false + head: 'Add Public Key' + large: true + + content: -> + + # show start dialog + content = $(App.view('integration/pgp_public_key_add')( + head: 'Add Public Key' + )) + content + + onSubmit: (e) => + params = new FormData($(e.currentTarget).closest('form').get(0)) + params.set('try', true) + if _.isEmpty(params.get('data')) + params.delete('data') + @formDisable(e) + + @ajax( + id: 'pgp-public_key-add' + type: 'POST' + url: "#{@apiPath}/integration/pgp/public_key" + processData: false + contentType: false + cache: false + data: params + success: (data, status, xhr) => + @close() + @callback() + error: (data) => + @close() + details = data.responseJSON || {} + @notify + type: 'error' + msg: App.i18n.translateContent(details.error_human || details.error || 'The import failed.') + timeout: 6000 + ) + +class PrivateKey extends App.ControllerModal + buttonClose: true + buttonCancel: true + buttonSubmit: 'Add' + autoFocusOnFirstInput: false + head: 'Add Private Key' + large: true + + content: -> + + # show start dialog + content = $(App.view('integration/pgp_private_key_add')( + head: 'Add Private Key' + )) + content + + onSubmit: (e) => + params = new FormData($(e.currentTarget).closest('form').get(0)) + params.set('try', true) + if _.isEmpty(params.get('data')) + params.delete('data') + @formDisable(e) + + @ajax( + id: 'pgp-private_key-add' + type: 'POST' + url: "#{@apiPath}/integration/pgp/private_key" + processData: false + contentType: false + cache: false + data: params + success: (data, status, xhr) => + @close() + @callback() + error: (data) => + @close() + details = data.responseJSON || {} + @notify + type: 'error' + msg: App.i18n.translateContent(details.error_human || details.error || 'The import failed.') + timeout: 6000 + ) + + +class List extends App.Controller + events: + 'click .js-remove': 'remove' + + constructor: -> + super + @load() + + load: => + @ajax( + id: 'pgp-list' + type: 'GET' + url: "#{@apiPath}/integration/pgp/public_key" + success: (data, status, xhr) => + @render(data) + + error: (data, status) => + + # do not close window if request is aborted + return if status is 'abort' + + details = data.responseJSON || {} + @notify( + type: 'error' + msg: App.i18n.translateContent(details.error_human || details.error || 'Loading failed.') + ) + + # do something + ) + + render: (data) => + @html App.view('integration/pgp_list')( + keyPairs: data + ) + + remove: (e) => + e.preventDefault() + id = $(e.currentTarget).parents('tr').data('id') + return if !id + + @ajax( + id: 'pgp-list' + type: 'DELETE' + url: "#{@apiPath}/integration/pgp/public_key" + data: JSON.stringify(id: id) + success: (data, status, xhr) => + @load() + + error: (data, status) => + + # do not close window if request is aborted + return if status is 'abort' + + details = data.responseJSON || {} + @notify( + type: 'error' + msg: App.i18n.translateContent(details.error_human || details.error || 'Server operation failed.') + ) + ) + +class Group extends App.Controller + constructor: -> + super + @render() + + render: (data) => + groups = App.Group.search(sortBy: 'name', filter: active: true) + @html App.view('integration/pgp_group')( + groups: groups + ) + for group in groups + for type, selector of { default_sign: 'js-signDefault', default_encryption: 'js-encryptionDefault' } + selected = true + if @config?.group_id && @config.group_id[type] + selected = @config.group_id[type][group.id.toString()] + selection = App.UiElement.boolean.render( + name: "group_id::#{type}::#{group.id}" + multiple: false + null: false + nulloption: false + value: selected + class: 'form-control--small' + ) + @$("[data-id=#{group.id}] .#{selector}").html(selection) + +class State + @current: -> + App.Setting.get('pgp_integration') + +App.Config.set( + 'Integrationpgp' + { + name: 'PGP' + target: '#system/integration/pgp' + description: 'PGP enables you to send digitally signed and encrypted messages.' + controller: Index + state: State + } + 'NavBarIntegrations' +) diff --git a/zammad-addon-pgp/src/app/assets/javascripts/app/controllers/_ui_element/ticket_perform_action.coffee b/zammad-addon-pgp/src/app/assets/javascripts/app/controllers/_ui_element/ticket_perform_action.coffee new file mode 100644 index 0000000..6f9495c --- /dev/null +++ b/zammad-addon-pgp/src/app/assets/javascripts/app/controllers/_ui_element/ticket_perform_action.coffee @@ -0,0 +1,614 @@ +# coffeelint: disable=camel_case_classes +class App.UiElement.ticket_perform_action + @defaults: (attribute) -> + defaults = ['ticket.state_id'] + + groups = + ticket: + name: 'Ticket' + model: 'Ticket' + article: + name: 'Article' + model: 'Article' + + if attribute.notification + groups.notification = + name: 'Notification' + model: 'Notification' + + # merge config + elements = {} + for groupKey, groupMeta of groups + if !groupMeta.model || !App[groupMeta.model] + if groupKey is 'notification' + elements["#{groupKey}.email"] = { name: 'email', display: 'Email' } + elements["#{groupKey}.sms"] = { name: 'sms', display: 'SMS' } + elements["#{groupKey}.webhook"] = { name: 'webhook', display: 'Webhook' } + else if groupKey is 'article' + elements["#{groupKey}.note"] = { name: 'note', display: 'Note' } + else + + for row in App[groupMeta.model].configure_attributes + + # ignore passwords and relations + if row.type isnt 'password' && row.name.substr(row.name.length-4,4) isnt '_ids' + + # ignore readonly attributes + if !row.readonly + config = _.clone(row) + + switch config.tag + when 'datetime' + config.operator = ['static', 'relative'] + when 'tag' + config.operator = ['add', 'remove'] + + elements["#{groupKey}.#{config.name}"] = config + + # add ticket deletion action + if attribute.ticket_delete + elements['ticket.action'] = + name: 'action' + display: 'Action' + tag: 'select' + null: false + translate: true + options: + delete: 'Delete' + + [defaults, groups, elements] + + @placeholder: (elementFull, attribute, params, groups, elements) -> + item = $( App.view('generic/ticket_perform_action/row')( attribute: attribute ) ) + selector = @buildAttributeSelector(elementFull, groups, elements) + item.find('.js-attributeSelector').prepend(selector) + item + + @render: (attribute, params = {}) -> + + [defaults, groups, elements] = @defaults(attribute) + + # return item + item = $( App.view('generic/ticket_perform_action/index')( attribute: attribute ) ) + + # add filter + item.on('click', '.js-rowActions .js-add', (e) => + element = $(e.target).closest('.js-filterElement') + placeholder = @placeholder(item, attribute, params, groups, elements) + if element.get(0) + element.after(placeholder) + else + item.append(placeholder) + placeholder.find('.js-attributeSelector select').trigger('change') + @updateAttributeSelectors(item) + ) + + # remove filter + item.on('click', '.js-rowActions .js-remove', (e) => + return if $(e.currentTarget).hasClass('is-disabled') + $(e.target).closest('.js-filterElement').remove() + @updateAttributeSelectors(item) + ) + + # change attribute selector + item.on('change', '.js-attributeSelector select', (e) => + elementRow = $(e.target).closest('.js-filterElement') + groupAndAttribute = elementRow.find('.js-attributeSelector option:selected').attr('value') + @rebuildAttributeSelectors(item, elementRow, groupAndAttribute, elements, {}, attribute) + @updateAttributeSelectors(item) + ) + + # change operator selector + item.on('change', '.js-operator select', (e) => + elementRow = $(e.target).closest('.js-filterElement') + groupAndAttribute = elementRow.find('.js-attributeSelector option:selected').attr('value') + @buildOperator(item, elementRow, groupAndAttribute, elements, {}, attribute) + ) + + # build initial params + if _.isEmpty(params[attribute.name]) + + for groupAndAttribute in defaults + + # build and append + element = @placeholder(item, attribute, params, groups, elements) + item.append(element) + @rebuildAttributeSelectors(item, element, groupAndAttribute, elements, {}, attribute) + + else + + for groupAndAttribute, meta of params[attribute.name] + + # build and append + element = @placeholder(item, attribute, params, groups, elements) + @rebuildAttributeSelectors(item, element, groupAndAttribute, elements, meta, attribute) + item.append(element) + + @disableRemoveForOneAttribute(item) + item + + @buildAttributeSelector: (elementFull, groups, elements) -> + + # find first possible attribute + selectedValue = '' + elementFull.find('.js-attributeSelector select option').each(-> + if !selectedValue && !$(@).prop('disabled') + selectedValue = $(@).val() + ) + + selection = $('') + for groupKey, groupMeta of groups + displayName = App.i18n.translateInline(groupMeta.name) + selection.closest('select').append("") + optgroup = selection.find("optgroup.js-#{groupKey}") + for elementKey, elementGroup of elements + spacer = elementKey.split(/\./) + if spacer[0] is groupKey + attributeConfig = elements[elementKey] + displayName = App.i18n.translateInline(attributeConfig.display) + + selected = '' + if elementKey is selectedValue + selected = 'selected="selected"' + optgroup.append("") + selection + + # disable - if we only have one attribute + @disableRemoveForOneAttribute: (elementFull) -> + if elementFull.find('.js-attributeSelector select').length > 1 + elementFull.find('.js-remove').removeClass('is-disabled') + else + elementFull.find('.js-remove').addClass('is-disabled') + + @updateAttributeSelectors: (elementFull) -> + + # enable all + elementFull.find('.js-attributeSelector select option').prop('disabled', false) + + # disable all used attributes + elementFull.find('.js-attributeSelector select').each(-> + keyLocal = $(@).val() + elementFull.find('.js-attributeSelector select option[value="' + keyLocal + '"]').attr('disabled', true) + ) + + # disable - if we only have one attribute + @disableRemoveForOneAttribute(elementFull) + + @rebuildAttributeSelectors: (elementFull, elementRow, groupAndAttribute, elements, meta, attribute) -> + + # set attribute + if groupAndAttribute + elementRow.find('.js-attributeSelector select').val(groupAndAttribute) + + notificationTypeMatch = groupAndAttribute.match(/^notification.([\w]+)$/) + articleTypeMatch = groupAndAttribute.match(/^article.([\w]+)$/) + + if _.isArray(notificationTypeMatch) && notificationType = notificationTypeMatch[1] + elementRow.find('.js-setAttribute').html('').addClass('hide') + elementRow.find('.js-setArticle').html('').addClass('hide') + @buildNotificationArea(notificationType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute) + else if _.isArray(articleTypeMatch) && articleType = articleTypeMatch[1] + elementRow.find('.js-setAttribute').html('').addClass('hide') + elementRow.find('.js-setNotification').html('').addClass('hide') + @buildArticleArea(articleType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute) + else + elementRow.find('.js-setNotification').html('').addClass('hide') + elementRow.find('.js-setArticle').html('').addClass('hide') + if !elementRow.find('.js-setAttribute div').get(0) + attributeSelectorElement = $( App.view('generic/ticket_perform_action/attribute_selector')( + attribute: attribute + name: name + meta: meta || {} + )) + elementRow.find('.js-setAttribute').html(attributeSelectorElement).removeClass('hide') + @buildOperator(elementFull, elementRow, groupAndAttribute, elements, meta, attribute) + + @buildOperator: (elementFull, elementRow, groupAndAttribute, elements, meta, attribute) -> + currentOperator = elementRow.find('.js-operator option:selected').attr('value') + + if !meta.operator + meta.operator = currentOperator + + name = "#{attribute.name}::#{groupAndAttribute}::operator" + + selection = $("") + attributeConfig = elements[groupAndAttribute] + if !attributeConfig || !attributeConfig.operator + elementRow.find('.js-operator').parent().addClass('hide') + else + elementRow.find('.js-operator').parent().removeClass('hide') + if attributeConfig && attributeConfig.operator + for operator in attributeConfig.operator + operatorName = App.i18n.translateInline(operator) + selected = '' + if meta.operator is operator + selected = 'selected="selected"' + selection.append("") + selection + + elementRow.find('.js-operator select').replaceWith(selection) + + @buildPreCondition(elementFull, elementRow, groupAndAttribute, elements, meta, attribute) + + @buildPreCondition: (elementFull, elementRow, groupAndAttribute, elements, meta, attributeConfig) -> + currentOperator = elementRow.find('.js-operator option:selected').attr('value') + currentPreCondition = elementRow.find('.js-preCondition option:selected').attr('value') + + if !meta.pre_condition + meta.pre_condition = currentPreCondition + + toggleValue = => + preCondition = elementRow.find('.js-preCondition option:selected').attr('value') + if preCondition isnt 'specific' + elementRow.find('.js-value select').html('') + elementRow.find('.js-value').addClass('hide') + else + elementRow.find('.js-value').removeClass('hide') + @buildValue(elementFull, elementRow, groupAndAttribute, elements, meta, attribute) + + # force to use auto complition on user lookup + attribute = _.clone(attributeConfig) + + name = "#{attribute.name}::#{groupAndAttribute}::value" + attributeSelected = elements[groupAndAttribute] + + preCondition = false + if attributeSelected.relation is 'User' + preCondition = 'user' + attribute.tag = 'user_autocompletion' + if attributeSelected.relation is 'Organization' + preCondition = 'org' + attribute.tag = 'autocompletion_ajax' + if !preCondition + elementRow.find('.js-preCondition select').html('') + elementRow.find('.js-preCondition').closest('.controls').addClass('hide') + toggleValue() + @buildValue(elementFull, elementRow, groupAndAttribute, elements, meta, attribute) + return + + elementRow.find('.js-preCondition').closest('.controls').removeClass('hide') + name = "#{attribute.name}::#{groupAndAttribute}::pre_condition" + + selection = $("") + options = {} + if preCondition is 'user' + options = + 'current_user.id': App.i18n.translateInline('current user') + 'specific': App.i18n.translateInline('specific user') + + if attributeSelected.null is true + options['not_set'] = App.i18n.translateInline('unassign user') + + else if preCondition is 'org' + options = + 'current_user.organization_id': App.i18n.translateInline('current user organization') + 'specific': App.i18n.translateInline('specific organization') + + for key, value of options + selected = '' + if key is meta.pre_condition + selected = 'selected="selected"' + selection.append("") + elementRow.find('.js-preCondition').closest('.controls').removeClass('hide') + elementRow.find('.js-preCondition select').replaceWith(selection) + + elementRow.find('.js-preCondition select').on('change', (e) -> + toggleValue() + ) + + @buildValue(elementFull, elementRow, groupAndAttribute, elements, meta, attribute) + toggleValue() + + @buildValue: (elementFull, elementRow, groupAndAttribute, elements, meta, attribute) -> + name = "#{attribute.name}::#{groupAndAttribute}::value" + + # build new item + attributeConfig = elements[groupAndAttribute] + config = _.clone(attributeConfig) + + if config.relation is 'User' + config.tag = 'user_autocompletion' + if config.relation is 'Organization' + config.tag = 'autocompletion_ajax' + + # render ui element + item = '' + if config && App.UiElement[config.tag] + config['name'] = name + if attribute.value && attribute.value[groupAndAttribute] + config['value'] = _.clone(attribute.value[groupAndAttribute]['value']) + config.multiple = false + config.nulloption = config.null + if config.tag is 'checkbox' + config.tag = 'select' + tagSearch = "#{config.tag}_search" + if config.tag is 'datetime' + config.validationContainer = 'self' + if App.UiElement[tagSearch] + item = App.UiElement[tagSearch].render(config, {}) + else + item = App.UiElement[config.tag].render(config, {}) + + relative_operators = [ + 'before (relative)', + 'within next (relative)', + 'within last (relative)', + 'after (relative)', + 'till (relative)', + 'from (relative)', + 'relative' + ] + + upcoming_operator = meta.operator + + if !_.include(config.operator, upcoming_operator) + if Array.isArray(config.operator) + upcoming_operator = config.operator[0] + else + upcoming_operator = null + + if _.include(relative_operators, upcoming_operator) + config['name'] = "#{attribute.name}::#{groupAndAttribute}" + if attribute.value && attribute.value[groupAndAttribute] + config['value'] = _.clone(attribute.value[groupAndAttribute]) + item = App.UiElement['time_range'].render(config, {}) + + elementRow.find('.js-setAttribute > .flex > .js-value').removeClass('hide').html(item) + + @buildNotificationArea: (notificationType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute) -> + + return if elementRow.find(".js-setNotification .js-body-#{notificationType}").get(0) + + elementRow.find('.js-setNotification').empty() + + options = + 'article_last_sender': 'Sender of last article' + 'ticket_owner': 'Owner' + 'ticket_customer': 'Customer' + 'ticket_agents': 'All agents' + + name = "#{attribute.name}::notification.#{notificationType}" + + messageLength = switch notificationType + when 'sms' then 160 + else 200000 + + # meta.recipient was a string in the past (single-select) so we convert it to array if needed + if !_.isArray(meta.recipient) + meta.recipient = [meta.recipient] + + columnSelectOptions = [] + for key, value of options + selected = undefined + for recipient in meta.recipient + if key is recipient + selected = true + columnSelectOptions.push({ value: key, name: App.i18n.translatePlain(value), selected: selected }) + + columnSelectRecipientUserOptions = [] + for user in App.User.all() + key = "userid_#{user.id}" + selected = undefined + for recipient in meta.recipient + if key is recipient + selected = true + columnSelectRecipientUserOptions.push({ value: key, name: "#{user.firstname} #{user.lastname}", selected: selected }) + + columnSelectRecipient = new App.ColumnSelect + attribute: + name: "#{name}::recipient" + options: [ + { + label: 'Variables', + group: columnSelectOptions + }, + { + label: 'User', + group: columnSelectRecipientUserOptions + }, + ] + + selectionRecipient = columnSelectRecipient.element() + + if notificationType is 'webhook' + notificationElement = $( App.view('generic/ticket_perform_action/webhook')( + attribute: attribute + name: name + notificationType: notificationType + meta: meta || {} + )) + + notificationElement.find('.js-recipient select').replaceWith(selectionRecipient) + + + if App.Webhook.search(filter: { active: true }).length isnt 0 || !_.isEmpty(meta.webhook_id) + webhookSelection = App.UiElement.select.render( + name: "#{name}::webhook_id" + multiple: false + null: false + relation: 'Webhook' + value: meta.webhook_id + translate: false + nulloption: true + ) + else + webhookSelection = App.view('generic/ticket_perform_action/webhook_not_available')( attribute: attribute ) + + notificationElement.find('.js-webhooks').html(webhookSelection) + + else + notificationElement = $( App.view('generic/ticket_perform_action/notification')( + attribute: attribute + name: name + notificationType: notificationType + meta: meta || {} + )) + + notificationElement.find('.js-recipient select').replaceWith(selectionRecipient) + + visibilitySelection = App.UiElement.select.render( + name: "#{name}::internal" + multiple: false + null: false + options: { true: 'internal', false: 'public' } + value: meta.internal || 'false' + translate: true + ) + + includeAttachmentsCheckbox = App.UiElement.select.render( + name: "#{name}::include_attachments" + multiple: false + null: false + options: { true: 'Yes', false: 'No' } + value: meta.include_attachments || 'false' + translate: true + ) + + notificationElement.find('.js-internal').html(visibilitySelection) + notificationElement.find('.js-include_attachments').html(includeAttachmentsCheckbox) + + notificationElement.find('.js-body div[contenteditable="true"]').ce( + mode: 'richtext' + placeholder: 'message' + maxlength: messageLength + ) + new App.WidgetPlaceholder( + el: notificationElement.find('.js-body div[contenteditable="true"]').parent() + objects: [ + { + prefix: 'ticket' + object: 'Ticket' + display: 'Ticket' + }, + { + prefix: 'article' + object: 'TicketArticle' + display: 'Article' + }, + { + prefix: 'user' + object: 'User' + display: 'Current User' + }, + ] + ) + + elementRow.find('.js-setNotification').html(notificationElement).removeClass('hide') + + if App.Config.get('smime_integration') == true || App.Config.get('pgp_integration') == true + selection = App.UiElement.select.render( + name: "#{name}::sign" + multiple: false + options: { + 'no': 'Do not sign email' + 'discard': 'Sign email (if not possible, discard notification)' + 'always': 'Sign email (if not possible, send notification anyway)' + } + value: meta.sign + translate: true + ) + + elementRow.find('.js-sign').html(selection) + + selection = App.UiElement.select.render( + name: "#{name}::encryption" + multiple: false + options: { + 'no': 'Do not encrypt email' + 'discard': 'Encrypt email (if not possible, discard notification)' + 'always': 'Encrypt email (if not possible, send notification anyway)' + } + value: meta.encryption + translate: true + ) + + elementRow.find('.js-encryption').html(selection) + + @buildArticleArea: (articleType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute) -> + + return if elementRow.find(".js-setArticle .js-body-#{articleType}").get(0) + + elementRow.find('.js-setArticle').empty() + + name = "#{attribute.name}::article.#{articleType}" + selection = App.UiElement.select.render( + name: "#{name}::internal" + multiple: false + null: false + label: 'Visibility' + options: { true: 'internal', false: 'public' } + value: meta.internal + translate: true + ) + articleElement = $( App.view('generic/ticket_perform_action/article')( + attribute: attribute + name: name + articleType: articleType + meta: meta || {} + )) + articleElement.find('.js-internal').html(selection) + articleElement.find('.js-body div[contenteditable="true"]').ce( + mode: 'richtext' + placeholder: 'message' + maxlength: 200000 + ) + new App.WidgetPlaceholder( + el: articleElement.find('.js-body div[contenteditable="true"]').parent() + objects: [ + { + prefix: 'ticket' + object: 'Ticket' + display: 'Ticket' + }, + { + prefix: 'article' + object: 'TicketArticle' + display: 'Article' + }, + { + prefix: 'user' + object: 'User' + display: 'Current User' + }, + ] + ) + + elementRow.find('.js-setArticle').html(articleElement).removeClass('hide') + + @humanText: (condition) -> + none = App.i18n.translateContent('No filter.') + return [none] if _.isEmpty(condition) + [defaults, groups, operators, elements] = @defaults() + rules = [] + for attribute, value of condition + + objectAttribute = attribute.split(/\./) + + # get stored params + if meta && objectAttribute[1] + model = toCamelCase(objectAttribute[0]) + config = elements[attribute] + + valueHuman = [] + if _.isArray(value) + for data in value + r = @humanTextLookup(config, data) + valueHuman.push r + else + valueHuman.push @humanTextLookup(config, value) + + if valueHuman.join + valueHuman = valueHuman.join(', ') + rules.push "#{App.i18n.translateContent('Set')} #{App.i18n.translateContent(model)} -> #{App.i18n.translateContent(config.display)} #{App.i18n.translateContent('to')} #{valueHuman}." + + return [none] if _.isEmpty(rules) + rules + + @humanTextLookup: (config, value) -> + return value if !App[config.relation] + return value if !App[config.relation].exists(value) + data = App[config.relation].fullLocal(value) + return value if !data + if data.displayName + return App.i18n.translateContent( data.displayName() ) + valueHuman.push App.i18n.translateContent( data.name ) diff --git a/zammad-addon-pgp/src/app/assets/javascripts/app/lib/mixins/security_options.coffee b/zammad-addon-pgp/src/app/assets/javascripts/app/lib/mixins/security_options.coffee new file mode 100644 index 0000000..55dd11f --- /dev/null +++ b/zammad-addon-pgp/src/app/assets/javascripts/app/lib/mixins/security_options.coffee @@ -0,0 +1,99 @@ +# Methods for displaying security ui elements and to get security params + +App.SecurityOptions = + + securityOptionsShow: -> + @$('.js-securityOptions').removeClass('hide') + + securityOptionsHide: -> + @$('.js-securityOptions').addClass('hide') + + securityOptionsShown: -> + !@$('.js-securityOptions').hasClass('hide') + + securityEnabled: -> + App.Config.get('smime_integration') || App.Config.get('pgp_integration') + + paramsSecurity: => + if @$('.js-securityOptions').hasClass('hide') + return {} + + security = {} + security.encryption ||= {} + security.sign ||= {} + if App.Config.get('pgp_integration') + security.type = 'PGP' + else + security.type = 'S/MIME' + if @$('.js-securityEncrypt').hasClass('btn--active') + security.encryption.success = true + if @$('.js-securitySign').hasClass('btn--active') + security.sign.success = true + security + + updateSecurityOptionsRemote: (key, ticket, article, securityOptions) -> + if securityOptions.type == 'PGP' + id = "pgp-check-#{key}" + url = "#{@apiPath}/integration/pgp" + securityConfig = App.Config.get('pgp_config') + else + id = "smime-check-#{key}" + url = "#{@apiPath}/integration/smime" + securityConfig = App.Config.get('smime_config') + callback = => + @ajax( + id: id + type: 'POST' + url: url + data: JSON.stringify(ticket: ticket, article: article) + processData: true + success: (data, status, xhr) => + + # get default selected security options + selected = + encryption: true + sign: true + for type, selector of { default_sign: 'sign', default_encryption: 'encryption' } + if securityConfig?.group_id?[type] && ticket.group_id + if securityConfig.group_id[type][ticket.group_id.toString()] == false + selected[selector] = false + + @$('.js-securityEncryptComment').attr('title', data.encryption.comment) + + # if encryption is possible + if data.encryption.success is true + @$('.js-securityEncrypt').attr('disabled', false) + + # overrule current selection with Group configuration + if selected.encryption + @$('.js-securityEncrypt').addClass('btn--active') + else + @$('.js-securityEncrypt').removeClass('btn--active') + + # if encryption is not possible + else + @$('.js-securityEncrypt').attr('disabled', true) + @$('.js-securityEncrypt').removeClass('btn--active') + + @$('.js-securitySignComment').attr('title', data.sign.comment) + + # if sign is possible + if data.sign.success is true + @$('.js-securitySign').attr('disabled', false) + + # overrule current selection with Group configuration + if selected.sign + @$('.js-securitySign').addClass('btn--active') + else + @$('.js-securitySign').removeClass('btn--active') + + # if sign is possible + else + @$('.js-securitySign').attr('disabled', true) + @$('.js-securitySign').removeClass('btn--active') + + error: (data) -> + details = data.responseJSON || {} + console.log(details) + ) + @delay(callback, 200, 'security-check') diff --git a/zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp.jst.eco b/zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp.jst.eco new file mode 100644 index 0000000..eff0fbd --- /dev/null +++ b/zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp.jst.eco @@ -0,0 +1,14 @@ +
+

<%- @T('Public & Private Keys') %>

+
+ +
<%- @T('Add Public Key') %>
+
<%- @T('Add Private Key') %>
+ +
+ +

<%- @T('Default Behavior') %>

+

Choose the default behavior of the PGP integration on per group basis. If signing or encrypting is not possible, the setting has no effect. Agents call always manually alter the behavior for each article.

+
+
<%- @T('Update') %>
+
diff --git a/zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp_group.jst.eco b/zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp_group.jst.eco new file mode 100644 index 0000000..fb4e954 --- /dev/null +++ b/zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp_group.jst.eco @@ -0,0 +1,25 @@ + + + + + + + <% if _.isEmpty(@groups): %> + + + + <% else: %> + <% for group in @groups: %> + + +
<%- @T('Group') %> + <%- @T('Sign') %> + <%- @T('Encryption') %> +
+ <%- @T('No Entries') %> +
<%= group.name %> + + + <% end %> + <% end %> +
diff --git a/zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp_list.jst.eco b/zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp_list.jst.eco new file mode 100644 index 0000000..e0552fe --- /dev/null +++ b/zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp_list.jst.eco @@ -0,0 +1,44 @@ + + + + + + <% if _.isEmpty(@keyPairs): %> + + + + <% else: %> + <% for keyPair in @keyPairs: %> + + + <% end %> + <% end %> + +
<%- @T('Email') %> + <%- @T('Fingerprint') %> + <%- @T('Actions') %> +
+ <%- @T('No Entries') %> +
<% if !_.isEmpty(keyPair.email_addresses): %><%= keyPair.email_addresses.toString() %><% end %> + <% if keyPair.private_key: %>
<%- @T('Including private key.') %><% end %> +
<%= keyPair.fingerprint %> + + +
diff --git a/zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp_private_key_add.jst.eco b/zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp_private_key_add.jst.eco new file mode 100644 index 0000000..e2b2918 --- /dev/null +++ b/zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp_private_key_add.jst.eco @@ -0,0 +1,38 @@ +
+

+ +
+
+
+ +
+
+ +
+
+ +
+ <%- @T('or') %> +
+ +
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
diff --git a/zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp_public_key_add.jst.eco b/zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp_public_key_add.jst.eco new file mode 100644 index 0000000..d7e73c9 --- /dev/null +++ b/zammad-addon-pgp/src/app/assets/javascripts/app/views/integration/pgp_public_key_add.jst.eco @@ -0,0 +1,27 @@ +
+

+ +
+
+ +
+
+ +
+
+ +
+ <%- @T('or') %> +
+ +
+
+ +
+
+ +
+
+ +
diff --git a/zammad-addon-pgp/src/app/assets/stylesheets/addons/pgp-support.css b/zammad-addon-pgp/src/app/assets/stylesheets/addons/pgp-support.css new file mode 100644 index 0000000..f66e3e3 --- /dev/null +++ b/zammad-addon-pgp/src/app/assets/stylesheets/addons/pgp-support.css @@ -0,0 +1 @@ +.icon-pgp { width:17px; height: 17px; } diff --git a/zammad-addon-pgp/src/app/controllers/integration/pgp_controller.rb b/zammad-addon-pgp/src/app/controllers/integration/pgp_controller.rb new file mode 100644 index 0000000..8e3cd73 --- /dev/null +++ b/zammad-addon-pgp/src/app/controllers/integration/pgp_controller.rb @@ -0,0 +1,154 @@ +# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ + +class Integration::PGPController < ApplicationController + prepend_before_action { authentication_check && authorize! } + + def public_key_download + cert = PGPKeypair.find(params[:id]) + + send_data( + cert.public_key, + filename: "#{cert.fingerprint}.asc", + type: 'text/plain', + disposition: 'attachment' + ) + end + + def private_key_download + cert = PGPKeypair.find(params[:id]) + + send_data( + cert.private_key, + filename: "#{cert.fingerprint}-private.asc", + type: 'text/plain', + disposition: 'attachment' + ) + end + + def public_key_list + render json: PGPKeypair.all, methods: :email_addresses + end + + def public_key_delete + PGPKeypair.find(params[:id]).destroy! + render json: { + result: 'ok' + } + end + + def public_key_add + string = params[:data] + string = params[:file].read.force_encoding('utf-8') if string.blank? && params[:file].present? + + items = PGPKeypair.create_public_keys(string) + + render json: { + result: 'ok', + response: items + } + rescue StandardError => e + unprocessable_entity(e) + end + + def private_key_delete + PGPKeypair.find(params[:id]).update!( + private_key: nil, + private_key_secret: nil + ) + + render json: { + result: 'ok' + } + end + + def private_key_add + string = params[:data] + string = params[:file].read.force_encoding('utf-8') if string.blank? && params[:file].present? + + raise "Parameter 'data' or 'file' required." if string.blank? + + PGPKeypair.create_private_keys(string, params[:secret]) + + render json: { + result: 'ok' + } + rescue StandardError => e + unprocessable_entity(e) + end + + def search + result = { + type: 'PGP' + } + + result[:encryption] = article_encryption(params[:article]) + result[:sign] = article_sign(params[:ticket]) + + render json: result + end + + def article_encryption(article) + result = { + success: false, + comment: 'no recipient found' + } + + return result if article.blank? + return result if article[:to].blank? && article[:cc].blank? + + recipient = [article[:to], article[:cc]].compact.join(',').to_s + recipients = [] + begin + list = Mail::AddressList.new(recipient) + list.addresses.each do |address| + recipients.push address.address + end + rescue StandardError # rubocop:disable Lint/SuppressedException + end + + return result if recipients.blank? + + begin + keys = PGPKeypair.for_recipient_email_addresses!(recipients) + + if keys + result[:success] = true + result[:comment] = "keys found for #{recipients.join(',')}" + end + rescue StandardError => e + result[:comment] = e.message + end + + result + end + + def article_sign(ticket) + result = { + success: false, + comment: 'key not found' + } + + return result if ticket.blank? || !ticket[:group_id] + + group = Group.find_by(id: ticket[:group_id]) + return result unless group + + email_address = group.email_address + begin + list = Mail::AddressList.new(email_address.email) + from = list.addresses.first.to_s + key = PGPKeypair.for_sender_email_address(from) + if key + result[:success] = true + result[:comment] = "key for #{email_address.email} found" + else + result[:success] = false + result[:comment] = "no key for #{email_address.email} found" + end + rescue StandardError => e + result[:comment] = e.message + end + + result + end +end diff --git a/zammad-addon-pgp/src/app/models/pgp_keypair.rb b/zammad-addon-pgp/src/app/models/pgp_keypair.rb new file mode 100644 index 0000000..60a0705 --- /dev/null +++ b/zammad-addon-pgp/src/app/models/pgp_keypair.rb @@ -0,0 +1,64 @@ +# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ + +class PGPKeypair < ApplicationModel + validates :fingerprint, uniqueness: { case_sensitive: true } + + def self.create_private_keys(raw, secret) + Sequoia.emails_of(keys: raw).each do |address| + downcased_address = address.downcase + public_key = find_each.detect do |certificate| + certificate.email_addresses.include?(downcased_address) + end + + unless public_key + raise Exceptions::UnprocessableEntity, + 'The public key for this private key could not be found.' + end + + public_key.update!(private_key: raw, private_key_secret: secret) + end + end + + def self.create_public_keys(raw) + create!(public_key: raw) + end + + def self.for_sender_email_address(address) + downcased_address = address.downcase + where.not(private_key: nil).find_each.detect do |certificate| + certificate.email_addresses.include?(downcased_address) + end + end + + def self.for_recipient_email_addresses!(addresses) + certificates = [] + remaining_addresses = addresses.map(&:downcase) + find_each do |certificate| + # intersection of both lists + certificate_for = certificate.email_addresses & remaining_addresses + next if certificate_for.blank? + + certificates.push(certificate) + + # subtract found recipient(s) + remaining_addresses -= certificate_for + + # end loop if no addresses are remaining + break if remaining_addresses.blank? + end + + return certificates if remaining_addresses.blank? + + raise ActiveRecord::RecordNotFound, + "Can't find PGP encryption certificates for: #{remaining_addresses.join(', ')}" + end + + def public_key=(string) + self.fingerprint = Sequoia.fingerprints_of(keys: string).first + self[:public_key] = string + end + + def email_addresses + @email_addresses ||= Sequoia.emails_of(keys: public_key) + end +end diff --git a/zammad-addon-pgp/src/app/models/ticket.rb b/zammad-addon-pgp/src/app/models/ticket.rb new file mode 100644 index 0000000..77c09b9 --- /dev/null +++ b/zammad-addon-pgp/src/app/models/ticket.rb @@ -0,0 +1,1909 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class Ticket < ApplicationModel + include CanBeImported + include HasActivityStreamLog + include ChecksClientNotification + include ChecksLatestChangeObserved + include CanCsvImport + include ChecksHtmlSanitized + include HasHistory + include HasTags + include HasSearchIndexBackend + include HasOnlineNotifications + include HasKarmaActivityLog + include HasLinks + include HasObjectManagerAttributesValidation + include HasTaskbars + include Ticket::CallsStatsTicketReopenLog + include Ticket::EnqueuesUserTicketCounterJob + include Ticket::ResetsPendingTimeSeconds + include Ticket::SetsCloseTime + include Ticket::SetsOnlineNotificationSeen + include Ticket::TouchesAssociations + + include ::Ticket::Escalation + include ::Ticket::Subject + include ::Ticket::Assets + include ::Ticket::SearchIndex + include ::Ticket::Search + include ::Ticket::MergeHistory + + store :preferences + before_create :check_generate, :check_defaults, :check_title, :set_default_state, :set_default_priority + before_update :check_defaults, :check_title, :reset_pending_time, :check_owner_active + + # This must be loaded late as it depends on the internal before_create and before_update handlers of ticket.rb. + include Ticket::SetsLastOwnerUpdateTime + + include HasTransactionDispatcher + + validates :group_id, presence: true + + activity_stream_permission 'ticket.agent' + + activity_stream_attributes_ignored :organization_id, # organization_id will change automatically on user update + :create_article_type_id, + :create_article_sender_id, + :article_count, + :first_response_at, + :first_response_escalation_at, + :first_response_in_min, + :first_response_diff_in_min, + :close_at, + :close_escalation_at, + :close_in_min, + :close_diff_in_min, + :update_escalation_at, + :update_in_min, + :update_diff_in_min, + :last_contact_at, + :last_contact_agent_at, + :last_contact_customer_at, + :last_owner_update_at, + :preferences + + history_attributes_ignored :create_article_type_id, + :create_article_sender_id, + :article_count, + :preferences + + history_relation_object 'Ticket::Article', 'Mention' + + sanitized_html :note + + belongs_to :group, optional: true + belongs_to :organization, optional: true + has_many :articles, class_name: 'Ticket::Article', after_add: :cache_update, after_remove: :cache_update, dependent: :destroy, inverse_of: :ticket + has_many :ticket_time_accounting, class_name: 'Ticket::TimeAccounting', dependent: :destroy, inverse_of: :ticket + has_many :flags, class_name: 'Ticket::Flag', dependent: :destroy + has_many :mentions, as: :mentionable, dependent: :destroy + belongs_to :state, class_name: 'Ticket::State', optional: true + belongs_to :priority, class_name: 'Ticket::Priority', optional: true + belongs_to :owner, class_name: 'User', optional: true + belongs_to :customer, class_name: 'User', optional: true + belongs_to :created_by, class_name: 'User', optional: true + belongs_to :updated_by, class_name: 'User', optional: true + belongs_to :create_article_type, class_name: 'Ticket::Article::Type', optional: true + belongs_to :create_article_sender, class_name: 'Ticket::Article::Sender', optional: true + + association_attributes_ignored :flags, :mentions + + attr_accessor :callback_loop + +=begin + +get user access conditions + + conditions = Ticket.access_condition( User.find(1) , 'full') + +returns + + result = [user1, user2, ...] + +=end + + def self.access_condition(user, access) + sql = [] + bind = [] + + if user.permissions?('ticket.agent') + sql.push('group_id IN (?)') + bind.push(user.group_ids_access(access)) + end + + if user.permissions?('ticket.customer') + if !user.organization || ( !user.organization.shared || user.organization.shared == false ) + sql.push('tickets.customer_id = ?') + bind.push(user.id) + else + sql.push('(tickets.customer_id = ? OR tickets.organization_id = ?)') + bind.push(user.id) + bind.push(user.organization.id) + end + end + + return if sql.blank? + + [ sql.join(' OR ') ].concat(bind) + end + +=begin + +processes tickets which have reached their pending time and sets next state_id + + processed_tickets = Ticket.process_pending + +returns + + processed_tickets = [, ...] + +=end + + def self.process_pending + result = [] + + # process pending action tickets + pending_action = Ticket::StateType.find_by(name: 'pending action') + ticket_states_pending_action = Ticket::State.where(state_type_id: pending_action) + .where.not(next_state_id: nil) + if ticket_states_pending_action.present? + next_state_map = {} + ticket_states_pending_action.each do |state| + next_state_map[state.id] = state.next_state_id + end + + tickets = where(state_id: next_state_map.keys) + .where('pending_time <= ?', Time.zone.now) + + tickets.find_each(batch_size: 500) do |ticket| + Transaction.execute do + ticket.state_id = next_state_map[ticket.state_id] + ticket.updated_at = Time.zone.now + ticket.updated_by_id = 1 + ticket.save! + end + result.push ticket + end + end + + # process pending reminder tickets + pending_reminder = Ticket::StateType.find_by(name: 'pending reminder') + ticket_states_pending_reminder = Ticket::State.where(state_type_id: pending_reminder) + + if ticket_states_pending_reminder.present? + reminder_state_map = {} + ticket_states_pending_reminder.each do |state| + reminder_state_map[state.id] = state.next_state_id + end + + tickets = where(state_id: reminder_state_map.keys) + .where('pending_time <= ?', Time.zone.now) + + tickets.find_each(batch_size: 500) do |ticket| + + article_id = nil + article = Ticket::Article.last_customer_agent_article(ticket.id) + if article + article_id = article.id + end + + # send notification + TransactionJob.perform_now( + object: 'Ticket', + type: 'reminder_reached', + object_id: ticket.id, + article_id: article_id, + user_id: 1, + ) + + result.push ticket + end + end + + result + end + +=begin + +processes escalated tickets + + processed_tickets = Ticket.process_escalation + +returns + + processed_tickets = [, ...] + +=end + + def self.process_escalation + result = [] + + # fetch all escalated and soon to be escalating tickets + where('escalation_at <= ?', Time.zone.now + 15.minutes).find_each(batch_size: 500) do |ticket| + + article_id = nil + article = Ticket::Article.last_customer_agent_article(ticket.id) + if article + article_id = article.id + end + + # send escalation + if ticket.escalation_at < Time.zone.now + TransactionJob.perform_now( + object: 'Ticket', + type: 'escalation', + object_id: ticket.id, + article_id: article_id, + user_id: 1, + ) + result.push ticket + next + end + + # check if warning need to be sent + TransactionJob.perform_now( + object: 'Ticket', + type: 'escalation_warning', + object_id: ticket.id, + article_id: article_id, + user_id: 1, + ) + result.push ticket + end + result + end + +=begin + +processes tickets which auto unassign time has reached + + processed_tickets = Ticket.process_auto_unassign + +returns + + processed_tickets = [, ...] + +=end + + def self.process_auto_unassign + + # process pending action tickets + state_ids = Ticket::State.by_category(:work_on).pluck(:id) + return [] if state_ids.blank? + + result = [] + groups = Group.where(active: true).where('assignment_timeout IS NOT NULL AND groups.assignment_timeout != 0') + return [] if groups.blank? + + groups.each do |group| + next if group.assignment_timeout.blank? + + ticket_ids = Ticket.where('state_id IN (?) AND owner_id != 1 AND group_id = ? AND last_owner_update_at IS NOT NULL', state_ids, group.id).limit(600).pluck(:id) + ticket_ids.each do |ticket_id| + ticket = Ticket.find_by(id: ticket_id) + next if !ticket + + minutes_since_last_assignment = Time.zone.now - ticket.last_owner_update_at + next if (minutes_since_last_assignment / 60) <= group.assignment_timeout + + Transaction.execute do + ticket.owner_id = 1 + ticket.updated_at = Time.zone.now + ticket.updated_by_id = 1 + ticket.save! + end + result.push ticket + end + end + + result + end + +=begin + +merge tickets + + ticket = Ticket.find(123) + result = ticket.merge_to( + ticket_id: 123, + user_id: 123, + ) + +returns + + result = true|false + +=end + + def merge_to(data) + + # prevent cross merging tickets + target_ticket = Ticket.find_by(id: data[:ticket_id]) + raise 'no target ticket given' if !target_ticket + raise Exceptions::UnprocessableEntity, 'ticket already merged, no merge into merged ticket possible' if target_ticket.state.state_type.name == 'merged' + + # check different ticket ids + raise Exceptions::UnprocessableEntity, 'Can\'t merge ticket with it self!' if id == target_ticket.id + + # update articles + Transaction.execute context: 'merge' do + + Ticket::Article.where(ticket_id: id).each(&:touch) + + # quiet update of reassign of articles + Ticket::Article.where(ticket_id: id).update_all(['ticket_id = ?', data[:ticket_id]]) # rubocop:disable Rails/SkipsModelValidations + + # mark target ticket as updated + # otherwise the "received_merge" history entry + # will be the same as the last updated_at + # which might be a long time ago + target_ticket.updated_at = Time.zone.now + + # add merge event to both ticket's history (Issue #2469 - Add information "Ticket merged" to History) + target_ticket.history_log( + 'received_merge', + data[:user_id], + id_to: target_ticket.id, + id_from: id, + ) + history_log( + 'merged_into', + data[:user_id], + id_to: target_ticket.id, + id_from: id, + ) + + # create new merge article + Ticket::Article.create( + ticket_id: id, + type_id: Ticket::Article::Type.lookup(name: 'note').id, + sender_id: Ticket::Article::Sender.lookup(name: 'Agent').id, + body: 'merged', + internal: false, + created_by_id: data[:user_id], + updated_by_id: data[:user_id], + ) + + # search for mention duplicates and destroy them before moving mentions + Mention.duplicates(self, target_ticket).destroy_all + Mention.where(mentionable: self).update_all(mentionable_id: target_ticket.id) # rubocop:disable Rails/SkipsModelValidations + + # reassign links to the new ticket + # rubocop:disable Rails/SkipsModelValidations + ticket_source_id = Link::Object.find_by(name: 'Ticket').id + + # search for all duplicate source and target links and destroy them + # before link merging + Link.duplicates( + object1_id: ticket_source_id, + object1_value: id, + object2_value: data[:ticket_id] + ).destroy_all + Link.where( + link_object_source_id: ticket_source_id, + link_object_source_value: id, + ).update_all(link_object_source_value: data[:ticket_id]) + Link.where( + link_object_target_id: ticket_source_id, + link_object_target_value: id, + ).update_all(link_object_target_value: data[:ticket_id]) + # rubocop:enable Rails/SkipsModelValidations + + # link tickets + Link.add( + link_type: 'parent', + link_object_source: 'Ticket', + link_object_source_value: data[:ticket_id], + link_object_target: 'Ticket', + link_object_target_value: id + ) + + # external sync references + ExternalSync.migrate('Ticket', id, target_ticket.id) + + # set state to 'merged' + self.state_id = Ticket::State.lookup(name: 'merged').id + + # rest owner + self.owner_id = 1 + + # save ticket + save! + + # touch new ticket (to broadcast change) + target_ticket.touch # rubocop:disable Rails/SkipsModelValidations + end + true + end + +=begin + +check if online notification should be shown in general as already seen with current state + + ticket = Ticket.find(1) + seen = ticket.online_notification_seen_state(user_id_check) + +returns + + result = true # or false + +=end + + def online_notification_seen_state(user_id_check = nil) + state = Ticket::State.lookup(id: state_id) + state_type = Ticket::StateType.lookup(id: state.state_type_id) + + # always to set unseen for ticket owner and users which did not the update + return false if state_type.name != 'merged' && user_id_check && user_id_check == owner_id && user_id_check != updated_by_id + + # set all to seen if pending action state is a closed or merged state + if state_type.name == 'pending action' && state.next_state_id + state = Ticket::State.lookup(id: state.next_state_id) + state_type = Ticket::StateType.lookup(id: state.state_type_id) + end + + # set all to seen if new state is pending reminder state + if state_type.name == 'pending reminder' + if user_id_check + return false if owner_id == 1 + return false if updated_by_id != owner_id && user_id_check == owner_id + + return true + end + return true + end + + # set all to seen if new state is a closed or merged state + return true if state_type.name == 'closed' + return true if state_type.name == 'merged' + + false + end + +=begin + +get count of tickets and tickets which match on selector + +@param [Hash] selectors hash with conditions +@oparam [Hash] options + +@option options [String] :access can be 'full', 'read', 'create' or 'ignore' (ignore means a selector over all tickets), defaults to 'full' +@option options [Integer] :limit of tickets to return +@option options [User] :user is a current user +@option options [Integer] :execution_time is a current user + +@return [Integer, []] + +@example + ticket_count, tickets = Ticket.selectors(params[:condition], limit: limit, current_user: current_user, access: 'full') + + ticket_count # count of found tickets + tickets # tickets + +=end + + def self.selectors(selectors, options) + limit = options[:limit] || 10 + current_user = options[:current_user] + access = options[:access] || 'full' + raise 'no selectors given' if !selectors + + query, bind_params, tables = selector2sql(selectors, current_user: current_user, execution_time: options[:execution_time]) + return [] if !query + + ActiveRecord::Base.transaction(requires_new: true) do + + if !current_user || access == 'ignore' + ticket_count = Ticket.distinct.where(query, *bind_params).joins(tables).count + tickets = Ticket.distinct.where(query, *bind_params).joins(tables).limit(limit) + return [ticket_count, tickets] + end + + access_condition = Ticket.access_condition(current_user, access) + ticket_count = Ticket.distinct.where(access_condition).where(query, *bind_params).joins(tables).count + tickets = Ticket.distinct.where(access_condition).where(query, *bind_params).joins(tables).limit(limit) + + return [ticket_count, tickets] + rescue ActiveRecord::StatementInvalid => e + Rails.logger.error e + raise ActiveRecord::Rollback + + end + [] + end + +=begin + +generate condition query to search for tickets based on condition + + query_condition, bind_condition, tables = selector2sql(params[:condition], current_user: current_user) + +condition example + + { + 'ticket.title' => { + operator: 'contains', # contains not + value: 'some value', + }, + 'ticket.state_id' => { + operator: 'is', + value: [1,2,5] + }, + 'ticket.created_at' => { + operator: 'after (absolute)', # after,before + value: '2015-10-17T06:00:00.000Z', + }, + 'ticket.created_at' => { + operator: 'within next (relative)', # within next, within last, after, before + range: 'day', # minute|hour|day|month|year + value: '25', + }, + 'ticket.owner_id' => { + operator: 'is', # is not + pre_condition: 'current_user.id', + }, + 'ticket.owner_id' => { + operator: 'is', # is not + pre_condition: 'specific', + value: 4711, + }, + 'ticket.escalation_at' => { + operator: 'is not', # not + value: nil, + }, + 'ticket.tags' => { + operator: 'contains all', # contains all|contains one|contains all not|contains one not + value: 'tag1, tag2', + }, + } + +=end + + def self.selector2sql(selectors, options = {}) + current_user = options[:current_user] + current_user_id = UserInfo.current_user_id + if current_user + current_user_id = current_user.id + end + return if !selectors + + # remember query and bind params + query = '' + bind_params = [] + like = Rails.application.config.db_like + + if selectors.respond_to?(:permit!) + selectors = selectors.permit!.to_h + end + + # get tables to join + tables = '' + selectors.each do |attribute, selector_raw| + attributes = attribute.split('.') + selector = selector_raw.stringify_keys + next if !attributes[1] + next if attributes[0] == 'execution_time' + next if tables.include?(attributes[0]) + next if attributes[0] == 'ticket' && attributes[1] != 'mention_user_ids' + next if attributes[0] == 'ticket' && attributes[1] == 'mention_user_ids' && selector['pre_condition'] == 'not_set' + + if query != '' + query += ' AND ' + end + case attributes[0] + when 'customer' + tables += ', users customers' + query += 'tickets.customer_id = customers.id' + when 'organization' + tables += ', organizations' + query += 'tickets.organization_id = organizations.id' + when 'owner' + tables += ', users owners' + query += 'tickets.owner_id = owners.id' + when 'article' + tables += ', ticket_articles articles' + query += 'tickets.id = articles.ticket_id' + when 'ticket_state' + tables += ', ticket_states' + query += 'tickets.state_id = ticket_states.id' + when 'ticket' + if attributes[1] == 'mention_user_ids' + tables += ', mentions' + query += "tickets.id = mentions.mentionable_id AND mentions.mentionable_type = 'Ticket'" + end + else + raise "invalid selector #{attribute.inspect}->#{attributes.inspect}" + end + end + + # add conditions + no_result = false + selectors.each do |attribute, selector_raw| + + # validation + raise "Invalid selector #{selector_raw.inspect}" if !selector_raw + raise "Invalid selector #{selector_raw.inspect}" if !selector_raw.respond_to?(:key?) + + selector = selector_raw.stringify_keys + raise "Invalid selector, operator missing #{selector.inspect}" if !selector['operator'] + raise "Invalid selector, operator #{selector['operator']} is invalid #{selector.inspect}" if !selector['operator'].match?(%r{^(is|is\snot|contains|contains\s(not|all|one|all\snot|one\snot)|(after|before)\s\(absolute\)|(within\snext|within\slast|after|before|till|from)\s\(relative\))|(is\sin\sworking\stime|is\snot\sin\sworking\stime)$}) + + # validate value / allow blank but only if pre_condition exists and is not specific + if !selector.key?('value') || + (selector['value'].instance_of?(Array) && selector['value'].respond_to?(:blank?) && selector['value'].blank?) || + (selector['operator'].start_with?('contains') && selector['value'].respond_to?(:blank?) && selector['value'].blank?) + return nil if selector['pre_condition'].nil? + return nil if selector['pre_condition'].respond_to?(:blank?) && selector['pre_condition'].blank? + return nil if selector['pre_condition'] == 'specific' + end + + # validate pre_condition values + return nil if selector['pre_condition'] && selector['pre_condition'] !~ %r{^(not_set|current_user\.|specific)} + + # get attributes + attributes = attribute.split('.') + attribute = "#{ActiveRecord::Base.connection.quote_table_name("#{attributes[0]}s")}.#{ActiveRecord::Base.connection.quote_column_name(attributes[1])}" + + # magic selectors + if attributes[0] == 'ticket' && attributes[1] == 'out_of_office_replacement_id' + attribute = "#{ActiveRecord::Base.connection.quote_table_name("#{attributes[0]}s")}.#{ActiveRecord::Base.connection.quote_column_name('owner_id')}" + end + + if attributes[0] == 'ticket' && attributes[1] == 'tags' + selector['value'] = selector['value'].split(',').collect(&:strip) + end + + if selector['operator'].include?('in working time') + next if attributes[1] != 'calendar_id' + raise 'Please enable execution_time feature to use it (currently only allowed for triggers and schedulers)' if !options[:execution_time] + + biz = Calendar.lookup(id: selector['value'])&.biz + next if biz.blank? + + if ( selector['operator'] == 'is in working time' && !biz.in_hours?(Time.zone.now) ) || ( selector['operator'] == 'is not in working time' && biz.in_hours?(Time.zone.now) ) + no_result = true + break + end + + # skip to next condition + next + end + + if query != '' + query += ' AND ' + end + + # because of no grouping support we select not_set by sub select for mentions + if attributes[0] == 'ticket' && attributes[1] == 'mention_user_ids' + if selector['pre_condition'] == 'not_set' + query += if selector['operator'] == 'is' + "(SELECT 1 FROM mentions mentions_sub WHERE mentions_sub.mentionable_type = 'Ticket' AND mentions_sub.mentionable_id = tickets.id) IS NULL" + else + "1 = (SELECT 1 FROM mentions mentions_sub WHERE mentions_sub.mentionable_type = 'Ticket' AND mentions_sub.mentionable_id = tickets.id)" + end + else + query += if selector['operator'] == 'is' + 'mentions.user_id IN (?)' + else + 'mentions.user_id NOT IN (?)' + end + if selector['pre_condition'] == 'current_user.id' + bind_params.push current_user_id + else + bind_params.push selector['value'] + end + end + next + end + + if selector['operator'] == 'is' + if selector['pre_condition'] == 'not_set' + if attributes[1].match?(%r{^(created_by|updated_by|owner|customer|user)_id}) + query += "(#{attribute} IS NULL OR #{attribute} IN (?))" + bind_params.push 1 + else + query += "#{attribute} IS NULL" + end + elsif selector['pre_condition'] == 'current_user.id' + raise "Use current_user.id in selector, but no current_user is set #{selector.inspect}" if !current_user_id + + query += "#{attribute} IN (?)" + if attributes[1] == 'out_of_office_replacement_id' + bind_params.push User.find(current_user_id).out_of_office_agent_of.pluck(:id) + else + bind_params.push current_user_id + end + elsif selector['pre_condition'] == 'current_user.organization_id' + raise "Use current_user.id in selector, but no current_user is set #{selector.inspect}" if !current_user_id + + query += "#{attribute} IN (?)" + user = User.find_by(id: current_user_id) + bind_params.push user.organization_id + else + # rubocop:disable Style/IfInsideElse + if selector['value'].nil? + query += "#{attribute} IS NULL" + else + if attributes[1] == 'out_of_office_replacement_id' + query += "#{attribute} IN (?)" + bind_params.push User.find(selector['value']).out_of_office_agent_of.pluck(:id) + else + if selector['value'].class != Array + selector['value'] = [selector['value']] + end + query += if selector['value'].include?('') + "(#{attribute} IN (?) OR #{attribute} IS NULL)" + else + "#{attribute} IN (?)" + end + bind_params.push selector['value'] + end + end + # rubocop:enable Style/IfInsideElse + end + elsif selector['operator'] == 'is not' + if selector['pre_condition'] == 'not_set' + if attributes[1].match?(%r{^(created_by|updated_by|owner|customer|user)_id}) + query += "(#{attribute} IS NOT NULL AND #{attribute} NOT IN (?))" + bind_params.push 1 + else + query += "#{attribute} IS NOT NULL" + end + elsif selector['pre_condition'] == 'current_user.id' + query += "(#{attribute} IS NULL OR #{attribute} NOT IN (?))" + if attributes[1] == 'out_of_office_replacement_id' + bind_params.push User.find(current_user_id).out_of_office_agent_of.pluck(:id) + else + bind_params.push current_user_id + end + elsif selector['pre_condition'] == 'current_user.organization_id' + query += "(#{attribute} IS NULL OR #{attribute} NOT IN (?))" + user = User.find_by(id: current_user_id) + bind_params.push user.organization_id + else + # rubocop:disable Style/IfInsideElse + if selector['value'].nil? + query += "#{attribute} IS NOT NULL" + else + if attributes[1] == 'out_of_office_replacement_id' + bind_params.push User.find(selector['value']).out_of_office_agent_of.pluck(:id) + query += "(#{attribute} IS NULL OR #{attribute} NOT IN (?))" + else + if selector['value'].class != Array + selector['value'] = [selector['value']] + end + query += if selector['value'].include?('') + "(#{attribute} IS NOT NULL AND #{attribute} NOT IN (?))" + else + "(#{attribute} IS NULL OR #{attribute} NOT IN (?))" + end + bind_params.push selector['value'] + end + end + # rubocop:enable Style/IfInsideElse + end + elsif selector['operator'] == 'contains' + query += "#{attribute} #{like} (?)" + value = "%#{selector['value']}%" + bind_params.push value + elsif selector['operator'] == 'contains not' + query += "#{attribute} NOT #{like} (?)" + value = "%#{selector['value']}%" + bind_params.push value + elsif selector['operator'] == 'contains all' && attributes[0] == 'ticket' && attributes[1] == 'tags' + query += "? = ( + SELECT + COUNT(*) + FROM + tag_objects, + tag_items, + tags + WHERE + tickets.id = tags.o_id AND + tag_objects.id = tags.tag_object_id AND + tag_objects.name = 'Ticket' AND + tag_items.id = tags.tag_item_id AND + tag_items.name IN (?) + )" + bind_params.push selector['value'].count + bind_params.push selector['value'] + elsif selector['operator'] == 'contains one' && attributes[0] == 'ticket' && attributes[1] == 'tags' + tables += ', tag_objects, tag_items, tags' + query += " + tickets.id = tags.o_id AND + tag_objects.id = tags.tag_object_id AND + tag_objects.name = 'Ticket' AND + tag_items.id = tags.tag_item_id AND + tag_items.name IN (?)" + + bind_params.push selector['value'] + elsif selector['operator'] == 'contains all not' && attributes[0] == 'ticket' && attributes[1] == 'tags' + query += "0 = ( + SELECT + COUNT(*) + FROM + tag_objects, + tag_items, + tags + WHERE + tickets.id = tags.o_id AND + tag_objects.id = tags.tag_object_id AND + tag_objects.name = 'Ticket' AND + tag_items.id = tags.tag_item_id AND + tag_items.name IN (?) + )" + bind_params.push selector['value'] + elsif selector['operator'] == 'contains one not' && attributes[0] == 'ticket' && attributes[1] == 'tags' + query += "( + SELECT + COUNT(*) + FROM + tag_objects, + tag_items, + tags + WHERE + tickets.id = tags.o_id AND + tag_objects.id = tags.tag_object_id AND + tag_objects.name = 'Ticket' AND + tag_items.id = tags.tag_item_id AND + tag_items.name IN (?) + ) BETWEEN 0 AND 0" + bind_params.push selector['value'] + elsif selector['operator'] == 'before (absolute)' + query += "#{attribute} <= ?" + bind_params.push selector['value'] + elsif selector['operator'] == 'after (absolute)' + query += "#{attribute} >= ?" + bind_params.push selector['value'] + elsif selector['operator'] == 'within last (relative)' + query += "#{attribute} BETWEEN ? AND ?" + time = nil + case selector['range'] + when 'minute' + time = selector['value'].to_i.minutes.ago + when 'hour' + time = selector['value'].to_i.hours.ago + when 'day' + time = selector['value'].to_i.days.ago + when 'month' + time = selector['value'].to_i.months.ago + when 'year' + time = selector['value'].to_i.years.ago + else + raise "Unknown selector attributes '#{selector.inspect}'" + end + bind_params.push time + bind_params.push Time.zone.now + elsif selector['operator'] == 'within next (relative)' + query += "#{attribute} BETWEEN ? AND ?" + time = nil + case selector['range'] + when 'minute' + time = selector['value'].to_i.minutes.from_now + when 'hour' + time = selector['value'].to_i.hours.from_now + when 'day' + time = selector['value'].to_i.days.from_now + when 'month' + time = selector['value'].to_i.months.from_now + when 'year' + time = selector['value'].to_i.years.from_now + else + raise "Unknown selector attributes '#{selector.inspect}'" + end + bind_params.push Time.zone.now + bind_params.push time + elsif selector['operator'] == 'before (relative)' + query += "#{attribute} <= ?" + time = nil + case selector['range'] + when 'minute' + time = selector['value'].to_i.minutes.ago + when 'hour' + time = selector['value'].to_i.hours.ago + when 'day' + time = selector['value'].to_i.days.ago + when 'month' + time = selector['value'].to_i.months.ago + when 'year' + time = selector['value'].to_i.years.ago + else + raise "Unknown selector attributes '#{selector.inspect}'" + end + bind_params.push time + elsif selector['operator'] == 'after (relative)' + query += "#{attribute} >= ?" + time = nil + case selector['range'] + when 'minute' + time = selector['value'].to_i.minutes.from_now + when 'hour' + time = selector['value'].to_i.hours.from_now + when 'day' + time = selector['value'].to_i.days.from_now + when 'month' + time = selector['value'].to_i.months.from_now + when 'year' + time = selector['value'].to_i.years.from_now + else + raise "Unknown selector attributes '#{selector.inspect}'" + end + bind_params.push time + elsif selector['operator'] == 'till (relative)' + query += "#{attribute} <= ?" + time = nil + case selector['range'] + when 'minute' + time = selector['value'].to_i.minutes.from_now + when 'hour' + time = selector['value'].to_i.hours.from_now + when 'day' + time = selector['value'].to_i.days.from_now + when 'month' + time = selector['value'].to_i.months.from_now + when 'year' + time = selector['value'].to_i.years.from_now + else + raise "Unknown selector attributes '#{selector.inspect}'" + end + bind_params.push time + elsif selector['operator'] == 'from (relative)' + query += "#{attribute} >= ?" + time = nil + case selector['range'] + when 'minute' + time = selector['value'].to_i.minutes.ago + when 'hour' + time = selector['value'].to_i.hours.ago + when 'day' + time = selector['value'].to_i.days.ago + when 'month' + time = selector['value'].to_i.months.ago + when 'year' + time = selector['value'].to_i.years.ago + else + raise "Unknown selector attributes '#{selector.inspect}'" + end + bind_params.push time + else + raise "Invalid operator '#{selector['operator']}' for '#{selector['value'].inspect}'" + end + end + + return if no_result + + [query, bind_params, tables] + end + +=begin + +perform changes on ticket + + ticket.perform_changes(trigger, 'trigger', item, current_user_id) + + # or + + ticket.perform_changes(job, 'job', item, current_user_id) + +=end + + def perform_changes(performable, perform_origin, item = nil, current_user_id = nil) + + perform = performable.perform + logger.debug { "Perform #{perform_origin} #{perform.inspect} on Ticket.find(#{id})" } + + article = begin + Ticket::Article.find_by(id: item.try(:dig, :article_id)) + rescue ArgumentError + nil + end + + # if the configuration contains the deletion of the ticket then + # we skip all other ticket changes because they does not matter + if perform['ticket.action'].present? && perform['ticket.action']['value'] == 'delete' + perform.each_key do |key| + (object_name, attribute) = key.split('.', 2) + next if object_name != 'ticket' + next if attribute == 'action' + + perform.delete(key) + end + end + + perform_notification = {} + perform_article = {} + changed = false + perform.each do |key, value| + (object_name, attribute) = key.split('.', 2) + raise "Unable to update object #{object_name}.#{attribute}, only can update tickets, send notifications and create articles!" if object_name != 'ticket' && object_name != 'article' && object_name != 'notification' + + # send notification/create article (after changes are done) + if object_name == 'article' + perform_article[key] = value + next + end + if object_name == 'notification' + perform_notification[key] = value + next + end + + # Apply pending_time changes + if key == 'ticket.pending_time' + new_value = case value['operator'] + when 'static' + value['value'] + when 'relative' + pendtil = Time.zone.now + val = value['value'].to_i + + case value['range'] + when 'day' + pendtil += val.days + when 'minute' + pendtil += val.minutes + when 'hour' + pendtil += val.hours + when 'month' + pendtil += val.months + when 'year' + pendtil += val.years + end + + pendtil + end + + if new_value + self[attribute] = new_value + changed = true + next + end + end + + # update tags + if key == 'ticket.tags' + next if value['value'].blank? + + tags = value['value'].split(',') + case value['operator'] + when 'add' + tags.each do |tag| + tag_add(tag, current_user_id || 1) + end + when 'remove' + tags.each do |tag| + tag_remove(tag, current_user_id || 1) + end + else + logger.error "Unknown #{attribute} operator #{value['operator']}" + end + next + end + + # delete ticket + if key == 'ticket.action' + next if value['value'].blank? + next if value['value'] != 'delete' + + logger.info { "Deleted ticket from #{perform_origin} #{perform.inspect} Ticket.find(#{id})" } + destroy! + next + end + + # lookup pre_condition + if value['pre_condition'] + if value['pre_condition'].start_with?('not_set') + value['value'] = 1 + elsif value['pre_condition'].start_with?('current_user.') + raise 'Unable to use current_user, got no current_user_id for ticket.perform_changes' if !current_user_id + + value['value'] = current_user_id + end + end + + # update ticket + next if self[attribute].to_s == value['value'].to_s + + changed = true + + self[attribute] = value['value'] + logger.debug { "set #{object_name}.#{attribute} = #{value['value'].inspect} for ticket_id #{id}" } + end + + if changed + save! + end + + objects = build_notification_template_objects(article) + + perform_article.each do |key, value| + raise 'Unable to create article, we only support article.note' if key != 'article.note' + + add_trigger_note(id, value, objects, perform_origin) + end + + perform_notification.each do |key, value| + + # send notification + case key + when 'notification.sms' + send_sms_notification(value, article, perform_origin) + next + when 'notification.email' + send_email_notification(value, article, perform_origin) + when 'notification.webhook' + TriggerWebhookJob.perform_later(performable, self, article) + end + end + + true + end + +=begin + +perform changes on ticket + + ticket.add_trigger_note(ticket_id, note, objects, perform_origin) + +=end + + def add_trigger_note(ticket_id, note, objects, perform_origin) + rendered_subject = NotificationFactory::Mailer.template( + templateInline: note[:subject], + objects: objects, + quote: true, + ) + + rendered_body = NotificationFactory::Mailer.template( + templateInline: note[:body], + objects: objects, + quote: true, + ) + + Ticket::Article.create!( + ticket_id: ticket_id, + subject: rendered_subject, + content_type: 'text/html', + body: rendered_body, + internal: note[:internal], + sender: Ticket::Article::Sender.find_by(name: 'System'), + type: Ticket::Article::Type.find_by(name: 'note'), + preferences: { + perform_origin: perform_origin, + notification: true, + }, + updated_by_id: 1, + created_by_id: 1, + ) + end + +=begin + +perform active triggers on ticket + + Ticket.perform_triggers(ticket, article, item, options) + +=end + + def self.perform_triggers(ticket, article, item, options = {}) + recursive = Setting.get('ticket_trigger_recursive') + type = options[:type] || item[:type] + local_options = options.clone + local_options[:type] = type + local_options[:reset_user_id] = true + local_options[:disable] = ['Transaction::Notification'] + local_options[:trigger_ids] ||= {} + local_options[:trigger_ids][ticket.id.to_s] ||= [] + local_options[:loop_count] ||= 0 + local_options[:loop_count] += 1 + + ticket_trigger_recursive_max_loop = Setting.get('ticket_trigger_recursive_max_loop')&.to_i || 10 + if local_options[:loop_count] > ticket_trigger_recursive_max_loop + message = "Stopped perform_triggers for this object (Ticket/#{ticket.id}), because loop count was #{local_options[:loop_count]}!" + logger.info { message } + return [false, message] + end + + triggers = if Rails.configuration.db_case_sensitive + ::Trigger.where(active: true).order(Arel.sql('LOWER(name)')) + else + ::Trigger.where(active: true).order(:name) + end + return [true, 'No triggers active'] if triggers.blank? + + # check if notification should be send because of customer emails + send_notification = true + if local_options[:send_notification] == false + send_notification = false + elsif item[:article_id] + article = Ticket::Article.lookup(id: item[:article_id]) + if article&.preferences && article.preferences['send-auto-response'] == false + send_notification = false + end + end + + Transaction.execute(local_options) do + triggers.each do |trigger| + logger.debug { "Probe trigger (#{trigger.name}/#{trigger.id}) for this object (Ticket:#{ticket.id}/Loop:#{local_options[:loop_count]})" } + + condition = trigger.condition + + # check if one article attribute is used + one_has_changed_done = false + article_selector = false + trigger.condition.each_key do |key| + (object_name, attribute) = key.split('.', 2) + next if object_name != 'article' + next if attribute == 'id' + + article_selector = true + end + if article && article_selector + one_has_changed_done = true + end + if article && type == 'update' + one_has_changed_done = true + end + + # check ticket "has changed" options + has_changed_done = true + condition.each do |key, value| + next if value.blank? + next if value['operator'].blank? + next if !value['operator']['has changed'] + + # remove condition item, because it has changed + (object_name, attribute) = key.split('.', 2) + next if object_name != 'ticket' + next if item[:changes].blank? + next if !item[:changes].key?(attribute) + + condition.delete(key) + one_has_changed_done = true + end + + # check if we have not matching "has changed" attributes + condition.each_value do |value| + next if value.blank? + next if value['operator'].blank? + next if !value['operator']['has changed'] + + has_changed_done = false + break + end + + # check ticket action + if condition['ticket.action'] + next if condition['ticket.action']['operator'] == 'is' && condition['ticket.action']['value'] != type + next if condition['ticket.action']['operator'] != 'is' && condition['ticket.action']['value'] == type + + condition.delete('ticket.action') + end + next if !has_changed_done + + # check in min one attribute of condition has changed on update + one_has_changed_condition = false + if type == 'update' + + # verify if ticket condition exists + condition.each_key do |key| + (object_name, attribute) = key.split('.', 2) + next if object_name != 'ticket' + + one_has_changed_condition = true + next if item[:changes].blank? + next if !item[:changes].key?(attribute) + + one_has_changed_done = true + break + end + next if one_has_changed_condition && !one_has_changed_done + end + + # check if ticket selector is matching + condition['ticket.id'] = { + operator: 'is', + value: ticket.id, + } + next if article_selector && !article + + # check if article selector is matching + if article_selector + condition['article.id'] = { + operator: 'is', + value: article.id, + } + end + + user_id = ticket.updated_by_id + if article + user_id = article.updated_by_id + end + + user = User.lookup(id: user_id) + + # verify is condition is matching + ticket_count, tickets = Ticket.selectors(condition, limit: 1, execution_time: true, current_user: user, access: 'ignore') + + next if ticket_count.blank? + next if ticket_count.zero? + next if tickets.first.id != ticket.id + + if recursive == false && local_options[:loop_count] > 1 + message = "Do not execute recursive triggers per default until Zammad 3.0. With Zammad 3.0 and higher the following trigger is executed '#{trigger.name}' on Ticket:#{ticket.id}. Please review your current triggers and change them if needed." + logger.info { message } + return [true, message] + end + + if article && send_notification == false && trigger.perform['notification.email'] && trigger.perform['notification.email']['recipient'] + recipient = trigger.perform['notification.email']['recipient'] + local_options[:send_notification] = false + if recipient.include?('ticket_customer') || recipient.include?('article_last_sender') + logger.info { "Skip trigger (#{trigger.name}/#{trigger.id}) because sender do not want to get auto responder for object (Ticket/#{ticket.id}/Article/#{article.id})" } + next + end + end + + if local_options[:trigger_ids][ticket.id.to_s].include?(trigger.id) + logger.info { "Skip trigger (#{trigger.name}/#{trigger.id}) because was already executed for this object (Ticket:#{ticket.id}/Loop:#{local_options[:loop_count]})" } + next + end + local_options[:trigger_ids][ticket.id.to_s].push trigger.id + logger.info { "Execute trigger (#{trigger.name}/#{trigger.id}) for this object (Ticket:#{ticket.id}/Loop:#{local_options[:loop_count]})" } + + ticket.perform_changes(trigger, 'trigger', item, user_id) + + if recursive == true + TransactionDispatcher.commit(local_options) + end + end + end + [true, ticket, local_options] + end + +=begin + +get all email references headers of a ticket, to exclude some, parse it as array into method + + references = ticket.get_references + +result + + ['message-id-1234', 'message-id-5678'] + +ignore references header(s) + + references = ticket.get_references(['message-id-5678']) + +result + + ['message-id-1234'] + +=end + + def get_references(ignore = []) + references = [] + Ticket::Article.select('in_reply_to, message_id').where(ticket_id: id).each do |article| + if article.in_reply_to.present? + references.push article.in_reply_to + end + next if article.message_id.blank? + + references.push article.message_id + end + ignore.each do |item| + references.delete(item) + end + references + end + +=begin + +get all articles of a ticket in correct order (overwrite active record default method) + + articles = ticket.articles + +result + + [article1, article2] + +=end + + def articles + Ticket::Article.where(ticket_id: id).order(:created_at, :id) + end + + # Get whichever #last_contact_* was later + # This is not identical to #last_contact_at + # It returns time to last original (versus follow up) contact + # @return [Time, nil] + def last_original_update_at + [last_contact_agent_at, last_contact_customer_at].compact.max + end + + # true if conversation did happen and agent responded + # false if customer is waiting for response or agent reached out and customer did not respond yet + # @return [Bool] + def agent_responded? + return false if last_contact_customer_at.blank? + return false if last_contact_agent_at.blank? + + last_contact_customer_at < last_contact_agent_at + end + + private + + def check_generate + return true if number + + self.number = Ticket::Number.generate + true + end + + def check_title + return true if !title + + title.gsub!(%r{\s|\t|\r}, ' ') + true + end + + def check_defaults + if !owner_id + self.owner_id = 1 + end + return true if !customer_id + + customer = User.find_by(id: customer_id) + return true if !customer + return true if organization_id == customer.organization_id + + self.organization_id = customer.organization_id + true + end + + def reset_pending_time + + # ignore if no state has changed + return true if !changes_to_save['state_id'] + + # ignore if new state is blank and + # let handle ActiveRecord the error + return if state_id.blank? + + # check if new state isn't pending* + current_state = Ticket::State.lookup(id: state_id) + current_state_type = Ticket::StateType.lookup(id: current_state.state_type_id) + + # in case, set pending_time to nil + return true if current_state_type.name.match?(%r{^pending}i) + + self.pending_time = nil + true + end + + def set_default_state + return true if state_id + + default_ticket_state = Ticket::State.find_by(default_create: true) + return true if !default_ticket_state + + self.state_id = default_ticket_state.id + true + end + + def set_default_priority + return true if priority_id + + default_ticket_priority = Ticket::Priority.find_by(default_create: true) + return true if !default_ticket_priority + + self.priority_id = default_ticket_priority.id + true + end + + def check_owner_active + return true if Setting.get('import_mode') + + # only change the owner for non closed Tickets for historical/reporting reasons + return true if state.present? && Ticket::StateType.lookup(id: state.state_type_id)&.name == 'closed' + + # return when ticket is unassigned + return true if owner_id.blank? + return true if owner_id == 1 + + # return if owner is active, is agent and has access to group of ticket + return true if owner.active? && owner.permissions?('ticket.agent') && owner.group_access?(group_id, 'full') + + # else set the owner of the ticket to the default user as unassigned + self.owner_id = 1 + true + end + + # articles.last breaks (returns the wrong article) + # if another email notification trigger preceded this one + # (see https://github.com/zammad/zammad/issues/1543) + def build_notification_template_objects(article) + { + ticket: self, + article: article || articles.last + } + end + + def send_email_notification(value, article, perform_origin) + # value['recipient'] was a string in the past (single-select) so we convert it to array if needed + value_recipient = Array(value['recipient']) + + recipients_raw = [] + value_recipient.each do |recipient| + case recipient + when 'article_last_sender' + if article.present? + if article.reply_to.present? + recipients_raw.push(article.reply_to) + elsif article.from.present? + recipients_raw.push(article.from) + elsif article.origin_by_id + email = User.find_by(id: article.origin_by_id).email + recipients_raw.push(email) + elsif article.created_by_id + email = User.find_by(id: article.created_by_id).email + recipients_raw.push(email) + end + end + when 'ticket_customer' + email = User.find_by(id: customer_id).email + recipients_raw.push(email) + when 'ticket_owner' + email = User.find_by(id: owner_id).email + recipients_raw.push(email) + when 'ticket_agents' + User.group_access(group_id, 'full').sort_by(&:login).each do |user| + recipients_raw.push(user.email) + end + when %r{\Auserid_(\d+)\z} + user = User.lookup(id: $1) + if !user + logger.warn "Can't find configured Trigger Email recipient User with ID '#{$1}'" + next + end + recipients_raw.push(user.email) + else + logger.error "Unknown email notification recipient '#{recipient}'" + next + end + end + + recipients_checked = [] + recipients_raw.each do |recipient_email| + + users = User.where(email: recipient_email) + next if users.any? { |user| !trigger_based_notification?(user) } + + # send notifications only to email addresses + next if recipient_email.blank? + + # check if address is valid + begin + Mail::AddressList.new(recipient_email).addresses.each do |address| + recipient_email = address.address + email_address_validation = EmailAddressValidation.new(recipient_email) + break if recipient_email.present? && email_address_validation.valid_format? + end + rescue + if recipient_email.present? + if recipient_email !~ %r{^(.+?)<(.+?)@(.+?)>$} + next # no usable format found + end + + recipient_email = "#{$2}@#{$3}" # rubocop:disable Lint/OutOfRangeRegexpRef + end + end + + email_address_validation = EmailAddressValidation.new(recipient_email) + next if !email_address_validation.valid_format? + + # do not send notification if system address + next if EmailAddress.exists?(email: recipient_email.downcase) + + # do not sent notifications to this recipients + send_no_auto_response_reg_exp = Setting.get('send_no_auto_response_reg_exp') + begin + next if recipient_email.match?(%r{#{send_no_auto_response_reg_exp}}i) + rescue => e + logger.error "Invalid regex '#{send_no_auto_response_reg_exp}' in setting send_no_auto_response_reg_exp" + logger.error e + next if recipient_email.match?(%r{(mailer-daemon|postmaster|abuse|root|noreply|noreply.+?|no-reply|no-reply.+?)@.+?}i) + end + + # check if notification should be send because of customer emails + if article.present? && article.preferences.fetch('is-auto-response', false) == true && article.from && article.from =~ %r{#{Regexp.quote(recipient_email)}}i + logger.info "Send no trigger based notification to #{recipient_email} because of auto response tagged incoming email" + next + end + + # loop protection / check if maximal count of trigger mail has reached + map = { + 10 => 10, + 30 => 15, + 60 => 25, + 180 => 50, + 600 => 100, + } + skip = false + map.each do |minutes, count| + already_sent = Ticket::Article.where( + ticket_id: id, + sender: Ticket::Article::Sender.find_by(name: 'System'), + type: Ticket::Article::Type.find_by(name: 'email'), + ).where('ticket_articles.created_at > ? AND ticket_articles.to LIKE ?', Time.zone.now - minutes.minutes, "%#{recipient_email.strip}%").count + next if already_sent < count + + logger.info "Send no trigger based notification to #{recipient_email} because already sent #{count} for this ticket within last #{minutes} minutes (loop protection)" + skip = true + break + end + next if skip + + map = { + 10 => 30, + 30 => 60, + 60 => 120, + 180 => 240, + 600 => 360, + } + skip = false + map.each do |minutes, count| + already_sent = Ticket::Article.where( + sender: Ticket::Article::Sender.find_by(name: 'System'), + type: Ticket::Article::Type.find_by(name: 'email'), + ).where('ticket_articles.created_at > ? AND ticket_articles.to LIKE ?', Time.zone.now - minutes.minutes, "%#{recipient_email.strip}%").count + next if already_sent < count + + logger.info "Send no trigger based notification to #{recipient_email} because already sent #{count} in total within last #{minutes} minutes (loop protection)" + skip = true + break + end + next if skip + + email = recipient_email.downcase.strip + next if recipients_checked.include?(email) + + recipients_checked.push(email) + end + + return if recipients_checked.blank? + + recipient_string = recipients_checked.join(', ') + + group_id = self.group_id + return if !group_id + + email_address = Group.find(group_id).email_address + if !email_address + logger.info "Unable to send trigger based notification to #{recipient_string} because no email address is set for group '#{group.name}'" + return + end + + if !email_address.channel_id + logger.info "Unable to send trigger based notification to #{recipient_string} because no channel is set for email address '#{email_address.email}' (id: #{email_address.id})" + return + end + + security = nil + security_type = nil + if Setting.get('smime_integration') + security_type = 'S/MIME' + elsif Setting.get('pgp_integration') + security_type = 'PGP' + end + if security_type + sign = value['sign'].present? && value['sign'] != 'no' + encryption = value['encryption'].present? && value['encryption'] != 'no' + security = { + type: security_type, + sign: { + success: false, + }, + encryption: { + success: false, + }, + } + + if sign + sign_found = false + cert = nil + if security_type == 'PGP' + cert = PGPKeypair.for_sender_email_address(from) + else + cert = SMIMECertificate.for_sender_email_address(from) + end + + begin + list = Mail::AddressList.new(email_address.email) + from = list.addresses.first.to_s + if cert && !cert.expired? + sign_found = true + security[:sign][:success] = true + security[:sign][:comment] = "certificate for #{email_address.email} found" + end + rescue # rubocop:disable Lint/SuppressedException + end + + if value['sign'] == 'discard' && !sign_found + logger.info "Unable to send trigger based notification to #{recipient_string} because of missing group #{group.name} email #{email_address.email} certificate for signing (discarding notification)." + return + end + end + + if encryption + certs_found = false + begin + if security_type == 'PGP' + PGPKeypair.for_recipient_email_addresses!(recipients_checked) + else + SMIMECertificate.for_recipipent_email_addresses!(recipients_checked) + end + certs_found = true + security[:encryption][:success] = true + security[:encryption][:comment] = "certificates found for #{recipient_string}" + rescue # rubocop:disable Lint/SuppressedException + end + + if value['encryption'] == 'discard' && !certs_found + logger.info "Unable to send trigger based notification to #{recipient_string} because public certificate is not available for encryption (discarding notification)." + return + end + end + end + + objects = build_notification_template_objects(article) + + # get subject + subject = NotificationFactory::Mailer.template( + templateInline: value['subject'], + objects: objects, + quote: false, + ) + subject = subject_build(subject) + + body = NotificationFactory::Mailer.template( + templateInline: value['body'], + objects: objects, + quote: true, + ) + + (body, attachments_inline) = HtmlSanitizer.replace_inline_images(body, id) + + preferences = {} + preferences[:perform_origin] = perform_origin + if security.present? + preferences[:security] = security + end + + message = Ticket::Article.create( + ticket_id: id, + to: recipient_string, + subject: subject, + content_type: 'text/html', + body: body, + internal: value['internal'] || false, # default to public if value was not set + sender: Ticket::Article::Sender.find_by(name: 'System'), + type: Ticket::Article::Type.find_by(name: 'email'), + preferences: preferences, + updated_by_id: 1, + created_by_id: 1, + ) + + attachments_inline.each do |attachment| + Store.add( + object: 'Ticket::Article', + o_id: message.id, + data: attachment[:data], + filename: attachment[:filename], + preferences: attachment[:preferences], + ) + end + + original_article = objects[:article] + if original_article&.should_clone_inline_attachments? # rubocop:disable Style/GuardClause + original_article.clone_attachments('Ticket::Article', message.id, only_inline_attachments: true) + original_article.should_clone_inline_attachments = false # cancel the temporary flag after cloning + end + end + + def sms_recipients_by_type(recipient_type, article) + case recipient_type + when 'article_last_sender' + return nil if article.blank? + + if article.origin_by_id + article.origin_by_id + elsif article.created_by_id + article.created_by_id + end + when 'ticket_customer' + customer_id + when 'ticket_owner' + owner_id + when 'ticket_agents' + User.group_access(group_id, 'full').sort_by(&:login) + when %r{\Auserid_(\d+)\z} + return $1 if User.exists?($1) + + logger.warn "Can't find configured Trigger SMS recipient User with ID '#{$1}'" + nil + else + logger.error "Unknown sms notification recipient '#{recipient}'" + nil + end + end + + def build_sms_recipients_list(value, article) + Array(value['recipient']) + .each_with_object([]) { |recipient_type, sum| sum.concat(Array(sms_recipients_by_type(recipient_type, article))) } + .map { |user_or_id| user_or_id.is_a?(User) ? user_or_id : User.lookup(id: user_or_id) } + .uniq(&:id) + .select { |user| user.mobile.present? } + end + + def send_sms_notification(value, article, perform_origin) + sms_recipients = build_sms_recipients_list(value, article) + + if sms_recipients.blank? + logger.debug "No SMS recipients found for Ticket# #{number}" + return + end + + sms_recipients_to = sms_recipients + .map { |recipient| "#{recipient.fullname} (#{recipient.mobile})" } + .join(', ') + + channel = Channel.find_by(area: 'Sms::Notification') + if !channel.active? + # write info message since we have an active trigger + logger.info "Found possible SMS recipient(s) (#{sms_recipients_to}) for Ticket# #{number} but SMS channel is not active." + return + end + + objects = build_notification_template_objects(article) + body = NotificationFactory::Renderer.new( + objects: objects, + template: value['body'], + escape: false + ).render.html2text.tr(' ', ' ') # convert non-breaking space to simple space + + # attributes content_type is not needed for SMS + Ticket::Article.create( + ticket_id: id, + subject: 'SMS notification', + to: sms_recipients_to, + body: body, + internal: value['internal'] || false, # default to public if value was not set + sender: Ticket::Article::Sender.find_by(name: 'System'), + type: Ticket::Article::Type.find_by(name: 'sms'), + preferences: { + perform_origin: perform_origin, + sms_recipients: sms_recipients.map(&:mobile), + channel_id: channel.id, + }, + updated_by_id: 1, + created_by_id: 1, + ) + end + + def trigger_based_notification?(user) + blocked_in_days = trigger_based_notification_blocked_in_days(user) + return true if blocked_in_days.zero? + + logger.info "Send no trigger based notification to #{user.email} because email is marked as mail_delivery_failed for #{blocked_in_days} day(s)" + false + end + + def trigger_based_notification_blocked_in_days(user) + return 0 if !user.preferences[:mail_delivery_failed] + return 0 if user.preferences[:mail_delivery_failed_data].blank? + + # blocked for 60 full days + (user.preferences[:mail_delivery_failed_data].to_date - Time.zone.now.to_date).to_i + 61 + end +end diff --git a/zammad-addon-pgp/src/app/policies/controllers/integration/pgp_controller_policy.rb b/zammad-addon-pgp/src/app/policies/controllers/integration/pgp_controller_policy.rb new file mode 100644 index 0000000..3ba7b0f --- /dev/null +++ b/zammad-addon-pgp/src/app/policies/controllers/integration/pgp_controller_policy.rb @@ -0,0 +1,6 @@ +# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ + +class Controllers::Integration::PGPControllerPolicy < Controllers::ApplicationControllerPolicy + permit! :search, to: 'ticket.agent' + default_permit!('admin.integration.pgp') +end diff --git a/zammad-addon-pgp/src/config/initializers/inflections.rb b/zammad-addon-pgp/src/config/initializers/inflections.rb new file mode 100644 index 0000000..35e51b8 --- /dev/null +++ b/zammad-addon-pgp/src/config/initializers/inflections.rb @@ -0,0 +1,29 @@ +# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ + +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, '\1en' +# inflect.singular /^(ox)en/i, '\1' +# inflect.irregular 'person', 'people' +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym 'RESTful' +# end + +ActiveSupport::Inflector.inflections(:en) do |inflect| + + # Rails thinks the singularized version of knowledge_bases is knowledge_basis?! + # see: KnowledgeBase.table_name.singularize + inflect.irregular 'base', 'bases' + inflect.acronym 'SMIME' + inflect.acronym 'PGP' + inflect.acronym 'GitLab' + inflect.acronym 'GitHub' +end diff --git a/zammad-addon-pgp/src/config/initializers/pgp_support.rb b/zammad-addon-pgp/src/config/initializers/pgp_support.rb new file mode 100644 index 0000000..bf99b35 --- /dev/null +++ b/zammad-addon-pgp/src/config/initializers/pgp_support.rb @@ -0,0 +1,18 @@ +require 'ruby_openpgp' + +Rails.application.config.before_configuration do + #FIXME need icon + icon = File.read("public/assets/images/icons/pgp.svg") + doc = File.open("public/assets/images/icons.svg") { |f| Nokogiri::XML(f) } + if !doc.at_css('#icon-pgp') + doc.at('svg').add_child(icon) + Rails.logger.debug "PGP support icon added to icon set" + else + Rails.logger.debug "PGP support icon already in icon set" + end + File.write("public/assets/images/icons.svg", doc.to_xml) +end + +# Rails.application.config.after_initialize do +# Ticket::Article.add_observer Observer::Ticket::Article::CommunicatePgpSupport.instance +# end diff --git a/zammad-addon-pgp/src/config/routes/integration_pgp.rb b/zammad-addon-pgp/src/config/routes/integration_pgp.rb new file mode 100644 index 0000000..0c9aee4 --- /dev/null +++ b/zammad-addon-pgp/src/config/routes/integration_pgp.rb @@ -0,0 +1,14 @@ +# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ + +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match api_path + '/integration/pgp', to: 'integration/pgp#search', via: :post + match api_path + '/integration/pgp/public_key', to: 'integration/pgp#public_key_add', via: :post + match api_path + '/integration/pgp/public_key', to: 'integration/pgp#public_key_delete', via: :delete + match api_path + '/integration/pgp/public_key', to: 'integration/pgp#public_key_list', via: :get + match api_path + '/integration/pgp/private_key', to: 'integration/pgp#private_key_add', via: :post + match api_path + '/integration/pgp/private_key', to: 'integration/pgp#private_key_delete', via: :delete + match api_path + '/integration/pgp/public_key_download/:id', to: 'integration/pgp#public_key_download', via: :get + match api_path + '/integration/pgp/private_key_download/:id', to: 'integration/pgp#private_key_download', via: :get +end diff --git a/zammad-addon-pgp/src/db/addon/pgpsupport/20220403000001_pgpsupport.rb b/zammad-addon-pgp/src/db/addon/pgpsupport/20220403000001_pgpsupport.rb new file mode 100644 index 0000000..e142ddb --- /dev/null +++ b/zammad-addon-pgp/src/db/addon/pgpsupport/20220403000001_pgpsupport.rb @@ -0,0 +1,65 @@ +class PGPSupport < ActiveRecord::Migration[5.2] + def up + # return if it's a new setup + # return unless Setting.exists?(name: 'system_init_done') + + Setting.create_if_not_exists( + title: 'PGP integration', + name: 'pgp_integration', + area: 'Integration::Switch', + description: 'Defines if PGP encryption is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'pgp_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no' + } + } + ] + }, + state: false, + preferences: { + prio: 1, + authentication: true, + permission: ['admin.integration'] + }, + frontend: true + ) + Setting.create_if_not_exists( + title: 'PGP config', + name: 'pgp_config', + area: 'Integration::PGP', + description: 'Defines the PGP config.', + options: {}, + state: {}, + preferences: { + prio: 2, + permission: ['admin.integration'] + }, + frontend: true + ) + Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0016_postmaster_filter_smime', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to handle secure mailing.', + options: {}, + state: 'Channel::Filter::SecureMailing', + frontend: false + ) + + create_table :pgp_keypairs do |t| + t.string :fingerprint, limit: 250, null: false + t.binary :public_key, limit: 10.megabytes, null: false + t.binary :private_key, limit: 10.megabytes, null: true + t.string :private_key_secret, limit: 500, null: true + t.timestamps limit: 3, null: false + end + add_index :pgp_keypairs, [:fingerprint], unique: true + end +end diff --git a/zammad-addon-pgp/src/db/seeds/settings.rb b/zammad-addon-pgp/src/db/seeds/settings.rb new file mode 100644 index 0000000..4648889 --- /dev/null +++ b/zammad-addon-pgp/src/db/seeds/settings.rb @@ -0,0 +1,4832 @@ +Setting.create_if_not_exists( + title: 'Application secret', + name: 'application_secret', + area: 'Core', + description: 'Defines the random application secret.', + options: {}, + state: SecureRandom.hex(128), + preferences: { + permission: ['admin'], + protected: true, + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'System Init Done', + name: 'system_init_done', + area: 'Core', + description: 'Defines if application is in init mode.', + options: {}, + state: false, + preferences: { online_service_disable: true }, + frontend: true +) +Setting.create_if_not_exists( + title: 'App Version', + name: 'app_version', + area: 'Core::WebApp', + description: 'Only used internally to propagate current web app version to clients.', + options: {}, + state: '', + preferences: { online_service_disable: true }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Maintenance Mode', + name: 'maintenance_mode', + area: 'Core::WebApp', + description: 'Enable or disable the maintenance mode of Zammad. If enabled, all non-administrators get logged out and only administrators can start a new session.', + options: {}, + state: false, + preferences: { + permission: ['admin.maintenance'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Maintenance Login', + name: 'maintenance_login', + area: 'Core::WebApp', + description: 'Put a message on the login page. To change it, click on the text area below and change it in-line.', + options: {}, + state: false, + preferences: { + permission: ['admin.maintenance'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Maintenance Login', + name: 'maintenance_login_message', + area: 'Core::WebApp', + description: 'Message for login page.', + options: {}, + state: 'Something about to share. Click here to change.', + preferences: { + permission: ['admin.maintenance'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Developer System', + name: 'developer_mode', + area: 'Core::Develop', + description: 'Defines if application is in developer mode (useful for developer, all users have the same password, password reset will work without email delivery).', + options: {}, + state: Rails.env.development?, + preferences: { online_service_disable: true }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Online Service', + name: 'system_online_service', + area: 'Core', + description: 'Defines if application is used as online service.', + options: {}, + state: false, + preferences: { online_service_disable: true }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Product Name', + name: 'product_name', + area: 'System::Branding', + description: 'Defines the name of the application, shown in the web interface, tabs and title bar of the web browser.', + options: { + form: [ + { + display: '', + null: false, + name: 'product_name', + tag: 'input', + }, + ], + }, + preferences: { + render: true, + prio: 1, + placeholder: true, + permission: ['admin.branding'], + }, + state: 'Zammad Helpdesk', + frontend: true +) +Setting.create_if_not_exists( + title: 'Logo', + name: 'product_logo', + area: 'System::Branding', + description: 'Defines the logo of the application, shown in the web interface.', + options: { + form: [ + { + display: '', + null: false, + name: 'product_logo', + tag: 'input', + }, + ], + }, + preferences: { + prio: 3, + controller: 'SettingsAreaLogo', + permission: ['admin.branding'], + }, + state: 'logo.svg', + frontend: true +) +Setting.create_if_not_exists( + title: 'Organization', + name: 'organization', + area: 'System::Branding', + description: 'Will be shown in the app and is included in email footers.', + options: { + form: [ + { + display: '', + null: false, + name: 'organization', + tag: 'input', + }, + ], + }, + state: '', + preferences: { + prio: 2, + placeholder: true, + permission: ['admin.branding'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Locale', + name: 'locale_default', + area: 'System::Branding', + description: 'Defines the system default language.', + options: { + form: [ + { + name: 'locale_default', + } + ], + }, + state: 'en-us', + preferences: { + prio: 8, + controller: 'SettingsAreaItemDefaultLocale', + permission: ['admin.system'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Timezone', + name: 'timezone_default', + area: 'System::Branding', + description: 'Defines the system default timezone.', + options: { + form: [ + { + name: 'timezone_default', + } + ], + }, + state: '', + preferences: { + prio: 9, + controller: 'SettingsAreaItemDefaultTimezone', + permission: ['admin.system'], + }, + frontend: true +) +Setting.create_or_update( + title: 'Pretty Date', + name: 'pretty_date_format', + area: 'System::Branding', + description: 'Defines pretty date format.', + options: { + form: [ + { + display: '', + null: false, + name: 'pretty_date_format', + tag: 'select', + options: { + relative: 'relative - e. g. "2 hours ago" or "2 days and 15 minutes ago"', + absolute: 'absolute - e. g. "Monday 09:30" or "Tuesday 23. Feb 14:20"', + timestamp: 'timestamp - e. g. "2018-08-30 14:30"', + }, + }, + ], + }, + preferences: { + render: true, + prio: 10, + permission: ['admin.branding'], + }, + state: 'relative', + frontend: true +) +options = {} +(10..99).each do |item| + options[item] = item +end +system_id = rand(10..99) +Setting.create_if_not_exists( + title: 'SystemID', + name: 'system_id', + area: 'System::Base', + description: 'Defines the system identifier. Every ticket number contains this ID. This ensures that only tickets which belong to your system will be processed as follow-ups (useful when communicating between two instances of Zammad).', + options: { + form: [ + { + display: '', + null: true, + name: 'system_id', + tag: 'select', + options: options, + }, + ], + }, + state: system_id, + preferences: { + online_service_disable: true, + placeholder: true, + authentication: true, + permission: ['admin.system'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Fully Qualified Domain Name', + name: 'fqdn', + area: 'System::Base', + description: 'Defines the fully qualified domain name of the system. This setting is used as a variable, #{setting.fqdn} which is found in all forms of messaging used by the application, to build links to the tickets within your system.', # rubocop:disable Lint/InterpolationCheck + options: { + form: [ + { + display: '', + null: false, + name: 'fqdn', + tag: 'input', + }, + ], + }, + state: 'zammad.example.com', + preferences: { + online_service_disable: true, + placeholder: true, + permission: ['admin.system'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Websocket backend', + name: 'websocket_backend', + area: 'System::WebSocket', + description: 'Defines how to reach websocket server. "websocket" is default on production, "websocketPort" is for CI', + state: Rails.env.production? ? 'websocket' : 'websocketPort', + frontend: true +) +Setting.create_if_not_exists( + title: 'Websocket port', + name: 'websocket_port', + area: 'System::WebSocket', + description: 'Defines the port of the websocket server.', + options: { + form: [ + { + display: '', + null: false, + name: 'websocket_port', + tag: 'input', + }, + ], + }, + state: '6042', + preferences: { online_service_disable: true }, + frontend: true +) +Setting.create_if_not_exists( + title: 'HTTP type', + name: 'http_type', + area: 'System::Base', + description: 'Define the http protocol of your instance.', + options: { + form: [ + { + display: '', + null: true, + name: 'http_type', + tag: 'select', + options: { + 'https' => 'https', + 'http' => 'http', + }, + }, + ], + }, + state: 'http', + preferences: { + online_service_disable: true, + placeholder: true, + permission: ['admin.system'], + }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Storage Mechanism', + name: 'storage_provider', + area: 'System::Storage', + description: '"Database" stores all attachments in the database (not recommended for storing large amounts of data). "Filesystem" stores the data in the filesystem. You can switch between the modules even on a system that is already in production without any loss of data.', + options: { + form: [ + { + display: '', + null: true, + name: 'storage_provider', + tag: 'select', + tranlate: true, + options: { + 'DB' => 'Database', + 'File' => 'Filesystem', + }, + }, + ], + }, + state: 'DB', + preferences: { + controller: 'SettingsAreaStorageProvider', + online_service_disable: true, + permission: ['admin.system'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Image Service', + name: 'image_backend', + area: 'System::Services', + description: 'Defines the backend for user and organization image lookups.', + options: { + form: [ + { + display: '', + null: true, + name: 'image_backend', + tag: 'select', + options: { + '' => '-', + 'Service::Image::Zammad' => 'Zammad Image Service', + }, + }, + ], + }, + state: 'Service::Image::Zammad', + preferences: { + prio: 1, + permission: ['admin.system'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Geo IP Service', + name: 'geo_ip_backend', + area: 'System::Services', + description: 'Defines the backend for geo IP lookups. Shows also location of an IP address if an IP address is shown.', + options: { + form: [ + { + display: '', + null: true, + name: 'geo_ip_backend', + tag: 'select', + options: { + '' => '-', + 'Service::GeoIp::Zammad' => 'Zammad GeoIP Service', + }, + }, + ], + }, + state: 'Service::GeoIp::Zammad', + preferences: { + prio: 2, + permission: ['admin.system'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Geo Location Service', + name: 'geo_location_backend', + area: 'System::Services', + description: 'Defines the backend for geo location lookups to store geo locations for addresses.', + options: { + form: [ + { + display: '', + null: true, + name: 'geo_location_backend', + tag: 'select', + options: { + '' => '-', + 'Service::GeoLocation::Gmaps' => 'Google Maps', + }, + }, + ], + }, + state: 'Service::GeoLocation::Gmaps', + preferences: { + prio: 3, + permission: ['admin.system'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Geo Calendar Service', + name: 'geo_calendar_backend', + area: 'System::Services', + description: 'Defines the backend for geo calendar lookups. Used for initial calendar succession.', + options: { + form: [ + { + display: '', + null: true, + name: 'geo_calendar_backend', + tag: 'select', + options: { + '' => '-', + 'Service::GeoCalendar::Zammad' => 'Zammad GeoCalendar Service', + }, + }, + ], + }, + state: 'Service::GeoCalendar::Zammad', + preferences: { + prio: 2, + permission: ['admin.system'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Proxy Settings', + name: 'proxy', + area: 'System::Network', + description: 'Address of the proxy server for http and https resources.', + options: { + form: [ + { + display: '', + null: false, + name: 'proxy', + tag: 'input', + placeholder: 'proxy.example.com:3128', + }, + ], + }, + state: '', + preferences: { + online_service_disable: true, + controller: 'SettingsAreaProxy', + prio: 1, + permission: ['admin.system'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Proxy User', + name: 'proxy_username', + area: 'System::Network', + description: 'Username for proxy connection.', + options: { + form: [ + { + display: '', + null: false, + name: 'proxy_username', + tag: 'input', + }, + ], + }, + state: '', + preferences: { + disabled: true, + online_service_disable: true, + prio: 2, + permission: ['admin.system'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Proxy Password', + name: 'proxy_password', + area: 'System::Network', + description: 'Password for proxy connection.', + options: { + form: [ + { + display: '', + null: false, + name: 'proxy_password', + tag: 'input', + }, + ], + }, + state: '', + preferences: { + disabled: true, + online_service_disable: true, + prio: 3, + permission: ['admin.system'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'No Proxy', + name: 'proxy_no', + area: 'System::Network', + description: 'No proxy for the following hosts.', + options: { + form: [ + { + display: '', + null: false, + name: 'proxy_no', + tag: 'input', + }, + ], + }, + state: 'localhost,127.0.0.0,::1', + preferences: { + disabled: true, + online_service_disable: true, + prio: 4, + permission: ['admin.system'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Send client stats', + name: 'ui_send_client_stats', + area: 'System::UI', + description: 'Send client stats/error message to central server to improve the usability.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_send_client_stats', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + permission: ['admin.system'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Client storage', + name: 'ui_client_storage', + area: 'System::UI', + description: 'Use client storage to cache data to enhance performance of application.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_client_storage', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 2, + permission: ['admin.system'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'User Organization Selector - email', + name: 'ui_user_organization_selector_with_email', + area: 'UI::UserOrganizatiomSelector', + description: 'Display of the e-mail in the result of the user/organization widget.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_user_organization_selector_with_email', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 100, + permission: ['admin.ui'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Note - default visibility', + name: 'ui_ticket_zoom_article_note_new_internal', + area: 'UI::TicketZoom', + description: 'Default visibility for new note.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_article_note_new_internal', + tag: 'boolean', + translate: true, + options: { + true => 'internal', + false => 'public', + }, + }, + ], + }, + state: true, + preferences: { + prio: 100, + permission: ['admin.ui'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Email - subject field', + name: 'ui_ticket_zoom_article_email_subject', + area: 'UI::TicketZoom', + description: 'Use subject field for emails. If disabled, the ticket title will be used as subject.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_article_email_subject', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 200, + permission: ['admin.ui'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Email - full quote', + name: 'ui_ticket_zoom_article_email_full_quote', + area: 'UI::TicketZoom', + description: 'Enable if you want to quote the full email in your answer. The quoted email will be put at the end of your answer. If you just want to quote a certain phrase, just mark the text and press reply (this feature is always available).', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_article_email_full_quote', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 220, + permission: ['admin.ui'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Email - quote header', + name: 'ui_ticket_zoom_article_email_full_quote_header', + area: 'UI::TicketZoom', + description: 'Enable if you want a timestamped reply header to be automatically inserted in front of quoted messages.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_article_email_full_quote_header', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + prio: 240, + permission: ['admin.ui'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Twitter - tweet initials', + name: 'ui_ticket_zoom_article_twitter_initials', + area: 'UI::TicketZoom', + description: 'Add sender initials to end of a tweet.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_article_twitter_initials', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + prio: 300, + permission: ['admin.ui'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Sidebar Attachments', + name: 'ui_ticket_zoom_attachments_preview', + area: 'UI::TicketZoom::Preview', + description: 'Enables preview of attachments.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_attachments_preview', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + prio: 400, + permission: ['admin.ui'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Sidebar Attachments', + name: 'ui_ticket_zoom_sidebar_article_attachments', + area: 'UI::TicketZoom::Preview', + description: 'Enables a sidebar to show an overview of all attachments.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_sidebar_article_attachments', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 500, + permission: ['admin.ui'], + }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Set notes for ticket create types.', + name: 'ui_ticket_create_notes', + area: 'UI::TicketCreate', + description: 'Set notes for ticket create types by selecting type.', + options: {}, + state: { + #'email-out' => 'Attention: When creating a ticket an e-mail is sent.', + }, + preferences: { + permission: ['admin.ui'], + }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Default type for a new ticket', + name: 'ui_ticket_create_default_type', + area: 'UI::TicketCreate', + description: 'Select default ticket type', + options: { + form: [ + { + display: '', + null: false, + multiple: false, + name: 'ui_ticket_create_default_type', + tag: 'select', + options: { + 'phone-in' => '1. Phone inbound', + 'phone-out' => '2. Phone outbound', + 'email-out' => '3. Email outbound', + }, + }, + ], + }, + state: 'phone-in', + preferences: { + permission: ['admin.ui'] + }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Available types for a new ticket', + name: 'ui_ticket_create_available_types', + area: 'UI::TicketCreate', + description: 'Set available ticket types', + options: { + form: [ + { + display: '', + null: false, + multiple: true, + name: 'ui_ticket_create_available_types', + tag: 'select', + options: { + 'phone-in' => '1. Phone inbound', + 'phone-out' => '2. Phone outbound', + 'email-out' => '3. Email outbound', + }, + }, + ], + }, + state: %w[phone-in phone-out email-out], + preferences: { + permission: ['admin.ui'] + }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Open ticket indicator', + name: 'ui_sidebar_open_ticket_indicator_colored', + area: 'UI::Sidebar', + description: 'Color representation of the open ticket indicator in the sidebar.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_sidebar_open_ticket_indicator_colored', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + permission: ['admin.ui'], + }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Open ticket indicator', + name: 'ui_table_group_by_show_count', + area: 'UI::Base', + description: 'Total display of the number of objects in a grouping.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_table_group_by_show_count', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + permission: ['admin.ui'], + }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Priority Icons in Overviews', + name: 'ui_ticket_overview_priority_icon', + area: 'UI::TicketOverview::PriorityIcons', + description: 'Enables priority icons in ticket overviews.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_overview_priority_icon', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 500, + permission: ['admin.ui'], + }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'New User Accounts', + name: 'user_create_account', + area: 'Security::Base', + description: 'Enables users to create their own account via web interface.', + options: { + form: [ + { + display: '', + null: true, + name: 'user_create_account', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + prio: 10, + permission: ['admin.security'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Lost Password', + name: 'user_lost_password', + area: 'Security::Base', + description: 'Activates lost password feature for users.', + options: { + form: [ + { + display: '', + null: true, + name: 'user_lost_password', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + prio: 20, + permission: ['admin.security'], + }, + frontend: true +) + +options = [ { value: '0', name: 'disabled' }, { value: 1.hour.seconds, name: '1 hour' }, { value: 2.hours.seconds, name: '2 hours' }, { value: 1.day.seconds, name: '1 day' }, { value: 7.days.seconds, name: '1 week' }, { value: 14.days.seconds, name: '2 weeks' }, { value: 21.days.seconds, name: '3 weeks' }, { value: 28.days.seconds, name: '4 weeks' } ] +Setting.create_if_not_exists( + title: 'Session Timeout', + name: 'session_timeout', + area: 'Security::Base', + description: 'Defines the session timeout for inactivity of users. Based on the assigned permissions the highest timeout value will be used, otherwise the default.', + options: { + form: [ + { + display: 'Default', + null: false, + name: 'default', + tag: 'select', + options: options, + translate: true, + }, + { + display: 'admin', + null: false, + name: 'admin', + tag: 'select', + options: options, + translate: true, + }, + { + display: 'ticket.agent', + null: false, + name: 'ticket.agent', + tag: 'select', + options: options, + translate: true, + }, + { + display: 'ticket.customer', + null: false, + name: 'ticket.customer', + tag: 'select', + options: options, + translate: true, + }, + ], + }, + preferences: { + prio: 30, + }, + state: { + 'default' => 4.weeks.seconds, + 'admin' => 4.weeks.seconds, + 'ticket.agent' => 4.weeks.seconds, + 'ticket.customer' => 4.weeks.seconds, + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'User email for muliple users', + name: 'user_email_multiple_use', + area: 'Model::User', + description: 'Allow to use email address for muliple users.', + options: { + form: [ + { + display: '', + null: true, + name: 'user_email_multiple_use', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + permission: ['admin'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Authentication via %s', + name: 'auth_ldap', + area: 'Security::Authentication', + description: 'Enables user authentication via %s.', + preferences: { + title_i18n: ['LDAP'], + description_i18n: ['LDAP'], + permission: ['admin.security'], + }, + state: { + adapter: 'Auth::Ldap', + host: 'localhost', + port: 389, + bind_dn: 'cn=Manager,dc=example,dc=org', + bind_pw: 'example', + uid: 'mail', + base: 'dc=example,dc=org', + always_filter: '', + always_roles: %w[Admin Agent], + always_groups: ['Users'], + sync_params: { + firstname: 'sn', + lastname: 'givenName', + email: 'mail', + login: 'mail', + }, + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Automatic account link on initial logon', + name: 'auth_third_party_auto_link_at_inital_login', + area: 'Security::ThirdPartyAuthentication', + description: 'Enables the automatic linking of an existing account on initial login via a third party application. If this is disabled, an existing user must first log into Zammad and then link his "Third Party" account to his Zammad account via Profile -> Linked Accounts.', + options: { + form: [ + { + display: '', + null: true, + name: 'auth_third_party_auto_link_at_inital_login', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + preferences: { + permission: ['admin.security'], + prio: 10, + }, + state: false, + frontend: false +) +Setting.create_if_not_exists( + title: 'Authentication via %s', + name: 'auth_twitter', + area: 'Security::ThirdPartyAuthentication', + description: 'Enables user authentication via %s. Register your app first at [%s](%s).', + options: { + form: [ + { + display: '', + null: true, + name: 'auth_twitter', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + preferences: { + controller: 'SettingsAreaSwitch', + sub: ['auth_twitter_credentials'], + title_i18n: ['Twitter'], + description_i18n: ['Twitter', 'Twitter Developer Site', 'https://dev.twitter.com/apps'], + permission: ['admin.security'], + }, + state: false, + frontend: true +) +Setting.create_if_not_exists( + title: 'Twitter App Credentials', + name: 'auth_twitter_credentials', + area: 'Security::ThirdPartyAuthentication::Twitter', + description: 'App credentials for Twitter.', + options: { + form: [ + { + display: 'Twitter Key', + null: true, + name: 'key', + tag: 'input', + }, + { + display: 'Twitter Secret', + null: true, + name: 'secret', + tag: 'input', + }, + ], + }, + state: {}, + preferences: { + permission: ['admin.security'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Authentication via %s', + name: 'auth_facebook', + area: 'Security::ThirdPartyAuthentication', + description: 'Enables user authentication via %s. Register your app first at [%s](%s).', + options: { + form: [ + { + display: '', + null: true, + name: 'auth_facebook', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + preferences: { + controller: 'SettingsAreaSwitch', + sub: ['auth_facebook_credentials'], + title_i18n: ['Facebook'], + description_i18n: ['Facebook', 'Facebook Developer Site', 'https://developers.facebook.com/apps/'], + permission: ['admin.security'], + }, + state: false, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Facebook App Credentials', + name: 'auth_facebook_credentials', + area: 'Security::ThirdPartyAuthentication::Facebook', + description: 'App credentials for Facebook.', + options: { + form: [ + { + display: 'App ID', + null: true, + name: 'app_id', + tag: 'input', + }, + { + display: 'App Secret', + null: true, + name: 'app_secret', + tag: 'input', + }, + ], + }, + state: {}, + preferences: { + permission: ['admin.security'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Authentication via %s', + name: 'auth_google_oauth2', + area: 'Security::ThirdPartyAuthentication', + description: 'Enables user authentication via %s. Register your app first at [%s](%s).', + options: { + form: [ + { + display: '', + null: true, + name: 'auth_google_oauth2', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + preferences: { + controller: 'SettingsAreaSwitch', + sub: ['auth_google_oauth2_credentials'], + title_i18n: ['Google'], + description_i18n: ['Google', 'Google API Console Site', 'https://console.cloud.google.com/apis/credentials'], + permission: ['admin.security'], + }, + state: false, + frontend: true +) +Setting.create_if_not_exists( + title: 'Google App Credentials', + name: 'auth_google_oauth2_credentials', + area: 'Security::ThirdPartyAuthentication::Google', + description: 'Enables user authentication via Google.', + options: { + form: [ + { + display: 'Client ID', + null: true, + name: 'client_id', + tag: 'input', + }, + { + display: 'Client Secret', + null: true, + name: 'client_secret', + tag: 'input', + }, + ], + }, + state: {}, + preferences: { + permission: ['admin.security'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Authentication via %s', + name: 'auth_linkedin', + area: 'Security::ThirdPartyAuthentication', + description: 'Enables user authentication via %s. Register your app first at [%s](%s).', + options: { + form: [ + { + display: '', + null: true, + name: 'auth_linkedin', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + preferences: { + controller: 'SettingsAreaSwitch', + sub: ['auth_linkedin_credentials'], + title_i18n: ['LinkedIn'], + description_i18n: ['LinkedIn', 'Linkedin Developer Site', 'https://www.linkedin.com/developer/apps'], + permission: ['admin.security'], + }, + state: false, + frontend: true +) +Setting.create_if_not_exists( + title: 'LinkedIn App Credentials', + name: 'auth_linkedin_credentials', + area: 'Security::ThirdPartyAuthentication::Linkedin', + description: 'Enables user authentication via LinkedIn.', + options: { + form: [ + { + display: 'App ID', + null: true, + name: 'app_id', + tag: 'input', + }, + { + display: 'App Secret', + null: true, + name: 'app_secret', + tag: 'input', + }, + ], + }, + state: {}, + preferences: { + permission: ['admin.security'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Authentication via %s', + name: 'auth_github', + area: 'Security::ThirdPartyAuthentication', + description: 'Enables user authentication via %s. Register your app first at [%s](%s).', + options: { + form: [ + { + display: '', + null: true, + name: 'auth_github', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + preferences: { + controller: 'SettingsAreaSwitch', + sub: ['auth_github_credentials'], + title_i18n: ['Github'], + description_i18n: ['Github', 'Github OAuth Applications', 'https://github.com/settings/applications'], + permission: ['admin.security'], + }, + state: false, + frontend: true +) +Setting.create_if_not_exists( + title: 'Github App Credentials', + name: 'auth_github_credentials', + area: 'Security::ThirdPartyAuthentication::Github', + description: 'Enables user authentication via Github.', + options: { + form: [ + { + display: 'App ID', + null: true, + name: 'app_id', + tag: 'input', + }, + { + display: 'App Secret', + null: true, + name: 'app_secret', + tag: 'input', + }, + ], + }, + state: {}, + preferences: { + permission: ['admin.security'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Authentication via %s', + name: 'auth_gitlab', + area: 'Security::ThirdPartyAuthentication', + description: 'Enables user authentication via %s. Register your app first at [%s](%s).', + options: { + form: [ + { + display: '', + null: true, + name: 'auth_gitlab', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + preferences: { + controller: 'SettingsAreaSwitch', + sub: ['auth_gitlab_credentials'], + title_i18n: ['GitLab'], + description_i18n: ['GitLab', 'GitLab Applications', 'https://your-gitlab-host/admin/applications'], + permission: ['admin.security'], + }, + state: false, + frontend: true +) +Setting.create_if_not_exists( + title: 'GitLab App Credentials', + name: 'auth_gitlab_credentials', + area: 'Security::ThirdPartyAuthentication::GitLab', + description: 'Enables user authentication via GitLab.', + options: { + form: [ + { + display: 'App ID', + null: true, + name: 'app_id', + tag: 'input', + }, + { + display: 'App Secret', + null: true, + name: 'app_secret', + tag: 'input', + }, + { + display: 'Site', + null: true, + name: 'site', + tag: 'input', + placeholder: 'https://gitlab.YOURDOMAIN.com/api/v4/', + }, + ], + }, + state: {}, + preferences: { + permission: ['admin.security'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Authentication via %s', + name: 'auth_microsoft_office365', + area: 'Security::ThirdPartyAuthentication', + description: 'Enables user authentication via %s. Register your app first at [%s](%s).', + options: { + form: [ + { + display: '', + null: true, + name: 'auth_microsoft_office365', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + preferences: { + controller: 'SettingsAreaSwitch', + sub: ['auth_microsoft_office365_credentials'], + title_i18n: ['Office 365'], + description_i18n: ['Office 365', 'Microsoft Application Registration Portal', 'https://portal.azure.com'], + permission: ['admin.security'], + }, + state: false, + frontend: true +) +Setting.create_if_not_exists( + title: 'Office 365 App Credentials', + name: 'auth_microsoft_office365_credentials', + area: 'Security::ThirdPartyAuthentication::Office365', + description: 'Enables user authentication via Office 365.', + options: { + form: [ + { + display: 'App ID', + null: true, + name: 'app_id', + tag: 'input', + }, + { + display: 'App Secret', + null: true, + name: 'app_secret', + tag: 'input', + }, + { + display: 'App Tenant ID', + null: true, + name: 'app_tenant', + tag: 'input', + placeholder: 'common', + }, + ], + }, + state: {}, + preferences: { + permission: ['admin.security'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Authentication via %s', + name: 'auth_weibo', + area: 'Security::ThirdPartyAuthentication', + description: 'Enables user authentication via %s. Register your app first at [%s](%s).', + options: { + form: [ + { + display: '', + null: true, + name: 'auth_weibo', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + preferences: { + controller: 'SettingsAreaSwitch', + sub: ['auth_weibo_credentials'], + title_i18n: ['Weibo'], + description_i18n: ['Sina Weibo', 'Sina Weibo Open Protal', 'http://open.weibo.com'], + permission: ['admin.security'], + }, + state: false, + frontend: true +) +Setting.create_if_not_exists( + title: 'Weibo App Credentials', + name: 'auth_weibo_credentials', + area: 'Security::ThirdPartyAuthentication::Weibo', + description: 'Enables user authentication via Weibo.', + options: { + form: [ + { + display: 'App ID', + null: true, + name: 'client_id', + tag: 'input', + }, + { + display: 'App Secret', + null: true, + name: 'client_secret', + tag: 'input', + }, + ], + }, + state: {}, + preferences: { + permission: ['admin.security'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Authentication via %s', + name: 'auth_saml', + area: 'Security::ThirdPartyAuthentication', + description: 'Enables user authentication via %s.', + options: { + form: [ + { + display: '', + null: true, + name: 'auth_saml', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + preferences: { + controller: 'SettingsAreaSwitch', + sub: ['auth_saml_credentials'], + title_i18n: ['SAML'], + description_i18n: ['SAML'], + permission: ['admin.security'], + }, + state: false, + frontend: true +) +Setting.create_if_not_exists( + title: 'SAML App Credentials', + name: 'auth_saml_credentials', + area: 'Security::ThirdPartyAuthentication::SAML', + description: 'Enables user authentication via SAML.', + options: { + form: [ + { + display: 'IDP SSO target URL', + null: true, + name: 'idp_sso_target_url', + tag: 'input', + placeholder: 'https://capriza.github.io/samling/samling.html', + }, + { + display: 'IDP certificate', + null: true, + name: 'idp_cert', + tag: 'input', + placeholder: '-----BEGIN CERTIFICATE-----\n...-----END CERTIFICATE-----', + }, + { + display: 'IDP certificate fingerprint', + null: true, + name: 'idp_cert_fingerprint', + tag: 'input', + placeholder: 'E7:91:B2:E1:...', + }, + { + display: 'Name Identifier Format', + null: true, + name: 'name_identifier_format', + tag: 'input', + placeholder: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + }, + ], + }, + state: {}, + preferences: { + permission: ['admin.security'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Minimum length', + name: 'password_min_size', + area: 'Security::Password', + description: 'Password needs to have at least a minimal number of characters.', + options: { + form: [ + { + display: '', + null: true, + name: 'password_min_size', + tag: 'select', + options: { + 4 => ' 4', + 5 => ' 5', + 6 => ' 6', + 7 => ' 7', + 8 => ' 8', + 9 => ' 9', + 10 => '10', + 11 => '11', + 12 => '12', + 13 => '13', + 14 => '14', + 15 => '15', + 16 => '16', + 17 => '17', + 18 => '18', + 19 => '19', + 20 => '20', + }, + }, + ], + }, + state: 10, + preferences: { + permission: ['admin.security'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: '2 lower and 2 upper characters', + name: 'password_min_2_lower_2_upper_characters', + area: 'Security::Password', + description: 'Password needs to contain 2 lower and 2 upper characters.', + options: { + form: [ + { + display: '', + null: true, + name: 'password_min_2_lower_2_upper_characters', + tag: 'select', + options: { + 1 => 'yes', + 0 => 'no', + }, + }, + ], + }, + state: 1, + preferences: { + permission: ['admin.security'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Digit required', + name: 'password_need_digit', + area: 'Security::Password', + description: 'Password needs to contain at least one digit.', + options: { + form: [ + { + display: 'Needed', + null: true, + name: 'password_need_digit', + tag: 'select', + options: { + 1 => 'yes', + 0 => 'no', + }, + }, + ], + }, + state: 1, + preferences: { + permission: ['admin.security'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Special character required', + name: 'password_need_special_character', + area: 'Security::Password', + description: 'Password needs to contain at least one special character.', + options: { + form: [ + { + display: 'Needed', + null: true, + name: 'password_need_special_character', + tag: 'select', + options: { + 1 => 'yes', + 0 => 'no', + }, + }, + ], + }, + state: 0, + preferences: { + permission: ['admin.security'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Maximum failed logins', + name: 'password_max_login_failed', + area: 'Security::Password', + description: 'Number of failed logins after account will be deactivated.', + options: { + form: [ + { + display: '', + null: true, + name: 'password_max_login_failed', + tag: 'select', + options: { + 4 => ' 4', + 5 => ' 5', + 6 => ' 6', + 7 => ' 7', + 8 => ' 8', + 9 => ' 9', + 10 => '10', + 11 => '11', + 13 => '13', + 14 => '14', + 15 => '15', + 16 => '16', + 17 => '17', + 18 => '18', + 19 => '19', + 20 => '20', + }, + }, + ], + }, + state: 5, + preferences: { + permission: ['admin.security'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Ticket Hook', + name: 'ticket_hook', + area: 'Ticket::Base', + description: 'The identifier for a ticket, e.g. Ticket#, Call#, MyTicket#. The default is Ticket#.', + options: { + form: [ + { + display: '', + null: false, + name: 'ticket_hook', + tag: 'input', + }, + ], + }, + preferences: { + render: true, + placeholder: true, + authentication: true, + permission: ['admin.ticket'], + }, + state: 'Ticket#', + frontend: true +) +Setting.create_if_not_exists( + title: 'Ticket Hook Divider', + name: 'ticket_hook_divider', + area: 'Ticket::Base::Shadow', + description: 'The divider between TicketHook and ticket number. E. g. \': \'.', + options: { + form: [ + { + display: '', + null: true, + name: 'ticket_hook_divider', + tag: 'input', + }, + ], + }, + state: '', + preferences: { + permission: ['admin.ticket'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Ticket Hook Position', + name: 'ticket_hook_position', + area: 'Ticket::Base', + description: "The format of the subject. +* **Right** means **Some Subject [Ticket#12345]** +* **Left** means **[Ticket#12345] Some Subject** +* **None** means **Some Subject** (without ticket number). In the last case you should enable *postmaster_follow_up_search_in* to recognize follow-ups based on email headers and/or body.", + options: { + form: [ + { + display: '', + null: true, + name: 'ticket_hook_position', + tag: 'select', + translate: true, + options: { + 'left' => 'left', + 'right' => 'right', + 'none' => 'none', + }, + }, + ], + }, + state: 'right', + preferences: { + controller: 'SettingsAreaTicketHookPosition', + permission: ['admin.ticket'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Ticket Last Contact Behaviour', + name: 'ticket_last_contact_behaviour', + area: 'Ticket::Base', + description: 'Sets the last customer contact based on the last contact of a customer or on the last contact of a customer to whom an agent has not yet responded.', + options: { + form: [ + { + display: '', + null: true, + name: 'ticket_last_contact_behaviour', + tag: 'select', + translate: true, + options: { + 'based_on_customer_reaction' => 'Last customer contact (without consideration an agent has replied to it)', + 'check_if_agent_already_replied' => 'Last customer contact (with consideration an agent has replied to it)', + }, + }, + ], + }, + state: 'check_if_agent_already_replied', + preferences: { + permission: ['admin.ticket'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Ticket Number Format', + name: 'ticket_number', + area: 'Ticket::Number', + description: "Selects the ticket number generator module. +* **Increment** increments the ticket number, the SystemID and the counter are used with SystemID.Counter format (e.g. 1010138, 1010139). +* With **Date** the ticket numbers will be generated by the current date, the SystemID and the counter. The format looks like Year.Month.Day.SystemID.counter (e.g. 201206231010138, 201206231010139).", + options: { + form: [ + { + display: '', + null: true, + name: 'ticket_number', + tag: 'select', + translate: true, + options: { + 'Ticket::Number::Increment' => 'Increment (SystemID.Counter)', + 'Ticket::Number::Date' => 'Date (Year.Month.Day.SystemID.Counter)', + }, + }, + ], + }, + state: 'Ticket::Number::Increment', + preferences: { + settings_included: %w[ticket_number_increment ticket_number_date], + controller: 'SettingsAreaTicketNumber', + permission: ['admin.ticket'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Ticket Number Increment', + name: 'ticket_number_increment', + area: 'Ticket::Number', + description: '-', + options: { + form: [ + { + display: 'Checksum', + null: true, + name: 'checksum', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + { + display: 'Min. size of number', + null: true, + name: 'min_size', + tag: 'select', + options: { + 1 => ' 1', + 2 => ' 2', + 3 => ' 3', + 4 => ' 4', + 5 => ' 5', + 6 => ' 6', + 7 => ' 7', + 8 => ' 8', + 9 => ' 9', + 10 => '10', + 11 => '11', + 12 => '12', + 13 => '13', + 14 => '14', + 15 => '15', + 16 => '16', + 17 => '17', + 18 => '18', + 19 => '19', + 20 => '20', + }, + }, + ], + }, + state: { + checksum: false, + min_size: 5, + }, + preferences: { + permission: ['admin.ticket'], + hidden: true, + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Ticket Number Increment Date', + name: 'ticket_number_date', + area: 'Ticket::Number', + description: '-', + options: { + form: [ + { + display: 'Checksum', + null: true, + name: 'checksum', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: { + checksum: false + }, + preferences: { + permission: ['admin.ticket'], + hidden: true, + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Auto Assigment', + name: 'ticket_auto_assignment', + area: 'Web::Base', + description: 'Enable ticket auto assignment.', + options: { + form: [ + { + display: '', + null: true, + name: 'ticket_auto_assignment', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + preferences: { + authentication: true, + permission: ['admin.ticket_auto_assignment'], + }, + state: false, + frontend: true +) +Setting.create_if_not_exists( + title: 'Time Accounting Selector', + name: 'ticket_auto_assignment_selector', + area: 'Web::Base', + description: 'Enable auto assignment for following matching tickets.', + options: { + form: [ + {}, + ], + }, + preferences: { + authentication: true, + permission: ['admin.ticket_auto_assignment'], + }, + state: { condition: { 'ticket.state_id' => { operator: 'is', value: Ticket::State.by_category(:work_on).pluck(:id) } } }, + frontend: true +) +Setting.create_or_update( + title: 'Time Accounting Selector', + name: 'ticket_auto_assignment_user_ids_ignore', + area: 'Web::Base', + description: 'Define an exception of "automatic assignment" for certain users (e.g. executives).', + options: { + form: [ + {}, + ], + }, + preferences: { + authentication: true, + permission: ['admin.ticket_auto_assignment'], + }, + state: [], + frontend: true +) +Setting.create_if_not_exists( + title: 'Ticket Number ignore system_id', + name: 'ticket_number_ignore_system_id', + area: 'Ticket::Core', + description: '-', + options: { + form: [ + { + display: 'Ignore system_id', + null: true, + name: 'ticket_number_ignore_system_id', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + permission: ['admin.ticket'], + hidden: true, + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Recursive Ticket Triggers', + name: 'ticket_trigger_recursive', + area: 'Ticket::Core', + description: 'Activate the recursive processing of ticket triggers.', + options: { + form: [ + { + display: 'Recursive Ticket Triggers', + null: true, + name: 'ticket_trigger_recursive', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + permission: ['admin.ticket'], + hidden: true, + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Recursive Ticket Triggers Loop Max.', + name: 'ticket_trigger_recursive_max_loop', + area: 'Ticket::Core', + description: 'Maximum number of recursively executed triggers.', + options: { + form: [ + { + display: 'Recursive Ticket Triggers', + null: true, + name: 'ticket_trigger_recursive_max_loop', + tag: 'select', + options: { + 1 => ' 1', + 2 => ' 2', + 3 => ' 3', + 4 => ' 4', + 5 => ' 5', + 6 => ' 6', + 7 => ' 7', + 8 => ' 8', + 9 => ' 9', + 10 => '10', + 11 => '11', + 12 => '12', + 13 => '13', + 14 => '14', + 15 => '15', + 16 => '16', + 17 => '17', + 18 => '18', + 19 => '19', + 20 => '20', + }, + }, + ], + }, + state: 10, + preferences: { + permission: ['admin.ticket'], + hidden: true, + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Enable Ticket creation', + name: 'customer_ticket_create', + area: 'CustomerWeb::Base', + description: 'Defines if a customer can create tickets via the web interface.', + options: { + form: [ + { + display: '', + null: true, + name: 'customer_ticket_create', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + authentication: true, + permission: ['admin.channel_web'], + }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Group selection for Ticket creation', + name: 'customer_ticket_create_group_ids', + area: 'CustomerWeb::Base', + description: 'Defines groups for which a customer can create tickets via web interface. "-" means all groups are available.', + options: { + form: [ + { + display: '', + null: true, + name: 'group_ids', + tag: 'select', + multiple: true, + nulloption: true, + relation: 'Group', + }, + ], + }, + state: '', + preferences: { + authentication: true, + permission: ['admin.channel_web'], + }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Enable Ticket creation', + name: 'form_ticket_create', + area: 'Form::Base', + description: 'Defines if tickets can be created via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, +) + +group = Group.where(active: true).first +if !group + group = Group.first +end +group_id = 1 +if group + group_id = group.id +end +Setting.create_if_not_exists( + title: 'Group selection for Ticket creation', + name: 'form_ticket_create_group_id', + area: 'Form::Base', + description: 'Defines if group of created tickets via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_group_id', + tag: 'select', + relation: 'Group', + }, + ], + }, + state: group_id, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, +) + +Setting.create_if_not_exists( + title: 'Limit tickets by ip per hour', + name: 'form_ticket_create_by_ip_per_hour', + area: 'Form::Base', + description: 'Defines limit of tickets by ip per hour via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_by_ip_per_hour', + tag: 'input', + }, + ], + }, + state: 20, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'Limit tickets by ip per day', + name: 'form_ticket_create_by_ip_per_day', + area: 'Form::Base', + description: 'Defines limit of tickets by ip per day via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_by_ip_per_day', + tag: 'input', + }, + ], + }, + state: 240, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'Limit tickets per day', + name: 'form_ticket_create_per_day', + area: 'Form::Base', + description: 'Defines limit of tickets per day via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_per_day', + tag: 'input', + }, + ], + }, + state: 5000, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, +) + +Setting.create_if_not_exists( + title: 'Ticket Subject Size', + name: 'ticket_subject_size', + area: 'Email::Base', + description: 'Max. length of the subject in an email reply.', + options: { + form: [ + { + display: '', + null: false, + name: 'ticket_subject_size', + tag: 'input', + }, + ], + }, + state: '110', + preferences: { + permission: ['admin.channel_email', 'admin.channel_google', 'admin.channel_microsoft365'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Ticket Subject Reply', + name: 'ticket_subject_re', + area: 'Email::Base', + description: 'The text at the beginning of the subject in an email reply, e.g. RE, AW, or AS.', + options: { + form: [ + { + display: '', + null: true, + name: 'ticket_subject_re', + tag: 'input', + }, + ], + }, + state: 'RE', + preferences: { + permission: ['admin.channel_email', 'admin.channel_google', 'admin.channel_microsoft365'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Ticket Subject Forward', + name: 'ticket_subject_fwd', + area: 'Email::Base', + description: 'The text at the beginning of the subject in an email forward, e. g. FWD.', + options: { + form: [ + { + display: '', + null: true, + name: 'ticket_subject_fwd', + tag: 'input', + }, + ], + }, + state: 'FWD', + preferences: { + permission: ['admin.channel_email', 'admin.channel_google', 'admin.channel_microsoft365'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Sender Format', + name: 'ticket_define_email_from', + area: 'Email::Base', + description: 'Defines how the From field of emails (sent from answers and email tickets) should look like.', + options: { + form: [ + { + display: '', + null: true, + name: 'ticket_define_email_from', + tag: 'select', + options: { + SystemAddressName: 'System Address Display Name', + AgentNameSystemAddressName: 'Agent Name + FromSeparator + System Address Display Name', + AgentName: 'Agent Name', + }, + }, + ], + }, + state: 'AgentNameSystemAddressName', + preferences: { + permission: ['admin.channel_email', 'admin.channel_google', 'admin.channel_microsoft365'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Sender Format Separator', + name: 'ticket_define_email_from_separator', + area: 'Email::Base', + description: 'Defines the separator between the agent\'s real name and the given group email address.', + options: { + form: [ + { + display: '', + null: false, + name: 'ticket_define_email_from_separator', + tag: 'input', + }, + ], + }, + state: 'via', + preferences: { + permission: ['admin.channel_email', 'admin.channel_google', 'admin.channel_microsoft365'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Maximum Email Size', + name: 'postmaster_max_size', + area: 'Email::Base', + description: 'Maximum size in MB of emails.', + options: { + form: [ + { + display: '', + null: true, + name: 'postmaster_max_size', + tag: 'select', + options: { + 1 => ' 1', + 2 => ' 2', + 3 => ' 3', + 4 => ' 4', + 5 => ' 5', + 6 => ' 6', + 7 => ' 7', + 8 => ' 8', + 9 => ' 9', + 10 => ' 10', + 15 => ' 15', + 20 => ' 20', + 25 => ' 25', + 30 => ' 30', + 35 => ' 35', + 40 => ' 40', + 45 => ' 45', + 50 => ' 50', + 60 => ' 60', + 70 => ' 70', + 80 => ' 80', + 90 => ' 90', + 100 => '100', + 125 => '125', + 150 => '150', + }, + }, + ], + }, + state: 10, + preferences: { + online_service_disable: true, + permission: ['admin.channel_email', 'admin.channel_google', 'admin.channel_microsoft365'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Additional follow-up detection', + name: 'postmaster_follow_up_search_in', + area: 'Email::Base', + description: 'By default the follow-up check is done via the subject of an email. With this setting you can add more fields for which the follow-up check will be executed.', + options: { + form: [ + { + display: '', + null: true, + name: 'postmaster_follow_up_search_in', + tag: 'checkbox', + options: { + 'references' => 'References - Search for follow-up also in In-Reply-To or References headers.', + 'body' => 'Body - Search for follow-up also in mail body.', + 'attachment' => 'Attachment - Search for follow-up also in attachments.', + }, + }, + ], + }, + state: [], + preferences: { + permission: ['admin.channel_email', 'admin.channel_google', 'admin.channel_microsoft365'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Sender based on Reply-To header', + name: 'postmaster_sender_based_on_reply_to', + area: 'Email::Base', + description: 'Set/overwrite sender/from of email based on reply-to header. Useful to set correct customer if email is received from a third party system on behalf of a customer.', + options: { + form: [ + { + display: '', + null: true, + name: 'postmaster_sender_based_on_reply_to', + tag: 'select', + options: { + '' => '-', + 'as_sender_of_email' => 'Take reply-to header as sender/from of email.', + 'as_sender_of_email_use_from_realname' => 'Take reply-to header as sender/from of email and use realname of origin from.', + }, + }, + ], + }, + state: [], + preferences: { + permission: ['admin.channel_email', 'admin.channel_google', 'admin.channel_microsoft365'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Customer selection based on sender and receiver list', + name: 'postmaster_sender_is_agent_search_for_customer', + area: 'Email::Base', + description: 'If the sender is an agent, set the first user in the recipient list as a customer.', + options: { + form: [ + { + display: '', + null: true, + name: 'postmaster_sender_is_agent_search_for_customer', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + permission: ['admin.channel_email', 'admin.channel_google', 'admin.channel_microsoft365'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Send postmaster mail if mail too large', + name: 'postmaster_send_reject_if_mail_too_large', + area: 'Email::Base', + description: 'Send postmaster reject mail to sender of mail if mail is too large.', + options: { + form: [ + { + display: '', + null: true, + name: 'postmaster_send_reject_if_mail_too_large', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + online_service_disable: true, + permission: ['admin.channel_email', 'admin.channel_google', 'admin.channel_microsoft365'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Notification Sender', + name: 'notification_sender', + area: 'Email::Base', + description: 'Defines the sender of email notifications.', + options: { + form: [ + { + display: '', + null: false, + name: 'notification_sender', + tag: 'input', + }, + ], + }, + state: 'Notification Master ', # rubocop:disable Lint/InterpolationCheck + preferences: { + online_service_disable: true, + permission: ['admin.channel_email', 'admin.channel_google', 'admin.channel_microsoft365'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Block Notifications', + name: 'send_no_auto_response_reg_exp', + area: 'Email::Base', + description: 'If this regex matches, no notification will be sent by the sender.', + options: { + form: [ + { + display: '', + null: false, + name: 'send_no_auto_response_reg_exp', + tag: 'input', + }, + ], + }, + state: '(mailer-daemon|postmaster|abuse|root|noreply|noreply.+?|no-reply|no-reply.+?)@.+?', + preferences: { + online_service_disable: true, + permission: ['admin.channel_email', 'admin.channel_google', 'admin.channel_microsoft365'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Bcc address for all outgoing emails', + name: 'system_bcc', + area: 'Email::Enhanced', + description: 'To archive all outgoing emails from Zammad to external, you can store a bcc email address here.', + options: {}, + state: '', + preferences: { online_service_disable: true }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'API Token Access', + name: 'api_token_access', + area: 'API::Base', + description: 'Enable REST API using tokens (not username/email address and password). Each user needs to create its own access tokens in user profile.', + options: { + form: [ + { + display: '', + null: true, + name: 'api_token_access', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + permission: ['admin.api'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'API Password Access', + name: 'api_password_access', + area: 'API::Base', + description: 'Enable REST API access using the username/email address and password for the authentication user.', + options: { + form: [ + { + display: '', + null: true, + name: 'api_password_access', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + permission: ['admin.api'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Monitoring Token', + name: 'monitoring_token', + area: 'HealthCheck::Base', + description: 'Token for monitoring.', + options: { + form: [ + { + display: '', + null: false, + name: 'monitoring_token', + tag: 'input', + }, + ], + }, + state: ENV['MONITORING_TOKEN'] || SecureRandom.urlsafe_base64(40), + preferences: { + permission: ['admin.monitoring'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Enable Chat', + name: 'chat', + area: 'Chat::Base', + description: 'Enable/disable online chat.', + options: { + form: [ + { + display: '', + null: true, + name: 'chat', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + preferences: { + trigger: ['menu:render', 'chat:rerender'], + permission: ['admin.channel_chat'], + }, + state: false, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Agent idle timeout', + name: 'chat_agent_idle_timeout', + area: 'Chat::Extended', + description: 'Idle timeout in seconds until agent is set offline automatically.', + options: { + form: [ + { + display: '', + null: false, + name: 'chat_agent_idle_timeout', + tag: 'input', + }, + ], + }, + state: '120', + preferences: { + permission: ['admin.channel_chat'], + }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Defines searchable models.', + name: 'models_searchable', + area: 'Models::Base', + description: 'Defines the searchable models.', + options: {}, + state: Models.searchable.map(&:to_s), + preferences: { + authentication: true, + }, + frontend: true, +) + +Setting.create_if_not_exists( + title: 'Default Screen', + name: 'default_controller', + area: 'Core', + description: 'Defines the default screen.', + options: {}, + state: '#dashboard', + frontend: true +) + +Setting.create_if_not_exists( + title: 'Elasticsearch Endpoint URL', + name: 'es_url', + area: 'SearchIndex::Elasticsearch', + description: 'Defines endpoint of Elasticsearch.', + state: '', + preferences: { online_service_disable: true }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Elasticsearch Endpoint User', + name: 'es_user', + area: 'SearchIndex::Elasticsearch', + description: 'Defines HTTP basic auth user of Elasticsearch.', + state: '', + preferences: { online_service_disable: true }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Elasticsearch Endpoint Password', + name: 'es_password', + area: 'SearchIndex::Elasticsearch', + description: 'Defines HTTP basic auth password of Elasticsearch.', + state: '', + preferences: { online_service_disable: true }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Elasticsearch Endpoint Index', + name: 'es_index', + area: 'SearchIndex::Elasticsearch', + description: 'Defines Elasticsearch index name.', + state: 'zammad', + preferences: { online_service_disable: true }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Elasticsearch Attachment Extensions', + name: 'es_attachment_ignore', + area: 'SearchIndex::Elasticsearch', + description: 'Defines attachment extensions which will be ignored by Elasticsearch.', + state: [ '.png', '.jpg', '.jpeg', '.mpeg', '.mpg', '.mov', '.bin', '.exe', '.box', '.mbox' ], + preferences: { online_service_disable: true }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Elasticsearch Attachment Size', + name: 'es_attachment_max_size_in_mb', + area: 'SearchIndex::Elasticsearch', + description: 'Define max. attachment size for Elasticsearch.', + state: 10, + preferences: { online_service_disable: true }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Elasticsearch Total Payload Size', + name: 'es_total_max_size_in_mb', + area: 'SearchIndex::Elasticsearch', + description: 'Define max. payload size for Elasticsearch.', + state: 300, + preferences: { online_service_disable: true }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Elasticsearch Pipeline Name', + name: 'es_pipeline', + area: 'SearchIndex::Elasticsearch', + description: 'Define pipeline name for Elasticsearch.', + state: '', + preferences: { online_service_disable: true }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Import Mode', + name: 'import_mode', + area: 'Import::Base', + description: 'Puts Zammad into import mode (disables some triggers).', + options: { + form: [ + { + display: '', + null: true, + name: 'import_mode', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + frontend: true +) +Setting.create_if_not_exists( + title: 'Import Backend', + name: 'import_backend', + area: 'Import::Base::Internal', + description: 'Set backend which is being used for import.', + options: {}, + state: '', + frontend: true +) +Setting.create_if_not_exists( + title: 'Ignore Escalation/SLA Information', + name: 'import_ignore_sla', + area: 'Import::Base', + description: 'Ignore escalation/SLA information for import.', + options: { + form: [ + { + display: '', + null: true, + name: 'import_ignore_sla', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Import Endpoint', + name: 'import_otrs_endpoint', + area: 'Import::OTRS', + description: 'Defines OTRS endpoint to import users, tickets, states and articles.', + options: { + form: [ + { + display: '', + null: false, + name: 'import_otrs_endpoint', + tag: 'input', + }, + ], + }, + state: 'http://otrs_host/otrs', + frontend: false +) +Setting.create_if_not_exists( + title: 'Import Key', + name: 'import_otrs_endpoint_key', + area: 'Import::OTRS', + description: 'Defines OTRS endpoint authentication key.', + options: { + form: [ + { + display: '', + null: false, + name: 'import_otrs_endpoint_key', + tag: 'input', + }, + ], + }, + state: '', + frontend: false +) + +Setting.create_if_not_exists( + title: 'Import User for HTTP basic authentication', + name: 'import_otrs_user', + area: 'Import::OTRS', + description: 'Defines HTTP basic authentication user (only if OTRS is protected via HTTP basic auth).', + options: { + form: [ + { + display: '', + null: true, + name: 'import_otrs_user', + tag: 'input', + }, + ], + }, + state: '', + frontend: false +) + +Setting.create_if_not_exists( + title: 'Import Password for http basic authentication', + name: 'import_otrs_password', + area: 'Import::OTRS', + description: 'Defines http basic authentication password (only if OTRS is protected via http basic auth).', + options: { + form: [ + { + display: '', + null: true, + name: 'import_otrs_password', + tag: 'input', + }, + ], + }, + state: '', + frontend: false +) + +Setting.create_if_not_exists( + title: 'Import Endpoint', + name: 'import_zendesk_endpoint', + area: 'Import::Zendesk', + description: 'Defines Zendesk endpoint to import users, ticket, states and articles.', + options: { + form: [ + { + display: '', + null: false, + name: 'import_zendesk_endpoint', + tag: 'input', + }, + ], + }, + state: 'https://yours.zendesk.com/api/v2', + frontend: false +) +Setting.create_if_not_exists( + title: 'Import Key for requesting the Zendesk API', + name: 'import_zendesk_endpoint_key', + area: 'Import::Zendesk', + description: 'Defines Zendesk endpoint authentication key.', + options: { + form: [ + { + display: '', + null: false, + name: 'import_zendesk_endpoint_key', + tag: 'input', + }, + ], + }, + state: '', + frontend: false +) + +Setting.create_if_not_exists( + title: 'Import User for requesting the Zendesk API', + name: 'import_zendesk_endpoint_username', + area: 'Import::Zendesk', + description: 'Defines Zendesk endpoint authentication user.', + options: { + form: [ + { + display: '', + null: true, + name: 'import_zendesk_endpoint_username', + tag: 'input', + }, + ], + }, + state: '', + frontend: false +) + +Setting.create_if_not_exists( + title: 'Import Endpoint', + name: 'import_freshdesk_endpoint', + area: 'Import::Freshdesk', + description: 'Defines Freshdesk endpoint to import users, ticket, states and articles.', + options: { + form: [ + { + display: '', + null: false, + name: 'import_freshdesk_endpoint', + tag: 'input', + }, + ], + }, + state: 'https://yours.freshdesk.com/api/v2', + frontend: false +) +Setting.create_if_not_exists( + title: 'Import Key for requesting the Freshdesk API', + name: 'import_freshdesk_endpoint_key', + area: 'Import::Freshdesk', + description: 'Defines Freshdesk endpoint authentication key.', + options: { + form: [ + { + display: '', + null: false, + name: 'import_freshdesk_endpoint_key', + tag: 'input', + }, + ], + }, + state: '', + frontend: false +) + +Setting.create_if_not_exists( + title: 'Import Backends', + name: 'import_backends', + area: 'Import', + description: 'A list of active import backends that get scheduled automatically.', + options: {}, + state: ['Import::Ldap', 'Import::Exchange'], + preferences: { + permission: ['admin'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Sequencer log level', + name: 'sequencer_log_level', + area: 'Core', + description: 'Defines the log levels for various logging actions of the Sequencer.', + options: {}, + state: { + sequence: { + start_finish: :debug, + unit: :debug, + result: :debug, + }, + state: { + optional: :debug, + set: :debug, + get: :debug, + attribute_initialization: { + start_finish: :debug, + attributes: :debug, + }, + parameter_initialization: { + parameters: :debug, + start_finish: :debug, + unused: :debug, + }, + expectations_initialization: :debug, + cleanup: { + start_finish: :debug, + remove: :debug, + } + } + }, + frontend: false, +) + +Setting.create_if_not_exists( + title: 'Time Accounting', + name: 'time_accounting', + area: 'Web::Base', + description: 'Enable time accounting.', + options: { + form: [ + { + display: '', + null: true, + name: 'time_accounting', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + preferences: { + authentication: true, + permission: ['admin.time_accounting'], + }, + state: false, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Time Accounting Selector', + name: 'time_accounting_selector', + area: 'Web::Base', + description: 'Enable time accounting for these tickets.', + options: { + form: [ + {}, + ], + }, + preferences: { + authentication: true, + permission: ['admin.time_accounting'], + }, + state: {}, + frontend: true +) + +Setting.create_if_not_exists( + title: 'New Tags', + name: 'tag_new', + area: 'Web::Base', + description: 'Allow users to create new tags.', + options: { + form: [ + { + display: '', + null: true, + name: 'tag_new', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + preferences: { + authentication: true, + permission: ['admin.tag'], + }, + state: true, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Default calendar tickets subscriptions', + name: 'defaults_calendar_subscriptions_tickets', + area: 'Defaults::CalendarSubscriptions', + description: 'Defines the default calendar tickets subscription settings.', + options: {}, + state: { + escalation: { + own: true, + not_assigned: false, + }, + new_open: { + own: true, + not_assigned: false, + }, + pending: { + own: true, + not_assigned: false, + } + }, + preferences: { + authentication: true, + }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Defines translator identifier.', + name: 'translator_key', + area: 'i18n::translator_key', + description: 'Defines the translator identifier for contributions.', + options: {}, + state: '', + frontend: false +) + +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0005_postmaster_filter_trusted', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to remove X-Zammad headers from not trusted sources.', + options: {}, + state: 'Channel::Filter::Trusted', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0006_postmaster_filter_auto_response_check', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to identify auto responses to prevent auto replies from Zammad.', + options: {}, + state: 'Channel::Filter::AutoResponseCheck', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0007_postmaster_filter_follow_up_check', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to identify follow-ups (based on admin settings).', + options: {}, + state: 'Channel::Filter::FollowUpCheck', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0008_postmaster_filter_follow_up_merged', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to identify follow-up ticket for merged tickets.', + options: {}, + state: 'Channel::Filter::FollowUpMerged', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0011_postmaster_sender_based_on_reply_to', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to set the sender/from of emails based on reply-to header.', + options: {}, + state: 'Channel::Filter::ReplyToBasedSender', + frontend: false +) +Setting.create_if_not_exists( + title: 'Define postmaster filter.', + name: '0018_postmaster_import_archive', + area: 'Postmaster::PreFilter', + description: 'Define postmaster filter to import archive mailboxes.', + options: {}, + state: 'Channel::Filter::ImportArchive', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0012_postmaster_filter_sender_is_system_address', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to check if email has been created by Zammad itself and will set the article sender.', + options: {}, + state: 'Channel::Filter::SenderIsSystemAddress', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0014_postmaster_filter_own_notification_loop_detection', + area: 'Postmaster::PreFilter', + description: 'Define postmaster filter to check if email is a own created notification email, then ignore it to prevent email loops.', + options: {}, + state: 'Channel::Filter::OwnNotificationLoopDetection', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0015_postmaster_filter_identify_sender', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to identify sender user.', + options: {}, + state: 'Channel::Filter::IdentifySender', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0016_postmaster_filter_smime', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to handle secure mailing.', + options: {}, + state: 'Channel::Filter::SecureMailing', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0030_postmaster_filter_out_of_office_check', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to identify out-of-office emails for follow-up detection and keeping current ticket state.', + options: {}, + state: 'Channel::Filter::OutOfOfficeCheck', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0200_postmaster_filter_follow_up_possible_check', + area: 'Postmaster::PreFilter', + description: 'Define postmaster filter to check if follow-ups get created (based on admin settings).', + options: {}, + state: 'Channel::Filter::FollowUpPossibleCheck', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0900_postmaster_filter_bounce_follow_up_check', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to identify postmaster bounced - to handle it as follow-up of the original ticket.', + options: {}, + state: 'Channel::Filter::BounceFollowUpCheck', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0950_postmaster_filter_bounce_delivery_permanent_failed', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to identify postmaster bounced - disable sending notification on permanent deleivery failed.', + options: {}, + state: 'Channel::Filter::BounceDeliveryPermanentFailed', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0955_postmaster_filter_bounce_delivery_temporary_failed', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to identify postmaster bounced - reopen ticket on permanent temporary failed.', + options: {}, + state: 'Channel::Filter::BounceDeliveryTemporaryFailed', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '1000_postmaster_filter_database_check', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter for filters managed via admin interface.', + options: {}, + state: 'Channel::Filter::Database', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '5000_postmaster_filter_icinga', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to manage Icinga (http://www.icinga.org) emails.', + options: {}, + state: 'Channel::Filter::Icinga', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '5100_postmaster_filter_nagios', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to manage Nagios (http://www.nagios.org) emails.', + options: {}, + state: 'Channel::Filter::Nagios', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '5300_postmaster_filter_monit', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to manage Monit (https://mmonit.com/monit/) emails.', + options: {}, + state: 'Channel::Filter::Monit', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '5400_postmaster_filter_service_now_check', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to identify service now mails for correct follow-ups.', + options: {}, + state: 'Channel::Filter::ServiceNowCheck', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '5401_postmaster_filter_service_now_check', + area: 'Postmaster::PostFilter', + description: 'Defines postmaster filter to identify service now mails for correct follow-ups.', + options: {}, + state: 'Channel::Filter::ServiceNowCheck', + frontend: false +) +Setting.create_if_not_exists( + title: 'Define postmaster filter.', + name: '5500_postmaster_internal_article_check', + area: 'Postmaster::PreFilter', + description: 'Defines the postmaster filter which set the article internal if a forwarded, replied or sent email also exists with the article internal received.', + options: {}, + state: 'Channel::Filter::InternalArticleCheck', + frontend: false +) +Setting.create_if_not_exists( + title: 'Icinga integration', + name: 'icinga_integration', + area: 'Integration::Switch', + description: 'Defines if Icinga (http://www.icinga.org) is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'icinga_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Sender', + name: 'icinga_sender', + area: 'Integration::Icinga', + description: 'Defines the sender email address of Icinga emails.', + options: { + form: [ + { + display: '', + null: false, + name: 'icinga_sender', + tag: 'input', + placeholder: 'icinga@monitoring.example.com', + }, + ], + }, + state: 'icinga@monitoring.example.com', + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'Auto close', + name: 'icinga_auto_close', + area: 'Integration::Icinga', + description: 'Defines if tickets should be closed if service is recovered.', + options: { + form: [ + { + display: '', + null: true, + name: 'icinga_auto_close', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + prio: 3, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Auto close state', + name: 'icinga_auto_close_state_id', + area: 'Integration::Icinga', + description: 'Defines the state of auto closed tickets.', + options: { + form: [ + { + display: '', + null: false, + name: 'icinga_auto_close_state_id', + tag: 'select', + relation: 'TicketState', + }, + ], + }, + state: 4, + preferences: { + prio: 4, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Nagios integration', + name: 'nagios_integration', + area: 'Integration::Switch', + description: 'Defines if Nagios (http://www.nagios.org) is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'nagios_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Sender', + name: 'nagios_sender', + area: 'Integration::Nagios', + description: 'Defines the sender email address of Nagios emails.', + options: { + form: [ + { + display: '', + null: false, + name: 'nagios_sender', + tag: 'input', + placeholder: 'nagios@monitoring.example.com', + }, + ], + }, + state: 'nagios@monitoring.example.com', + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'Auto close', + name: 'nagios_auto_close', + area: 'Integration::Nagios', + description: 'Defines if tickets should be closed if service is recovered.', + options: { + form: [ + { + display: '', + null: true, + name: 'nagios_auto_close', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + prio: 3, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Auto close state', + name: 'nagios_auto_close_state_id', + area: 'Integration::Nagios', + description: 'Defines the state of auto closed tickets.', + options: { + form: [ + { + display: '', + null: false, + name: 'nagios_auto_close_state_id', + tag: 'select', + relation: 'TicketState', + }, + ], + }, + state: 4, + preferences: { + prio: 4, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Checkmk integration', + name: 'check_mk_integration', + area: 'Integration::Switch', + description: 'Defines if Checkmk (https://checkmk.com/) is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'check_mk_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Group', + name: 'check_mk_group_id', + area: 'Integration::CheckMK', + description: 'Defines the group of created tickets.', + options: { + form: [ + { + display: '', + null: false, + name: 'check_mk_group_id', + tag: 'select', + relation: 'Group', + }, + ], + }, + state: 1, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Auto close', + name: 'check_mk_auto_close', + area: 'Integration::CheckMK', + description: 'Defines if tickets should be closed if service is recovered.', + options: { + form: [ + { + display: '', + null: true, + name: 'check_mk_auto_close', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + prio: 3, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Auto close state', + name: 'check_mk_auto_close_state_id', + area: 'Integration::CheckMK', + description: 'Defines the state of auto closed tickets.', + options: { + form: [ + { + display: '', + null: false, + name: 'check_mk_auto_close_state_id', + tag: 'select', + relation: 'TicketState', + }, + ], + }, + state: 4, + preferences: { + prio: 4, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Checkmk token', + name: 'check_mk_token', + area: 'Core', + description: 'Defines the Checkmk token for allowing updates.', + options: {}, + state: ENV['CHECK_MK_TOKEN'] || SecureRandom.hex(16), + preferences: { + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Monit integration', + name: 'monit_integration', + area: 'Integration::Switch', + description: 'Defines if Monit (https://mmonit.com/monit/) is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'monit_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Sender', + name: 'monit_sender', + area: 'Integration::Monit', + description: 'Defines the sender email address of the service emails.', + options: { + form: [ + { + display: '', + null: false, + name: 'monit_sender', + tag: 'input', + placeholder: 'monit@monitoring.example.com', + }, + ], + }, + state: 'monit@monitoring.example.com', + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'Auto close', + name: 'monit_auto_close', + area: 'Integration::Monit', + description: 'Defines if tickets should be closed if service is recovered.', + options: { + form: [ + { + display: '', + null: true, + name: 'monit_auto_close', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + translate: true, + }, + ], + }, + state: true, + preferences: { + prio: 3, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Auto close state', + name: 'monit_auto_close_state_id', + area: 'Integration::Monit', + description: 'Defines the state of auto closed tickets.', + options: { + form: [ + { + display: '', + null: false, + name: 'monit_auto_close_state_id', + tag: 'select', + relation: 'TicketState', + translate: true, + }, + ], + }, + state: 4, + preferences: { + prio: 4, + permission: ['admin.integration'], + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'LDAP integration', + name: 'ldap_integration', + area: 'Integration::Switch', + description: 'Defines if LDAP is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'ldap_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + authentication: true, + permission: ['admin.integration'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Exchange config', + name: 'exchange_config', + area: 'Integration::Exchange', + description: 'Defines the Exchange config.', + options: {}, + state: {}, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'Exchange integration', + name: 'exchange_integration', + area: 'Integration::Switch', + description: 'Defines if Exchange is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'exchange_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + authentication: true, + permission: ['admin.integration'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'LDAP config', + name: 'ldap_config', + area: 'Integration::LDAP', + description: 'Defines the LDAP config.', + options: {}, + state: {}, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'i-doit integration', + name: 'idoit_integration', + area: 'Integration::Switch', + description: 'Defines if i-doit (http://www.i-doit) is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'idoit_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + authentication: true, + permission: ['admin.integration'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'i-doit config', + name: 'idoit_config', + area: 'Integration::Idoit', + description: 'Defines the i-doit config.', + options: {}, + state: {}, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'GitLab integration', + name: 'gitlab_integration', + area: 'Integration::Switch', + description: 'Defines if the GitLab (http://www.gitlab.com) integration is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'gitlab_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + authentication: true, + permission: ['admin.integration'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'GitLab config', + name: 'gitlab_config', + area: 'Integration::GitLab', + description: 'Stores the GitLab configuration.', + options: {}, + state: { + endpoint: 'https://gitlab.com/api/graphql', + }, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'GitHub integration', + name: 'github_integration', + area: 'Integration::Switch', + description: 'Defines if the GitHub (http://www.github.com) integration is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'github_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + authentication: true, + permission: ['admin.integration'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'GitHub config', + name: 'github_config', + area: 'Integration::GitHub', + description: 'Stores the GitHub configuration.', + options: {}, + state: { + endpoint: 'https://api.github.com/graphql', + }, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'Defines sync transaction backend.', + name: '0100_trigger', + area: 'Transaction::Backend::Sync', + description: 'Defines the transaction backend to execute triggers.', + options: {}, + state: 'Transaction::Trigger', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines transaction backend.', + name: '0100_notification', + area: 'Transaction::Backend::Async', + description: 'Defines the transaction backend to send agent notifications.', + options: {}, + state: 'Transaction::Notification', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines transaction backend.', + name: '1000_signature_detection', + area: 'Transaction::Backend::Async', + description: 'Defines the transaction backend to detect customer signatures in emails.', + options: {}, + state: 'Transaction::SignatureDetection', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines transaction backend.', + name: '6000_slack_webhook', + area: 'Transaction::Backend::Async', + description: 'Defines the transaction backend which posts messages to Slack (http://www.slack.com).', + options: {}, + state: 'Transaction::Slack', + frontend: false +) +Setting.create_if_not_exists( + title: 'Slack integration', + name: 'slack_integration', + area: 'Integration::Switch', + description: 'Defines if Slack (http://www.slack.org) is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'slack_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Slack config', + name: 'slack_config', + area: 'Integration::Slack', + description: 'Defines the slack config.', + options: {}, + state: { + items: [] + }, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'sipgate.io integration', + name: 'sipgate_integration', + area: 'Integration::Switch', + description: 'Defines if sipgate.io (http://www.sipgate.io) is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'sipgate_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + trigger: ['menu:render', 'cti:reload'], + authentication: true, + permission: ['admin.integration'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'sipgate.io config', + name: 'sipgate_config', + area: 'Integration::Sipgate', + description: 'Defines the sipgate.io config.', + options: {}, + state: { 'outbound' => { 'routing_table' => [], 'default_caller_id' => '' }, 'inbound' => { 'block_caller_ids' => [] } }, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'sipgate.io alternative fqdn', + name: 'sipgate_alternative_fqdn', + area: 'Integration::Sipgate::Expert', + description: 'Alternative FQDN for callbacks if you operate Zammad in internal network.', + options: { + form: [ + { + display: '', + null: false, + name: 'sipgate_alternative_fqdn', + tag: 'input', + }, + ], + }, + state: '', + preferences: { + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'cti integration', + name: 'cti_integration', + area: 'Integration::Switch', + description: 'Defines if generic CTI is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'cti_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + trigger: ['menu:render', 'cti:reload'], + authentication: true, + permission: ['admin.integration'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'cti config', + name: 'cti_config', + area: 'Integration::Cti', + description: 'Defines the cti config.', + options: {}, + state: { 'outbound' => { 'routing_table' => [], 'default_caller_id' => '' }, 'inbound' => { 'block_caller_ids' => [] } }, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'CTI Token', + name: 'cti_token', + area: 'Integration::Cti', + description: 'Token for cti.', + options: { + form: [ + { + display: '', + null: false, + name: 'cti_token', + tag: 'input', + }, + ], + }, + state: ENV['CTI_TOKEN'] || SecureRandom.urlsafe_base64(20), + preferences: { + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'cti customer last activity', + name: 'cti_customer_last_activity', + area: 'Integration::Cti', + description: 'Defines the range in seconds of customer activity to trigger the user profile dialog on call.', + options: {}, + state: 30.days, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'Placetel integration', + name: 'placetel_integration', + area: 'Integration::Switch', + description: 'Defines if Placetel (http://www.placetel.de) is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'placetel_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + trigger: ['menu:render', 'cti:reload'], + authentication: true, + permission: ['admin.integration'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Placetel config', + name: 'placetel_config', + area: 'Integration::Placetel', + description: 'Defines the Placetel config.', + options: {}, + state: { 'outbound' => { 'routing_table' => [], 'default_caller_id' => '' }, 'inbound' => { 'block_caller_ids' => [] } }, + preferences: { + prio: 2, + permission: ['admin.integration'], + cache: ['placetelGetVoipUsers'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'PLACETEL Token', + name: 'placetel_token', + area: 'Integration::Placetel', + description: 'Token for Placetel.', + options: { + form: [ + { + display: '', + null: false, + name: 'placetel_token', + tag: 'input', + }, + ], + }, + state: ENV['PLACETEL_TOKEN'] || SecureRandom.urlsafe_base64(20), + preferences: { + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Clearbit integration', + name: 'clearbit_integration', + area: 'Integration::Switch', + description: 'Defines if Clearbit (http://www.clearbit.com) is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'clearbit_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Clearbit config', + name: 'clearbit_config', + area: 'Integration::Clearbit', + description: 'Defines the Clearbit config.', + options: {}, + state: {}, + frontend: false, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, +) +Setting.create_if_not_exists( + title: 'Defines transaction backend.', + name: '9000_clearbit_enrichment', + area: 'Transaction::Backend::Async', + description: 'Defines the transaction backend which will enrich customer and organization information from Clearbit (http://www.clearbit.com).', + options: {}, + state: 'Transaction::ClearbitEnrichment', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines transaction backend.', + name: '9100_cti_caller_id_detection', + area: 'Transaction::Backend::Async', + description: 'Defines the transaction backend which detects caller IDs in objects and store them for CTI lookups.', + options: {}, + state: 'Transaction::CtiCallerIdDetection', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines transaction backend.', + name: '9200_karma', + area: 'Transaction::Backend::Async', + description: 'Defines the transaction backend which creates the karma score.', + options: {}, + state: 'Transaction::Karma', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines karma levels.', + name: 'karma_levels', + area: 'Core::Karma', + description: 'Defines the karma levels.', + options: {}, + state: [ + { + name: 'Beginner', + start: 0, + end: 499, + }, + { + name: 'Newbie', + start: 500, + end: 1999, + }, + { + name: 'Intermediate', + start: 2000, + end: 4999, + }, + { + name: 'Professional', + start: 5000, + end: 6999, + }, + { + name: 'Expert', + start: 7000, + end: 8999, + }, + { + name: 'Master', + start: 9000, + end: 18_999, + }, + { + name: 'Evangelist', + start: 19_000, + end: 49_999, + }, + { + name: 'Hero', + start: 50_000, + end: nil, + }, + ], + frontend: false +) +Setting.create_if_not_exists( + title: 'Set limit of agents', + name: 'system_agent_limit', + area: 'Core::Online', + description: 'Defines the limit of the agents.', + options: {}, + state: false, + preferences: { online_service_disable: true }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'HTML Email CSS Font', + name: 'html_email_css_font', + area: 'Core', + description: 'Defines the CSS font information for HTML Emails.', + options: {}, + state: "font-family:'Helvetica Neue', Helvetica, Arial, Geneva, sans-serif; font-size: 12px;", + preferences: { + permission: ['admin'], + }, + frontend: false +) + +# add the dashboard stats backend for 'Stats::TicketWaitingTime' +Setting.create_if_not_exists( + title: 'Stats Backend', + name: 'Stats::TicketWaitingTime', + area: 'Dashboard::Stats', + description: 'Defines a dashboard stats backend that get scheduled automatically.', + options: {}, + state: 'Stats::TicketWaitingTime', + preferences: { + permission: ['ticket.agent'], + prio: 1, + }, + frontend: false +) + +# add the dashboard stats backend for 'Stats::TicketEscalation' +Setting.create_if_not_exists( + title: 'Stats Backend', + name: 'Stats::TicketEscalation', + area: 'Dashboard::Stats', + description: 'Defines a dashboard stats backend that get scheduled automatically.', + options: {}, + state: 'Stats::TicketEscalation', + preferences: { + permission: ['ticket.agent'], + prio: 2, + }, + frontend: false +) + +# add the dashboard stats backend for 'Stats::TicketChannelDistribution' +Setting.create_if_not_exists( + title: 'Stats Backend', + name: 'Stats::TicketChannelDistribution', + area: 'Dashboard::Stats', + description: 'Defines a dashboard stats backend that get scheduled automatically.', + options: {}, + state: 'Stats::TicketChannelDistribution', + preferences: { + permission: ['ticket.agent'], + prio: 3, + }, + frontend: false +) + +# add the dashboard stats backend for 'Stats::TicketLoadMeasure' +Setting.create_if_not_exists( + title: 'Stats Backend', + name: 'Stats::TicketLoadMeasure', + area: 'Dashboard::Stats', + description: 'Defines a dashboard stats backend that get scheduled automatically.', + options: {}, + state: 'Stats::TicketLoadMeasure', + preferences: { + permission: ['ticket.agent'], + prio: 4, + }, + frontend: false +) + +# add the dashboard stats backend for 'Stats::TicketInProcess' +Setting.create_if_not_exists( + title: 'Stats Backend', + name: 'Stats::TicketInProcess', + area: 'Dashboard::Stats', + description: 'Defines a dashboard stats backend that get scheduled automatically.', + options: {}, + state: 'Stats::TicketInProcess', + preferences: { + permission: ['ticket.agent'], + prio: 5, + }, + frontend: false +) + +# add the dashboard stats backend for 'Stats::TicketReopen' +Setting.create_if_not_exists( + title: 'Stats Backend', + name: 'Stats::TicketReopen', + area: 'Dashboard::Stats', + description: 'Defines a dashboard stats backend that get scheduled automatically.', + options: {}, + state: 'Stats::TicketReopen', + preferences: { + permission: ['ticket.agent'], + prio: 6, + }, + frontend: false +) + +Setting.create_if_not_exists( + title: 'Kb multi-lingual support', + name: 'kb_multi_lingual_support', + area: 'Kb::Core', + description: 'Support of multi-lingual Knowledge Base.', + options: {}, + state: true, + preferences: { online_service_disable: true }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Kb active', + name: 'kb_active', + area: 'Kb::Core', + description: 'Defines if KB navbar button is enabled', + state: true, + preferences: { + prio: 1, + trigger: ['menu:render'], + authentication: true, + permission: ['admin.knowledge_base'], + }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Kb active publicly', + name: 'kb_active_publicly', + area: 'Kb::Core', + description: 'Defines if KB navbar button is enabled for users without KB permission', + state: false, + preferences: { + prio: 1, + trigger: ['menu:render'], + authentication: true, + permission: [], + }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'Define timeframe where a own created note can get deleted.', + name: 'ui_ticket_zoom_article_delete_timeframe', + area: 'UI::TicketZoomArticle', + description: "Set timeframe in seconds. If it's set to 0 you can delete notes without time limits", + options: {}, + state: 600, + preferences: { + permission: ['admin.ui'] + }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'S/MIME integration', + name: 'smime_integration', + area: 'Integration::Switch', + description: 'Defines if S/MIME encryption is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'smime_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + authentication: true, + permission: ['admin.integration'], + }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'S/MIME config', + name: 'smime_config', + area: 'Integration::SMIME', + description: 'Defines the S/MIME config.', + options: {}, + state: {}, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: true, +) + +Setting.create_if_not_exists( + title: 'PGP integration', + name: 'pgp_integration', + area: 'Integration::Switch', + description: 'Defines if PGP encryption is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'pgp_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + authentication: true, + permission: ['admin.integration'], + }, + frontend: true +) + +Setting.create_if_not_exists( + title: 'PGP config', + name: 'pgp_config', + area: 'Integration::PGP', + description: 'Defines the PGP config.', + options: {}, + state: {}, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: true, +) + +Setting.create_if_not_exists( + title: 'Authentication via %s', + name: 'auth_sso', + area: 'Security::ThirdPartyAuthentication', + description: 'Enables button for user authentication via %s. The button will redirect to /auth/sso on user interaction.', + options: { + form: [ + { + display: '', + null: true, + name: 'auth_sso', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + preferences: { + controller: 'SettingsAreaSwitch', + sub: {}, + title_i18n: ['SSO'], + description_i18n: ['SSO', 'Button for Single Sign On.'], + permission: ['admin.security'], + }, + state: false, + frontend: true +) diff --git a/zammad-addon-pgp/src/lib/secure_mailing/pgp.rb b/zammad-addon-pgp/src/lib/secure_mailing/pgp.rb new file mode 100644 index 0000000..d6c512c --- /dev/null +++ b/zammad-addon-pgp/src/lib/secure_mailing/pgp.rb @@ -0,0 +1,7 @@ +# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ + +class SecureMailing::PGP < SecureMailing::Backend + def self.active? + Setting.get('pgp_integration') + end +end diff --git a/zammad-addon-pgp/src/lib/secure_mailing/pgp/incoming.rb b/zammad-addon-pgp/src/lib/secure_mailing/pgp/incoming.rb new file mode 100644 index 0000000..d888be9 --- /dev/null +++ b/zammad-addon-pgp/src/lib/secure_mailing/pgp/incoming.rb @@ -0,0 +1,170 @@ +# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ + +class SecureMailing::PGP::Incoming < SecureMailing::Backend::Handler + attr_accessor :mail, :content_type + + EXPRESSION_ENCRYPTED = %r{application/pgp-encrypted}i.freeze + EXPRESSION_SIGNATURE = %r{application/pgp-signature}i.freeze + + def initialize(mail) + super() + + @mail = mail + @content_type = mail[:mail_instance].content_type + end + + def process + return unless process? + + initialize_article_preferences + decrypt + verify_signature + log + end + + def initialize_article_preferences + article_preferences[:security] = { + type: 'PGP', + sign: { + success: false, + comment: nil + }, + encryption: { + success: false, + comment: nil + } + } + end + + def article_preferences + @article_preferences ||= begin + key = :'x-zammad-article-preferences' + mail[key] ||= {} + mail[key] + end + end + + def process? + signed? || encrypted? + end + + def signed?(check_content_type = content_type) + EXPRESSION_SIGNATURE.match?(check_content_type) + end + + def encrypted?(check_content_type = content_type) + EXPRESSION_ENCRYPTED.match?(check_content_type) + end + + def decrypt + return unless encrypted? + + success = false + comment = 'Private key for decryption could not be found.' + ::PGPKeypair.where.not(private_key: [nil, '']).find_each do |cert| + begin + index = mail[:attachments].index { |file| file[:preferences]['Content-Type'] == 'application/pgp-encrypted' } + data = mail[:attachments][index + 1][:data] + decrypted_data = Sequoia.decrypt_for(ciphertext: data.chop, recipient: cert.private_key, + password: cert.private_key_secret) + rescue StandardError + next + end + + parse_new_mail(decrypted_data) + + success = true + comment = cert.email_addresses.join(', ') + + # overwrite content_type for signature checking + @content_type = mail[:mail_instance].content_type + break + end + + article_preferences[:security][:encryption] = { + success: success, + comment: comment + } + end + + def verify_signature + return unless signed? + + success = false + comment = 'Certificate for verification could not be found.' + + ::PGPKeypair.where.not(public_key: [nil, '']).find_each do |cert| + next unless cert.email_addresses.include? mail[:from_email] + + begin + index = mail[:attachments].index { |file| file[:preferences]['Mime-Type'] == 'application/pgp-signature' } + data = mail[:attachments][index][:data] + verified_data = Sequoia.verify_detached_from(plaintext: mail[:mail_instance].body.encoded, signature: data.chop, + sender: cert.public_key) + rescue StandardError + next + end + + parse_new_mail(verified_data) + + success = true + comment = cert.email_addresses.join(', ') + + # overwrite content_type for signature checking + @content_type = mail[:mail_instance].content_type + break + end + + article_preferences[:security][:sign] = { + success: success, + comment: comment + } + end + + private + + def log + %i[sign encryption].each do |action| + result = article_preferences[:security][action] + next if result.blank? + + if result[:success] + status = 'success' + elsif result[:comment].blank? + # means not performed + next + else + status = 'failed' + end + + HttpLog.create( + direction: 'in', + facility: 'PGP', + url: "#{mail[:from_email]} -> #{mail[:to]}", + status: status, + ip: nil, + request: { + message_id: mail[:message_id] + }, + response: article_preferences[:security], + method: action, + created_by_id: 1, + updated_by_id: 1 + ) + end + end + + def parse_new_mail(new_mail) + mail[:mail_instance].header['Content-Type'] = nil + mail[:mail_instance].header['Content-Disposition'] = nil + mail[:mail_instance].header['Content-Transfer-Encoding'] = nil + mail[:mail_instance].header['Content-Description'] = nil + + new_raw_mail = "#{mail[:mail_instance].header}#{new_mail}" + + mail_new = Channel::EmailParser.new.parse(new_raw_mail) + mail_new.each do |local_key, local_value| + mail[local_key] = local_value + end + end +end diff --git a/zammad-addon-pgp/src/lib/secure_mailing/pgp/outgoing.rb b/zammad-addon-pgp/src/lib/secure_mailing/pgp/outgoing.rb new file mode 100644 index 0000000..6e883a9 --- /dev/null +++ b/zammad-addon-pgp/src/lib/secure_mailing/pgp/outgoing.rb @@ -0,0 +1,120 @@ +# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ + +class SecureMailing::PGP::Outgoing < SecureMailing::Backend::Handler + def initialize(mail, security) + super() + + @mail = mail + @security = security + end + + def process + return unless process? + + if @security[:sign][:success] + sign + log('sign', 'success') + end + if @security[:encryption][:success] + encrypt + log('encryption', 'success') + end + end + + def process? + return false if @security.blank? + return false if @security[:type] != 'PGP' + + @security[:sign][:success] || @security[:encryption][:success] + end + + def cleanup(mail) + part = Mail::Part.new + if mail.multipart? + if mail.content_type =~ /^(multipart[^;]+)/ + part.content_type Regexp.last_match(1) + else + part.content_type 'multipart/mixed' + end + mail.body.parts.each do |p| + part.add_part cleanup(p) + end + else + # retain important headers if present + part.content_type mail.content_type + part.content_id mail.header['Content-ID'] if mail.header['Content-ID'] + part.content_disposition mail.content_disposition if mail.content_disposition + + # force base64 encoding + part.body Mail::Encodings::Base64.encode(mail.body.to_s) + part.body.encoding = 'base64' + end + part + end + + def sign + from = @mail.from.first + cert = PGPKeypair.for_sender_email_address(from) + raise "Unable to find PGP private key for '#{from}'" unless cert + + signature = Sequoia.sign_detached_with(plaintext: @mail.body.encoded, sender: cert.private_key, + password: cert.private_key_secret) + + signature_part = Mail::Part.new do + content_type 'application/pgp-signature; name="signature.asc"' + content_disposition 'attachment; filename="signature.asc"' + content_description 'OpenPGP signature' + body signature + end + @mail.add_part signature_part + @mail.content_type "multipart/signed; protocol=\"application/pgp-signature\"; micalg=\"pgp-sha512\"; boundary=\"#{@mail.boundary}\"" + rescue StandardError => e + log('sign', 'failed', e.message) + raise + end + + def encrypt + recipients = [] + recipients += @mail.to if @mail.to + recipients += @mail.cc if @mail.cc + recipients += @mail.bcc if @mail.bcc + + certificates = PGPKeypair.for_recipient_email_addresses!(recipients) + + encrypted_control = Mail::Part.new do + content_type 'application/pgp-encrypted' + content_description 'OpenPGP version' + body 'Version: 1' + end + + plaintext = @mail.encoded + encrypted_part = Mail::Part.new do + content_type 'application/octet-stream; name="encrypted.asc"' + content_disposition 'inline; filename="encrypted.asc"' + content_description 'OpenPGP encrypted message' + body Sequoia.encrypt_for(plaintext: plaintext, recipients: certificates.map(&:public_key)) + end + @mail.body = nil + @mail.add_part encrypted_control + @mail.add_part encrypted_part + @mail.content_type "multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"#{@mail.boundary}\"" + rescue StandardError => e + log('encryption', 'failed', e.message) + raise + end + + def log(action, status, error = nil) + HttpLog.create( + direction: 'out', + facility: 'PGP', + url: "#{@mail[:from_email]} -> #{@mail[:to]}", + status: status, + ip: nil, + request: @security, + response: { error: error }, + method: action, + created_by_id: 1, + updated_by_id: 1 + ) + end +end diff --git a/zammad-addon-pgp/src/lib/secure_mailing/pgp/retry.rb b/zammad-addon-pgp/src/lib/secure_mailing/pgp/retry.rb new file mode 100644 index 0000000..3e2ae35 --- /dev/null +++ b/zammad-addon-pgp/src/lib/secure_mailing/pgp/retry.rb @@ -0,0 +1,93 @@ +# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ + +class SecureMailing::PGP::Retry < SecureMailing::Backend::Handler + def initialize(article) + super() + @article = article + end + + def process + return existing_result if already_processed? + + save_result if retry_succeeded? + retry_result + end + + def signature_checked? + @signature_checked ||= existing_result&.dig('sign', 'success') || false + end + + def decrypted? + @decrypted ||= existing_result&.dig('encryption', 'success') || false + end + + def already_processed? + signature_checked? && decrypted? + end + + def existing_result + @article.preferences['security'] + end + + def mail + @mail ||= begin + raw_mail = @article.as_raw.store_file.content + Channel::EmailParser.new.parse(raw_mail).tap do |parsed| + SecureMailing.incoming(parsed) + end + end + end + + def retry_result + @retry_result ||= mail['x-zammad-article-preferences']['security'] + end + + def signature_found? + return false if signature_checked? + + retry_result['sign']['success'] + end + + def decryption_succeeded? + return false if decrypted? + + retry_result['encryption']['success'] + end + + def retry_succeeded? + return true if signature_found? + + decryption_succeeded? + end + + def save_result + save_decrypted if decryption_succeeded? + @article.preferences['security'] = retry_result + @article.save! + end + + def save_decrypted + @article.content_type = mail['content_type'] + @article.body = mail['body'] + + Store.remove( + object: 'Ticket::Article', + o_id: @article.id + ) + + mail[:attachments]&.each do |attachment| + filename = attachment[:filename].force_encoding('utf-8') + unless filename.force_encoding('UTF-8').valid_encoding? + filename = filename.utf8_encode(fallback: :read_as_sanitized_binary) + end + Store.add( + object: 'Ticket::Article', + o_id: @article.id, + data: attachment[:data], + filename: filename, + preferences: attachment[:preferences], + created_by_id: @article.created_by_id + ) + end + end +end diff --git a/zammad-addon-pgp/src/public/assets/images/icons/pgp.svg b/zammad-addon-pgp/src/public/assets/images/icons/pgp.svg new file mode 100644 index 0000000..a894cce --- /dev/null +++ b/zammad-addon-pgp/src/public/assets/images/icons/pgp.svg @@ -0,0 +1 @@ + diff --git a/zammad-addon-pgp/zammad-addon-pgp.szpm.template b/zammad-addon-pgp/zammad-addon-pgp.szpm.template new file mode 100644 index 0000000..dc1381b --- /dev/null +++ b/zammad-addon-pgp/zammad-addon-pgp.szpm.template @@ -0,0 +1,24 @@ +{ + "name": "PGPSupport", + "version": "0.0.1", + "vendor": "Center for Digital Resilience", + "license": "AGPL-v3+", + "url": "https://gitlab.com/digiresilience/link/zammad-addon-pgp", + "buildhost": "", + "builddate": "", + "change_log": [ + { + "version": "0.0.1", + "date": "", + "log": "Initial version." + } + ], + "description": [ + { + "language": "en", + "text": "An addon for Zammad to include PGP integration." + } + ], + "files": [ + ] +} diff --git a/zammad/.dockerignore b/zammad/.dockerignore new file mode 100644 index 0000000..e166442 --- /dev/null +++ b/zammad/.dockerignore @@ -0,0 +1 @@ +.gitkeep diff --git a/zammad/.gitignore b/zammad/.gitignore new file mode 100644 index 0000000..b79e25a --- /dev/null +++ b/zammad/.gitignore @@ -0,0 +1,4 @@ +docker-compose-test.yml +*.zpm +*.zip +addons diff --git a/zammad/.gitlab-ci.yml b/zammad/.gitlab-ci.yml new file mode 100644 index 0000000..a498562 --- /dev/null +++ b/zammad/.gitlab-ci.yml @@ -0,0 +1,35 @@ +--- +image: docker:git +services: + - docker:dind + +stages: + - build + - release + - trigger + +before_script: + - apk add --update make curl unzip findutils + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - export CI_JOB_TIMESTAMP=$(date --utc +"%Y-%m-%d") + +build: + stage: build + variables: + DOCKER_NS: $CI_REGISTRY_IMAGE + DOCKER_TAG: ${CI_COMMIT_SHORT_SHA} + script: + - make build-push + +release: + stage: release + variables: + DOCKER_NS: $CI_REGISTRY_IMAGE + DOCKER_TAG: ${CI_COMMIT_SHORT_SHA} + DOCKER_TAG_NEW: ${CI_COMMIT_REF_NAME} + only: + - main + - develop + script: + - make add-tag + diff --git a/zammad/Dockerfile b/zammad/Dockerfile new file mode 100644 index 0000000..92e9762 --- /dev/null +++ b/zammad/Dockerfile @@ -0,0 +1,124 @@ +FROM ruby:2.6.8-slim-bullseye AS builder + +LABEL maintainer="Abel Luck " +ARG DEBIAN_FRONTEND=noninteractive +ENV GOSU_VERSION 1.11 +COPY keys.asc /tmp/keys.asc +RUN set -ex; \ + apt-get update; \ + apt-get install -y --no-install-recommends gnupg2 dirmngr build-essential curl git libimlib2-dev libpq-dev patch shared-mime-info nodejs libclang-dev clang llvm pkg-config nettle-dev rustc cargo libmariadb-dev; \ + gpg2 --import /tmp/keys.asc ; \ + rm /tmp/keys.asc ; \ + gpgconf --kill all ; \ + rm -rf /var/lib/apt/lists/* ; \ + curl -s -J -L -o /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-$(dpkg --print-architecture)" ; \ + curl -s -J -L -o /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-$(dpkg --print-architecture).asc" ; \ + gpg2 --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu ; \ + rm /usr/local/bin/gosu.asc ; \ + chmod +x /usr/local/bin/gosu ; \ + gosu nobody true + +COPY package-auto-reinstall.patch /tmp/package-auto-reinstall.patch +COPY fetch_locales.rb /tmp/fetch_locales.rb + +ARG SEQUOIA_PROJECT_URL=https://gitlab.com/sequoia-pgp/sequoia-ffi.git +ARG SEQUOIA_GIT_TAG=main +ENV SEQUOIA_DIR=/usr/lib/sequoia +ENV LD_LIBRARY_PATH=${SEQUOIA_DIR}/target/debug +ARG ZAMMAD_PROJECT_URL=https://github.com/zammad/zammad.git +ARG ZAMMAD_GIT_TAG=develop +ENV ZAMMAD_TMP_DIR /tmp/zammad-${ZAMMAD_GIT_TAG} + +ENV ZAMMAD_DIR /opt/zammad +ENV ZAMMAD_USER zammad +ENV RAILS_ENV production + +RUN set -ex; \ + groupadd -g 1000 "${ZAMMAD_USER}"; \ + useradd -M -d "${ZAMMAD_DIR}" -s /bin/bash -u 1000 -g 1000 "${ZAMMAD_USER}" ; \ + git clone -b "${SEQUOIA_GIT_TAG}" --single-branch --depth 1 "${SEQUOIA_PROJECT_URL}" "${SEQUOIA_DIR}" ; \ + cd "${SEQUOIA_DIR}" && cargo build -p sequoia-openpgp-ffi ; \ + git clone -b "${ZAMMAD_GIT_TAG}" --single-branch --depth 1 "${ZAMMAD_PROJECT_URL}" "${ZAMMAD_TMP_DIR}" ; \ + cd ${ZAMMAD_TMP_DIR}; \ + echo "gem 'ruby_openpgp', git: 'https://github.com/throneless-tech/ruby_openpgp', branch: 'signing-and-userids'" >> Gemfile.local ; \ + echo "gem 'rails-observers'" >> Gemfile.local ; \ + bundle update tcr; \ + bundle install --without test development mysql ; \ + /tmp/fetch_locales.rb ; \ + sed -e 's#.*adapter: postgresql# adapter: nulldb#g' -e 's#.*username:.*# username: postgres#g' -e 's#.*password:.*# password: \n host: zammad-postgresql\n#g' < contrib/packager.io/database.yml.pkgr > config/database.yml ; \ + sed -i "/require 'rails\/all'/a require\ 'nulldb'" config/application.rb ; \ + sed -i 's/.*scheduler_\(err\|out\).log.*//g' script/scheduler.rb ; \ + touch db/schema.rb ; \ + bundle exec rake assets:precompile ; \ + chown -R "${ZAMMAD_USER}":"${ZAMMAD_USER}" "${ZAMMAD_TMP_DIR}" + +COPY auto_install "${ZAMMAD_TMP_DIR}"/auto_install + +FROM ruby:2.6.8-slim-bullseye + +LABEL maintainer="Abel Luck " +ARG BUILD_DATE +ARG DEBIAN_FRONTEND=noninteractive + +LABEL org.label-schema.build-date="$BUILD_DATE" \ + org.label-schema.name="Zammad" \ + org.label-schema.license="AGPL-3.0" \ + org.label-schema.description="Docker container for Zammad - Data Container" \ + org.label-schema.url="https://zammad.org" \ + org.label-schema.vcs-url="https://github.com/zammad/zammad" \ + org.label-schema.vcs-type="Git" \ + org.label-schema.vendor="Zammad" \ + org.label-schema.schema-version="2.9.0" \ + org.label-schema.docker.cmd="sysctl -w vm.max_map_count=262144;docker-compose up" + + +ARG ZAMMAD_GIT_TAG=develop +ENV RAILS_ENV production +ENV SEQUOIA_DIR=/usr/lib/sequoia +ENV LD_LIBRARY_PATH=${SEQUOIA_DIR}/target/debug +ENV ZAMMAD_DIR /opt/zammad +ENV ZAMMAD_READY_FILE ${ZAMMAD_DIR}/tmp/zammad.ready +ENV ZAMMAD_TMP_DIR /tmp/zammad-${ZAMMAD_GIT_TAG} +ENV ZAMMAD_USER zammad + +RUN set -ex; \ + apt-get update; \ + apt-get install -y --no-install-recommends curl libimlib2 libimlib2-dev libpq5 nginx rsync clang llvm pkg-config; \ + rm -rf /var/lib/apt/lists/* + +RUN set -ex; \ + groupadd -g 1000 "${ZAMMAD_USER}" ; \ + useradd -M -d "${ZAMMAD_DIR}" -s /bin/bash -u 1000 -g 1000 "${ZAMMAD_USER}" + +COPY --from=builder ${ZAMMAD_TMP_DIR} ${ZAMMAD_TMP_DIR} +COPY --from=builder ${SEQUOIA_DIR} ${SEQUOIA_DIR} +COPY --from=builder /usr/local/bin/gosu /usr/local/bin/gosu +COPY --from=builder /usr/local/bundle /usr/local/bundle + +COPY docker-entrypoint.sh / +RUN chmod +x /docker-entrypoint.sh +ENTRYPOINT ["/docker-entrypoint.sh"] + +WORKDIR ${ZAMMAD_DIR} + +ENV AUTOWIZARD_JSON= +ENV ELASTICSEARCH_HOST=zammad-elasticsearch +ENV ELASTICSEARCH_PORT=9200 +ENV ELASTICSEARCH_SCHEMA=http +ENV ELASTICSEARCH_SSL_VERIFY=true +ENV ELASTICSEARCH_PURGE=false +ENV MEMCACHED_HOST=zammad-memcached +ENV MEMCACHED_PORT=11211 +ENV POSTGRESQL_HOST=zammad-postgresql +ENV POSTGRESQL_PORT=5432 +ENV POSTGRESQL_USER=postgres +ENV POSTGRESQL_PASS= +ENV POSTGRESQL_DB=zammad_production +ENV POSTGRESQL_DB_CREATE=true +ENV ZAMMAD_RAILSSERVER_HOST=zammad-railsserver +ENV ZAMMAD_RAILSSERVER_PORT=3000 +ENV ZAMMAD_WEBSOCKET_HOST=zammad-websocket +ENV ZAMMAD_WEBSOCKET_PORT=6042 +ENV NGINX_SERVER_NAME=_ +ENV RAILS_SERVER puma +ENV RAILS_LOG_TO_STDOUT true diff --git a/zammad/LICENSE.md b/zammad/LICENSE.md new file mode 100644 index 0000000..cba6f6a --- /dev/null +++ b/zammad/LICENSE.md @@ -0,0 +1,660 @@ +### GNU AFFERO GENERAL PUBLIC LICENSE + +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +### Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains +free software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing +under this license. + +The precise terms and conditions for copying, distribution and +modification follow. + +### TERMS AND CONDITIONS + +#### 0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public +License. + +"Copyright" also means copyright-like laws that apply to other kinds +of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of +an exact copy. The resulting work is called a "modified version" of +the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user +through a computer network, with no transfer of a copy, is not +conveying. + +An interactive user interface displays "Appropriate Legal Notices" to +the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +#### 1. Source Code. + +The "source code" for a work means the preferred form of the work for +making modifications to it. "Object code" means any non-source form of +a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can +regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same +work. + +#### 2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, +without conditions so long as your license otherwise remains in force. +You may convey covered works to others for the sole purpose of having +them make modifications exclusively for you, or provide you with +facilities for running those works, provided that you comply with the +terms of this License in conveying all material for which you do not +control copyright. Those thus making or running the covered works for +you must do so exclusively on your behalf, under your direction and +control, on terms that prohibit them from making any copies of your +copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes +it unnecessary. + +#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such +circumvention is effected by exercising rights under this License with +respect to the covered work, and you disclaim any intention to limit +operation or modification of the work as a means of enforcing, against +the work's users, your or third parties' legal rights to forbid +circumvention of technological measures. + +#### 4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +#### 5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these +conditions: + +- a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. +- b) The work must carry prominent notices stating that it is + released under this License and any conditions added under + section 7. This requirement modifies the requirement in section 4 + to "keep intact all notices". +- c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +#### 6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms of +sections 4 and 5, provided that you also convey the machine-readable +Corresponding Source under the terms of this License, in one of these +ways: + +- a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. +- b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the Corresponding + Source from a network server at no charge. +- c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. +- d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. +- e) Convey the object code using peer-to-peer transmission, + provided you inform other peers where the object code and + Corresponding Source of the work are being offered to the general + public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, +family, or household purposes, or (2) anything designed or sold for +incorporation into a dwelling. In determining whether a product is a +consumer product, doubtful cases shall be resolved in favor of +coverage. For a particular product received by a particular user, +"normally used" refers to a typical or common use of that class of +product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected +to use, the product. A product is a consumer product regardless of +whether the product has substantial commercial, industrial or +non-consumer uses, unless such uses represent the only significant +mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to +install and execute modified versions of a covered work in that User +Product from a modified version of its Corresponding Source. The +information must suffice to ensure that the continued functioning of +the modified object code is in no case prevented or interfered with +solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or +updates for a work that has been modified or installed by the +recipient, or for the User Product in which it has been modified or +installed. Access to a network may be denied when the modification +itself materially and adversely affects the operation of the network +or violates the rules and protocols for communication across the +network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +#### 7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders +of that material) supplement the terms of this License with terms: + +- a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or +- b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or +- c) Prohibiting misrepresentation of the origin of that material, + or requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or +- d) Limiting the use for publicity purposes of names of licensors + or authors of the material; or +- e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or +- f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions + of it) with contractual assumptions of liability to the recipient, + for any liability that these contractual assumptions directly + impose on those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; the +above requirements apply either way. + +#### 8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your license +from a particular copyright holder is reinstated (a) provisionally, +unless and until the copyright holder explicitly and finally +terminates your license, and (b) permanently, if the copyright holder +fails to notify you of the violation by some reasonable means prior to +60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +#### 9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run +a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +#### 10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +#### 11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned +or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within the +scope of its coverage, prohibits the exercise of, or is conditioned on +the non-exercise of one or more of the rights that are specifically +granted under this License. You may not convey a covered work if you +are a party to an arrangement with a third party that is in the +business of distributing software, under which you make payment to the +third party based on the extent of your activity of conveying the +work, and under which the third party grants, to any of the parties +who would receive the covered work from you, a discriminatory patent +license (a) in connection with copies of the covered work conveyed by +you (or copies made from those copies), or (b) primarily for and in +connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +#### 12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under +this License and any other pertinent obligations, then as a +consequence you may not convey it at all. For example, if you agree to +terms that obligate you to collect a royalty for further conveying +from those to whom you convey the Program, the only way you could +satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +#### 13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your +version supports such interaction) an opportunity to receive the +Corresponding Source of your version by providing access to the +Corresponding Source from a network server at no charge, through some +standard or customary means of facilitating copying of software. This +Corresponding Source shall include the Corresponding Source for any +work covered by version 3 of the GNU General Public License that is +incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +#### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU Affero General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever +published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions +of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +#### 15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT +WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND +PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR +CORRECTION. + +#### 16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR +CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT +NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR +LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM +TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER +PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +#### 17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +### How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these +terms. + +To do so, attach the following notices to the program. It is safest to +attach them to the start of each source file to most effectively state +the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper +mail. + +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for +the specific requirements. + +You should also get your employer (if you work as a programmer) or +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. For more information on this, and how to apply and follow +the GNU AGPL, see . diff --git a/zammad/Makefile b/zammad/Makefile new file mode 100644 index 0000000..433098e --- /dev/null +++ b/zammad/Makefile @@ -0,0 +1,77 @@ +UPSTREAM_BRANCH ?= main +UPSTREAM_JOB ?= build +ADDONS := 16838842 23149108 +BUILD_DATE ?=$(shell date -u +”%Y-%m-%dT%H:%M:%SZ”) +DOCKER_ARGS ?= +PROJECT_URL ?= https://github.com/zammad/zammad.git +ZAMMAD_TAG ?= stable-4.1 +DOCKER_NS ?= registry.gitlab.com/digiresilience/link/docker-zammad +DOCKER_TAG ?= ${ZAMMAD_TAG} +DOCKER_BUILD := docker build ${DOCKER_ARGS} +DOCKER_BUILD_FRESH := ${DOCKER_BUILD} --pull --no-cache +DOCKER_BUILD_ARGS := --build-arg PROJECT_URL=${PROJECT_URL} --build-arg ZAMMAD_GIT_TAG=${ZAMMAD_TAG} --build-arg BUILD_DATE=${BUILD_DATE} +DOCKER_PUSH := docker push +DOCKER_BUILD_TAG := ${DOCKER_NS}:${DOCKER_TAG} + +.PHONY: zammad clean env + +env: + @echo + @echo + @echo Build Environment + @echo --------------------------- + @echo "DOCKER_NS=${DOCKER_NS}" + @echo "DOCKER_TAG=${DOCKER_TAG}" + @echo "DOCKER_TAG_NEW=${DOCKER_TAG_NEW}" + @echo "DOCKER_ARGS=${DOCKER_ARGS}" + @echo "UPSTREAM_BRANCH=${UPSTREAM_BRANCH}" + @echo "UPSTREAM_JOB=${UPSTREAM_JOB}" + @echo "ZAMMAD_TAG=${ZAMMAD_TAG}" + @echo "PROJECT_URL=${PROJECT_URL}" + @echo --------------------------- + @echo + @echo + +build: addons env + ${DOCKER_BUILD} ${DOCKER_BUILD_ARGS} -t ${DOCKER_BUILD_TAG} ${PWD} + +build-fresh: addons env + ${DOCKER_BUILD_FRESH} ${DOCKER_BUILD_ARGS} -t ${DOCKER_BUILD_TAG} ${PWD} + +push: + ${DOCKER_PUSH} ${DOCKER_BUILD_TAG} + +build-push: build push +build-fresh-push: build-fresh push + +add-tag: env + docker pull ${DOCKER_NS}:${DOCKER_TAG} + docker tag ${DOCKER_NS}:${DOCKER_TAG} ${DOCKER_NS}:${DOCKER_TAG_NEW} + docker push ${DOCKER_NS}:${DOCKER_TAG_NEW} + +keys: + gpg2 --batch --keyserver "ha.pool.sks-keyservers.net" --recv-keys "B42F6819007F00F88E364FD4036A9C25BF357DD4" + gpg2 --batch --keyserver "ha.pool.sks-keyservers.net" --recv-keys "9185813DDCCD789E5D4BA51B884B649C340C81F4" + gpg2 --export --armor "B42F6819007F00F88E364FD4036A9C25BF357DD4" > keys.asc + gpg2 --export --armor "9185813DDCCD789E5D4BA51B884B649C340C81F4" >> keys.asc + +$(ADDONS): + @echo Fetching addon "https://gitlab.com/api/v4/projects/$@/jobs/artifacts/${UPSTREAM_BRANCH}/download?job=${UPSTREAM_JOB}&job_token=redacted" + @curl ${ACCESS_HEADER} -s -L "https://gitlab.com/api/v4/projects/$@/jobs/artifacts/${UPSTREAM_BRANCH}/download?job=${UPSTREAM_JOB}&job_token=${CI_JOB_TOKEN}" --output $@.zip + unzip -o -d addons $@.zip + +fetch-addons: $(ADDONS) + +# note: do not use find -exec, it eats errors +install-addons: + mkdir -p auto_install + @find addons -type f -iname "*.szpm" -print0 | xargs -0 -I '{}' cp {} ./auto_install/ + @find ./auto_install/ -depth -name "*.szpm" -print0 | xargs -0 -I '{}' sh -c 'mv "$$1" "$${1%.szpm}.zpm"' _ {} + @echo + @echo + @echo "Installed addons:" + @find ./auto_install/ -iname "*zpm" + @echo + @echo + +addons: fetch-addons install-addons diff --git a/zammad/README.md b/zammad/README.md new file mode 100644 index 0000000..1e0e07f --- /dev/null +++ b/zammad/README.md @@ -0,0 +1,54 @@ +# docker-zammad + +[![pipeline status](https://gitlab.com/digiresilience/link/docker-zammad/badges/master/pipeline.svg)](https://gitlab.com/digiresilience/link/docker-zammad/-/commits/master) + +Builds Link's Zammad docker container + +[Zammad](https://github.com/zammad/zammad) is a web based open source helpdesk/customer support system. + +This project started as a fork of the official [zammad docker](https://github.com/zammad/zammad-docker-compose) project. + +It builds the [Center for Digital Resilience's version of Zammad](https://gitlab.com/digiresilience/link/zammad) + +## Developer Notes + +### Building + +### Simple + +```bash +make ZAMMAD_TAG=v3.3.0 +``` + +Creates an image `digiresilience/zammad:v3.3.0` + +### Your own tag + +Supply your own docker image tag: + +```bash +make ZAMMAD_TAG=v3.3.0 DOCKER_TAG=myspecialversion +``` + +Creates an image `digiresilience/zammad:myspecialversion` + +### You special snowflake you + +```bash +make ZAMMAD_TAG=develop DOCKER_TAG=myspecialversion PROJECT_URL=http://my/zammadrepo.git DOCKER_NS=batman +``` + +Creates the image `batman/zammad:myspecialversion` based off `develop` branch of the git repo at `http://my/zammadrepo.git` + +### Help and Support + +Join us in our public matrix channel [#cdr-link-dev-support:matrix.org](https://matrix.to/#/#cdr-link-dev-support:matrix.org?via=matrix.org&via=neo.keanu.im). + +## Credits and License + +Zammad is licensed under the [GNU Affero General Public License (AGPL) +v3+](https://www.gnu.org/licenses/agpl-3.0.en.html). So is this project. + +## Maintainers + +- Abel Luck of [Guardian Project](https://guardianproject.info) diff --git a/zammad/auto_install/.gitkeep b/zammad/auto_install/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/zammad/docker-entrypoint.sh b/zammad/docker-entrypoint.sh new file mode 100644 index 0000000..f6188f0 --- /dev/null +++ b/zammad/docker-entrypoint.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash + +set -e +AUTOWIZARD_JSON="${AUTOWIZARD_JSON:-}" +ELASTICSEARCH_ENABLED="${ELASTICSEARCH_ENABLED:-true}" +ELASTICSEARCH_HOST="${ELASTICSEARCH_HOST:-zammad-elasticsearch}" +ELASTICSEARCH_PORT="${ELASTICSEARCH_PORT:-9200}" +ELASTICSEARCH_SCHEMA="${ELASTICSEARCH_SCHEMA:-http}" +ELASTICSEARCH_NAMESPACE="${ELASTICSEARCH_NAMESPACE:-zammad}" +ELASTICSEARCH_REINDEX="${ELASTICSEARCH_REINDEX:-true}" +ELASTICSEARCH_SSL_VERIFY="${ELASTICSEARCH_SSL_VERIFY:-true}" +MEMCACHED_ENABLED="${MEMCACHED_DISABLED:-true}" +MEMCACHED_HOST="${MEMCACHED_HOST:-zammad-memcached}" +MEMCACHED_PORT="${MEMCACHED_PORT:-11211}" +POSTGRESQL_HOST="${POSTGRESQL_HOST:-zammad-postgresql}" +POSTGRESQL_PORT="${POSTGRESQL_PORT:-5432}" +POSTGRESQL_USER="${POSTGRESQL_USER:-postgres}" +POSTGRESQL_PASS="${POSTGRESQL_PASS:-}" +POSTGRESQL_DB="${POSTGRESQL_DB:-zammad_production}" +POSTGRESQL_DB_CREATE="${POSTGRESQL_DB_CREATE:-true}" +: "${RAILS_TRUSTED_PROXIES:=['127.0.0.1', '::1']}" +: "${RSYNC_ADDITIONAL_PARAMS:=--no-perms --no-owner}" +ZAMMAD_RAILSSERVER_HOST="${ZAMMAD_RAILSSERVER_HOST:-zammad-railsserver}" +ZAMMAD_RAILSSERVER_PORT="${ZAMMAD_RAILSSERVER_PORT:-3000}" +ZAMMAD_WEBSOCKET_HOST="${ZAMMAD_WEBSOCKET_HOST:-zammad-websocket}" +ZAMMAD_WEBSOCKET_PORT="${ZAMMAD_WEBSOCKET_PORT:-6042}" +ZAMMAD_LOG_LEVEL="${ZAMMAD_LOG_LEVEL:-warn}" +NGINX_SERVER_NAME="${NGINX_SERVER_NAME:-_}" +: "${NGINX_SERVER_SCHEME:=\$scheme}" + +function check_zammad_ready { + sleep 15 + until [ -f "${ZAMMAD_READY_FILE}" ]; do + echo "waiting for init container to finish install or update..." + sleep 10 + done +} + +# zammad init +if [ "$1" = 'zammad-init' ]; then + # install / update zammad + test -f "${ZAMMAD_READY_FILE}" && rm "${ZAMMAD_READY_FILE}" + rsync -a ${RSYNC_ADDITIONAL_PARAMS} --delete --exclude 'public/assets/images/*' --exclude 'storage/fs/*' "${ZAMMAD_TMP_DIR}/" "${ZAMMAD_DIR}" + rsync -a ${RSYNC_ADDITIONAL_PARAMS} "${ZAMMAD_TMP_DIR}"/public/assets/images/ "${ZAMMAD_DIR}"/public/assets/images + + + until (echo > /dev/tcp/"${POSTGRESQL_HOST}"/"${POSTGRESQL_PORT}") &> /dev/null; do + echo "zammad-init waiting for postgresql server to be ready..." + sleep 5 + done + + cd "${ZAMMAD_DIR}" + + # configure database + sed -e "s#.*adapter:.*# adapter: postgresql#g" -e "s#.*database:.*# database: ${POSTGRESQL_DB}#g" -e "s#.*username:.*# username: ${POSTGRESQL_USER}#g" -e "s#.*password:.*# password: ${POSTGRESQL_PASS}\\n host: ${POSTGRESQL_HOST}\\n port: ${POSTGRESQL_PORT}#g" < contrib/packager.io/database.yml.pkgr > config/database.yml + + if [ "${MEMCACHED_ENABLED}" == "true" ]; then + # configure memcache + sed -i -e "s/.*config.cache_store.*file_store.*cache_file_store.*/ config.cache_store = :dalli_store, '${MEMCACHED_HOST}:${MEMCACHED_PORT}'\\n config.session_store = :dalli_store, '${MEMCACHED_HOST}:${MEMCACHED_PORT}'/" config/application.rb + fi + + # configure trusted proxies + sed -i -e "s#config.action_dispatch.trusted_proxies =.*#config.action_dispatch.trusted_proxies = ${RAILS_TRUSTED_PROXIES}#" config/environments/production.rb + sed -i -e "s#.*config.log_level =.*#config.log_level = :${ZAMMAD_LOG_LEVEL}#" config/environments/production.rb + + + # check if database exists / update to new version + echo "initialising / updating database..." + if ! (bundle exec rails r 'puts User.any?' 2> /dev/null | grep -q true); then + if [ "${POSTGRESQL_DB_CREATE}" == "true" ]; then + bundle exec rake db:create + fi + bundle exec rake db:migrate + bundle exec rake db:seed + + # create autowizard.json on first install + if [ -n "${AUTOWIZARD_JSON}" ]; then + echo "${AUTOWIZARD_JSON}" | base64 -d > auto_wizard.json + fi + else + bundle exec rake db:migrate + fi + + # echo "auto installing packages..." + bundle exec rails r "Package.auto_install" + # bundle exec rails zammad:package:migrate + + # es config + echo "changing elasticsearch settings..." + if [ "${ELASTICSEARCH_ENABLED}" == "false" ]; then + bundle exec rails r "Setting.set('es_url', '')" + else + bundle exec rails r "Setting.set('es_url', '${ELASTICSEARCH_SCHEMA}://${ELASTICSEARCH_HOST}:${ELASTICSEARCH_PORT}')" + + bundle exec rails r "Setting.set('es_index', '${ELASTICSEARCH_NAMESPACE}')" + + if [ -n "${ELASTICSEARCH_USER}" ] && [ -n "${ELASTICSEARCH_PASS}" ]; then + bundle exec rails r "Setting.set('es_user', \"${ELASTICSEARCH_USER}\")" + bundle exec rails r "Setting.set('es_password', \"${ELASTICSEARCH_PASS}\")" + fi + + until (echo > /dev/tcp/${ELASTICSEARCH_HOST}/${ELASTICSEARCH_PORT}) &> /dev/null; do + echo "zammad railsserver waiting for elasticsearch server to be ready..." + sleep 5 + done + + if [ "${ELASTICSEARCH_SSL_VERIFY}" == "false" ]; then + SSL_SKIP_VERIFY="-k" + else + SSL_SKIP_VERIFY="" + fi + + if [ "${ELASTICSEARCH_REINDEX}" == "true" ]; then + if ! curl -s "${SSL_SKIP_VERIFY}" "${ELASTICSEARCH_SCHEMA}://${ELASTICSEARCH_HOST}:${ELASTICSEARCH_PORT}/_cat/indices" | grep -q zammad; then + echo "rebuilding es searchindex..." + bundle exec rake searchindex:rebuild + fi + fi + fi + echo "rebuilding assets..." + bundle exec rake assets:precompile &> /dev/null + + # chown everything to zammad user + chown -R "${ZAMMAD_USER}":"${ZAMMAD_USER}" "${ZAMMAD_DIR}" + + echo "zammad-init ready" + # create install ready file + su -c "echo 'zammad-init' > ${ZAMMAD_READY_FILE}" "${ZAMMAD_USER}" +fi + + +# zammad nginx +if [ "$1" = 'zammad-nginx' ]; then + check_zammad_ready + + # configure nginx + sed -e "s#proxy_set_header X-Forwarded-Proto .*;#proxy_set_header X-Forwarded-Proto ${NGINX_SERVER_SCHEME};#g" \ + -e 's#client_max_body_size .*#client_max_body_size 0;#' \ + -e "s#server .*:3000#server ${ZAMMAD_RAILSSERVER_HOST}:${ZAMMAD_RAILSSERVER_PORT}#g" \ + -e "s#server .*:6042#server ${ZAMMAD_WEBSOCKET_HOST}:${ZAMMAD_WEBSOCKET_PORT}#g" \ + -e "s#server_name .*#server_name ${NGINX_SERVER_NAME};#g" \ + -e 's#/var/log/nginx/zammad.\(access\|error\).log#/dev/stdout#g' < contrib/nginx/zammad.conf > /etc/nginx/sites-enabled/default + + echo "starting nginx..." + + exec /usr/sbin/nginx -g 'daemon off;' +fi + + +# zammad-railsserver +if [ "$1" = 'zammad-railsserver' ]; then + test -f /opt/zammad/tmp/pids/server.pid && rm /opt/zammad/tmp/pids/server.pid + + check_zammad_ready + + cd "${ZAMMAD_DIR}" + + echo "starting railsserver..." + + #shellcheck disable=SC2101 + exec gosu "${ZAMMAD_USER}":"${ZAMMAD_USER}" bundle exec rails server puma -b [::] -p "${ZAMMAD_RAILSSERVER_PORT}" -e "${RAILS_ENV}" +fi + + +# zammad-scheduler +if [ "$1" = 'zammad-scheduler' ]; then + check_zammad_ready + + cd "${ZAMMAD_DIR}" + + echo "starting scheduler..." + + exec gosu "${ZAMMAD_USER}":"${ZAMMAD_USER}" bundle exec script/scheduler.rb run +fi + + +# zammad-websocket +if [ "$1" = 'zammad-websocket' ]; then + check_zammad_ready + + cd "${ZAMMAD_DIR}" + + echo "starting websocket server..." + + exec gosu "${ZAMMAD_USER}":"${ZAMMAD_USER}" bundle exec script/websocket-server.rb -b 0.0.0.0 -p "${ZAMMAD_WEBSOCKET_PORT}" start +fi diff --git a/zammad/fetch_locales.rb b/zammad/fetch_locales.rb new file mode 100755 index 0000000..8bc4d4e --- /dev/null +++ b/zammad/fetch_locales.rb @@ -0,0 +1,30 @@ +#!/usr/bin/env ruby +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +require 'rubygems' +require 'uri' +require 'net/http' +require 'json' +require 'yaml' + +version = File.read('VERSION') +version.strip! + +url_locales = 'https://i18n.zammad.com/locales.json' + +file_locales = "config/locales-#{version}.yml" + +# download locales +uri = URI.parse(url_locales) +http = Net::HTTP.new(uri.host, uri.port) +http.use_ssl = true +request = Net::HTTP::Get.new(uri) +response = http.request(request) +data = JSON.parse(response.body) + +puts "Writing #{file_locales}..." +File.open(file_locales, 'w') do |out| + YAML.dump(data, out) +end + +puts 'done' diff --git a/zammad/keys.asc b/zammad/keys.asc new file mode 100644 index 0000000..6a3850f --- /dev/null +++ b/zammad/keys.asc @@ -0,0 +1,559 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBFMQ+McBEADBj3C5hgBeWgnIeEMOPuFCwbdWZrwjgUYUMf0xkGeNpDIHlR9m +leh3pi3yLEmofRtkQWa9cNqn63Zi5wrQLk+DLWUeLDW13SqB5JtY7tZJTpsI2gf4 +q9XrUExzAv79+9P8ZieD4WE0mpGkSeIFQDfZ7Agc5wMEhO3xKjihtHgD6g5x6tk3 +FLUfQk/YHib9xPr4C05ft3OLEa/FhTSEztvvHecBNgaoZesxdslrAVPrko0Z2BpW +1RNjfc3ow653psL/DOOLkSB8+/bXuRKRyCYhJbTg6BYiDPtRROnb5T3urtm9RflM +HyTYf/+VcvdODyb0MPHp73SxVfBYSj2qixjkoA1jc9GTBVcKCTbq7jJtXppA9iaa +gOYkq3GGOuO+zOOI4xqyPQDpyaViWGIy5D+4/cdZzqqJL+SnHTT835FsdEv+dg83 +u22+8UjZaIBk21zNsjIgpj4JRyh1iFBZygMzfxv2bCb51EnjoPOoo6haj633lCOK +pH3emV56AZZ+PTTGdUVDVfeF77FFTSDSb3slWKdsN1HnkusQkVNntJvMFbm5xioM +ij65UYMF9LqTxRX7MZZi6RGxvjfWLzQ/sf3nhV/yzF8e3pA7dVKZUpkEXD8aui8A +iE1lxC/QzoVLUYTcroEL24Ux+nf2uApGQKb4M17Pryi7F0AxEauTqHhA+QARAQAB +tCBUaWFub24gR3JhdmkgPHRpYW5vbkBkZWJpYW4ub3JnPokCNwQTAQoAIQUCVc5a +owIbAwULCQgHAwUVCgkICwUWAgMBAAIeAQIXgAAKCRADapwlvzV91JS+EACbQ8CG +oOOiPRJ5f0eVxX5wfWvA6QAHUwKIKeeYmk2RjcA3D0CBfSS6B8M9+ux/ftn2FoWP +fSR4Yo5jQhLvz3HYOnK6dfpp29w03MkGq/tidhKkpUmtg7/KqkFw9LV1a1RNwOpQ +iPzv8xSfDzycw2aZzVYGt5xuqPfJ5lfgIgy7xZ40OT5pJbeqSp4lFaBFKSm7ctUe +O5ARlfwO0kkyY8hKFEO21WvpWM9t6lipZanpgBok3yHVQiYS+5rbo8k2WTFzlSst +GCg5bGGLUDP3UXBjBtc85Dt/E4xHeHrjyx1XQenqo+evBFpp+VZR00va8h57l6gu +eCHrIMRyRW702gFK+S2SFDGrodN76P1FV0nNe9U2kUPHcaccYEWmRY7oz+3+hsw6 +BtlNfmPa32iS6OzFnXSIAF8fYjnEqLgkk2mCBu9lFkWeNcX5uLXOL9obwpS5GsgI +8jrD3B2FWsOLPjotF76se+HgCdCC/4PYUw9+RvGa3Q6ElAxaM+w2IjSsUgwjJEuD +G1pULi5zsTssT2znF8FWiZbu0JhzTZlRRMCk/DH/vn9+2HAU5WbAGY1R/QE1VPda +7mNzDWCTxIRUL3NQEKBl/Z6lEdzDjb398j3Iizv3oCqQVqm5ONA0LsSztPrG4qVc +N1OrhxPO4k3GpaXNImaFqNbQnol49P8rPZ7boIkCVAQTAQoAPgIbAwULCQgHAwUV +CgkICwUWAgMBAAIeAQIXgBYhBLQvaBkAfwD4jjZP1ANqnCW/NX3UBQJZXqgoBQkK +EBZbAAoJEANqnCW/NX3UvG4P/28Ei48i94vXkfhrXtfa2QUNuiz3EhMdcrhMvOnP +u5D7xNuxriXqs8lI1UJ6EYJaoZ7/V9gx19JWOrQDus0y7OsaFFKko17m9q9tL4gw +3lt7cocKktAW/tO7QNt2mFsinuokyEevNChZPxUnNjCjldLaDuiZOMcq+hE2f5i4 +mA712z4FLiPEBAjNOR5zRJOG13GgqI6iB7cN+P1DnjNjh9upRhpUVDcW2xDzlZ2Y +d/pQqB5qOqnogMh8QBsgYyEsJeKtkYnw2PoZTz9ufBJoc7zWHYbC9LFay60vl0Tv +GOD1U3KHFoV0ApZyCZ6UBapeSVTNtVGxrSg3VFirnemyWc+V8ftXuOA0ydnQysxH +PcSyVxgGp0+YgpnbmEn/lPlZL1jI23LCaJFJ/rZji02+P6YYIy6suMBjqmHCo9MF +KxCqfBMOXWjQZ8DhwaylCKcsMLtLMWT0mSo0far+KgtKtcPtRQD3+M40yVelLbiA +MpRKl8uVM+c7cuDsZgZa1RncUw1//Dyga7QsvQqIKUHnBOu81iRcwFXKhCPcRtSO +fl6eipmZclP98TUJ134FeRICCutzoaJXgE6WWDPpqe2QaDUPxg6TsKse/qhRs4gs +0kzSOveWockN2cvEjck/q+fg0vO5eR9uykEQ94AUdvy67d4c3fqXsZVr6L8Zz2/O +OSz5iQJUBBMBCgA+AhsDBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAFiEEtC9oGQB/ +APiONk/UA2qcJb81fdQFAl0anS8FCQ343mQACgkQA2qcJb81fdTrTxAAvIr0Ue3K +EfrZM2ALmh0pNq5q0iaLiIRt4BC5xyMQrckaVQ4jU5NULXy8t2sYCs6Pw/z0ofUn +FvJ1buJuB8DLTWc6t/1774c1gGuMnv2+y4AlqeAJMu6RTvHva1B4ubc1YnMsU2re ++S3QYdRiKX102jA5nRta1WyohG256pM/mjHcrUgBrLdc5Svkuy3lhrnkgHqnKuVL +65XXTmomQuAo/yaglh5A4KiOIV/l4JM84fYKvsTFcqiR2AGqa0uiBcqeE0rtjphJ +qPHpB1thoNI3/4eWspA/VEryKBt/9tUT8U36BH5HD/ANeJU/l9KtaVU9S70Yo89T +xNSL4fb4ijZ72AquX+8i3FwUZ6SOfQ8A4Z376EjS3jCwcQrjruYQ0xenfnsU5y5j +7XjBQLStYcJBOzGs/uqiNemsUhxCFbT+swzt2Ez/bnTgOTwDh0VGB8bfrKW+kTj1 +6XYv3KD8clvNQVG30Mb/UClRvRcVUhv0uTv+OIAfE3tQsBwH4oj2TL+/rki+G8Ka +iVAxv9bbh7cOs+uwzf1iwIoQCwNJahdSK6pJjIRnwcRFhyC0Kin8TtX+2tVpLrda +9V/YUec0/naj2d2XrnC5cU6Oaj2WZlpCM8xsSscxPHrIhYO9sz27pHMDEH+wWol0 +Ak0ncy0MEtJEg7IRZq2nrB3hxU/D8uTQKMm0IFRpYW5vbiBHcmF2aSA8dGlhbm9u +QHRpYW5vbi54eXo+iQI3BBMBCgAhBQJV2DCZAhsDBQsJCAcDBRUKCQgLBRYCAwEA +Ah4BAheAAAoJEANqnCW/NX3UwNEP/jnMEFxhhTdutjE/jGwxh9dA9YyI+vroYAe7 +fD0UVulJl45hvyMYLns8Ax0kEZ247DfYSlNMt7hnHWI0I8i8OwoEWFh2etR6Vidi +uKjCtzAZcSb2vBBcv/hTy1udO4v+n0v3Hr39O3u+ESGRpv1+zeQd5aXstu3XWz3m +BH79t/zCQ6KyXxncy640gzy9imr1tT2uYm11VuIK4sYj7RBgC/Tm2a+8fhGQYU4d +4cCwUkRDE0z/iV1n/NKlFsl94o2+q8Ry1aSGSoEwxfhYHfcpo1mp6ouPkoIn6CdY +W0KJzN5QUlzpkNTEXTh6LKTIaU662MwkbEowE1ZUkoURJswyZUIYc+7nDn/1dTne +qohkWHaiu5J64pJ6JdUD1H0eAke1g5FIBzICDiT2DGbgWaMne3V7rKaKoTe/Yhnq +6CmttDS8rqn8r3rFsJyJXzk6dso5+UtNcDZ9D+nhhsyJIc36mP/Lp8Ozc7vthvw6 +WpkYlJ0WaaQnTAdL8ZTjmIsdsAvsH/u1wmIwYQSQecsbnh6wjeFv333xoiQdN8Pf +0/OZyQY18Pcd1M+gQj+AIvlIx5Vr5MRQO1Dm5syr/04Xxb4n9SwRgDO2awlHLA2R +sZ5YFHjxMEdq4lbnetNWoRGDUEVHRoR5sqXFajTeN1NmFVL8btbjvfm5Z3GuKa1U +ofFSB3OliQI6BBMBCgAkAhsDBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheABQJV2DCx +AhkBAAoJEANqnCW/NX3U/F8P/RYwP56j8JnbvKhen9QR7OsZLo83EnsEhYIibnmZ +tj+lpWcKEOox1rBYwM9xwHgXnIHEaHtgGzMjfhLjaKDPL7GR6VBWdfxnBPTAARfC +S7VKAzCcayL+pocx7zVCx59kwVifsrlVyHiUqI43InpvNYmQLSP5B25vYjVOVIrI +6XP7vr/p5dxrciCoy4G3u6YjKHklyt6fvk9gL2r55vOz38OAMRd20lBdthXiJpnk +Z6oOOIQB2prOpccqq2zgXWPUGAm96COkEXA8XPbAnoLftSFsXgGPY83DpLGEG3g3 +DKTsLwjmZKSI+saheD6VFYQRVGgckd6CFQsazH6S/ahAkSUgW260ZLPn6Rds4OEh +jbTdbw2fyHlSiKqJM/66YF2LMqmW/Is6VCXenUC1sYbG1ZtDQVRoQB+kZjf1R9A8 +dPEMm1cCuVGOAKpgKK9a3Ne38uDzoi4cj8+q5eCYMExAlDp3YF3KMeKlu2caBDdF +0zpjKdN+bylQ2pBkv40xOU1pBkziwIpJ40sdSKCw0hkIjeC8QB6EFEjyX6iq5aIJ +Me8jSwanAY3Bj8v+Eq8TpEFKdwKPbadwcGm7koxMSjjUAr5uyqm4AEzECOwa72Ej +VUsmpGRM5RCY6R3qG1C4QQuC67fLhuMoJKQq8Eu1gkj8KFEXzMwvOQqwUZBQR7Zn +FiGUiQJXBBMBCgBBAhsDBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAAhkBFiEEtC9o +GQB/APiONk/UA2qcJb81fdQFAlleqCIFCQoQFlsACgkQA2qcJb81fdR7pQ/9G8a/ +iLeGc1KDGGeAdcVSgRDnvUZnTpF0bONqLegwLsMB68OC/4MTjaCFMRWAI2eNYkeB +WMJQJBJ3Bf+cwkuLKXFap/bjwZMccEB5TIAAf6GzUBRdTbLbV197GhS7i4rIeWl3 +6S9p77qT/f0rBRGDUAcQMe55kMrB/Vq0GP5Of+67klGPVIsXb/IKOmjENfSSM3Kh +7wOCUTIBAWWRHjzvMIx9XWBVn5FhqdOgFXO39StaWUF0TJchJ86y3xnGOj0K1qn6 +nauhTtGIUOrLDPBVv3o8gX5uJUVq5M4oc5BvrN1j8pQHfNHrgSXOuJwst9kFD3tO +slRZIMmosw7QPw6PLHuIPnU+i/CMPdfSw2r1HYVXdng6gGCqGxGWwSjizv6O2Mgl +Qic+8mpBa1fRESxF+SLrs8Aij0kYbZfFZXKrMTgJ3MYWHT9un5JUabr8AVTY5PHU +XBX7jVjdZUZadZOYXN62p6IP7P+aTuBpQNrugUo/rT3iiYR5hkt7a8+mAMh1uTGw +N1QKwadU9Kq2H/fcm+pFwq9ac0BV8VwrHj4lXbL7vhe3IZ4v+GuRfIue/JjKENJV +YbNy0UUOACuLcxlqkDVUjPsjc9VK34Y1yOWbz1c4S66Z8XnY913j+Sa9gR6jFe4Z +u6m89nr9FNpKLII3OZZj37AqJXbviRCXnqs0Ph+JAlcEEwEKAEECGwMFCwkIBwMF +FQoJCAsFFgIDAQACHgECF4ACGQEWIQS0L2gZAH8A+I42T9QDapwlvzV91AUCXRqd +KwUJDfjeZAAKCRADapwlvzV91EzdD/wNkx26S9M4osxgWWbP/DR7JgTVSMIxX8YE +PC+ftwKXD7VXfjf1JFWFeqJw5/uL83VCqdOhEZuN40W1UFFN4KXE6fSPeNkSkWwt +iVJx0WpRiQoR6p7pHjyNV1He18O1yxeUQXij+Eoe61QH/B5ldFVRQ0zazRKUnFpt +uR4yqHNgzyQnIKCVHHctlzrX2u2YT/53Nctp+Ot39ahhvSM6iQ442X5bVh9aK9sc +A+gWu4nNBtcm+bevRMvlmatZHPwjMrS7rnxjN2Y8Dod1GG8UwKFKaQ2juii3RJuh +0yK04mXeyZAioAl+XGKmV9fpNsEWE8ixUt/47CqHK+xfGDgkKCWOWDKjOpsm9GQA +C4ZytbP9fgCLgfuTMjXVPNQn5dBEXDiDBpuIb02xmCfZjpA2rvMqut8biKVVIFno +lYiWJMbbMYf9p3KQ80jrGqblHGZipMU+dPg34dpemj626mZw04lMeabvPCdz2/0C +Y7x/fXH1C8kTX8uzfTNhO36IyHlXLTUJ68feFGYYoARTHBxwJLxgNRFjdzR8bxVf +8qrNiQPNZrTdZhepQWpjAAKtpRRg54Q1wP4SG8fvWDfG5eF0ZiJYREGyZUBIvh4/ +JEtqgPaELbEMWB3ZtdSz9avAqFgt7KH3Jg1ppfPqO4Aqjhc2P0Qga37+vkseoooa +m4OIJrjvOrQnVGlhbm9uIEdyYXZpIDx0aWFub25AZG9ja2VycHJvamVjdC5vcmc+ +iQI3BBMBCgAhBQJVzlpjAhsDBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAAAoJEANq +nCW/NX3UFD0P/2NPjTJupO5cxYWRWH5UVONE66tZ5lLPTqIEjFjFcXhi5u9MYeuo +cKf8K1Frmr5yNH8KkAvABjikGFvBRXFIXDPXVBuJfP7VV6pjQNCt7xG6Nehe7LEK +6qen8O4NQZhReYkFJUQeVwGy1G6yLCdluYZ9lCju5SQm+CoISMj5OuBghev5l7nx +NMTLVtQmy3bmd8nynoe8CTBByG9s7WY7gg7CQw8YDc86Lekwd4y792Eml1aBuN5q +GC1zj4hV3IuxIEepV9r3Izw286SZOonuzACaG4KoaQytAHtrwh//mdMIp+79r4ee +zwSHsEn0Ph8boTOHfNbYUw8Hxj9v/rDQwDM5ZA4rjZtt40Ygu44+iTdhgvwLeOvp +1S2aB5VZBPYIKEAA8EzZsUw5TBKrHAke90AbENt0/W55EK1JNzU7Io1PLUHmT28J +sN0fkBjrW7lk8DMtq0cU/sfZY/us3LjbHWkqSEBOY6mMXVVsDZb1ba/39tSNFVdc +aK236g0b2EB/BSjRuiaPHyq6aj6EAECLPgJySNJ8YaH/iiqAKuDHj8svEehof7pm +rC8RZpwC4zi85cJCzMuib72qTUAHSOoLlnnAE4rOv3A1Q42nq+725pLXrwWxK3rN +F1uXrtzCj6+nrTMNVOMSZ4IA9Xdm2DCxv79Sry8p8ni7AnSsxQHkOuzdiQJUBBMB +CgA+AhsDBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAFiEEtC9oGQB/APiONk/UA2qc +Jb81fdQFAlleqCcFCQoQFlsACgkQA2qcJb81fdR3QRAAqduD4uIm6UwDJHBywDTf +H/WE6288JcwJtosw4xXeMYfKjxwLiEeQQcKM1hOQuQAIepyMDlrx0mxULsfFhnxg +5+p5dE0dwmiWs8lCJEs4NflCNK8Z3kTeeaNDFDg4JnfW/bAw9iLyew3AMyHdMfze +uxKW1eO1JBbVD/41CdP/BUeDV+6BGTYWAXFyoy/Zb9yS5AvGjEhd5W4sdzjnLLN5 +UOFvIYU5ToxJ07s4oSMCOpclYzEiAHO/IxvGWl9EUODhTnialwQsxsI0NYbWtgmx +p8mO7tFiqDv5xpdLtQL8wQNTmLbbCMVssirV1xLnAiqYrNW1CPeR/08ic6elBy1I +h5zQ98ZkZyyjDyvhEuJdUp0rfoV4D/FZSg5G4POlSjLN0fYZIWtFhPtzCDxhGZtb +WiVIt2mJYt9ViOlSqQkdiTC7c5ltrnD6sWkodGKzds6mFBLgA05+kkpll0AsbYk0 +Mme3lC1KYm2S+GsN9/LbLDQYe+fEVE6dsuxtzMokiAumVUjVo0tmymj/xxdSKLT5 +odoRZGvN0yGQZMxpfQAAiQBBzQNsNjmjSTFuVt4cn2Pfo435kD1LC8sVcznuuFTJ +FkgcqwrXI53oiRhLgu2D8qiC8XR3lPvBxqrCs2Hk/Mmk9Trc704nKcqhV+FKQ0iG +DSgVajh6gE8EzqkU/o/RJomJAlQEEwEKAD4CGwMFCwkIBwMFFQoJCAsFFgIDAQAC +HgECF4AWIQS0L2gZAH8A+I42T9QDapwlvzV91AUCXRqdLwUJDfjeZAAKCRADapwl +vzV91NAFEACUMCDeduxgHcLHkPhUA9GgN8DN9dVQfyodINblxOi8z9z9QnD/f+8W ++kTNQKnIgPl+Al9ybszoGLPScCqowKaW7GO+XAWEzoeWZhEV2nSLKLpoFaj9hd15 +OmpePwF2RE4KM5ew8u22tu5JnjwID0uDPG90y9BZJceopHev3X+31pk/W9TrlIla +W6vbh+NeG7k1eKiS/wNhfem2d7RlWoxqK0eVaLEuXyFFQosdDr9WCQ+AWMKZgq1V +gJvYZqnwgSS9TEpavBWbsenO/FYV1HZ3lEPJrIIV/hHl8F88AuPzRcTmNFJZ9qMi ++lT2j60DvJkYyB2cOeS+Cs+a1ye2UDbT+vTUl7yGGPBEJm3zBGZjTTw/6gMCjEB7 +cpBtuIp65NST9bqQ6v/GyaDqXLmOfzjGM24GbSFTvZo0Z+KnyyU/sdCnhzwG7brM +cGTUS9sMUn1+4wayhoDUcXad9HgYJ3BfHVh1V3auG8aTkQIeyc/TYe1da/h8aSs0 +iXNEC5F19I/8F4mxfLujXWcaIIIt4TmEy6sW5eAF+XhF9WYMMlhKGQZ1wdyvrtl0 +8eJ72yGOM4VWTz86el0geBhqjesSv8tIApYNHj2JfC4pGubsoPIG9SdL5niHQo9+ +C7A7/SE/Myj40EM5xd5cre+n24ZXe2ynZzlNw/ti0yB6PWaeiRyHD7QrQW5kcmV3 +IFBhZ2UgKHRpYW5vbikgPGFuZHJld0BpbmZvc2lmdHIuY29tPokCNwQTAQoAIQUC +UxD6gAIbAwULCQgHAwUVCgkICwUWAgMBAAIeAQIXgAAKCRADapwlvzV91JGqEACq +aDhGIzvHNunE9ZoMy/MabLlPaS5FmbrrtxCTC/RB7+pCuJkCI/tByrR/QncZ+eD3 +mgxLZai9FRfXalARa5Abg8KvmYsFpCU++T7Uzcl4DRZ4Brievk1KgzHFYcJJX4ZU +8w6tiAsWD7Cep613IOrs8X4X3aSrVxGD/uNwjam6AhB+Qw6tFxDuihMVf+SI8EU4 +EfpVJKpZh19JWz/zG4uI4l6iamnDG5fsEuYFZgGBAoyubCy1Hx9NaLG+XHO99hJg +Niq+PNKevzmtzBEoajxW2kFSW451Gle5t5tHwV6VzU8g3YgdDiuDEr01apnX9fye +5C0z+Op7xOi4RwA9HEvK5hlGCrV75qhdFj62dJPR9u9sZNqVSpVvyvZxRNgU+ysq +kTtkrl7SUiaF3ey+zhLYJscEVG0L9iwWpd1hp/re3ZY0rQaBbQRsEk4XcnIjeHjp +as2yBBwDmZ2yJcGiQDorQPQThfxq3woWqgy/pSQrsJjqfs9U1XbkVWSrdAN9ZfMc +ID9oqYngz5rGSro0M5nBORmsZjXUkeB2jGavngmvz/2oX6aJrarWezw7WQBvsZWF +4axVwF4KUUadlNqI/xADav95lwIs0MhSuVa+gp8CH/pdsF6au9H8o8LLVDD/avr+ +lS/ztaXkavB7IyJplFNSboIFxf3H2zIhTzMqEqCsQokCSwQTAQoANQIbAwULCQgH +AwUVCgkICwUWAgMBAAIeAQIXgAUCUypIQhMYaHR0cDovL3BncC5taXQuZWR1AAoJ +EANqnCW/NX3UFVgP/iw1m9nqHeNGi5AiG+dvl8e22cOsS3NWkhwz7OdSNDLhrQbl +H9+AN7y+tM8zJOsZdWr8s0wv0aJaqbFd4FkwMMZ+dwt5h9gXolcGW1HmXcIqmCku +CYq6X9pbLxncKxoN2fbSHkkgoEbFxlwkDE3UuhkuPTs1Y6PgbjmBkxo5Zrb4XsCM +wUf1EkJ0wi5kCGTX09+Kj2r3ir3mxYA7K4TQJ592VFbpC3jHEGRP/KxsdCldfHy5 +gVZC9SF3iTWiU7tCElZnnE5EpXyOF7kJLaZrt75JITycT5O2RghwwRUDbhq3Ntef ++HXO1tuEG7UAfMx5qO34ffv2vyxoxvLLY2RFojNxfuf3ObIGqkVVUvJBz4Dnguxu +bOUemE6A+HLdj1lKiSJo/NW/mO7Neitl5ZqtNQEvgP+83qUqUHW1L0xMYDla2Zf4 +pYPIlxa6nH42xdrzYH1o0EWPHSNWWBhmfrF4ZGEhgFJYnIDR9oPbEi6tWoiLa8rP +QZsucIdOuXTqG8OO4dxD+iYFdxHRMxQrHvljCo84MmGmkZtbCwHfiybvqpsRd8b3 +Cso9eUkUIc1/UOQAL14QezrSPgkO75A5xR9+J0cLJ9XvLBMAAdg8Q2hK3SGtDr7e +Rd7lFYXGAOA2LSPNwTwHsv7typgX98JPwA4MhmUppiqz05ZbhT2bgmSs/AJviQJo +BBMBCgBSAhsDBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAExhodHRwOi8vcGdwLm1p +dC5lZHUWIQS0L2gZAH8A+I42T9QDapwlvzV91AUCWV6oJwUJChAWWwAKCRADapwl +vzV91DssD/0Tj94XNE6odWnbd3JyQtfHbFeJNpWfYicn3sQwUSNbknTxGVH6YY4e +I8ixjGlbsvJTCA/EwJHk3TY8gDur3uNa+aUrdabYlnP0PMp/edOOMVVK9DfRYqgJ +b4ZXeuSWfApgf9buxqeqLn5gUxeoiImdoLVVworn8Y1pnBoX53TvfiRSmYDLiP0E +g9egdc28KWA75FTe4wkuir6oJVgwGujJcK68AWPktRhDvupCK6WllScYVURC8pXn +Fx6FDgaTNQzsfEuUB/ToeXUEQFkQW8/j/G/AYczOSYf92JFxyrEw8v/S18rWK5rQ +Ya2Jn7Cy1ozKJXBvaQBsrisJxkGtOF9ysZazK2Y7FvSZqQe6xB/YM0HB50lBBtat +IyUPIiY5QQO4ZItflkyChixG6MgnmiUObb2eiLeefPj325KKD9Gt5Ws0W+uO0B33 +luBPxKV81KfHovLfHYZBA7CHp44X67pDVm7yAPHIzCsL3vtEgyWpjRULbEjasBND +MccGd30gWxziuEgFcqDvWRGyG9vP67HrLPPJHVMyOa21yxWpDGWeQIJTH4jY8959 +V8YqaaSAboUYips7EJeJJ06Lvq0oilDKepJ1+mofF/ZIURHUIZ+J7aWoV/OZfBJV +MNgaz8ENRdO3/Gq7x9lSCkc+O0uAsFbKoyjSYaZh5NlYuAxnNc5PFIkCaAQTAQoA +UgIbAwULCQgHAwUVCgkICwUWAgMBAAIeAQIXgBMYaHR0cDovL3BncC5taXQuZWR1 +FiEEtC9oGQB/APiONk/UA2qcJb81fdQFAl0anS8FCQ343mQACgkQA2qcJb81fdR3 +XQ//XuxqMayQOPi3V+TlnL90GIWmi23BuRXCxZBMagixQu8UX5k0R3yq/UIGkNAP +kkVWgH49VEZ+gXQs3i3Ug+gnevVcXhdnDAakDQ1OxenxlEF0HjpW3IQ1G2J+Us5w +e9TLEC4LRrlpAQFd9DmjNT4p++W7hrjLJdOIdWBEjNW2NDvWIjWSnRSVA6HddS/p +rdGX9M3Gv1iIi9KCuIeH1spRpGIcYNB3aLlhTxoADT4ktxM5qM+Lvy57ADtYuIfT +348b7n236M1VLirjvpIGmjCB5PllPf4Sg5M+5tGH96A+Gta0hZp6DboHPKf3623M +nm0jl10qMWf6R31m24kgfn28Mx2sRA7/tChMfNJzZFNyHGzhgMNZ4SsdNwjljnNH +J0IvI1MIS6+RX7pEFiV0E1/+rUkBJUlZ7RuQr4/mvtLgCfYchT1O9WDsx2/sSDwj +CLI2L6Dy6uN0HBoBdA31G32DK/h4tb8h4DWUZUWC0WJR8J1RMMsahPcgZQmHRHD7 +qiYysK9rYi4ThtLXTNHK4ATgnlJBXXBuFUHY2A3Km1VMhkC/4xo2XVZVva3ATPac +aTjFpevlNy0iU7x9pfgjGg/g5r8KSpshPAGTnSvEhToEq8UPtOF8FjlsX8IYs17T +82uVhAaf1F6OQ0lVVWPav8A5LTbXzl+AQorAirKGurXri3G0LEFuZHJldyBQYWdl +ICh0aWFub24pIDxhbmRyZXdAdml0YWxyb3V0ZS5jb20+iQI3BBMBCgAhBQJTJUFA +AhsDBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAAAoJEANqnCW/NX3U530P/jM6kilf +PSxpjuL8DaHpNMqPyTSI2ufwJG7M17gOjy7i8hKmwZ/vELk6UTOlTVkUWY+dM2YQ +zsU6V+xmXCbpynJrYMsyYJG9hKShbF5YecbvzV68s+zHzt3ALIsyD4dkGJlMss5J +kwSy5Eej+61pL4nmjm1yW+e+CVpjvWOiA+pQeXfWIfVU3YgtCvSS4z8RBkqoL6Dm +G1oOnEmdnFFGrKB/LA173oK0jzynuh7E4Vv/XPDBnA0WlMJBiLAJzvcdnIBAElhn +mWR+drWGWLvR2gIA6PEwrT5rWJlh2SQYHhQzsvP0jOjA7KQKVqBGiS+KZucVXhYy +FDI+eQ3GHlI06CObRXVWamu2ypEmF9oCjtv8TLN50WbamrH5KsDuG/o8AswV5DbG +9mTAdAVqcz5JLYubG4zc56vvpj4qZ0En7jJoAD3eI0hezILA5VaU2SfLb3u3oziY +18g1RLYxxcDg42LCBT54qcoSxA4CMEW7VjbceFePs5mhj8ADQcpSn45XdGl4RLDX +G0VO1vmQnSvuDws+wp0c0TdOTbhUmjD4w8ClcfMkRDU11sb+ftAP+IhulC7m0loE +iGf7IfVLPkQ7fBkUHn0eeJHW2D7qbkdDMq8B6kCEb5HMIsa9Bt2uwBpUXgOKiwEU ++kbBe36StQu14GImQNCnKsHcEkZEHS0ykM7OiQJLBBMBCgA1AhsDBQsJCAcDBRUK +CQgLBRYCAwEAAh4BAheABQJTKkhCExhodHRwOi8vcGdwLm1pdC5lZHUACgkQA2qc +Jb81fdSLNw//Y9klsfHg9mEqSn2NUoWtxFZMYuW6/Fs+AFEkYF3xdisCo+R844aF +5XWDKIXBq0sULcLu1H2UGSoUD6DGy6G1LrkUR019jlo7sXeUENZrYHWtRd5xCc8T +HtBmrktUF8hylwGS4+4BPLPEa5klYM6hj9lZSA77xtpjjlaUxqMEoCbxj1Mxv0hw +M/FLPvpIHOGFk+Q69oWyg8rw0d3MOpP8DCHUJQCrb1XElijbYi4oyiBLDcfwpPEB +ccBVgLz6RSFyFYK22iPqS/r/OIFt1aoRdW+XyGaVX6PexBhSwccj88Q7MHxwCxSO +YiF4FUA+xYcTspmBc1xvsYC+By2ZDap+NFHe68pdcP3B6bHRkCGYXraaLI6ZoqXB +aE+KkZylgpb/029VGJv1dGaiMCl7UstjAvRIicPGmBzO9sRiUyqpCC8vd6Sy2SGb +d+4ya3TfCa/kKH32QwBcw7irAlGHHyBHl91qwdgAxaf25QDBFfdEedp/QIQDyDg9 +mao16cFhUjloXa5Ue4RGtq2UX9xxOBfQIu5MNIF6/N1icVG9XC0CUEjRYCVW345T +/xgUscL//TE5xZQlG54OpxJ17CyjMg8ZtCsHW2qFljy0bYQ2lD0GGLAK9Wv9g98O +DNJBOfeWmGp1fmh088cLDCNdzKpbhtyosRNuJLQvdGdqkVmXlPgqlcCJAmgEEwEK +AFICGwMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4ATGGh0dHA6Ly9wZ3AubWl0LmVk +dRYhBLQvaBkAfwD4jjZP1ANqnCW/NX3UBQJZXqgnBQkKEBZbAAoJEANqnCW/NX3U +ZI8P/1lN/qbee29GF5uQQQ7givUEZAqv6QnYAWnnvvu1nFcXG/RV8MPb+JhfJyXi +cGWHWG9HtPdf0Qc0EUyTu3hGdX9gXOM2cHYWXTzEfTOd0s9X7yyU0jy0PRjcic9C +Lzp0CPJpJRLsmeNDCwWtdeiJ5oQZKQpMz9vxVBO7jpXBd66Brm4SxCxXMxfo9osj +u39KBhjTc5LXjPpdq9byFLPU4nwEpx1Jn8vDr3hohHAV8s8Yk00ofX5E25r3tmQi +BynMwG+v1EGsUT6L691fV4CCZTjAgoOKviNglw3CrkVjzJ6Ti3QPMljwbkCpeJRa +Fs+g2VR1Je/ypoPsXxX3y+lDvGdn1jbyvj12OHExBkT4hdbKl7ZdMXdkyGH+hvrr +opnFt7mXCj3K2AMieYwL4u0OcNafTaHAwx2yvVL+/7sghiLnXGzX4hwWacxukio4 +BYhNzYoD8ynwfNCpcJawujO68QG+C7Kwbtgqx7PSpzKsM6lJgCvBqWnS1ia5Gp1A +iS61JZl4kBHpOkOyVAJbwg/S67dx+9PQbbh5JWulzY+zbom41FQnkCKN91TUFPEo +nylCAyah4CP7Dat8z7WE+RjlfNtKB3+jNEk5DO9zhkd0VT62lQXcyJqUUsYVNtP+ +mjW+RIRuVLYjUbyWzDN8SD7Quv4B5Jf1ur23orw/2cI5qHOOiQJoBBMBCgBSAhsD +BQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAExhodHRwOi8vcGdwLm1pdC5lZHUWIQS0 +L2gZAH8A+I42T9QDapwlvzV91AUCXRqdLwUJDfjeZAAKCRADapwlvzV91NynD/9u +5qjMeLX1xcYTvI+gwTJ8v/2m4Il149S1VeGWXsGlYygOlk06Zbwb1dq3MNDvLYgT +d0tKqtEz++/oj8g3BPYp++0BnCt/zjmMe9A12C0iggazTnVX69iW7ibG8E8u12WX +8/sJ1fKrwF3sm4W6LY0fVegUIKjgNk8/n5obCw7fl4ajJGOoYbmxt+jxD/4R4MLc +gz4znetEtjDFmKKPTNIuF65o0S+T5IW3PxfQsJRJL3+PJ2wlk5hHsrQDDfsgK96u +4cr7KyHoQAMDaPc/GN+3JXWTyaJcwsXbBbzq8B0VwqunbLLMTgbYIbtLSFC2BFjJ +X/qrJIeLophL4hnpHS38U4lbs71J3dlyuZUytb0pg4Oz1KaIHWDbQ2PuhMqTiPhK +ScLsP5ihW32PwSmEr6rZSc6HA5tdCkNn1rL6TJD9C23+A9iVOnOCU5CvVH3mrQGN +y7ABVitegkPwkBk+iNHcjq3CtH/9iEB1P1exQRs9zGXKO4oo6iqztEvzMMS1IQ1k +Dllfw9NwmYu7qx46BRowo6itPlcAqf0mT6FaXfC4hztntOKOLbD2gouzNygYdrXx +oe28Cw4xvGpiqS20Q7Zfe4kxtXOH8mF59NTTfy4fO7CvCFpk/dNC1RhIYHdOmDRO +5Xno2VyzAiEVR0lGWiO2KItiCTCNrfmppyXRufNM8LQwQW5kcmV3IFBhZ2UgKFRp +YW5vbiBHcmF2aSkgPGFkbXdpZ2dpbkBnbWFpbC5jb20+iQI3BBMBCgAhBQJTEPjH +AhsDBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAAAoJEANqnCW/NX3U0/AP/ir2MJlE +p9bBs3YLeT3BdRfFPlDhIlA6/87vLAx0382/nvVhbdyroenoNAZr7dZ4NTziXsR0 +u0sznDpVv+lAs6c0ZsM7GgJ/c2QkyzlPCnIWKLFH9eWaGoTX9e7s8s8/9GkAPMds +gWwDCfOJAd/riE4JmgK1MU4lk7WrqLg/gevnlLN0rF5ZWiisjTAvo6MII3FGXu+a ++UNHngYh3AszSbeQe+hDXc5H8p9oM0abg27bh184maAi71t1RtxljPtBSK10AQEf +NWNRQZrLVcvo9NitQ+e+EWBmMIPpklEGc8TKtUe0wPltJ8HGMwSerU66qup1h0xC +hfyPGGx3imoVQ2xoXSjIg/4VpIEK6YREH78wVFqPqewWLfk8NdnxEkK/A6fMiBpr +uH5Rssgc1sKG4NeHdKN5Ikx5vRZ/jsQgA0nyAEup/RloENpnwiTasZgwamkdsqY/ +WKBu96SeKneY5//vitgsWJTPENy/0mdG5OfDzp12TKNiK4I7w3JDYXkF6/M5xbnE +/UIVUGU/x+ZBmdRWI68x3vplXGxsWhaVPWYjuB+gkMc9Ic/5sNAEhxksWUluR7id +VoTPm0wse0+ti7lts7DcS/LCy1/CL+NAvccVOocTTn5+yAInwUnArViS0afaEWVR ++UqYfupxReLxPpCr/m2dFftbvUh3RlNTGMHfiQI6BBMBCgAkAhsDBQsJCAcDBRUK +CQgLBRYCAwEAAh4BAheABQJTEPqJAhkBAAoJEANqnCW/NX3U4SoP/2lkjBPKcpX0 +e6tbNZHmGlNfpQ2S790kbyf+5gRjBDu3prlmdJMRDDjdVsh1HOkt+Jn2P/U8BOdT +ba+0p7D29W1drpTW5wzTi7aB/GrN9ovPXikZxZW0aszTcW2DX5LWjAJbhFXtHthk +YYiFUyKaqdY+KuqFqKVltUnTQ5/Agj+IfNloMSrf0vVWNZa3QcvWQcl7+o9Srge+ +F40LFIgIyXL3pFrA2z50o7w+ilOiKfoODS9hDp1bmk91O72KdW4k6McfBi3yTpUt +llNyGBCnaxb4FEFqtD90HCmOabRzdCOLSFMJxa5V2fUUmIvmjyoY+TgB8AtbT2zU +HdNGeygGGlRZnEXm2d3HnYNskDJSERZgpgvAcOP9s8Lx25rZ7WEg3MD8UOYxtNcn +4jrDGOqXU1G+VC8rDemXNQ0s8XDa5skqI7aFLhe0fqx0St2Y27mrC4MvkMQhO9ER +gEiptew37B5LuRZSxRFXQ4FjzwFjXdYhDJCeeLLm+/J6KovZeL23Ts5GkSTlTVYa +AKVsR6B2diVPsIQack0loW4M33MAJwAOsN8kmTBXkcuQNetKjdkxJ3PorG7X6pf4 +D3roOaw55NStRKICTqFHcLJW/bMpe8STp7H+9z3tmvminbgCbx3VE4VXCEefgm+C +ARk1J6newD1XNUqNA85zsA0nSb3Cj7DDiQJLBBMBCgA1AhsDBQsJCAcDBRUKCQgL +BRYCAwEAAh4BAheAExhodHRwOi8vcGdwLm1pdC5lZHUFAlXYMLAACgkQA2qcJb81 +fdQGZg/9FMee19vhxj3h96C4HJlhVw+wxL2qtxQ4/ZtrYAoateS/yan3J+F/syM3 +E7BzG/4yASXjJXrvgFaQqoEqttt4+LRe7jOdKyz3N/Utmno32s+qdQSTGwlfu64k +EHVULbPUxqDRUfSsO2WvC51ifTh9wHs+L+sDzaiQ9D/HILD5sO/QLhZEWh1uxwR1 +GI2CuhOHvY+Z5XIlDps32Da2zjNKhJfm6sBFvrO/hCUFq1YmvxaIbS2u68G5OPQC +uMhZ6915h3VCXqFWV6uSjbPE1AsmvbhZ9X/6aIc53VLNSLQVHWcf6QdhdHc++VOY +JVWRxjgE3mDV2rFm4oqgdkDWMISlN7EO5ghpYpcBgCo9hwD4NAfDpdL29RHEI82O +31QLs8x6SYJ4EJEL4CaRlGtJRYkgm4yKpIzpPEpTjqh16naV5KBzT3kIr7Ltkk8I +iPh4nTWpTSZ6n/W+Qie6L4Dy0hJGzii3Zl2oGmth+8wtck6l3fgwYtMZCfKIBZD4 +zYOxAHt0dRUmNiJ0i53KrRvVV4/89wf+3r/BXSBMj+pDqZoGUEl9DqRC1tTD1Zgd +lvK2IJxV+fSASTyAc4gxhMxb6ut7d3DQfGrNpCXECV5Ny7nFSRYLisYR9lhpyUr5 +JTvNovlyvZB7IU/0nzjVmnk0ZLFYw5UpBe6vX0x+rDvMHHzTsE+JAk4EEwEKADgC +GwMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4ACGQEFAlMqSD0TGGh0dHA6Ly9wZ3Au +bWl0LmVkdQAKCRADapwlvzV91J1iD/wP0qHAqvsihrEh+8K06s+W0UA9pajnwRVo +iKUM+bLj27ZBoTLj+dknjd379Whz534/qvVLIqHnznHvYrSfjdgvkjYhjL5NrFqZ +KSn01Vlfd4d099hqLYwr3UxDgDknL4xg9Z9naobGYhtmxO5f7JvpAhX+0hIveECC +N+uPk8fVIjv8rcbVGc7wLsTwI0CE8eYQS5uDLYj9bK1DaP4iyTeoPB3QMEDYjBZ3 +sQmd2iSxHSN5rDnFkqypi8dFx/KAAh0jFQcrte18nlwGxpB7SL6rjYhLONtEmJGb +2WkMgtokEMbKBWyqr7ERuXllUISvJSwsyLAifzdlV5EUYAjO0HHKog3OZ1AFQJva +cMXBBNU3pH8K+0NF6d425vyWhC1KB7ytPs/+nbVO4gKVJy3jZpWd/IJcSnOdWcte +2mXAl88rSP92yFRT4cEsaVz2QuQVxbu/Mk07wRGoIzqVeHzAhQjNjdem8d0ipHWj +3X2DYGwJpHOzDMYQVQULthX6T0DHYtPUDftCPohLXWiorsw3UBEkYnvwrl0XpODG +I+Wgg4nbV9VplHENLTjm/1bXmakQU4waRxUt+Fa+eobUmaoHTGdnxieHH/1K+yC3 +k5zMEjmJZ8KP4QQ1U4sqJwqpMxPoy3yiY2boRL4KH0xf8XfBaKsN13msuzK5NfgL +NgwaWff8DYkCaAQTAQoAUgIbAwULCQgHAwUVCgkICwUWAgMBAAIeAQIXgBMYaHR0 +cDovL3BncC5taXQuZWR1FiEEtC9oGQB/APiONk/UA2qcJb81fdQFAlleqCcFCQoQ +FlsACgkQA2qcJb81fdQ5wQ/9EsIJVJkJzvbmIWH2r6bGgh0e2RJSWCgGaebmKNBS +QyrMjRHmxaWMVNH4w5m9Kmy7vNO6oMog2ohxWGHDnPHhdPJ4Hx32o1urrhaMh1SN +LU1hygYe2SL5TCc4De53D49hRAm/5oZcKe/tsY8kMFie75MzEbiauvwKfE67faJo +FYlmvWx/wmqnkY+NHqfC8zzSk9bvzRfmFVw6M9qm08JQOy89OtjQGwHQimI1ipYf +0eb2u+FTsx9zHR8ipiECZO4rN+xAeEYlDxno9Xm5BKQXaBDK507Swl4l8LoFrZpD +0QgeFZJ305p61Nut2jdXkwX2M/FDpYiivcPw7gJgm4A2moU4+JLJiwNtmgyLs5OZ +UsWgNvthDvAQY2F5RTK4r83GDt6939yLSx3DM52QbjclUc4w+Apxu30xWCaC9TX9 +DpJDo4ZgPe7U51TCFjmx/NbzrItnkx6ZSOQWxCgD6+sSojnlbrx7NaCOQ7rZUjZM +DzCVSXumY2Y05ZwTk2yzB8NRmR4ZazmDH7RJHa2CqmT1f7DO6nUIpRpNX+XZSDsw +Z561s3mYhL8iUGMGH/ycuegWBRF+VZdgT6lnGuFqexA86RrE+DwXkuZUC1ODcKU+ +CRVreIkN8S5Ai9u7pjE92W4g1HBGotkQTZtiAs4MTdIrK4Y6EoprX/e7yEcfa1+p +4tmJAmgEEwEKAFICGwMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4ATGGh0dHA6Ly9w +Z3AubWl0LmVkdRYhBLQvaBkAfwD4jjZP1ANqnCW/NX3UBQJdGp0vBQkN+N5kAAoJ +EANqnCW/NX3UvGIP/1/6lY/Z1wi7AGK2NRAmVqCZ9EGXZKgSlExRuL1JXl6QiaUr +ydPtWdyclNzcfG0652fYFTLkCfeSJ5Q4I9ByigHgdgCBU1SeCe5bDAbtDZvlsic6 +OIZDw1ftpkfVYxKXSSkxLrk/a4rzPXGV7MGnt5p8JbXAtMWNRD/Ty2lh0rSZbb9H +vkGGvKtBW/jp8njzwXW+u6EjaemVAnbgh2HBgAhLxFNbez4PzFZePdmGO1k5Ig3J +rWaudA0Yy5E8vHQxQBFr5bJfT9LM7r71mZ7wXqhhGZ/IeVXqo40MKLOwaiCBb6ZI +nYnxDPLT5kbucUJy6pRpWWbGIq9fnb0QXf21Fs/vXm8oCKKy+uoWnAHIgUfWn4qV +G9HF1GaXbRYTMlkX4nOacHZ68FkaUjWSzupTlzMCX25xpjag6M3/V93nsIfokE6d +lx7/MAXYkxrCCXCHVAn9mr5Dd2aVcCTS5ggJRvz4hfHtg41dTZwan3BrCkt6+Azt +EMp+omR7YFXf/xGm+pgiLkuKidvVKV27w2/EiEFFKHPwbhrxPGfPD3mvKCgVNRky +B7Fxh3Dc+QBqmo51B9tFTlvzJnPEunNxbt+GGblv2hBIp3WTphiAiaQyFH+3szgJ +RvZlFx7dEd0hekapjZdQ4uLvXfAE7eV2iMVRfVMR2xyjxZdYmjb6YL/IoCFJtDFU +aWFub24gR3JhdmkgKEFuZHJldyBQYWdlKSA8dGlhbm9uQGluZm9zaWZ0ci5jb20+ +iQI3BBMBCgAhBQJWAcyQAhsDBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAAAoJEANq +nCW/NX3U6TQP/2rJ+oNLyN0ei7pRVAJpsapz/wEzEB54MDaw4W9M86HuV0vlVyxZ +GjS7hVhQlD8JnrbOevUsDdUbCxplVSSdCSSw4P0ruIStVIdhH6X6bATgebIXD7J/ +tsno8hsFja5CGEyXfqELqGzIGHx+nzEbuoqNkMA/2pn+PE+195hHuDYJbGmeASJM +kqEqRyGva2TXcR5xhZ5AmOyOHQM7k/KcbG7Mcti5XChj61l3+UDwRFatZP/ktqoz +egUAH26jIVD0njel/AmfkNZmlfisv2HWPEbyy4sgzvYViBuw0gNNnefaESK63cjp +BZSmdr9mR3f1Ewa+48CPAAOAbuSaPiYS5m59dMmJScDFBpKVMYtOxK5VrMXeTpPh +i/+/9QBH+sOsGpSXA8uwIftKyfW/7QwswqEOMAO25dRgK2g+GFFSobBPk7JL2ZEW +8S3OGKrQDt4eY0ZNhnQEFRcNWq1rS+Pigpsj7pkPeSdnqXi8sNhHPWDspWGtJz09 +mcOWPBfuaCagILHIGtZ2w2l9AXJaBXNFp9LyTv6d4fUKHAC/WoGkNJaF7r1KZu8W +cHr/K2twghrVpF88WH0uHy0J1M6vuCiThWqQcAeVr1rkgXOrY8EiSGQfCxauUO6L +QHlQ/CwKbhf32DemPMcYckRBbNTihkWtuEdHYkro4W4kcy3/O8bDtIUAiQJUBBMB +CgA+AhsDBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAFiEEtC9oGQB/APiONk/UA2qc +Jb81fdQFAlleqCgFCQoQFlsACgkQA2qcJb81fdRArg/9EOsxoRkL4IwRw3ZrXaYX +m1uw+jCVuOA9sGbIisJQWsgJoQmFC3KCWtSFDfNz+VillVRKy5fH7gv6flnEM64k +QWWZ6CgW/v8Xtx+BqmuXlt7fHx3oDRPs5i2D1dQEQIvw01R5PvZh1iI0JIX75l/S +185eJoeuzRhom/QWm7LNi/VI9UpRWPoraoMuWKH/InCXqR/qx5iY3a4OCrFf9a1A +FogIFLL+/iDYuIlG3ckSRu1ZbfmOSPKCFUdCPmy+8eBYgsTuF5HC/u66g2JG5LT+ +KuH0HkZnBv9bxAh95dEHa41z66/Eog18MsqYNcxjUuqJMHCIBdpa557zGNT7qx+B +nQ4L+qlq/7Wx+IPTCl5kYaE+TGYQOuidw/RiQfS13F/3pw80CLPicVVRJezO1Ntu +oLC9BPVTX+3T3c0IF2tWoedHXXk9ngddUpJleLmd6M23JfNGAddz6JYU0/+ZaQdV +i/UspHB4+uFgS2dDDbxzwcFivWfo6wcP+wK2IDNRSGBwLNCe1zjGFTVx62gMPTXA +wcgVR3MbJKmlLpT8jtcTQI8ztyRZX8JCwcBoczqGL8PPRQdUTAiBe3U1tLu7RsNL +ATLI921wZFnaM+Ix7LwhL7ccrhI5hr44tbnaKINZTv0XJZm38WyGMlYwVjJy6QgG +vPzjhnphmW1pmLH+d4xH4uiJAlQEEwEKAD4CGwMFCwkIBwMFFQoJCAsFFgIDAQAC +HgECF4AWIQS0L2gZAH8A+I42T9QDapwlvzV91AUCXRqdLwUJDfjeZAAKCRADapwl +vzV91AxKEACkRsKAy5OS4EeeDPM2no76ib+udhBZDOV7vQSPFFfBX2nWplEErB4X +AGnq0qtH74fC7oaZY4la4NGl3js7ywp1smILwTDqwoJuLKpFTFHOuIb+cjwoWwGc +xHXt6yg3uTZ1MSMjUwKcgeOSFglNnNpZYbNeKd/A2Nral4EGbmBgMYnlpQ/KlWdt +mOhterk7pNstUIbMwmnKvZaotVarD2hirLjCAOS/H1vSTWozrPIoS+Gf6akZrbee +rm1Yf2b+AffqG22J9WQ3X4tBK57S/z1dKGNdYXozOtZ0xgX04E1tzIfhUZpB3qeg +8Z4+Kq+NIW8ZwaInXK0QJq6XYLg16QxTNEyixggVEkCLOsih18EsAV+O+D/cUW6O +CvZKxhwCuNhaxD8bV0GhYuYA4jGGWHkoJ4WaZCrbL3FlUiCHr1IB5ZIvBiAa06sK +cdmW/nOh5Z4NAfQY3F68FjMO0yFGgTsH/Y6GMcPyxEULghFMd6peEi7/xdq3Mdlx +kCWBZLumFlPc/7QxO09inAhQegAYn3+edfjjzgIVpy19CXdmyPvfU5pKv5NWSvDx +9zfk/2usqIFK2PX78FNzcTly9W4W7QFJSgvTIB5gHNrsbAOfdrj2ZsmSFZfIZguY +RimpS5j70BsLRxZbVoJISZQMBv+76FxADGmrJbhyrgLWas93NhSh1LkCDQRTEPjH +ARAAxSyokySqcTxoPVPazGZ8VLisI82ydHlynumF4OXTx6iERNQi3keTlBXMVdso +nk1LxbJjXCBlCttOoWsQI8csqdd9qA95IVGzH5sjm+79voYzkndUeCm21PSjT8qC +7J3IQt458Rb2RXkO08lZkdZLwGpHhaVtLW6UV7iT5a2wzDkviNz8vlpCyHP77LUI +e7vTba7UajoEx0e++ey7svg1KXksk/mXFeQr1N5mqDEl2dGwiTxWGjmHvQ466iIs +hdl1MYXeT/lJB1XrWT0S68ekbOBGofrvuBeccA2LhGfQI7yVn1NLY4Ap2AjBd260 +rm/8TIVmRzv9vtznK0BMW/d3m2/fO6t3z8meLfLdcvkzBo0tDX1JNfUkKOSARA0h +d6MOTAI59tf7/YBiNPz4pkgk9zVzMNCoX+yWvltcI/FSguFco5yLPl33jkH4mtqc +7B5sPmbPh+Tyv5suC8TBNmrTou/pijL3VYaVjm/BYWgZiIyptnRFfK/qgn2PcgCJ +4ykM4z/+w6yVLCm7vCljX/RHXIcLXM1cfvzDZ6rBv8PgYxnVhU9IWn4enVf3EVjF +Y5rkDVYUCRAUOZtGfuQrPQars5cuOwCc1RPZ6B8sI6fvZ0zAavE20k7z0eJUcdgz +w1dg2GgKws1Le4A1yqg9X60ZeUj+xumO2yScd/juVnARtaMAEQEAAYkCPAQYAQoA +JgIbDBYhBLQvaBkAfwD4jjZP1ANqnCW/NX3UBQJdGpaiBQkN+NfbAAoJEANqnCW/ +NX3U/vUP/0Y/KAMF9feW2Qw/iCQIDjkM6GedV2JYNO56PtNt5xxUOntQMghzS4Dm +7YooGgZqaK5/mSZZmKgzcy0+gvK/VOrHtMrbfN8oNw4TiATC17ZQthzWujYLuVvR +FcKQ2VrhLRB9YRPQPjk6n8nBw5MgvO25K4veQfMdokdWoxi7cx+hashqigMYCDal +k11IVFT7hqS0lwndibNEHaH7Jz4ZSBb7+7krZ58KopYFqnziAXUBB9MfcJf5ua3A +GqEtpI2bmvVo0+CKUGIoxl8oXL4Xt5MBLVM+yN90Mxntou2pGkkZp36TW1QtvGU9 +glx9B2whzwob12PRLQgGuK/oYFOhIhVWKK4KLzK3lCDXD/tBWCKVveF4wE/WQk4M +Z73pNhhsoJe3+5eErG0JPcu8G6gGvPrQB698PD/VT7anxwW8KxOUcH+joJrJSNro +i2U3Q4FA9eguVArTSjT/XWwYO4x4BzeC2qEOhJVGoWjkmaWV8MIvaP7gqy2P8HtH +c11JWMQejbi5oy/UZM3YOQGHHHP93ck6BOddbU1xGOd2lR0OLOh5qbhOpsGoHg7L +MfcdrjPm9Hkjlbl+6fDVhzzfpfHR3z0MeUM8oiZaWdcWJOItj+Yl7aYALNt2gWO6 +vnAqyL2IHwpBAoWjyut9z/qzZxHuODzH5/A2sW+4gNkiaX8yUkDD +=viYu +-----END PGP PUBLIC KEY BLOCK----- +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBFhoRoABEADCr3xFypcY2okDw1onNeAVt3MMS0TbQWVV8W6NHh9WN9H1tkrt +EIXH8SNSPjfsix039PQ0wQ6akmttcBYNZb6QHf89F1jFq/HU4wJQJKwFO1B9gZ7J +PDzgpmn8mmGIRuhAsP7L1EP/OQ89A4YDqLRyTpXnEA3qqHeFqe4PXXxrk0BlY0SJ +i0bAxH+GBRM+cDOpFnNkFQJXA4+fj49RGNWq7Os3IudJ8G7FQycsLZHxlU1pwzjW +CnQ+bodnoVqrKSYBPj90Rt+quUsLH8AI0jDwyn8n7C3wT3ESLLrXRDMtSUuwzDCl +T5DTtYubKezOEWnV4+0yyi6jyIumhXGMLgynI3mEmOrUqKZd6zXRSmXZSyQts6jo +Pio4Hbht0DKwnBP5AYuPSOMwY00JfJu/8WzkaDpscMn6ujRamGHP1pv1tkc8s96B +AINe61elwHIveQDoNFxsvURFOtIFJZrX1/G0h3ZRPjKKiR8/tB7BeVkoa77BPWlj +r9NZsNHjj/z/W6CuOblEgZ5IyMw1gmsqkZZs+3YLUFIct7f/dm8OWIsIkpdBqaZv +ORehSuyBLnygEhugN/N6Qf7ih0k1uymCjZqcqsNgjH+kfm86tYI1BXvsSQ3AdOTP +hUKGNfYNlOZdHCjMRKLVjVPhBpXGxugre1V59oAHCX47QYL8jlL5yC+czQARAQAB +tCxBYmVsIEx1Y2sgKGFiZWwpIDxhYmVsQGd1YXJkaWFucHJvamVjdC5pbmZvPokC +TgQTAQoAOBYhBJGFgT3czXieXUulG4hLZJw0DIH0BQJYaEaAAhshBQsJCAcDBRUK +CQgLBRYCAwEAAh4BAheAAAoJEIhLZJw0DIH0VEsQAKphoSkrwXDFEbFv+sRxiaRg +IOZ3TKhRAu3LU3XGwqCf8aykmCQXztL3okUN7J/x84fXMVPQ5DWVs4Je8ykzm+gB +FRR0kYL8nCpB8GosxKtLyuT640uPc4nT26pieskrO+zJy4rBBodmf6yWFGOyuYPv +/CwSejky1NRMTmlAbhrdcq+i8+k9V75xIZPHo0BlRCHUnfM4Bp8LkGH5n9C5Wx1v +irQWWn2m82sUBoO9t0UU38f0k2V5+//19UJz5pJixfQ7l1f7F2OTFJhq4IIjIYB/ +5cy8PV6w8EVjJQRHdP2unMXeBZ7Dpbapi6BRpIlUelkG/+27CXVkhAuYm6Eb9r3i +rPMSKZnlAOkD+8IRrs576lEtCV0yNulDB1y8BfEK+Eq8dEnKWNwb5uzLKGyDdJFP +G3sMsr/Z9bX8Eqi5EiD+w8XRJTDwr9fhAqvNO/TvTyYRo+TpiWHPj4pegrFBjbX2 +q/etfFljrT80n6QYF1Zu/Uf4aFUyAylJPRH7v1pbg7VIIyo1EWWUmnD7YLsUTbgf +pK+iMn+5Oufdn9zdg5CeGeBeDvjCsbABgmckQ9r6/VPwYGh6onK9PpWE6mJBEpTy +2qBkT8sfL2cf0sqOYwv13fHxioBMUjiiphBQ9j16MrejPRlR9jGz2Q6DyJrh7L1S +vglLyFrCsa6JpJo+JlXxiQIzBBABCgAdFiEE7mYgxxNrDSxFbApN6eKN6gCqVVYF +Alm/i6QACgkQ6eKN6gCqVVY0yxAAkG5FwZYHm6sc+h2MNT5ZVb/0yGvpoPxqWQsb +ICd9y9qflF6VjIKnWSKYWOJTwMuq6fRtrS62dzdN2K+RMaudI19noRtBssaw2WCl +8l9RpE0ZtbhLOP8wB8jaSkUOLF84t8Td/ZUCNT4X/BZUE8ov/4ZUuUL47WRV07yz +pZUKydqEw6xWnPKM8/GvrvIj0vij/00F7UURnq5LUsY1iH9EdzeTI1Hmb6OhtW2S +sG2tbkyqlUqmD8EsRZe+ek+/qwfJpWLGn6JoXuxeLvWpP/eTDDP0mFF6xK1egJyU +o7YaWMgiEcYnFYr+/TWbV8WQXrBK/qk82Grev2s0itTuLw0n5C2j8caIOsmhORKz +TbLgrK6ytGGdRikzVS0Q1zqWAKc80EWbtmHpnz8Dxq2vYfMvud04+xQ7cbqwADeA +rye8fdTkhwGwX6JOIYHoBC/IUeLjikRo1hprtn0vVC5zmU8GIhPScL1C5x3+TDOr ++VV6xGRnLqE44hd4rgRATemf8QmNjP8PLx/+PFaew3VCpiMUNsLMcJX5Tx3EyntN +YGCBHkpfc/WFqP64vMfP4uitQS5qVCq+o+Jlg7DWJz7vq7+ivuQx7Ll90Lh5493o +kH58L4MYv9O4Nb4rJslurzLFqrLrXYvQJZCMMf56j1VnCRk5XTnZNWJaF5B4bWNq +MJRpaFa5AQ0EWGhGvQEIAOQI1tWXlUtzjEozWrZuoB/iqCVB085U/NCjJ11hz4/9 +VJi4ewZJTLou5t6FIHM4l4RZ3oXzrkLxFLy9RaExGG/WSiIyLu4jF+hnU9EZZnJR +RC12kcleM1ZGJDutenVkFNGbxWySL7Dtzc1qZK0v0XL8yhdrUEDFTsJWZJRlZSLo +wGL/Rq6CiKnyMVK+2ab1LOQJQPTvswjuVsGpcjqxzUmpNiYDuCBcI6cPfSt0KZzB +aivF1EM86pkE32Jckthw/u/x1Ye1C7osxH+oGqti1aHnQRnyVL4uMTlStNshFHwb +cns4falyrIiLZTCauy3grNgpK/TREjQh/piRj1G9Si0AEQEAAYkCPAQYAQoAJgIb +DBYhBJGFgT3czXieXUulG4hLZJw0DIH0BQJcQb6vBQkFuqtyAAoJEIhLZJw0DIH0 +p3oQAKxa0fV4zS7uDZasNTnEm03OPR7wwuvaRH0UG2QfQvZ554rxqStM8vc9aYy8 +8vYdA+mpm+5jq5SqdNqp/quh1e6kSEmauBzWYm1ngkqNSqvtt7MwoXoIAWQyAsMq +uWov1AJLQvz3iA5HNepmaCgV6YZNsmOPawi0Shvz6+d6H9qC/6jilfxI1HTQgcCl +9POALCx27MGktZDmLM4slu5YW89RVlJz2P58FAQuCkADVWOuWx/jHZIqNcOzAc9L +Nlx4+9202v4hNYH/xHteJ7cejJh5H6RKQM+RpJA3sWRH7B6xro3PxMt9yVPttLcn +Fqulk5kDUct1eDX+icunq/14rY4BLpQqrc/0DnMimdF1W4ivlMD1MRZXXDdUd84D +4wVjJV8CFWTSm++guj+BBg02+xfGGrbmA1rqciTCY23Y9UpHUB/oBPTzHUBkNHsL +8Tg/QJu2tJ0JQDKcBQCKLPlmgQrF2+clc0IPLgy0YgTm1Z3hHlOSNnflSFzoXdHx +df6WlscXDiJWMEu35bOWRaeORQX0CplGNXpUZnCytjDUFlI/dVTpqZ9PEB8cXXFt +YebLPPhj24DNHPTcrsbhVCDFlIjjrLb4X54GwftenXl8n2qXm7DcJ0mHTESlDirz +GrShh5KVyjhN6kBXgTYYROma9DM2svmPzXAKDZ4QhuwafYU9uQENBFhoRr0BCADP +0Df9A0P9fc3hlWKZgo6RN4OL+oKnCxxCm4N5H1Dg0pFsxlGS43rYCUrZP/YZOZbS +eWLS095gf1zwXYg7BhgHYldMt7dkWvXJkaWYtqoh7Nhj396U39Z34G4is5aQhC5p +ncU7rmcy/hBWeoocM3ep32nvgbaVNTg0dOEE6Nw6FfEyvaKBEAOhXXTFoS7rVwYZ +cKRyrBRKPY/Ln/TjKjTnqfq9h6QfJi/jgsU47qg1wZ3yuBiE1iEoSMZEz0lAQx51 +kDsKwPvCQ79EPmQTCGZeZqSpM6dtYmznGr8jqd1tF1SRDwzZozQ53DsPw6AUSaIm +GKQzgN97lg80c4Yx0TJtABEBAAGJA3IEGAEKACYCGwIWIQSRhYE93M14nl1LpRuI +S2ScNAyB9AUCXEG+tgUJBbqreQFAwHQgBBkBCgAdFiEEpjlV15DEkBwxvQOW14DH +58WSeMgFAlhoRr0ACgkQ14DH58WSeMg8aAf/aM/zAMBQ2UTKswjWTH7ER/tzIrAo +NNNr0sogyN+/vgsY1Vp+WhlLZa5bgKAjHTVo6vDl4KshIqzVCcRtLt071skdTD+p +T2292IjmyW/Tfo/F+6fZTwH8yLmVoOpdAPNH3zvFAf3vKxAwJRodqNguMluRBMQ0 +1da/1vOCTwgftfIaisaJEUvPg3mjFQhfTIQIeU6pC+wPez6uBP4lJ1G5lX9sgJGc +p2PiZHXksozzxQ24YUC7o1g5291fBxMP4b/8pZ3ukBs/4URW7C/znyi/U2J5RBFT +pidJocWPxLE0O7a/f4PatUALL2OmJbX3zO6gvnYbumEWUys/X5e4TYQH9gkQiEtk +nDQMgfRcnRAAkQaoVI+5Vo8f2uMjfF89I/bj2Puat9OcBGhF0+QDdjxHmSvBA9yk +5f3J0xZVjqXLm1s0yp0lw1to37y9pMBF/w4mvIG9Pfbfd/H65Mg1qLEPCrkqRiw0 +of9hQcU2gMb6nEkYdB72PXm3NtUTGCjZTGa6HNO+H2OQC6PCsguDwdXwtTpWHXog +a0WsEaUm4zJKxAyNHPRbetwrmMoawAHnRWoVSM8XFXDwXqKC4FtxgpcL0uin/ipN +3geGN519k1mG/UB2e2HLrpdNvZqRsl8WFhS0uuOeSWZci5ci6HSyqkDPeLDsdsKP +PCTcOxPM2O2gp1Lec0qhSPboSuwob7ZTZASUbXVvYMeBLgkFNv4eL0R0ZbxqsI8t +b1l7dYWNQ+H5YJyjz5RW2xmLDsHKr9eaP+4Vd8iS66LqMZHD3VV1AQ6X2HVrjw4+ +9AKkpo85jmcgRH3dw3f0x2Vt0AG/u6Zko0Dt3f7eQsqKRkVMNihGJ8bWh6KNlZXe +ysWFp3dnw8b8xMpxDw09lAonnE58XQT/e3c3viuH3q6PDm9+rj+z9VKAOnTbWEko +4apoE51a7agMkQMZbk5OODxuaFUPKH/E8xsEbtJ1HuyHwd+avxk1EFwO9kMAuK73 +37Qviw6J6i9raC4P2mOKBelcmyhFIy7Lsev49fZTpCMzCgRtr/YBh9G5AQ0EXEG+ +xwEIALgE7g2GEFW3k/3LmiAJpzdKJkUuxze1Rp9twD5QD+IWSuVFbQeyie2xoVVW +B3LL7lDOj7eDK+85gI5ed+/0QFyDIVQqZ5LNzHaIQt5SDCaECyrXWHxWI6GbNBix +PTeHCR+h43D0/nWxAAAtCrYDlS29G7JHil55Bmck8iSB4MTojOkc6l77t3sZD2J1 +oQuM6DWKyuuDld2Degd2Bs0XtnCYIzvuj5wkt6LZB5EYRDpPvNFmU5R4+ye9Q3I3 +3Hg1xe/8w/Di1oDGTs4TRwbmUEFf6OrQWmQhqf2xO5AjJlhBWdsnNkeJoeZBhvOc +W0Rw/oIJTAcsORCkGVXW85US3x0AEQEAAYkCPAQYAQgAJhYhBJGFgT3czXieXUul +G4hLZJw0DIH0BQJcQb7HAhsgBQkB4TOAAAoJEIhLZJw0DIH0F8kQAJc2fmE55hrM +RoNOaGPE2VqBM8Hrv4jDkSjcQqN89OXnBXWzS77PGiEoNPl0x/SSIlB3JZpC2yaw +FcKjZWAln3US3DaurF7wHQ9jXYm3DvD6/W+nZJl4zAtOzZBAMCy3Q/GbLr2T5iPh +Y9DAd/znSHQJkQfqS6WP1xS1D8FwcRMAAmvqiUTq06Dwe+wSddfvnMZ51mGQeHQ0 +O3SdT4xEHHY3FW49gCT9uOleugZwhwV7YEjmOPlYiDBAoC5V6SIpQIy1z9uUaQEG +9Y/NF2d72LfI17N/2FKhpOY9kQZgopLu7tACIaBH1KrT0wbVWbl/v+bz49RWOtC9 +YsE9og4fhJ9AscXqJybPryAQSblQkYjmInu3xmAQXRqL5aZZerLeCLoWNPChQj8t +ON+8tJEr4Z8KI/4zsSPqAnXx0yaFGalkwUbgWS89mKGPTER9kypHTtIIN4+4jn8P +/gn0/qT/BrGYSV/mIBWYKize+i1yys1LbnBwiTOMXc7jNF5VbJnCK764WUo/1fGW +AKK9mgsgnQS4ogudWRpCrLaYPFBZxSwjJEh/DxIm/cMLeQyr3SFc88X3gJkSICxU +Rcx68TeLJ9AYe/zK7FMmo5WQilUi0I6qiHyZxUpTfztsUSW/DccptT5rakf+0bGW +sAUZwzJ6wZuOXVyvP2c4PWZvz0xg/53TuQINBF4XYdQBEADHeKkR978ggqGvHjks +UC6msXV3AG0fQN3KqaES6mDPkRVLe46xjSruFSix+EXzwC8Ez/VJ5jRD2VX2B4eS +D2Q83ZXsuUloI07c14qXkiHpq40RmI7XixfChcgxouwhpxwgWJgcdWS9a6YjC6aD +QAZ0Q3SrqM/ngISFSf3jOvCgYdmjv1/2cwYK8SHYxOUz8fjx2a8Arx4LU0Lo50Rz +Dof02/QyuMFm2hbn4gWEFAKQ3trQ9CnDdvhFsIqJob49mu4TTbIvzLhMYgcY9IfM +hSn2U5AxqYUKO+NQV1ptgl8l/ozL8C1NYlb+wxVbstEqG9bGib97VuQuq1EF9yqX +IHXFVCouyqZnkipe5Sxs678n4dWimq4EiKe+bUyusAlm5OmGzChErfuzQKcJkZbg +AjKBGfeAMVbvdueFWe2zC9AlxFQBq/U8vWVfKIYhOlfHd8jvnXrv3Dzw7LHYqbHp +x4dG9jbPsZb5SbTjo9XioeDLXKBi+K+M/FW16airzLZamyXqrCl1N0fydg7DtnZK +ZYtw6WkemqlctlgrqmkHCjm8RDUfYZkRzErXcKgsJRL8xQPthppjqYZoP2SI2czJ +Aom8nWe1siG4D8jNshQ96AQkP9zM34oMueJ81ve3EAjGb7p0YP05y6W4p6M7twWu +81vi69+Lc8K9KLLgp8N+TMn70QARAQABiQRyBBgBCAAmFiEEkYWBPdzNeJ5dS6Ub +iEtknDQMgfQFAl4XYdQCGwIFCQHuYoACQAkQiEtknDQMgfTBdCAEGQEIAB0WIQQN +4e6eInfqwPwdC4ZI7NsnpIJ6rgUCXhdh1AAKCRBI7NsnpIJ6rsV4D/46f8zawRKG +h+7AyI0JWHj7bDHIa+S3h7xPD/btYVY4Zd+xD8rGmP8KpHRWwslFtJf2rOZkT+jt +ZgoaZHHKwArtvYmWIkviFYd9pMsQ3pgaUf7U9hV92M2aLZrGz1MYSGydzYUu6I1v +dYq3T4szdwfZvSvlgSym8LeWSkalV6apJlAAyn+j6K6TqPGz6y6Ay8Gjext2KnzD +4SM7UBc9ypn3Uaj9+eSYsUTZpStMgUy9lEGVAozAM/+nWGsPjqYuqtiWA2IVfLyT +tjIqHL5hvDX+XMEnV2awurzesYxwv2gKXYSXR1CoenUMwCgcKgb8+DDqo+ElSMmJ +2mNitQrwISv+r2q1ViP0fePhMjVG4AghVw+8trudInAoMUoLYZws99Q41jL1RVe3 +ZW8iut+kox4ukPMa87UP75TiNghcAhN9+3yThNjzHdvQo9R6d+GCZp9wgDCK4FUi +61Svvqme9Q0BIvU84I4qGNQUHK0HMyBzghdu9YMHIOSOklH36xXyZ0AgQ2wHBCfK +Uw+nj+KZ2oYHPOylwUlBA4sMQmwqPyzPevfdDzwl2GLOMeJWz2XqK+Wnjz56+WN2 +psnBQfpn67inZ6MotzaTGnf9jpK/+PCHR6WE3Wz2XQrYg+yvRIcBRaQqa2yeRG0S +7lRdEjMnjYbfVyu9B7cbapvI5F7tufgfQjtPD/4oH1Uua6DRHajMXThyUBxhvyI6 +cNVJNa/0iLV+dkxp2AdphCOVMoAWqmOKl328QFga/rbewdB9rc+XGxbgnxnhmx78 +wCwe4V5GLxhZ+hgyRyzGaaf5HLd2/jjF2EGgTbaPZlhUYRMVdMmLz4r3MtK1xTa5 +BAAxPAy9Hn9qilFqmU/4ZY9Hzp/ORwPn7Ao9EKESDCE6trN5F1y4tzCwDJ/NQvQu +4MqTK30E1CxXiVyG6qHuJEmWCdReLzRQ8mJVxyEhuxZiWzTXHdc6+0l9fmtrdd0r +H3IIWJhPCz/LO/9bMHJnXQB5BRKXw5vZxSuqCEkfWBnj5utaHj0NrhkwEitkgQya +XRoAtJ2aaHZf5KiUXwclBDpDSL4Zb3GgI+USo6v95QWggWoURTO6RwTOHv7pymf7 +WcR0n9Lnc5GohsPsEDal1uZ8EnD+llErd4Vm5xdlgrxgEYeCqCI9fm+Re+iyQsnE +EDB1XM1jVSFFOvZmmKc0FUTPWqdC9eeaihYuaGGZhJilAw6hJmtAcjrjgp9IBXqg +f5whE0MtK33bWQXKWrTmy0BWCXxfWb+lATx2y6q8ghvbS1AB7vAw4/nF3TzHdO3u +Z9LR3nI5EA++MHfAbK73k4XX8KWdNyzi6YAJnsGbB9yKfR3ttNd/uUBOV/Y5eh4O +uQJOsAHUSZTIEsXVi7kCDQReF2I+ARAA0508Y3JwOCizaJgHDb2G5D4b7ofb4NwM +AJu4rRKWzYj9s4+elFPnmv+lWdtzKy82KYZus3T0AAlGPf9rbjAkjpZ8z2ZhxRFx +FggK5I7L9kXMPQYZ1XAiQyf0y0EtbbnzsMWdq20+tZeDCv49eKDd5jnIlifotrNW +p939DwA1zC61l2rrT7gA+lN6NOrXxxLiitChRBgcC2I+vzKGcfgGSY1cwh5yT+2o +T2yrca/qsJeRR04Tz5bYVv2Gb2USYXt+/CITQg6wR94DlDXW9M+8cFBF2JWFJnni +rpjkcKGSugiWp9BEaPNq9wOt52hfFGmQmL5/AZNVYM0C6g+ODyMwJrW/a0MrvZbZ +iJcvgD3OU5AKkUEwmS5KfhLwOYJktWvy7gaOo3/vGo1P4vE/QhgsOjwUps7n+uL7 +JL/vGZLJB9W4K4g/ofvp00knSehhE3ZnXncA4G8F5M4T6wSvJCgRgYI1BU8bFRNM +nohhEly7eXIAjkY2zIxt29NMpfCTbFHlPhwYj2OZhEmKnI2lksSTi6r/DkU6gr1O +J5ENsd9gVT8uIyIoBDM6S2IzHVuSGTTKNSo3KZasHuwmEeki8a3hsGuuaZepYrGw +bpH3aTu+oZ2xdLzlwzSEWxCI3TNzSqHS59ICjvwdFKyJx8AJUOHn06XDCC7qRWcw +6UCPXwI0y4kAEQEAAYkCPAQYAQgAJhYhBJGFgT3czXieXUulG4hLZJw0DIH0BQJe +F2I+AhsMBQkB7mKAAAoJEIhLZJw0DIH0E6gP/1jCiZMtcccXTBHtgg4toWiTC/0u +757Ea+BmzY34kjFx63ANK/+/Kusv/+Y91eijR1UhYds9Y5tmGYIwhbmGJgDz2FD2 +CVd9tXisoWUtDRflVcSu2SJ0+DMzBZXxrxHaGsWPo1S5dYHOIWSTik/Y5eWvgWHo +tMzhquL8cfzAzXpPX1jqBrJrXJCY016yt4vb6Nko2AoDee+DTwS93ZQV1vMZlf3Y +7+rvt5C6C9D5PxVk3ETtJK/eukcp+cAjRn7/EPkUoAUDzccvMwa9BoaiqPTRI5k8 +QsmYhg12Kcwu9X37VOUY2eoec2m6+k3dC6iMPZdNXptpgqzcF0p+mUj4/Zuxdixm +d4rNDv+J+3rwXcfDAt6RtQVpDj2EhpxV3srt1H+1FehVYpi8aahXN7ch0RhtRwAT +QHcmQ6X6FKM7HVaYG48FPwbvLWb3MEKt6iAwlkvzYparStA57hXNdvECTaGigYM1 +k63enoQ8AxpDAmwB3Y9d8duXpGa7wMWa7axdUV64rfog054nwH4vki0jJjnrz50K +qB3yVgc4A5YNaBZt9T45yF+wMT/kR3j5E4r/3RfReb2xw4UJ4jRy8Mg08aHvFUER +gleE5F68pwbfe/xytaq+PIVbD7WAJea+Euja3LpYEwevOwwCrouqFnQMJ0zJmyRj +ImeRZ27WyrurJD0guQINBF4XZM0BEAD+E2zSaYypg3YEppd0z4EQZCIbASNVKAD0 +GYsCAxiq1bCYPJFN/5hmhyHujDyMvXRz6uArEcUgLzh3iyVtFHmehbEQy0Gmo7Uj +Hi97oebo7JNN5Yq5c8G4tZqo37yAg3MBneJY3vq1uxHOXqrpzHBe1RvHzO2/ydMY +HGUdNaQL3iZRyw2uuS07zdLuLxDx546RpVtC2lZCm/Xn42A4q17ibub08zoq/s1F +obuDlXG3j9JPUDaOt4g2bK0sKMHjWtbcq+XhnWJKJ2k5l+gY2ro6OgqvPyYkauNg +SDnL0vUui/T/PfgkDer9PhLIo5TYWc+rFeiYqEcwwDEKP3x301Ywh7F3UJsxf8lQ +wAhZqeRnb8n17O+Zv1vZ67QlFFibnjeUvCWvMZ0hemCgdsKGzR3T2cMu0k5iL5Xm +i/mTRC+iN/0Heo3N1sGytMpweKJJR32+GlL0qlai93aSbL73356ksibhP6R5e/c0 +aTmUhgtcU1LZqEqo6eRii8SCab9pQw3/RoMxUZiLUigJJCpRAlAv6MGg7xUOZx+g +jyOiOVvom+DkAAel/L7hm6f5i9XRE+5bS74wkirHwWOoZnZkisSUs/ieOFwwG7ph +G6QrdJQ8xfxjPMR8W9xnrYD1Vd0qJBjnmUtHtg9el3see38huUHDbgY83x3ut6lW +1tS8YTrE1QARAQABiQI8BBgBCAAmFiEEkYWBPdzNeJ5dS6UbiEtknDQMgfQFAl4X +ZM0CGyAFCQHuYoAACgkQiEtknDQMgfTt7BAAjn/zMf0+br7Sk0DmOLgMaAh8Kcye +6VIiJmycJSaoe8j+2c8zwvZkrBnFIT6t53VmFw9l7AAFCmrud/ZIwc5se/E+wKzG +wKt1sMcWGtQ4TocD4M1ycdEdQb7nMPA47dN7ySee+KeBZJqe/SWSfkwc2Vf7KIM4 +xU8SATbWSJchALE77nD3K+w8p4s6sl0FRxFEp1YRh0u7Y94ATPg/apArhfZHxmK6 +d+pJLD05ZIjfDzRrNy9JYEug5KmIf9u9Y7XjWOQmT4O8PjYHIyuf/QbumRwYNxNA +0E90tXcVNaO8f4uEe6Yqzvy22YaBIekDa0Pex21O+nlzfLi/vmxeXook7S+OC9X+ +YD5ctbqFYsRHgR88NB6KLlESaSTI70KUSmrIWqOf2OUhiZMV70UevfQY2PwD5lfi +mlLbdd9XUVWJKHwU0mFwMrR2yK9YzuQC2b7/4wqCOGnjGtYGJcvKT1atLUeEKpoL +S3MDhW6751ZyADPa8M+rygrLmN3sv5gy/eLC6QxSoqV1Fwj4V9OTdQf4qHRGRJ4O +xWGYyv4ElpbXtnhi6No1EYe5LNASNQJipuvEbtLrT+NMKDGeQLET2ppHHNsJKCdq +c5Y6zNsd8CaSTHnskuQMr3RbII/CrONNxK2UX7kwoVeAr8WpsvqKoO9Bufr5Xj85 +qt8sxLYxB1Tx//g= +=U5jx +-----END PGP PUBLIC KEY BLOCK----- diff --git a/zammad/package-auto-reinstall.patch b/zammad/package-auto-reinstall.patch new file mode 100644 index 0000000..9180e9a --- /dev/null +++ b/zammad/package-auto-reinstall.patch @@ -0,0 +1,34 @@ +diff --git a/app/models/package.rb b/app/models/package.rb +index 76f21fa..2915fa5 100644 +--- a/app/models/package.rb ++++ b/app/models/package.rb +@@ -493,4 +493,29 @@ execute all pending package migrations at once + file.exclude?('..') && file.exclude?('%2e%2e') + end + private_class_method :allowed_file_path? ++ ++ def self.auto_reinstall ++ path = "#{@@root}/auto_install/" ++ return if !File.exist?(path) ++ data = [] ++ Dir.foreach(path) do |entry| ++ if entry =~ /\.zpm/ && entry !~ /^\./ ++ data.push entry ++ end ++ end ++ data.each do |file| ++ json = _read_file("#{path}/#{file}", true) ++ package = JSON.parse(json) ++ installed_pkg = Package.find_by(name: package['name']) ++ if installed_pkg.nil? or Gem::Version.new(installed_pkg.version) < Gem::Version.new(package["version"]) ++ # install new package or newer version ++ install(string: json) ++ elsif Gem::Version.new(installed_pkg.version) == Gem::Version.new(package["version"]) ++ # reinstall existing version ++ install(string: json, reinstall: true) ++ end ++ end ++ data ++ end ++ + end