mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-31 16:14:46 +02:00
Merge pull request #32 from fosrl/dev
add site_block_size to config, improve target input form validation, and lock down redirects
This commit is contained in:
commit
5774e534e5
37 changed files with 273 additions and 86 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -30,3 +30,4 @@ dist
|
|||
.dist
|
||||
installer
|
||||
*.tar
|
||||
bin
|
||||
|
|
5
Makefile
5
Makefile
|
@ -1,4 +1,4 @@
|
|||
build-all:
|
||||
build-release:
|
||||
@if [ -z "$(tag)" ]; then \
|
||||
echo "Error: tag is required. Usage: make build-all tag=<tag>"; \
|
||||
exit 1; \
|
||||
|
@ -12,6 +12,9 @@ build-arm:
|
|||
build-x86:
|
||||
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:
|
||||
docker build -t fosrl/pangolin:latest .
|
||||
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
# 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.
|
||||
|
||||
### Installation and Documentation
|
||||
|
|
|
@ -21,8 +21,9 @@ traefik:
|
|||
gerbil:
|
||||
start_port: 51820
|
||||
base_endpoint: localhost
|
||||
block_size: 16
|
||||
subnet_group: 10.0.0.0/8
|
||||
block_size: 24
|
||||
site_block_size: 30
|
||||
subnet_group: 100.89.137.0/20
|
||||
use_subdomain: true
|
||||
|
||||
rate_limits:
|
||||
|
|
|
@ -1,8 +1,14 @@
|
|||
|
||||
all: build
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o installer
|
||||
build:
|
||||
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:
|
||||
rm installer
|
||||
rm bin/installer
|
||||
rm bin/installer_linux_amd64
|
||||
rm bin/installer_linux_arm64
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
app:
|
||||
dashboard_url: https://{{.Domain}}
|
||||
base_domain: {{.Domain}}
|
||||
dashboard_url: https://{{.DashboardDomain}}
|
||||
base_domain: {{.BaseDomain}}
|
||||
log_level: info
|
||||
save_logs: false
|
||||
|
||||
|
@ -21,10 +21,11 @@ traefik:
|
|||
|
||||
gerbil:
|
||||
start_port: 51820
|
||||
base_endpoint: {{.Domain}}
|
||||
base_endpoint: {{.DashboardDomain}}
|
||||
use_subdomain: false
|
||||
block_size: 16
|
||||
subnet_group: 10.0.0.0/8
|
||||
block_size: 24
|
||||
site_block_size: 30
|
||||
subnet_group: 100.89.137.0/20
|
||||
|
||||
rate_limits:
|
||||
global:
|
||||
|
|
|
@ -8,7 +8,7 @@ http:
|
|||
routers:
|
||||
# HTTP to HTTPS redirect router
|
||||
main-app-router-redirect:
|
||||
rule: "Host(`{{.Domain}}`)"
|
||||
rule: "Host(`{{.DashboardDomain}}`)"
|
||||
service: next-service
|
||||
entryPoints:
|
||||
- web
|
||||
|
@ -17,7 +17,7 @@ http:
|
|||
|
||||
# Next.js router (handles everything except API and WebSocket paths)
|
||||
next-router:
|
||||
rule: "Host(`{{.Domain}}`) && !PathPrefix(`/api/v1`)"
|
||||
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)"
|
||||
service: next-service
|
||||
entryPoints:
|
||||
- websecure
|
||||
|
@ -26,7 +26,7 @@ http:
|
|||
|
||||
# API router (handles /api/v1 paths)
|
||||
api-router:
|
||||
rule: "Host(`{{.Domain}}`) && PathPrefix(`/api/v1`)"
|
||||
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
|
||||
service: api-service
|
||||
entryPoints:
|
||||
- websecure
|
||||
|
@ -35,7 +35,7 @@ http:
|
|||
|
||||
# WebSocket router
|
||||
ws-router:
|
||||
rule: "Host(`{{.Domain}}`)"
|
||||
rule: "Host(`{{.DashboardDomain}}`)"
|
||||
service: api-service
|
||||
entryPoints:
|
||||
- websecure
|
||||
|
|
|
@ -18,7 +18,8 @@ import (
|
|||
var configFiles embed.FS
|
||||
|
||||
type Config struct {
|
||||
Domain string `yaml:"domain"`
|
||||
BaseDomain string `yaml:"baseDomain"`
|
||||
DashboardDomain string `yaml:"dashboardUrl"`
|
||||
LetsEncryptEmail string `yaml:"letsEncryptEmail"`
|
||||
AdminUserEmail string `yaml:"adminUserEmail"`
|
||||
AdminUserPassword string `yaml:"adminUserPassword"`
|
||||
|
@ -44,7 +45,10 @@ func main() {
|
|||
// check if there is already a config file
|
||||
if _, err := os.Stat("config/config.yml"); err != nil {
|
||||
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 shouldInstallDocker() {
|
||||
|
@ -102,12 +106,13 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||
|
||||
// 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", "")
|
||||
|
||||
// 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 {
|
||||
config.AdminUserPassword = readString(reader, "Enter admin user password", "")
|
||||
if valid, message := validatePassword(config.AdminUserPassword); valid {
|
||||
|
@ -140,10 +145,14 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||
}
|
||||
|
||||
// Validate required fields
|
||||
if config.Domain == "" {
|
||||
if config.BaseDomain == "" {
|
||||
fmt.Println("Error: Domain name is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
if config.DashboardDomain == "" {
|
||||
fmt.Println("Error: Dashboard Domain name is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
if config.LetsEncryptEmail == "" {
|
||||
fmt.Println("Error: Let's Encrypt email is required")
|
||||
os.Exit(1)
|
||||
|
@ -269,8 +278,26 @@ func createConfigFiles(config Config) error {
|
|||
return fmt.Errorf("error walking config files: %v", err)
|
||||
}
|
||||
|
||||
// move the docker-compose.yml file to the root directory
|
||||
os.Rename("config/docker-compose.yml", "docker-compose.yml")
|
||||
// get the current directory
|
||||
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
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@fosrl/pangolin",
|
||||
"version": "1.0.0-beta.2",
|
||||
"version": "1.0.0-beta.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
|
||||
|
|
|
@ -45,7 +45,8 @@ const environmentSchema = z.object({
|
|||
base_endpoint: z.string().transform((url) => url.toLowerCase()),
|
||||
use_subdomain: z.boolean(),
|
||||
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({
|
||||
global: z.object({
|
||||
|
|
|
@ -8,6 +8,7 @@ import createHttpError from "http-errors";
|
|||
import logger from "@server/logger";
|
||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export type PickSiteDefaultsResponse = {
|
||||
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
|
||||
let subnets = sitesQuery.map((site) => site.subnet);
|
||||
// exclude the exit node address by replacing after the / with a /28
|
||||
subnets.push(exitNode.address.replace(/\/\d+$/, "/29"));
|
||||
const newSubnet = findNextAvailableCidr(subnets, 29, exitNode.address);
|
||||
// exclude the exit node address by replacing after the / with a site block size
|
||||
subnets.push(exitNode.address.replace(/\/\d+$/, `/${config.getRawConfig().gerbil.site_block_size}`));
|
||||
const newSubnet = findNextAvailableCidr(subnets, config.getRawConfig().gerbil.site_block_size, exitNode.address);
|
||||
if (!newSubnet) {
|
||||
return next(
|
||||
createHttpError(
|
||||
|
|
|
@ -72,6 +72,16 @@ export async function acceptInvite(
|
|||
|
||||
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) {
|
||||
return next(
|
||||
createHttpError(
|
||||
|
|
|
@ -8,6 +8,7 @@ import { __DIRNAME } from "@server/lib/consts";
|
|||
import { loadAppVersion } from "@server/lib/loadAppVersion";
|
||||
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
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
|
@ -15,7 +16,8 @@ import m2 from "./scripts/1.0.0-beta2";
|
|||
// Define the migration list with versions and their corresponding functions
|
||||
const migrations = [
|
||||
{ version: "1.0.0-beta.1", run: m1 },
|
||||
{ version: "1.0.0-beta.2", run: m2 }
|
||||
{ version: "1.0.0-beta.2", run: m2 },
|
||||
{ version: "1.0.0-beta.3", run: m3 }
|
||||
// Add new migrations here as they are created
|
||||
] as const;
|
||||
|
||||
|
|
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();
|
||||
|
||||
if (!user) {
|
||||
redirect(`/?redirect=/${orgId}`);
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
@ -26,7 +26,7 @@ export default async function GeneralSettingsPage({
|
|||
const user = await getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect(`/?redirect=/${orgId}/settings/general`);
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
let orgUser = null;
|
||||
|
|
|
@ -61,7 +61,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||
const user = await getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect(`/?redirect=/${params.orgId}/`);
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
const cookie = await authCookieHeader();
|
||||
|
|
|
@ -62,6 +62,7 @@ import {
|
|||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { useSiteContext } from "@app/hooks/useSiteContext";
|
||||
|
||||
// Regular expressions for validation
|
||||
const DOMAIN_REGEX =
|
||||
|
@ -351,7 +352,7 @@ export default function ReverseProxyTargets(props: {
|
|||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: "IP Address",
|
||||
header: "IP / Hostname",
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
defaultValue={row.original.ip}
|
||||
|
@ -528,11 +529,23 @@ export default function ReverseProxyTargets(props: {
|
|||
name="ip"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IP Address</FormLabel>
|
||||
<FormLabel>IP / Hostname</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="ip" {...field} />
|
||||
</FormControl>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
@ -551,6 +564,19 @@ export default function ReverseProxyTargets(props: {
|
|||
/>
|
||||
</FormControl>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -2,13 +2,12 @@ import ResourceProvider from "@app/providers/ResourceProvider";
|
|||
import { internal } from "@app/lib/api";
|
||||
import {
|
||||
GetResourceAuthInfoResponse,
|
||||
GetResourceResponse,
|
||||
GetResourceResponse
|
||||
} from "@server/routers/resource";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { SidebarSettings } from "@app/components/SidebarSettings";
|
||||
import { Cloud, Settings, Shield } from "lucide-react";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
|
@ -20,7 +19,7 @@ import {
|
|||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbSeparator
|
||||
} from "@app/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
|
@ -39,7 +38,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||
try {
|
||||
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
|
||||
`/resource/${params.resourceId}`,
|
||||
await authCookieHeader(),
|
||||
await authCookieHeader()
|
||||
);
|
||||
resource = res.data.data;
|
||||
} catch {
|
||||
|
@ -68,8 +67,8 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||
const getOrg = cache(async () =>
|
||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
||||
`/org/${params.orgId}`,
|
||||
await authCookieHeader(),
|
||||
),
|
||||
await authCookieHeader()
|
||||
)
|
||||
);
|
||||
const res = await getOrg();
|
||||
org = res.data.data;
|
||||
|
@ -84,19 +83,19 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||
const sidebarNavItems = [
|
||||
{
|
||||
title: "General",
|
||||
href: `/{orgId}/settings/resources/{resourceId}/general`,
|
||||
href: `/{orgId}/settings/resources/{resourceId}/general`
|
||||
// icon: <Settings className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
title: "Connectivity",
|
||||
href: `/{orgId}/settings/resources/{resourceId}/connectivity`,
|
||||
href: `/{orgId}/settings/resources/{resourceId}/connectivity`
|
||||
// icon: <Cloud className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
title: "Authentication",
|
||||
href: `/{orgId}/settings/resources/{resourceId}/authentication`,
|
||||
href: `/{orgId}/settings/resources/{resourceId}/authentication`
|
||||
// icon: <Shield className="w-4 h-4" />,
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
@ -21,7 +21,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
|||
<>
|
||||
{user && (
|
||||
<UserProvider user={user}>
|
||||
<div>
|
||||
<div className="p-3">
|
||||
<ProfileIcon />
|
||||
</div>
|
||||
</UserProvider>
|
||||
|
|
|
@ -13,6 +13,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
|
||||
type DashboardLoginFormProps = {
|
||||
redirect?: string;
|
||||
|
@ -57,10 +58,9 @@ export default function DashboardLoginForm({
|
|||
<LoginForm
|
||||
redirect={redirect}
|
||||
onLogin={() => {
|
||||
if (redirect && redirect.includes("http")) {
|
||||
window.location.href = redirect;
|
||||
} else if (redirect) {
|
||||
router.push(redirect);
|
||||
if (redirect) {
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(safe);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { cache } from "react";
|
|||
import DashboardLoginForm from "./DashboardLoginForm";
|
||||
import { Mail } from "lucide-react";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
@ -25,6 +26,11 @@ export default async function Page(props: {
|
|||
redirect("/");
|
||||
}
|
||||
|
||||
let redirectUrl: string | undefined = undefined;
|
||||
if (searchParams.redirect) {
|
||||
redirectUrl = cleanRedirect(searchParams.redirect as string);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isInvite && (
|
||||
|
@ -42,16 +48,16 @@ export default async function Page(props: {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<DashboardLoginForm redirect={searchParams.redirect as string} />
|
||||
<DashboardLoginForm redirect={redirectUrl} />
|
||||
|
||||
{(!signUpDisabled || isInvite) && (
|
||||
<p className="text-center text-muted-foreground mt-4">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href={
|
||||
!searchParams.redirect
|
||||
!redirectUrl
|
||||
? `/auth/signup`
|
||||
: `/auth/signup?redirect=${searchParams.redirect}`
|
||||
: `/auth/signup?redirect=${redirectUrl}`
|
||||
}
|
||||
className="underline"
|
||||
>
|
||||
|
|
|
@ -43,6 +43,7 @@ import { createApiClient } from "@app/lib/api";
|
|||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
|
||||
const requestSchema = z.object({
|
||||
email: z.string().email()
|
||||
|
@ -186,11 +187,9 @@ export default function ResetPasswordForm({
|
|||
setSuccessMessage("Password reset successfully! Back to login...");
|
||||
|
||||
setTimeout(() => {
|
||||
if (redirect && redirect.includes("http")) {
|
||||
window.location.href = redirect;
|
||||
}
|
||||
if (redirect) {
|
||||
router.push(redirect);
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(safe);
|
||||
} else {
|
||||
router.push("/login");
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
|
|||
import { cache } from "react";
|
||||
import ResetPasswordForm from "./ResetPasswordForm";
|
||||
import Link from "next/link";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
@ -21,6 +22,11 @@ export default async function Page(props: {
|
|||
redirect("/");
|
||||
}
|
||||
|
||||
let redirectUrl: string | undefined = undefined;
|
||||
if (searchParams.redirect) {
|
||||
redirectUrl = cleanRedirect(searchParams.redirect);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResetPasswordForm
|
||||
|
@ -34,7 +40,7 @@ export default async function Page(props: {
|
|||
href={
|
||||
!searchParams.redirect
|
||||
? `/auth/signup`
|
||||
: `/auth/signup?redirect=${searchParams.redirect}`
|
||||
: `/auth/signup?redirect=${redirectUrl}`
|
||||
}
|
||||
className="underline"
|
||||
>
|
||||
|
|
|
@ -481,11 +481,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||
className={`${numMethods <= 1 ? "mt-0" : ""}`}
|
||||
>
|
||||
<LoginForm
|
||||
redirect={
|
||||
typeof window !== "undefined"
|
||||
? window.location.href
|
||||
: ""
|
||||
}
|
||||
redirect={`/auth/resource/${props.resource.id}`}
|
||||
onLogin={async () =>
|
||||
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 =
|
||||
authInfo.password ||
|
||||
|
|
|
@ -30,6 +30,7 @@ import { formatAxiosError } from "@app/lib/api";
|
|||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import Image from "next/image";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
|
||||
type SignupFormProps = {
|
||||
redirect?: string;
|
||||
|
@ -92,17 +93,17 @@ export default function SignupForm({
|
|||
|
||||
if (res.data?.data?.emailVerificationRequired) {
|
||||
if (redirect) {
|
||||
router.push(`/auth/verify-email?redirect=${redirect}`);
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(`/auth/verify-email?redirect=${safe}`);
|
||||
} else {
|
||||
router.push("/auth/verify-email");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (redirect && redirect.includes("http")) {
|
||||
window.location.href = redirect;
|
||||
} else if (redirect) {
|
||||
router.push(redirect);
|
||||
if (redirect) {
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(safe);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import SignupForm from "@app/app/auth/signup/SignupForm";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { Mail } from "lucide-react";
|
||||
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 (
|
||||
<>
|
||||
{isInvite && (
|
||||
|
@ -59,7 +65,7 @@ export default async function Page(props: {
|
|||
)}
|
||||
|
||||
<SignupForm
|
||||
redirect={searchParams.redirect as string}
|
||||
redirect={redirectUrl}
|
||||
inviteToken={inviteToken}
|
||||
inviteId={inviteId}
|
||||
/>
|
||||
|
@ -68,9 +74,9 @@ export default async function Page(props: {
|
|||
Already have an account?{" "}
|
||||
<Link
|
||||
href={
|
||||
!searchParams.redirect
|
||||
!redirectUrl
|
||||
? `/auth/login`
|
||||
: `/auth/login?redirect=${searchParams.redirect}`
|
||||
: `/auth/login?redirect=${redirectUrl}`
|
||||
}
|
||||
className="underline"
|
||||
>
|
||||
|
|
|
@ -36,6 +36,7 @@ import { useRouter } from "next/navigation";
|
|||
import { formatAxiosError } from "@app/lib/api";;
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
|
||||
const FormSchema = z.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
|
@ -91,11 +92,9 @@ export default function VerifyEmailForm({
|
|||
"Email successfully verified! Redirecting you..."
|
||||
);
|
||||
setTimeout(() => {
|
||||
if (redirect && redirect.includes("http")) {
|
||||
window.location.href = redirect;
|
||||
}
|
||||
if (redirect) {
|
||||
router.push(redirect);
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(safe);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
|
@ -27,11 +28,16 @@ export default async function Page(props: {
|
|||
redirect("/");
|
||||
}
|
||||
|
||||
let redirectUrl: string | undefined;
|
||||
if (searchParams.redirect) {
|
||||
redirectUrl = cleanRedirect(searchParams.redirect as string);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VerifyEmailForm
|
||||
email={user.email}
|
||||
redirect={searchParams.redirect as string}
|
||||
redirect={redirectUrl}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -14,7 +14,7 @@ import { XCircle } from "lucide-react";
|
|||
import { useRouter } from "next/navigation";
|
||||
|
||||
type InviteStatusCardProps = {
|
||||
type: "rejected" | "wrong_user" | "user_does_not_exist";
|
||||
type: "rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in";
|
||||
token: string;
|
||||
};
|
||||
|
||||
|
|
|
@ -60,6 +60,8 @@ export default async function InvitePage(props: {
|
|||
)
|
||||
) {
|
||||
return "user_does_not_exist";
|
||||
} else if (error.includes("You must be logged in to accept an invite")) {
|
||||
return "not_logged_in";
|
||||
} else {
|
||||
return "rejected";
|
||||
}
|
||||
|
@ -71,6 +73,10 @@ export default async function InvitePage(props: {
|
|||
redirect(`/auth/signup?redirect=/invite?token=${params.token}`);
|
||||
}
|
||||
|
||||
if (!user && type === "not_logged_in") {
|
||||
redirect(`/auth/login?redirect=/invite?token=${params.token}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<InviteStatusCard type={type} token={tokenParam} />
|
||||
|
|
|
@ -6,6 +6,8 @@ import { ThemeProvider } from "@app/providers/ThemeProvider";
|
|||
import EnvProvider from "@app/providers/EnvProvider";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { BookOpenText } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Dashboard - Pangolin`,
|
||||
|
@ -38,10 +40,10 @@ export default async function RootLayout({
|
|||
<div className="flex-grow">{children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="w-full mt-12 py-3 mb-6">
|
||||
<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="whitespace-nowrap">
|
||||
Pangolin
|
||||
<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-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600 select-none">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<span>Pangolin</span>
|
||||
</div>
|
||||
<Separator orientation="vertical" />
|
||||
<div className="whitespace-nowrap">
|
||||
|
@ -60,7 +62,7 @@ export default async function RootLayout({
|
|||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
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" />
|
||||
</svg>
|
||||
|
@ -70,10 +72,11 @@ export default async function RootLayout({
|
|||
href="https://docs.fossorial.io/Pangolin/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub"
|
||||
aria-label="Documentation"
|
||||
className="flex items-center space-x-3 whitespace-nowrap"
|
||||
>
|
||||
<span>Docs</span>
|
||||
<span>Documentation</span>
|
||||
<BookOpenText className="w-3 h-3" />
|
||||
</a>
|
||||
{version && (
|
||||
<>
|
||||
|
|
|
@ -11,6 +11,7 @@ import { redirect } from "next/navigation";
|
|||
import { cache } from "react";
|
||||
import OrganizationLanding from "./components/OrganizationLanding";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
@ -29,7 +30,8 @@ export default async function Page(props: {
|
|||
|
||||
if (!user) {
|
||||
if (params.redirect) {
|
||||
redirect(`/auth/login?redirect=${params.redirect}`);
|
||||
const safe = cleanRedirect(params.redirect);
|
||||
redirect(`/auth/login?redirect=${safe}`);
|
||||
} else {
|
||||
redirect(`/auth/login`);
|
||||
}
|
||||
|
@ -40,7 +42,8 @@ export default async function Page(props: {
|
|||
env.flags.emailVerificationRequired
|
||||
) {
|
||||
if (params.redirect) {
|
||||
redirect(`/auth/verify-email?redirect=${params.redirect}`);
|
||||
const safe = cleanRedirect(params.redirect);
|
||||
redirect(`/auth/verify-email?redirect=${safe}`);
|
||||
} else {
|
||||
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">
|
||||
<OrganizationLanding
|
||||
disableCreateOrg={env.flags.disableUserCreateOrg && !user.serverAdmin}
|
||||
organizations={orgs.map((org) => ({
|
||||
name: org.name,
|
||||
id: org.orgId
|
||||
|
|
|
@ -41,7 +41,7 @@ import Image from 'next/image'
|
|||
|
||||
type LoginFormProps = {
|
||||
redirect?: string;
|
||||
onLogin?: () => void;
|
||||
onLogin?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
|
|
|
@ -57,6 +57,7 @@ export default function ProfileIcon() {
|
|||
})
|
||||
.then(() => {
|
||||
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 : "/";
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue