Compare commits

..

71 Commits

Author SHA1 Message Date
Shlee
b0d1291cff Installer: Bootstrap optional PostgreSQL/Redis (#3152)
* Make optional postgres and redis in installer
2026-05-29 09:43:59 -07:00
Owen Schwartz
1215aa8122 Merge pull request #3184 from fosrl/dependabot/npm_and_yarn/prod-minor-updates-1701004488
Bump the prod-minor-updates group with 9 updates
2026-05-28 20:36:43 -07:00
Owen Schwartz
d318a756a8 Merge pull request #3183 from fosrl/dependabot/npm_and_yarn/dev-patch-updates-60744307c2
Bump the dev-patch-updates group with 4 updates
2026-05-28 20:36:17 -07:00
Owen Schwartz
b3c1e49c0c Merge pull request #3185 from fosrl/dependabot/npm_and_yarn/stripe-22.2.0
Bump stripe from 20.4.1 to 22.2.0
2026-05-28 20:35:52 -07:00
Owen Schwartz
dc12b00502 Merge pull request #3186 from fosrl/dependabot/npm_and_yarn/lucide-react-1.17.0
Bump lucide-react from 0.577.0 to 1.17.0
2026-05-28 20:35:39 -07:00
Owen Schwartz
1e27acbf88 Merge pull request #2980 from rinseaid/blueprint-auto-create-roles
Auto-create roles referenced in blueprints
2026-05-28 20:10:53 -07:00
dependabot[bot]
4012cc658d Bump lucide-react from 0.577.0 to 1.17.0
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.577.0 to 1.17.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.17.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-29 01:44:09 +00:00
dependabot[bot]
84d7a87609 Bump stripe from 20.4.1 to 22.2.0
Bumps [stripe](https://github.com/stripe/stripe-node) from 20.4.1 to 22.2.0.
- [Release notes](https://github.com/stripe/stripe-node/releases)
- [Changelog](https://github.com/stripe/stripe-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-node/compare/v20.4.1...v22.2.0)

---
updated-dependencies:
- dependency-name: stripe
  dependency-version: 22.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-29 01:43:42 +00:00
dependabot[bot]
9a92be532a Bump the prod-minor-updates group with 9 updates
Bumps the prod-minor-updates group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1047.0` | `3.1056.0` |
| [@hookform/resolvers](https://github.com/react-hook-form/resolvers) | `5.2.2` | `5.4.0` |
| [helmet](https://github.com/helmetjs/helmet) | `8.1.0` | `8.2.0` |
| [ioredis](https://github.com/luin/ioredis) | `5.10.1` | `5.11.0` |
| [next-intl](https://github.com/amannn/next-intl) | `4.12.0` | `4.13.0` |
| [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) | `8.20.0` | `8.21.0` |
| [posthog-node](https://github.com/PostHog/posthog-js/tree/HEAD/packages/node) | `5.34.1` | `5.35.6` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.75.0` | `7.76.1` |
| [ws](https://github.com/websockets/ws) | `8.20.1` | `8.21.0` |


Updates `@aws-sdk/client-s3` from 3.1047.0 to 3.1056.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1056.0/clients/client-s3)

Updates `@hookform/resolvers` from 5.2.2 to 5.4.0
- [Release notes](https://github.com/react-hook-form/resolvers/releases)
- [Commits](https://github.com/react-hook-form/resolvers/compare/v5.2.2...v5.4.0)

Updates `helmet` from 8.1.0 to 8.2.0
- [Changelog](https://github.com/helmetjs/helmet/blob/main/CHANGELOG.md)
- [Commits](https://github.com/helmetjs/helmet/compare/v8.1.0...v8.2.0)

Updates `ioredis` from 5.10.1 to 5.11.0
- [Release notes](https://github.com/luin/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/luin/ioredis/compare/v5.10.1...v5.11.0)

Updates `next-intl` from 4.12.0 to 4.13.0
- [Release notes](https://github.com/amannn/next-intl/releases)
- [Changelog](https://github.com/amannn/next-intl/blob/main/CHANGELOG.md)
- [Commits](https://github.com/amannn/next-intl/compare/v4.12.0...v4.13.0)

Updates `pg` from 8.20.0 to 8.21.0
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.21.0/packages/pg)

Updates `posthog-node` from 5.34.1 to 5.35.6
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/packages/node/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/commits/posthog-node@5.35.6/packages/node)

Updates `react-hook-form` from 7.75.0 to 7.76.1
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.75.0...v7.76.1)

Updates `ws` from 8.20.1 to 8.21.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.20.1...8.21.0)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1056.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@hookform/resolvers"
  dependency-version: 5.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: helmet
  dependency-version: 8.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: ioredis
  dependency-version: 5.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: next-intl
  dependency-version: 4.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: pg
  dependency-version: 8.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: posthog-node
  dependency-version: 5.35.6
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: react-hook-form
  dependency-version: 7.76.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: ws
  dependency-version: 8.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-29 01:43:25 +00:00
dependabot[bot]
18ac542e30 Bump the dev-patch-updates group with 4 updates
Bumps the dev-patch-updates group with 4 updates: [@tanstack/react-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/react-query-devtools), [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react), [postcss](https://github.com/postcss/postcss) and [tsx](https://github.com/privatenumber/tsx).


Updates `@tanstack/react-query-devtools` from 5.100.10 to 5.100.14
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query-devtools@5.100.14/packages/react-query-devtools)

Updates `@types/react` from 19.2.14 to 19.2.15
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `postcss` from 8.5.14 to 8.5.15
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.14...8.5.15)

Updates `tsx` from 4.22.0 to 4.22.3
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.22.0...v4.22.3)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query-devtools"
  dependency-version: 5.100.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/react"
  dependency-version: 19.2.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: postcss
  dependency-version: 8.5.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tsx
  dependency-version: 4.22.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-29 01:36:20 +00:00
Owen Schwartz
c74b423bae Merge pull request #3119 from Adityakk9031/#3086
Sort resource filter options in audit logs
2026-05-28 15:50:27 -07:00
Owen Schwartz
6d17bb04c4 Merge pull request #3167 from shleeable/patch-1
Installer: format main.go
2026-05-28 12:13:45 -07:00
Owen Schwartz
957e7ba127 Merge pull request #3175 from shleeable/patch-4
Fix:  OLM token rate limit uses wrong field name
2026-05-28 12:13:04 -07:00
Owen Schwartz
def710cba8 Merge pull request #3176 from shleeable/patch-5
Fix: Update external.ts windowMs rate limit for milliseconds
2026-05-28 12:12:39 -07:00
Owen Schwartz
44da854575 Merge pull request #3177 from shleeable/patch-6
Fix: Missing return
2026-05-28 12:11:40 -07:00
Owen Schwartz
d7d37c6f6e Merge pull request #3179 from fosrl/dependabot/npm_and_yarn/dev-minor-updates-545c73ecbb
Bump the dev-minor-updates group across 1 directory with 6 updates
2026-05-28 12:10:40 -07:00
dependabot[bot]
3c80b9a229 Bump the dev-minor-updates group across 1 directory with 6 updates
Bumps the dev-minor-updates group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@dotenvx/dotenvx](https://github.com/dotenvx/dotenvx) | `1.66.0` | `1.69.1` |
| [@react-email/ui](https://github.com/resend/react-email/tree/HEAD/packages/ui) | `6.1.4` | `6.5.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.8.0` | `25.9.1` |
| [eslint](https://github.com/eslint/eslint) | `10.3.0` | `10.4.0` |
| [react-email](https://github.com/resend/react-email/tree/HEAD/packages/react-email) | `6.1.4` | `6.5.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.3` | `8.60.0` |



Updates `@dotenvx/dotenvx` from 1.66.0 to 1.69.1
- [Release notes](https://github.com/dotenvx/dotenvx/releases)
- [Changelog](https://github.com/dotenvx/dotenvx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dotenvx/dotenvx/compare/v1.66.0...v1.69.1)

Updates `@react-email/ui` from 6.1.4 to 6.5.0
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/ui/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/ui@6.5.0/packages/ui)

Updates `@types/node` from 25.8.0 to 25.9.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 10.3.0 to 10.4.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.3.0...v10.4.0)

Updates `react-email` from 6.1.4 to 6.5.0
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/react-email/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/react-email@6.5.0/packages/react-email)

Updates `typescript-eslint` from 8.59.3 to 8.60.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.60.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@dotenvx/dotenvx"
  dependency-version: 1.69.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@react-email/ui"
  dependency-version: 6.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@types/node"
  dependency-version: 25.9.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: eslint
  dependency-version: 10.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: react-email
  dependency-version: 6.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: typescript-eslint
  dependency-version: 8.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 19:10:09 +00:00
Owen Schwartz
a998a35482 Merge pull request #3181 from fosrl/remove-resend
Remove resend
2026-05-28 12:07:20 -07:00
Owen
20e0e5ebd0 Remove resend 2026-05-28 12:06:29 -07:00
Owen Schwartz
4d831effe1 Merge pull request #3180 from fosrl/dependabot/npm_and_yarn/prod-patch-updates-203742b32f
Bump the prod-patch-updates group across 1 directory with 5 updates
2026-05-28 12:06:08 -07:00
dependabot[bot]
80f4dd0e60 Bump the prod-patch-updates group across 1 directory with 5 updates
Bumps the prod-patch-updates group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) | `13.3.0` | `13.3.1` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.100.10` | `5.100.14` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `8.0.7` | `8.0.9` |
| [resend](https://github.com/resend/resend-node) | `6.12.3` | `6.12.4` |
| [semver](https://github.com/npm/node-semver) | `7.8.0` | `7.8.1` |



Updates `@simplewebauthn/server` from 13.3.0 to 13.3.1
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.1/packages/server)

Updates `@tanstack/react-query` from 5.100.10 to 5.100.14
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.100.14/packages/react-query)

Updates `nodemailer` from 8.0.7 to 8.0.9
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.7...v8.0.9)

Updates `resend` from 6.12.3 to 6.12.4
- [Release notes](https://github.com/resend/resend-node/releases)
- [Commits](https://github.com/resend/resend-node/compare/v6.12.3...v6.12.4)

Updates `semver` from 7.8.0 to 7.8.1
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.8.0...v7.8.1)

---
updated-dependencies:
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.100.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: nodemailer
  dependency-version: 8.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: resend
  dependency-version: 6.12.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: semver
  dependency-version: 7.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 19:02:31 +00:00
Owen Schwartz
eafa3076d8 Merge pull request #3137 from fosrl/dependabot/npm_and_yarn/qs-6.15.2
Bump qs from 6.15.1 to 6.15.2
2026-05-28 12:01:50 -07:00
Owen Schwartz
fef3cd8354 Merge pull request #2908 from fosrl/dependabot/github_actions/actions/setup-node-6.4.0
Bump actions/setup-node from 6.3.0 to 6.4.0
2026-05-28 12:00:48 -07:00
Owen Schwartz
36ada0705e Merge pull request #3044 from fosrl/dependabot/github_actions/sigstore/cosign-installer-4.1.2
Bump sigstore/cosign-installer from 4.1.1 to 4.1.2
2026-05-28 12:00:38 -07:00
Owen Schwartz
8ae3c06df7 Merge pull request #3143 from fosrl/dependabot/github_actions/actions/stale-10.3.0
Bump actions/stale from 10.2.0 to 10.3.0
2026-05-28 12:00:25 -07:00
dependabot[bot]
ba127a8536 Bump qs from 6.15.1 to 6.15.2
Bumps [qs](https://github.com/ljharb/qs) from 6.15.1 to 6.15.2.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.15.1...v6.15.2)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.15.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 18:59:36 +00:00
Owen Schwartz
5c024f3a3a Merge pull request #3142 from fosrl/dependabot/github_actions/docker/login-action-4.2.0
Bump docker/login-action from 4.1.0 to 4.2.0
2026-05-28 11:57:53 -07:00
Owen Schwartz
4fdb8583f6 Merge pull request #3178 from fosrl/sec-updates
Advance security updates to main
2026-05-28 11:56:57 -07:00
Owen Schwartz
2946df3b8e Merge pull request #3085 from marcschaeferger-org/security-updates
Normalize request parameters and update dependencies for Security
2026-05-28 11:54:23 -07:00
Shlee
c3b0c4e5e9 Update verifyApiKeyOrgAccess.ts 2026-05-28 15:55:34 +09:30
Shlee
a79d0f1677 Update external.ts 2026-05-28 15:45:06 +09:30
Shlee
bfd7a7f561 Update external.ts 2026-05-28 15:31:45 +09:30
Shlee
cf12ab1ac3 Update main.go 2026-05-27 12:12:48 +09:30
Owen Schwartz
ddabfb5ca1 Merge pull request #3154 from RitwijParmar/codex/pangolin-refresh-live-log-window
fix(logs): refresh default end time
2026-05-26 11:52:10 -07:00
Owen Schwartz
ec0666a612 Merge pull request #3151 from shleeable/patch-1
Installer: Handle both Maxmind Country and ASN databases.
2026-05-26 09:50:08 -07:00
Shlee
bbf42c5802 Update main.go 2026-05-26 17:14:06 +09:30
Ritwij Aryan Parmar
6aa1d3b094 fix(logs): refresh default end time 2026-05-26 01:26:53 -04:00
Shlee
f1ec1a2fb1 Update docker-compose.yml 2026-05-26 13:49:06 +09:30
Shlee
32fcf90467 Update docker-compose.yml 2026-05-26 13:48:00 +09:30
Shlee
5a53f88fd6 Update main.go 2026-05-26 13:37:28 +09:30
Shlee
51971c7ef2 Update config.yml 2026-05-26 13:36:01 +09:30
Shlee
491096109a Update main.go 2026-05-26 13:31:07 +09:30
Shlee
802a41b1bd Update main.go 2026-05-26 13:25:53 +09:30
Shlee
f59fbabede Update main.go 2026-05-26 13:12:48 +09:30
Shlee
5a7d54058e Update main.go 2026-05-26 13:06:35 +09:30
Owen Schwartz
5ef4490692 Merge pull request #3148 from bishnubista/fix-audit-log-replica-routing
fix(audit-logs): route request audit log reads through logsDb
2026-05-25 12:02:24 -07:00
bishnubista
817e848d08 fix(audit-logs): route request audit log reads through logsDb
Route the read paths in queryRequestAuditLog.ts and
queryRequestAnalytics.ts through `logsDb` instead of
`primaryLogsDb`, matching the existing private audit log routes
(queryActionAuditLog, queryAccessAuditLog, queryConnectionAuditLog
all already use `logsDb`). In PostgreSQL deployments configured
with a read replica via `withReplicas` (see server/db/pg/logsDriver.ts),
this keeps high-volume audit log reads off the primary. No-op
in OSS-SQLite where `logsDb === primaryDb`.

Investigated rewriting `queryUniqueFilterAttributes` per the
in-line TODO ("SOMEONE PLEASE OPTIMIZE THIS!!!!!"). A candidate
rewrite using UNION ALL with six GROUP BY...LIMIT 500 arms
benchmarked 48-61% slower than the current SELECT DISTINCT
LIMIT 501 approach on SQLite (100k/300k/1M rows, 20 runs each):
each grouped arm materializes a temp B-tree before applying LIMIT,
while DISTINCT short-circuits via hash dedup with early exit.
A materialized facets table is likely the right long-term fix,
not a query-shape rewrite.
2026-05-25 10:37:47 -07:00
dependabot[bot]
166c8326c5 Bump actions/stale from 10.2.0 to 10.3.0
Bumps [actions/stale](https://github.com/actions/stale) from 10.2.0 to 10.3.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](b5d41d4e1d...eb5cf3af3a)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 10.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-25 01:52:46 +00:00
dependabot[bot]
673f1e93f4 Bump docker/login-action from 4.1.0 to 4.2.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](4907a6ddec...650006c6eb)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-25 01:52:42 +00:00
Owen Schwartz
35ad235f49 Merge pull request #3129 from fosrl/fix-site-delete
Improve delete function speed & order of ops
2026-05-21 12:06:18 -07:00
Owen
834672c846 Improve delete function speed & order of ops 2026-05-21 12:05:16 -07:00
Owen Schwartz
b8180d848a Merge pull request #3118 from Adityakk9031/#3105
Fix public resource health with unknown WireGuard targets
2026-05-20 16:20:25 -07:00
Owen Schwartz
fef7563e14 Merge pull request #3125 from fosrl/fix-3104
Fix #3104
2026-05-20 16:15:21 -07:00
Owen
6337cf4359 Fix #3104 2026-05-20 16:14:47 -07:00
Owen Schwartz
b3cfe82dff Merge pull request #3124 from fosrl/fix-logoUrl
Fix logo url
2026-05-20 14:19:29 -07:00
Owen
d65128671c Fix logo url 2026-05-20 14:18:55 -07:00
Owen Schwartz
41fdd5de74 Merge pull request #3122 from fosrl/button-to-rebuild-association
Add button to rebuid cache
2026-05-20 12:08:47 -07:00
Owen
2704202ba9 Add button to rebuid cache 2026-05-20 12:08:20 -07:00
Owen Schwartz
72ef0ae020 Merge pull request #3121 from fosrl/patch-rebuild-sites
patch rebuild sites
2026-05-20 11:48:33 -07:00
Owen
1442faa740 Prevent concurrent rebuilds 2026-05-20 11:46:59 -07:00
Owen
6aa589e612 Block adds to clients in jit mode 2026-05-20 11:35:15 -07:00
Owen
4b1a8e14c4 Put long running into the background to end transaction 2026-05-20 11:18:47 -07:00
Owen
1a0db10b1a Verify button to verify cache 2026-05-20 11:15:15 -07:00
Owen
b7634086db Just accept any url for now 2026-05-20 10:47:37 -07:00
Aditya kumar singh
73e9e830c3 Sort resource filter options in audit logs 2026-05-20 11:13:50 +05:30
Aditya kumar singh
a6469e67a8 Fix public resource health with unknown WireGuard targets 2026-05-20 09:05:05 +05:30
Owen Schwartz
1ba75092f9 Merge pull request #3113 from fosrl/dev
derived only from roles that the user holds AND are assigned to the target resource
2026-05-19 10:56:30 -07:00
Owen Schwartz
82745c701a Merge pull request #3094 from fosrl/dev
Sync dev
2026-05-16 20:46:12 -07:00
dependabot[bot]
e4fd2b656d Bump sigstore/cosign-installer from 4.1.1 to 4.1.2
Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 4.1.1 to 4.1.2.
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/v4.1.1...6f9f17788090df1f26f669e9d70d6ae9567deba6)

---
updated-dependencies:
- dependency-name: sigstore/cosign-installer
  dependency-version: 4.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-16 21:55:15 +00:00
rinseaid
4786fc3a31 Auto-create roles referenced in blueprints
When a blueprint references a role that doesn't exist, create it
automatically with default permissions (getOrg, getResource,
listResources) instead of throwing an error or silently dropping
the association.
2026-05-03 13:37:47 -04:00
dependabot[bot]
f286d66cbc Bump actions/setup-node from 6.3.0 to 6.4.0
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.3.0 to 6.4.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](53b83947a5...48b55a011b)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 01:36:02 +00:00
112 changed files with 4682 additions and 8292 deletions

View File

@@ -77,7 +77,7 @@ jobs:
fi fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: docker.io registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -149,7 +149,7 @@ jobs:
fi fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: docker.io registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -204,7 +204,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: docker.io registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -407,7 +407,7 @@ jobs:
shell: bash shell: bash
- name: Login to GitHub Container Registry (for cosign) - name: Login to GitHub Container Registry (for cosign)
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View File

@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: '24' node-version: '24'

View File

@@ -23,7 +23,7 @@ jobs:
skopeo --version skopeo --version
- name: Install cosign - name: Install cosign
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Input check - name: Input check
run: | run: |

View File

@@ -14,7 +14,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with: with:
days-before-stale: 14 days-before-stale: 14
days-before-close: 14 days-before-close: 14

View File

@@ -17,7 +17,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Node - name: Install Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: '24' node-version: '24'

View File

@@ -1,4 +1,4 @@
import { APP_PATH } from "@server/lib/consts"; import { APP_PATH } from "./server/lib/consts";
import { defineConfig } from "drizzle-kit"; import { defineConfig } from "drizzle-kit";
import path from "path"; import path from "path";

View File

@@ -22,7 +22,8 @@ server:
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"] methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
allowed_headers: ["X-CSRF-Token", "Content-Type"] allowed_headers: ["X-CSRF-Token", "Content-Type"]
credentials: false credentials: false
{{if .EnableGeoblocking}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}} {{if .EnableMaxMind}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}}
{{if .EnableMaxMind}}maxmind_asn_path: "./config/GeoLite2-ASN.mmdb"{{end}}
{{if .EnableEmail}} {{if .EnableEmail}}
email: email:
smtp_host: "{{.EmailSMTPHost}}" smtp_host: "{{.EmailSMTPHost}}"
@@ -36,3 +37,8 @@ flags:
disable_signup_without_invite: true disable_signup_without_invite: true
disable_user_create_org: false disable_user_create_org: false
allow_raw_resources: true allow_raw_resources: true
{{if .IsPostgreSQL}}
postgres:
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin
{{end}}

View File

@@ -1,7 +1,7 @@
name: pangolin name: pangolin
services: services:
pangolin: pangolin:
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}} image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{if .IsPostgreSQL}}postgresql-{{end}}{{.PangolinVersion}}
container_name: pangolin container_name: pangolin
restart: unless-stopped restart: unless-stopped
deploy: deploy:
@@ -10,6 +10,20 @@ services:
memory: 1g memory: 1g
reservations: reservations:
memory: 256m memory: 256m
{{if or .IsPostgreSQL .IsRedis}}
depends_on:
{{if .IsPostgreSQL}}
postgres:
condition: service_healthy
{{end}}
{{if .IsRedis}}
redis:
condition: service_healthy
{{end}}
networks:
- default
- backend
{{end}}
volumes: volumes:
- ./config:/app/config - ./config:/app/config
healthcheck: healthcheck:
@@ -60,8 +74,56 @@ services:
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs - ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
{{if .IsPostgreSQL}}
postgres:
image: postgres:18
container_name: postgres
restart: unless-stopped
environment:
POSTGRES_USER: pangolin
POSTGRES_PASSWORD: {{.IsPostgreSQLPass}}
POSTGRES_DB: pangolin
volumes:
- ./postgres18:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pangolin"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend
{{end}}
{{if .IsRedis}}
redis:
image: redis:8-trixie
container_name: redis
restart: unless-stopped
command: >
redis-server
--save 3600 1000
--appendonly yes
--requirepass {{.IsRedisPass}}
volumes:
- ./redis8:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "{{.IsRedisPass}}", "ping"]
interval: 10s
timeout: 3s
retries: 3
start_period: 10s
networks:
- backend
{{end}}
networks: networks:
default: default:
driver: bridge driver: bridge
name: pangolin name: pangolin_frontend
{{if .EnableIPv6}} enable_ipv6: true{{end}} {{if .EnableIPv6}} enable_ipv6: true{{end}}
{{if or .IsPostgreSQL .IsRedis}}
backend:
driver: bridge
name: pangolin_backend
internal: true
{{end}}

View File

@@ -0,0 +1,6 @@
{{if .IsRedis}}
redis:
host: "redis"
port: 6379
password: "{{.IsRedisPass}}"
{{end}}

View File

@@ -5,7 +5,7 @@ go 1.25.0
require ( require (
github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/term v0.42.0 golang.org/x/term v0.43.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -33,6 +33,6 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.15.0 // indirect golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/text v0.23.0 // indirect
) )

View File

@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View File

@@ -54,9 +54,13 @@ type Config struct {
InstallGerbil bool InstallGerbil bool
TraefikBouncerKey string TraefikBouncerKey string
DoCrowdsecInstall bool DoCrowdsecInstall bool
EnableGeoblocking bool EnableMaxMind bool
Secret string Secret string
IsEnterprise bool IsEnterprise bool
IsPostgreSQL bool
IsPostgreSQLPass string
IsRedis bool
IsRedisPass string
} }
type SupportedContainer string type SupportedContainer string
@@ -123,11 +127,11 @@ func main() {
fmt.Println("\nConfiguration files created successfully!") fmt.Println("\nConfiguration files created successfully!")
// Download MaxMind database if requested // Download MaxMind Country / ASN database if requested
if config.EnableGeoblocking { if config.EnableMaxMind {
fmt.Println("\n=== Downloading MaxMind Database ===") fmt.Println("\n=== Downloading MaxMind Country and ASN Databases ===")
if err := downloadMaxMindDatabase(); err != nil { if err := downloadMaxMindDatabase(); err != nil {
fmt.Printf("Error downloading MaxMind database: %v\n", err) fmt.Printf("Error downloading MaxMind databases: %v\n", err)
fmt.Println("You can download it manually later if needed.") fmt.Println("You can download it manually later if needed.")
} }
} }
@@ -188,15 +192,15 @@ func main() {
fmt.Println("\n=== MaxMind Database Update ===") fmt.Println("\n=== MaxMind Database Update ===")
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil { if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
fmt.Println("MaxMind GeoLite2 Country database found.") fmt.Println("MaxMind GeoLite2 Country database found.")
if readBool("Would you like to update the MaxMind database to the latest version?", false) { if readBool("Would you like to update the MaxMind databases (Country and ASN) to the latest version?", false) {
if err := downloadMaxMindDatabase(); err != nil { if err := downloadMaxMindDatabase(); err != nil {
fmt.Printf("Error updating MaxMind database: %v\n", err) fmt.Printf("Error updating MaxMind database: %v\n", err)
fmt.Println("You can try updating it manually later if needed.") fmt.Println("You can try updating it manually later if needed.")
} }
} }
} else { } else {
fmt.Println("MaxMind GeoLite2 Country database not found.") fmt.Println("MaxMind GeoLite2 Country and ASN databases not found.")
if readBool("Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) { if readBool("Would you like to download the MaxMind GeoLite2 databases for blocking functionality?", false) {
if err := downloadMaxMindDatabase(); err != nil { if err := downloadMaxMindDatabase(); err != nil {
fmt.Printf("Error downloading MaxMind database: %v\n", err) fmt.Printf("Error downloading MaxMind database: %v\n", err)
fmt.Println("You can try downloading it manually later if needed.") fmt.Println("You can try downloading it manually later if needed.")
@@ -204,8 +208,10 @@ func main() {
// Now you need to update your config file accordingly to enable geoblocking // Now you need to update your config file accordingly to enable geoblocking
fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n") fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n")
// add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server // add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server
fmt.Println("Add the following line under the 'server' section:") // add maxmind_asn_path: "./config/GeoLite2-ASN.mmdb" under server
fmt.Println("Add the following lines under the 'server' section:")
fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"") fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"")
fmt.Println(" maxmind_asn_path: \"./config/GeoLite2-ASN.mmdb\"")
} }
} }
} }
@@ -484,6 +490,17 @@ func collectUserInput() Config {
fmt.Println("\n=== Basic Configuration ===") fmt.Println("\n=== Basic Configuration ===")
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.") config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
if config.IsEnterprise {
config.IsRedis = readBool("Do you want to run the Redis containers locally? Required for HA.")
if config.IsRedis {
config.IsRedisPass = readPassword("Enter a unique password for the Redis service.")
}
}
config.IsPostgreSQL = readBool("Do you want to run the PostgreSQL containers locally? Otherwise, default to the local SQLite database only.", false)
if config.IsPostgreSQL {
config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.")
}
config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "") config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "")
@@ -527,7 +544,7 @@ func collectUserInput() Config {
fmt.Println("\n=== Advanced Configuration ===") fmt.Println("\n=== Advanced Configuration ===")
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true) config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
config.EnableGeoblocking = readBool("Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true) config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ADN databases for blocking functionality?", true)
if config.DashboardDomain == "" { if config.DashboardDomain == "" {
fmt.Println("Error: Dashboard Domain name is required") fmt.Println("Error: Dashboard Domain name is required")
@@ -780,29 +797,42 @@ func checkPortsAvailable(port int) error {
} }
func downloadMaxMindDatabase() error { func downloadMaxMindDatabase() error {
fmt.Println("Downloading MaxMind GeoLite2 Country database...") fmt.Println("Downloading MaxMind GeoLite2 Country and ASN databases...")
// Download the GeoLite2 Country database // Download the GeoLite2 Country databases
if err := run("curl", "-L", "-o", "GeoLite2-Country.tar.gz", if err := run("curl", "-L", "-o", "GeoLite2-Country.tar.gz",
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-Country.tar.gz"); err != nil { "https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-Country.tar.gz"); err != nil {
return fmt.Errorf("failed to download GeoLite2 database: %v", err) return fmt.Errorf("failed to download GeoLite2 Country database: %v", err)
}
if err := run("curl", "-L", "-o", "GeoLite2-ASN.tar.gz",
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-ASN.tar.gz"); err != nil {
return fmt.Errorf("failed to download GeoLite2 ASN database: %v", err)
} }
// Extract the database // Extract the Country database
if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil { if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil {
return fmt.Errorf("failed to extract GeoLite2 database: %v", err) return fmt.Errorf("failed to extract GeoLite2 Country database: %v", err)
}
if err := run("tar", "-xzf", "GeoLite2-ASN.tar.gz"); err != nil {
return fmt.Errorf("failed to extract GeoLite2 ASN database: %v", err)
} }
// Find the .mmdb file and move it to the config directory // Find the .mmdb file and move it to the config directory
if err := run("bash", "-c", "mv GeoLite2-Country_*/GeoLite2-Country.mmdb config/"); err != nil { if err := run("bash", "-c", "mv GeoLite2-Country_*/GeoLite2-Country.mmdb config/"); err != nil {
return fmt.Errorf("failed to move GeoLite2 database to config directory: %v", err) return fmt.Errorf("failed to move GeoLite2 Country database to config directory: %v", err)
}
if err := run("bash", "-c", "mv GeoLite2-ASN_*/GeoLite2-ASN.mmdb config/"); err != nil {
return fmt.Errorf("failed to move GeoLite2 ASN database to config directory: %v", err)
} }
// Clean up the downloaded files // Clean up the downloaded files
if err := run("rm", "-rf", "GeoLite2-Country.tar.gz", "GeoLite2-Country_*"); err != nil { if err := run("sh", "-c", "rm -rf GeoLite2-Country.tar.gz GeoLite2-Country_*"); err != nil {
fmt.Printf("Warning: failed to clean up temporary files: %v\n", err) fmt.Printf("Warning: failed to clean up temporary country files: %v\n", err)
}
if err := run("sh", "-c", "rm -rf GeoLite2-ASN.tar.gz GeoLite2-ASN_*"); err != nil {
fmt.Printf("Warning: failed to clean up temporary ASN files: %v\n", err)
} }
fmt.Println("MaxMind GeoLite2 Country database downloaded successfully!") fmt.Println("MaxMind GeoLite2 Country and ASN database downloaded successfully!")
return nil return nil
} }

View File

@@ -255,23 +255,6 @@
"resourceGoTo": "Go to Resource", "resourceGoTo": "Go to Resource",
"resourceDelete": "Delete Resource", "resourceDelete": "Delete Resource",
"resourceDeleteConfirm": "Confirm Delete Resource", "resourceDeleteConfirm": "Confirm Delete Resource",
"labelDelete": "Delete Label",
"labelAdd": "Add Label",
"labelCreateSuccessMessage": "Label Created Successfully",
"labelEditSuccessMessage": "Label Modified Successfully",
"labelNameField": "Label Name",
"labelColorField": "Label Color",
"labelPlaceholder": "Ex: homelab",
"labelCreate": "Create Label",
"createLabelDialogTitle": "Create Label",
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
"labelEdit": "Edit Label",
"editLabelDialogTitle": "Update Label",
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
"labelDeleteConfirm": "Confirm Delete Label",
"labelErrorDelete": "Failed to delete label",
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
"visibility": "Visibility", "visibility": "Visibility",
"enabled": "Enabled", "enabled": "Enabled",
"disabled": "Disabled", "disabled": "Disabled",
@@ -1157,15 +1140,6 @@
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.", "idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
"idpErrorNotFound": "IdP not found", "idpErrorNotFound": "IdP not found",
"inviteInvalid": "Invalid Invite", "inviteInvalid": "Invalid Invite",
"labels": "Labels",
"orgLabelsDescription": "Manage labels in this organization.",
"addLabels": "Add labels",
"siteLabelsTab": "Labels",
"siteLabelsDescription": "Manage labels associated with this site.",
"labelsNotFound": "Labels not found",
"labelSearch": "Search labels",
"selectColor": "Select color",
"createNewLabel": "Create new org label \"{label}\"",
"inviteInvalidDescription": "The invite link is invalid.", "inviteInvalidDescription": "The invite link is invalid.",
"inviteErrorWrongUser": "Invite is not for this user", "inviteErrorWrongUser": "Invite is not for this user",
"inviteErrorUserNotExists": "User does not exist. Please create an account first.", "inviteErrorUserNotExists": "User does not exist. Please create an account first.",
@@ -1983,7 +1957,7 @@
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.", "sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
"sshSudo": "Allow sudo", "sshSudo": "Allow sudo",
"sshSudoCommands": "Sudo Commands", "sshSudoCommands": "Sudo Commands",
"sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo.", "sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo. Absolute paths must be used.",
"sshCreateHomeDir": "Create Home Directory", "sshCreateHomeDir": "Create Home Directory",
"sshUnixGroups": "Unix Groups", "sshUnixGroups": "Unix Groups",
"sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.", "sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.",

View File

@@ -5,12 +5,7 @@ const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
reactStrictMode: false, reactStrictMode: false,
eslint: { reactCompiler: true,
ignoreDuringBuilds: true
},
experimental: {
reactCompiler: true
},
output: "standalone" output: "standalone"
}; };

5819
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -32,11 +32,11 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "8.4.1", "@asteasolutions/zod-to-openapi": "8.5.0",
"@aws-sdk/client-s3": "3.1011.0", "@aws-sdk/client-s3": "3.1056.0",
"@faker-js/faker": "10.3.0", "@faker-js/faker": "10.4.0",
"@headlessui/react": "2.2.9", "@headlessui/react": "2.2.10",
"@hookform/resolvers": "5.2.2", "@hookform/resolvers": "5.4.0",
"@monaco-editor/react": "4.7.0", "@monaco-editor/react": "4.7.0",
"@node-rs/argon2": "2.0.2", "@node-rs/argon2": "2.0.2",
"@oslojs/crypto": "1.0.1", "@oslojs/crypto": "1.0.1",
@@ -59,16 +59,17 @@
"@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-tooltip": "1.2.8",
"@react-email/components": "1.0.8", "@react-email/body": "0.3.0",
"@react-email/render": "2.0.4", "@react-email/components": "1.0.12",
"@react-email/tailwind": "2.0.5", "@react-email/render": "2.0.8",
"@react-email/tailwind": "2.0.7",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.0", "@simplewebauthn/server": "13.3.1",
"@tailwindcss/forms": "0.5.11", "@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.90.21", "@tanstack/react-query": "5.100.14",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"arctic": "3.7.0", "arctic": "3.7.0",
"axios": "1.15.0", "axios": "1.16.1",
"better-sqlite3": "11.9.1", "better-sqlite3": "11.9.1",
"canvas-confetti": "1.9.4", "canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
@@ -80,77 +81,76 @@
"d3": "7.9.0", "d3": "7.9.0",
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"express": "5.2.1", "express": "5.2.1",
"express-rate-limit": "8.3.0", "express-rate-limit": "8.5.2",
"glob": "13.0.6", "glob": "13.0.6",
"helmet": "8.1.0", "helmet": "8.2.0",
"http-errors": "2.0.1", "http-errors": "2.0.1",
"input-otp": "1.4.2", "input-otp": "1.4.2",
"ioredis": "5.10.0", "ioredis": "5.11.0",
"jmespath": "0.16.0", "jmespath": "0.16.0",
"js-yaml": "4.1.1", "js-yaml": "4.1.1",
"jsonwebtoken": "9.0.3", "jsonwebtoken": "9.0.3",
"lucide-react": "0.577.0", "lucide-react": "1.17.0",
"maxmind": "5.0.5", "maxmind": "5.0.6",
"moment": "2.30.1", "moment": "2.30.1",
"next": "15.5.15", "next": "16.2.6",
"next-intl": "4.8.3", "next-intl": "4.13.0",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"nextjs-toploader": "3.9.17", "nextjs-toploader": "3.9.17",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"nodemailer": "8.0.5", "nodemailer": "8.0.9",
"oslo": "1.2.1", "oslo": "1.2.1",
"pg": "8.20.0", "pg": "8.21.0",
"posthog-node": "5.28.0", "posthog-node": "5.35.6",
"qrcode.react": "4.2.0", "qrcode.react": "4.2.0",
"react": "19.2.4", "react": "19.2.6",
"react-day-picker": "9.14.0", "react-day-picker": "9.14.0",
"react-dom": "19.2.4", "react-dom": "19.2.6",
"react-easy-sort": "1.8.0", "react-easy-sort": "1.8.0",
"react-hook-form": "7.71.2", "react-hook-form": "7.76.1",
"react-icons": "5.6.0", "react-icons": "5.6.0",
"recharts": "2.15.4", "recharts": "3.8.1",
"reodotdev": "1.1.0", "reodotdev": "1.1.0",
"resend": "6.9.2", "semver": "7.8.1",
"semver": "7.7.4",
"sshpk": "1.18.0", "sshpk": "1.18.0",
"stripe": "20.4.1", "stripe": "22.2.0",
"swagger-ui-express": "5.0.1", "swagger-ui-express": "5.0.1",
"tailwind-merge": "3.5.0", "tailwind-merge": "3.6.0",
"topojson-client": "3.1.0", "topojson-client": "3.1.0",
"tw-animate-css": "1.4.0", "tw-animate-css": "1.4.0",
"use-debounce": "10.1.0", "use-debounce": "10.1.1",
"uuid": "13.0.0", "uuid": "14.0.0",
"vaul": "1.1.2", "vaul": "1.1.2",
"visionscarto-world-atlas": "1.0.0", "visionscarto-world-atlas": "1.0.0",
"winston": "3.19.0", "winston": "3.19.0",
"winston-daily-rotate-file": "5.0.0", "winston-daily-rotate-file": "5.0.0",
"ws": "8.19.0", "ws": "8.21.0",
"yaml": "2.8.3", "yaml": "2.9.0",
"yargs": "18.0.0", "yargs": "18.0.0",
"zod": "4.3.6", "zod": "4.4.3",
"zod-validation-error": "5.0.0" "zod-validation-error": "5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@dotenvx/dotenvx": "1.54.1", "@dotenvx/dotenvx": "1.69.1",
"@esbuild-plugins/tsconfig-paths": "0.1.2", "@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/preview-server": "5.2.10", "@react-email/ui": "^6.5.0",
"@tailwindcss/postcss": "4.2.2", "@tailwindcss/postcss": "4.3.0",
"@tanstack/react-query-devtools": "5.91.3", "@tanstack/react-query-devtools": "5.100.14",
"@types/better-sqlite3": "7.6.13", "@types/better-sqlite3": "7.6.13",
"@types/cookie-parser": "1.4.10", "@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.19", "@types/cors": "2.8.19",
"@types/crypto-js": "4.2.2", "@types/crypto-js": "4.2.2",
"@types/d3": "7.4.3", "@types/d3": "7.4.3",
"@types/express": "5.0.6", "@types/express": "5.0.6",
"@types/express-session": "1.18.2", "@types/express-session": "1.19.0",
"@types/jmespath": "0.15.2", "@types/jmespath": "0.15.2",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10", "@types/jsonwebtoken": "9.0.10",
"@types/node": "25.3.5", "@types/node": "25.9.1",
"@types/nodemailer": "7.0.11", "@types/nodemailer": "8.0.0",
"@types/nprogress": "0.2.3", "@types/nprogress": "0.2.3",
"@types/pg": "8.18.0", "@types/pg": "8.20.0",
"@types/react": "19.2.14", "@types/react": "19.2.15",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
"@types/sshpk": "1.17.4", "@types/sshpk": "1.17.4",
@@ -160,21 +160,22 @@
"@types/yargs": "17.0.35", "@types/yargs": "17.0.35",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"esbuild": "0.27.4", "esbuild": "0.28.0",
"esbuild-node-externals": "1.20.1", "esbuild-node-externals": "1.22.0",
"eslint": "10.0.3", "eslint": "10.4.0",
"eslint-config-next": "16.1.7", "eslint-config-next": "16.2.6",
"postcss": "8.5.8", "postcss": "8.5.15",
"prettier": "3.8.1", "prettier": "3.8.3",
"react-email": "5.2.10", "react-email": "6.5.0",
"tailwindcss": "4.2.2", "tailwindcss": "4.3.0",
"tsc-alias": "1.8.16", "tsc-alias": "1.8.17",
"tsx": "4.21.0", "tsx": "4.22.3",
"typescript": "5.9.3", "typescript": "6.0.3",
"typescript-eslint": "8.56.1" "typescript-eslint": "8.60.0"
}, },
"overrides": { "overrides": {
"esbuild": "0.27.4", "esbuild": "0.28.0",
"dompurify": "3.3.2" "dompurify": "3.4.0",
"postcss": "8.5.15"
} }
} }

View File

@@ -148,12 +148,6 @@ export enum ActionsEnum {
updateAlertRule = "updateAlertRule", updateAlertRule = "updateAlertRule",
deleteAlertRule = "deleteAlertRule", deleteAlertRule = "deleteAlertRule",
listAlertRules = "listAlertRules", listAlertRules = "listAlertRules",
listOrgLabels = "listOrgLabels",
createOrgLabel = "createOrgLabel",
updateOrgLabel = "updateOrgLabel",
deleteOrgLabel = "deleteOrgLabel",
attachLabelToItem = "attachLabelToItem",
detachLabelFromItem = "detachLabelFromItem",
getAlertRule = "getAlertRule", getAlertRule = "getAlertRule",
createHealthCheck = "createHealthCheck", createHealthCheck = "createHealthCheck",
updateHealthCheck = "updateHealthCheck", updateHealthCheck = "updateHealthCheck",

View File

@@ -162,89 +162,6 @@ export const resources = pgTable("resources", {
wildcard: boolean("wildcard").notNull().default(false) wildcard: boolean("wildcard").notNull().default(false)
}); });
export const labels = pgTable("labels", {
labelId: serial("labelId").primaryKey(),
name: varchar("name").notNull(),
color: varchar("color").notNull(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const siteLabels = pgTable(
"siteLabels",
{
siteLabelId: serial("siteLabelId").primaryKey(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
);
export const resourceLabels = pgTable(
"resourceLabels",
{
resourceLabelId: serial("resourceLabelId").primaryKey(),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
);
export const siteResourceLabels = pgTable(
"siteResourceLabels",
{
siteResourceLabelId: serial("siteResourceLabelId").primaryKey(),
siteResourceId: integer("siteResourceId")
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
);
export const clientLabels = pgTable(
"clientLabels",
{
clientLabelId: serial("clientLabelId").primaryKey(),
clientId: integer("clientId")
.references(() => clients.clientId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
);
export const targets = pgTable("targets", { export const targets = pgTable("targets", {
targetId: serial("targetId").primaryKey(), targetId: serial("targetId").primaryKey(),
resourceId: integer("resourceId") resourceId: integer("resourceId")
@@ -279,11 +196,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
siteId: integer("siteId") siteId: integer("siteId").references(() => sites.siteId, {
.references(() => sites.siteId, { onDelete: "cascade"
onDelete: "cascade" }).notNull(),
})
.notNull(),
name: varchar("name"), name: varchar("name"),
hcEnabled: boolean("hcEnabled").notNull().default(false), hcEnabled: boolean("hcEnabled").notNull().default(false),
hcPath: varchar("hcPath"), hcPath: varchar("hcPath"),
@@ -1182,30 +1097,19 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
complete: boolean("complete").notNull().default(false) complete: boolean("complete").notNull().default(false)
}); });
export const statusHistory = pgTable( export const statusHistory = pgTable("statusHistory", {
"statusHistory", id: serial("id").primaryKey(),
{ entityType: varchar("entityType").notNull(),
id: serial("id").primaryKey(), entityId: integer("entityId").notNull(),
entityType: varchar("entityType").notNull(), orgId: varchar("orgId")
entityId: integer("entityId").notNull(), .notNull()
orgId: varchar("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }),
.notNull() status: varchar("status").notNull(),
.references(() => orgs.orgId, { onDelete: "cascade" }), timestamp: integer("timestamp").notNull(),
status: varchar("status").notNull(), }, (table) => [
timestamp: integer("timestamp").notNull() index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
}, index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
(table) => [ ]);
index("idx_statusHistory_entity").on(
table.entityType,
table.entityId,
table.timestamp
),
index("idx_statusHistory_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export type Org = InferSelectModel<typeof orgs>; export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
@@ -1275,4 +1179,3 @@ export type RoundTripMessageTracker = InferSelectModel<
>; >;
export type Network = InferSelectModel<typeof networks>; export type Network = InferSelectModel<typeof networks>;
export type StatusHistory = InferSelectModel<typeof statusHistory>; export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;

View File

@@ -183,95 +183,6 @@ export const resources = sqliteTable("resources", {
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false) wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
}); });
export const labels = sqliteTable("labels", {
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
color: text("color").notNull(),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const siteLabels = sqliteTable(
"siteLabels",
{
siteLabelId: integer("siteLabelId").primaryKey({ autoIncrement: true }),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
);
export const resourceLabels = sqliteTable(
"resourceLabels",
{
resourceLabelId: integer("resourceLabelId").primaryKey({
autoIncrement: true
}),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
);
export const siteResourceLabels = sqliteTable(
"siteResourceLabels",
{
siteResourceLabelId: integer("siteResourceLabelId").primaryKey({
autoIncrement: true
}),
siteResourceId: integer("siteResourceId")
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
);
export const clientLabels = sqliteTable(
"clientLabels",
{
clientLabelId: integer("clientLabelId").primaryKey({
autoIncrement: true
}),
clientId: integer("clientId")
.references(() => clients.clientId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
);
export const targets = sqliteTable("targets", { export const targets = sqliteTable("targets", {
targetId: integer("targetId").primaryKey({ autoIncrement: true }), targetId: integer("targetId").primaryKey({ autoIncrement: true }),
resourceId: integer("resourceId") resourceId: integer("resourceId")
@@ -308,11 +219,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
siteId: integer("siteId") siteId: integer("siteId").references(() => sites.siteId, {
.references(() => sites.siteId, { onDelete: "cascade"
onDelete: "cascade" }).notNull(),
})
.notNull(),
name: text("name"), name: text("name"),
hcEnabled: integer("hcEnabled", { mode: "boolean" }) hcEnabled: integer("hcEnabled", { mode: "boolean" })
.notNull() .notNull()
@@ -1287,30 +1196,19 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
complete: integer("complete", { mode: "boolean" }).notNull().default(false) complete: integer("complete", { mode: "boolean" }).notNull().default(false)
}); });
export const statusHistory = sqliteTable( export const statusHistory = sqliteTable("statusHistory", {
"statusHistory", id: integer("id").primaryKey({ autoIncrement: true }),
{ entityType: text("entityType").notNull(), // "site" | "healthCheck"
id: integer("id").primaryKey({ autoIncrement: true }), entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
entityType: text("entityType").notNull(), // "site" | "healthCheck" orgId: text("orgId")
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId .notNull()
orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }),
.notNull() status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
.references(() => orgs.orgId, { onDelete: "cascade" }), timestamp: integer("timestamp").notNull(), // unix epoch seconds
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks }, (table) => [
timestamp: integer("timestamp").notNull() // unix epoch seconds index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
}, index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
(table) => [ ]);
index("idx_statusHistory_entity").on(
table.entityType,
table.entityId,
table.timestamp
),
index("idx_statusHistory_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export type Org = InferSelectModel<typeof orgs>; export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
@@ -1380,4 +1278,3 @@ export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker typeof roundTripMessageTracker
>; >;
export type StatusHistory = InferSelectModel<typeof statusHistory>; export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;

View File

@@ -1,5 +1,5 @@
#! /usr/bin/env node #! /usr/bin/env node
import "./extendZod.ts"; import "./extendZod";
import { runSetupFunctions } from "./setup"; import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer"; import { createApiServer } from "./apiServer";

View File

@@ -221,10 +221,18 @@ async function handleResource(
) )
.where(eq(targets.resourceId, resource.resourceId)); .where(eq(targets.resourceId, resource.resourceId));
const monitoredTargets = otherTargets.filter(
(t) => t.hcHealth !== "unknown"
);
let health = "healthy"; let health = "healthy";
const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown"); const allUnknown = monitoredTargets.length === 0;
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy"); const allHealthy = monitoredTargets.every(
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy"); (t) => t.hcHealth === "healthy"
);
const allUnhealthy = monitoredTargets.every(
(t) => t.hcHealth === "unhealthy"
);
if (allUnknown) { if (allUnknown) {
logger.debug( logger.debug(

View File

@@ -24,12 +24,10 @@ export enum TierFeature {
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
StandaloneHealthChecks = "standaloneHealthChecks", StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules", AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain", WildcardSubdomain = "wildcardSubdomain"
Labels = "labels"
} }
export const tierMatrix: Record<TierFeature, Tier[]> = { export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.Labels]: ["tier2", "tier3", "enterprise"],
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"], [TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],

View File

@@ -3,6 +3,7 @@ import {
clientSiteResources, clientSiteResources,
domains, domains,
orgDomains, orgDomains,
roleActions,
roles, roles,
roleSiteResources, roleSiteResources,
Site, Site,
@@ -19,6 +20,7 @@ import { sites } from "@server/db";
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm"; import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
import { Config } from "./types"; import { Config } from "./types";
import logger from "@server/logger"; import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { getNextAvailableAliasAddress } from "../ip"; import { getNextAvailableAliasAddress } from "../ip";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
@@ -332,8 +334,7 @@ export async function updateClientResources(
} }
if (resourceData.roles.length > 0) { if (resourceData.roles.length > 0) {
// Re-add specified roles but we need to get the roleIds from the role name in the array const existingRoles = await trx
const rolesToUpdate = await trx
.select() .select()
.from(roles) .from(roles)
.where( .where(
@@ -343,7 +344,28 @@ export async function updateClientResources(
) )
); );
const roleIds = rolesToUpdate.map((role) => role.roleId); const foundNames = new Set(existingRoles.map((r) => r.name));
const missingNames = resourceData.roles.filter(
(n) => !foundNames.has(n)
);
for (const name of missingNames) {
const [created] = await trx
.insert(roles)
.values({ name, orgId })
.returning();
await trx.insert(roleActions).values(
defaultRoleAllowedActions.map((action) => ({
roleId: created.roleId,
actionId: action,
orgId
}))
);
existingRoles.push(created);
logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`);
}
const roleIds = existingRoles.map((role) => role.roleId);
await trx await trx
.insert(roleSiteResources) .insert(roleSiteResources)
@@ -444,8 +466,7 @@ export async function updateClientResources(
}); });
if (resourceData.roles.length > 0) { if (resourceData.roles.length > 0) {
// get roleIds from role names const existingRoles = await trx
const rolesToUpdate = await trx
.select() .select()
.from(roles) .from(roles)
.where( .where(
@@ -455,7 +476,28 @@ export async function updateClientResources(
) )
); );
const roleIds = rolesToUpdate.map((role) => role.roleId); const foundNames = new Set(existingRoles.map((r) => r.name));
const missingNames = resourceData.roles.filter(
(n) => !foundNames.has(n)
);
for (const name of missingNames) {
const [created] = await trx
.insert(roles)
.values({ name, orgId })
.returning();
await trx.insert(roleActions).values(
defaultRoleAllowedActions.map((action) => ({
roleId: created.roleId,
actionId: action,
orgId
}))
);
existingRoles.push(created);
logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`);
}
const roleIds = existingRoles.map((role) => role.roleId);
await trx await trx
.insert(roleSiteResources) .insert(roleSiteResources)

View File

@@ -8,6 +8,7 @@ import {
resourcePincode, resourcePincode,
resourceRules, resourceRules,
resourceWhitelist, resourceWhitelist,
roleActions,
roleResources, roleResources,
roles, roles,
Target, Target,
@@ -36,6 +37,7 @@ import { isValidRegionId } from "@server/db/regions";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts"; import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
import { tierMatrix } from "../billing/tierMatrix"; import { tierMatrix } from "../billing/tierMatrix";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
export type ProxyResourcesResults = { export type ProxyResourcesResults = {
proxyResource: Resource; proxyResource: Resource;
@@ -925,14 +927,26 @@ async function syncRoleResources(
.where(eq(roleResources.resourceId, resourceId)); .where(eq(roleResources.resourceId, resourceId));
for (const roleName of ssoRoles) { for (const roleName of ssoRoles) {
const [role] = await trx let [role] = await trx
.select() .select()
.from(roles) .from(roles)
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId))) .where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
.limit(1); .limit(1);
if (!role) { if (!role) {
throw new Error(`Role not found: ${roleName} in org ${orgId}`); const [created] = await trx
.insert(roles)
.values({ name: roleName, orgId })
.returning();
await trx.insert(roleActions).values(
defaultRoleAllowedActions.map((action) => ({
roleId: created.roleId,
actionId: action,
orgId
}))
);
role = created;
logger.info(`Auto-created role "${roleName}" in org ${orgId} from blueprint`);
} }
if (role.isAdmin) { if (role.isAdmin) {

View File

@@ -82,7 +82,7 @@ export const RuleSchema = z
.object({ .object({
action: z.enum(["allow", "deny", "pass"]), action: z.enum(["allow", "deny", "pass"]),
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]), match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
value: z.string(), value: z.coerce.string(),
priority: z.int().optional() priority: z.int().optional()
}) })
.refine( .refine(
@@ -340,7 +340,8 @@ export const ResourceSchema = z
if (parts.includes("*", 1)) return false; // no further wildcards if (parts.includes("*", 1)) return false; // no further wildcards
if (parts.length < 3) return false; // need at least *.label.tld if (parts.length < 3) return false; // need at least *.label.tld
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/; const labelRegex =
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
return parts.slice(1).every((label) => labelRegex.test(label)); return parts.slice(1).every((label) => labelRegex.test(label));
}, },
{ {

View File

@@ -18,7 +18,7 @@ import {
userOrgRoles, userOrgRoles,
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
import { and, eq, inArray, ne } from "drizzle-orm"; import { and, count, eq, inArray, ne } from "drizzle-orm";
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers"; import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
import { import {
@@ -39,6 +39,11 @@ import {
removePeerData, removePeerData,
removeTargets as removeSubnetProxyTargets removeTargets as removeSubnetProxyTargets
} from "@server/routers/client/targets"; } from "@server/routers/client/targets";
import { lockManager } from "#dynamic/lib/lock";
// TTL for rebuild-association locks. These functions can fan out into many
// peer/proxy updates, so give them a generous window.
const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000;
export async function getClientSiteResourceAccess( export async function getClientSiteResourceAccess(
siteResource: SiteResource, siteResource: SiteResource,
@@ -161,6 +166,23 @@ export async function rebuildClientAssociationsFromSiteResource(
pubKey: string | null; pubKey: string | null;
subnet: string | null; subnet: string | null;
}[]; }[];
}> {
return await lockManager.withLock(
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource, trx),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
);
}
async function rebuildClientAssociationsFromSiteResourceImpl(
siteResource: SiteResource,
trx: Transaction | typeof db = db
): Promise<{
mergedAllClients: {
clientId: number;
pubKey: string | null;
subnet: string | null;
}[];
}> { }> {
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
@@ -539,6 +561,29 @@ async function handleMessagesForSiteClients(
} }
} }
// get the number of sites on each of these clients so we can log it and make decisions about whether to send messages based on it
const clientSiteCounts: Record<number, number> = {};
if (clientsToProcess.size > 0) {
const clientIdsToProcess = Array.from(clientsToProcess.keys());
const siteCounts = await trx
.select({
clientId: clientSitesAssociationsCache.clientId,
siteCount: count(clientSitesAssociationsCache.siteId)
})
.from(clientSitesAssociationsCache)
.where(
inArray(
clientSitesAssociationsCache.clientId,
clientIdsToProcess
)
)
.groupBy(clientSitesAssociationsCache.clientId);
for (const row of siteCounts) {
clientSiteCounts[row.clientId] = Number(row.siteCount);
}
}
for (const client of clientsToProcess.values()) { for (const client of clientsToProcess.values()) {
// UPDATE THE NEWT // UPDATE THE NEWT
if (!client.subnet || !client.pubKey) { if (!client.subnet || !client.pubKey) {
@@ -582,7 +627,14 @@ async function handleMessagesForSiteClients(
} }
if (isAdd) { if (isAdd) {
// TODO: if we are in jit mode here should we really be sending this? if (clientSiteCounts[client.clientId] > 250) {
// skip adding the peer if we have more than 250 sites because we are in jit mode anyway
logger.info(
`rebuildClientAssociations: Client ${client.clientId} has ${clientSiteCounts[client.clientId]} sites so skipping adding peer to newt and olm because it is likely in jit mode`
);
continue;
}
await initPeerAddHandshake( await initPeerAddHandshake(
// this will kick off the add peer process for the client // this will kick off the add peer process for the client
client.clientId, client.clientId,
@@ -600,9 +652,24 @@ async function handleMessagesForSiteClients(
exitNodeJobs.push(updateClientSiteDestinations(client, trx)); exitNodeJobs.push(updateClientSiteDestinations(client, trx));
} }
await Promise.all(exitNodeJobs); Promise.all(exitNodeJobs).catch((error) => {
await Promise.all(newtJobs); // do the servers first to make sure they are ready? logger.error(
await Promise.all(olmJobs); `rebuildClientAssociations: Error updating client site destinations for site ${site.siteId}:`,
error
);
});
Promise.all(newtJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating Newt peers for site ${site.siteId}:`,
error
);
});
Promise.all(olmJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating Olm peers for site ${site.siteId}:`,
error
);
});
} }
interface PeerDestination { interface PeerDestination {
@@ -885,6 +952,17 @@ async function handleSubnetProxyTargetUpdates(
export async function rebuildClientAssociationsFromClient( export async function rebuildClientAssociationsFromClient(
client: Client, client: Client,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> {
return await lockManager.withLock(
`rebuild-client-associations:client:${client.clientId}`,
() => rebuildClientAssociationsFromClientImpl(client, trx),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
);
}
async function rebuildClientAssociationsFromClientImpl(
client: Client,
trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
let newSiteResourceIds: number[] = []; let newSiteResourceIds: number[] = [];
@@ -1157,6 +1235,12 @@ async function handleMessagesForClientSites(
const olmJobs: Promise<any>[] = []; const olmJobs: Promise<any>[] = [];
const exitNodeJobs: Promise<any>[] = []; const exitNodeJobs: Promise<any>[] = [];
const totalSitesOnClient = await trx
.select({ count: count(clientSitesAssociationsCache.siteId) })
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.clientId, client.clientId))
.then((rows) => Number(rows[0].count));
for (const siteData of sitesData) { for (const siteData of sitesData) {
const site = siteData.sites; const site = siteData.sites;
const exitNode = siteData.exitNodes; const exitNode = siteData.exitNodes;
@@ -1217,7 +1301,14 @@ async function handleMessagesForClientSites(
continue; continue;
} }
// TODO: if we are in jit mode here should we really be sending this? if (totalSitesOnClient > 250) {
// skip adding the site if we have more than 250 because we are in jit mode anyway
logger.info(
`rebuildClientAssociations: Client ${client.clientId} has ${totalSitesOnClient} sites so skipping adding peer to newt and olm because it is likely in jit mode`
);
continue;
}
await initPeerAddHandshake( await initPeerAddHandshake(
// this will kick off the add peer process for the client // this will kick off the add peer process for the client
client.clientId, client.clientId,
@@ -1245,9 +1336,24 @@ async function handleMessagesForClientSites(
); );
} }
await Promise.all(exitNodeJobs); Promise.all(exitNodeJobs).catch((error) => {
await Promise.all(newtJobs); logger.error(
await Promise.all(olmJobs); `rebuildClientAssociations: Error updating client site destinations for client ${client.clientId}:`,
error
);
});
Promise.all(newtJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating Newt peers for client ${client.clientId}:`,
error
);
});
Promise.all(olmJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating Olm peers for client ${client.clientId}:`,
error
);
});
} }
async function handleMessagesForClientResources( async function handleMessagesForClientResources(
@@ -1528,3 +1634,269 @@ async function handleMessagesForClientResources(
await Promise.all([...proxyJobs, ...olmJobs]); await Promise.all([...proxyJobs, ...olmJobs]);
} }
export type ClientAssociationsCacheVerification = {
clientId: number;
consistent: boolean;
// What permissions say the cache should contain
expectedSiteResourceIds: number[];
expectedSiteIds: number[];
// What the cache currently contains
actualSiteResourceIds: number[];
actualSiteIds: number[];
// Diff
missingSiteResourceIds: number[]; // present in expected, missing from cache
extraSiteResourceIds: number[]; // present in cache, not in expected
missingSiteIds: number[];
extraSiteIds: number[];
};
// verifyClientAssociationsCache walks the same permission-derivation logic as
// rebuildClientAssociationsFromClient but does NOT modify the database. It
// returns the expected vs actual cache contents and a boolean indicating
// whether the cache is in sync with what permissions imply.
export async function verifyClientAssociationsCache(
client: Client,
trx: Transaction | typeof db = db
): Promise<ClientAssociationsCacheVerification> {
let newSiteResourceIds: number[] = [];
// 1. Direct client associations
const directSiteResources = await trx
.select({ siteResourceId: clientSiteResources.siteResourceId })
.from(clientSiteResources)
.innerJoin(
siteResources,
eq(siteResources.siteResourceId, clientSiteResources.siteResourceId)
)
.where(
and(
eq(clientSiteResources.clientId, client.clientId),
eq(siteResources.orgId, client.orgId)
)
);
newSiteResourceIds.push(
...directSiteResources.map((r) => r.siteResourceId)
);
// 2. User-based and role-based access (if client has a userId)
if (client.userId) {
const userSiteResourceIds = await trx
.select({ siteResourceId: userSiteResources.siteResourceId })
.from(userSiteResources)
.innerJoin(
siteResources,
eq(
siteResources.siteResourceId,
userSiteResources.siteResourceId
)
)
.where(
and(
eq(userSiteResources.userId, client.userId),
eq(siteResources.orgId, client.orgId)
)
);
newSiteResourceIds.push(
...userSiteResourceIds.map((r) => r.siteResourceId)
);
const roleIds = await trx
.select({ roleId: userOrgRoles.roleId })
.from(userOrgRoles)
.where(
and(
eq(userOrgRoles.userId, client.userId),
eq(userOrgRoles.orgId, client.orgId)
)
)
.then((rows) => rows.map((row) => row.roleId));
if (roleIds.length > 0) {
const roleSiteResourceIds = await trx
.select({ siteResourceId: roleSiteResources.siteResourceId })
.from(roleSiteResources)
.innerJoin(
siteResources,
eq(
siteResources.siteResourceId,
roleSiteResources.siteResourceId
)
)
.where(
and(
inArray(roleSiteResources.roleId, roleIds),
eq(siteResources.orgId, client.orgId)
)
);
newSiteResourceIds.push(
...roleSiteResourceIds.map((r) => r.siteResourceId)
);
}
}
newSiteResourceIds = Array.from(new Set(newSiteResourceIds));
const newSiteResources =
newSiteResourceIds.length > 0
? await trx
.select()
.from(siteResources)
.where(
inArray(siteResources.siteResourceId, newSiteResourceIds)
)
: [];
const networkIds = Array.from(
new Set(
newSiteResources
.map((sr) => sr.networkId)
.filter((id): id is number => id !== null)
)
);
const newSiteIds =
networkIds.length > 0
? await trx
.select({ siteId: siteNetworks.siteId })
.from(siteNetworks)
.where(inArray(siteNetworks.networkId, networkIds))
.then((rows) =>
Array.from(new Set(rows.map((r) => r.siteId)))
)
: [];
// Read the existing cache state
const existingResourceAssociations = await trx
.select({
siteResourceId: clientSiteResourcesAssociationsCache.siteResourceId
})
.from(clientSiteResourcesAssociationsCache)
.where(
eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
);
const existingSiteResourceIds = existingResourceAssociations.map(
(r) => r.siteResourceId
);
const existingSiteAssociations = await trx
.select({ siteId: clientSitesAssociationsCache.siteId })
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
const existingSiteIds = existingSiteAssociations.map((s) => s.siteId);
const expectedSiteResourceSet = new Set(newSiteResourceIds);
const actualSiteResourceSet = new Set(existingSiteResourceIds);
const expectedSiteSet = new Set(newSiteIds);
const actualSiteSet = new Set(existingSiteIds);
const missingSiteResourceIds = newSiteResourceIds.filter(
(id) => !actualSiteResourceSet.has(id)
);
const extraSiteResourceIds = existingSiteResourceIds.filter(
(id) => !expectedSiteResourceSet.has(id)
);
const missingSiteIds = newSiteIds.filter((id) => !actualSiteSet.has(id));
const extraSiteIds = existingSiteIds.filter(
(id) => !expectedSiteSet.has(id)
);
const consistent =
missingSiteResourceIds.length === 0 &&
extraSiteResourceIds.length === 0 &&
missingSiteIds.length === 0 &&
extraSiteIds.length === 0;
return {
clientId: client.clientId,
consistent,
expectedSiteResourceIds: Array.from(expectedSiteResourceSet).sort(
(a, b) => a - b
),
expectedSiteIds: Array.from(expectedSiteSet).sort((a, b) => a - b),
actualSiteResourceIds: Array.from(actualSiteResourceSet).sort(
(a, b) => a - b
),
actualSiteIds: Array.from(actualSiteSet).sort((a, b) => a - b),
missingSiteResourceIds: missingSiteResourceIds.sort((a, b) => a - b),
extraSiteResourceIds: extraSiteResourceIds.sort((a, b) => a - b),
missingSiteIds: missingSiteIds.sort((a, b) => a - b),
extraSiteIds: extraSiteIds.sort((a, b) => a - b)
};
}
// cleanupSiteAssociations efficiently removes all client associations for a
// site that is being deleted. Instead of calling
// rebuildClientAssociationsFromSiteResource once per site resource (which is
// O(resources) in DB round-trips and message fan-out), this function performs
// a single bulk lookup of affected clients and site resources, deletes all
// cache rows at once, and fires all peer/proxy removal messages in parallel.
//
// The caller is responsible for deleting the site row itself (and for sending
// the newt/wg/terminate signal to the newt process).
export async function cleanupSiteAssociations(
site: Site,
trx: Transaction | typeof db = db
): Promise<void> {
const siteId = site.siteId;
logger.debug(`cleanupSiteAssociations: START siteId=${siteId}`);
// 1. Find every client currently cached against this site.
const cachedSiteClientRows = await trx
.select({ clientId: clientSitesAssociationsCache.clientId })
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.siteId, siteId));
const cachedClientIds = cachedSiteClientRows.map((r) => r.clientId);
// 2. Load full client details (needed for WireGuard public-key references).
const allClients =
cachedClientIds.length > 0
? await trx
.select({
clientId: clients.clientId,
pubKey: clients.pubKey,
subnet: clients.subnet
})
.from(clients)
.where(inArray(clients.clientId, cachedClientIds))
: [];
// 6. Bulk-delete all cache entries for this site. Do this before sending
// destination-update messages so updateClientSiteDestinations computes
// the correct (post-deletion) set of destinations.
await trx
.delete(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.siteId, siteId));
logger.debug(
`cleanupSiteAssociations: siteId=${siteId} cache cleared. clients=${allClients.length}`
);
// 7. Fire all removal messages in parallel.
const jobs: Promise<any>[] = [];
for (const client of allClients) {
// Tell each olm to drop the site's WireGuard peer.
if (site.publicKey) {
jobs.push(olmDeletePeer(client.clientId, siteId, site.publicKey));
}
// Recompute and push updated relay destinations (now excluding this site).
if (client.pubKey && client.subnet) {
jobs.push(updateClientSiteDestinations(client, trx));
}
}
await Promise.all(jobs).catch((error) => {
logger.error(
`cleanupSiteAssociations: error sending cleanup messages for siteId=${siteId}:`,
error
);
});
logger.debug(`cleanupSiteAssociations: DONE siteId=${siteId}`);
}

View File

@@ -0,0 +1,11 @@
export function getFirstString(value: unknown): string | undefined {
if (typeof value === "string") {
return value;
}
if (Array.isArray(value) && typeof value[0] === "string") {
return value[0];
}
return undefined;
}

View File

@@ -4,6 +4,7 @@ import { resourceAccessToken, resources, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyAccessTokenAccess( export async function verifyApiKeyAccessTokenAccess(
req: Request, req: Request,
@@ -12,7 +13,7 @@ export async function verifyApiKeyAccessTokenAccess(
) { ) {
try { try {
const apiKey = req.apiKey; const apiKey = req.apiKey;
const accessTokenId = req.params.accessTokenId; const accessTokenId = getFirstString(req.params.accessTokenId);
if (!apiKey) { if (!apiKey) {
return next( return next(
@@ -20,6 +21,12 @@ export async function verifyApiKeyAccessTokenAccess(
); );
} }
if (!accessTokenId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid access token ID")
);
}
const [accessToken] = await db const [accessToken] = await db
.select() .select()
.from(resourceAccessToken) .from(resourceAccessToken)

View File

@@ -4,6 +4,7 @@ import { apiKeys, apiKeyOrg } from "@server/db";
import { and, eq, or } from "drizzle-orm"; import { and, eq, or } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyApiKeyAccess( export async function verifyApiKeyApiKeyAccess(
req: Request, req: Request,
@@ -14,8 +15,10 @@ export async function verifyApiKeyApiKeyAccess(
const { apiKey: callerApiKey } = req; const { apiKey: callerApiKey } = req;
const apiKeyId = const apiKeyId =
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId; getFirstString(req.params.apiKeyId) ||
const orgId = req.params.orgId; getFirstString(req.body.apiKeyId) ||
getFirstString(req.query.apiKeyId);
const orgId = getFirstString(req.params.orgId);
if (!callerApiKey) { if (!callerApiKey) {
return next( return next(

View File

@@ -3,6 +3,7 @@ import { db, domains, orgDomains, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyDomainAccess( export async function verifyApiKeyDomainAccess(
req: Request, req: Request,
@@ -12,8 +13,10 @@ export async function verifyApiKeyDomainAccess(
try { try {
const apiKey = req.apiKey; const apiKey = req.apiKey;
const domainId = const domainId =
req.params.domainId || req.body.domainId || req.query.domainId; getFirstString(req.params.domainId) ||
const orgId = req.params.orgId; getFirstString(req.body.domainId) ||
getFirstString(req.query.domainId);
const orgId = getFirstString(req.params.orgId);
if (!apiKey) { if (!apiKey) {
return next( return next(
@@ -27,6 +30,12 @@ export async function verifyApiKeyDomainAccess(
); );
} }
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
if (apiKey.isRoot) { if (apiKey.isRoot) {
// Root keys can access any domain in any org // Root keys can access any domain in any org
return next(); return next();

View File

@@ -4,6 +4,7 @@ import { idp, idpOrg, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyIdpAccess( export async function verifyApiKeyIdpAccess(
req: Request, req: Request,
@@ -12,8 +13,12 @@ export async function verifyApiKeyIdpAccess(
) { ) {
try { try {
const apiKey = req.apiKey; const apiKey = req.apiKey;
const idpId = req.params.idpId || req.body.idpId || req.query.idpId; const idpIdRaw =
const orgId = req.params.orgId; getFirstString(req.params.idpId) ||
getFirstString(req.body.idpId) ||
getFirstString(req.query.idpId);
const idpId = Number.parseInt(idpIdRaw ?? "", 10);
const orgId = getFirstString(req.params.orgId);
if (!apiKey) { if (!apiKey) {
return next( return next(
@@ -27,7 +32,7 @@ export async function verifyApiKeyIdpAccess(
); );
} }
if (!idpId) { if (Number.isNaN(idpId)) {
return next( return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID") createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID")
); );

View File

@@ -4,6 +4,7 @@ import { apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyOrgAccess( export async function verifyApiKeyOrgAccess(
req: Request, req: Request,
@@ -12,7 +13,7 @@ export async function verifyApiKeyOrgAccess(
) { ) {
try { try {
const apiKeyId = req.apiKey?.apiKeyId; const apiKeyId = req.apiKey?.apiKeyId;
const orgId = req.params.orgId; const orgId = getFirstString(req.params.orgId);
if (!apiKeyId) { if (!apiKeyId) {
return next( return next(
@@ -45,7 +46,7 @@ export async function verifyApiKeyOrgAccess(
} }
if (!req.apiKeyOrg) { if (!req.apiKeyOrg) {
next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Key does not have access to this organization" "Key does not have access to this organization"

View File

@@ -4,6 +4,7 @@ import { siteResources, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeySiteResourceAccess( export async function verifyApiKeySiteResourceAccess(
req: Request, req: Request,
@@ -12,7 +13,8 @@ export async function verifyApiKeySiteResourceAccess(
) { ) {
try { try {
const apiKey = req.apiKey; const apiKey = req.apiKey;
const siteResourceId = parseInt(req.params.siteResourceId); const siteResourceIdRaw = getFirstString(req.params.siteResourceId);
const siteResourceId = Number.parseInt(siteResourceIdRaw ?? "", 10);
if (!apiKey) { if (!apiKey) {
return next( return next(
@@ -20,7 +22,7 @@ export async function verifyApiKeySiteResourceAccess(
); );
} }
if (!siteResourceId) { if (Number.isNaN(siteResourceId)) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,

View File

@@ -4,6 +4,7 @@ import { resources, targets, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyTargetAccess( export async function verifyApiKeyTargetAccess(
req: Request, req: Request,
@@ -12,7 +13,8 @@ export async function verifyApiKeyTargetAccess(
) { ) {
try { try {
const apiKey = req.apiKey; const apiKey = req.apiKey;
const targetId = parseInt(req.params.targetId); const targetIdRaw = getFirstString(req.params.targetId);
const targetId = Number.parseInt(targetIdRaw ?? "", 10);
if (!apiKey) { if (!apiKey) {
return next( return next(
@@ -20,7 +22,7 @@ export async function verifyApiKeyTargetAccess(
); );
} }
if (isNaN(targetId)) { if (Number.isNaN(targetId)) {
return next( return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID") createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID")
); );

View File

@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "@server/auth/canUserAccessResource"; import { canUserAccessResource } from "@server/auth/canUserAccessResource";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyAccessTokenAccess( export async function verifyAccessTokenAccess(
req: Request, req: Request,
@@ -14,7 +15,7 @@ export async function verifyAccessTokenAccess(
next: NextFunction next: NextFunction
) { ) {
const userId = req.user!.userId; const userId = req.user!.userId;
const accessTokenId = req.params.accessTokenId; const accessTokenId = getFirstString(req.params.accessTokenId);
if (!userId) { if (!userId) {
return next( return next(
@@ -22,6 +23,12 @@ export async function verifyAccessTokenAccess(
); );
} }
if (!accessTokenId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid access token ID")
);
}
const [accessToken] = await db const [accessToken] = await db
.select() .select()
.from(resourceAccessToken) .from(resourceAccessToken)
@@ -87,7 +94,7 @@ export async function verifyAccessTokenAccess(
} }
if (!req.userOrg) { if (!req.userOrg) {
next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"User does not have access to this organization" "User does not have access to this organization"

View File

@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyAccess( export async function verifyApiKeyAccess(
req: Request, req: Request,
@@ -14,9 +15,24 @@ export async function verifyApiKeyAccess(
) { ) {
try { try {
const userId = req.user!.userId; const userId = req.user!.userId;
const apiKeyId = const apiKeyIdFromParams = getFirstString(req.params?.apiKeyId);
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId; const apiKeyIdFromBody = getFirstString(req.body?.apiKeyId);
const orgId = req.params.orgId;
if (
apiKeyIdFromParams &&
apiKeyIdFromBody &&
apiKeyIdFromParams !== apiKeyIdFromBody
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"API key ID provided in both URL and body with different values"
)
);
}
const apiKeyId = apiKeyIdFromParams || apiKeyIdFromBody;
const orgId = getFirstString(req.params.orgId);
if (!userId) { if (!userId) {
return next( return next(
@@ -104,10 +120,7 @@ export async function verifyApiKeyAccess(
} }
} }
req.userOrgRoleIds = await getUserOrgRoleIds( req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.userOrg.userId,
orgId
);
return next(); return next();
} catch (error) { } catch (error) {

View File

@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyDomainAccess( export async function verifyDomainAccess(
req: Request, req: Request,
@@ -14,9 +15,8 @@ export async function verifyDomainAccess(
) { ) {
try { try {
const userId = req.user!.userId; const userId = req.user!.userId;
const domainId = const domainId = getFirstString(req.params.domainId);
req.params.domainId; const orgId = getFirstString(req.params.orgId);
const orgId = req.params.orgId;
if (!userId) { if (!userId) {
return next( return next(
@@ -62,10 +62,7 @@ export async function verifyDomainAccess(
.select() .select()
.from(userOrgs) .from(userOrgs)
.where( .where(
and( and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, orgId)
)
) )
.limit(1); .limit(1);
req.userOrg = userOrgRole[0]; req.userOrg = userOrgRole[0];

View File

@@ -3,6 +3,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { build } from "@server/build"; import { build } from "@server/build";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyLimits( export async function verifyLimits(
req: Request, req: Request,
@@ -13,7 +14,10 @@ export async function verifyLimits(
return next(); return next();
} }
const orgId = req.userOrgId || req.apiKeyOrg?.orgId || req.params.orgId; const orgId =
req.userOrgId ||
req.apiKeyOrg?.orgId ||
getFirstString(req.params.orgId);
if (!orgId) { if (!orgId) {
return next(); // its fine if we silently fail here because this is not critical to operation or security and its better user experience if we dont fail return next(); // its fine if we silently fail here because this is not critical to operation or security and its better user experience if we dont fail

View File

@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyOrgAccess( export async function verifyOrgAccess(
req: Request, req: Request,
@@ -13,7 +14,7 @@ export async function verifyOrgAccess(
next: NextFunction next: NextFunction
) { ) {
const userId = req.user!.userId; const userId = req.user!.userId;
const orgId = req.params.orgId; const orgId = getFirstString(req.params.orgId);
if (!userId) { if (!userId) {
return next( return next(

View File

@@ -1,10 +1,16 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db, userOrgs, siteProvisioningKeys, siteProvisioningKeyOrg } from "@server/db"; import {
db,
userOrgs,
siteProvisioningKeys,
siteProvisioningKeyOrg
} from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifySiteProvisioningKeyAccess( export async function verifySiteProvisioningKeyAccess(
req: Request, req: Request,
@@ -13,8 +19,10 @@ export async function verifySiteProvisioningKeyAccess(
) { ) {
try { try {
const userId = req.user!.userId; const userId = req.user!.userId;
const siteProvisioningKeyId = req.params.siteProvisioningKeyId; const siteProvisioningKeyId = getFirstString(
const orgId = req.params.orgId; req.params.siteProvisioningKeyId
);
const orgId = getFirstString(req.params.orgId);
if (!userId) { if (!userId) {
return next( return next(
@@ -80,10 +88,7 @@ export async function verifySiteProvisioningKeyAccess(
.where( .where(
and( and(
eq(userOrgs.userId, userId), eq(userOrgs.userId, userId),
eq( eq(userOrgs.orgId, row.siteProvisioningKeyOrg.orgId)
userOrgs.orgId,
row.siteProvisioningKeyOrg.orgId
)
) )
) )
.limit(1); .limit(1);

View File

@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "../auth/canUserAccessResource"; import { canUserAccessResource } from "../auth/canUserAccessResource";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyTargetAccess( export async function verifyTargetAccess(
req: Request, req: Request,
@@ -14,7 +15,8 @@ export async function verifyTargetAccess(
next: NextFunction next: NextFunction
) { ) {
const userId = req.user!.userId; const userId = req.user!.userId;
const targetId = parseInt(req.params.targetId); const targetIdRaw = getFirstString(req.params.targetId);
const targetId = Number.parseInt(targetIdRaw ?? "", 10);
if (!userId) { if (!userId) {
return next( return next(

View File

@@ -4,6 +4,7 @@ import { userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyUserIsOrgOwner( export async function verifyUserIsOrgOwner(
req: Request, req: Request,
@@ -11,7 +12,7 @@ export async function verifyUserIsOrgOwner(
next: NextFunction next: NextFunction
) { ) {
const userId = req.user!.userId; const userId = req.user!.userId;
const orgId = req.params.orgId; const orgId = getFirstString(req.params.orgId);
if (!userId) { if (!userId) {
return next( return next(

View File

@@ -24,8 +24,7 @@ import { LogStreamingManager } from "./LogStreamingManager";
*/ */
export const logStreamingManager = new LogStreamingManager(); export const logStreamingManager = new LogStreamingManager();
if (build !== "saas") { if (build != "saas") { // this is handled separately in the saas build, so we don't want to start it here
// this is handled separately in the saas build, so we don't want to start it here
logStreamingManager.start(); logStreamingManager.start();
} }

View File

@@ -19,6 +19,7 @@ import { eq, and } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger"; import logger from "@server/logger";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyCertificateAccess( export async function verifyCertificateAccess(
req: Request, req: Request,
@@ -27,11 +28,43 @@ export async function verifyCertificateAccess(
) { ) {
try { try {
// Assume user/org access is already verified // Assume user/org access is already verified
const orgId = req.params.orgId; const orgId = getFirstString(req.params.orgId);
const certId =
req.params.certId || req.body?.certId || req.query?.certId; const certIdFromParams = getFirstString(req.params?.certId);
let domainId = const certIdFromBody = getFirstString(req.body?.certId);
req.params.domainId || req.body?.domainId || req.query?.domainId;
if (
certIdFromParams &&
certIdFromBody &&
certIdFromParams !== certIdFromBody
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Certificate ID provided in both URL and body with different values"
)
);
}
const certId = certIdFromParams || certIdFromBody;
const domainIdFromParams = getFirstString(req.params?.domainId);
const domainIdFromBody = getFirstString(req.body?.domainId);
if (
domainIdFromParams &&
domainIdFromBody &&
domainIdFromParams !== domainIdFromBody
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Domain ID provided in both URL and body with different values"
)
);
}
let domainId = domainIdFromParams || domainIdFromBody;
if (!orgId) { if (!orgId) {
return next( return next(
@@ -65,7 +98,7 @@ export async function verifyCertificateAccess(
); );
} }
domainId = cert.domainId; domainId = cert.domainId ?? undefined;
if (!domainId) { if (!domainId) {
return next( return next(
createHttpError( createHttpError(

View File

@@ -17,6 +17,7 @@ import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyIdpAccess( export async function verifyIdpAccess(
req: Request, req: Request,
@@ -25,8 +26,12 @@ export async function verifyIdpAccess(
) { ) {
try { try {
const userId = req.user!.userId; const userId = req.user!.userId;
const idpId = req.params.idpId || req.body.idpId || req.query.idpId; const idpIdRaw =
const orgId = req.params.orgId; getFirstString(req.params.idpId) ||
getFirstString(req.body?.idpId) ||
getFirstString(req.query?.idpId);
const idpId = Number.parseInt(idpIdRaw ?? "", 10);
const orgId = getFirstString(req.params.orgId);
if (!userId) { if (!userId) {
return next( return next(
@@ -40,7 +45,7 @@ export async function verifyIdpAccess(
); );
} }
if (!idpId) { if (Number.isNaN(idpId)) {
return next( return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID") createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
); );

View File

@@ -18,6 +18,7 @@ import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyRemoteExitNodeAccess( export async function verifyRemoteExitNodeAccess(
req: Request, req: Request,
@@ -25,11 +26,11 @@ export async function verifyRemoteExitNodeAccess(
next: NextFunction next: NextFunction
) { ) {
const userId = req.user!.userId; // Assuming you have user information in the request const userId = req.user!.userId; // Assuming you have user information in the request
const orgId = req.params.orgId; const orgId = getFirstString(req.params.orgId);
const remoteExitNodeId = const remoteExitNodeId =
req.params.remoteExitNodeId || getFirstString(req.params.remoteExitNodeId) ||
req.body.remoteExitNodeId || getFirstString(req.body?.remoteExitNodeId) ||
req.query.remoteExitNodeId; getFirstString(req.query?.remoteExitNodeId);
if (!userId) { if (!userId) {
return next( return next(
@@ -37,6 +38,15 @@ export async function verifyRemoteExitNodeAccess(
); );
} }
if (!orgId || !remoteExitNodeId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid organization or remote exit node ID"
)
);
}
try { try {
const [remoteExitNode] = await db const [remoteExitNode] = await db
.select() .select()

View File

@@ -25,7 +25,7 @@ export function verifyValidSubscription(tiers: Tier[]) {
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
try { try {
if (build !== "saas") { if (build != "saas") {
return next(); return next();
} }

View File

@@ -93,6 +93,20 @@ export const queryAccessAuditLogsCombined = queryAccessAuditLogsQuery.merge(
); );
type Q = z.infer<typeof queryAccessAuditLogsCombined>; type Q = z.infer<typeof queryAccessAuditLogsCombined>;
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
items: T[]
): T[] {
return [...items].sort((a, b) => {
const nameA = a.name ?? "";
const nameB = b.name ?? "";
if (nameA < nameB) return -1;
if (nameA > nameB) return 1;
return a.id - b.id;
});
}
function getWhere(data: Q) { function getWhere(data: Q) {
return and( return and(
gt(accessAuditLog.timestamp, data.timeStart), gt(accessAuditLog.timestamp, data.timeStart),
@@ -308,7 +322,7 @@ async function queryUniqueFilterAttributes(
actors: uniqueActors actors: uniqueActors
.map((row) => row.actor) .map((row) => row.actor)
.filter((actor): actor is string => actor !== null), .filter((actor): actor is string => actor !== null),
resources: resourcesWithNames, resources: sortNamedFilterOptions(resourcesWithNames),
locations: uniqueLocations locations: uniqueLocations
.map((row) => row.locations) .map((row) => row.locations)
.filter((location): location is string => location !== null) .filter((location): location is string => location !== null)

View File

@@ -107,6 +107,20 @@ export const queryConnectionAuditLogsCombined =
queryConnectionAuditLogsQuery.merge(queryConnectionAuditLogsParams); queryConnectionAuditLogsQuery.merge(queryConnectionAuditLogsParams);
type Q = z.infer<typeof queryConnectionAuditLogsCombined>; type Q = z.infer<typeof queryConnectionAuditLogsCombined>;
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
items: T[]
): T[] {
return [...items].sort((a, b) => {
const nameA = a.name ?? "";
const nameB = b.name ?? "";
if (nameA < nameB) return -1;
if (nameA > nameB) return 1;
return a.id - b.id;
});
}
function getWhere(data: Q) { function getWhere(data: Q) {
return and( return and(
gt(connectionAuditLog.startedAt, data.timeStart), gt(connectionAuditLog.startedAt, data.timeStart),
@@ -425,7 +439,7 @@ async function queryUniqueFilterAttributes(
.map((row) => row.destAddr) .map((row) => row.destAddr)
.filter((addr): addr is string => addr !== null), .filter((addr): addr is string => addr !== null),
clients: clientsWithNames, clients: clientsWithNames,
resources: resourcesWithNames, resources: sortNamedFilterOptions(resourcesWithNames),
users: usersWithEmails users: usersWithEmails
}; };
} }

View File

@@ -31,7 +31,7 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
import * as alertRule from "#private/routers/alertRule"; import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks"; import * as healthChecks from "#private/routers/healthChecks";
import * as labels from "#private/routers/labels"; import * as client from "@server/routers/client";
import { import {
verifyOrgAccess, verifyOrgAccess,
@@ -733,59 +733,6 @@ authenticated.get(
alertRule.getAlertRule alertRule.getAlertRule
); );
authenticated.get(
"/org/:orgId/labels",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.listOrgLabels),
labels.listOrgLabels
);
authenticated.post(
"/org/:orgId/labels",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.createOrgLabel),
labels.createOrgLabel
);
authenticated.patch(
"/org/:orgId/label/:labelId",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.updateOrgLabel),
labels.updateOrgLabel
);
authenticated.delete(
"/org/:orgId/label/:labelId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
labels.deleteOrgLabel
);
authenticated.put(
"/org/:orgId/label/:labelId/attach",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.attachLabelToItem),
labels.attachLabelToItem
);
authenticated.put(
"/org/:orgId/label/:labelId/detach",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
labels.detachLabelFromItem
);
authenticated.get( authenticated.get(
"/org/:orgId/health-checks", "/org/:orgId/health-checks",
verifyValidLicense, verifyValidLicense,
@@ -829,3 +776,15 @@ authenticated.get(
verifyUserHasAction(ActionsEnum.getTarget), verifyUserHasAction(ActionsEnum.getTarget),
healthChecks.getHealthCheckStatusHistory healthChecks.getHealthCheckStatusHistory
); );
authenticated.get(
"/client/:clientId/verify-associations-cache",
verifyClientAccess,
client.verifyClientAssociationsCache
);
authenticated.post(
"/client/:clientId/rebuild-associations-cache",
verifyClientAccess,
client.rebuildClientAssociationsCacheRoute
);

View File

@@ -16,40 +16,44 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response"; import { response as sendResponse } from "@server/lib/response";
import { getFirstString } from "@server/lib/requestParams";
import privateConfig from "#private/lib/config"; import privateConfig from "#private/lib/config";
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types"; import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
export interface CreateNewLicenseResponse { export interface CreateNewLicenseResponse {
data: Data data: Data;
success: boolean success: boolean;
error: boolean error: boolean;
message: string message: string;
status: number status: number;
} }
export interface Data { export interface Data {
licenseKey: LicenseKey licenseKey: LicenseKey;
} }
export interface LicenseKey { export interface LicenseKey {
id: number id: number;
instanceName: any instanceName: any;
instanceId: string instanceId: string;
licenseKey: string licenseKey: string;
tier: string tier: string;
type: string type: string;
quantity: number quantity: number;
quantity_2: number quantity_2: number;
isValid: boolean isValid: boolean;
updatedAt: string updatedAt: string;
createdAt: string createdAt: string;
expiresAt: string expiresAt: string;
paidFor: boolean paidFor: boolean;
orgId: string orgId: string;
metadata: string metadata: string;
} }
export async function createNewLicense(orgId: string, licenseData: any): Promise<CreateNewLicenseResponse> { export async function createNewLicense(
orgId: string,
licenseData: any
): Promise<CreateNewLicenseResponse> {
try { try {
const response = await fetch( const response = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both
@@ -80,7 +84,7 @@ export async function generateNewLicense(
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
try { try {
const { orgId } = req.params; const orgId = getFirstString(req.params.orgId);
if (!orgId) { if (!orgId) {
return next( return next(

View File

@@ -16,6 +16,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response"; import { response as sendResponse } from "@server/lib/response";
import { getFirstString } from "@server/lib/requestParams";
import privateConfig from "#private/lib/config"; import privateConfig from "#private/lib/config";
import { import {
GeneratedLicenseKey, GeneratedLicenseKey,
@@ -55,7 +56,7 @@ export async function listSaasLicenseKeys(
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
try { try {
const { orgId } = req.params; const orgId = getFirstString(req.params.orgId);
if (!orgId) { if (!orgId) {
return next( return next(

View File

@@ -1,224 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import {
clients,
clientLabels,
db,
labels,
resourceLabels,
resources,
siteLabels,
siteResourceLabels,
siteResources,
sites
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
const attachLabelBodySchema = z.strictObject({
siteId: z.number().int().optional(),
resourceId: z.number().int().optional(),
siteResourceId: z.number().int().optional(),
clientId: z.number().int().optional()
});
export async function attachLabelToItem(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const parsedBody = attachLabelBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, resourceId, siteResourceId, clientId } =
parsedBody.data;
if (!siteId && !resourceId && !siteResourceId && !clientId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
)
);
}
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Label with Id ${labelId} not found`
)
);
}
if (siteId) {
const siteCount = await db.$count(
sites,
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
);
if (siteCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with Id ${siteId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(siteLabels)
.values({
labelId,
siteId
})
.onConflictDoNothing();
}
if (resourceId) {
const resourceCount = await db.$count(
resources,
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with Id ${resourceId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(resourceLabels)
.values({
labelId,
resourceId
})
.onConflictDoNothing();
}
if (siteResourceId) {
const resourceCount = await db.$count(
siteResources,
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`SiteResource with Id ${siteResourceId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(siteResourceLabels)
.values({
labelId,
siteResourceId
})
.onConflictDoNothing();
}
if (clientId) {
const clientCount = await db.$count(
clients,
and(
eq(clients.clientId, clientId),
eq(clients.orgId, orgId),
isNull(clients.userId)
)
);
if (clientCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Client with Id ${clientId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(clientLabels)
.values({
labelId,
clientId
})
.onConflictDoNothing();
}
return response(res, {
data: {},
success: true,
error: false,
message: "Label attached successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,149 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import {
db,
labels,
resourceLabels,
resources,
siteLabels,
sites
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const bodySchema = z.strictObject({
name: z.string().nonempty(),
color: z
.string()
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
.nonempty(),
siteId: z.number().int().optional(),
resourceId: z.number().int().optional()
});
export async function createOrgLabel(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { name, color, siteId, resourceId } = parsedBody.data;
if (siteId) {
const siteCount = await db.$count(
sites,
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
);
if (siteCount === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Site with Id ${siteId} doesn't exist.`
)
);
}
}
if (resourceId) {
const resourceCount = await db.$count(
resources,
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Resource with Id ${resourceId} doesn't exist.`
)
);
}
}
const label = await db.transaction(async (tx) => {
const [label] = await tx
.insert(labels)
.values({
name,
color,
orgId
})
.returning();
if (siteId) {
await tx.insert(siteLabels).values({
siteId,
labelId: label.labelId
});
}
if (resourceId) {
await tx.insert(resourceLabels).values({
resourceId,
labelId: label.labelId
});
}
return label;
});
return response<CreateOrEditLabelResponse>(res, {
data: { label },
success: true,
error: false,
message: "Org Label created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,72 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db, labels } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
export async function deleteOrgLabel(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
}
await db
.delete(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
return response(res, {
data: null,
success: true,
error: false,
message: "Label deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,224 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import {
clients,
clientLabels,
db,
labels,
resourceLabels,
resources,
siteLabels,
siteResourceLabels,
siteResources,
sites
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
const detachLabelBodySchema = z.strictObject({
siteId: z.number().int().optional(),
resourceId: z.number().int().optional(),
siteResourceId: z.number().int().optional(),
clientId: z.number().int().optional()
});
export async function detachLabelFromItem(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const parsedBody = detachLabelBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, resourceId, siteResourceId, clientId } =
parsedBody.data;
if (!siteId && !resourceId && !siteResourceId && !clientId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
)
);
}
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Label with Id ${labelId} not found`
)
);
}
if (siteId) {
const siteCount = await db.$count(
sites,
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
);
if (siteCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with Id ${siteId} doesn't exist.`
)
);
}
await db
.delete(siteLabels)
.where(
and(
eq(siteLabels.labelId, labelId),
eq(siteLabels.siteId, siteId)
)
);
}
if (resourceId) {
const resourceCount = await db.$count(
resources,
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with Id ${resourceId} doesn't exist.`
)
);
}
await db
.delete(resourceLabels)
.where(
and(
eq(resourceLabels.labelId, labelId),
eq(resourceLabels.resourceId, resourceId)
)
);
}
if (siteResourceId) {
const resourceCount = await db.$count(
siteResources,
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`SiteResource with Id ${siteResourceId} doesn't exist.`
)
);
}
await db
.delete(siteResourceLabels)
.where(
and(
eq(siteResourceLabels.labelId, labelId),
eq(siteResourceLabels.siteResourceId, siteResourceId)
)
);
}
if (clientId) {
const clientCount = await db.$count(
clients,
and(
eq(clients.clientId, clientId),
eq(clients.orgId, orgId),
isNull(clients.userId)
)
);
if (clientCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Client with Id ${clientId} doesn't exist.`
)
);
}
await db
.delete(clientLabels)
.where(
and(
eq(clientLabels.labelId, labelId),
eq(clientLabels.clientId, clientId)
)
);
}
return response(res, {
data: {},
success: true,
error: false,
message: "Label detached successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,19 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export * from "./listOrgLabels";
export * from "./createOrgLabel";
export * from "./updateOrgLabel";
export * from "./attachLabelToItem";
export * from "./detachLabelFromItem";
export * from "./deleteOrgLabel";

View File

@@ -1,155 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db, labels } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode";
import { and, asc, eq, like, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const listLabelsSchema = z.object({
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional()
});
function queryLabelsBase() {
return db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color
})
.from(labels);
}
export async function listOrgLabels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listLabelsSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
const { pageSize, page, query } = parsedQuery.data;
const conditions = [and(eq(labels.orgId, orgId))];
if (query) {
conditions.push(
like(
sql`LOWER(${labels.name})`,
"%" + query.toLowerCase() + "%"
)
);
}
const baseQuery = queryLabelsBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(
queryLabelsBase()
.where(and(...conditions))
.as("filtered_labels")
);
const labelListQuery = baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(labels.name));
const [totalCount, rows] = await Promise.all([
countQuery,
labelListQuery
]);
return response<ListOrgLabelsResponse>(res, {
data: {
labels: rows,
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true,
error: false,
message: "Labels retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,101 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db, labels } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
const updateLabelBodySchema = z.strictObject({
name: z.string().min(1).max(255).optional(),
color: z
.string()
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
.nonempty()
});
export async function updateOrgLabel(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const parsedBody = updateLabelBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
}
const { name, color } = parsedBody.data;
const [label] = await db
.update(labels)
.set({
name,
color
})
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)))
.returning();
return response<CreateOrEditLabelResponse>(res, {
data: {
label
},
success: true,
error: false,
message: "Label updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -26,7 +26,6 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { eq, InferInsertModel } from "drizzle-orm"; import { eq, InferInsertModel } from "drizzle-orm";
import { build } from "@server/build"; import { build } from "@server/build";
import { validateLocalPath } from "@app/lib/validateLocalPath";
import config from "#private/lib/config"; import config from "#private/lib/config";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
@@ -35,78 +34,9 @@ const paramsSchema = z.strictObject({
const bodySchema = z.strictObject({ const bodySchema = z.strictObject({
logoUrl: z logoUrl: z
.union([ .string()
z.literal(""), .optional()
z .transform((val) => (val === "" ? null : val)),
.string()
.superRefine(async (urlOrPath, ctx) => {
const parseResult = z.url().safeParse(urlOrPath);
if (!parseResult.success) {
if (build !== "enterprise") {
ctx.addIssue({
code: "custom",
message: "Must be a valid URL"
});
return;
} else {
try {
validateLocalPath(urlOrPath);
} catch (error) {
ctx.addIssue({
code: "custom",
message: "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
});
} finally {
return;
}
}
}
try {
const response = await fetch(urlOrPath, {
method: "HEAD"
}).catch(() => {
// If HEAD fails (CORS or method not allowed), try GET
return fetch(urlOrPath, { method: "GET" });
});
if (response.status !== 200) {
ctx.addIssue({
code: "custom",
message: `Failed to load image. Please check that the URL is accessible.`
});
return;
}
const contentType =
response.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) {
ctx.addIssue({
code: "custom",
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
});
return;
}
} catch (error) {
let errorMessage =
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
if (error instanceof TypeError && error.message.includes("fetch")) {
errorMessage =
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
} else if (error instanceof Error) {
errorMessage = `Error verifying URL: ${error.message}`;
}
ctx.addIssue({
code: "custom",
message: errorMessage
});
}
})
])
.transform((val) => (val === "" ? null : val))
.nullish(),
logoWidth: z.coerce.number<number>().min(1), logoWidth: z.coerce.number<number>().min(1),
logoHeight: z.coerce.number<number>().min(1), logoHeight: z.coerce.number<number>().min(1),
resourceTitle: z.string(), resourceTitle: z.string(),

View File

@@ -1,202 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import axios from "axios";
import { db, exitNodes, newts, sites } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
import redisManager from "#private/lib/redis";
import { sendToClient } from "#private/routers/ws";
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
const MAX_DURATION_MS = 5 * 60 * 1000; // Give up after 5 minutes
const REDIS_PENDING_SET = "exit-node-reconnect-pending";
const REDIS_HASH_PREFIX = "exit-node-reconnect:";
interface PendingReconnect {
startTime: number;
reachableAt: string;
}
// In-memory tracking for this node
const pendingReconnects = new Map<number, PendingReconnect>();
let schedulerInterval: NodeJS.Timeout | null = null;
/**
* Schedules a reconnect check for newts connected to the given exit node.
* Called when an exit node transitions from offline to online.
*/
export async function scheduleExitNodeReconnect(
exitNodeId: number,
reachableAt: string
): Promise<void> {
logger.info(
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})`
);
const entry: PendingReconnect = {
startTime: Date.now(),
reachableAt
};
pendingReconnects.set(exitNodeId, entry);
// Store in Redis if available for cross-node coordination
if (redisManager.isRedisEnabled()) {
await redisManager.sadd(REDIS_PENDING_SET, exitNodeId.toString());
await redisManager.hset(
`${REDIS_HASH_PREFIX}${exitNodeId}`,
"startTime",
entry.startTime.toString()
);
await redisManager.hset(
`${REDIS_HASH_PREFIX}${exitNodeId}`,
"reachableAt",
reachableAt
);
}
}
/**
* Starts the background interval that checks pending exit node reconnects.
*/
export function startExitNodeReconnectScheduler(): void {
if (schedulerInterval) {
return;
}
schedulerInterval = setInterval(async () => {
try {
await processPendingReconnects();
} catch (error) {
logger.error("Error in exit node reconnect scheduler", { error });
}
}, CHECK_INTERVAL_MS);
logger.debug("Started exit node reconnect scheduler");
}
async function processPendingReconnects(): Promise<void> {
// Merge in-memory and Redis-tracked pending reconnects
const toProcess = new Map(pendingReconnects);
if (redisManager.isRedisEnabled()) {
const redisIds = await redisManager.smembers(REDIS_PENDING_SET);
for (const idStr of redisIds) {
const id = parseInt(idStr, 10);
if (!toProcess.has(id)) {
const startTimeStr = await redisManager.hget(
`${REDIS_HASH_PREFIX}${id}`,
"startTime"
);
const reachableAt = await redisManager.hget(
`${REDIS_HASH_PREFIX}${id}`,
"reachableAt"
);
if (startTimeStr && reachableAt) {
toProcess.set(id, {
startTime: parseInt(startTimeStr, 10),
reachableAt
});
}
}
}
}
const now = Date.now();
for (const [exitNodeId, entry] of toProcess) {
const elapsed = now - entry.startTime;
// Give up after max duration
if (elapsed >= MAX_DURATION_MS) {
logger.warn(
`Exit node reconnect check timed out for exit node ${exitNodeId} after 5 minutes`
);
await removePending(exitNodeId);
continue;
}
// Respect initial delay
if (elapsed < INITIAL_DELAY_MS) {
continue;
}
// Check if the exit node HTTP endpoint is reachable
const pingUrl = `${entry.reachableAt}/ping`;
try {
await axios.get(pingUrl, { timeout: 5000 });
} catch {
logger.debug(
`Exit node ${exitNodeId} not yet reachable at ${pingUrl}`
);
continue;
}
// Node is reachable — send reconnect to all connected newts
logger.info(
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
);
await sendReconnectToNewts(exitNodeId);
await removePending(exitNodeId);
}
}
async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
try {
const connectedNewts = await db
.select({ newtId: newts.newtId })
.from(newts)
.innerJoin(sites, eq(newts.siteId, sites.siteId))
.where(eq(sites.exitNodeId, exitNodeId));
if (connectedNewts.length === 0) {
logger.debug(
`No newts found for exit node ${exitNodeId}, nothing to reconnect`
);
return;
}
logger.info(
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
);
const reconnectMessage = {
type: "newt/wg/reconnect",
data: {}
};
await Promise.allSettled(
connectedNewts.map(({ newtId }) =>
sendToClient(newtId, reconnectMessage)
)
);
} catch (error) {
logger.error(
`Failed to send reconnect messages for exit node ${exitNodeId}`,
{ error }
);
}
}
async function removePending(exitNodeId: number): Promise<void> {
pendingReconnects.delete(exitNodeId);
if (redisManager.isRedisEnabled()) {
await redisManager.srem(REDIS_PENDING_SET, exitNodeId.toString());
await redisManager.del(`${REDIS_HASH_PREFIX}${exitNodeId}`);
}
}

View File

@@ -16,7 +16,6 @@ import { MessageHandler } from "@server/routers/ws";
import { RemoteExitNode } from "@server/db"; import { RemoteExitNode } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler";
/** /**
* Handles ping messages from clients and responds with pong * Handles ping messages from clients and responds with pong
@@ -38,13 +37,6 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
} }
try { try {
// Fetch the current state before updating so we can detect the offline→online transition
const [currentExitNode] = await db
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt })
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
.limit(1);
// Update the exit node's last ping timestamp // Update the exit node's last ping timestamp
await db await db
.update(exitNodes) .update(exitNodes)
@@ -53,16 +45,6 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
online: true online: true
}) })
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)); .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
// If the exit node was offline and is now coming online, schedule newt reconnects
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) {
scheduleExitNodeReconnect(
remoteExitNode.exitNodeId,
currentExitNode.reachableAt
).catch((error) => {
logger.error("Failed to schedule exit node reconnect", { error });
});
}
} catch (error) { } catch (error) {
logger.error("Error handling ping message", { error }); logger.error("Error handling ping message", { error });
} }

View File

@@ -22,4 +22,3 @@ export * from "./listRemoteExitNodes";
export * from "./pickRemoteExitNodeDefaults"; export * from "./pickRemoteExitNodeDefaults";
export * from "./quickStartRemoteExitNode"; export * from "./quickStartRemoteExitNode";
export * from "./offlineChecker"; export * from "./offlineChecker";
export * from "./exitNodeReconnectScheduler";

View File

@@ -14,8 +14,7 @@
import { import {
handleRemoteExitNodeRegisterMessage, handleRemoteExitNodeRegisterMessage,
handleRemoteExitNodePingMessage, handleRemoteExitNodePingMessage,
startRemoteExitNodeOfflineChecker, startRemoteExitNodeOfflineChecker
startExitNodeReconnectScheduler
} from "#private/routers/remoteExitNode"; } from "#private/routers/remoteExitNode";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { build } from "@server/build"; import { build } from "@server/build";
@@ -30,5 +29,4 @@ export const messageHandlers: Record<string, MessageHandler> = {
if (build != "saas") { if (build != "saas") {
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
startExitNodeReconnectScheduler(); // check pending exit node reconnects and notify newts
} }

View File

@@ -1,4 +1,4 @@
import { logsDb, requestAuditLog, driver, primaryLogsDb } from "@server/db"; import { logsDb, requestAuditLog, driver } from "@server/db";
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { NextFunction } from "express"; import { NextFunction } from "express";
import { Request, Response } from "express"; import { Request, Response } from "express";
@@ -74,12 +74,12 @@ async function query(query: Q) {
); );
} }
const [all] = await primaryLogsDb const [all] = await logsDb
.select({ total: count() }) .select({ total: count() })
.from(requestAuditLog) .from(requestAuditLog)
.where(baseConditions); .where(baseConditions);
const [blocked] = await primaryLogsDb const [blocked] = await logsDb
.select({ total: count() }) .select({ total: count() })
.from(requestAuditLog) .from(requestAuditLog)
.where(and(baseConditions, eq(requestAuditLog.action, false))); .where(and(baseConditions, eq(requestAuditLog.action, false)));
@@ -90,7 +90,7 @@ async function query(query: Q) {
const DISTINCT_LIMIT = 500; const DISTINCT_LIMIT = 500;
const requestsPerCountry = await primaryLogsDb const requestsPerCountry = await logsDb
.selectDistinct({ .selectDistinct({
code: requestAuditLog.location, code: requestAuditLog.location,
count: totalQ count: totalQ
@@ -118,7 +118,7 @@ async function query(query: Q) {
const booleanTrue = driver === "pg" ? sql`true` : sql`1`; const booleanTrue = driver === "pg" ? sql`true` : sql`1`;
const booleanFalse = driver === "pg" ? sql`false` : sql`0`; const booleanFalse = driver === "pg" ? sql`false` : sql`0`;
const requestsPerDay = await primaryLogsDb const requestsPerDay = await logsDb
.select({ .select({
day: groupByDayFunction.as("day"), day: groupByDayFunction.as("day"),
allowedCount: allowedCount:

View File

@@ -1,4 +1,4 @@
import { logsDb, primaryLogsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db"; import { logsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db";
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { NextFunction } from "express"; import { NextFunction } from "express";
import { Request, Response } from "express"; import { Request, Response } from "express";
@@ -86,6 +86,20 @@ export const queryRequestAuditLogsCombined = queryAccessAuditLogsQuery.merge(
); );
type Q = z.infer<typeof queryRequestAuditLogsCombined>; type Q = z.infer<typeof queryRequestAuditLogsCombined>;
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
items: T[]
): T[] {
return [...items].sort((a, b) => {
const nameA = a.name ?? "";
const nameB = b.name ?? "";
if (nameA < nameB) return -1;
if (nameA > nameB) return 1;
return a.id - b.id;
});
}
function getWhere(data: Q) { function getWhere(data: Q) {
return and( return and(
gt(requestAuditLog.timestamp, data.timeStart), gt(requestAuditLog.timestamp, data.timeStart),
@@ -110,7 +124,7 @@ function getWhere(data: Q) {
} }
export function queryRequest(data: Q) { export function queryRequest(data: Q) {
return primaryLogsDb return logsDb
.select({ .select({
id: requestAuditLog.id, id: requestAuditLog.id,
timestamp: requestAuditLog.timestamp, timestamp: requestAuditLog.timestamp,
@@ -211,7 +225,7 @@ async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRe
} }
export function countRequestQuery(data: Q) { export function countRequestQuery(data: Q) {
const countQuery = primaryLogsDb const countQuery = logsDb
.select({ count: count() }) .select({ count: count() })
.from(requestAuditLog) .from(requestAuditLog)
.where(getWhere(data)); .where(getWhere(data));
@@ -254,34 +268,34 @@ async function queryUniqueFilterAttributes(
uniqueResources, uniqueResources,
uniqueSiteResources uniqueSiteResources
] = await Promise.all([ ] = await Promise.all([
primaryLogsDb logsDb
.selectDistinct({ actor: requestAuditLog.actor }) .selectDistinct({ actor: requestAuditLog.actor })
.from(requestAuditLog) .from(requestAuditLog)
.where(baseConditions) .where(baseConditions)
.limit(DISTINCT_LIMIT + 1), .limit(DISTINCT_LIMIT + 1),
primaryLogsDb logsDb
.selectDistinct({ locations: requestAuditLog.location }) .selectDistinct({ locations: requestAuditLog.location })
.from(requestAuditLog) .from(requestAuditLog)
.where(baseConditions) .where(baseConditions)
.limit(DISTINCT_LIMIT + 1), .limit(DISTINCT_LIMIT + 1),
primaryLogsDb logsDb
.selectDistinct({ hosts: requestAuditLog.host }) .selectDistinct({ hosts: requestAuditLog.host })
.from(requestAuditLog) .from(requestAuditLog)
.where(baseConditions) .where(baseConditions)
.limit(DISTINCT_LIMIT + 1), .limit(DISTINCT_LIMIT + 1),
primaryLogsDb logsDb
.selectDistinct({ paths: requestAuditLog.path }) .selectDistinct({ paths: requestAuditLog.path })
.from(requestAuditLog) .from(requestAuditLog)
.where(baseConditions) .where(baseConditions)
.limit(DISTINCT_LIMIT + 1), .limit(DISTINCT_LIMIT + 1),
primaryLogsDb logsDb
.selectDistinct({ .selectDistinct({
id: requestAuditLog.resourceId id: requestAuditLog.resourceId
}) })
.from(requestAuditLog) .from(requestAuditLog)
.where(baseConditions) .where(baseConditions)
.limit(DISTINCT_LIMIT + 1), .limit(DISTINCT_LIMIT + 1),
primaryLogsDb logsDb
.selectDistinct({ .selectDistinct({
id: requestAuditLog.siteResourceId id: requestAuditLog.siteResourceId
}) })
@@ -353,7 +367,7 @@ async function queryUniqueFilterAttributes(
actors: uniqueActors actors: uniqueActors
.map((row) => row.actor) .map((row) => row.actor)
.filter((actor): actor is string => actor !== null), .filter((actor): actor is string => actor !== null),
resources: resourcesWithNames, resources: sortNamedFilterOptions(resourcesWithNames),
locations: uniqueLocations locations: uniqueLocations
.map((row) => row.locations) .map((row) => row.locations)
.filter((location): location is string => location !== null), .filter((location): location is string => location !== null),

View File

@@ -25,6 +25,7 @@ import { UserType } from "@server/types/UserTypes";
import { verifyPassword } from "@server/auth/password"; import { verifyPassword } from "@server/auth/password";
import { unauthorized } from "@server/auth/unauthorizedResponse"; import { unauthorized } from "@server/auth/unauthorizedResponse";
import { verifyTotpCode } from "@server/auth/totp"; import { verifyTotpCode } from "@server/auth/totp";
import { getFirstString } from "@server/lib/requestParams";
// The RP ID is the domain name of your application // The RP ID is the domain name of your application
const rpID = (() => { const rpID = (() => {
@@ -406,7 +407,12 @@ export async function deleteSecurityKey(
res: Response, res: Response,
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
const { credentialId: encodedCredentialId } = req.params; const encodedCredentialId = getFirstString(req.params.credentialId);
if (!encodedCredentialId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid credential ID")
);
}
const credentialId = decodeURIComponent(encodedCredentialId); const credentialId = decodeURIComponent(encodedCredentialId);
const user = req.user as User; const user = req.user as User;

View File

@@ -10,3 +10,5 @@ export * from "./listUserDevices";
export * from "./updateClient"; export * from "./updateClient";
export * from "./getClient"; export * from "./getClient";
export * from "./createUserClient"; export * from "./createUserClient";
export * from "./verifyClientAssociationsCache";
export * from "./rebuildClientAssociationsCacheRoute";

View File

@@ -1,20 +1,15 @@
import { import {
clientLabels,
clients, clients,
clientSitesAssociationsCache, clientSitesAssociationsCache,
currentFingerprint, currentFingerprint,
db, db,
labels,
olms, olms,
orgs, orgs,
roleClients, roleClients,
sites, sites,
userClients, userClients,
users, users
type Label
} from "@server/db"; } from "@server/db";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -174,7 +169,6 @@ type ClientWithSites = Awaited<ReturnType<typeof queryClientsBase>>[0] & {
siteNiceId: string | null; siteNiceId: string | null;
}>; }>;
olmUpdateAvailable?: boolean; olmUpdateAvailable?: boolean;
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
}; };
type OlmWithUpdateAvailable = ClientWithSites; type OlmWithUpdateAvailable = ClientWithSites;
@@ -261,11 +255,6 @@ export async function listClients(
(client) => client.clientId (client) => client.clientId
); );
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
// Get client count with filter // Get client count with filter
const conditions = [ const conditions = [
and( and(
@@ -299,29 +288,18 @@ export async function listClients(
} }
if (query) { if (query) {
const q = "%" + query.toLowerCase() + "%"; conditions.push(
const queryList = [ or(
like(sql`LOWER(${clients.name})`, q), like(
like(sql`LOWER(${clients.niceId})`, q) sql`LOWER(${clients.name})`,
]; "%" + query.toLowerCase() + "%"
),
if (isLabelFeatureEnabled) { like(
queryList.push( sql`LOWER(${clients.niceId})`,
inArray( "%" + query.toLowerCase() + "%"
clients.clientId,
db
.select({ id: clientLabels.clientId })
.from(clientLabels)
.innerJoin(
labels,
eq(labels.labelId, clientLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
) )
); )
} );
conditions.push(or(...queryList));
} }
const baseQuery = queryClientsBase().where(and(...conditions)); const baseQuery = queryClientsBase().where(and(...conditions));
@@ -348,30 +326,6 @@ export async function listClients(
const clientIds = clientsList.map((client) => client.clientId); const clientIds = clientsList.map((client) => client.clientId);
const siteAssociations = await getSiteAssociations(clientIds); const siteAssociations = await getSiteAssociations(clientIds);
let labelsForClients: Array<{
labelId: number;
name: string;
color: string;
clientId: number;
}> = [];
if (isLabelFeatureEnabled && clientIds.length > 0) {
labelsForClients = await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
clientId: clientLabels.clientId
})
.from(labels)
.innerJoin(
clientLabels,
eq(clientLabels.labelId, labels.labelId)
)
.where(inArray(clientLabels.clientId, clientIds))
.orderBy(asc(clientLabels.clientLabelId));
}
// Group site associations by client ID // Group site associations by client ID
const sitesByClient = siteAssociations.reduce( const sitesByClient = siteAssociations.reduce(
(acc, association) => { (acc, association) => {
@@ -399,10 +353,7 @@ export async function listClients(
const clientsWithSites = clientsList.map((client) => { const clientsWithSites = clientsList.map((client) => {
return { return {
...client, ...client,
sites: sitesByClient[client.clientId] || [], sites: sitesByClient[client.clientId] || []
labels: labelsForClients.filter(
(l) => l.clientId === client.clientId
)
}; };
}); });

View File

@@ -0,0 +1,81 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { clients } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
const paramsSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive())
});
registry.registerPath({
method: "post",
path: "/client/{clientId}/rebuild-associations-cache",
description:
"Rebuild the client's site/site-resource association cache based on current permissions.",
tags: [OpenAPITags.Client],
request: {
params: paramsSchema
},
responses: {}
});
export async function rebuildClientAssociationsCacheRoute(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { clientId } = parsedParams.data;
const [client] = await db
.select()
.from(clients)
.where(eq(clients.clientId, clientId))
.limit(1);
if (!client) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Client with ID ${clientId} not found`
)
);
}
await rebuildClientAssociationsFromClient(client);
return response(res, {
data: null,
success: true,
error: false,
message: "Client association cache rebuilt successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to rebuild client association cache"
)
);
}
}

View File

@@ -0,0 +1,83 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { clients } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { verifyClientAssociationsCache as verifyClientAssociationsCacheLib } from "@server/lib/rebuildClientAssociations";
const paramsSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive())
});
registry.registerPath({
method: "get",
path: "/client/{clientId}/verify-associations-cache",
description:
"Read-only check of whether the client's site/site-resource association cache matches what the current permissions imply.",
tags: [OpenAPITags.Client],
request: {
params: paramsSchema
},
responses: {}
});
export async function verifyClientAssociationsCache(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { clientId } = parsedParams.data;
const [client] = await db
.select()
.from(clients)
.where(eq(clients.clientId, clientId))
.limit(1);
if (!client) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Client with ID ${clientId} not found`
)
);
}
const report = await verifyClientAssociationsCacheLib(client);
return response(res, {
data: report,
success: true,
error: false,
message: report.consistent
? "Client association cache is consistent"
: "Client association cache is INCONSISTENT",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to verify client association cache"
)
);
}
}

View File

@@ -8,7 +8,6 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { domain } from "zod/v4/core/regexes";
const getDomainSchema = z.strictObject({ const getDomainSchema = z.strictObject({
domainId: z.string().optional(), domainId: z.string().optional(),

View File

@@ -1156,7 +1156,7 @@ export const authRouter = Router();
unauthenticated.use("/auth", authRouter); unauthenticated.use("/auth", authRouter);
authRouter.use( authRouter.use(
rateLimit({ rateLimit({
windowMs: config.getRawConfig().rate_limits.auth.window_minutes, windowMs: config.getRawConfig().rate_limits.auth.window_minutes * 60 * 1000,
max: config.getRawConfig().rate_limits.auth.max_requests, max: config.getRawConfig().rate_limits.auth.max_requests,
keyGenerator: (req) => keyGenerator: (req) =>
`authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`, `authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`,
@@ -1252,7 +1252,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 900, max: 900,
keyGenerator: (req) => keyGenerator: (req) =>
`olmGetToken:${req.body.newtId || ipKeyGenerator(req.ip || "")}`, `olmGetToken:${req.body.olmId || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only request an Olm token ${900} times every ${15} minutes. Please try again later.`; const message = `You can only request an Olm token ${900} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));

