mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-20 20:06:39 +00:00
Compare commits
34 Commits
1.0.0-beta
...
1.0.0-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
025c2c5306 | ||
|
|
fa39b708a9 | ||
|
|
5774e534e5 | ||
|
|
e32301ade4 | ||
|
|
a2bf3ba7e7 | ||
|
|
62ba797cd0 | ||
|
|
82192fa180 | ||
|
|
7b20329743 | ||
|
|
a85303161c | ||
|
|
38544cc2d6 | ||
|
|
484a099ee3 | ||
|
|
832d7e5d6d | ||
|
|
c8c756df28 | ||
|
|
c3d19454f7 | ||
|
|
fcc6cad6d7 | ||
|
|
6c813186b8 | ||
|
|
a556339b76 | ||
|
|
d2b10def35 | ||
|
|
4421f470a4 | ||
|
|
235e91294e | ||
|
|
184a22c238 | ||
|
|
b598fc3fba | ||
|
|
dc7bd41eb9 | ||
|
|
fb754bc4e0 | ||
|
|
ab69ded396 | ||
|
|
b4dd827ce1 | ||
|
|
e1f0834af4 | ||
|
|
a36691e5ab | ||
|
|
26a165ab71 | ||
|
|
7ab89b1adb | ||
|
|
b1d111a089 | ||
|
|
9e8086908d | ||
|
|
cf6e48be9a | ||
|
|
1df1b55e24 |
@@ -23,7 +23,6 @@ next-env.d.ts
|
|||||||
.machinelogs*.json
|
.machinelogs*.json
|
||||||
*-audit.json
|
*-audit.json
|
||||||
package-lock.json
|
package-lock.json
|
||||||
config/
|
|
||||||
install/
|
install/
|
||||||
bruno/
|
bruno/
|
||||||
LICENSE
|
LICENSE
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -25,7 +25,9 @@ next-env.d.ts
|
|||||||
migrations
|
migrations
|
||||||
package-lock.json
|
package-lock.json
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
config/
|
config/config.yml
|
||||||
dist
|
dist
|
||||||
.dist
|
.dist
|
||||||
installer
|
installer
|
||||||
|
*.tar
|
||||||
|
bin
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! Please see the following page in our documentation with future plans and feature ideas if you are looking for a place to start.
|
Contributions are welcome!
|
||||||
|
|
||||||
|
Please see the contribution and local development guide on the docs page before getting started:
|
||||||
|
|
||||||
|
https://docs.fossorial.io/development
|
||||||
|
|
||||||
|
For ideas about what features to work on and our future plans, please see the roadmap:
|
||||||
|
|
||||||
https://docs.fossorial.io/roadmap
|
https://docs.fossorial.io/roadmap
|
||||||
|
|
||||||
@@ -15,4 +21,4 @@ By creating this pull request, I grant the project maintainers an unlimited,
|
|||||||
perpetual license to use, modify, and redistribute these contributions under any terms they
|
perpetual license to use, modify, and redistribute these contributions under any terms they
|
||||||
choose, including both the AGPLv3 and the Fossorial Commercial license terms. I
|
choose, including both the AGPLv3 and the Fossorial Commercial license terms. I
|
||||||
represent that I have the right to grant this license for all contributed content.
|
represent that I have the right to grant this license for all contributed content.
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ COPY --from=builder /app/.next ./.next
|
|||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/init ./dist/init
|
COPY --from=builder /app/init ./dist/init
|
||||||
|
|
||||||
COPY config.example.yml ./dist/config.example.yml
|
COPY config/config.example.yml ./dist/config.example.yml
|
||||||
COPY server/db/names.json ./dist/names.json
|
COPY server/db/names.json ./dist/names.json
|
||||||
|
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|||||||
17
Makefile
17
Makefile
@@ -1,18 +1,23 @@
|
|||||||
|
build-release:
|
||||||
all: build push
|
@if [ -z "$(tag)" ]; then \
|
||||||
|
echo "Error: tag is required. Usage: make build-all tag=<tag>"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:latest -f Dockerfile --push .
|
||||||
|
docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:$(tag) -f Dockerfile --push .
|
||||||
|
|
||||||
build-arm:
|
build-arm:
|
||||||
docker buildx build --platform linux/arm64 -t fosrl/pangolin:latest .
|
docker buildx build --platform linux/arm64 -t fosrl/pangolin:latest .
|
||||||
|
|
||||||
build-x86:
|
build-x86:
|
||||||
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
|
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
|
||||||
|
|
||||||
|
build-x86-ecr:
|
||||||
|
docker buildx build --platform linux/amd64 -t 216989133116.dkr.ecr.us-east-1.amazonaws.com/pangolin:latest --push .
|
||||||
|
|
||||||
build:
|
build:
|
||||||
docker build -t fosrl/pangolin:latest .
|
docker build -t fosrl/pangolin:latest .
|
||||||
|
|
||||||
push:
|
|
||||||
docker push fosrl/pangolin:latest
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest
|
docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest
|
||||||
|
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -1,5 +1,11 @@
|
|||||||
# Pangolin
|
# Pangolin
|
||||||
|
|
||||||
|
[](https://docs.fossorial.io/)
|
||||||
|
[](https://hub.docker.com/r/fosrl/pangolin)
|
||||||
|

|
||||||
|
[](https://discord.gg/HCJR8Xhme4)
|
||||||
|
[](https://www.youtube.com/@fossorial-app)
|
||||||
|
|
||||||
Pangolin is a self-hosted tunneled reverse proxy management server with identity and access management, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI.
|
Pangolin is a self-hosted tunneled reverse proxy management server with identity and access management, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI.
|
||||||
|
|
||||||
### Installation and Documentation
|
### Installation and Documentation
|
||||||
@@ -96,7 +102,7 @@ Pangolin has a straightforward and simple dashboard UI:
|
|||||||
|
|
||||||
3. **Connect Private Sites**:
|
3. **Connect Private Sites**:
|
||||||
- Install Newt or use another WireGuard client on private sites.
|
- Install Newt or use another WireGuard client on private sites.
|
||||||
- Automaticlaly establish a connection from these sites to the central server.
|
- Automatically establish a connection from these sites to the central server.
|
||||||
4. **Configure Users & Roles**
|
4. **Configure Users & Roles**
|
||||||
- Define organizations and invite users.
|
- Define organizations and invite users.
|
||||||
- Implement user- or role-based permissions to control resource access.
|
- Implement user- or role-based permissions to control resource access.
|
||||||
@@ -123,4 +129,7 @@ Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license.
|
|||||||
|
|
||||||
## Contributions
|
## Contributions
|
||||||
|
|
||||||
Please see [CONTRIBUTIONS](./CONTRIBUTING.md) in the repository for guidelines and best practices.
|
Please see [CONTRIBUTING](./CONTRIBUTING.md) in the repository for guidelines and best practices.
|
||||||
|
|
||||||
|
Please post bug reports and other functional issues in the [Issues](https://github.com/fosrl/pangolin/issues) section of the repository.
|
||||||
|
For all feature requests, or other ideas, please use the [Discussions](https://github.com/orgs/fosrl/discussions) section.
|
||||||
|
|||||||
14
SECURITY.md
Normal file
14
SECURITY.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
If you discover a security vulnerability, please follow the steps below to responsibly disclose it to us:
|
||||||
|
|
||||||
|
1. **Do not create a public GitHub issue or discussion post.** This could put the security of other users at risk.
|
||||||
|
2. Send a detailed report to [security@fossorial.io](mailto:security@fossorial.io) or send a **private** message to a maintainer on [Discord](https://discord.gg/HCJR8Xhme4). Include:
|
||||||
|
|
||||||
|
- Description and location of the vulnerability.
|
||||||
|
- Potential impact of the vulnerability.
|
||||||
|
- Steps to reproduce the vulnerability.
|
||||||
|
- Potential solutions to fix the vulnerability.
|
||||||
|
- Your name/handle and a link for recognition (optional).
|
||||||
|
|
||||||
|
We aim to address the issue as soon as possible.
|
||||||
0
config/.gitkeep
Normal file
0
config/.gitkeep
Normal file
@@ -1,13 +1,14 @@
|
|||||||
app:
|
app:
|
||||||
base_url: https://proxy.example.com
|
dashboard_url: http://localhost
|
||||||
log_level: info
|
base_domain: localhost
|
||||||
|
log_level: debug
|
||||||
save_logs: false
|
save_logs: false
|
||||||
|
|
||||||
server:
|
server:
|
||||||
external_port: 3000
|
external_port: 3000
|
||||||
internal_port: 3001
|
internal_port: 3001
|
||||||
next_port: 3002
|
next_port: 3002
|
||||||
internal_hostname: pangolin
|
internal_hostname: localhost
|
||||||
secure_cookies: false
|
secure_cookies: false
|
||||||
session_cookie_name: p_session
|
session_cookie_name: p_session
|
||||||
resource_session_cookie_name: p_resource_session
|
resource_session_cookie_name: p_resource_session
|
||||||
@@ -16,34 +17,24 @@ traefik:
|
|||||||
cert_resolver: letsencrypt
|
cert_resolver: letsencrypt
|
||||||
http_entrypoint: web
|
http_entrypoint: web
|
||||||
https_entrypoint: websecure
|
https_entrypoint: websecure
|
||||||
prefer_wildcard_cert: true
|
|
||||||
|
|
||||||
gerbil:
|
gerbil:
|
||||||
start_port: 51820
|
start_port: 51820
|
||||||
base_endpoint: proxy.example.com
|
base_endpoint: localhost
|
||||||
use_subdomain: false
|
block_size: 24
|
||||||
block_size: 16
|
site_block_size: 30
|
||||||
subnet_group: 10.0.0.0/8
|
subnet_group: 100.89.137.0/20
|
||||||
|
use_subdomain: true
|
||||||
|
|
||||||
rate_limits:
|
rate_limits:
|
||||||
global:
|
global:
|
||||||
window_minutes: 1
|
window_minutes: 1
|
||||||
max_requests: 100
|
max_requests: 100
|
||||||
|
|
||||||
email:
|
|
||||||
smtp_host: host.hoster.net
|
|
||||||
smtp_port: 587
|
|
||||||
smtp_user: no-reply@example.com
|
|
||||||
smtp_pass: aaaaaaaaaaaaaaaaaa
|
|
||||||
no_reply: no-reply@example.com
|
|
||||||
|
|
||||||
users:
|
users:
|
||||||
server_admin:
|
server_admin:
|
||||||
email: admin@example.com
|
email: admin@example.com
|
||||||
password: Password123!
|
password: Password123!
|
||||||
|
|
||||||
flags:
|
flags:
|
||||||
require_email_verification: true
|
require_email_verification: false
|
||||||
disable_signup_without_invite: true
|
|
||||||
disable_user_create_org: true
|
|
||||||
|
|
||||||
0
config/db/.gitkeep
Normal file
0
config/db/.gitkeep
Normal file
0
config/logs/.gitkeep
Normal file
0
config/logs/.gitkeep
Normal file
@@ -2,12 +2,9 @@ version: "3.7"
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: fosrl/pangolin:1.0.0-beta.1
|
image: fosrl/pangolin:latest
|
||||||
container_name: pangolin
|
container_name: pangolin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- 3001:3001
|
|
||||||
- 3000:3000
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config
|
- ./config:/app/config
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -17,7 +14,7 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
gerbil:
|
gerbil:
|
||||||
image: fosrl/gerbil:1.0.0-beta.1
|
image: fosrl/gerbil:latest
|
||||||
container_name: gerbil
|
container_name: gerbil
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
build:
|
build:
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o installer
|
CGO_ENABLED=0 go build -o bin/installer
|
||||||
|
|
||||||
|
release:
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm installer
|
rm bin/installer
|
||||||
|
rm bin/installer_linux_amd64
|
||||||
|
rm bin/installer_linux_arm64
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
app:
|
app:
|
||||||
base_url: https://{{.Domain}}
|
dashboard_url: https://{{.DashboardDomain}}
|
||||||
|
base_domain: {{.BaseDomain}}
|
||||||
log_level: info
|
log_level: info
|
||||||
save_logs: false
|
save_logs: false
|
||||||
|
|
||||||
@@ -20,10 +21,11 @@ traefik:
|
|||||||
|
|
||||||
gerbil:
|
gerbil:
|
||||||
start_port: 51820
|
start_port: 51820
|
||||||
base_endpoint: {{.Domain}}
|
base_endpoint: {{.DashboardDomain}}
|
||||||
use_subdomain: false
|
use_subdomain: false
|
||||||
block_size: 16
|
block_size: 24
|
||||||
subnet_group: 10.0.0.0/8
|
site_block_size: 30
|
||||||
|
subnet_group: 100.89.137.0/20
|
||||||
|
|
||||||
rate_limits:
|
rate_limits:
|
||||||
global:
|
global:
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: fosrl/pangolin:1.0.0-beta.1
|
image: fosrl/pangolin:latest
|
||||||
container_name: pangolin
|
container_name: pangolin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- 3001:3001
|
|
||||||
- 3000:3000
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config
|
- ./config:/app/config
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -15,7 +12,7 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
gerbil:
|
gerbil:
|
||||||
image: fosrl/gerbil:1.0.0-beta.1
|
image: fosrl/gerbil:latest
|
||||||
container_name: gerbil
|
container_name: gerbil
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ http:
|
|||||||
routers:
|
routers:
|
||||||
# HTTP to HTTPS redirect router
|
# HTTP to HTTPS redirect router
|
||||||
main-app-router-redirect:
|
main-app-router-redirect:
|
||||||
rule: "Host(`{{.Domain}}`)"
|
rule: "Host(`{{.DashboardDomain}}`)"
|
||||||
service: next-service
|
service: next-service
|
||||||
entryPoints:
|
entryPoints:
|
||||||
- web
|
- web
|
||||||
@@ -17,7 +17,7 @@ http:
|
|||||||
|
|
||||||
# Next.js router (handles everything except API and WebSocket paths)
|
# Next.js router (handles everything except API and WebSocket paths)
|
||||||
next-router:
|
next-router:
|
||||||
rule: "Host(`{{.Domain}}`) && !PathPrefix(`/api/v1`)"
|
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)"
|
||||||
service: next-service
|
service: next-service
|
||||||
entryPoints:
|
entryPoints:
|
||||||
- websecure
|
- websecure
|
||||||
@@ -26,7 +26,7 @@ http:
|
|||||||
|
|
||||||
# API router (handles /api/v1 paths)
|
# API router (handles /api/v1 paths)
|
||||||
api-router:
|
api-router:
|
||||||
rule: "Host(`{{.Domain}}`) && PathPrefix(`/api/v1`)"
|
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
|
||||||
service: api-service
|
service: api-service
|
||||||
entryPoints:
|
entryPoints:
|
||||||
- websecure
|
- websecure
|
||||||
@@ -35,7 +35,7 @@ http:
|
|||||||
|
|
||||||
# WebSocket router
|
# WebSocket router
|
||||||
ws-router:
|
ws-router:
|
||||||
rule: "Host(`{{.Domain}}`)"
|
rule: "Host(`{{.DashboardDomain}}`)"
|
||||||
service: api-service
|
service: api-service
|
||||||
entryPoints:
|
entryPoints:
|
||||||
- websecure
|
- websecure
|
||||||
|
|||||||
105
install/main.go
105
install/main.go
@@ -18,7 +18,8 @@ import (
|
|||||||
var configFiles embed.FS
|
var configFiles embed.FS
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Domain string `yaml:"domain"`
|
BaseDomain string `yaml:"baseDomain"`
|
||||||
|
DashboardDomain string `yaml:"dashboardUrl"`
|
||||||
LetsEncryptEmail string `yaml:"letsEncryptEmail"`
|
LetsEncryptEmail string `yaml:"letsEncryptEmail"`
|
||||||
AdminUserEmail string `yaml:"adminUserEmail"`
|
AdminUserEmail string `yaml:"adminUserEmail"`
|
||||||
AdminUserPassword string `yaml:"adminUserPassword"`
|
AdminUserPassword string `yaml:"adminUserPassword"`
|
||||||
@@ -44,7 +45,10 @@ func main() {
|
|||||||
// check if there is already a config file
|
// check if there is already a config file
|
||||||
if _, err := os.Stat("config/config.yml"); err != nil {
|
if _, err := os.Stat("config/config.yml"); err != nil {
|
||||||
config := collectUserInput(reader)
|
config := collectUserInput(reader)
|
||||||
createConfigFiles(config)
|
if err := createConfigFiles(config); err != nil {
|
||||||
|
fmt.Printf("Error creating config files: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
if !isDockerInstalled() && runtime.GOOS == "linux" {
|
if !isDockerInstalled() && runtime.GOOS == "linux" {
|
||||||
if shouldInstallDocker() {
|
if shouldInstallDocker() {
|
||||||
@@ -102,12 +106,13 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||||||
|
|
||||||
// Basic configuration
|
// Basic configuration
|
||||||
fmt.Println("\n=== Basic Configuration ===")
|
fmt.Println("\n=== Basic Configuration ===")
|
||||||
config.Domain = readString(reader, "Enter your domain name", "")
|
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
|
||||||
|
config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", "pangolin."+config.BaseDomain)
|
||||||
config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
|
config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
|
||||||
|
|
||||||
// Admin user configuration
|
// Admin user configuration
|
||||||
fmt.Println("\n=== Admin User Configuration ===")
|
fmt.Println("\n=== Admin User Configuration ===")
|
||||||
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.Domain)
|
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
|
||||||
for {
|
for {
|
||||||
config.AdminUserPassword = readString(reader, "Enter admin user password", "")
|
config.AdminUserPassword = readString(reader, "Enter admin user password", "")
|
||||||
if valid, message := validatePassword(config.AdminUserPassword); valid {
|
if valid, message := validatePassword(config.AdminUserPassword); valid {
|
||||||
@@ -140,10 +145,14 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if config.Domain == "" {
|
if config.BaseDomain == "" {
|
||||||
fmt.Println("Error: Domain name is required")
|
fmt.Println("Error: Domain name is required")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
if config.DashboardDomain == "" {
|
||||||
|
fmt.Println("Error: Dashboard Domain name is required")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
if config.LetsEncryptEmail == "" {
|
if config.LetsEncryptEmail == "" {
|
||||||
fmt.Println("Error: Let's Encrypt email is required")
|
fmt.Println("Error: Let's Encrypt email is required")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -269,8 +278,26 @@ func createConfigFiles(config Config) error {
|
|||||||
return fmt.Errorf("error walking config files: %v", err)
|
return fmt.Errorf("error walking config files: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// move the docker-compose.yml file to the root directory
|
// get the current directory
|
||||||
os.Rename("config/docker-compose.yml", "docker-compose.yml")
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourcePath := filepath.Join(dir, "config/docker-compose.yml")
|
||||||
|
destPath := filepath.Join(dir, "docker-compose.yml")
|
||||||
|
|
||||||
|
// Check if source file exists
|
||||||
|
if _, err := os.Stat(sourcePath); err != nil {
|
||||||
|
return fmt.Errorf("source docker-compose.yml not found: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to move the file
|
||||||
|
err = os.Rename(sourcePath, destPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to move docker-compose.yml from %s to %s: %v",
|
||||||
|
sourcePath, destPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -289,39 +316,66 @@ func installDocker() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to detect Linux distribution: %v", err)
|
return fmt.Errorf("failed to detect Linux distribution: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
osRelease := string(output)
|
osRelease := string(output)
|
||||||
var installCmd *exec.Cmd
|
|
||||||
|
|
||||||
|
// Detect system architecture
|
||||||
|
archCmd := exec.Command("uname", "-m")
|
||||||
|
archOutput, err := archCmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to detect system architecture: %v", err)
|
||||||
|
}
|
||||||
|
arch := strings.TrimSpace(string(archOutput))
|
||||||
|
|
||||||
|
// Map architecture to Docker's architecture naming
|
||||||
|
var dockerArch string
|
||||||
|
switch arch {
|
||||||
|
case "x86_64":
|
||||||
|
dockerArch = "amd64"
|
||||||
|
case "aarch64":
|
||||||
|
dockerArch = "arm64"
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported architecture: %s", arch)
|
||||||
|
}
|
||||||
|
|
||||||
|
var installCmd *exec.Cmd
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(osRelease, "ID=ubuntu") || strings.Contains(osRelease, "ID=debian"):
|
case strings.Contains(osRelease, "ID=ubuntu"):
|
||||||
installCmd = exec.Command("bash", "-c", `
|
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
|
||||||
apt-get update &&
|
apt-get update &&
|
||||||
apt-get install -y apt-transport-https ca-certificates curl software-properties-common &&
|
apt-get install -y apt-transport-https ca-certificates curl software-properties-common &&
|
||||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
|
||||||
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
|
echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
|
||||||
apt-get update &&
|
apt-get update &&
|
||||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||||
`)
|
`, dockerArch))
|
||||||
|
case strings.Contains(osRelease, "ID=debian"):
|
||||||
|
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
|
||||||
|
apt-get update &&
|
||||||
|
apt-get install -y apt-transport-https ca-certificates curl software-properties-common &&
|
||||||
|
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
|
||||||
|
echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
|
||||||
|
apt-get update &&
|
||||||
|
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||||
|
`, dockerArch))
|
||||||
case strings.Contains(osRelease, "ID=fedora"):
|
case strings.Contains(osRelease, "ID=fedora"):
|
||||||
installCmd = exec.Command("bash", "-c", `
|
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
|
||||||
dnf -y install dnf-plugins-core &&
|
dnf -y install dnf-plugins-core &&
|
||||||
dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo &&
|
dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo &&
|
||||||
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||||
`)
|
`))
|
||||||
case strings.Contains(osRelease, "ID=opensuse") || strings.Contains(osRelease, "ID=\"opensuse-"):
|
case strings.Contains(osRelease, "ID=opensuse") || strings.Contains(osRelease, "ID=\"opensuse-"):
|
||||||
installCmd = exec.Command("bash", "-c", `
|
installCmd = exec.Command("bash", "-c", `
|
||||||
zypper install -y docker docker-compose &&
|
zypper install -y docker docker-compose &&
|
||||||
systemctl enable docker
|
systemctl enable docker
|
||||||
`)
|
`)
|
||||||
case strings.Contains(osRelease, "ID=rhel") || strings.Contains(osRelease, "ID=\"rhel"):
|
case strings.Contains(osRelease, "ID=rhel") || strings.Contains(osRelease, "ID=\"rhel"):
|
||||||
installCmd = exec.Command("bash", "-c", `
|
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
|
||||||
dnf remove -y runc &&
|
dnf remove -y runc &&
|
||||||
dnf -y install yum-utils &&
|
dnf -y install yum-utils &&
|
||||||
dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo &&
|
dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo &&
|
||||||
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin &&
|
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin &&
|
||||||
systemctl enable docker
|
systemctl enable docker
|
||||||
`)
|
`))
|
||||||
case strings.Contains(osRelease, "ID=amzn"):
|
case strings.Contains(osRelease, "ID=amzn"):
|
||||||
installCmd = exec.Command("bash", "-c", `
|
installCmd = exec.Command("bash", "-c", `
|
||||||
yum update -y &&
|
yum update -y &&
|
||||||
@@ -332,7 +386,6 @@ func installDocker() error {
|
|||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported Linux distribution")
|
return fmt.Errorf("unsupported Linux distribution")
|
||||||
}
|
}
|
||||||
|
|
||||||
installCmd.Stdout = os.Stdout
|
installCmd.Stdout = os.Stdout
|
||||||
installCmd.Stderr = os.Stderr
|
installCmd.Stderr = os.Stderr
|
||||||
return installCmd.Run()
|
return installCmd.Run()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@fosrl/pangolin",
|
"name": "@fosrl/pangolin",
|
||||||
"version": "1.0.0-beta.1",
|
"version": "1.0.0-beta.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
|
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
|
||||||
|
|||||||
BIN
public/logo/pangolin_orange_192x192.png
Normal file
BIN
public/logo/pangolin_orange_192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
BIN
public/logo/pangolin_orange_512x512.png
Normal file
BIN
public/logo/pangolin_orange_512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
public/logo/pangolin_orange_96x96.png
Normal file
BIN
public/logo/pangolin_orange_96x96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
@@ -31,7 +31,7 @@ export function createApiServer() {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const corsOptions = {
|
const corsOptions = {
|
||||||
origin: config.getRawConfig().app.base_url,
|
origin: config.getRawConfig().app.dashboard_url,
|
||||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||||
allowedHeaders: ["Content-Type", "X-CSRF-Token"]
|
allowedHeaders: ["Content-Type", "X-CSRF-Token"]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export async function sendEmailVerificationCode(
|
|||||||
VerifyEmail({
|
VerifyEmail({
|
||||||
username: email,
|
username: email,
|
||||||
verificationCode: code,
|
verificationCode: code,
|
||||||
verifyLink: `${config.getRawConfig().app.base_url}/auth/verify-email`
|
verifyLink: `${config.getRawConfig().app.dashboard_url}/auth/verify-email`
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
to: email,
|
to: email,
|
||||||
|
|||||||
@@ -3,18 +3,25 @@ import yaml from "js-yaml";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
|
import { __DIRNAME, APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
import { loadAppVersion } from "@server/lib/loadAppVersion";
|
import { loadAppVersion } from "@server/lib/loadAppVersion";
|
||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
|
|
||||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||||
|
const hostnameSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$/
|
||||||
|
)
|
||||||
|
.or(z.literal("localhost"));
|
||||||
|
|
||||||
const environmentSchema = z.object({
|
const environmentSchema = z.object({
|
||||||
app: z.object({
|
app: z.object({
|
||||||
base_url: z
|
dashboard_url: z
|
||||||
.string()
|
.string()
|
||||||
.url()
|
.url()
|
||||||
.transform((url) => url.toLowerCase()),
|
.transform((url) => url.toLowerCase()),
|
||||||
|
base_domain: hostnameSchema,
|
||||||
log_level: z.enum(["debug", "info", "warn", "error"]),
|
log_level: z.enum(["debug", "info", "warn", "error"]),
|
||||||
save_logs: z.boolean()
|
save_logs: z.boolean()
|
||||||
}),
|
}),
|
||||||
@@ -38,7 +45,8 @@ const environmentSchema = z.object({
|
|||||||
base_endpoint: z.string().transform((url) => url.toLowerCase()),
|
base_endpoint: z.string().transform((url) => url.toLowerCase()),
|
||||||
use_subdomain: z.boolean(),
|
use_subdomain: z.boolean(),
|
||||||
subnet_group: z.string(),
|
subnet_group: z.string(),
|
||||||
block_size: z.number().positive().gt(0)
|
block_size: z.number().positive().gt(0),
|
||||||
|
site_block_size: z.number().positive().gt(0)
|
||||||
}),
|
}),
|
||||||
rate_limits: z.object({
|
rate_limits: z.object({
|
||||||
global: z.object({
|
global: z.object({
|
||||||
@@ -58,7 +66,7 @@ const environmentSchema = z.object({
|
|||||||
smtp_port: portSchema,
|
smtp_port: portSchema,
|
||||||
smtp_user: z.string(),
|
smtp_user: z.string(),
|
||||||
smtp_pass: z.string(),
|
smtp_pass: z.string(),
|
||||||
no_reply: z.string().email(),
|
no_reply: z.string().email()
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
users: z.object({
|
users: z.object({
|
||||||
@@ -99,9 +107,6 @@ export class Config {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const configFilePath1 = path.join(APP_PATH, "config.yml");
|
|
||||||
const configFilePath2 = path.join(APP_PATH, "config.yaml");
|
|
||||||
|
|
||||||
let environment: any;
|
let environment: any;
|
||||||
if (fs.existsSync(configFilePath1)) {
|
if (fs.existsSync(configFilePath1)) {
|
||||||
environment = loadConfig(configFilePath1);
|
environment = loadConfig(configFilePath1);
|
||||||
@@ -190,15 +195,7 @@ export class Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getBaseDomain(): string {
|
public getBaseDomain(): string {
|
||||||
const newUrl = new URL(this.rawConfig.app.base_url);
|
return this.rawConfig.app.base_domain;
|
||||||
const hostname = newUrl.hostname;
|
|
||||||
const parts = hostname.split(".");
|
|
||||||
|
|
||||||
if (parts.length <= 2) {
|
|
||||||
return parts.join(".");
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.slice(1).join(".");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ export const __FILENAME = fileURLToPath(import.meta.url);
|
|||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|
||||||
export const APP_PATH = path.join("config");
|
export const APP_PATH = path.join("config");
|
||||||
|
|
||||||
|
export const configFilePath1 = path.join(APP_PATH, "config.yml");
|
||||||
|
export const configFilePath2 = path.join(APP_PATH, "config.yaml");
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export async function requestPasswordReset(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = `${config.getRawConfig().app.base_url}/auth/reset-password?email=${email}&token=${token}`;
|
const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`;
|
||||||
|
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
ResetPasswordCode({
|
ResetPasswordCode({
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export async function verifyResourceSession(
|
|||||||
return allowed(res);
|
return allowed(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUrl = `${config.getRawConfig().app.base_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
||||||
|
|
||||||
if (!sessions) {
|
if (!sessions) {
|
||||||
return notAllowed(res);
|
return notAllowed(res);
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ export async function createOrg(
|
|||||||
let org: Org | null = null;
|
let org: Org | null = null;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
// create a url from config.getRawConfig().app.base_url and get the hostname
|
|
||||||
const domain = config.getBaseDomain();
|
const domain = config.getBaseDomain();
|
||||||
|
|
||||||
const newOrg = await trx
|
const newOrg = await trx
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||||
import { generateId } from "@server/auth/sessions/app";
|
import { generateId } from "@server/auth/sessions/app";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export type PickSiteDefaultsResponse = {
|
export type PickSiteDefaultsResponse = {
|
||||||
exitNodeId: number;
|
exitNodeId: number;
|
||||||
@@ -51,9 +52,9 @@ export async function pickSiteDefaults(
|
|||||||
|
|
||||||
// TODO: we need to lock this subnet for some time so someone else does not take it
|
// TODO: we need to lock this subnet for some time so someone else does not take it
|
||||||
let subnets = sitesQuery.map((site) => site.subnet);
|
let subnets = sitesQuery.map((site) => site.subnet);
|
||||||
// exclude the exit node address by replacing after the / with a /28
|
// exclude the exit node address by replacing after the / with a site block size
|
||||||
subnets.push(exitNode.address.replace(/\/\d+$/, "/29"));
|
subnets.push(exitNode.address.replace(/\/\d+$/, `/${config.getRawConfig().gerbil.site_block_size}`));
|
||||||
const newSubnet = findNextAvailableCidr(subnets, 29, exitNode.address);
|
const newSubnet = findNextAvailableCidr(subnets, config.getRawConfig().gerbil.site_block_size, exitNode.address);
|
||||||
if (!newSubnet) {
|
if (!newSubnet) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|||||||
@@ -12,6 +12,34 @@ import { isIpInCidr } from "@server/lib/ip";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { addTargets } from "../newt/targets";
|
import { addTargets } from "../newt/targets";
|
||||||
|
|
||||||
|
// Regular expressions for validation
|
||||||
|
const DOMAIN_REGEX =
|
||||||
|
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||||
|
const IPV4_REGEX =
|
||||||
|
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||||
|
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
|
||||||
|
|
||||||
|
// Schema for domain names and IP addresses
|
||||||
|
const domainSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1, "Domain cannot be empty")
|
||||||
|
.max(255, "Domain name too long")
|
||||||
|
.refine(
|
||||||
|
(value) => {
|
||||||
|
// Check if it's a valid IP address (v4 or v6)
|
||||||
|
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a valid domain name
|
||||||
|
return DOMAIN_REGEX.test(value);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Invalid domain name or IP address format",
|
||||||
|
path: ["domain"]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const createTargetParamsSchema = z
|
const createTargetParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
resourceId: z
|
resourceId: z
|
||||||
@@ -23,7 +51,7 @@ const createTargetParamsSchema = z
|
|||||||
|
|
||||||
const createTargetSchema = z
|
const createTargetSchema = z
|
||||||
.object({
|
.object({
|
||||||
ip: z.string().ip().or(z.literal('localhost')),
|
ip: domainSchema,
|
||||||
method: z.string().min(1).max(10),
|
method: z.string().min(1).max(10),
|
||||||
port: z.number().int().min(1).max(65535),
|
port: z.number().int().min(1).max(65535),
|
||||||
protocol: z.string().optional(),
|
protocol: z.string().optional(),
|
||||||
|
|||||||
@@ -11,6 +11,34 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { addPeer } from "../gerbil/peers";
|
import { addPeer } from "../gerbil/peers";
|
||||||
import { addTargets } from "../newt/targets";
|
import { addTargets } from "../newt/targets";
|
||||||
|
|
||||||
|
// Regular expressions for validation
|
||||||
|
const DOMAIN_REGEX =
|
||||||
|
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||||
|
const IPV4_REGEX =
|
||||||
|
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||||
|
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
|
||||||
|
|
||||||
|
// Schema for domain names and IP addresses
|
||||||
|
const domainSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1, "Domain cannot be empty")
|
||||||
|
.max(255, "Domain name too long")
|
||||||
|
.refine(
|
||||||
|
(value) => {
|
||||||
|
// Check if it's a valid IP address (v4 or v6)
|
||||||
|
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a valid domain name
|
||||||
|
return DOMAIN_REGEX.test(value);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Invalid domain name or IP address format",
|
||||||
|
path: ["domain"]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const updateTargetParamsSchema = z
|
const updateTargetParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
targetId: z.string().transform(Number).pipe(z.number().int().positive())
|
targetId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||||
@@ -19,7 +47,7 @@ const updateTargetParamsSchema = z
|
|||||||
|
|
||||||
const updateTargetBodySchema = z
|
const updateTargetBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
ip: z.string().ip().or(z.literal('localhost')).optional(), // for now we cant update the ip; you will have to delete
|
ip: domainSchema.optional(),
|
||||||
method: z.string().min(1).max(10).optional(),
|
method: z.string().min(1).max(10).optional(),
|
||||||
port: z.number().int().min(1).max(65535).optional(),
|
port: z.number().int().min(1).max(65535).optional(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ export async function acceptInvite(
|
|||||||
|
|
||||||
const { user, session } = await verifySession(req);
|
const { user, session } = await verifySession(req);
|
||||||
|
|
||||||
|
// at this point we know the user exists
|
||||||
|
if (!user) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.UNAUTHORIZED,
|
||||||
|
"You must be logged in to accept an invite"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (user && user.email !== existingInvite.email) {
|
if (user && user.email !== existingInvite.email) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export async function inviteUser(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const inviteLink = `${config.getRawConfig().app.base_url}/invite?token=${inviteId}-${token}`;
|
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}`;
|
||||||
|
|
||||||
if (doEmail) {
|
if (doEmail) {
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { eq, ne } from "drizzle-orm";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export async function copyInConfig() {
|
export async function copyInConfig() {
|
||||||
// create a url from config.getRawConfig().app.base_url and get the hostname
|
|
||||||
const domain = config.getBaseDomain();
|
const domain = config.getBaseDomain();
|
||||||
const endpoint = config.getRawConfig().gerbil.base_endpoint;
|
const endpoint = config.getRawConfig().gerbil.base_endpoint;
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,17 @@ import { desc } from "drizzle-orm";
|
|||||||
import { __DIRNAME } from "@server/lib/consts";
|
import { __DIRNAME } from "@server/lib/consts";
|
||||||
import { loadAppVersion } from "@server/lib/loadAppVersion";
|
import { loadAppVersion } from "@server/lib/loadAppVersion";
|
||||||
import m1 from "./scripts/1.0.0-beta1";
|
import m1 from "./scripts/1.0.0-beta1";
|
||||||
|
import m2 from "./scripts/1.0.0-beta2";
|
||||||
|
import m3 from "./scripts/1.0.0-beta3";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
|
|
||||||
// Define the migration list with versions and their corresponding functions
|
// Define the migration list with versions and their corresponding functions
|
||||||
const migrations = [
|
const migrations = [
|
||||||
{ version: "1.0.0-beta.1", run: m1 }
|
{ version: "1.0.0-beta.1", run: m1 },
|
||||||
|
{ version: "1.0.0-beta.2", run: m2 },
|
||||||
|
{ version: "1.0.0-beta.3", run: m3 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
export default async function migration() {
|
export default async function migration() {
|
||||||
console.log("Running setup script 1.0.0-beta.1");
|
console.log("Running setup script 1.0.0-beta.1...");
|
||||||
// SQL operations would go here in ts format
|
// SQL operations would go here in ts format
|
||||||
console.log("Done...");
|
console.log("Done.");
|
||||||
}
|
}
|
||||||
|
|||||||
59
server/setup/scripts/1.0.0-beta2.ts
Normal file
59
server/setup/scripts/1.0.0-beta2.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
|
import fs from "fs";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log("Running setup script 1.0.0-beta.2...");
|
||||||
|
|
||||||
|
// Determine which config file exists
|
||||||
|
const filePaths = [configFilePath1, configFilePath2];
|
||||||
|
let filePath = "";
|
||||||
|
for (const path of filePaths) {
|
||||||
|
if (fs.existsSync(path)) {
|
||||||
|
filePath = path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
throw new Error(
|
||||||
|
`No config file found (expected config.yml or config.yaml).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and parse the YAML file
|
||||||
|
let rawConfig: any;
|
||||||
|
const fileContents = fs.readFileSync(filePath, "utf8");
|
||||||
|
rawConfig = yaml.load(fileContents);
|
||||||
|
|
||||||
|
// Validate the structure
|
||||||
|
if (!rawConfig.app || !rawConfig.app.base_url) {
|
||||||
|
throw new Error(`Invalid config file: app.base_url is missing.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move base_url to dashboard_url and calculate base_domain
|
||||||
|
const baseUrl = rawConfig.app.base_url;
|
||||||
|
rawConfig.app.dashboard_url = baseUrl;
|
||||||
|
rawConfig.app.base_domain = getBaseDomain(baseUrl);
|
||||||
|
|
||||||
|
// Remove the old base_url
|
||||||
|
delete rawConfig.app.base_url;
|
||||||
|
|
||||||
|
// Write the updated YAML back to the file
|
||||||
|
const updatedYaml = yaml.dump(rawConfig);
|
||||||
|
fs.writeFileSync(filePath, updatedYaml, "utf8");
|
||||||
|
|
||||||
|
console.log("Done.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseDomain(url: string): string {
|
||||||
|
const newUrl = new URL(url);
|
||||||
|
const hostname = newUrl.hostname;
|
||||||
|
const parts = hostname.split(".");
|
||||||
|
|
||||||
|
if (parts.length <= 2) {
|
||||||
|
return parts.join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.slice(-2).join(".");
|
||||||
|
}
|
||||||
42
server/setup/scripts/1.0.0-beta3.ts
Normal file
42
server/setup/scripts/1.0.0-beta3.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
|
import fs from "fs";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log("Running setup script 1.0.0-beta.3...");
|
||||||
|
|
||||||
|
// Determine which config file exists
|
||||||
|
const filePaths = [configFilePath1, configFilePath2];
|
||||||
|
let filePath = "";
|
||||||
|
for (const path of filePaths) {
|
||||||
|
if (fs.existsSync(path)) {
|
||||||
|
filePath = path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
throw new Error(
|
||||||
|
`No config file found (expected config.yml or config.yaml).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and parse the YAML file
|
||||||
|
let rawConfig: any;
|
||||||
|
const fileContents = fs.readFileSync(filePath, "utf8");
|
||||||
|
rawConfig = yaml.load(fileContents);
|
||||||
|
|
||||||
|
// Validate the structure
|
||||||
|
if (!rawConfig.gerbil) {
|
||||||
|
throw new Error(`Invalid config file: gerbil is missing.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the config
|
||||||
|
rawConfig.gerbil.site_block_size = 29;
|
||||||
|
|
||||||
|
// Write the updated YAML back to the file
|
||||||
|
const updatedYaml = yaml.dump(rawConfig);
|
||||||
|
fs.writeFileSync(filePath, updatedYaml, "utf8");
|
||||||
|
|
||||||
|
console.log("Done.");
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ export default async function OrgLayout(props: {
|
|||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect(`/?redirect=/${orgId}`);
|
redirect(`/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default async function GeneralSettingsPage({
|
|||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect(`/?redirect=/${orgId}/settings/general`);
|
redirect(`/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let orgUser = null;
|
let orgUser = null;
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect(`/?redirect=/${params.orgId}/`);
|
redirect(`/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookie = await authCookieHeader();
|
const cookie = await authCookieHeader();
|
||||||
|
|||||||
@@ -62,9 +62,38 @@ import {
|
|||||||
SettingsSectionFooter
|
SettingsSectionFooter
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { useSiteContext } from "@app/hooks/useSiteContext";
|
||||||
|
|
||||||
|
// Regular expressions for validation
|
||||||
|
const DOMAIN_REGEX =
|
||||||
|
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||||
|
const IPV4_REGEX =
|
||||||
|
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||||
|
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
|
||||||
|
|
||||||
|
// Schema for domain names and IP addresses
|
||||||
|
const domainSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1, "Domain cannot be empty")
|
||||||
|
.max(255, "Domain name too long")
|
||||||
|
.refine(
|
||||||
|
(value) => {
|
||||||
|
// Check if it's a valid IP address (v4 or v6)
|
||||||
|
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a valid domain name
|
||||||
|
return DOMAIN_REGEX.test(value);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Invalid domain name or IP address format",
|
||||||
|
path: ["domain"]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const addTargetSchema = z.object({
|
const addTargetSchema = z.object({
|
||||||
ip: z.union([z.string().ip(), z.literal("localhost")]),
|
ip: domainSchema,
|
||||||
method: z.string(),
|
method: z.string(),
|
||||||
port: z.coerce.number().int().positive()
|
port: z.coerce.number().int().positive()
|
||||||
// protocol: z.string(),
|
// protocol: z.string(),
|
||||||
@@ -179,7 +208,7 @@ export default function ReverseProxyTargets(props: {
|
|||||||
// make sure that the target IP is within the site subnet
|
// make sure that the target IP is within the site subnet
|
||||||
const targetIp = data.ip;
|
const targetIp = data.ip;
|
||||||
const subnet = site.subnet;
|
const subnet = site.subnet;
|
||||||
if (targetIp === "localhost" || !isIPInSubnet(targetIp, subnet)) {
|
if (!isIPInSubnet(targetIp, subnet)) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Invalid target IP",
|
title: "Invalid target IP",
|
||||||
@@ -323,7 +352,7 @@ export default function ReverseProxyTargets(props: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "ip",
|
accessorKey: "ip",
|
||||||
header: "IP Address",
|
header: "IP / Hostname",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Input
|
<Input
|
||||||
defaultValue={row.original.ip}
|
defaultValue={row.original.ip}
|
||||||
@@ -500,11 +529,23 @@ export default function ReverseProxyTargets(props: {
|
|||||||
name="ip"
|
name="ip"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>IP Address</FormLabel>
|
<FormLabel>IP / Hostname</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input id="ip" {...field} />
|
<Input id="ip" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
{site?.type === "newt" ? (
|
||||||
|
<FormDescription>
|
||||||
|
This is the IP or hostname
|
||||||
|
of the target service on
|
||||||
|
your network.
|
||||||
|
</FormDescription>
|
||||||
|
) : site?.type === "wireguard" ? (
|
||||||
|
<FormDescription>
|
||||||
|
This is the IP of the
|
||||||
|
WireGuard peer.
|
||||||
|
</FormDescription>
|
||||||
|
) : null}
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -523,6 +564,19 @@ export default function ReverseProxyTargets(props: {
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
{site?.type === "newt" ? (
|
||||||
|
<FormDescription>
|
||||||
|
This is the port of the
|
||||||
|
target service on your
|
||||||
|
network.
|
||||||
|
</FormDescription>
|
||||||
|
) : site?.type === "wireguard" ? (
|
||||||
|
<FormDescription>
|
||||||
|
This is the port exposed on
|
||||||
|
an address on the WireGuard
|
||||||
|
network.
|
||||||
|
</FormDescription>
|
||||||
|
) : null}
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ import ResourceProvider from "@app/providers/ResourceProvider";
|
|||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import {
|
import {
|
||||||
GetResourceAuthInfoResponse,
|
GetResourceAuthInfoResponse,
|
||||||
GetResourceResponse,
|
GetResourceResponse
|
||||||
} from "@server/routers/resource";
|
} from "@server/routers/resource";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { SidebarSettings } from "@app/components/SidebarSettings";
|
import { SidebarSettings } from "@app/components/SidebarSettings";
|
||||||
import { Cloud, Settings, Shield } from "lucide-react";
|
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { GetOrgResponse } from "@server/routers/org";
|
import { GetOrgResponse } from "@server/routers/org";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
@@ -20,7 +19,7 @@ import {
|
|||||||
BreadcrumbLink,
|
BreadcrumbLink,
|
||||||
BreadcrumbList,
|
BreadcrumbList,
|
||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator
|
||||||
} from "@app/components/ui/breadcrumb";
|
} from "@app/components/ui/breadcrumb";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||||||
try {
|
try {
|
||||||
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
|
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
|
||||||
`/resource/${params.resourceId}`,
|
`/resource/${params.resourceId}`,
|
||||||
await authCookieHeader(),
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
resource = res.data.data;
|
resource = res.data.data;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -68,8 +67,8 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||||||
const getOrg = cache(async () =>
|
const getOrg = cache(async () =>
|
||||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
internal.get<AxiosResponse<GetOrgResponse>>(
|
||||||
`/org/${params.orgId}`,
|
`/org/${params.orgId}`,
|
||||||
await authCookieHeader(),
|
await authCookieHeader()
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
const res = await getOrg();
|
const res = await getOrg();
|
||||||
org = res.data.data;
|
org = res.data.data;
|
||||||
@@ -84,19 +83,19 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||||||
const sidebarNavItems = [
|
const sidebarNavItems = [
|
||||||
{
|
{
|
||||||
title: "General",
|
title: "General",
|
||||||
href: `/{orgId}/settings/resources/{resourceId}/general`,
|
href: `/{orgId}/settings/resources/{resourceId}/general`
|
||||||
// icon: <Settings className="w-4 h-4" />,
|
// icon: <Settings className="w-4 h-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Connectivity",
|
title: "Connectivity",
|
||||||
href: `/{orgId}/settings/resources/{resourceId}/connectivity`,
|
href: `/{orgId}/settings/resources/{resourceId}/connectivity`
|
||||||
// icon: <Cloud className="w-4 h-4" />,
|
// icon: <Cloud className="w-4 h-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Authentication",
|
title: "Authentication",
|
||||||
href: `/{orgId}/settings/resources/{resourceId}/authentication`,
|
href: `/{orgId}/settings/resources/{resourceId}/authentication`
|
||||||
// icon: <Shield className="w-4 h-4" />,
|
// icon: <Shield className="w-4 h-4" />,
|
||||||
},
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
|||||||
<>
|
<>
|
||||||
{user && (
|
{user && (
|
||||||
<UserProvider user={user}>
|
<UserProvider user={user}>
|
||||||
<div>
|
<div className="p-3">
|
||||||
<ProfileIcon />
|
<ProfileIcon />
|
||||||
</div>
|
</div>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
|
|
||||||
type DashboardLoginFormProps = {
|
type DashboardLoginFormProps = {
|
||||||
redirect?: string;
|
redirect?: string;
|
||||||
@@ -57,10 +58,9 @@ export default function DashboardLoginForm({
|
|||||||
<LoginForm
|
<LoginForm
|
||||||
redirect={redirect}
|
redirect={redirect}
|
||||||
onLogin={() => {
|
onLogin={() => {
|
||||||
if (redirect && redirect.includes("http")) {
|
if (redirect) {
|
||||||
window.location.href = redirect;
|
const safe = cleanRedirect(redirect);
|
||||||
} else if (redirect) {
|
router.push(safe);
|
||||||
router.push(redirect);
|
|
||||||
} else {
|
} else {
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { cache } from "react";
|
|||||||
import DashboardLoginForm from "./DashboardLoginForm";
|
import DashboardLoginForm from "./DashboardLoginForm";
|
||||||
import { Mail } from "lucide-react";
|
import { Mail } from "lucide-react";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -25,6 +26,11 @@ export default async function Page(props: {
|
|||||||
redirect("/");
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let redirectUrl: string | undefined = undefined;
|
||||||
|
if (searchParams.redirect) {
|
||||||
|
redirectUrl = cleanRedirect(searchParams.redirect as string);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isInvite && (
|
{isInvite && (
|
||||||
@@ -42,16 +48,16 @@ export default async function Page(props: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DashboardLoginForm redirect={searchParams.redirect as string} />
|
<DashboardLoginForm redirect={redirectUrl} />
|
||||||
|
|
||||||
{(!signUpDisabled || isInvite) && (
|
{(!signUpDisabled || isInvite) && (
|
||||||
<p className="text-center text-muted-foreground mt-4">
|
<p className="text-center text-muted-foreground mt-4">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
!searchParams.redirect
|
!redirectUrl
|
||||||
? `/auth/signup`
|
? `/auth/signup`
|
||||||
: `/auth/signup?redirect=${searchParams.redirect}`
|
: `/auth/signup?redirect=${redirectUrl}`
|
||||||
}
|
}
|
||||||
className="underline"
|
className="underline"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { createApiClient } from "@app/lib/api";
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
email: z.string().email()
|
email: z.string().email()
|
||||||
@@ -186,11 +187,9 @@ export default function ResetPasswordForm({
|
|||||||
setSuccessMessage("Password reset successfully! Back to login...");
|
setSuccessMessage("Password reset successfully! Back to login...");
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (redirect && redirect.includes("http")) {
|
|
||||||
window.location.href = redirect;
|
|
||||||
}
|
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
router.push(redirect);
|
const safe = cleanRedirect(redirect);
|
||||||
|
router.push(safe);
|
||||||
} else {
|
} else {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import ResetPasswordForm from "./ResetPasswordForm";
|
import ResetPasswordForm from "./ResetPasswordForm";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -21,6 +22,11 @@ export default async function Page(props: {
|
|||||||
redirect("/");
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let redirectUrl: string | undefined = undefined;
|
||||||
|
if (searchParams.redirect) {
|
||||||
|
redirectUrl = cleanRedirect(searchParams.redirect);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ResetPasswordForm
|
<ResetPasswordForm
|
||||||
@@ -34,7 +40,7 @@ export default async function Page(props: {
|
|||||||
href={
|
href={
|
||||||
!searchParams.redirect
|
!searchParams.redirect
|
||||||
? `/auth/signup`
|
? `/auth/signup`
|
||||||
: `/auth/signup?redirect=${searchParams.redirect}`
|
: `/auth/signup?redirect=${redirectUrl}`
|
||||||
}
|
}
|
||||||
className="underline"
|
className="underline"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -481,11 +481,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||||||
className={`${numMethods <= 1 ? "mt-0" : ""}`}
|
className={`${numMethods <= 1 ? "mt-0" : ""}`}
|
||||||
>
|
>
|
||||||
<LoginForm
|
<LoginForm
|
||||||
redirect={
|
redirect={`/auth/resource/${props.resource.id}`}
|
||||||
typeof window !== "undefined"
|
|
||||||
? window.location.href
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
onLogin={async () =>
|
onLogin={async () =>
|
||||||
await handleSSOAuth()
|
await handleSSOAuth()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,17 @@ export default async function ResourceAuthPage(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUrl = searchParams.redirect || authInfo.url;
|
let redirectUrl = authInfo.url;
|
||||||
|
if (searchParams.redirect) {
|
||||||
|
try {
|
||||||
|
const serverResourceHost = new URL(authInfo.url).host;
|
||||||
|
const redirectHost = new URL(searchParams.redirect).host;
|
||||||
|
|
||||||
|
if (serverResourceHost === redirectHost) {
|
||||||
|
redirectUrl = searchParams.redirect;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
const hasAuth =
|
const hasAuth =
|
||||||
authInfo.password ||
|
authInfo.password ||
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { formatAxiosError } from "@app/lib/api";
|
|||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
|
|
||||||
type SignupFormProps = {
|
type SignupFormProps = {
|
||||||
redirect?: string;
|
redirect?: string;
|
||||||
@@ -92,17 +93,17 @@ export default function SignupForm({
|
|||||||
|
|
||||||
if (res.data?.data?.emailVerificationRequired) {
|
if (res.data?.data?.emailVerificationRequired) {
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
router.push(`/auth/verify-email?redirect=${redirect}`);
|
const safe = cleanRedirect(redirect);
|
||||||
|
router.push(`/auth/verify-email?redirect=${safe}`);
|
||||||
} else {
|
} else {
|
||||||
router.push("/auth/verify-email");
|
router.push("/auth/verify-email");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (redirect && redirect.includes("http")) {
|
if (redirect) {
|
||||||
window.location.href = redirect;
|
const safe = cleanRedirect(redirect);
|
||||||
} else if (redirect) {
|
router.push(safe);
|
||||||
router.push(redirect);
|
|
||||||
} else {
|
} else {
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SignupForm from "@app/app/auth/signup/SignupForm";
|
import SignupForm from "@app/app/auth/signup/SignupForm";
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import { Mail } from "lucide-react";
|
import { Mail } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -41,6 +42,11 @@ export default async function Page(props: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let redirectUrl: string | undefined;
|
||||||
|
if (searchParams.redirect) {
|
||||||
|
redirectUrl = cleanRedirect(searchParams.redirect);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isInvite && (
|
{isInvite && (
|
||||||
@@ -59,7 +65,7 @@ export default async function Page(props: {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<SignupForm
|
<SignupForm
|
||||||
redirect={searchParams.redirect as string}
|
redirect={redirectUrl}
|
||||||
inviteToken={inviteToken}
|
inviteToken={inviteToken}
|
||||||
inviteId={inviteId}
|
inviteId={inviteId}
|
||||||
/>
|
/>
|
||||||
@@ -68,9 +74,9 @@ export default async function Page(props: {
|
|||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
!searchParams.redirect
|
!redirectUrl
|
||||||
? `/auth/login`
|
? `/auth/login`
|
||||||
: `/auth/login?redirect=${searchParams.redirect}`
|
: `/auth/login?redirect=${redirectUrl}`
|
||||||
}
|
}
|
||||||
className="underline"
|
className="underline"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";;
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
|
|
||||||
const FormSchema = z.object({
|
const FormSchema = z.object({
|
||||||
email: z.string().email({ message: "Invalid email address" }),
|
email: z.string().email({ message: "Invalid email address" }),
|
||||||
@@ -91,11 +92,9 @@ export default function VerifyEmailForm({
|
|||||||
"Email successfully verified! Redirecting you..."
|
"Email successfully verified! Redirecting you..."
|
||||||
);
|
);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (redirect && redirect.includes("http")) {
|
|
||||||
window.location.href = redirect;
|
|
||||||
}
|
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
router.push(redirect);
|
const safe = cleanRedirect(redirect);
|
||||||
|
router.push(safe);
|
||||||
} else {
|
} else {
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm";
|
import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm";
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
@@ -27,11 +28,16 @@ export default async function Page(props: {
|
|||||||
redirect("/");
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let redirectUrl: string | undefined;
|
||||||
|
if (searchParams.redirect) {
|
||||||
|
redirectUrl = cleanRedirect(searchParams.redirect as string);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<VerifyEmailForm
|
<VerifyEmailForm
|
||||||
email={user.email}
|
email={user.email}
|
||||||
redirect={searchParams.redirect as string}
|
redirect={redirectUrl}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { XCircle } from "lucide-react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
type InviteStatusCardProps = {
|
type InviteStatusCardProps = {
|
||||||
type: "rejected" | "wrong_user" | "user_does_not_exist";
|
type: "rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in";
|
||||||
token: string;
|
token: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export default async function InvitePage(props: {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return "user_does_not_exist";
|
return "user_does_not_exist";
|
||||||
|
} else if (error.includes("You must be logged in to accept an invite")) {
|
||||||
|
return "not_logged_in";
|
||||||
} else {
|
} else {
|
||||||
return "rejected";
|
return "rejected";
|
||||||
}
|
}
|
||||||
@@ -71,6 +73,10 @@ export default async function InvitePage(props: {
|
|||||||
redirect(`/auth/signup?redirect=/invite?token=${params.token}`);
|
redirect(`/auth/signup?redirect=/invite?token=${params.token}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user && type === "not_logged_in") {
|
||||||
|
redirect(`/auth/login?redirect=/invite?token=${params.token}`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<InviteStatusCard type={type} token={tokenParam} />
|
<InviteStatusCard type={type} token={tokenParam} />
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { ThemeProvider } from "@app/providers/ThemeProvider";
|
|||||||
import EnvProvider from "@app/providers/EnvProvider";
|
import EnvProvider from "@app/providers/EnvProvider";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
import { Separator } from "@app/components/ui/separator";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
|
import { BookOpenText } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `Dashboard - Pangolin`,
|
title: `Dashboard - Pangolin`,
|
||||||
@@ -38,10 +40,10 @@ export default async function RootLayout({
|
|||||||
<div className="flex-grow">{children}</div>
|
<div className="flex-grow">{children}</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="w-full mt-12 py-3 mb-6">
|
<footer className="w-full mt-12 py-3 mb-6 px-4">
|
||||||
<div className="container mx-auto flex flex-wrap justify-center items-center h-4 space-x-4 text-sm text-neutral-400 select-none">
|
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600 select-none">
|
||||||
<div className="whitespace-nowrap">
|
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||||
Pangolin
|
<span>Pangolin</span>
|
||||||
</div>
|
</div>
|
||||||
<Separator orientation="vertical" />
|
<Separator orientation="vertical" />
|
||||||
<div className="whitespace-nowrap">
|
<div className="whitespace-nowrap">
|
||||||
@@ -60,7 +62,7 @@ export default async function RootLayout({
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
className="w-4 h-4"
|
className="w-3 h-3"
|
||||||
>
|
>
|
||||||
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
|
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -70,10 +72,11 @@ export default async function RootLayout({
|
|||||||
href="https://docs.fossorial.io/Pangolin/overview"
|
href="https://docs.fossorial.io/Pangolin/overview"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label="GitHub"
|
aria-label="Documentation"
|
||||||
className="flex items-center space-x-3 whitespace-nowrap"
|
className="flex items-center space-x-3 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<span>Docs</span>
|
<span>Documentation</span>
|
||||||
|
<BookOpenText className="w-3 h-3" />
|
||||||
</a>
|
</a>
|
||||||
{version && (
|
{version && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import OrganizationLanding from "./components/OrganizationLanding";
|
import OrganizationLanding from "./components/OrganizationLanding";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -29,7 +30,8 @@ export default async function Page(props: {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
if (params.redirect) {
|
if (params.redirect) {
|
||||||
redirect(`/auth/login?redirect=${params.redirect}`);
|
const safe = cleanRedirect(params.redirect);
|
||||||
|
redirect(`/auth/login?redirect=${safe}`);
|
||||||
} else {
|
} else {
|
||||||
redirect(`/auth/login`);
|
redirect(`/auth/login`);
|
||||||
}
|
}
|
||||||
@@ -40,7 +42,8 @@ export default async function Page(props: {
|
|||||||
env.flags.emailVerificationRequired
|
env.flags.emailVerificationRequired
|
||||||
) {
|
) {
|
||||||
if (params.redirect) {
|
if (params.redirect) {
|
||||||
redirect(`/auth/verify-email?redirect=${params.redirect}`);
|
const safe = cleanRedirect(params.redirect);
|
||||||
|
redirect(`/auth/verify-email?redirect=${safe}`);
|
||||||
} else {
|
} else {
|
||||||
redirect(`/auth/verify-email`);
|
redirect(`/auth/verify-email`);
|
||||||
}
|
}
|
||||||
@@ -80,6 +83,7 @@ export default async function Page(props: {
|
|||||||
|
|
||||||
<div className="w-full max-w-md mx-auto md:mt-32 mt-4">
|
<div className="w-full max-w-md mx-auto md:mt-32 mt-4">
|
||||||
<OrganizationLanding
|
<OrganizationLanding
|
||||||
|
disableCreateOrg={env.flags.disableUserCreateOrg && !user.serverAdmin}
|
||||||
organizations={orgs.map((org) => ({
|
organizations={orgs.map((org) => ({
|
||||||
name: org.name,
|
name: org.name,
|
||||||
id: org.orgId
|
id: org.orgId
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import Image from 'next/image'
|
|||||||
|
|
||||||
type LoginFormProps = {
|
type LoginFormProps = {
|
||||||
redirect?: string;
|
redirect?: string;
|
||||||
onLogin?: () => void;
|
onLogin?: () => void | Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default function ProfileIcon() {
|
|||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
router.push("/auth/login");
|
router.push("/auth/login");
|
||||||
|
router.refresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
src/lib/cleanRedirect.ts
Normal file
18
src/lib/cleanRedirect.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
type PatternConfig = {
|
||||||
|
name: string;
|
||||||
|
regex: RegExp;
|
||||||
|
};
|
||||||
|
|
||||||
|
const patterns: PatternConfig[] = [
|
||||||
|
{ name: "Invite Token", regex: /^\/invite\?token=[a-zA-Z0-9-]+$/ },
|
||||||
|
{ name: "Setup", regex: /^\/setup$/ },
|
||||||
|
{ name: "Resource Auth Portal", regex: /^\/auth\/resource\/\d+$/ }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function cleanRedirect(input: string): string {
|
||||||
|
if (!input || typeof input !== "string") {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
const isAccepted = patterns.some((pattern) => pattern.regex.test(input));
|
||||||
|
return isAccepted ? input : "/";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user