Merge remote-tracking branch 'origin/main' into hs/improve-request-throughput

This commit is contained in:
Half-Shot 2024-04-08 16:02:11 +01:00
commit d2b2a9f074
56 changed files with 818 additions and 330 deletions

View File

@ -1,3 +1,12 @@
5.2.1 (2024-02-21)
==================
Bugfixes
--------
- Fix Atom feeds being repeated in rooms once after an upgrade. ([\#901](https://github.com/matrix-org/matrix-hookshot/issues/901))
5.2.0 (2024-02-21)
==================

177
Cargo.lock generated
View File

@ -78,6 +78,12 @@ version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64ct"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
[[package]]
name = "bitflags"
version = "1.3.2"
@ -111,6 +117,12 @@ version = "1.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2ef034f05691a48569bd920a96c81b9d91bbad1ab5ac7c4616c1f6ef36cb79f"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.5.0"
@ -138,6 +150,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "contrast"
version = "0.1.0"
@ -228,6 +246,17 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "der"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c"
dependencies = [
"const-oid",
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "derive_builder"
version = "0.12.0"
@ -266,6 +295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
]
@ -417,9 +447,9 @@ checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
[[package]]
name = "h2"
version = "0.3.24"
version = "0.3.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9"
checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8"
dependencies = [
"bytes",
"fnv",
@ -608,6 +638,9 @@ name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
dependencies = [
"spin",
]
[[package]]
name = "libc"
@ -625,6 +658,12 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "libm"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058"
[[package]]
name = "linux-raw-sys"
version = "0.4.13"
@ -669,9 +708,10 @@ dependencies = [
[[package]]
name = "matrix-hookshot"
version = "5.2.0"
version = "5.2.1"
dependencies = [
"atom_syndication",
"base64ct",
"contrast",
"hex",
"md-5",
@ -681,6 +721,7 @@ dependencies = [
"rand",
"reqwest",
"rgb",
"rsa",
"rss",
"ruma",
"serde",
@ -722,9 +763,9 @@ dependencies = [
[[package]]
name = "mio"
version = "0.8.10"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"wasi",
@ -821,6 +862,43 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54"
[[package]]
name = "num-bigint-dig"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151"
dependencies = [
"byteorder",
"lazy_static",
"libm",
"num-integer",
"num-iter",
"num-traits",
"rand",
"smallvec",
"zeroize",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d869c01cc0c455284163fd0092f1f93835385ccab5a98a0dcc497b2f8bf055a9"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.18"
@ -828,6 +906,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a"
dependencies = [
"autocfg",
"libm",
]
[[package]]
@ -922,6 +1001,15 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "percent-encoding"
version = "2.3.1"
@ -1020,6 +1108,27 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"pkcs8",
"spki",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.30"
@ -1193,6 +1302,26 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "rsa"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc"
dependencies = [
"const-oid",
"digest",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"rand_core",
"signature",
"spki",
"subtle",
"zeroize",
]
[[package]]
name = "rss"
version = "2.0.7"
@ -1450,6 +1579,16 @@ dependencies = [
"serde",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core",
]
[[package]]
name = "siphasher"
version = "0.3.11"
@ -1481,6 +1620,22 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "spin"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "string_cache"
version = "0.8.7"
@ -1513,6 +1668,12 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "subtle"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc"
[[package]]
name = "syn"
version = "1.0.109"
@ -2046,3 +2207,9 @@ dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "zeroize"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d"

View File

@ -1,6 +1,6 @@
[package]
name = "matrix-hookshot"
version = "5.2.0"
version = "5.2.1"
edition = "2021"
[lib]
@ -22,6 +22,7 @@ atom_syndication = "0.12"
ruma = { version = "0.9", features = ["events", "html"] }
reqwest = "0.11"
rand = "0.8.5"
rsa = "0.9.6"
base64ct = { version = "1.6.0", features = ["alloc"] }
[build-dependencies]
napi-build = "2"

View File

@ -45,6 +45,8 @@ COPY --from=builder /src/lib ./
COPY --from=builder /src/public ./public
COPY --from=builder /src/assets ./assets
ENV NODE_ENV="production"
VOLUME /data
EXPOSE 9993
EXPOSE 7775

View File

@ -1 +0,0 @@
Fix Atom feeds being repeated in rooms once after an upgrade.

3
changelog.d/902.removal Normal file
View File

@ -0,0 +1,3 @@
The cache/queue configuration has been changed in this release. The `queue.monolithic` option has been deprecated, in place of a dedicated `cache`
config section. Check the ["Cache configuration" section](https://matrix-org.github.io/matrix-hookshot/latest/setup.html#cache-configuration) for
more information on how to configure Hookshot caches.

1
changelog.d/904.misc Normal file
View File

@ -0,0 +1 @@
Switch expressjs to production mode for improved performance.

1
changelog.d/915.misc Normal file
View File

@ -0,0 +1 @@
Track which key was used to encrypt secrets in storage, and encrypt/decrypt secrets in Rust.

View File

@ -10,7 +10,7 @@ bridge:
passFile:
# A passkey used to encrypt tokens stored inside the bridge.
# Run openssl genpkey -out passkey.pem -outform PEM -algorithm RSA -pkeyopt rsa_keygen_bits:4096 to generate
passkey.pem
./passkey.pem
logging:
# Logging settings. You can have a severity debug,info,warn,error
level: info
@ -134,12 +134,15 @@ listeners:
# # (Optional) Prometheus metrics support
# enabled: true
#cache:
# # (Optional) Cache options for large scale deployments.
# # For encryption to work, this must be configured.
# redisUri: redis://localhost:6379
#queue:
# # (Optional) Message queue / cache configuration options for large scale deployments.
# # For encryption to work, must be set to monolithic mode and have a host & port specified.
# monolithic: true
# port: 6379
# host: localhost
# # (Optional) Message queue configuration options for large scale deployments.
# # For encryption to work, this must not be configured.
# redisUri: redis://localhost:6379
#widgets:
# # (Optional) EXPERIMENTAL support for complimentary widgets

View File

@ -13,7 +13,7 @@ Hookshot supports end-to-bridge encryption via [MSC3202](https://github.com/matr
In order for Hookshot to use encryption, it must be configured as follows:
- The `experimentalEncryption.storagePath` setting must point to a directory that Hookshot has permissions to write files into. If running with Docker, this path should be within a volume (for persistency). Hookshot uses this directory for its crypto store (i.e. long-lived state relating to its encryption keys).
- Once a crypto store has been initialized, its files must not be modified, and Hookshot cannot be configured to use another crypto store of the same type as one it has used before. If a crypto store's files get lost or corrupted, Hookshot may fail to start up, or may be unable to decrypt command messages. To fix such issues, stop Hookshot, then reset its crypto store by running `yarn start:resetcrypto`.
- [Redis](./workers.md) must be enabled. Note that worker mode is not yet supported with encryption, so `queue.monolithic` must be set to `true`.
- [Redis](./workers.md) must be enabled. Note that worker mode is not yet supported with encryption, so `queue` MUST **NOT be configured**.
If you ever reset your homeserver's state, ensure you also reset Hookshot's encryption state. This includes clearing the `experimentalEncryption.storagePath` directory and all worker state stored in your redis instance. Otherwise, Hookshot may fail on start up with registration errors.

View File

@ -11,18 +11,19 @@ This feature is <b>experimental</b> and should only be used when you are reachin
You must first have a working Redis instance somewhere which can talk between processes. For example, in Docker you can run:
`docker run --name github-bridge-redis -p 6379:6379 -d redis`.
`docker run --name redis-host -p 6379:6379 -d redis`.
The processes should all share the same config, which should contain the correct config to enable Redis:
```yaml
queue:
monolithic: false
port: 6379
host: github-bridge-redis
redisUri: "redis://redis-host:6379"
cache:
redisUri: "redis://redis-host:6379"
```
Note that if [encryption](./encryption.md) is enabled, `queue.monolithic` must be set to `true`, as worker mode is not yet supported with encryption.
Note that if [encryption](./encryption.md) is enabled, you MUST enable the `cache` config but NOT the `queue` config. Workers require persistent
storage in Redis, but cannot make use of worker-mode queues.
Once that is done, you can simply start the processes by name using yarn:
```

View File

@ -27,7 +27,12 @@ cd matrix-hookshot
yarn # or npm i
```
Starting the bridge (after configuring it), is a matter of running `yarn start`.
Starting the bridge (after configuring it), is a matter of setting the `NODE_ENV` environment variable to `production` or `development`, depending if you want [better performance or more verbose logging](https://expressjs.com/en/advanced/best-practice-performance.html#set-node_env-to-production), and then running it:
```bash
NODE_ENV=production yarn start
```
## Installation via Docker
@ -222,6 +227,20 @@ Please note that the appservice HTTP listener is configured <strong>separately</
in the upstream library. See <a href="https://github.com/turt2live/matrix-bot-sdk/issues/191">this issue</a> for details.
</section>
### Cache configuration
You can optionally enable a Redis-backed cache for Hookshot. This is generally a good thing to enable if you can
afford to, as it will generally improve startup times. Some features such as resuming RSS/Atom feeds between restarts
is also only possible with a external cache.
To enable, simply set:
```yaml
cache:
redisUri: "redis://redis-host:3679"
```
### Services configuration
You will need to configure some services. Each service has its own documentation file inside the setup subdirectory.

View File

@ -3,7 +3,6 @@
#
# -- Number of replicas to deploy. Consequences of using multiple Hookshot replicas currently unknown.
replicaCount: 1
image:
# -- Repository to pull hookshot image from
repository: halfshot/matrix-hookshot
@ -11,16 +10,12 @@ image:
pullPolicy: IfNotPresent
# -- Image tag to pull. Defaults to chart's appVersion value as set in Chart.yaml
tag:
# -- List of names of k8s secrets to be used as ImagePullSecrets for the pod
imagePullSecrets: []
# -- Name override for helm chart
nameOverride: ""
# -- Full name override for helm chart
fullnameOverride: ""
serviceAccount:
# -- Specifies whether a service account should be created
create: true
@ -28,10 +23,8 @@ serviceAccount:
annotations: {}
# -- The name of the service account to use. If not set and create is true, a name is generated using the fullname template
name: ""
# -- Extra annotations for Hookshot pod
podAnnotations: {}
# -- Pod security context settings
podSecurityContext: {}
# fsGroup: 2000
@ -54,7 +47,6 @@ service:
annotations: {}
# -- Extra labels for service
labels: {}
webhook:
# -- Webhook port as configured in container
port: 9000
@ -64,7 +56,6 @@ service:
appservice:
# -- Appservice port as configured in container
port: 9002
ingress:
webhook:
# -- Enable ingress for webhook
@ -77,7 +68,6 @@ ingress:
hosts: []
# -- TLS configuration for webhook ingress
tls: []
appservice:
# -- Enable ingress for appservice
enabled: false
@ -89,7 +79,6 @@ ingress:
hosts: []
# -- TLS configuration for appservice ingress
tls: []
# -- Pod resource requests / limits
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
@ -105,189 +94,40 @@ resources: {}
autoscaling:
enabled: false
# -- Node selector parameters
nodeSelector: {}
# -- Tolerations for deployment
tolerations: []
# -- Affinity settings for deployment
affinity: {}
hookshot:
# -- Name of existing ConfigMap with valid Hookshot configuration
existingConfigMap:
# -- Raw Hookshot configuration. Gets templated into a YAML file and then loaded unless an existingConfigMap is specified.
config:
# This is an example configuration file
bridge:
# Basic homeserver configuration
#
domain: example.com
url: http://localhost:8008
mediaUrl: https://example.com
port: 9993
bindAddress: 127.0.0.1
# github:
# (Optional) Configure this to enable GitHub support
#
# auth:
# Authentication for the GitHub App.
#
# id: 123
# privateKeyFile: github-key.pem
# webhook:
# Webhook settings for the GitHub app.
#
# secret: secrettoken
# oauth:
# (Optional) Settings for allowing users to sign in via OAuth.
#
# client_id: foo
# client_secret: bar
# redirect_uri: https://example.com/bridge_oauth/
# defaultOptions:
# (Optional) Default options for GitHub connections.
#
# showIssueRoomLink: false
# hotlinkIssues:
# prefix: "#"
# userIdPrefix: _github_
# (Optional) Prefix used when creating ghost users for GitHub accounts.
#
# gitlab:
# (Optional) Configure this to enable GitLab support
#
# instances:
# gitlab.com:
# url: https://gitlab.com
# webhook:
# secret: secrettoken
# publicUrl: https://example.com/hookshot/
# userIdPrefix: _gitlab_
# (Optional) Prefix used when creating ghost users for GitLab accounts.
#
# figma:
# (Optional) Configure this to enable Figma support
#
# publicUrl: https://example.com/hookshot/
# instances:
# your-instance:
# teamId: your-team-id
# accessToken: your-personal-access-token
# passcode: your-webhook-passcode
# jira:
# (Optional) Configure this to enable Jira support. Only specify `url` if you are using a On Premise install (i.e. not atlassian.com)
#
# webhook:
# Webhook settings for JIRA
#
# secret: secrettoken
# oauth:
# (Optional) OAuth settings for connecting users to JIRA. See documentation for more information
#
# client_id: foo
# client_secret: bar
# redirect_uri: https://example.com/bridge_oauth/
generic:
# (Optional) Support for generic webhook events.
#'allowJsTransformationFunctions' will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments
#
#
enabled: false
enableHttpGet: false
urlPrefix: https://example.com/webhook/
userIdPrefix: _webhooks_
allowJsTransformationFunctions: false
waitForComplete: false
feeds:
# (Optional) Configure this to enable RSS/Atom feed support
#
enabled: false
pollIntervalSeconds: 600
pollTimeoutSeconds: 30
# provisioning:
# (Optional) Provisioning API for integration managers
#
# secret: "!secretToken"
passFile: passkey.pem
# A passkey used to encrypt tokens stored inside the bridge.
# Run openssl genpkey -out passkey.pem -outform PEM -algorithm RSA -pkeyopt rsa_keygen_bits:4096 to generate
#
# bot:
# (Optional) Define profile information for the bot user
#
# displayname: Hookshot Bot
# avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d
# serviceBots:
# (Optional) Define additional bot users for specific services
#
# - localpart: feeds
# displayname: Feeds
# avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d
# prefix: "!feeds"
# service: feeds
metrics:
# (Optional) Prometheus metrics support
#
enabled: true
# queue:
# (Optional) Message queue / cache configuration options for large scale deployments.
# For encryption to work, must be set to monolithic mode and have a host & port specified.
#
# monolithic: true
# port: 6379
# host: localhost
logging:
# (Optional) Logging settings. You can have a severity debug,info,warn,error
#
# Logging settings. You can have a severity debug,info,warn,error
level: info
colorize: true
json: false
timestampFormat: HH:mm:ss:SSS
# widgets:
# (Optional) EXPERIMENTAL support for complimentary widgets
#
# addToAdminRooms: false
# disallowedIpRanges:
# - 127.0.0.0/8
# - 10.0.0.0/8
# - 172.16.0.0/12
# - 192.168.0.0/16
# - 100.64.0.0/10
# - 192.0.0.0/24
# - 169.254.0.0/16
# - 192.88.99.0/24
# - 198.18.0.0/15
# - 192.0.2.0/24
# - 198.51.100.0/24
# - 203.0.113.0/24
# - 224.0.0.0/4
# - ::1/128
# - fe80::/10
# - fc00::/7
# - 2001:db8::/32
# - ff00::/8
# - fec0::/10
# roomSetupWidget:
# addOnInvite: false
# publicUrl: https://example.com/widgetapi/v1/static/
# branding:
# widgetTitle: Hookshot Configuration
# permissions:
# (Optional) Permissions for using the bridge. See docs/setup.md#permissions for help
#
# - actor: example.com
# services:
# - service: "*"
# level: admin
listeners:
# (Optional) HTTP Listener configuration.
# HTTP Listener configuration.
# Bind resource endpoints to ports and addresses.
# 'port' must be specified. Each listener must listen on a unique port.
# 'bindAddress' will default to '127.0.0.1' if not specified, which may not be suited to Docker environments.
# 'resources' may be any of webhooks, widgets, metrics, provisioning
#
- port: 9000
bindAddress: 0.0.0.0
resources:
@ -302,6 +142,153 @@ hookshot:
resources:
- widgets
registration:
#github:
# # (Optional) Configure this to enable GitHub support
# auth:
# # Authentication for the GitHub App.
# id: 123
# privateKeyFile: github-key.pem
# webhook:
# # Webhook settings for the GitHub app.
# secret: secrettoken
# oauth:
# # (Optional) Settings for allowing users to sign in via OAuth.
# client_id: foo
# client_secret: bar
# redirect_uri: https://example.com/oauth/
# defaultOptions:
# # (Optional) Default options for GitHub connections.
# showIssueRoomLink: false
# hotlinkIssues:
# prefix: "#"
# userIdPrefix:
# # (Optional) Prefix used when creating ghost users for GitHub accounts.
# _github_
#gitlab:
# # (Optional) Configure this to enable GitLab support
# instances:
# gitlab.com:
# url: https://gitlab.com
# webhook:
# secret: secrettoken
# publicUrl: https://example.com/hookshot/
# userIdPrefix:
# # (Optional) Prefix used when creating ghost users for GitLab accounts.
# _gitlab_
# commentDebounceMs:
# # (Optional) Aggregate comments by waiting this many miliseconds before posting them to Matrix. Defaults to 5000 (5 seconds)
# 5000
#figma:
# # (Optional) Configure this to enable Figma support
# publicUrl: https://example.com/hookshot/
# instances:
# your-instance:
# teamId: your-team-id
# accessToken: your-personal-access-token
# passcode: your-webhook-passcode
#jira:
# # (Optional) Configure this to enable Jira support. Only specify `url` if you are using a On Premise install (i.e. not atlassian.com)
# webhook:
# # Webhook settings for JIRA
# secret: secrettoken
# oauth:
# # (Optional) OAuth settings for connecting users to JIRA. See documentation for more information
# client_id: foo
# client_secret: bar
# redirect_uri: https://example.com/oauth/
#generic:
# # (Optional) Support for generic webhook events.
# #'allowJsTransformationFunctions' will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments
# enabled: false
# enableHttpGet: false
# urlPrefix: https://example.com/webhook/
# userIdPrefix: _webhooks_
# allowJsTransformationFunctions: false
# waitForComplete: false
#feeds:
# # (Optional) Configure this to enable RSS/Atom feed support
# enabled: false
# pollConcurrency: 4
# pollIntervalSeconds: 600
# pollTimeoutSeconds: 30
#provisioning:
# # (Optional) Provisioning API for integration managers
# secret: "!secretToken"
#bot:
# # (Optional) Define profile information for the bot user
# displayname: Hookshot Bot
# avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d
#serviceBots:
# # (Optional) Define additional bot users for specific services
# - localpart: feeds
# displayname: Feeds
# avatar: ./assets/feeds_avatar.png
# prefix: "!feeds"
# service: feeds
#metrics:
# # (Optional) Prometheus metrics support
# enabled: true
#cache:
# # (Optional) Cache options for large scale deployments.
# # For encryption to work, this must be configured.
# redisUri: redis://localhost:6379
#queue:
# # (Optional) Message queue configuration options for large scale deployments.
# # For encryption to work, this must not be configured.
# redisUri: redis://localhost:6379
#widgets:
# # (Optional) EXPERIMENTAL support for complimentary widgets
# addToAdminRooms: false
# disallowedIpRanges:
# - 127.0.0.0/8
# - 10.0.0.0/8
# - 172.16.0.0/12
# - 192.168.0.0/16
# - 100.64.0.0/10
# - 192.0.0.0/24
# - 169.254.0.0/16
# - 192.88.99.0/24
# - 198.18.0.0/15
# - 192.0.2.0/24
# - 198.51.100.0/24
# - 203.0.113.0/24
# - 224.0.0.0/4
# - ::1/128
# - fe80::/10
# - fc00::/7
# - 2001:db8::/32
# - ff00::/8
# - fec0::/10
# roomSetupWidget:
# addOnInvite: false
# publicUrl: https://example.com/widgetapi/v1/static/
# branding:
# widgetTitle: Hookshot Configuration
#sentry:
# # (Optional) Configure Sentry error reporting
# dsn: https://examplePublicKey@o0.ingest.sentry.io/0
# environment: production
#permissions:
# # (Optional) Permissions for using the bridge. See docs/setup.md#permissions for help
# - actor: example.com
# services:
# - service: "*"
# level: admin
id: matrix-hookshot
as_token: ""
hs_token: ""

View File

@ -1,6 +1,6 @@
{
"name": "matrix-hookshot",
"version": "5.2.0",
"version": "5.2.1",
"description": "A bridge between Matrix and multiple project management services, such as GitHub, GitLab and JIRA.",
"main": "lib/app.js",
"repository": "https://github.com/matrix-org/matrix-hookshot",
@ -115,6 +115,6 @@
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",
"typescript": "^5.3.3",
"vite": "^5.0.12"
"vite": "^5.0.13"
}
}

View File

@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# exit when any command fails
set -e

View File

@ -19,12 +19,12 @@ describe('Basic test setup', () => {
const user = testEnv.getUser('user');
const roomId = await user.createRoom({ name: 'Test room', invite:[testEnv.botMxid] });
await user.waitForRoomJoin({sender: testEnv.botMxid, roomId });
await user.sendText(roomId, "!hookshot help");
const msg = await user.waitForRoomEvent<MessageEventContent>({
const msg = user.waitForRoomEvent<MessageEventContent>({
eventType: 'm.room.message', sender: testEnv.botMxid, roomId
});
await user.sendText(roomId, "!hookshot help");
// Expect help text.
expect(msg.data.content.body).to.include('!hookshot help` - This help text\n');
expect((await msg).data.content.body).to.include('!hookshot help` - This help text\n');
});
// TODO: Move test to it's own generic connections file.

View File

@ -203,9 +203,6 @@ export class E2ETestEnv {
logging: {
level: 'info',
},
queue: {
monolithic: true,
},
// Always enable webhooks so that hookshot starts.
generic: {
enabled: true,

View File

@ -16,7 +16,7 @@ import { Intent } from "matrix-bot-sdk";
import { JiraBotCommands } from "./jira/AdminCommands";
import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters";
import { ProjectsListResponseData } from "./github/Types";
import { UserTokenStore } from "./UserTokenStore";
import { UserTokenStore } from "./tokens/UserTokenStore";
import { Logger } from "matrix-appservice-bridge";
import markdown from "markdown-it";
type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"];

View File

@ -1,7 +1,7 @@
import EventEmitter from "events";
import { Intent } from "matrix-bot-sdk";
import { BridgeConfig } from "./config/Config";
import { UserTokenStore } from "./UserTokenStore";
import { UserTokenStore } from "./tokens/UserTokenStore";
export enum Category {

View File

@ -10,6 +10,7 @@ import BotUsersManager from "../Managers/BotUsersManager";
import * as Sentry from '@sentry/node';
import { GenericHookConnection } from "../Connections";
import { installRequestFunction } from "../Request";
import { UserTokenStore } from "../tokens/UserTokenStore";
Logger.configure({console: "info"});
const log = new Logger("App");
@ -29,7 +30,7 @@ export async function start(config: BridgeConfig, registration: IAppserviceRegis
const {appservice, storage} = getAppservice(config, registration);
if (config.queue.monolithic) {
if (!config.queue) {
const matrixSender = new MatrixSender(config, appservice);
matrixSender.listen();
const userNotificationWatcher = new UserNotificationWatcher(config);
@ -53,7 +54,8 @@ export async function start(config: BridgeConfig, registration: IAppserviceRegis
const botUsersManager = new BotUsersManager(config, appservice);
const bridgeApp = new Bridge(config, listener, appservice, storage, botUsersManager);
const tokenStore = await UserTokenStore.fromKeyPath(config.passFile , appservice.botIntent, config);
const bridgeApp = new Bridge(config, tokenStore, listener, appservice, storage, botUsersManager);
process.once("SIGTERM", () => {
log.error("Got SIGTERM");

View File

@ -23,7 +23,7 @@ import { NotificationsEnableEvent, NotificationsDisableEvent, Webhooks } from ".
import { GitHubOAuthToken, GitHubOAuthTokenResponse, ProjectsGetResponseData } from "./github/Types";
import { retry } from "./PromiseUtil";
import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher";
import { UserTokenStore } from "./UserTokenStore";
import { UserTokenStore } from "./tokens/UserTokenStore";
import * as GitHubWebhookTypes from "@octokit/webhooks-types";
import { Logger } from "matrix-appservice-bridge";
import { Provisioner } from "./provisioning/provisioner";
@ -49,11 +49,9 @@ export class Bridge {
private readonly queue: MessageQueue;
private readonly commentProcessor: CommentProcessor;
private readonly notifProcessor: NotificationProcessor;
private readonly tokenStore: UserTokenStore;
private connectionManager?: ConnectionManager;
private github?: GithubInstance;
private adminRooms: Map<string, AdminRoom> = new Map();
private widgetApi?: BridgeWidgetApi;
private feedReader?: FeedReader;
private provisioningApi?: Provisioner;
private replyProcessor = new RichRepliesPreprocessor(true);
@ -62,6 +60,7 @@ export class Bridge {
constructor(
private config: BridgeConfig,
private readonly tokenStore: UserTokenStore,
private readonly listener: ListenerService,
private readonly as: Appservice,
private readonly storage: IBridgeStorageProvider,
@ -71,8 +70,6 @@ export class Bridge {
this.messageClient = new MessageSenderClient(this.queue);
this.commentProcessor = new CommentProcessor(this.as, this.config.bridge.mediaUrl || this.config.bridge.url);
this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient);
this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent, this.config);
this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this));
// Legacy routes, to be removed.
this.as.expressAppInstance.get("/live", (_, res) => res.send({ok: true}));
@ -87,8 +84,8 @@ export class Bridge {
}
public async start() {
this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this));
log.info('Starting up');
await this.tokenStore.load();
await this.storage.connect?.();
await this.queue.connect?.();
@ -755,7 +752,7 @@ export class Bridge {
if (apps.length > 1) {
throw Error('You may only bind `widgets` to one listener.');
}
this.widgetApi = new BridgeWidgetApi(
new BridgeWidgetApi(
this.adminRooms,
this.config,
this.storage,

View File

@ -17,7 +17,7 @@ import { IBridgeStorageProvider } from "./Stores/StorageProvider";
import { JiraProject, JiraVersion } from "./jira/Types";
import { Logger } from "matrix-appservice-bridge";
import { MessageSenderClient } from "./MatrixSender";
import { UserTokenStore } from "./UserTokenStore";
import { UserTokenStore } from "./tokens/UserTokenStore";
import BotUsersManager from "./Managers/BotUsersManager";
import { retry, retryMatrixErrorFilter } from "./PromiseUtil";
import Metrics from "./Metrics";

View File

@ -1,6 +1,6 @@
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { UserTokenStore } from "../UserTokenStore";
import { UserTokenStore } from "../tokens/UserTokenStore";
import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender";
import { ensureUserIsInRoom, getIntentForUser } from "../IntentUtils";

View File

@ -2,7 +2,7 @@ import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnectio
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent";
import markdown from "markdown-it";
import { UserTokenStore } from "../UserTokenStore";
import { UserTokenStore } from "../tokens/UserTokenStore";
import { Logger } from "matrix-appservice-bridge";
import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender";

View File

@ -13,7 +13,7 @@ import { MatrixMessageContent, MatrixEvent, MatrixReactionContent } from "../Mat
import { MessageSenderClient } from "../MatrixSender";
import { CommandError, NotLoggedInError } from "../errors";
import { ReposGetResponseData } from "../github/Types";
import { UserTokenStore } from "../UserTokenStore";
import { UserTokenStore } from "../tokens/UserTokenStore";
import axios, { AxiosError } from "axios";
import { emojify } from "node-emoji";
import { Logger } from "matrix-appservice-bridge";

View File

@ -1,7 +1,7 @@
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent";
import { UserTokenStore } from "../UserTokenStore";
import { UserTokenStore } from "../tokens/UserTokenStore";
import { Logger } from "matrix-appservice-bridge";
import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender";

View File

@ -1,4 +1,4 @@
import { UserTokenStore } from "../UserTokenStore";
import { UserTokenStore } from "../tokens/UserTokenStore";
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { BotCommands, botCommand, compileBotCommands } from "../BotCommands";
import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent";

View File

@ -3,7 +3,7 @@ import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types";
import { ConnectionWarning, GetConnectionsResponseItem } from "../provisioning/api";
import { Appservice, Intent, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk";
import { BridgeConfig, BridgePermissionLevel } from "../config/Config";
import { UserTokenStore } from "../UserTokenStore";
import { UserTokenStore } from "../tokens/UserTokenStore";
import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender";
import { IBridgeStorageProvider } from "../Stores/StorageProvider";

View File

@ -9,7 +9,7 @@ import { JiraProject, JiraVersion } from "../jira/Types";
import { botCommand, BotCommands, compileBotCommands } from "../BotCommands";
import { MatrixMessageContent } from "../MatrixEvent";
import { CommandConnection } from "./CommandConnection";
import { UserTokenStore } from "../UserTokenStore";
import { UserTokenStore } from "../tokens/UserTokenStore";
import { CommandError, NotLoggedInError } from "../errors";
import { ApiError, ErrCode } from "../api";
import JiraApi from "jira-client";

View File

@ -3,7 +3,7 @@ import { Appservice } from "matrix-bot-sdk";
import { BridgeConfigGitLab } from "../config/Config";
import { GitLabRepoConnection } from "../Connections";
import { GrantChecker } from "../grants/GrantCheck";
import { UserTokenStore } from "../UserTokenStore";
import { UserTokenStore } from "../tokens/UserTokenStore";
const log = new Logger('GitLabGrantChecker');

View File

@ -30,6 +30,7 @@ export class ListenerService {
}
for (const listenerConfig of config) {
const app = expressApp();
app.set('x-powered-by', false);
app.use(Handlers.requestHandler());
this.listeners.push({
config: listenerConfig,

View File

@ -1,4 +1,4 @@
import { BridgeConfigQueue } from "../config/Config";
import { BridgeConfigQueue } from "../config/sections";
import { LocalMQ } from "./LocalMQ";
import { RedisMQ } from "./RedisQueue";
import { MessageQueue } from "./Types";
@ -6,8 +6,8 @@ import { MessageQueue } from "./Types";
const staticLocalMq = new LocalMQ();
let staticRedisMq: RedisMQ|null = null;
export function createMessageQueue(config: BridgeConfigQueue): MessageQueue {
if (config.monolithic) {
export function createMessageQueue(config?: BridgeConfigQueue): MessageQueue {
if (!config) {
return staticLocalMq;
}
if (staticRedisMq === null) {

View File

@ -1,7 +1,7 @@
import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT, MessageQueueMessageOut } from "./Types";
import { Redis, default as redis } from "ioredis";
import { BridgeConfigQueue } from "../config/Config";
import { BridgeConfigQueue } from "../config/sections/queue";
import { EventEmitter } from "events";
import { Logger } from "matrix-appservice-bridge";
import { randomUUID } from 'node:crypto';
@ -22,9 +22,10 @@ export class RedisMQ extends EventEmitter implements MessageQueue {
private myUuid: string;
constructor(config: BridgeConfigQueue) {
super();
this.redisSub = new redis(config.port ?? 6379, config.host ?? "localhost");
this.redisPub = new redis(config.port ?? 6379, config.host ?? "localhost");
this.redis = new redis(config.port ?? 6379, config.host ?? "localhost");
const uri = 'redisUri' in config ? config.redisUri : `redis://${config.host ?? 'localhost'}:${config.port ?? 6379}`;
this.redisSub = new redis(uri);
this.redisPub = new redis(uri);
this.redis = new redis(uri);
this.myUuid = randomUUID();
this.redisSub.on("pmessage", (_: string, channel: string, message: string) => {
const msg = JSON.parse(message) as MessageQueueMessageOut<unknown>;

View File

@ -6,6 +6,7 @@ import { IBridgeStorageProvider, MAX_FEED_ITEMS } from "./StorageProvider";
import { IFilterInfo, IStorageProvider } from "matrix-bot-sdk";
import { ProvisionSession } from "matrix-appservice-bridge";
import { SerializedGitlabDiscussionThreads } from "../Gitlab/Types";
import { BridgeConfigCache } from "../config/sections";
const BOT_SYNC_TOKEN_KEY = "bot.sync_token.";
const BOT_FILTER_KEY = "bot.filter.";
@ -68,8 +69,8 @@ export class RedisStorageContextualProvider implements IStorageProvider {
export class RedisStorageProvider extends RedisStorageContextualProvider implements IBridgeStorageProvider {
constructor(host: string, port: number, contextSuffix = '') {
super(new redis(port, host), contextSuffix);
constructor(cacheConfig: BridgeConfigCache, contextSuffix = '') {
super(new redis(cacheConfig.redisUri), contextSuffix);
this.redis.expire(COMPLETED_TRANSACTIONS_KEY, COMPLETED_TRANSACTIONS_EXPIRE_AFTER).catch((ex) => {
log.warn("Failed to set expiry time on as.completed_transactions", ex);
});

View File

@ -11,7 +11,7 @@ import BotUsersManager, {BotUser} from "../Managers/BotUsersManager";
import { assertUserPermissionsInRoom, GetConnectionsResponseItem } from "../provisioning/api";
import { Appservice, PowerLevelsEvent } from "matrix-bot-sdk";
import { GithubInstance } from '../github/GithubInstance';
import { AllowedTokenTypes, TokenType, UserTokenStore } from '../UserTokenStore';
import { AllowedTokenTypes, TokenType, UserTokenStore } from '../tokens/UserTokenStore';
const log = new Logger("BridgeWidgetApi");

View File

@ -9,9 +9,9 @@ const log = new Logger("Appservice");
export function getAppservice(config: BridgeConfig, registration: IAppserviceRegistration) {
let storage: IBridgeStorageProvider;
if (config.queue.host && config.queue.port) {
log.info(`Initialising Redis storage (on ${config.queue.host}:${config.queue.port})`);
storage = new RedisStorageProvider(config.queue.host, config.queue.port);
if (config.cache) {
log.info(`Initialising Redis storage`);
storage = new RedisStorageProvider(config.cache);
} else {
log.info('Initialising memory storage');
storage = new MemoryStorageProvider();

View File

@ -10,6 +10,8 @@ import { ConfigError } from "../errors";
import { ApiError, ErrCode } from "../api";
import { GithubInstance, GITHUB_CLOUD_URL } from "../github/GithubInstance";
import { Logger } from "matrix-appservice-bridge";
import { BridgeConfigCache } from "./sections/cache";
import { BridgeConfigQueue } from "./sections";
const log = new Logger("Config");
@ -407,12 +409,6 @@ interface BridgeConfigWebhook {
bindAddress?: string;
}
export interface BridgeConfigQueue {
monolithic: boolean;
port?: number;
host?: string;
}
export interface BridgeConfigLogging {
level: "debug"|"info"|"warn"|"error"|"trace";
json?: boolean;
@ -454,40 +450,45 @@ export interface BridgeConfigSentry {
environment?: string;
}
export interface BridgeConfigRoot {
bot?: BridgeConfigBot;
serviceBots?: BridgeConfigServiceBot[];
bridge: BridgeConfigBridge;
cache?: BridgeConfigCache;
experimentalEncryption?: BridgeConfigEncryption;
figma?: BridgeConfigFigma;
feeds?: BridgeConfigFeedsYAML;
figma?: BridgeConfigFigma;
generic?: BridgeGenericWebhooksConfigYAML;
github?: BridgeConfigGitHubYAML;
gitlab?: BridgeConfigGitLabYAML;
jira?: BridgeConfigJiraYAML;
listeners?: BridgeConfigListener[];
logging: BridgeConfigLogging;
metrics?: BridgeConfigMetrics;
passFile: string;
permissions?: BridgeConfigActorPermission[];
provisioning?: BridgeConfigProvisioning;
jira?: BridgeConfigJiraYAML;
logging: BridgeConfigLogging;
passFile: string;
queue: BridgeConfigQueue;
queue?: BridgeConfigQueue;
sentry?: BridgeConfigSentry;
serviceBots?: BridgeConfigServiceBot[];
webhook?: BridgeConfigWebhook;
widgets?: BridgeWidgetConfigYAML;
metrics?: BridgeConfigMetrics;
listeners?: BridgeConfigListener[];
sentry?: BridgeConfigSentry;
}
export class BridgeConfig {
@configKey("Basic homeserver configuration")
public readonly bridge: BridgeConfigBridge;
@configKey(`Cache options for large scale deployments.
For encryption to work, this must be configured.`, true)
public readonly cache?: BridgeConfigCache;
@configKey(`Configuration for encryption support in the bridge.
If omitted, encryption support will be disabled.
This feature is HIGHLY EXPERIMENTAL AND SUBJECT TO CHANGE.
For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.`, true)
public readonly encryption?: BridgeConfigEncryption;
@configKey(`Message queue / cache configuration options for large scale deployments.
For encryption to work, must be set to monolithic mode and have a host & port specified.`, true)
public readonly queue: BridgeConfigQueue;
@configKey(`Message queue configuration options for large scale deployments.
For encryption to work, this must not be configured.`, true)
public readonly queue?: Omit<BridgeConfigQueue, "monolithic">;
@configKey("Logging settings. You can have a severity debug,info,warn,error")
public readonly logging: BridgeConfigLogging;
@configKey(`Permissions for using the bridge. See docs/setup.md#permissions for help`, true)
@ -549,13 +550,38 @@ export class BridgeConfig {
this.generic = configData.generic && new BridgeConfigGenericWebhooks(configData.generic);
this.feeds = configData.feeds && new BridgeConfigFeeds(configData.feeds);
this.provisioning = configData.provisioning;
this.passFile = configData.passFile;
this.passFile = configData.passFile ?? "./passkey.pem";
this.bot = configData.bot;
this.serviceBots = configData.serviceBots;
this.metrics = configData.metrics;
this.queue = configData.queue || {
monolithic: true,
};
// TODO: Formalize env support
if (env?.CFG_QUEUE_MONOLITHIC && ["false", "off", "no"].includes(env.CFG_QUEUE_MONOLITHIC)) {
if (!env?.CFG_QUEUE_HOST) {
throw new ConfigError("env:CFG_QUEUE_HOST", "CFG_QUEUE_MONOLITHIC was defined but host was not");
}
configData.queue = {
monolithic: false,
host: env?.CFG_QUEUE_HOST,
port: env?.CFG_QUEUE_POST ? parseInt(env?.CFG_QUEUE_POST, 10) : undefined,
}
}
this.cache = configData.cache;
this.queue = configData.queue;
if (configData.queue?.monolithic !== undefined) {
log.warn("The `queue.monolithic` config option is deprecated. Instead, configure the `cache` section.");
this.cache = {
redisUri: 'redisUri' in configData.queue ? configData.queue.redisUri
: `redis://${configData.queue.host ?? 'localhost'}:${configData.queue.port ?? 6379}`
};
// If monolithic, disable the redis queue.
if (configData.queue.monolithic === true) {
this.queue = undefined;
}
}
this.encryption = configData.experimentalEncryption;
@ -589,13 +615,6 @@ export class BridgeConfig {
throw Error("Config is not valid: At least one of GitHub, GitLab, JIRA, Figma, feeds or generic hooks must be configured");
}
// TODO: Formalize env support
if (env?.CFG_QUEUE_MONOLITHIC && ["false", "off", "no"].includes(env.CFG_QUEUE_MONOLITHIC)) {
this.queue.monolithic = false;
this.queue.host = env?.CFG_QUEUE_HOST;
this.queue.port = env?.CFG_QUEUE_POST ? parseInt(env?.CFG_QUEUE_POST, 10) : undefined;
}
if ('goNebMigrator' in configData) {
log.warn(`The GoNEB migrator has been removed from this release. You should remove the 'goNebMigrator' from your config.`);
}
@ -677,12 +696,12 @@ Please back up your crypto store at ${this.encryption.storagePath},
remove "useLegacySledStore" from your configuration file, and restart Hookshot.
`);
}
if (!this.queue.monolithic) {
throw new ConfigError("queue.monolithic", "Encryption is not supported in worker mode yet.");
if (!this.cache) {
throw new ConfigError("cache", "Encryption requires the Redis cache to be enabled.");
}
if (!this.queue.port) {
throw new ConfigError("queue.port", "You must enable redis support for encryption to work.");
if (this.queue) {
throw new ConfigError("queue", "Encryption does not support message queues.");
}
}

View File

@ -16,9 +16,10 @@ export const DefaultConfigRoot: BridgeConfigRoot = {
bindAddress: "127.0.0.1",
},
queue: {
monolithic: true,
port: 6379,
host: "localhost",
redisUri: "redis://localhost:6379",
},
cache: {
redisUri: "redis://localhost:6379",
},
logging: {
level: "info",
@ -33,7 +34,7 @@ export const DefaultConfigRoot: BridgeConfigRoot = {
level: "admin"
}],
}],
passFile: "passkey.pem",
passFile: "./passkey.pem",
widgets: {
publicUrl: `${hookshotWebhooksUrl}/widgetapi/v1/static`,
addToAdminRooms: false,

View File

@ -0,0 +1,7 @@
export interface BridgeConfigCache {
/**
* A redis URI string
* @example `redis://user:password@host:port/dbnum`
*/
redisUri: string;
}

View File

@ -0,0 +1,2 @@
export * from "./cache";
export * from "./queue";

View File

@ -0,0 +1,22 @@
/**
* Configuration for the message queue.
*/
interface BridgeConfigQueueBase {
/**
* Controls whether the queue config is used just for the cache (monolithic),
* or the message queue as well.
* @deprecated Use the `cache` config instead to control this seperately.
*/
monolithic?: boolean;
}
interface BridgeConfigQueueUri extends BridgeConfigQueueBase {
redisUri: string;
}
interface BridgeConfigQueueLegacyOptions extends BridgeConfigQueueBase {
port?: number;
host?: string;
}
export type BridgeConfigQueue = BridgeConfigQueueUri|BridgeConfigQueueLegacyOptions

View File

@ -1,7 +1,7 @@
use crate::github::types::*;
use crate::jira;
use crate::jira::types::{JiraIssue, JiraIssueLight, JiraIssueMessageBody, JiraIssueSimpleItem};
use contrast;
use contrast::contrast;
use md5::{Digest, Md5};
use napi::bindgen_prelude::*;
use napi_derive::napi;
@ -87,12 +87,11 @@ pub fn format_labels(array: Vec<IssueLabelDetail>) -> Result<MatrixMessageFormat
write!(html, " data-mx-bg-color=\"#{}\"", color).unwrap();
// Determine the constrast
let color_rgb = parse_rgb(color)?;
let contrast_color =
if contrast::contrast::<u8, f32>(color_rgb, RGB::new(0, 0, 0)) > 4.5 {
"#000000"
} else {
"#FFFFFF"
};
let contrast_color = if contrast::<u8, f32>(color_rgb, RGB::new(0, 0, 0)) > 4.5 {
"#000000"
} else {
"#FFFFFF"
};
write!(html, " data-mx-color=\"{}\"", contrast_color).unwrap();
}
if let Some(description) = label.description {

View File

@ -1,7 +1,7 @@
import { Appservice } from "matrix-bot-sdk";
import { GitHubRepoConnection } from "../Connections";
import { GrantChecker } from "../grants/GrantCheck";
import { UserTokenStore } from "../UserTokenStore";
import { UserTokenStore } from "../tokens/UserTokenStore";
import { Logger } from 'matrix-appservice-bridge';
const log = new Logger('GitHubGrantChecker');

View File

@ -1,7 +1,7 @@
import { Router, Request, Response, NextFunction } from "express";
import { BridgeConfigGitHub } from "../config/Config";
import { ApiError, ErrCode } from "../api";
import { UserTokenStore } from "../UserTokenStore";
import { UserTokenStore } from "../tokens/UserTokenStore";
import { Logger } from "matrix-appservice-bridge";
import { GithubInstance } from "./GithubInstance";
import { NAMELESS_ORG_PLACEHOLDER } from "./Types";

View File

@ -1,7 +1,7 @@
import { Appservice } from "matrix-bot-sdk";
import { JiraProjectConnection } from "../Connections";
import { GrantChecker } from "../grants/GrantCheck";
import { UserTokenStore } from "../UserTokenStore";
import { UserTokenStore } from "../tokens/UserTokenStore";
interface JiraGrantConnectionId{
url: string;

View File

@ -1,7 +1,7 @@
import { BridgeConfigJira } from "../config/Config";
import { MessageQueue } from "../MessageQueue";
import { Router, Request, Response, NextFunction, json } from "express";
import { UserTokenStore } from "../UserTokenStore";
import { UserTokenStore } from "../tokens/UserTokenStore";
import { Logger } from "matrix-appservice-bridge";
import { ApiError, ErrCode } from "../api";
import { JiraOAuthRequestCloud, JiraOAuthRequestOnPrem, JiraOAuthRequestResult } from "./OAuth";

View File

@ -3,6 +3,7 @@ pub mod feeds;
pub mod format_util;
pub mod github;
pub mod jira;
pub mod tokens;
pub mod util;
#[macro_use]

View File

@ -1,22 +1,22 @@
import { GithubInstance } from "./github/GithubInstance";
import { GitLabClient } from "./Gitlab/Client";
import { GithubInstance } from "../github/GithubInstance";
import { GitLabClient } from "../Gitlab/Client";
import { Intent } from "matrix-bot-sdk";
import { promises as fs } from "fs";
import { publicEncrypt, privateDecrypt } from "crypto";
import { Logger } from "matrix-appservice-bridge";
import { isJiraCloudInstance, JiraClient } from "./jira/Client";
import { JiraStoredToken } from "./jira/Types";
import { BridgeConfig, BridgeConfigJira, BridgeConfigJiraOnPremOAuth, BridgePermissionLevel } from "./config/Config";
import { isJiraCloudInstance, JiraClient } from "../jira/Client";
import { JiraStoredToken } from "../jira/Types";
import { BridgeConfig, BridgeConfigJira, BridgeConfigJiraOnPremOAuth, BridgePermissionLevel } from "../config/Config";
import { randomUUID } from 'node:crypto';
import { GitHubOAuthToken } from "./github/Types";
import { ApiError, ErrCode } from "./api";
import { JiraOAuth } from "./jira/OAuth";
import { JiraCloudOAuth } from "./jira/oauth/CloudOAuth";
import { JiraOnPremOAuth } from "./jira/oauth/OnPremOAuth";
import { JiraOnPremClient } from "./jira/client/OnPremClient";
import { JiraCloudClient } from "./jira/client/CloudClient";
import { TokenError, TokenErrorCode } from "./errors";
import { GitHubOAuthToken } from "../github/Types";
import { ApiError, ErrCode } from "../api";
import { JiraOAuth } from "../jira/OAuth";
import { JiraCloudOAuth } from "../jira/oauth/CloudOAuth";
import { JiraOnPremOAuth } from "../jira/oauth/OnPremOAuth";
import { JiraOnPremClient } from "../jira/client/OnPremClient";
import { JiraCloudClient } from "../jira/client/CloudClient";
import { TokenError, TokenErrorCode } from "../errors";
import { TypedEmitter } from "tiny-typed-emitter";
import { hashId, TokenEncryption } from "../libRs";
const ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-hookshot.github.password-store:";
const ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-hookshot.gitlab.password-store:";
@ -31,6 +31,8 @@ export const AllowedTokenTypes = ["github", "gitlab", "jira"];
interface StoredTokenData {
encrypted: string|string[];
keyId: string;
algorithm: 'rsa';
instance?: string;
}
@ -51,20 +53,29 @@ function tokenKey(type: TokenType, userId: string, legacy = false, instanceUrl?:
return `${legacy ? LEGACY_ACCOUNT_DATA_GITLAB_TYPE : ACCOUNT_DATA_GITLAB_TYPE}${instanceUrl}${userId}`;
}
const MAX_TOKEN_PART_SIZE = 128;
const OAUTH_TIMEOUT_MS = 1000 * 60 * 30;
interface Emitter {
onNewToken: (type: TokenType, userId: string, token: string, instanceUrl?: string) => void,
}
export class UserTokenStore extends TypedEmitter<Emitter> {
private key!: Buffer;
public static async fromKeyPath(keyPath: string, intent: Intent, config: BridgeConfig) {
log.info(`Loading token key file ${keyPath}`);
const key = await fs.readFile(keyPath);
return new UserTokenStore(key, intent, config);
}
private oauthSessionStore: Map<string, {userId: string, timeout: NodeJS.Timeout}> = new Map();
private userTokens: Map<string, string>;
public readonly jiraOAuth?: JiraOAuth;
constructor(private keyPath: string, private intent: Intent, private config: BridgeConfig) {
private tokenEncryption: TokenEncryption;
private readonly keyId: string;
constructor(key: Buffer, private readonly intent: Intent, private readonly config: BridgeConfig) {
super();
this.tokenEncryption = new TokenEncryption(key);
this.userTokens = new Map();
this.keyId = hashId(key.toString('utf-8'));
if (config.jira?.oauth) {
if ("client_id" in config.jira.oauth) {
this.jiraOAuth = new JiraCloudOAuth(config.jira.oauth);
@ -76,11 +87,6 @@ export class UserTokenStore extends TypedEmitter<Emitter> {
}
}
public async load() {
log.info(`Loading token key file ${this.keyPath}`);
this.key = await fs.readFile(this.keyPath);
}
public stop() {
for (const session of this.oauthSessionStore.values()) {
clearTimeout(session.timeout);
@ -92,21 +98,16 @@ export class UserTokenStore extends TypedEmitter<Emitter> {
throw new ApiError('User does not have permission to log in to service', ErrCode.ForbiddenUser);
}
const key = tokenKey(type, userId, false, instanceUrl);
const tokenParts: string[] = [];
let tokenSource = token;
while (tokenSource && tokenSource.length > 0) {
const part = tokenSource.slice(0, MAX_TOKEN_PART_SIZE);
tokenSource = tokenSource.substring(MAX_TOKEN_PART_SIZE);
tokenParts.push(publicEncrypt(this.key, Buffer.from(part)).toString("base64"));
}
const tokenParts: string[] = this.tokenEncryption.encrypt(token);
const data: StoredTokenData = {
encrypted: tokenParts,
keyId: this.keyId,
algorithm: "rsa",
instance: instanceUrl,
};
await this.intent.underlyingClient.setAccountData(key, data);
this.userTokens.set(key, token);
log.info(`Stored new ${type} token for ${userId}`);
log.debug(`Stored`, data);
this.emit("onNewToken", type, userId, token, instanceUrl);
}
@ -146,8 +147,19 @@ export class UserTokenStore extends TypedEmitter<Emitter> {
if (!obj || "deleted" in obj) {
return null;
}
// For legacy we just assume it's the current configured key.
const algorithm = obj.algorithm ?? "rsa";
const keyId = obj.keyId ?? this.keyId;
if (algorithm !== 'rsa') {
throw new Error(`Algorithm for stored data is '${algorithm}', but we only support RSA`);
}
if (keyId !== this.keyId) {
throw new Error(`Stored data was encrypted with a different key to the one currently configured`);
}
const encryptedParts = typeof obj.encrypted === "string" ? [obj.encrypted] : obj.encrypted;
const token = encryptedParts.map((t) => privateDecrypt(this.key, Buffer.from(t, "base64")).toString("utf-8")).join("");
const token = this.tokenEncryption.decrypt(encryptedParts);
this.userTokens.set(key, token);
return token;
} catch (ex) {

116
src/tokens/mod.rs Normal file
View File

@ -0,0 +1,116 @@
use std::string::FromUtf8Error;
use base64ct::{Base64, Encoding};
use napi::bindgen_prelude::Buffer;
use napi::Error;
use rsa::pkcs8::DecodePrivateKey;
use rsa::{Pkcs1v15Encrypt, RsaPrivateKey, RsaPublicKey};
static MAX_TOKEN_PART_SIZE: usize = 128;
struct TokenEncryption {
pub private_key: RsaPrivateKey,
pub public_key: RsaPublicKey,
}
#[derive(Debug)]
#[allow(dead_code)]
enum TokenEncryptionError {
FromUtf8(FromUtf8Error),
PrivateKey(rsa::pkcs8::Error),
}
#[derive(Debug)]
#[allow(dead_code)]
enum DecryptError {
Base64(base64ct::Error),
Decryption(rsa::Error),
FromUtf8(FromUtf8Error),
}
impl TokenEncryption {
pub fn new(private_key_data: Vec<u8>) -> Result<Self, TokenEncryptionError> {
let data = String::from_utf8(private_key_data).map_err(TokenEncryptionError::FromUtf8)?;
let private_key = RsaPrivateKey::from_pkcs8_pem(data.as_str())
.map_err(TokenEncryptionError::PrivateKey)?;
let public_key = private_key.to_public_key();
Ok(TokenEncryption {
private_key,
public_key,
})
}
}
#[napi(js_name = "TokenEncryption")]
pub struct JsTokenEncryption {
inner: TokenEncryption,
}
#[napi]
impl JsTokenEncryption {
#[napi(constructor)]
pub fn new(private_key_data: Buffer) -> Result<Self, Error> {
let buf: Vec<u8> = private_key_data.into();
match TokenEncryption::new(buf) {
Ok(inner) => Ok(JsTokenEncryption { inner }),
Err(err) => Err(Error::new(
napi::Status::GenericFailure,
format!("Error reading private key: {:?}", err).to_string(),
)),
}
}
#[napi]
pub fn decrypt(&self, parts: Vec<String>) -> Result<String, Error> {
let mut result = String::new();
for v in parts {
match self.decrypt_value(v) {
Ok(new_value) => {
result += &new_value;
Ok(())
}
Err(err) => Err(Error::new(
napi::Status::GenericFailure,
format!("Could not decrypt string: {:?}", err).to_string(),
)),
}?
}
Ok(result)
}
fn decrypt_value(&self, value: String) -> Result<String, DecryptError> {
let raw_value = Base64::decode_vec(&value).map_err(DecryptError::Base64)?;
let decrypted_value = self
.inner
.private_key
.decrypt(Pkcs1v15Encrypt, &raw_value)
.map_err(DecryptError::Decryption)?;
let utf8_value = String::from_utf8(decrypted_value).map_err(DecryptError::FromUtf8)?;
Ok(utf8_value)
}
#[napi]
pub fn encrypt(&self, input: String) -> Result<Vec<String>, Error> {
let mut rng = rand::thread_rng();
let mut parts: Vec<String> = Vec::new();
for part in input.into_bytes().chunks(MAX_TOKEN_PART_SIZE) {
match self
.inner
.public_key
.encrypt(&mut rng, Pkcs1v15Encrypt, part)
{
Ok(encrypted) => {
let b64 = Base64::encode_string(encrypted.as_slice());
parts.push(b64);
Ok(())
}
Err(err) => Err(Error::new(
napi::Status::GenericFailure,
format!("Could not encrypt string: {:?}", err).to_string(),
)),
}?
}
Ok(parts)
}
}

View File

@ -4,7 +4,7 @@ import { AdminRoom } from "../src/AdminRoom";
import { DefaultConfig } from "../src/config/Defaults";
import { ConnectionManager } from "../src/ConnectionManager";
import { NotifFilter } from "../src/NotificationFilters";
import { UserTokenStore } from "../src/UserTokenStore";
import { UserTokenStore } from "../src/tokens/UserTokenStore";
import { IntentMock } from "./utils/IntentMock";
const ROOM_ID = "!foo:bar";
@ -14,9 +14,8 @@ function createAdminRoom(data: any = {admin_user: "@admin:bar"}): [AdminRoom, In
if (!data.admin_user) {
data.admin_user = "@admin:bar";
}
const tokenStore = new UserTokenStore("notapath", intent, DefaultConfig);
return [new AdminRoom(ROOM_ID, data, NotifFilter.getDefaultContent(), intent, tokenStore, DefaultConfig, {} as ConnectionManager), intent];
}
return [new AdminRoom(ROOM_ID, data, NotifFilter.getDefaultContent(), intent, {} as UserTokenStore, DefaultConfig, {} as ConnectionManager), intent];
}
describe("AdminRoom", () => {
it("will present help text", async () => {

View File

@ -1,9 +1,7 @@
import { expect } from "chai";
import { createMessageQueue } from "../src/MessageQueue/MessageQueue";
const mq = createMessageQueue({
monolithic: true,
});
const mq = createMessageQueue();
describe("MessageQueueTest", () => {
describe("LocalMq", () => {

76
tests/config/config.ts Normal file
View File

@ -0,0 +1,76 @@
import { BridgeConfig } from "../../src/config/Config";
import { DefaultConfigRoot } from "../../src/config/Defaults";
import { expect } from "chai";
describe("Config/BridgeConfig", () => {
describe("will handle the legacy queue.monolitihc option", () => {
it("with no parameters", () => {
const config = new BridgeConfig({ ...DefaultConfigRoot, queue: {
monolithic: true
}});
expect(config.queue).to.be.undefined;
expect(config.cache?.redisUri).to.equal("redis://localhost:6379");
});
it("with a host parameter", () => {
const config = new BridgeConfig({ ...DefaultConfigRoot, queue: {
monolithic: true,
host: 'bark'
}});
expect(config.queue).to.be.undefined;
expect(config.cache?.redisUri).to.equal("redis://bark:6379");
});
it("with a port parameter", () => {
const config = new BridgeConfig({ ...DefaultConfigRoot, queue: {
monolithic: true,
port: 6379,
}});
expect(config.queue).to.be.undefined;
expect(config.cache?.redisUri).to.equal("redis://localhost:6379");
});
it("with a host and port parameter", () => {
const config = new BridgeConfig({ ...DefaultConfigRoot, queue: {
monolithic: true,
host: 'bark',
port: 6379,
}});
expect(config.queue).to.be.undefined;
expect(config.cache?.redisUri).to.equal("redis://bark:6379");
});
it("with monolithic disabled", () => {
const config = new BridgeConfig({ ...DefaultConfigRoot, queue: {
monolithic: false
}});
expect(config.queue).to.deep.equal({
monolithic: false,
});
expect(config.cache?.redisUri).to.equal("redis://localhost:6379");
});
});
describe("will handle the queue option", () => {
it("with redisUri", () => {
const config = new BridgeConfig({ ...DefaultConfigRoot, queue: {
redisUri: "redis://localhost:6379"
}, cache: undefined});
expect(config.queue).to.deep.equal({
redisUri: "redis://localhost:6379"
});
expect(config.cache).to.be.undefined;
});
});
describe("will handle the cache option", () => {
it("with redisUri", () => {
const config = new BridgeConfig({
...DefaultConfigRoot,
cache: {
redisUri: "redis://localhost:6379"
},
queue: undefined,
});
expect(config.cache).to.deep.equal({
redisUri: "redis://localhost:6379"
});
expect(config.queue).to.be.undefined;
});
});
})

View File

@ -1,7 +1,7 @@
import { GitHubRepoConnection, GitHubRepoConnectionState } from "../../src/Connections/GithubRepo"
import { GithubInstance } from "../../src/github/GithubInstance";
import { createMessageQueue } from "../../src/MessageQueue";
import { UserTokenStore } from "../../src/UserTokenStore";
import { UserTokenStore } from "../../src/tokens/UserTokenStore";
import { DefaultConfig } from "../../src/config/Defaults";
import { AppserviceMock } from "../utils/AppserviceMock";
import { ApiError, ErrCode, ValidatorApiError } from "../../src/api";
@ -37,9 +37,7 @@ const GITHUB_ISSUE_CREATED_PAYLOAD = {
};
function createConnection(state: Record<string, unknown> = {}, isExistingState=false) {
const mq = createMessageQueue({
monolithic: true
});
const mq = createMessageQueue();
mq.subscribe('*');
const as = AppserviceMock.create();
const intent = as.getIntentForUserId('@github:example.test');

View File

@ -1,5 +1,5 @@
import { createMessageQueue } from "../../src/MessageQueue";
import { UserTokenStore } from "../../src/UserTokenStore";
import { UserTokenStore } from "../../src/tokens/UserTokenStore";
import { AppserviceMock } from "../utils/AppserviceMock";
import { ApiError, ErrCode, ValidatorApiError } from "../../src/api";
import { GitLabRepoConnection, GitLabRepoConnectionState } from "../../src/Connections";
@ -56,9 +56,7 @@ const GITLAB_MR_COMMENT = {
const COMMENT_DEBOUNCE_MS = 25;
function createConnection(state: Record<string, unknown> = {}, isExistingState=false): { connection: GitLabRepoConnection, intent: IntentMock } {
const mq = createMessageQueue({
monolithic: true
});
const mq = createMessageQueue();
mq.subscribe('*');
const as = AppserviceMock.create();
const intent = as.getIntentForUserId('@gitlab:example.test');

View File

@ -0,0 +1,48 @@
import { TokenEncryption } from "../../src/libRs";
import { RSAKeyPairOptions, generateKeyPair } from "node:crypto";
import { expect } from "chai";
describe("TokenEncryption", () => {
let keyPromise: Promise<Buffer>;
async function createTokenEncryption() {
return new TokenEncryption(await keyPromise);
}
before('generate RSA key', () => {
// Generate this once since it will take an age.
keyPromise = new Promise<Buffer>((resolve, reject) => generateKeyPair("rsa", {
// Deliberately shorter length to speed up test
modulusLength: 2048,
privateKeyEncoding: {
type: "pkcs8",
format: "pem",
},
publicKeyEncoding: {
format: "pem",
type: "pkcs1",
}
} satisfies RSAKeyPairOptions<"pem", "pem">, (err, _, privateKey) => {
if (err) { reject(err) } else { resolve(Buffer.from(privateKey)) }
}));
}, );
it('should be able to encrypt a string into a single part', async() => {
const tokenEncryption = await createTokenEncryption();
const result = tokenEncryption.encrypt('hello world');
expect(result).to.have.lengthOf(1);
});
it('should be able to decrypt from a single part into a string', async() => {
const tokenEncryption = await createTokenEncryption();
const value = tokenEncryption.encrypt('hello world');
const result = tokenEncryption.decrypt(value);
expect(result).to.equal('hello world');
});
it('should be able to decrypt from many parts into string', async() => {
const plaintext = 'This is a very long string that needs to be encoded into multiple parts in order for us to store it properly. This ' +
' should end up as multiple encrypted values in base64.';
const tokenEncryption = await createTokenEncryption();
const value = tokenEncryption.encrypt(plaintext);
expect(value).to.have.lengthOf(2);
const result = tokenEncryption.decrypt(value);
expect(result).to.equal(plaintext);
});
});

View File

@ -4135,9 +4135,9 @@ fn.name@1.x.x:
integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==
follow-redirects@^1.14.0, follow-redirects@^1.14.9, follow-redirects@^1.15.0:
version "1.15.4"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf"
integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==
version "1.15.6"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b"
integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==
for-each@^0.3.3:
version "0.3.3"
@ -7139,9 +7139,9 @@ safe-stable-stringify@^2.3.1:
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
sanitize-html@^2.11.0, sanitize-html@^2.8.0:
version "2.11.0"
resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.11.0.tgz#9a6434ee8fcaeddc740d8ae7cd5dd71d3981f8f6"
integrity sha512-BG68EDHRaGKqlsNjJ2xUB7gpInPA8gVx/mvjO743hZaeMCZ2DwzW7xvsqZ+KNU4QKwj86HJ3uu2liISf2qBBUA==
version "2.12.1"
resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.12.1.tgz#280a0f5c37305222921f6f9d605be1f6558914c7"
integrity sha512-Plh+JAn0UVDpBRP/xEjsk+xDCoOvMBwQUf/K+/cBAVuTbtX8bj2VB7S1sL1dssVpykqp0/KPSesHrqXtokVBpA==
dependencies:
deepmerge "^4.2.2"
escape-string-regexp "^4.0.0"
@ -8000,10 +8000,10 @@ vite-plugin-magical-svg@^1.1.1:
svgo "^3.1.0"
xml2js "^0.6.2"
vite@^5.0.12:
version "5.0.12"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.0.12.tgz#8a2ffd4da36c132aec4adafe05d7adde38333c47"
integrity sha512-4hsnEkG3q0N4Tzf1+t6NdN9dg/L3BM+q8SWgbSPnJvrgH2kgdyzfVJwbR1ic69/4uMJJ/3dqDZZE5/WwqW8U1w==
vite@^5.0.13:
version "5.0.13"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.0.13.tgz#605865b0e482506163e3f04f91665238f3be8cf1"
integrity sha512-/9ovhv2M2dGTuA+dY93B9trfyWMDRQw2jdVBhHNP6wr0oF34wG2i/N55801iZIpgUpnHDm4F/FabGQLyc+eOgg==
dependencies:
esbuild "^0.19.3"
postcss "^8.4.32"