View File

@@ -1,10 +0,0 @@
import type { Label } from "@server/db";
import type { PaginatedResponse } from "@server/types/Pagination";
export type ListOrgLabelsResponse = PaginatedResponse<{
labels: Omit<Label, "orgId">[];
}>;
export type CreateOrEditLabelResponse = {
label: Label;
};

View File

@@ -19,6 +19,7 @@ import {
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib/response"; import { response } from "@server/lib/response";
import { getFirstString } from "@server/lib/requestParams";
export async function getUserResources( export async function getUserResources(
req: Request, req: Request,
@@ -26,7 +27,7 @@ export async function getUserResources(
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
try { try {
const { orgId } = req.params; const orgId = getFirstString(req.params.orgId);
const userId = req.user?.userId; const userId = req.user?.userId;
if (!userId) { if (!userId) {
@@ -35,6 +36,12 @@ export async function getUserResources(
); );
} }
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
// Check user is in organization and get their role IDs // Check user is in organization and get their role IDs
const [userOrg] = await db const [userOrg] = await db
.select() .select()

View File

@@ -1,9 +1,7 @@
import { import {
db, db,
labels,
resourceHeaderAuth, resourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility, resourceHeaderAuthExtendedCompatibility,
resourceLabels,
resourcePassword, resourcePassword,
resourcePincode, resourcePincode,
resources, resources,
@@ -11,11 +9,8 @@ import {
sites, sites,
targetHealthCheck, targetHealthCheck,
targets, targets,
userResources, userResources
type Label
} from "@server/db"; } from "@server/db";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -159,7 +154,6 @@ export type ResourceWithTargets = {
siteNiceId: string; siteNiceId: string;
online?: boolean; // undefined for local sites online?: boolean; // undefined for local sites
}>; }>;
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
}; };
function queryResourcesBase() { function queryResourcesBase() {
@@ -294,11 +288,6 @@ export async function listResources(
); );
} }
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
let accessibleResources: Array<{ resourceId: number }>; let accessibleResources: Array<{ resourceId: number }>;
if (req.user) { if (req.user) {
accessibleResources = await db accessibleResources = await db
@@ -336,6 +325,24 @@ export async function listResources(
) )
]; ];
if (query) {
conditions.push(
or(
like(
sql`LOWER(${resources.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${resources.niceId})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${resources.fullDomain})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
if (typeof enabled !== "undefined") { if (typeof enabled !== "undefined") {
conditions.push(eq(resources.enabled, enabled)); conditions.push(eq(resources.enabled, enabled));
} }
@@ -379,32 +386,6 @@ export async function listResources(
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId))); .where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
conditions.push(inArray(resources.resourceId, resourcesWithSite)); conditions.push(inArray(resources.resourceId, resourcesWithSite));
} }
if (query) {
const q = "%" + query.toLowerCase() + "%";
const queryList = [
like(sql`LOWER(${resources.name})`, q),
like(sql`LOWER(${resources.niceId})`, q),
like(sql`LOWER(${resources.fullDomain})`, q)
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
resources.resourceId,
db
.select({ id: resourceLabels.resourceId })
.from(resourceLabels)
.innerJoin(
labels,
eq(labels.labelId, resourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList));
}
const baseQuery = queryResourcesBase().where(and(...conditions)); const baseQuery = queryResourcesBase().where(and(...conditions));
@@ -426,36 +407,6 @@ export async function listResources(
]); ]);
const resourceIdList = rows.map((row) => row.resourceId); const resourceIdList = rows.map((row) => row.resourceId);
let labelsForResources: Array<{
labelId: number;
name: string;
color: string;
resourceId: number;
}> = [];
if (isLabelFeatureEnabled) {
labelsForResources =
resourceIdList.length === 0
? []
: await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
resourceId: resourceLabels.resourceId
})
.from(labels)
.innerJoin(
resourceLabels,
eq(resourceLabels.labelId, labels.labelId)
)
.where(
inArray(resourceLabels.resourceId, resourceIdList)
)
.orderBy(asc(resourceLabels.resourceLabelId));
}
const allResourceTargets = const allResourceTargets =
resourceIdList.length === 0 resourceIdList.length === 0
? [] ? []
@@ -507,10 +458,7 @@ export async function listResources(
headerAuthId: row.headerAuthId, headerAuthId: row.headerAuthId,
health: row.health ?? null, health: row.health ?? null,
targets: [], targets: [],
sites: [], sites: []
labels: labelsForResources.filter(
(l) => l.resourceId === row.resourceId
)
}; };
map.set(row.resourceId, entry); map.set(row.resourceId, entry);
} }

View File

@@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, Site, siteNetworks, siteResources } from "@server/db"; import { db } from "@server/db";
import { newts, newtSessions, sites } from "@server/db"; import { newts, sites } from "@server/db";
import { eq, inArray } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -11,7 +11,7 @@ import { deletePeer } from "../gerbil/peers";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing"; import { FeatureId } from "@server/lib/billing";
@@ -63,7 +63,11 @@ export async function deleteSite(
); );
} }
let deletedNewtId: string | null = null; const [deletedNewt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
if (site.type == "wireguard") { if (site.type == "wireguard") {
@@ -71,56 +75,24 @@ export async function deleteSite(
await deletePeer(site.exitNodeId!, site.pubKey); await deletePeer(site.exitNodeId!, site.pubKey);
} }
} else if (site.type == "newt") { } else if (site.type == "newt") {
const networks = await trx // Clean up all client associations and send peer/proxy removal
.select({ networkId: siteNetworks.networkId }) // messages in a single efficient pass before deleting the row.
.from(siteNetworks) await cleanupSiteAssociations(site, trx);
.where(eq(siteNetworks.siteId, siteId));
// loop through them await trx.delete(sites).where(eq(sites.siteId, siteId));
const updatedSiteResources = await trx
.select()
.from(siteResources)
.where(
inArray(
siteResources.networkId,
networks.map((n) => n.networkId)
)
);
for (const siteResource of updatedSiteResources) {
await rebuildClientAssociationsFromSiteResource(
siteResource,
trx
);
}
// get the newt on the site by querying the newt table for siteId
const [deletedNewt] = await trx
.delete(newts)
.where(eq(newts.siteId, siteId))
.returning();
if (deletedNewt) {
deletedNewtId = deletedNewt.newtId;
// delete all of the sessions for the newt
await trx
.delete(newtSessions)
.where(eq(newtSessions.newtId, deletedNewt.newtId));
}
} }
await trx.delete(sites).where(eq(sites.siteId, siteId));
await usageService.add(site.orgId, FeatureId.SITES, -1, trx); await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
}); });
// Send termination message outside of transaction to prevent blocking // Send termination message outside of transaction to prevent blocking
if (deletedNewtId) { if (deletedNewt) {
const payload = { const payload = {
type: `newt/wg/terminate`, type: `newt/wg/terminate`,
data: {} data: {}
}; };
// Don't await this to prevent blocking the response // Don't await this to prevent blocking the response
sendToClient(deletedNewtId, payload).catch((error) => { sendToClient(deletedNewt.newtId, payload).catch((error) => {
logger.error( logger.error(
"Failed to send termination message to newt:", "Failed to send termination message to newt:",
error error

View File

@@ -9,10 +9,7 @@ import {
siteResources, siteResources,
targets, targets,
sites, sites,
userSites, userSites
labels,
siteLabels,
type Label
} from "@server/db"; } from "@server/db";
import cache from "#dynamic/lib/cache"; import cache from "#dynamic/lib/cache";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -26,8 +23,6 @@ import createHttpError from "http-errors";
import semver from "semver"; import semver from "semver";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
// Stale-while-revalidate: keeps the last successfully fetched version so that // Stale-while-revalidate: keeps the last successfully fetched version so that
// a transient network failure / timeout does not flip every site back to // a transient network failure / timeout does not flip every site back to
@@ -192,7 +187,7 @@ const listSitesSchema = z.object({
function querySitesBase() { function querySitesBase() {
return db return db
.selectDistinct({ .select({
siteId: sites.siteId, siteId: sites.siteId,
niceId: sites.niceId, niceId: sites.niceId,
name: sites.name, name: sites.name,
@@ -238,7 +233,6 @@ type SiteRowBase = Awaited<ReturnType<typeof querySitesBase>>[0];
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & { type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
online?: SiteRowBase["online"]; // undefined for local sites online?: SiteRowBase["online"]; // undefined for local sites
newtUpdateAvailable?: boolean; newtUpdateAvailable?: boolean;
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
}; };
export type ListSitesResponse = PaginatedResponse<{ export type ListSitesResponse = PaginatedResponse<{
@@ -314,11 +308,6 @@ export async function listSites(
.where(eq(sites.orgId, orgId)); .where(eq(sites.orgId, orgId));
} }
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
const { pageSize, page, query, sort_by, order, online, status } = const { pageSize, page, query, sort_by, order, online, status } =
parsedQuery.data; parsedQuery.data;
@@ -330,43 +319,33 @@ export async function listSites(
eq(sites.orgId, orgId) eq(sites.orgId, orgId)
) )
]; ];
if (query) {
conditions.push(
or(
like(
sql`LOWER(${sites.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${sites.niceId})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
if (typeof online !== "undefined") { if (typeof online !== "undefined") {
conditions.push(eq(sites.online, online)); conditions.push(eq(sites.online, online));
} }
if (typeof status !== "undefined") { if (typeof status !== "undefined") {
conditions.push(eq(sites.status, status)); conditions.push(eq(sites.status, status));
} }
if (query) {
const q = "%" + query.toLowerCase() + "%";
const queryList = [
like(sql`LOWER(${sites.name})`, q),
like(sql`LOWER(${sites.niceId})`, q)
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
sites.siteId,
db
.select({ id: siteLabels.siteId })
.from(siteLabels)
.innerJoin(
labels,
eq(labels.labelId, siteLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList));
}
const baseQuery = querySitesBase().where(and(...conditions)); const baseQuery = querySitesBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery // we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count( const countQuery = db.$count(
querySitesBase().where(and(...conditions)).as("filtered_sites") querySitesBase()
.where(and(...conditions))
.as("filtered_sites")
); );
const siteListQuery = baseQuery const siteListQuery = baseQuery
@@ -388,46 +367,11 @@ export async function listSites(
// Get latest version asynchronously without blocking the response // Get latest version asynchronously without blocking the response
const latestNewtVersionPromise = getLatestNewtVersion(); const latestNewtVersionPromise = getLatestNewtVersion();
const siteIds = rows.map((site) => site.siteId);
let labelsForSites: Array<{
labelId: number;
name: string;
color: string;
siteId: number;
}> = [];
if (isLabelFeatureEnabled) {
labelsForSites =
siteIds.length === 0
? []
: await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
siteId: siteLabels.siteId
})
.from(labels)
.innerJoin(
siteLabels,
eq(siteLabels.labelId, labels.labelId)
)
.where(inArray(siteLabels.siteId, siteIds))
.orderBy(asc(siteLabels.siteLabelId));
}
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => { const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
// Initially set to false, will be updated if version check succeeds // Initially set to false, will be updated if version check succeeds
siteWithUpdate.newtUpdateAvailable = false; siteWithUpdate.newtUpdateAvailable = false;
return siteWithUpdate;
// associate labels
const labelsForSite = labelsForSites.filter(
(label) => label.siteId === site.siteId
);
return { ...siteWithUpdate, labels: labelsForSite };
}); });
// Try to get the latest version, but don't block if it fails // Try to get the latest version, but don't block if it fails

View File

@@ -15,10 +15,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { eq, and, inArray } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
rebuildClientAssociationsFromClient,
rebuildClientAssociationsFromSiteResource
} from "@server/lib/rebuildClientAssociations";
const batchAddClientToSiteResourcesParamsSchema = z const batchAddClientToSiteResourcesParamsSchema = z
.object({ .object({

View File

@@ -1,14 +1,4 @@
import { import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
db,
DB_TYPE,
Label,
SiteResource,
siteNetworks,
siteResourceLabels,
siteResources,
sites,
labels
} from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -19,8 +9,6 @@ import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({ const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -81,11 +69,16 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
default: "asc", default: "asc",
description: "Sort order" description: "Sort order"
}), }),
siteId: z.coerce.number<string>().int().positive().optional().openapi({ siteId: z.coerce
type: "integer", .number<string>()
description: .int()
"When set, only site resources associated with this site (via network) are returned" .positive()
}) .optional()
.openapi({
type: "integer",
description:
"When set, only site resources associated with this site (via network) are returned"
})
}); });
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
@@ -95,7 +88,6 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
siteNames: string[]; siteNames: string[];
siteNiceIds: string[]; siteNiceIds: string[];
siteAddresses: (string | null)[]; siteAddresses: (string | null)[];
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
})[]; })[];
}>; }>;
@@ -242,11 +234,6 @@ export async function listAllSiteResourcesByOrg(
const { page, pageSize, query, mode, sort_by, order, siteId } = const { page, pageSize, query, mode, sort_by, order, siteId } =
parsedQuery.data; parsedQuery.data;
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
const conditions = [and(eq(siteResources.orgId, orgId))]; const conditions = [and(eq(siteResources.orgId, orgId))];
if (siteId != null) { if (siteId != null) {
@@ -271,41 +258,41 @@ export async function listAllSiteResourcesByOrg(
inArray(siteResources.siteResourceId, resourcesForSite) inArray(siteResources.siteResourceId, resourcesForSite)
); );
} }
if (query) {
conditions.push(
or(
like(
sql`LOWER(${siteResources.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${siteResources.niceId})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${siteResources.destination})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${siteResources.alias})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${siteResources.aliasAddress})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${sites.name})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
if (mode) { if (mode) {
conditions.push(eq(siteResources.mode, mode)); conditions.push(eq(siteResources.mode, mode));
} }
if (query) {
const q = "%" + query.toLowerCase() + "%";
const queryList = [
like(sql`LOWER(${siteResources.name})`, q),
like(sql`LOWER(${siteResources.niceId})`, q),
like(sql`LOWER(${siteResources.destination})`, q),
like(sql`LOWER(${siteResources.alias})`, q),
like(sql`LOWER(${siteResources.aliasAddress})`, q),
like(sql`LOWER(${sites.name})`, q)
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
siteResources.siteResourceId,
db
.select({ id: siteResourceLabels.siteResourceId })
.from(siteResourceLabels)
.innerJoin(
labels,
eq(labels.labelId, siteResourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList));
}
const baseQuery = querySiteResourcesBase().where(and(...conditions)); const baseQuery = querySiteResourcesBase().where(and(...conditions));
const countQuery = db.$count( const countQuery = db.$count(
@@ -328,51 +315,11 @@ export async function listAllSiteResourcesByOrg(
countQuery countQuery
]); ]);
const siteResourcesList = siteResourcesRaw.map( const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow);
transformSiteResourceRow
);
const siteResourceIdList = siteResourcesList.map(
(r) => r.siteResourceId
);
let labelsForSiteResources: Array<{
labelId: number;
name: string;
color: string;
siteResourceId: number;
}> = [];
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) {
labelsForSiteResources = await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
siteResourceId: siteResourceLabels.siteResourceId
})
.from(labels)
.innerJoin(
siteResourceLabels,
eq(siteResourceLabels.labelId, labels.labelId)
)
.where(
inArray(
siteResourceLabels.siteResourceId,
siteResourceIdList
)
)
.orderBy(asc(siteResourceLabels.siteResourceLabelId));
}
return response<ListAllSiteResourcesByOrgResponse>(res, { return response<ListAllSiteResourcesByOrgResponse>(res, {
data: { data: {
siteResources: siteResourcesList.map((r) => ({ siteResources: siteResourcesList,
...r,
labels: labelsForSiteResources.filter(
(l) => l.siteResourceId === r.siteResourceId
)
})),
pagination: { pagination: {
total: totalCount, total: totalCount,
pageSize, pageSize,
@@ -393,4 +340,4 @@ export async function listAllSiteResourcesByOrg(
) )
); );
} }
} }

View File

@@ -1,63 +0,0 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListOrgLabelsResponse } from "@server/routers/labels/types";
import { AxiosResponse } from "axios";
import OrgLabelsTable from "@app/components/OrgLabelsTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "Labels"
};
type Props = {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
};
export const dynamic = "force-dynamic";
export default async function LabelsPage({ params, searchParams }: Props) {
const { orgId } = await params;
const searchParamsObj = new URLSearchParams(await searchParams);
let labels: ListOrgLabelsResponse["labels"] = [];
let pagination: ListOrgLabelsResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try {
const res = await internal.get<AxiosResponse<ListOrgLabelsResponse>>(
`/org/${orgId}/labels?${searchParamsObj.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
labels = responseData.labels;
pagination = responseData.pagination;
} catch (e) {}
const t = await getTranslations();
return (
<>
<SettingsSectionTitle
title={t("labels")}
description={t("orgLabelsDescription")}
/>
<OrgLabelsTable
labels={labels}
orgId={orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
}

View File

@@ -76,8 +76,7 @@ export default async function ClientsPage(props: ClientsPageProps) {
agent: client.agent, agent: client.agent,
archived: client.archived || false, archived: client.archived || false,
blocked: client.blocked || false, blocked: client.blocked || false,
approvalState: client.approvalState ?? "approved", approvalState: client.approvalState ?? "approved"
labels: client.labels ?? []
}; };
}; };

View File

@@ -153,6 +153,65 @@ export default function GeneralPage() {
const [approvalId, setApprovalId] = useState<number | null>(null); const [approvalId, setApprovalId] = useState<number | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [cacheCheck, setCacheCheck] = useState<null | {
consistent: boolean;
missingSiteResourceIds: number[];
extraSiteResourceIds: number[];
missingSiteIds: number[];
extraSiteIds: number[];
expectedSiteResourceIds: number[];
actualSiteResourceIds: number[];
expectedSiteIds: number[];
actualSiteIds: number[];
}>(null);
const [isCheckingCache, setIsCheckingCache] = useState(false);
const [isRebuildingCache, setIsRebuildingCache] = useState(false);
const handleRebuildCache = async () => {
if (!client.clientId) return;
setIsRebuildingCache(true);
try {
await api.post(
`/client/${client.clientId}/rebuild-associations-cache`
);
// Re-verify after rebuild so the result refreshes
const res = await api.get(
`/client/${client.clientId}/verify-associations-cache`
);
setCacheCheck(res.data.data);
toast({
title: "Cache rebuilt",
description: "Association cache rebuilt successfully."
});
} catch (e) {
toast({
variant: "destructive",
title: "Rebuild failed",
description: formatAxiosError(e, "Failed to rebuild cache")
});
} finally {
setIsRebuildingCache(false);
}
};
const handleVerifyCache = async () => {
if (!client.clientId) return;
setIsCheckingCache(true);
try {
const res = await api.get(
`/client/${client.clientId}/verify-associations-cache`
);
setCacheCheck(res.data.data);
} catch (e) {
toast({
variant: "destructive",
title: "Cache check failed",
description: formatAxiosError(e, "Failed to verify cache")
});
} finally {
setIsCheckingCache(false);
}
};
const { env } = useEnvContext(); const { env } = useEnvContext();
const showApprovalFeatures = const showApprovalFeatures =
@@ -844,6 +903,75 @@ export default function GeneralPage() {
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)} )}
{/* Hidden cache verification — subtle button, dev/admin diagnostic */}
<div className="mt-8 flex flex-col gap-2 items-start opacity-30 hover:opacity-100 transition-opacity">
<button
type="button"
onClick={handleVerifyCache}
disabled={isCheckingCache}
className="text-xs text-muted-foreground underline disabled:opacity-50"
title="Verify the client's site association cache against current permissions (read-only)"
>
{isCheckingCache
? "Checking cache…"
: "Verify association cache"}
</button>
{cacheCheck && (
<div
className={
"text-xs rounded border px-2 py-1 " +
(cacheCheck.consistent
? "border-green-600 text-green-700"
: "border-red-600 text-red-700")
}
>
{cacheCheck.consistent ? (
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
Cache is consistent
</span>
) : (
<div className="space-y-2">
<div className="flex items-center gap-1 font-semibold">
<XCircle className="h-3 w-3" />
Cache is INCONSISTENT
</div>
<div>
Missing site resources: [
{cacheCheck.missingSiteResourceIds.join(
", "
)}
]
</div>
<div>
Extra site resources: [
{cacheCheck.extraSiteResourceIds.join(", ")}
]
</div>
<div>
Missing sites: [
{cacheCheck.missingSiteIds.join(", ")}]
</div>
<div>
Extra sites: [
{cacheCheck.extraSiteIds.join(", ")}]
</div>
<button
type="button"
onClick={handleRebuildCache}
disabled={isRebuildingCache}
className="mt-1 text-xs underline font-semibold disabled:opacity-50"
>
{isRebuildingCache
? "Rebuilding…"
: "Rebuild cache now"}
</button>
</div>
)}
</div>
)}
</div>
</SettingsContainer> </SettingsContainer>
); );
} }

