Compare commits

...

61 Commits

Author SHA1 Message Date
dependabot[bot] 774a0730b9 Bump node in the docker-dependencies group across 1 directory
Bumps the docker-dependencies group with 1 update in the / directory: node.


Updates `node` from 24.18.1-alpine to 26.8.1-alpine

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26.8.1-alpine
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: docker-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 01:34:28 +00:00
Owen Schwartz d6d923e972 Merge pull request #3628 from aithal007/perf/add-fk-indexes
perf: add secondary FK indexes to core OSS schemas
2026-08-31 17:05:04 -04:00
Parikshith 41139f2fd0 perf: add secondary FK indexes to core OSS schemas
Add 13 secondary indexes across 10 tables in the SQLite schema and
5 missing indexes to the PostgreSQL schema.

All list endpoints (listSites, listResources, listClients, listRoles,
listTargets, listUserOrgs) filter and join on these FK columns. In the
SQLite schema, no secondary indexes existed at all on these foreign keys,
forcing full sequential scans on every paginated request and session lookup.

SQLite changes (13 new indexes):
  - sites.orgId
  - resources.orgId
  - targets.resourceId, targets.siteId
  - newt.siteId
  - clients.orgId, clients.userId
  - labels.orgId
  - olms.userId
  - session.userId
  - userOrgs.userId, userOrgs.orgId
  - roles.orgId

PG changes (5 new indexes, rest already present):
  - labels.orgId
  - session.userId
  - userOrgs.userId, userOrgs.orgId
  - roles.orgId
  - olms.userId
2026-08-31 17:04:01 -04:00
Owen Schwartz ebcdeab414 Merge pull request #3597 from shleeable/patch-11
Fix: License.check can fail silently
2026-08-31 17:02:53 -04:00
Owen 2cc7d03ace Update to fall back but still use cache 2026-08-31 17:02:03 -04:00
Shlee 34671c6b13 Update license.ts 2026-08-31 16:57:36 -04:00
Owen Schwartz 8dfc95347f Merge pull request #3655 from moverq1337/fix/access-token-delete-root-key
Fix access token deletion for root API keys
2026-08-31 16:54:18 -04:00
Owen Schwartz 0dece5fef1 Merge pull request #3665 from fosrl/dev
Fix domain namespaces and country is not in blueprints
2026-08-31 11:03:06 -04:00
Owen e7f38c089f Merge branch 'main' into dev 2026-08-31 11:02:01 -04:00
miloschwartz dd0a5a359a check for namespace domain before blocking org check 2026-08-31 10:59:50 -04:00
moverq1337 1650ece0c3 Fix root API key access in verifyApiKeyAccessTokenAccess 2026-08-28 22:45:31 +03:00
Owen Schwartz bc56a2bed0 Merge pull request #3647 from argueta-xyz/country-is-not-rule-via-blueprint
Allow COUNTRY_IS_NOT rules to be created via Blueprints
2026-08-28 10:52:24 -04:00
Owen Schwartz 49dcc590ce Merge pull request #3651 from fosrl/dev
Update readme
2026-08-28 10:19:58 -04:00
Owen da3e3ff33f Fix typo 2026-08-28 10:19:35 -04:00
Owen Schwartz 69d539f107 Merge pull request #3650 from fosrl/dev
Update readme, tel, and fix EE feature flag
2026-08-28 09:55:40 -04:00
Owen 872e0f9ae1 Merge branch 'main' into dev 2026-08-28 09:54:46 -04:00
Owen 5b3713a72f Update readme 2026-08-28 09:54:07 -04:00
Owen Schwartz f02be1fdbf Merge pull request #3649 from fosrl/dependabot/npm_and_yarn/multi-2e40a8c091
Bump ws and socket.io-adapter
2026-08-28 09:21:25 -04:00
dependabot[bot] 0bf04cf0cd Bump ws and socket.io-adapter
Bumps [ws](https://github.com/websockets/ws) and [socket.io-adapter](https://github.com/socketio/socket.io). These dependencies needed to be updated together.

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

Updates `socket.io-adapter` from 2.5.6 to 2.5.8
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-adapter@2.5.6...socket.io-adapter@2.5.8)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.21.0
  dependency-type: indirect
- dependency-name: socket.io-adapter
  dependency-version: 2.5.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 13:12:35 +00:00
Alejandro Argueta 7cda28d685 Update RuleSchema to allow COUNTRY_IS_NOT rules to be created via blueprints as well. 2026-08-27 14:18:48 -07:00
Owen 60bc74c4df Add AI provider and usage metrics to telemetry data collection 2026-08-27 16:54:04 -04:00
Owen 48ab6c501f Fix #3646 2026-08-27 16:37:53 -04:00
Milo Schwartz 7a95e543d8 Merge pull request #3645 from fosrl/dev
Dev
2026-08-27 14:53:42 -04:00
Owen b87b7c7e80 Fix timestamp handling in AI session logs to use seconds instead of milliseconds 2026-08-27 14:26:47 -04:00
miloschwartz a47a68d8e1 update badger version 2026-08-27 11:40:46 -04:00
Owen Schwartz ed0d6fb6b9 Merge pull request #3644 from fosrl/dev
1.22.0-s.2
2026-08-26 11:30:38 -04:00
Owen Schwartz 5bdb12dafe Merge pull request #3643 from fosrl/dev
1.22.0-s.1
2026-08-26 10:53:37 -04:00
Owen Schwartz f3474dac98 Merge pull request #3641 from fosrl/dev
1.22.0
2026-08-25 17:19:46 -04:00
Owen Schwartz 7319bf84f7 Merge pull request #3481 from ThanatosDi/feat/zh-tw-for-1.21.0
feat: translate zh-tw for 1.21.0
2026-08-24 11:04:08 -04:00
Owen Schwartz 9ec9908ed7 Merge pull request #3509 from shubhamsinnh/codex/fix-zh-tw-language-detection
Fix regional locale detection
2026-08-24 11:03:09 -04:00
Milo Schwartz fd0a0818c1 Merge pull request #3617 from fosrl/dev
Dev
2026-08-19 16:41:48 -04:00
Milo Schwartz 929acc5b1c Merge pull request #3614 from fosrl/dev
Dev
2026-08-19 12:05:12 -04:00
Milo Schwartz 71348f45b2 Merge pull request #3611 from fosrl/dev
1.22.0
2026-08-19 11:26:08 -04:00
Milo Schwartz 21eb4d2876 Merge pull request #3598 from shleeable/patch-12
Fix: ensuring only internal users can request password resets.
2026-08-19 10:35:52 -04:00
Owen Schwartz 18270381c1 Merge pull request #3607 from fosrl/dependabot/npm_and_yarn/multi-ff8097116b
Bump sharp, next and @react-email/ui
2026-08-18 17:21:25 -04:00
dependabot[bot] 8e938a2723 Bump sharp, next and @react-email/ui
Bumps [sharp](https://github.com/lovell/sharp) to 0.35.3 and updates ancestor dependencies [sharp](https://github.com/lovell/sharp), [next](https://github.com/vercel/next.js) and [@react-email/ui](https://github.com/resend/react-email/tree/HEAD/packages/ui). These dependencies need to be updated together.


Updates `sharp` from 0.34.5 to 0.35.3
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.3)

Updates `next` from 16.2.11 to 16.3.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.11...v16.3.1)

Updates `@react-email/ui` from 6.5.0 to 6.9.2
- [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.9.2/packages/ui)

---
updated-dependencies:
- dependency-name: sharp
  dependency-version: 0.35.3
  dependency-type: indirect
- dependency-name: next
  dependency-version: 16.3.1
  dependency-type: direct:production
- dependency-name: "@react-email/ui"
  dependency-version: 6.9.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-18 16:26:28 +00:00
Owen Schwartz 197f8f7ba5 Merge pull request #3503 from fosrl/dependabot/npm_and_yarn/engine.io-6.6.9
Bump engine.io from 6.6.7 to 6.6.9
2026-08-18 12:25:27 -04:00
Owen Schwartz 10b528642d Merge pull request #3517 from fosrl/dependabot/github_actions/github-actions-dependencies-7eb9e48ea9
Bump the github-actions-dependencies group across 1 directory with 2 updates
2026-08-18 12:24:54 -04:00
Owen Schwartz 60d6fff085 Merge pull request #3525 from fosrl/dependabot/npm_and_yarn/ip-address-10.4.0
Bump ip-address from 10.2.0 to 10.4.0
2026-08-18 12:24:30 -04:00
Owen Schwartz c664b3da91 Merge pull request #3526 from fosrl/dependabot/npm_and_yarn/multi-5e81c1b34f
Bump brace-expansion
2026-08-18 12:24:17 -04:00
Owen Schwartz 492282e758 Merge pull request #3530 from fosrl/dependabot/npm_and_yarn/socket.io-parser-4.2.7
Bump socket.io-parser from 4.2.6 to 4.2.7
2026-08-18 12:24:03 -04:00
Owen Schwartz 47f4aefc25 Merge pull request #3531 from fosrl/dependabot/npm_and_yarn/fast-uri-3.1.5
Bump fast-uri from 3.1.4 to 3.1.5
2026-08-18 12:23:43 -04:00
Owen Schwartz 7c0ff9ede7 Merge pull request #3569 from fosrl/dependabot/npm_and_yarn/js-yaml-4.3.1
Bump js-yaml from 4.3.0 to 4.3.1
2026-08-18 12:23:32 -04:00
Owen Schwartz 44e81ea979 Merge pull request #3570 from fosrl/dependabot/npm_and_yarn/nanoid-3.3.18
Bump nanoid from 3.3.12 to 3.3.18
2026-08-18 12:23:17 -04:00
Owen Schwartz 56dc10330a Merge pull request #3572 from fosrl/dependabot/npm_and_yarn/postcss-8.5.23
Bump postcss from 8.5.15 to 8.5.23
2026-08-18 12:23:03 -04:00
dependabot[bot] eb8ad6a181 Bump the github-actions-dependencies group across 1 directory with 2 updates
Bumps the github-actions-dependencies group with 2 updates in the / directory: [docker/login-action](https://github.com/docker/login-action) and [actions/stale](https://github.com/actions/stale).


Updates `docker/login-action` from 4.5.1 to 4.6.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/abd2ef45e78c5afb21d64d4ca52ee8550d9572c7...dbcb813823bdd20940b903addbd779551569679f)

Updates `actions/stale` from 10.4.0 to 11.0.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/1e223db275d687790206a7acac4d1a11bd6fe629...4391f3da665fdf50b6810c1a66712fb9ba21aa93)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 11.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-dependencies
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 01:34:14 +00:00
Shlee 4edd2e4d32 Update requestPasswordReset.ts 2026-08-16 22:21:37 +09:30
Shlee 813c3abe54 Update requestPasswordReset.ts 2026-08-16 22:03:00 +09:30
Owen Schwartz 048e4fc73c Merge pull request #3574 from fosrl/dev
1.21.1-s.5
2026-08-12 16:34:31 -04:00
dependabot[bot] 923371e5b4 Bump postcss from 8.5.15 to 8.5.23
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.15 to 8.5.23.
- [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.15...8.5.23)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.23
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-12 02:57:16 +00:00
dependabot[bot] 81430ba3d3 Bump nanoid from 3.3.12 to 3.3.18
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.12 to 3.3.18.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/3.3.18/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.12...3.3.18)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 3.3.18
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 23:54:36 +00:00
dependabot[bot] e4aaadc9f9 Bump js-yaml from 4.3.0 to 4.3.1
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.0 to 4.3.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.1/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.0...4.3.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 20:19:18 +00:00
Owen Schwartz d04740fede Merge pull request #3537 from fosrl/dev
1.21.1-s.4
2026-08-06 14:07:42 -04:00
dependabot[bot] 4677a0d501 Bump fast-uri from 3.1.4 to 3.1.5
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-05 18:18:41 +00:00
dependabot[bot] b4463f0e1a Bump socket.io-parser from 4.2.6 to 4.2.7
Bumps [socket.io-parser](https://github.com/socketio/socket.io) from 4.2.6 to 4.2.7.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-parser@4.2.6...socket.io-parser@4.2.7)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-version: 4.2.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-05 06:42:16 +00:00
Owen Schwartz b7c0669c38 Merge pull request #3528 from fosrl/dev
1.21.1-s.3
2026-08-04 17:46:49 -04:00
dependabot[bot] 152d2fb1d6 Bump brace-expansion
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 5.0.6 to 5.0.9
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.9)