View File

@@ -280,10 +280,14 @@ export default function GeneralPage() {
console.log("Data refreshed"); console.log("Data refreshed");
setIsRefreshing(true); setIsRefreshing(true);
try { try {
const endDate = searchParams.get("end")
? dateRange.endDate
: { date: new Date() };
setDateRange((current) => ({ ...current, endDate }));
// Refresh data with current date range and pagination // Refresh data with current date range and pagination
await queryDateTime( await queryDateTime(
dateRange.startDate, dateRange.startDate,
dateRange.endDate, endDate,
currentPage, currentPage,
pageSize pageSize
); );

View File

@@ -266,10 +266,14 @@ export default function GeneralPage() {
console.log("Data refreshed"); console.log("Data refreshed");
setIsRefreshing(true); setIsRefreshing(true);
try { try {
const endDate = searchParams.get("end")
? dateRange.endDate
: { date: new Date() };
setDateRange((current) => ({ ...current, endDate }));
// Refresh data with current date range and pagination // Refresh data with current date range and pagination
await queryDateTime( await queryDateTime(
dateRange.startDate, dateRange.startDate,
dateRange.endDate, endDate,
currentPage, currentPage,
pageSize pageSize
); );

View File

@@ -9,7 +9,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { build } from "@server/build"; import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -294,7 +294,7 @@ export default function ConnectionLogsPage() {
} catch (error) { } catch (error) {
toast({ toast({
title: t("error"), title: t("error"),
description: formatAxiosError(error), description: t("Failed to filter logs"),
variant: "destructive" variant: "destructive"
}); });
} finally { } finally {
@@ -306,10 +306,14 @@ export default function ConnectionLogsPage() {
console.log("Data refreshed"); console.log("Data refreshed");
setIsRefreshing(true); setIsRefreshing(true);
try { try {
const endDate = searchParams.get("end")
? dateRange.endDate
: { date: new Date() };
setDateRange((current) => ({ ...current, endDate }));
// Refresh data with current date range and pagination // Refresh data with current date range and pagination
await queryDateTime( await queryDateTime(
dateRange.startDate, dateRange.startDate,
dateRange.endDate, endDate,
currentPage, currentPage,
pageSize pageSize
); );

View File

@@ -281,10 +281,14 @@ export default function GeneralPage() {
console.log("Data refreshed"); console.log("Data refreshed");
setIsRefreshing(true); setIsRefreshing(true);
try { try {
const endDate = searchParams.get("end")
? dateRange.endDate
: { date: new Date() };
setDateRange((current) => ({ ...current, endDate }));
// Refresh data with current date range and pagination // Refresh data with current date range and pagination
await queryDateTime( await queryDateTime(
dateRange.startDate, dateRange.startDate,
dateRange.endDate, endDate,
currentPage, currentPage,
pageSize pageSize
); );

View File

@@ -127,8 +127,7 @@ export default async function ClientResourcesPage(
authDaemonPort: siteResource.authDaemonPort ?? null, authDaemonPort: siteResource.authDaemonPort ?? null,
subdomain: siteResource.subdomain ?? null, subdomain: siteResource.subdomain ?? null,
domainId: siteResource.domainId ?? null, domainId: siteResource.domainId ?? null,
fullDomain: siteResource.fullDomain ?? null, fullDomain: siteResource.fullDomain ?? null
labels: siteResource.labels ?? []
}; };
} }
); );

View File

@@ -49,7 +49,7 @@ import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import SetResourcePasswordForm from "components/SetResourcePasswordForm"; import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm";
import { Binary, Bot, InfoIcon, Key } from "lucide-react"; import { Binary, Bot, InfoIcon, Key } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";

View File

@@ -111,7 +111,6 @@ export default async function ProxyResourcesPage(
protocol: resource.protocol, protocol: resource.protocol,
proxyPort: resource.proxyPort, proxyPort: resource.proxyPort,
http: resource.http, http: resource.http,
labels: resource.labels,
authState: !resource.http authState: !resource.http
? "none" ? "none"
: resource.sso || : resource.sso ||

View File

@@ -60,7 +60,6 @@ export default async function SitesPage(props: SitesPageProps) {
return { return {
name: site.name, name: site.name,
id: site.siteId, id: site.siteId,
labels: site.labels,
nice: site.niceId.toString(), nice: site.niceId.toString(),
address: site.address?.split("/")[0], address: site.address?.split("/")[0],
mbIn: formatSize(site.megabytesIn || 0, site.type), mbIn: formatSize(site.megabytesIn || 0, site.type),

View File

@@ -23,7 +23,6 @@ import {
Server, Server,
Settings, Settings,
SquareMousePointer, SquareMousePointer,
TagIcon,
TicketCheck, TicketCheck,
Unplug, Unplug,
User, User,
@@ -100,7 +99,7 @@ export const orgNavSections = (
href: "/{orgId}/settings/domains", href: "/{orgId}/settings/domains",
icon: <Globe className="size-4 flex-none" /> icon: <Globe className="size-4 flex-none" />
}, },
...(build === "saas" ...(build == "saas"
? [ ? [
{ {
title: "sidebarRemoteExitNodes", title: "sidebarRemoteExitNodes",
@@ -238,19 +237,10 @@ export const orgNavSections = (
title: "sidebarApiKeys", title: "sidebarApiKeys",
href: "/{orgId}/settings/api-keys", href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" /> icon: <KeyRound className="size-4 flex-none" />
}, }
...(build !== "oss"
? [
{
title: "labels",
href: "/{orgId}/settings/labels",
icon: <TagIcon className="size-4 flex-none" />
}
]
: [])
] ]
}, },
...(build === "saas" && options?.isPrimaryOrg ...(build == "saas" && options?.isPrimaryOrg
? [ ? [
{ {
title: "sidebarBillingAndLicenses", title: "sidebarBillingAndLicenses",

View File

@@ -44,77 +44,11 @@ export type AuthPageCustomizationProps = {
}; };
const AuthPageFormSchema = z.object({ const AuthPageFormSchema = z.object({
logoUrl: z.union([ logoUrl: z
z.literal(""), .string()
z.string().superRefine(async (urlOrPath, ctx) => { .optional()
const parseResult = z.url().safeParse(urlOrPath); .transform((val) => (val === "" ? undefined : val)),
if (!parseResult.success) {
if (build !== "enterprise") {
ctx.addIssue({
code: "custom",
message: "Must be a valid URL"
});
return;
} else {
try {
validateLocalPath(urlOrPath);
} catch (error) {
ctx.addIssue({
code: "custom",
message:
"Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
});
} finally {
return;
}
}
}
try {
const response = await fetch(urlOrPath, {
method: "HEAD"
}).catch(() => {
// If HEAD fails (CORS or method not allowed), try GET
return fetch(urlOrPath, { method: "GET" });
});
if (response.status !== 200) {
ctx.addIssue({
code: "custom",
message: `Failed to load image. Please check that the URL is accessible.`
});
return;
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) {
ctx.addIssue({
code: "custom",
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
});
return;
}
} catch (error) {
let errorMessage =
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
if (
error instanceof TypeError &&
error.message.includes("fetch")
) {
errorMessage =
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
} else if (error instanceof Error) {
errorMessage = `Error verifying URL: ${error.message}`;
}
ctx.addIssue({
code: "custom",
message: errorMessage
});
}
})
]),
logoWidth: z.coerce.number<number>().min(1), logoWidth: z.coerce.number<number>().min(1),
logoHeight: z.coerce.number<number>().min(1), logoHeight: z.coerce.number<number>().min(1),
orgTitle: z.string().optional(), orgTitle: z.string().optional(),

View File

@@ -2,6 +2,7 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard"; import CopyToClipboard from "@app/components/CopyToClipboard";
import { DataTable } from "@app/components/ui/data-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Badge } from "@app/components/ui/badge"; import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
@@ -29,21 +30,13 @@ import {
ChevronDown, ChevronDown,
ChevronsUpDownIcon, ChevronsUpDownIcon,
Funnel, Funnel,
MoreHorizontal, MoreHorizontal
PlusIcon
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Selectedsite, SitesSelector } from "@app/components/site-selector"; import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import { import { useEffect, useMemo, useState, useTransition } from "react";
startTransition,
useEffect,
useMemo,
useOptimistic,
useState,
useTransition
} from "react";
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
import type { PaginationState } from "@tanstack/react-table"; import type { PaginationState } from "@tanstack/react-table";
@@ -60,10 +53,6 @@ import {
} from "@app/components/ResourceSitesStatusCell"; } from "@app/components/ResourceSitesStatusCell";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import { build } from "@server/build"; import { build } from "@server/build";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { LabelBadge } from "./label-badge";
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
export type InternalResourceSiteRow = ResourceSiteRow; export type InternalResourceSiteRow = ResourceSiteRow;
@@ -95,11 +84,6 @@ export type InternalResourceRow = {
subdomain?: string | null; subdomain?: string | null;
domainId?: string | null; domainId?: string | null;
fullDomain?: string | null; fullDomain?: string | null;
labels?: Array<{
labelId: number;
name: string;
color: string;
}>;
}; };
function formatDestinationDisplay(row: InternalResourceRow): string { function formatDestinationDisplay(row: InternalResourceRow): string {
@@ -157,10 +141,7 @@ export default function ClientResourcesTable({
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [siteFilterOpen, setSiteFilterOpen] = useState(false); const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const [isRefreshing, startRefreshTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -186,7 +167,7 @@ export default function ClientResourcesTable({
}, [initialFilterSite, siteIdQ, siteIdNum, t]); }, [initialFilterSite, siteIdQ, siteIdNum, t]);
const refreshData = () => { const refreshData = () => {
startRefreshTransition(() => { startTransition(() => {
try { try {
router.refresh(); router.refresh();
} catch (error) { } catch (error) {
@@ -204,8 +185,8 @@ export default function ClientResourcesTable({
siteId: number siteId: number
) => { ) => {
try { try {
startTransition(async () => { await api.delete(`/site-resource/${resourceId}`).then(() => {
await api.delete(`/site-resource/${resourceId}`).then(() => { startTransition(() => {
router.refresh(); router.refresh();
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
}); });
@@ -273,333 +254,296 @@ export default function ClientResourcesTable({
); );
} }
const internalColumns = useMemo< const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
ExtendedColumnDef<InternalResourceRow>[] {
>(() => { accessorKey: "name",
const cols: ExtendedColumnDef<InternalResourceRow>[] = [ enableHiding: false,
{ friendlyName: t("name"),
accessorKey: "name", header: () => {
enableHiding: false, const nameOrder = getSortDirection("name", searchParams);
friendlyName: t("name"), const Icon =
header: () => { nameOrder === "asc"
const nameOrder = getSortDirection("name", searchParams); ? ArrowDown01Icon
const Icon = : nameOrder === "desc"
nameOrder === "asc" ? ArrowUp10Icon
? ArrowDown01Icon : ChevronsUpDownIcon;
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return ( return (
<Button <Button
variant="ghost" variant="ghost"
className="p-3" className="p-3"
onClick={() => toggleSort("name")} onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
accessorKey: "niceId",
friendlyName: t("identifier"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <span>{row.original.niceId || "-"}</span>;
}
},
{
id: "sites",
accessorFn: (row) =>
row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => (
<Popover
open={siteFilterOpen}
onOpenChange={setSiteFilterOpen}
> >
<PopoverTrigger asChild> {t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
accessorKey: "niceId",
friendlyName: t("identifier"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <span>{row.original.niceId || "-"}</span>;
}
},
{
id: "sites",
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => (
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedSite && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<div className="border-b p-1">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
role="combobox" size="sm"
className={cn( className="h-8 w-full justify-start font-normal"
"justify-between text-sm h-8 px-2 w-full p-3", onClick={clearSiteFilter}
!selectedSite && "text-muted-foreground"
)}
> >
<div className="flex items-center gap-2 min-w-0"> {t("standaloneHcFilterAnySite")}
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
</Button> </Button>
</PopoverTrigger> </div>
<PopoverContent <SitesSelector
className={dataTableFilterPopoverContentClassName} orgId={orgId}
align="start" selectedSite={selectedSite}
> onSelectSite={onPickSite}
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearSiteFilter}
>
{t("standaloneHcFilterAnySite")}
</Button>
</div>
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={onPickSite}
/>
</PopoverContent>
</Popover>
),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<ResourceSitesStatusCell
orgId={resourceRow.orgId}
resourceSites={resourceRow.sites}
/> />
); </PopoverContent>
} </Popover>
}, ),
{ cell: ({ row }) => {
accessorKey: "mode", const resourceRow = row.original;
friendlyName: t("editInternalResourceDialogMode"), return (
header: () => ( <ResourceSitesStatusCell
<ColumnFilterButton orgId={resourceRow.orgId}
options={[ resourceSites={resourceRow.sites}
{
value: "host",
label: t("editInternalResourceDialogModeHost")
},
{
value: "cidr",
label: t("editInternalResourceDialogModeCidr")
},
{
value: "http",
label: t("editInternalResourceDialogModeHttp")
}
]}
selectedValue={searchParams.get("mode") ?? undefined}
onValueChange={(value) =>
handleFilterChange("mode", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("editInternalResourceDialogMode")}
className="p-3"
/> />
), );
cell: ({ row }) => { }
const resourceRow = row.original; },
const modeLabels: Record< {
"host" | "cidr" | "port" | "http", accessorKey: "mode",
string friendlyName: t("editInternalResourceDialogMode"),
> = { header: () => (
host: t("editInternalResourceDialogModeHost"), <ColumnFilterButton
cidr: t("editInternalResourceDialogModeCidr"), options={[
port: t("editInternalResourceDialogModePort"), {
http: t("editInternalResourceDialogModeHttp") value: "host",
}; label: t("editInternalResourceDialogModeHost")
return <span>{modeLabels[resourceRow.mode]}</span>; },
} {
}, value: "cidr",
{ label: t("editInternalResourceDialogModeCidr")
accessorKey: "destination", },
friendlyName: t("resourcesTableDestination"), {
header: () => ( value: "http",
<span className="p-3"> label: t("editInternalResourceDialogModeHttp")
{t("resourcesTableDestination")} }
</span> ]}
), selectedValue={searchParams.get("mode") ?? undefined}
cell: ({ row }) => { onValueChange={(value) => handleFilterChange("mode", value)}
const resourceRow = row.original; searchPlaceholder={t("searchPlaceholder")}
const display = formatDestinationDisplay(resourceRow); emptyMessage={t("emptySearchOptions")}
label={t("editInternalResourceDialogMode")}
className="p-3"
/>
),
cell: ({ row }) => {
const resourceRow = row.original;
const modeLabels: Record<
"host" | "cidr" | "port" | "http",
string
> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort"),
http: t("editInternalResourceDialogModeHttp")
};
return <span>{modeLabels[resourceRow.mode]}</span>;
}
},
{
accessorKey: "destination",
friendlyName: t("resourcesTableDestination"),
header: () => (
<span className="p-3">{t("resourcesTableDestination")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
const display = formatDestinationDisplay(resourceRow);
return (
<CopyToClipboard
text={display}
isLink={false}
displayText={display}
/>
);
}
},
{
accessorKey: "alias",
friendlyName: t("resourcesTableAlias"),
header: () => (
<span className="p-3">{t("resourcesTableAlias")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
if (resourceRow.mode === "host" && resourceRow.alias) {
return ( return (
<CopyToClipboard <CopyToClipboard
text={display} text={resourceRow.alias}
isLink={false} isLink={false}
displayText={display} displayText={resourceRow.alias}
/> />
); );
} }
}, if (resourceRow.mode === "http") {
{ const domainId = resourceRow.domainId;
accessorKey: "alias", const fullDomain = resourceRow.fullDomain;
friendlyName: t("resourcesTableAlias"), const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
header: () => ( const did =
<span className="p-3">{t("resourcesTableAlias")}</span> build !== "oss" &&
), resourceRow.ssl &&
cell: ({ row }) => { domainId != null &&
const resourceRow = row.original; domainId !== "" &&
if (resourceRow.mode === "host" && resourceRow.alias) { fullDomain != null &&
return ( fullDomain !== "";
<CopyToClipboard
text={resourceRow.alias}
isLink={false}
displayText={resourceRow.alias}
/>
);
}
if (resourceRow.mode === "http") {
const domainId = resourceRow.domainId;
const fullDomain = resourceRow.fullDomain;
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
const did =
build !== "oss" &&
resourceRow.ssl &&
domainId != null &&
domainId !== "" &&
fullDomain != null &&
fullDomain !== "";
return (
<div className="flex items-center gap-2 min-w-0">
{did ? (
<ResourceAccessCertIndicator
orgId={resourceRow.orgId}
domainId={domainId}
fullDomain={fullDomain}
/>
) : null}
<div className="">
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
</div>
</div>
);
}
return <span>-</span>;
}
},
{
accessorKey: "aliasAddress",
friendlyName: t("resourcesTableAliasAddress"),
enableHiding: true,
header: () => (
<div className="flex items-center gap-2 p-3">
<span>{t("resourcesTableAliasAddress")}</span>
<InfoPopup info={t("resourcesTableAliasAddressInfo")} />
</div>
),
cell: ({ row }) => {
const resourceRow = row.original;
return resourceRow.aliasAddress ? (
<CopyToClipboard
text={resourceRow.aliasAddress}
isLink={false}
displayText={resourceRow.aliasAddress}
/>
) : (
<span>-</span>
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = row.original;
return ( return (
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 min-w-0">
<DropdownMenu> {did ? (
<DropdownMenuTrigger asChild> <ResourceAccessCertIndicator
<Button orgId={resourceRow.orgId}
variant="ghost" domainId={domainId}
className="h-8 w-8 p-0" fullDomain={fullDomain}
> />
<span className="sr-only"> ) : null}
{t("openMenu")} <div className="">
</span> <CopyToClipboard
<MoreHorizontal className="h-4 w-4" /> text={url}
</Button> isLink={isSafeUrlForLink(url)}
</DropdownMenuTrigger> displayText={url}
<DropdownMenuContent align="end"> />
<DropdownMenuItem </div>
onClick={() => {
setSelectedInternalResource(
resourceRow
);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
setEditingResource(resourceRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
</div> </div>
); );
} }
return <span>-</span>;
} }
]; },
{
if (isLabelFeatureEnabled) { accessorKey: "aliasAddress",
cols.splice(cols.length - 1, 0, { friendlyName: t("resourcesTableAliasAddress"),
id: "labels", enableHiding: true,
accessorKey: "labels", header: () => (
header: () => ( <div className="flex items-center gap-2 p-3">
<span className="p-3 text-end w-full inline-block"> <span>{t("resourcesTableAliasAddress")}</span>
{t("labels")} <InfoPopup info={t("resourcesTableAliasAddressInfo")} />
</span> </div>
), ),
cell: ({ row }: { row: { original: InternalResourceRow } }) => ( cell: ({ row }) => {
<ClientResourceLabelCell const resourceRow = row.original;
resource={row.original} return resourceRow.aliasAddress ? (
orgId={orgId} <CopyToClipboard
text={resourceRow.aliasAddress}
isLink={false}
displayText={resourceRow.aliasAddress}
/> />
) ) : (
}); <span>-</span>
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedInternalResource(
resourceRow
);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
setEditingResource(resourceRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
</div>
);
}
} }
];
return cols;
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
function handleFilterChange( function handleFilterChange(
column: string, column: string,
@@ -694,8 +638,7 @@ export default function ClientResourcesTable({
enableColumnVisibility enableColumnVisibility
columnVisibility={{ columnVisibility={{
niceId: false, niceId: false,
aliasAddress: false, aliasAddress: false
labels: false
}} }}
stickyLeftColumn="name" stickyLeftColumn="name"
stickyRightColumn="actions" stickyRightColumn="actions"
@@ -731,101 +674,3 @@ export default function ClientResourcesTable({
</> </>
); );
} }
type ClientResourceLabelCellProps = {
resource: InternalResourceRow;
orgId: string;
};
function ClientResourceLabelCell({
resource,
orgId
}: ClientResourceLabelCellProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const router = useRouter();
const labels = resource.labels ?? [];
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
function toggleResourceLabel(
label: SelectedLabel,
action: "attach" | "detach"
) {
startTransition(async () => {
try {
if (action === "attach") {
setOptimisticLabels([...optimisticLabels, label]);
await api.put(
`/org/${orgId}/label/${label.labelId}/attach`,
{ siteResourceId: resource.id }
);
} else {
setOptimisticLabels(
optimisticLabels.filter(
(lb) => lb.labelId !== label.labelId
)
);
await api.put(
`/org/${orgId}/label/${label.labelId}/detach`,
{ siteResourceId: resource.id }
);
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
} finally {
router.refresh();
}
});
}
return (
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
{optimisticLabels.slice(0, 3).map((label) => (
<LabelBadge
key={label.labelId}
onClick={() => setIsPopoverOpen(true)}
{...label}
/>
))}
{optimisticLabels.length > 3 && (
<Button
variant="outline"
className={cn(
"inline-flex gap-1 items-center",
"rounded-full text-sm cursor-pointer",
"px-1.5 py-0 h-auto"
)}
onClick={() => setIsPopoverOpen(true)}
>
+{optimisticLabels.length - 3}
</Button>
)}
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
size="icon"
variant="outline"
className="p-1 size-auto rounded-full"
title={t("addLabels")}
>
<span className="sr-only">{t("addLabels")}</span>
<PlusIcon className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent align="center" className="p-0 w-full">
<LabelsSelector
orgId={orgId}
selectedLabels={optimisticLabels}
toggleLabel={toggleResourceLabel}
/>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -16,7 +16,7 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState, useTransition } from "react"; import { useState } from "react";
import { import {
cleanForFQDN, cleanForFQDN,
InternalResourceForm, InternalResourceForm,
@@ -39,30 +39,30 @@ export default function CreateInternalResourceDialog({
}: CreateInternalResourceDialogProps) { }: CreateInternalResourceDialogProps) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [isSubmitting, setIsSubmitting] = useState(false);
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false); const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
const [isSubmitting, startTransition] = useTransition();
function handleSubmit(values: InternalResourceFormValues) { async function handleSubmit(values: InternalResourceFormValues) {
startTransition(async () => { setIsSubmitting(true);
try { try {
let data = { ...values }; let data = { ...values };
if ( if (
(data.mode === "host" || data.mode === "http") && (data.mode === "host" || data.mode === "http") &&
isHostname(data.destination) isHostname(data.destination)
) { ) {
const currentAlias = data.alias?.trim() || ""; const currentAlias = data.alias?.trim() || "";
if (!currentAlias) { if (!currentAlias) {
let aliasValue = data.destination; let aliasValue = data.destination;
if (data.destination.toLowerCase() === "localhost") { if (data.destination.toLowerCase() === "localhost") {
aliasValue = `${cleanForFQDN(data.name)}.internal`; aliasValue = `${cleanForFQDN(data.name)}.internal`;
}
data = { ...data, alias: aliasValue };
} }
data = { ...data, alias: aliasValue };
} }
}
await api.put< await api.put<AxiosResponse<{ data: { siteResourceId: number } }>>(
AxiosResponse<{ data: { siteResourceId: number } }> `/org/${orgId}/site-resource`,
>(`/org/${orgId}/site-resource`, { {
name: data.name, name: data.name,
siteIds: data.siteIds, siteIds: data.siteIds,
mode: data.mode, mode: data.mode,
@@ -106,30 +106,32 @@ export default function CreateInternalResourceDialog({
clientIds: data.clients clientIds: data.clients
? data.clients.map((c) => parseInt(c.id)) ? data.clients.map((c) => parseInt(c.id))
: [] : []
}); }
);
toast({ toast({
title: t("createInternalResourceDialogSuccess"), title: t("createInternalResourceDialogSuccess"),
description: t( description: t(
"createInternalResourceDialogInternalResourceCreatedSuccessfully" "createInternalResourceDialogInternalResourceCreatedSuccessfully"
), ),
variant: "default" variant: "default"
}); });
setOpen(false); setOpen(false);
onSuccess?.(); onSuccess?.();
} catch (error) { } catch (error) {
toast({ toast({
title: t("createInternalResourceDialogError"), title: t("createInternalResourceDialogError"),
description: formatAxiosError( description: formatAxiosError(
error, error,
t( t(
"createInternalResourceDialogFailedToCreateInternalResource" "createInternalResourceDialogFailedToCreateInternalResource"
) )
), ),
variant: "destructive" variant: "destructive"
}); });
} } finally {
}); setIsSubmitting(false);
}
} }
return ( return (

View File

@@ -1,102 +0,0 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "./Credenza";
import { OrgLabelForm } from "./OrgLabelForm";
import { Button } from "./ui/button";
export type CreateOrgLabelDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
orgId: string;
onSuccess?: () => void;
};
export function CreateOrgLabelDialog({
open,
setOpen,
orgId,
onSuccess
}: CreateOrgLabelDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isSubmitting, startTransition] = useTransition();
async function createOrgLabel(data: { name: string; color: string }) {
try {
const res = await api.post<
AxiosResponse<CreateOrEditLabelResponse>
>(`/org/${orgId}/labels`, data);
if (res.status === 201) {
setOpen(false);
onSuccess?.();
toast({
title: t("success"),
description: t("labelCreateSuccessMessage")
});
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
}
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="md:max-w-md">
<CredenzaHeader>
<CredenzaTitle>{t("createLabelDialogTitle")}</CredenzaTitle>
<CredenzaDescription>
{t("createLabelDialogDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<OrgLabelForm
onSubmit={(data) => {
startTransition(async () => createOrgLabel(data));
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="org-label-form"
disabled={isSubmitting}
loading={isSubmitting}
>
{t("labelCreate")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -1,109 +0,0 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "./Credenza";
import { OrgLabelForm } from "./OrgLabelForm";
import { Button } from "./ui/button";
export type EditOrgLabelDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
orgId: string;
onSuccess?: () => void;
label: {
name: string;
color: string;
labelId: number;
};
};
export function EditOrgLabelDialog({
open,
setOpen,
orgId,
onSuccess,
label
}: EditOrgLabelDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isSubmitting, startTransition] = useTransition();
async function editOrgLabel(data: { name: string; color: string }) {
try {
const res = await api.patch<
AxiosResponse<CreateOrEditLabelResponse>
>(`/org/${orgId}/label/${label.labelId}`, data);
if (res.status === 200) {
setOpen(false);
onSuccess?.();
toast({
title: t("success"),
description: t("labelEditSuccessMessage")
});
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
}
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="md:max-w-md">
<CredenzaHeader>
<CredenzaTitle>{t("editLabelDialogTitle")}</CredenzaTitle>
<CredenzaDescription>
{t("editLabelDialogDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<OrgLabelForm
defaultValue={label}
onSubmit={(data) => {
startTransition(async () => editOrgLabel(data));
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="org-label-form"
disabled={isSubmitting}
loading={isSubmitting}
>
{t("labelEdit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -10,11 +10,8 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { import {
ArrowRight, ArrowRight,
ArrowUpDown, ArrowUpDown,
@@ -22,26 +19,12 @@ import {
CircleSlash, CircleSlash,
ArrowDown01Icon, ArrowDown01Icon,
ArrowUp10Icon, ArrowUp10Icon,
ChevronsUpDownIcon, ChevronsUpDownIcon
PlusIcon
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import { useMemo, useState, useTransition } from "react";
startTransition,
useMemo,
useOptimistic,
useState,
useTransition
} from "react";
import { LabelBadge } from "./label-badge";
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "./ui/popover";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import type { PaginationState } from "@tanstack/react-table"; import type { PaginationState } from "@tanstack/react-table";
import { ControlledDataTable } from "./ui/controlled-data-table"; import { ControlledDataTable } from "./ui/controlled-data-table";
@@ -70,11 +53,6 @@ export type ClientRow = {
archived?: boolean; archived?: boolean;
blocked?: boolean; blocked?: boolean;
approvalState: "approved" | "pending" | "denied"; approvalState: "approved" | "pending" | "denied";
labels?: Array<{
labelId: number;
name: string;
color: string;
}>;
}; };
type ClientTableProps = { type ClientTableProps = {
@@ -106,21 +84,17 @@ export default function MachineClientsTable({
); );
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [isRefreshing, startRefreshTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition();
const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
const defaultMachineColumnVisibility = { const defaultMachineColumnVisibility = {
subnet: false, subnet: false,
userId: false, userId: false,
niceId: false, niceId: false
labels: false
}; };
const refreshData = () => { const refreshData = () => {
startRefreshTransition(() => { startTransition(() => {
try { try {
router.refresh(); router.refresh();
} catch (error) { } catch (error) {
@@ -410,24 +384,6 @@ export default function MachineClientsTable({
} }
]; ];
if (isLabelFeatureEnabled) {
baseColumns.push({
id: "labels",
accessorKey: "labels",
header: () => (
<span className="p-3 text-end w-full inline-block">
{t("labels")}
</span>
),
cell: ({ row }: { row: { original: ClientRow } }) => (
<MachineClientLabelCell
client={row.original}
orgId={orgId}
/>
)
});
}
// Only include actions column if there are rows without userIds // Only include actions column if there are rows without userIds
if (hasRowsWithoutUserId) { if (hasRowsWithoutUserId) {
baseColumns.push({ baseColumns.push({
@@ -508,7 +464,7 @@ export default function MachineClientsTable({
} }
return baseColumns; return baseColumns;
}, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]); }, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]);
const booleanSearchFilterSchema = z const booleanSearchFilterSchema = z
.enum(["true", "false"]) .enum(["true", "false"])
@@ -635,95 +591,3 @@ export default function MachineClientsTable({
</> </>
); );
} }
type MachineClientLabelCellProps = {
client: ClientRow;
orgId: string;
};
function MachineClientLabelCell({ client, orgId }: MachineClientLabelCellProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const router = useRouter();
const labels = client.labels ?? [];
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
function toggleClientLabel(label: SelectedLabel, action: "attach" | "detach") {
startTransition(async () => {
try {
if (action === "attach") {
setOptimisticLabels([...optimisticLabels, label]);
await api.put(
`/org/${orgId}/label/${label.labelId}/attach`,
{ clientId: client.id }
);
} else {
setOptimisticLabels(
optimisticLabels.filter(
(lb) => lb.labelId !== label.labelId
)
);
await api.put(
`/org/${orgId}/label/${label.labelId}/detach`,
{ clientId: client.id }
);
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
} finally {
router.refresh();
}
});
}
return (
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
{optimisticLabels.slice(0, 3).map((label) => (
<LabelBadge
key={label.labelId}
onClick={() => setIsPopoverOpen(true)}
{...label}
/>
))}
{optimisticLabels.length > 3 && (
<Button
variant="outline"
className={cn(
"inline-flex gap-1 items-center",
"rounded-full text-sm cursor-pointer",
"px-1.5 py-0 h-auto"
)}
onClick={() => setIsPopoverOpen(true)}
>
+{optimisticLabels.length - 3}
</Button>
)}
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
size="icon"
variant="outline"
className="p-1 size-auto rounded-full"
title={t("addLabels")}
>
<span className="sr-only">{t("addLabels")}</span>
<PlusIcon className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent align="center" className="p-0 w-full">
<LabelsSelector
orgId={orgId}
selectedLabels={optimisticLabels}
toggleLabel={toggleClientLabel}
/>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -1,126 +0,0 @@
"use client";
import z from "zod";
import { Input } from "./ui/input";
import { useTranslations } from "use-intl";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "./ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "./ui/select";
import { LABEL_COLORS } from "./labels-selector";
const labelFormSchema = z.object({
name: z.string().nonempty(),
color: z
.string()
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
.nonempty()
});
export type LabelFormData = z.infer<typeof labelFormSchema>;
export type OrgLabelFormProps = {
onSubmit: (data: LabelFormData) => void;
defaultValue?: LabelFormData;
};
export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
const t = useTranslations();
const colorValues = Object.values(LABEL_COLORS);
const randomColor =
colorValues[Math.floor(Math.random() * colorValues.length)];
const form = useForm({
resolver: zodResolver(labelFormSchema),
defaultValues: {
name: defaultValue?.name ?? "",
color: defaultValue?.color ?? randomColor
}
});
return (
<Form {...form}>
<form
id="org-label-form"
className="flex flex-col gap-4 px-0.5"
action={async () => {
if (await form.trigger()) {
onSubmit(form.getValues());
}
}}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("labelNameField")}</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t("labelPlaceholder")}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="color"
render={({ field }) => (
<FormItem>
<FormLabel>{t("labelColorField")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("selectColor")}
/>
</SelectTrigger>
<SelectContent>
{Object.entries(LABEL_COLORS).map(
([color, value]) => (
<SelectItem
value={value}
key={color}
className="flex items-center gap-2"
>
<div
className="size-4 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": value
}}
/>
<span data-name>{color}</span>
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}

View File

@@ -1,240 +0,0 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { type PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
ArrowUp10Icon,
ChevronsUpDownIcon,
MoreHorizontal,
PencilIcon,
PencilLineIcon
} from "lucide-react";
import { useTranslations } from "next-intl";
import { usePathname, useRouter } from "next/navigation";
import { useActionState, useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "./ui/controlled-data-table";
import { LabelBadge } from "./label-badge";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { cn } from "@app/lib/cn";
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
import { CreateOrgLabelDialog } from "./CreateOrgLabelDialog";
import { EditOrgLabelDialog } from "./EditOrgLabelDialog";
export type LabelRow = {
labelId: number;
name: string;
color: string;
};
type OrgLabelsTableProps = {
labels: LabelRow[];
pagination: PaginationState;
orgId: string;
rowCount: number;
};
export default function OrgLabelsTable({
labels,
orgId,
pagination,
rowCount
}: OrgLabelsTableProps) {
const router = useRouter();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [selectedLabel, setSelectedLabel] = useState<LabelRow | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isRefreshing, startTransition] = useTransition();
const api = createApiClient(useEnvContext());
const t = useTranslations();
function refreshData() {
startTransition(async () => {
try {
router.refresh();
} catch {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({ searchParams });
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({ searchParams });
}, 300);
const columns = useMemo<ExtendedColumnDef<LabelRow>[]>(
() => [
{
accessorKey: "name",
enableHiding: false,
header: () => {
return <span className="p-3">{t("name")}</span>;
},
cell: ({ row }) => (
<div className="flex items-center gap-1.5 group">
<div
className="size-2.5 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": row.original.color
}}
/>
{row.original.name}
</div>
)
},
{
accessorKey: "actions",
enableHiding: false,
header: () => {
return <span className="p-3">{t("actions")}</span>;
},
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{t("openMenu")}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedLabel(row.original);
setIsEditModalOpen(true);
}}
>
{t("edit")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedLabel(row.original);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
],
[searchParams, t]
);
function deleteLabel(label: LabelRow) {
startTransition(async () => {
await api
.delete(`/org/${orgId}/label/${label.labelId}`)
.catch((e) => {
toast({
variant: "destructive",
title: t("labelErrorDelete"),
description: formatAxiosError(e, t("labelErrorDelete"))
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
}
return (
<>
{selectedLabel && (
<>
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedLabel(null);
}}
dialog={
<div className="space-y-2">
<p>{t("labelQuestionRemove")}</p>
<p>{t("labelMessageRemove")}</p>
</div>
}
buttonText={t("labelDeleteConfirm")}
onConfirm={async () => deleteLabel(selectedLabel)}
string={selectedLabel.name}
title={t("labelDelete")}
/>
<EditOrgLabelDialog
open={isEditModalOpen}
setOpen={setIsEditModalOpen}
orgId={orgId}
onSuccess={() =>
startTransition(() => router.refresh())
}
label={selectedLabel}
/>
</>
)}
<CreateOrgLabelDialog
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
orgId={orgId}
onSuccess={() => startTransition(() => router.refresh())}
/>
<ControlledDataTable
columns={columns}
rows={labels}
addButtonText={t("labelAdd")}
onAdd={() => setIsCreateModalOpen(true)}
tableId="org-labels-table"
searchPlaceholder={t("labelSearch")}
pagination={pagination}
onPaginationChange={handlePaginationChange}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
rowCount={rowCount}
/>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More