Updates `brace-expansion` from 1.1.14 to 1.1.18
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.9)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.9
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 1.1.18
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 05:33:39 +00:00
dependabot[bot] d374b4f66e Bump ip-address from 10.2.0 to 10.4.0
Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.2.0 to 10.4.0.
- [Release notes](https://github.com/beaugunderson/ip-address/releases)
- [Commits](https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.4.0)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 01:45:48 +00:00
Shubham Singh cb3f0b49a8 Fix regional locale detection 2026-07-30 14:29:25 +05:30
dependabot[bot] 192542629f Bump engine.io from 6.6.7 to 6.6.9
Bumps [engine.io](https://github.com/socketio/socket.io) from 6.6.7 to 6.6.9.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/engine.io@6.6.7...engine.io@6.6.9)

---
updated-dependencies:
- dependency-name: engine.io
  dependency-version: 6.6.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 21:59:23 +00:00
ThanatosDi 84d5a4b86c feat: translate zh-tw for 1.21.0 2026-07-23 14:40:42 +08:00
23 changed files with 3263 additions and 1479 deletions
+4 -4
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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with: with:
days-before-stale: 14 days-before-stale: 14
days-before-close: 14 days-before-close: 14
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.18.1-alpine FROM node:26.8.1-alpine
WORKDIR /app WORKDIR /app
+13
View File
@@ -99,6 +99,19 @@ Access private resources like SSH servers, databases, RDP, and entire network ra
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" /> <img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
### Identity-aware AI gateway
Put an identity-aware proxy in front of public cloud (OpenAI, Anthropic, Gemini, etc.) and self-hosted model servers (Ollama, vLLM, Mistral, etc.) so coding agents and AI clients call a single Pangolin URL. Publish it as a public resource with personal API keys, or keep it private on a client tunnel where the connected client is the credential for keyless access. Budgets, session history, and usage analytics sit in front of every call.
* Access self-hosted models (vLLM, Ollama, etc) alongside cloud models (OpenAI, Anthropic, etc) in one place
* Keyless access by authenticating users with the Pangolin desktop client
* Or, provide users with personal API keys
* Control costs and token usage by setting budgets
* Audit with detailed session history and analytics
* Integrate AI clients and coding agents (Claude Code, Codex, OpenCode, etc)
<img src="public/screenshots/expanded-session-logs.png" alt="AI Session Logs" width="100%" />
### Give users and roles access to resources ### Give users and roles access to resources
Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define. Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
+2213 -788
View File
File diff suppressed because it is too large Load Diff
+394 -307
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -94,12 +94,12 @@
"input-otp": "1.4.2", "input-otp": "1.4.2",
"ioredis": "5.11.0", "ioredis": "5.11.0",
"jmespath": "0.16.0", "jmespath": "0.16.0",
"js-yaml": "4.3.0", "js-yaml": "4.3.1",
"jsonwebtoken": "9.0.3", "jsonwebtoken": "9.0.3",
"lucide-react": "1.17.0", "lucide-react": "1.17.0",
"maxmind": "5.0.6", "maxmind": "5.0.6",
"moment": "2.30.1", "moment": "2.30.1",
"next": "16.2.11", "next": "16.3.1",
"next-intl": "4.13.0", "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",
@@ -139,7 +139,7 @@
"devDependencies": { "devDependencies": {
"@dotenvx/dotenvx": "1.69.1", "@dotenvx/dotenvx": "1.69.1",
"@esbuild-plugins/tsconfig-paths": "0.1.2", "@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/ui": "^6.5.0", "@react-email/ui": "^6.9.2",
"@tailwindcss/postcss": "4.3.0", "@tailwindcss/postcss": "4.3.0",
"@tanstack/react-query-devtools": "5.100.14", "@tanstack/react-query-devtools": "5.100.14",
"@types/better-sqlite3": "7.6.13", "@types/better-sqlite3": "7.6.13",
@@ -170,7 +170,7 @@
"esbuild-node-externals": "1.22.0", "esbuild-node-externals": "1.22.0",
"eslint": "10.4.0", "eslint": "10.4.0",
"eslint-config-next": "16.2.6", "eslint-config-next": "16.2.6",
"postcss": "8.5.15", "postcss": "8.5.23",
"prettier": "3.8.3", "prettier": "3.8.3",
"react-email": "6.5.0", "react-email": "6.5.0",
"tailwindcss": "4.3.0", "tailwindcss": "4.3.0",
@@ -182,6 +182,6 @@
"overrides": { "overrides": {
"esbuild": "0.28.0", "esbuild": "0.28.0",
"dompurify": "3.4.0", "dompurify": "3.4.0",
"postcss": "8.5.15" "postcss": "8.5.23"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

+71 -49
View File
@@ -262,16 +262,20 @@ export const resourceAiModels = pgTable(
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })] (t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
); );
export const labels = pgTable("labels", { export const labels = pgTable(
labelId: serial("labelId").primaryKey(), "labels",
name: varchar("name").notNull(), {
color: varchar("color").notNull(), labelId: serial("labelId").primaryKey(),
orgId: varchar("orgId") name: varchar("name").notNull(),
.references(() => orgs.orgId, { color: varchar("color").notNull(),
onDelete: "cascade" orgId: varchar("orgId")
}) .references(() => orgs.orgId, {
.notNull() onDelete: "cascade"
}); })
.notNull()
},
(t) => [index("idx_labels_orgid").on(t.orgId)]
);
export const launcherViews = pgTable("launcherViews", { export const launcherViews = pgTable("launcherViews", {
viewId: serial("viewId").primaryKey(), viewId: serial("viewId").primaryKey(),
@@ -693,15 +697,19 @@ export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", {
codeHash: varchar("codeHash").notNull() codeHash: varchar("codeHash").notNull()
}); });
export const sessions = pgTable("session", { export const sessions = pgTable(
sessionId: varchar("id").primaryKey(), "session",
userId: varchar("userId") {
.notNull() sessionId: varchar("id").primaryKey(),
.references(() => users.userId, { onDelete: "cascade" }), userId: varchar("userId")
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), .notNull()
issuedAt: bigint("issuedAt", { mode: "number" }), .references(() => users.userId, { onDelete: "cascade" }),
deviceAuthUsed: boolean("deviceAuthUsed").notNull().default(false) expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
}); issuedAt: bigint("issuedAt", { mode: "number" }),
deviceAuthUsed: boolean("deviceAuthUsed").notNull().default(false)
},
(t) => [index("idx_sessions_userid").on(t.userId)]
);
export const newtSessions = pgTable("newtSession", { export const newtSessions = pgTable("newtSession", {
sessionId: varchar("id").primaryKey(), sessionId: varchar("id").primaryKey(),
@@ -711,19 +719,26 @@ export const newtSessions = pgTable("newtSession", {
expiresAt: bigint("expiresAt", { mode: "number" }).notNull() expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
}); });
export const userOrgs = pgTable("userOrgs", { export const userOrgs = pgTable(
userId: varchar("userId") "userOrgs",
.notNull() {
.references(() => users.userId, { onDelete: "cascade" }), userId: varchar("userId")
orgId: varchar("orgId") .notNull()
.references(() => orgs.orgId, { .references(() => users.userId, { onDelete: "cascade" }),
onDelete: "cascade" orgId: varchar("orgId")
}) .references(() => orgs.orgId, {
.notNull(), onDelete: "cascade"
isOwner: boolean("isOwner").notNull().default(false), })
autoProvisioned: boolean("autoProvisioned").default(false), .notNull(),
pamUsername: varchar("pamUsername") // cleaned username for ssh and such isOwner: boolean("isOwner").notNull().default(false),
}); autoProvisioned: boolean("autoProvisioned").default(false),
pamUsername: varchar("pamUsername") // cleaned username for ssh and such
},
(t) => [
index("idx_userOrgs_userid").on(t.userId),
index("idx_userOrgs_orgid").on(t.orgId)
]
);
export const emailVerificationCodes = pgTable("emailVerificationCodes", { export const emailVerificationCodes = pgTable("emailVerificationCodes", {
codeId: serial("id").primaryKey(), codeId: serial("id").primaryKey(),
@@ -751,22 +766,26 @@ export const actions = pgTable("actions", {
description: varchar("description") description: varchar("description")
}); });
export const roles = pgTable("roles", { export const roles = pgTable(
roleId: serial("roleId").primaryKey(), "roles",
orgId: varchar("orgId") {
.references(() => orgs.orgId, { roleId: serial("roleId").primaryKey(),
onDelete: "cascade" orgId: varchar("orgId")
}) .references(() => orgs.orgId, {
.notNull(), onDelete: "cascade"
isAdmin: boolean("isAdmin"), })
name: varchar("name").notNull(), .notNull(),
description: varchar("description"), isAdmin: boolean("isAdmin"),
requireDeviceApproval: boolean("requireDeviceApproval").default(false), name: varchar("name").notNull(),
sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("full"), // "none" | "full" | "commands" description: varchar("description"),
sshSudoCommands: text("sshSudoCommands").default("[]"), requireDeviceApproval: boolean("requireDeviceApproval").default(false),
sshCreateHomeDir: boolean("sshCreateHomeDir").default(true), sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("full"), // "none" | "full" | "commands"
sshUnixGroups: text("sshUnixGroups").default("[]") sshSudoCommands: text("sshSudoCommands").default("[]"),
}); sshCreateHomeDir: boolean("sshCreateHomeDir").default(true),
sshUnixGroups: text("sshUnixGroups").default("[]")
},
(t) => [index("idx_roles_orgid").on(t.orgId)]
);
export const userOrgRoles = pgTable( export const userOrgRoles = pgTable(
"userOrgRoles", "userOrgRoles",
@@ -1409,7 +1428,10 @@ export const olms = pgTable(
}), }),
archived: boolean("archived").notNull().default(false) archived: boolean("archived").notNull().default(false)
}, },
(t) => [index("idx_olms_clientid").on(t.clientId)] (t) => [
index("idx_olms_clientid").on(t.clientId),
index("idx_olms_userid").on(t.userId)
]
); );
export const currentFingerprint = pgTable("currentFingerprint", { export const currentFingerprint = pgTable("currentFingerprint", {
+336 -267
View File
@@ -99,131 +99,147 @@ export const orgDomains = sqliteTable("orgDomains", {
.references(() => domains.domainId, { onDelete: "cascade" }) .references(() => domains.domainId, { onDelete: "cascade" })
}); });
export const sites = sqliteTable("sites", { export const sites = sqliteTable(
siteId: integer("siteId").primaryKey({ autoIncrement: true }), "sites",
orgId: text("orgId") {
.references(() => orgs.orgId, { siteId: integer("siteId").primaryKey({ autoIncrement: true }),
onDelete: "cascade" orgId: text("orgId")
}) .references(() => orgs.orgId, {
.notNull(), onDelete: "cascade"
niceId: text("niceId").notNull(), })
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { .notNull(),
onDelete: "set null" niceId: text("niceId").notNull(),
}), exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
networkId: integer("networkId").references(() => networks.networkId, { onDelete: "set null"
onDelete: "set null" }),
}), networkId: integer("networkId").references(() => networks.networkId, {
name: text("name").notNull(), onDelete: "set null"
pubKey: text("pubKey"), }),
exitNodeSubnet: text("exitNodeSubnet"), name: text("name").notNull(),
megabytesIn: integer("bytesIn").default(0), pubKey: text("pubKey"),
megabytesOut: integer("bytesOut").default(0), exitNodeSubnet: text("exitNodeSubnet"),
lastBandwidthUpdate: text("lastBandwidthUpdate"), megabytesIn: integer("bytesIn").default(0),
type: text("type").notNull(), // "newt" or "wireguard" megabytesOut: integer("bytesOut").default(0),
online: integer("online", { mode: "boolean" }).notNull().default(false), lastBandwidthUpdate: text("lastBandwidthUpdate"),
lastPing: integer("lastPing"), type: text("type").notNull(), // "newt" or "wireguard"
online: integer("online", { mode: "boolean" }).notNull().default(false),
lastPing: integer("lastPing"),
// exit node stuff that is how to connect to the site when it has a wg server // exit node stuff that is how to connect to the site when it has a wg server
address: text("address"), // this is the address of the wireguard interface in newt address: text("address"), // this is the address of the wireguard interface in newt
endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config
localEndpoints: text("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to localEndpoints: text("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to
publicKey: text("publicKey"), // TODO: Fix typo in publicKey publicKey: text("publicKey"), // TODO: Fix typo in publicKey
lastHolePunch: integer("lastHolePunch"), lastHolePunch: integer("lastHolePunch"),
listenPort: integer("listenPort"), listenPort: integer("listenPort"),
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
.notNull() .notNull()
.default(true), .default(true),
autoUpdateEnabled: integer("autoUpdateEnabled", { mode: "boolean" }) autoUpdateEnabled: integer("autoUpdateEnabled", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
autoUpdateOverrideOrg: integer("autoUpdateOverrideOrg", { autoUpdateOverrideOrg: integer("autoUpdateOverrideOrg", {
mode: "boolean" mode: "boolean"
})
.notNull()
.default(false),
status: text("status").$type<"pending" | "approved">().default("approved")
});
export const resources = sqliteTable("resources", {
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
resourcePolicyId: integer("resourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{ onDelete: "set null" }
),
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{
onDelete: "restrict"
}
),
resourceGuid: text("resourceGuid", { length: 36 })
.unique()
.notNull()
.$defaultFn(() => randomUUID()),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
}) })
.notNull(), .notNull()
niceId: text("niceId").notNull(), .default(false),
name: text("name").notNull(), status: text("status")
subdomain: text("subdomain"), .$type<"pending" | "approved">()
fullDomain: text("fullDomain"), .default("approved")
domainId: text("domainId").references(() => domains.domainId, { },
onDelete: "set null" (table) => [
}), index("idx_sites_orgId").on(table.orgId)
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), ]
blockAccess: integer("blockAccess", { mode: "boolean" }) );
.notNull()
.default(false), export const resources = sqliteTable(
proxyPort: integer("proxyPort"), "resources",
sso: integer("sso", { mode: "boolean" }), {
emailWhitelistEnabled: integer("emailWhitelistEnabled", { resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
mode: "boolean" resourcePolicyId: integer("resourcePolicyId").references(
}), () => resourcePolicies.resourcePolicyId,
applyRules: integer("applyRules", { mode: "boolean" }), { onDelete: "set null" }
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), ),
stickySession: integer("stickySession", { mode: "boolean" }) defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
.notNull() () => resourcePolicies.resourcePolicyId,
.default(false), {
tlsServerName: text("tlsServerName"), onDelete: "restrict"
setHostHeader: text("setHostHeader"), }
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true), ),
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { resourceGuid: text("resourceGuid", { length: 36 })
onDelete: "set null" .unique()
}), .notNull()
headers: text("headers"), // comma-separated list of headers to add to the request .$defaultFn(() => randomUUID()),
proxyProtocol: integer("proxyProtocol", { mode: "boolean" }) orgId: text("orgId")
.notNull() .references(() => orgs.orgId, {
.default(false), onDelete: "cascade"
proxyProtocolVersion: integer("proxyProtocolVersion").default(1), })
maintenanceModeEnabled: integer("maintenanceModeEnabled", { .notNull(),
mode: "boolean" niceId: text("niceId").notNull(),
}) name: text("name").notNull(),
.notNull() subdomain: text("subdomain"),
.default(false), fullDomain: text("fullDomain"),
maintenanceModeType: text("maintenanceModeType", { domainId: text("domainId").references(() => domains.domainId, {
enum: ["forced", "automatic"] onDelete: "set null"
}).default("forced"), // "forced" = always show, "automatic" = only when down }),
maintenanceTitle: text("maintenanceTitle"), ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
maintenanceMessage: text("maintenanceMessage"), blockAccess: integer("blockAccess", { mode: "boolean" })
maintenanceEstimatedTime: text("maintenanceEstimatedTime"), .notNull()
postAuthPath: text("postAuthPath"), .default(false),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown" proxyPort: integer("proxyPort"),
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false), sso: integer("sso", { mode: "boolean" }),
mode: text("mode") emailWhitelistEnabled: integer("emailWhitelistEnabled", {
.default("http") mode: "boolean"
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">() }),
.notNull(), // rdp, ssh, http, vnc, inference applyRules: integer("applyRules", { mode: "boolean" }),
pamMode: text("pamMode") enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
.$type<"passthrough" | "push">() stickySession: integer("stickySession", { mode: "boolean" })
.default("passthrough"), .notNull()
authDaemonMode: text("authDaemonMode") .default(false),
.$type<"site" | "remote" | "native">() tlsServerName: text("tlsServerName"),
.default("site"), setHostHeader: text("setHostHeader"),
authDaemonPort: integer("authDaemonPort").default(22123), enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
status: text("status").$type<"pending" | "approved">().default("approved") skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
}); onDelete: "set null"
}),
headers: text("headers"), // comma-separated list of headers to add to the request
proxyProtocol: integer("proxyProtocol", { mode: "boolean" })
.notNull()
.default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: integer("maintenanceModeEnabled", {
mode: "boolean"
})
.notNull()
.default(false),
maintenanceModeType: text("maintenanceModeType", {
enum: ["forced", "automatic"]
}).default("forced"), // "forced" = always show, "automatic" = only when down
maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
mode: text("mode")
.default("http")
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
.notNull(), // rdp, ssh, http, vnc, inference
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123),
status: text("status")
.$type<"pending" | "approved">()
.default("approved")
},
(table) => [
index("idx_resources_orgId").on(table.orgId)
]
);
export const resourceAiProviders = sqliteTable( export const resourceAiProviders = sqliteTable(
"resourceAiProviders", "resourceAiProviders",
@@ -260,16 +276,22 @@ export const resourceAiModels = sqliteTable(
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })] (t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
); );
export const labels = sqliteTable("labels", { export const labels = sqliteTable(
labelId: integer("labelId").primaryKey({ autoIncrement: true }), "labels",
name: text("name").notNull(), {
color: text("color").notNull(), labelId: integer("labelId").primaryKey({ autoIncrement: true }),
orgId: text("orgId") name: text("name").notNull(),
.references(() => orgs.orgId, { color: text("color").notNull(),
onDelete: "cascade" orgId: text("orgId")
}) .references(() => orgs.orgId, {
.notNull() onDelete: "cascade"
}); })
.notNull()
},
(table) => [
index("idx_labels_orgId").on(table.orgId)
]
);
export const launcherViews = sqliteTable("launcherViews", { export const launcherViews = sqliteTable("launcherViews", {
viewId: integer("viewId").primaryKey({ autoIncrement: true }), viewId: integer("viewId").primaryKey({ autoIncrement: true }),
@@ -366,35 +388,44 @@ export const clientLabels = sqliteTable(
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)] (t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
); );
export const targets = sqliteTable("targets", { export const targets = sqliteTable(
targetId: integer("targetId").primaryKey({ autoIncrement: true }), "targets",
resourceId: integer("resourceId").references(() => resources.resourceId, { {
onDelete: "cascade" targetId: integer("targetId").primaryKey({ autoIncrement: true }),
}), resourceId: integer("resourceId").references(
providerId: integer("providerId").references(() => aiProviders.providerId, { () => resources.resourceId,
onDelete: "cascade" { onDelete: "cascade" }
}), ),
siteId: integer("siteId") providerId: integer("providerId").references(
.references(() => sites.siteId, { () => aiProviders.providerId,
onDelete: "cascade" { onDelete: "cascade" }
}) ),
.notNull(), siteId: integer("siteId")
ip: text("ip").notNull(), .references(() => sites.siteId, {
method: text("method"), onDelete: "cascade"
port: integer("port").notNull(), })
internalPort: integer("internalPort"), .notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), ip: text("ip").notNull(),
path: text("path"), method: text("method"),
pathMatchType: text("pathMatchType"), // exact, prefix, regex port: integer("port").notNull(),
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target internalPort: integer("internalPort"),
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull().default(100), path: text("path"),
mode: text("mode") pathMatchType: text("pathMatchType"), // exact, prefix, regex
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">() rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
.notNull() rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
.default("http"), priority: integer("priority").notNull().default(100),
authToken: text("authToken") mode: text("mode")
}); .$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
.notNull()
.default("http"),
authToken: text("authToken")
},
(table) => [
index("idx_targets_resourceId").on(table.resourceId),
index("idx_targets_siteId").on(table.siteId)
]
);
export const targetHealthCheck = sqliteTable("targetHealthCheck", { export const targetHealthCheck = sqliteTable("targetHealthCheck", {
targetHealthCheckId: integer("targetHealthCheckId").primaryKey({ targetHealthCheckId: integer("targetHealthCheckId").primaryKey({
@@ -663,50 +694,63 @@ export const setupTokens = sqliteTable("setupTokens", {
dateUsed: text("dateUsed") dateUsed: text("dateUsed")
}); });
export const newts = sqliteTable("newt", { export const newts = sqliteTable(
newtId: text("id").primaryKey(), "newt",
secretHash: text("secretHash").notNull(), {
dateCreated: text("dateCreated").notNull(), newtId: text("id").primaryKey(),
version: text("version"), secretHash: text("secretHash").notNull(),
siteId: integer("siteId").references(() => sites.siteId, { dateCreated: text("dateCreated").notNull(),
onDelete: "cascade" version: text("version"),
}) siteId: integer("siteId").references(() => sites.siteId, {
});
export const clients = sqliteTable("clients", {
clientId: integer("clientId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), },
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { (table) => [
onDelete: "set null" index("idx_newts_siteId").on(table.siteId)
}), ]
userId: text("userId").references(() => users.userId, { );
// optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade" export const clients = sqliteTable(
}), "clients",
niceId: text("niceId").notNull(), {
name: text("name").notNull(), clientId: integer("clientId").primaryKey({ autoIncrement: true }),
pubKey: text("pubKey"), orgId: text("orgId")
olmId: text("olmId"), // to lock it to a specific olm optionally .references(() => orgs.orgId, {
subnet: text("subnet").notNull(), onDelete: "cascade"
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node })
megabytesIn: integer("bytesIn"), .notNull(),
megabytesOut: integer("bytesOut"), exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
lastBandwidthUpdate: text("lastBandwidthUpdate"), onDelete: "set null"
lastPing: integer("lastPing"), }),
type: text("type").notNull(), // "olm" userId: text("userId").references(() => users.userId, {
online: integer("online", { mode: "boolean" }).notNull().default(false), // optionally tied to a user and in this case delete when the user deletes
// endpoint: text("endpoint"), onDelete: "cascade"
lastHolePunch: integer("lastHolePunch"), }),
archived: integer("archived", { mode: "boolean" }).notNull().default(false), niceId: text("niceId").notNull(),
blocked: integer("blocked", { mode: "boolean" }).notNull().default(false), name: text("name").notNull(),
approvalState: text("approvalState").$type< pubKey: text("pubKey"),
"pending" | "approved" | "denied" olmId: text("olmId"), // to lock it to a specific olm optionally
>() subnet: text("subnet").notNull(),
}); exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
megabytesIn: integer("bytesIn"),
megabytesOut: integer("bytesOut"),
lastBandwidthUpdate: text("lastBandwidthUpdate"),
lastPing: integer("lastPing"),
type: text("type").notNull(), // "olm"
online: integer("online", { mode: "boolean" }).notNull().default(false),
// endpoint: text("endpoint"),
lastHolePunch: integer("lastHolePunch"),
archived: integer("archived", { mode: "boolean" }).notNull().default(false),
blocked: integer("blocked", { mode: "boolean" }).notNull().default(false),
approvalState: text("approvalState").$type<
"pending" | "approved" | "denied"
>()
},
(table) => [
index("idx_clients_orgId").on(table.orgId),
index("idx_clients_userId").on(table.userId)
]
);
export const clientSitesAssociationsCache = sqliteTable( export const clientSitesAssociationsCache = sqliteTable(
"clientSitesAssociationsCache", "clientSitesAssociationsCache",
@@ -734,23 +778,29 @@ export const clientSiteResourcesAssociationsCache = sqliteTable(
} }
); );
export const olms = sqliteTable("olms", { export const olms = sqliteTable(
olmId: text("id").primaryKey(), "olms",
secretHash: text("secretHash").notNull(), {
dateCreated: text("dateCreated").notNull(), olmId: text("id").primaryKey(),
version: text("version"), secretHash: text("secretHash").notNull(),
agent: text("agent"), dateCreated: text("dateCreated").notNull(),
name: text("name"), version: text("version"),
clientId: integer("clientId").references(() => clients.clientId, { agent: text("agent"),
// we will switch this depending on the current org it wants to connect to name: text("name"),
onDelete: "set null" clientId: integer("clientId").references(() => clients.clientId, {
}), // we will switch this depending on the current org it wants to connect to
userId: text("userId").references(() => users.userId, { onDelete: "set null"
// optionally tied to a user and in this case delete when the user deletes }),
onDelete: "cascade" userId: text("userId").references(() => users.userId, {
}), // optionally tied to a user and in this case delete when the user deletes
archived: integer("archived", { mode: "boolean" }).notNull().default(false) onDelete: "cascade"
}); }),
archived: integer("archived", { mode: "boolean" }).notNull().default(false)
},
(table) => [
index("idx_olms_userId").on(table.userId)
]
);
export const currentFingerprint = sqliteTable("currentFingerprint", { export const currentFingerprint = sqliteTable("currentFingerprint", {
fingerprintId: integer("id").primaryKey({ autoIncrement: true }), fingerprintId: integer("id").primaryKey({ autoIncrement: true }),
@@ -912,17 +962,23 @@ export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", {
codeHash: text("codeHash").notNull() codeHash: text("codeHash").notNull()
}); });
export const sessions = sqliteTable("session", { export const sessions = sqliteTable(
sessionId: text("id").primaryKey(), "session",
userId: text("userId") {
.notNull() sessionId: text("id").primaryKey(),
.references(() => users.userId, { onDelete: "cascade" }), userId: text("userId")
expiresAt: integer("expiresAt").notNull(), .notNull()
issuedAt: integer("issuedAt"), .references(() => users.userId, { onDelete: "cascade" }),
deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" }) expiresAt: integer("expiresAt").notNull(),
.notNull() issuedAt: integer("issuedAt"),
.default(false) deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" })
}); .notNull()
.default(false)
},
(table) => [
index("idx_sessions_userId").on(table.userId)
]
);
export const newtSessions = sqliteTable("newtSession", { export const newtSessions = sqliteTable("newtSession", {
sessionId: text("id").primaryKey(), sessionId: text("id").primaryKey(),
@@ -940,21 +996,28 @@ export const olmSessions = sqliteTable("clientSession", {
expiresAt: integer("expiresAt").notNull() expiresAt: integer("expiresAt").notNull()
}); });
export const userOrgs = sqliteTable("userOrgs", { export const userOrgs = sqliteTable(
userId: text("userId") "userOrgs",
.notNull() {
.references(() => users.userId, { onDelete: "cascade" }), userId: text("userId")
orgId: text("orgId") .notNull()
.references(() => orgs.orgId, { .references(() => users.userId, { onDelete: "cascade" }),
onDelete: "cascade" orgId: text("orgId")
}) .references(() => orgs.orgId, {
.notNull(), onDelete: "cascade"
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false), })
autoProvisioned: integer("autoProvisioned", { .notNull(),
mode: "boolean" isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
}).default(false), autoProvisioned: integer("autoProvisioned", {
pamUsername: text("pamUsername") // cleaned username for ssh and such mode: "boolean"
}); }).default(false),
pamUsername: text("pamUsername") // cleaned username for ssh and such
},
(table) => [
index("idx_userOrgs_userId").on(table.userId),
index("idx_userOrgs_orgId").on(table.orgId)
]
);
export const emailVerificationCodes = sqliteTable("emailVerificationCodes", { export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
codeId: integer("id").primaryKey({ autoIncrement: true }), codeId: integer("id").primaryKey({ autoIncrement: true }),
@@ -982,26 +1045,32 @@ export const actions = sqliteTable("actions", {
description: text("description") description: text("description")
}); });
export const roles = sqliteTable("roles", { export const roles = sqliteTable(
roleId: integer("roleId").primaryKey({ autoIncrement: true }), "roles",
orgId: text("orgId") {
.references(() => orgs.orgId, { roleId: integer("roleId").primaryKey({ autoIncrement: true }),
onDelete: "cascade" orgId: text("orgId")
}) .references(() => orgs.orgId, {
.notNull(), onDelete: "cascade"
isAdmin: integer("isAdmin", { mode: "boolean" }), })
name: text("name").notNull(), .notNull(),
description: text("description"), isAdmin: integer("isAdmin", { mode: "boolean" }),
requireDeviceApproval: integer("requireDeviceApproval", { name: text("name").notNull(),
mode: "boolean" description: text("description"),
}).default(false), requireDeviceApproval: integer("requireDeviceApproval", {
sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands" mode: "boolean"
sshSudoCommands: text("sshSudoCommands").default("[]"), }).default(false),
sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default( sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands"
true sshSudoCommands: text("sshSudoCommands").default("[]"),
), sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
sshUnixGroups: text("sshUnixGroups").default("[]") true
}); ),
sshUnixGroups: text("sshUnixGroups").default("[]")
},
(table) => [
index("idx_roles_orgId").on(table.orgId)
]
);
export const userOrgRoles = sqliteTable( export const userOrgRoles = sqliteTable(
"userOrgRoles", "userOrgRoles",
+2 -2
View File
@@ -101,7 +101,7 @@ export const AuthSchema = z.object({
export const RuleSchema = z 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", "country_is_not", "asn", "region"]),
value: z.coerce.string(), value: z.coerce.string(),
priority: z.int().optional(), priority: z.int().optional(),
enabled: z.boolean().optional().default(true) enabled: z.boolean().optional().default(true)
@@ -136,7 +136,7 @@ export const RuleSchema = z
) )
.refine( .refine(
(rule) => { (rule) => {
if (rule.match === "country") { if (rule.match === "country" || rule.match === "country_is_not") {
if (!hasMaxmindCountryDb) { if (!hasMaxmindCountryDb) {
return false; return false;
} }
+10 -15
View File
@@ -31,7 +31,6 @@ export async function validateAndConstructDomain(
subdomain?: string | null subdomain?: string | null
): Promise<DomainValidationResult> { ): Promise<DomainValidationResult> {
try { try {
// Query domain with organization access check
const [domainRes] = await db const [domainRes] = await db
.select() .select()
.from(domains) .from(domains)
@@ -42,6 +41,10 @@ export async function validateAndConstructDomain(
eq(orgDomains.orgId, orgId), eq(orgDomains.orgId, orgId),
eq(orgDomains.domainId, domainId) eq(orgDomains.domainId, domainId)
) )
)
.leftJoin(
domainNamespaces,
eq(domainNamespaces.domainId, domainId)
); );
// Check if domain exists // Check if domain exists
@@ -52,7 +55,7 @@ export async function validateAndConstructDomain(
}; };
} }
if (!domainRes.orgDomains) { if (!domainRes.orgDomains && !domainRes.domainNamespaces) {
return { return {
success: false, success: false,
error: `Organization does not have access to domain with ID ${domainId}` error: `Organization does not have access to domain with ID ${domainId}`
@@ -83,19 +86,11 @@ export async function validateAndConstructDomain(
} }
// Wildcard subdomains are not allowed on namespace (provided/free) domains // Wildcard subdomains are not allowed on namespace (provided/free) domains
if (isWildcard) { if (isWildcard && domainRes.domainNamespaces) {
const [namespaceDomain] = await db return {
.select() success: false,
.from(domainNamespaces) error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead."
.where(eq(domainNamespaces.domainId, domainId)) };
.limit(1);
if (namespaceDomain) {
return {
success: false,
error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead."
};
}
} }
if ( if (
+50 -1
View File
@@ -3,6 +3,8 @@ import config from "./config";
import { getHostMeta } from "./hostMeta"; import { getHostMeta } from "./hostMeta";
import logger from "@server/logger"; import logger from "@server/logger";
import { import {
aiProviders,
aiUsageRecords,
alertRules, alertRules,
apiKeys, apiKeys,
blueprints, blueprints,
@@ -11,7 +13,16 @@ import {
siteResources siteResources
} from "@server/db"; } from "@server/db";
import { sites, users, orgs, resources, clients, idp } from "@server/db"; import { sites, users, orgs, resources, clients, idp } from "@server/db";
import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm"; import {
eq,
count,
countDistinct,
notInArray,
and,
isNotNull,
isNull,
gte
} from "drizzle-orm";
import { APP_VERSION } from "./consts"; import { APP_VERSION } from "./consts";
import crypto from "crypto"; import crypto from "crypto";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
@@ -172,6 +183,25 @@ class TelemetryClient {
.select({ count: count() }) .select({ count: count() })
.from(blueprints); .from(blueprints);
const [aiProvidersCount] = await db
.select({ count: count() })
.from(aiProviders);
const [orgsWithAiProviders] = await db
.select({ count: countDistinct(aiProviders.orgId) })
.from(aiProviders);
const usageWindowStart =
Math.floor(Date.now() / 1000) -
this.collectionIntervalDays * 24 * 60 * 60;
const [aiUsageRecordsRecent] = await db
.select({ count: count() })
.from(aiUsageRecords)
.where(gte(aiUsageRecords.createdAt, usageWindowStart));
const [orgsWithRecentAiUsage] = await db
.select({ count: countDistinct(aiUsageRecords.orgId) })
.from(aiUsageRecords)
.where(gte(aiUsageRecords.createdAt, usageWindowStart));
const supporterKey = config.getSupporterData(); const supporterKey = config.getSupporterData();
const allPrivateResources = await db.select().from(siteResources); const allPrivateResources = await db.select().from(siteResources);
@@ -182,6 +212,7 @@ class TelemetryClient {
let numPrivResourceCidr = 0; let numPrivResourceCidr = 0;
let numPrivResourceHttp = 0; let numPrivResourceHttp = 0;
let numPrivResourceSsh = 0; let numPrivResourceSsh = 0;
let numPrivResourceInference = 0;
for (const res of allPrivateResources) { for (const res of allPrivateResources) {
if (res.mode === "host") { if (res.mode === "host") {
numPrivResourceHosts += 1; numPrivResourceHosts += 1;
@@ -191,6 +222,8 @@ class TelemetryClient {
numPrivResourceHttp += 1; numPrivResourceHttp += 1;
} else if (res.mode === "ssh") { } else if (res.mode === "ssh") {
numPrivResourceSsh += 1; numPrivResourceSsh += 1;
} else if (res.mode === "inference") {
numPrivResourceInference += 1;
} }
if (res.alias) { if (res.alias) {
@@ -211,6 +244,11 @@ class TelemetryClient {
numPrivateResourceCidr: numPrivResourceCidr, numPrivateResourceCidr: numPrivResourceCidr,
numPrivateResourceHttp: numPrivResourceHttp, numPrivateResourceHttp: numPrivResourceHttp,
numPrivateResourceSsh: numPrivResourceSsh, numPrivateResourceSsh: numPrivResourceSsh,
numPrivateResourceInference: numPrivResourceInference,
numAiProviders: aiProvidersCount.count,
numOrgsWithAiProviders: orgsWithAiProviders.count,
numAiUsageRecordsRecent: aiUsageRecordsRecent.count,
numOrgsWithRecentAiUsage: orgsWithRecentAiUsage.count,
numAlertRules: numAlertRules.count, numAlertRules: numAlertRules.count,
numUserDevices: userDevicesCount.count, numUserDevices: userDevicesCount.count,
numMachineClients: machineClients.count, numMachineClients: machineClients.count,
@@ -323,6 +361,17 @@ class TelemetryClient {
num_resources_non_http: stats.resources.filter( num_resources_non_http: stats.resources.filter(
(r) => r.mode !== "http" (r) => r.mode !== "http"
).length, ).length,
num_resources_ai_gateway: stats.resources.filter(
(r) => r.mode === "inference"
).length,
num_private_resources_ai_gateway:
stats.numPrivateResourceInference,
num_ai_providers: stats.numAiProviders,
num_orgs_with_ai_providers: stats.numOrgsWithAiProviders,
num_ai_usage_records_recent:
stats.numAiUsageRecordsRecent,
num_orgs_with_recent_ai_usage:
stats.numOrgsWithRecentAiUsage,
num_newt_sites: stats.sites.filter((s) => s.type === "newt") num_newt_sites: stats.sites.filter((s) => s.type === "newt")
.length, .length,
num_local_sites: stats.sites.filter( num_local_sites: stats.sites.filter(
@@ -68,6 +68,11 @@ export async function verifyApiKeyAccessTokenAccess(
); );
} }
if (apiKey.isRoot) {
// Root keys can access any access token in any org
return next();
}
if (!resource.orgId) { if (!resource.orgId) {
return next( return next(
createHttpError( createHttpError(
+10 -14
View File
@@ -149,12 +149,8 @@ LQIDAQAB
} }
// Count used sites and users for license comparison // Count used sites and users for license comparison
const [siteCountRes] = await db const [siteCountRes] = await db.select({ value: count() }).from(sites);
.select({ value: count() }) const [userCountRes] = await db.select({ value: count() }).from(users);
.from(sites);
const [userCountRes] = await db
.select({ value: count() })
.from(users);
const status: LicenseStatus = { const status: LicenseStatus = {
hostId: this.hostMeta.hostMetaId, hostId: this.hostMeta.hostMetaId,
@@ -276,10 +272,13 @@ LQIDAQAB
logger.error( logger.error(
`Allowing failure. Will retry one more time at next run interval.` `Allowing failure. Will retry one more time at next run interval.`
); );
// return last known good status // Fall back to last known good status if we have
return this.statusCache.get( // one cached; otherwise return the freshly built
// status (with defaults) rather than undefined.
const lastKnownStatus = this.statusCache.get(
this.statusKey this.statusKey
) as LicenseStatus; ) as LicenseStatus | undefined;
return lastKnownStatus ?? status;
} else { } else {
// Subsequent failures: fail abruptly // Subsequent failures: fail abruptly
throw e; throw e;
@@ -368,10 +367,7 @@ LQIDAQAB
} }
// Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites) // Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites)
if ( if (cached.quantity_2 !== undefined && cached.quantity_2 >= 0) {
cached.quantity_2 !== undefined &&
cached.quantity_2 >= 0
) {
status.maxSites = status.maxSites =
(status.maxSites ?? 0) + cached.quantity_2; (status.maxSites ?? 0) + cached.quantity_2;
} }
@@ -561,7 +557,7 @@ LQIDAQAB
// Calculate exponential backoff delay // Calculate exponential backoff delay
const retryDelay = Math.floor( const retryDelay = Math.floor(
initialRetryDelay * initialRetryDelay *
Math.pow(exponentialFactor, attempt - 1) Math.pow(exponentialFactor, attempt - 1)
); );
logger.debug( logger.debug(
+7 -2
View File
@@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib/response"; import { response } from "@server/lib/response";
import { db } from "@server/db"; import { db } from "@server/db";
import { passwordResetTokens, users } from "@server/db"; import { passwordResetTokens, users } from "@server/db";
import { eq } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { alphabet, generateRandomString, sha256 } from "oslo/crypto"; import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
import { createDate } from "oslo"; import { createDate } from "oslo";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -49,7 +49,12 @@ export async function requestPasswordReset(
const existingUser = await db const existingUser = await db
.select() .select()
.from(users) .from(users)
.where(eq(users.email, email)); .where(
and(
eq(users.email, email),
eq(users.type, UserType.Internal)
)
);
if (!existingUser || !existingUser.length) { if (!existingUser || !existingUser.length) {
await randomDelay(2000); await randomDelay(2000);
+2 -2
View File
@@ -454,14 +454,14 @@ export default async function migration() {
throw new Error(fromZodError(parsedConfig.error).toString()); throw new Error(fromZodError(parsedConfig.error).toString());
} }
traefikConfig.experimental.plugins.badger.version = "v1.6.1"; traefikConfig.experimental.plugins.badger.version = "v1.7.0";
const updatedTraefikYaml = yaml.dump(traefikConfig); const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log( console.log(
"Updated the version of Badger in your Traefik configuration to v1.6.1" "Updated the version of Badger in your Traefik configuration to v1.7.0"
); );
} catch (e) { } catch (e) {
console.log( console.log(
+2 -2
View File
@@ -459,14 +459,14 @@ export default async function migration() {
throw new Error(fromZodError(parsedConfig.error).toString()); throw new Error(fromZodError(parsedConfig.error).toString());
} }
traefikConfig.experimental.plugins.badger.version = "v1.6.1"; traefikConfig.experimental.plugins.badger.version = "v1.7.0";
const updatedTraefikYaml = yaml.dump(traefikConfig); const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log( console.log(
"Updated the version of Badger in your Traefik configuration to v1.6.1" "Updated the version of Badger in your Traefik configuration to v1.7.0"
); );
} catch (e) { } catch (e) {
console.log( console.log(
+5 -3
View File
@@ -276,7 +276,9 @@ export default function AiSessionLogsPage() {
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div className="whitespace-nowrap"> <div className="whitespace-nowrap">
{new Date(row.original.createdAt).toLocaleString()} {new Date(
row.original.createdAt * 1000
).toLocaleString()}
</div> </div>
); );
} }
@@ -715,8 +717,8 @@ function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
null null
]; ];
const now = Date.now(); const now = Math.floor(Date.now() / 1000);
const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000; const sevenDaysAgoMs = now - 7 * 24 * 60 * 60;
return Array.from({ length: 10 }, (_, i) => { return Array.from({ length: 10 }, (_, i) => {
const provider = const provider =
@@ -50,8 +50,6 @@ import {
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { import {
createBrowserGatewayTargetFormSchema, createBrowserGatewayTargetFormSchema,
@@ -59,7 +57,6 @@ import {
selectedSiteSchema, selectedSiteSchema,
type SshSettingsFormValues type SshSettingsFormValues
} from "@app/lib/browserGatewayTargetFormSchema"; } from "@app/lib/browserGatewayTargetFormSchema";
import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries } from "@app/lib/queries"; import { orgQueries } from "@app/lib/queries";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -328,19 +325,20 @@ export default function Page() {
const rawResourcesAllowed = const rawResourcesAllowed =
env.flags.allowRawResources && env.flags.allowRawResources &&
(build !== "saas" || remoteExitNodes.length > 0); (build !== "saas" || remoteExitNodes.length > 0);
const enterpriseModesAllowed =
!env.flags.disableEnterpriseFeatures;
const availableTypes = useMemo((): NewResourceType[] => { const availableTypes = useMemo((): NewResourceType[] => {
const base: NewResourceType[] = ["http", "inference"]; const base: NewResourceType[] = [
if (enterpriseModesAllowed) { "http",
base.push("ssh", "rdp", "vnc"); "inference",
} "ssh",
"rdp",
"vnc"
];
if (rawResourcesAllowed) { if (rawResourcesAllowed) {
base.push("tcp", "udp"); base.push("tcp", "udp");
} }
return base; return base;
}, [enterpriseModesAllowed, rawResourcesAllowed]); }, [rawResourcesAllowed]);
useEffect(() => { useEffect(() => {
if (!availableTypes.includes(resourceType)) { if (!availableTypes.includes(resourceType)) {
+74
View File
@@ -0,0 +1,74 @@
import { assertEquals } from "@test/assert";
import { detectLocale } from "./detectLocale";
function runTests() {
assertEquals(
detectLocale("zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7,ja;q=0.6"),
"zh-TW",
"An exact regional match should take precedence over a language fallback"
);
assertEquals(
detectLocale("ZH-tw"),
"zh-TW",
"Locale matching should be case-insensitive"
);
assertEquals(
detectLocale(" zh-TW ; q=1 , zh-CN;q=0.8 "),
"zh-TW",
"Whitespace and quality parameters should not prevent an exact match"
);
assertEquals(
detectLocale("zh-CN,zh-TW;q=0.9"),
"zh-CN",
"Simplified Chinese should still match exactly"
);
assertEquals(
detectLocale("zh"),
"zh-CN",
"A generic Chinese preference should retain the existing fallback"
);
assertEquals(
detectLocale("en-GB,en;q=0.9"),
"en-US",
"An unsupported region should fall back to a supported locale for the language"
);
assertEquals(
detectLocale("ja-JP,zh-TW;q=0.9"),
"zh-TW",
"The next preference should be used when a language is unsupported"
);
assertEquals(
detectLocale("zh-CN;q=0.5,zh-TW;q=0.9"),
"zh-TW",
"Preferences should be evaluated by quality"
);
assertEquals(
detectLocale("zh-TW;q=0,fr-FR;q=0.8"),
"fr-FR",
"Locales with zero quality should be excluded"
);
assertEquals(
detectLocale("*,de-DE;q=0.8"),
"de-DE",
"A wildcard should not obscure a supported preference"
);
assertEquals(
detectLocale("ja-JP"),
undefined,
"An unsupported language should not match"
);
assertEquals(
detectLocale(""),
undefined,
"An empty Accept-Language header should not match"
);
console.log("All locale detection tests passed!");
}
try {
runTests();
} catch (error) {
console.error("Locale detection test failed:", error);
process.exit(1);
}
+48
View File
@@ -0,0 +1,48 @@
import { Locale, locales } from "./config";
export function detectLocale(acceptLanguage: string): Locale | undefined {
const browserLocales = acceptLanguage
.split(",")
.map((entry, index) => {
const [locale, ...parameters] = entry.trim().split(";");
const qualityParameter = parameters.find((parameter) =>
parameter.trim().toLowerCase().startsWith("q=")
);
const quality = qualityParameter
? Number(qualityParameter.trim().slice(2))
: 1;
return {
locale: locale.trim().toLowerCase(),
quality,
index
};
})
.filter(
({ locale, quality }) =>
locale && locale !== "*" && quality > 0 && quality <= 1
)
.sort(
(left, right) =>
right.quality - left.quality || left.index - right.index
);
for (const { locale: browserLocale } of browserLocales) {
const exactMatch = locales.find(
(locale) => locale.toLowerCase() === browserLocale
);
if (exactMatch) {
return exactMatch;
}
const browserLanguage = browserLocale.split("-")[0];
const languageMatch = locales.find(
(locale) => locale.split("-")[0].toLowerCase() === browserLanguage
);
if (languageMatch) {
return languageMatch;
}
}
return undefined;
}
+2 -6
View File
@@ -2,6 +2,7 @@
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { Locale, defaultLocale, locales } from "@/i18n/config"; import { Locale, defaultLocale, locales } from "@/i18n/config";
import { detectLocale } from "@/i18n/detectLocale";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
@@ -47,12 +48,7 @@ export async function getUserLocale(): Promise<Locale> {
const acceptLang = headerList.get("accept-language"); const acceptLang = headerList.get("accept-language");
if (acceptLang) { if (acceptLang) {
const browserLang = acceptLang.split(",")[0]; const matched = detectLocale(acceptLang);
const matched = locales.find((locale) =>
browserLang
.toLowerCase()
.startsWith(locale.split("-")[0].toLowerCase())
);
if (matched) { if (matched) {
return matched; return matched;
} }