mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-10 14:04:51 +02:00
commit
6fba13c8d1
17 changed files with 1401 additions and 28 deletions
3
Makefile
3
Makefile
|
@ -12,9 +12,6 @@ build-arm:
|
||||||
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 .
|
||||||
|
|
||||||
|
|
|
@ -51,13 +51,17 @@ export enum ActionsEnum {
|
||||||
// removeUserAction = "removeUserAction",
|
// removeUserAction = "removeUserAction",
|
||||||
// removeUserSite = "removeUserSite",
|
// removeUserSite = "removeUserSite",
|
||||||
getOrgUser = "getOrgUser",
|
getOrgUser = "getOrgUser",
|
||||||
"setResourcePassword" = "setResourcePassword",
|
setResourcePassword = "setResourcePassword",
|
||||||
"setResourcePincode" = "setResourcePincode",
|
setResourcePincode = "setResourcePincode",
|
||||||
"setResourceWhitelist" = "setResourceWhitelist",
|
setResourceWhitelist = "setResourceWhitelist",
|
||||||
"getResourceWhitelist" = "getResourceWhitelist",
|
getResourceWhitelist = "getResourceWhitelist",
|
||||||
"generateAccessToken" = "generateAccessToken",
|
generateAccessToken = "generateAccessToken",
|
||||||
"deleteAcessToken" = "deleteAcessToken",
|
deleteAcessToken = "deleteAcessToken",
|
||||||
"listAccessTokens" = "listAccessTokens"
|
listAccessTokens = "listAccessTokens",
|
||||||
|
createResourceRule = "createResourceRule",
|
||||||
|
deleteResourceRule = "deleteResourceRule",
|
||||||
|
listResourceRules = "listResourceRules",
|
||||||
|
updateResourceRule = "updateResourceRule",
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|
|
@ -54,7 +54,8 @@ export const resources = sqliteTable("resources", {
|
||||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
isBaseDomain: integer("isBaseDomain", { mode: "boolean" })
|
isBaseDomain: integer("isBaseDomain", { mode: "boolean" }),
|
||||||
|
applyRules: integer("applyRules", { mode: "boolean" }).notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targets = sqliteTable("targets", {
|
export const targets = sqliteTable("targets", {
|
||||||
|
@ -371,6 +372,16 @@ export const versionMigrations = sqliteTable("versionMigrations", {
|
||||||
executedAt: integer("executedAt").notNull()
|
executedAt: integer("executedAt").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const resourceRules = sqliteTable("resourceRules", {
|
||||||
|
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
|
||||||
|
resourceId: integer("resourceId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
|
action: text("action").notNull(), // ACCEPT, DROP
|
||||||
|
match: text("match").notNull(), // CIDR, PATH, IP
|
||||||
|
value: text("value").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Site = InferSelectModel<typeof sites>;
|
export type Site = InferSelectModel<typeof sites>;
|
||||||
|
@ -403,3 +414,4 @@ export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
|
||||||
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
||||||
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
||||||
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||||
|
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
|
@ -2,7 +2,7 @@ import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// This is a placeholder value replaced by the build process
|
// This is a placeholder value replaced by the build process
|
||||||
export const APP_VERSION = "1.0.0-beta.12";
|
export const APP_VERSION = "1.0.0-beta.13";
|
||||||
|
|
||||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { fromError } from "zod-validation-error";
|
||||||
import { response } from "@server/lib/response";
|
import { response } from "@server/lib/response";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import {
|
import {
|
||||||
|
resourceRules,
|
||||||
ResourceAccessToken,
|
ResourceAccessToken,
|
||||||
ResourcePassword,
|
ResourcePassword,
|
||||||
resourcePassword,
|
resourcePassword,
|
||||||
|
@ -14,7 +15,8 @@ import {
|
||||||
resources,
|
resources,
|
||||||
sessions,
|
sessions,
|
||||||
userOrgs,
|
userOrgs,
|
||||||
users
|
users,
|
||||||
|
ResourceRule
|
||||||
} from "@server/db/schema";
|
} from "@server/db/schema";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
@ -28,6 +30,7 @@ import logger from "@server/logger";
|
||||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||||
|
import { isIpInCidr } from "@server/lib/ip";
|
||||||
|
|
||||||
// We'll see if this speeds anything up
|
// We'll see if this speeds anything up
|
||||||
const cache = new NodeCache({
|
const cache = new NodeCache({
|
||||||
|
@ -79,6 +82,7 @@ export async function verifyResourceSession(
|
||||||
host,
|
host,
|
||||||
originalRequestURL,
|
originalRequestURL,
|
||||||
requestIp,
|
requestIp,
|
||||||
|
path,
|
||||||
accessToken: token
|
accessToken: token
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
|
@ -146,6 +150,25 @@ export async function verifyResourceSession(
|
||||||
return allowed(res);
|
return allowed(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check the rules
|
||||||
|
if (resource.applyRules) {
|
||||||
|
const action = await checkRules(
|
||||||
|
resource.resourceId,
|
||||||
|
clientIp,
|
||||||
|
path
|
||||||
|
);
|
||||||
|
|
||||||
|
if (action == "ACCEPT") {
|
||||||
|
logger.debug("Resource allowed by rule");
|
||||||
|
return allowed(res);
|
||||||
|
} else if (action == "DROP") {
|
||||||
|
logger.debug("Resource denied by rule");
|
||||||
|
return notAllowed(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise its undefined and we pass
|
||||||
|
}
|
||||||
|
|
||||||
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
||||||
|
|
||||||
// check for access token
|
// check for access token
|
||||||
|
@ -438,3 +461,93 @@ async function isUserAllowedToAccessResource(
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkRules(
|
||||||
|
resourceId: number,
|
||||||
|
clientIp: string | undefined,
|
||||||
|
path: string | undefined
|
||||||
|
): Promise<"ACCEPT" | "DROP" | undefined> {
|
||||||
|
const ruleCacheKey = `rules:${resourceId}`;
|
||||||
|
|
||||||
|
let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey);
|
||||||
|
|
||||||
|
if (!rules) {
|
||||||
|
rules = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceRules)
|
||||||
|
.where(eq(resourceRules.resourceId, resourceId));
|
||||||
|
|
||||||
|
cache.set(ruleCacheKey, rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.length === 0) {
|
||||||
|
logger.debug("No rules found for resource", resourceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasAcceptRule = false;
|
||||||
|
|
||||||
|
// First pass: look for DROP rules
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (
|
||||||
|
(clientIp &&
|
||||||
|
rule.match == "CIDR" &&
|
||||||
|
isIpInCidr(clientIp, rule.value) &&
|
||||||
|
rule.action === "DROP") ||
|
||||||
|
(clientIp &&
|
||||||
|
rule.match == "IP" &&
|
||||||
|
clientIp == rule.value &&
|
||||||
|
rule.action === "DROP") ||
|
||||||
|
(path &&
|
||||||
|
rule.match == "PATH" &&
|
||||||
|
urlGlobToRegex(rule.value).test(path) &&
|
||||||
|
rule.action === "DROP")
|
||||||
|
) {
|
||||||
|
return "DROP";
|
||||||
|
}
|
||||||
|
// Track if we see any ACCEPT rules for the second pass
|
||||||
|
if (rule.action === "ACCEPT") {
|
||||||
|
hasAcceptRule = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: only check ACCEPT rules if we found one and didn't find a DROP
|
||||||
|
if (hasAcceptRule) {
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.action !== "ACCEPT") continue;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(clientIp &&
|
||||||
|
rule.match == "CIDR" &&
|
||||||
|
isIpInCidr(clientIp, rule.value)) ||
|
||||||
|
(clientIp &&
|
||||||
|
rule.match == "IP" &&
|
||||||
|
clientIp == rule.value) ||
|
||||||
|
(path &&
|
||||||
|
rule.match == "PATH" &&
|
||||||
|
urlGlobToRegex(rule.value).test(path))
|
||||||
|
) {
|
||||||
|
return "ACCEPT";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlGlobToRegex(pattern: string): RegExp {
|
||||||
|
// Trim any leading or trailing slashes
|
||||||
|
pattern = pattern.replace(/^\/+|\/+$/g, "");
|
||||||
|
|
||||||
|
// Escape special regex characters except *
|
||||||
|
const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
|
||||||
|
// Replace * with regex pattern for any valid URL segment characters
|
||||||
|
const regexPattern = escapedPattern.replace(/\*/g, "[a-zA-Z0-9_-]+");
|
||||||
|
|
||||||
|
// Create the final pattern that:
|
||||||
|
// 1. Optionally matches leading slash
|
||||||
|
// 2. Matches the pattern
|
||||||
|
// 3. Optionally matches trailing slash
|
||||||
|
return new RegExp(`^/?${regexPattern}/?$`);
|
||||||
|
}
|
|
@ -186,6 +186,32 @@ authenticated.get(
|
||||||
verifyUserHasAction(ActionsEnum.listTargets),
|
verifyUserHasAction(ActionsEnum.listTargets),
|
||||||
target.listTargets
|
target.listTargets
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/resource/:resourceId/rule",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.createResourceRule),
|
||||||
|
resource.createResourceRule
|
||||||
|
);
|
||||||
|
authenticated.get(
|
||||||
|
"/resource/:resourceId/rules",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listResourceRules),
|
||||||
|
resource.listResourceRules
|
||||||
|
);
|
||||||
|
authenticated.post(
|
||||||
|
"/resource/:resourceId/rule/:ruleId",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.updateResourceRule),
|
||||||
|
resource.updateResourceRule
|
||||||
|
);
|
||||||
|
authenticated.delete(
|
||||||
|
"/resource/:resourceId/rule/:ruleId",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteResourceRule),
|
||||||
|
resource.deleteResourceRule
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/target/:targetId",
|
"/target/:targetId",
|
||||||
verifyTargetAccess,
|
verifyTargetAccess,
|
||||||
|
@ -205,6 +231,7 @@ authenticated.delete(
|
||||||
target.deleteTarget
|
target.deleteTarget
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/role",
|
"/org/:orgId/role",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
|
101
server/routers/resource/createResourceRule.ts
Normal file
101
server/routers/resource/createResourceRule.ts
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { resourceRules, resources } from "@server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const createResourceRuleSchema = z
|
||||||
|
.object({
|
||||||
|
action: z.enum(["ACCEPT", "DROP"]),
|
||||||
|
match: z.enum(["CIDR", "IP", "PATH"]),
|
||||||
|
value: z.string().min(1)
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const createResourceRuleParamsSchema = z
|
||||||
|
.object({
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export async function createResourceRule(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedBody = createResourceRuleSchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action, match, value } = parsedBody.data;
|
||||||
|
|
||||||
|
const parsedParams = createResourceRuleParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
// Verify that the referenced resource exists
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the new resource rule
|
||||||
|
const [newRule] = await db
|
||||||
|
.insert(resourceRules)
|
||||||
|
.values({
|
||||||
|
resourceId,
|
||||||
|
action,
|
||||||
|
match,
|
||||||
|
value
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: newRule,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource rule created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
71
server/routers/resource/deleteResourceRule.ts
Normal file
71
server/routers/resource/deleteResourceRule.ts
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { resourceRules, resources } from "@server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const deleteResourceRuleSchema = z
|
||||||
|
.object({
|
||||||
|
ruleId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive()),
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export async function deleteResourceRule(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = deleteResourceRuleSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ruleId } = parsedParams.data;
|
||||||
|
|
||||||
|
// Delete the rule and return the deleted record
|
||||||
|
const [deletedRule] = await db
|
||||||
|
.delete(resourceRules)
|
||||||
|
.where(eq(resourceRules.ruleId, ruleId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!deletedRule) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource rule with ID ${ruleId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource rule deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,3 +18,7 @@ export * from "./authWithWhitelist";
|
||||||
export * from "./authWithAccessToken";
|
export * from "./authWithAccessToken";
|
||||||
export * from "./transferResource";
|
export * from "./transferResource";
|
||||||
export * from "./getExchangeToken";
|
export * from "./getExchangeToken";
|
||||||
|
export * from "./createResourceRule";
|
||||||
|
export * from "./deleteResourceRule";
|
||||||
|
export * from "./listResourceRules";
|
||||||
|
export * from "./updateResourceRule";
|
132
server/routers/resource/listResourceRules.ts
Normal file
132
server/routers/resource/listResourceRules.ts
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { resourceRules, resources } from "@server/db/schema";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
const listResourceRulesParamsSchema = z
|
||||||
|
.object({
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const listResourceRulesSchema = z.object({
|
||||||
|
limit: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("1000")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive()),
|
||||||
|
offset: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("0")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().nonnegative())
|
||||||
|
});
|
||||||
|
|
||||||
|
function queryResourceRules(resourceId: number) {
|
||||||
|
let baseQuery = db
|
||||||
|
.select({
|
||||||
|
ruleId: resourceRules.ruleId,
|
||||||
|
resourceId: resourceRules.resourceId,
|
||||||
|
action: resourceRules.action,
|
||||||
|
match: resourceRules.match,
|
||||||
|
value: resourceRules.value
|
||||||
|
})
|
||||||
|
.from(resourceRules)
|
||||||
|
.leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId))
|
||||||
|
.where(eq(resourceRules.resourceId, resourceId));
|
||||||
|
|
||||||
|
return baseQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListResourceRulesResponse = {
|
||||||
|
rules: Awaited<ReturnType<typeof queryResourceRules>>;
|
||||||
|
pagination: { total: number; limit: number; offset: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listResourceRules(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = listResourceRulesSchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { limit, offset } = parsedQuery.data;
|
||||||
|
|
||||||
|
const parsedParams = listResourceRulesParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
// Verify the resource exists
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = queryResourceRules(resourceId);
|
||||||
|
|
||||||
|
let countQuery = db
|
||||||
|
.select({ count: sql<number>`cast(count(*) as integer)` })
|
||||||
|
.from(resourceRules)
|
||||||
|
.where(eq(resourceRules.resourceId, resourceId));
|
||||||
|
|
||||||
|
const rulesList = await baseQuery.limit(limit).offset(offset);
|
||||||
|
const totalCountResult = await countQuery;
|
||||||
|
const totalCount = totalCountResult[0].count;
|
||||||
|
|
||||||
|
return response<ListResourceRulesResponse>(res, {
|
||||||
|
data: {
|
||||||
|
rules: rulesList,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource rules retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -29,7 +29,8 @@ const updateResourceBodySchema = z
|
||||||
blockAccess: z.boolean().optional(),
|
blockAccess: z.boolean().optional(),
|
||||||
proxyPort: z.number().int().min(1).max(65535).optional(),
|
proxyPort: z.number().int().min(1).max(65535).optional(),
|
||||||
emailWhitelistEnabled: z.boolean().optional(),
|
emailWhitelistEnabled: z.boolean().optional(),
|
||||||
isBaseDomain: z.boolean().optional()
|
isBaseDomain: z.boolean().optional(),
|
||||||
|
applyRules: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
|
|
130
server/routers/resource/updateResourceRule.ts
Normal file
130
server/routers/resource/updateResourceRule.ts
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { resourceRules, resources } from "@server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
// Define Zod schema for request parameters validation
|
||||||
|
const updateResourceRuleParamsSchema = z
|
||||||
|
.object({
|
||||||
|
ruleId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive()),
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
// Define Zod schema for request body validation
|
||||||
|
const updateResourceRuleSchema = z
|
||||||
|
.object({
|
||||||
|
action: z.enum(["ACCEPT", "DROP"]).optional(),
|
||||||
|
match: z.enum(["CIDR", "IP", "PATH"]).optional(),
|
||||||
|
value: z.string().min(1).optional()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
|
message: "At least one field must be provided for update"
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateResourceRule(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
// Validate path parameters
|
||||||
|
const parsedParams = updateResourceRuleParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request body
|
||||||
|
const parsedBody = updateResourceRuleSchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ruleId, resourceId } = parsedParams.data;
|
||||||
|
const updateData = parsedBody.data;
|
||||||
|
|
||||||
|
// Verify that the resource exists
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the rule exists and belongs to the specified resource
|
||||||
|
const [existingRule] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceRules)
|
||||||
|
.where(eq(resourceRules.ruleId, ruleId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingRule) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource rule with ID ${ruleId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingRule.resourceId !== resourceId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
`Resource rule ${ruleId} does not belong to resource ${resourceId}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the rule
|
||||||
|
const [updatedRule] = await db
|
||||||
|
.update(resourceRules)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(resourceRules.ruleId, ruleId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: updatedRule,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource rule updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ import m5 from "./scripts/1.0.0-beta6";
|
||||||
import m6 from "./scripts/1.0.0-beta9";
|
import m6 from "./scripts/1.0.0-beta9";
|
||||||
import m7 from "./scripts/1.0.0-beta10";
|
import m7 from "./scripts/1.0.0-beta10";
|
||||||
import m8 from "./scripts/1.0.0-beta12";
|
import m8 from "./scripts/1.0.0-beta12";
|
||||||
|
import m13 from "./scripts/1.0.0-beta13";
|
||||||
|
|
||||||
// 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
|
||||||
|
@ -27,7 +28,8 @@ const migrations = [
|
||||||
{ version: "1.0.0-beta.6", run: m5 },
|
{ version: "1.0.0-beta.6", run: m5 },
|
||||||
{ version: "1.0.0-beta.9", run: m6 },
|
{ version: "1.0.0-beta.9", run: m6 },
|
||||||
{ version: "1.0.0-beta.10", run: m7 },
|
{ version: "1.0.0-beta.10", run: m7 },
|
||||||
{ version: "1.0.0-beta.12", run: m8 }
|
{ version: "1.0.0-beta.12", run: m8 },
|
||||||
|
{ version: "1.0.0-beta.13", run: m13 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
29
server/setup/scripts/1.0.0-beta13.ts
Normal file
29
server/setup/scripts/1.0.0-beta13.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import db from "@server/db";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log("Running setup script 1.0.0-beta.13...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.transaction((trx) => {
|
||||||
|
trx.run(sql`CREATE TABLE resourceRules (
|
||||||
|
ruleId integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
resourceId integer NOT NULL,
|
||||||
|
action text NOT NULL,
|
||||||
|
match text NOT NULL,
|
||||||
|
value text NOT NULL,
|
||||||
|
FOREIGN KEY (resourceId) REFERENCES resources(resourceId) ON UPDATE no action ON DELETE cascade
|
||||||
|
);`);
|
||||||
|
trx.run(
|
||||||
|
sql`ALTER TABLE resources ADD applyRules integer DEFAULT false NOT NULL;`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Added new table and column: resourceRules, applyRules`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Unable to add new table and column: resourceRules, applyRules");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Done.");
|
||||||
|
}
|
|
@ -131,7 +131,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
resolver: zodResolver(addTargetSchema),
|
resolver: zodResolver(addTargetSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
ip: "",
|
ip: "",
|
||||||
method: resource.http ? "http" : null,
|
method: resource.http ? "http" : null
|
||||||
// protocol: "TCP",
|
// protocol: "TCP",
|
||||||
} as z.infer<typeof addTargetSchema>
|
} as z.infer<typeof addTargetSchema>
|
||||||
});
|
});
|
||||||
|
@ -269,7 +269,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
>(`/resource/${params.resourceId}/target`, data);
|
>(`/resource/${params.resourceId}/target`, data);
|
||||||
target.targetId = res.data.data.targetId;
|
target.targetId = res.data.data.targetId;
|
||||||
} else if (target.updated) {
|
} else if (target.updated) {
|
||||||
const res = await api.post(
|
await api.post(
|
||||||
`/target/${target.targetId}`,
|
`/target/${target.targetId}`,
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
|
@ -290,7 +290,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
for (const targetId of targetsToRemove) {
|
for (const targetId of targetsToRemove) {
|
||||||
await api.delete(`/target/${targetId}`);
|
await api.delete(`/target/${targetId}`);
|
||||||
setTargets(
|
setTargets(
|
||||||
targets.filter((target) => target.targetId !== targetId)
|
targets.filter((t) => t.targetId !== targetId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -316,17 +316,31 @@ export default function ReverseProxyTargets(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSsl(val: boolean) {
|
async function saveSsl(val: boolean) {
|
||||||
const res = await api.post(`/resource/${params.resourceId}`, {
|
const res = await api
|
||||||
ssl: val
|
.post(`/resource/${params.resourceId}`, {
|
||||||
});
|
ssl: val
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to update SSL configuration",
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
"An error occurred while updating the SSL configuration"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
setSslEnabled(val);
|
if (res && res.status === 200) {
|
||||||
updateResource({ ssl: val });
|
setSslEnabled(val);
|
||||||
|
updateResource({ ssl: val });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "SSL Configuration",
|
title: "SSL Configuration",
|
||||||
description: "SSL configuration updated successfully"
|
description: "SSL configuration updated successfully"
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnDef<LocalTarget>[] = [
|
const columns: ColumnDef<LocalTarget>[] = [
|
||||||
|
@ -652,7 +666,8 @@ export default function ReverseProxyTargets(props: {
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Adding more than one target above will enable load balancing.
|
Adding more than one target above will enable load
|
||||||
|
balancing.
|
||||||
</p>
|
</p>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
|
|
|
@ -99,6 +99,11 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||||
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" />,
|
||||||
});
|
});
|
||||||
|
sidebarNavItems.push({
|
||||||
|
title: "Rules",
|
||||||
|
href: `/{orgId}/settings/resources/{resourceId}/rules`
|
||||||
|
// icon: <Shield className="w-4 h-4" />,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
730
src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx
Normal file
730
src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx
Normal file
|
@ -0,0 +1,730 @@
|
||||||
|
"use client";
|
||||||
|
import { useEffect, useState, use } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
flexRender
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from "@app/components/ui/table";
|
||||||
|
import { useToast } from "@app/hooks/useToast";
|
||||||
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
|
import { ArrayElement } from "@server/types/ArrayElement";
|
||||||
|
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionFooter
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
|
import { Check, Info, InfoIcon, X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
InfoSection,
|
||||||
|
InfoSections,
|
||||||
|
InfoSectionTitle
|
||||||
|
} from "@app/components/InfoSection";
|
||||||
|
import { Separator } from "@app/components/ui/separator";
|
||||||
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
|
||||||
|
// Schema for rule validation
|
||||||
|
const addRuleSchema = z.object({
|
||||||
|
action: z.string(),
|
||||||
|
match: z.string(),
|
||||||
|
value: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
type LocalRule = ArrayElement<ListResourceRulesResponse["rules"]> & {
|
||||||
|
new?: boolean;
|
||||||
|
updated?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum RuleAction {
|
||||||
|
ACCEPT = "Always Allow",
|
||||||
|
DROP = "Always Deny"
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResourceRules(props: {
|
||||||
|
params: Promise<{ resourceId: number }>;
|
||||||
|
}) {
|
||||||
|
const params = use(props.params);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { resource, updateResource } = useResourceContext();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
const [rules, setRules] = useState<LocalRule[]>([]);
|
||||||
|
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
|
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
|
||||||
|
|
||||||
|
const addRuleForm = useForm({
|
||||||
|
resolver: zodResolver(addRuleSchema),
|
||||||
|
defaultValues: {
|
||||||
|
action: "ACCEPT",
|
||||||
|
match: "IP",
|
||||||
|
value: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRules = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<
|
||||||
|
AxiosResponse<ListResourceRulesResponse>
|
||||||
|
>(`/resource/${params.resourceId}/rules`);
|
||||||
|
if (res.status === 200) {
|
||||||
|
setRules(res.data.data.rules);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to fetch rules",
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
"An error occurred while fetching rules"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPageLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchRules();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function addRule(data: z.infer<typeof addRuleSchema>) {
|
||||||
|
const isDuplicate = rules.some(
|
||||||
|
(rule) =>
|
||||||
|
rule.action === data.action &&
|
||||||
|
rule.match === data.match &&
|
||||||
|
rule.value === data.value
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isDuplicate) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Duplicate rule",
|
||||||
|
description: "A rule with these settings already exists"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.match === "CIDR" && !isValidCIDR(data.value)) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid CIDR",
|
||||||
|
description: "Please enter a valid CIDR value"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid URL path",
|
||||||
|
description: "Please enter a valid URL path value"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.match === "IP" && !isValidIP(data.value)) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid IP",
|
||||||
|
description: "Please enter a valid IP address"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRule: LocalRule = {
|
||||||
|
...data,
|
||||||
|
ruleId: new Date().getTime(),
|
||||||
|
new: true,
|
||||||
|
resourceId: resource.resourceId
|
||||||
|
};
|
||||||
|
|
||||||
|
setRules([...rules, newRule]);
|
||||||
|
addRuleForm.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeRule = (ruleId: number) => {
|
||||||
|
setRules([...rules.filter((rule) => rule.ruleId !== ruleId)]);
|
||||||
|
if (!rules.find((rule) => rule.ruleId === ruleId)?.new) {
|
||||||
|
setRulesToRemove([...rulesToRemove, ruleId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function updateRule(ruleId: number, data: Partial<LocalRule>) {
|
||||||
|
setRules(
|
||||||
|
rules.map((rule) =>
|
||||||
|
rule.ruleId === ruleId
|
||||||
|
? { ...rule, ...data, updated: true }
|
||||||
|
: rule
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveApplyRules(val: boolean) {
|
||||||
|
const res = await api
|
||||||
|
.post(`/resource/${params.resourceId}`, {
|
||||||
|
applyRules: val
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to update rules",
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
"An error occurred while updating rules"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
setRulesEnabled(val);
|
||||||
|
updateResource({ applyRules: val });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Enable Rules",
|
||||||
|
description: "Rule evaluation has been updated"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRules() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
for (let rule of rules) {
|
||||||
|
const data = {
|
||||||
|
action: rule.action,
|
||||||
|
match: rule.match,
|
||||||
|
value: rule.value
|
||||||
|
};
|
||||||
|
|
||||||
|
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid CIDR",
|
||||||
|
description: "Please enter a valid CIDR value"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rule.match === "PATH" &&
|
||||||
|
!isValidUrlGlobPattern(rule.value)
|
||||||
|
) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid URL path",
|
||||||
|
description: "Please enter a valid URL path value"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rule.match === "IP" && !isValidIP(rule.value)) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid IP",
|
||||||
|
description: "Please enter a valid IP address"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.new) {
|
||||||
|
const res = await api.put(`/resource/${params.resourceId}/rule`, data);
|
||||||
|
rule.ruleId = res.data.data.ruleId;
|
||||||
|
} else if (rule.updated) {
|
||||||
|
await api.post(
|
||||||
|
`/resource/${params.resourceId}/rule/${rule.ruleId}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setRules([
|
||||||
|
...rules.map((r) => {
|
||||||
|
let res = {
|
||||||
|
...r,
|
||||||
|
new: false,
|
||||||
|
updated: false
|
||||||
|
};
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ruleId of rulesToRemove) {
|
||||||
|
await api.delete(
|
||||||
|
`/resource/${params.resourceId}/rule/${ruleId}`
|
||||||
|
);
|
||||||
|
setRules(
|
||||||
|
rules.filter((r) => r.ruleId !== ruleId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Rules updated",
|
||||||
|
description: "Rules updated successfully"
|
||||||
|
});
|
||||||
|
|
||||||
|
setRulesToRemove([]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Operation failed",
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
"An error occurred during the save operation"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<LocalRule>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "action",
|
||||||
|
header: "Action",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Select
|
||||||
|
defaultValue={row.original.action}
|
||||||
|
onValueChange={(value: "ACCEPT" | "DROP") =>
|
||||||
|
updateRule(row.original.ruleId, { action: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="min-w-[100px]">
|
||||||
|
{row.original.action}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ACCEPT">
|
||||||
|
{RuleAction.ACCEPT}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="DROP">{RuleAction.DROP}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "match",
|
||||||
|
header: "Match Type",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Select
|
||||||
|
defaultValue={row.original.match}
|
||||||
|
onValueChange={(value: "CIDR" | "IP" | "PATH") =>
|
||||||
|
updateRule(row.original.ruleId, { match: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="min-w-[100px]">
|
||||||
|
{row.original.match}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="IP">IP</SelectItem>
|
||||||
|
<SelectItem value="CIDR">IP Range</SelectItem>
|
||||||
|
<SelectItem value="PATH">PATH</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "value",
|
||||||
|
header: "Value",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Input
|
||||||
|
defaultValue={row.original.value}
|
||||||
|
className="min-w-[200px]"
|
||||||
|
onBlur={(e) =>
|
||||||
|
updateRule(row.original.ruleId, {
|
||||||
|
value: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => removeRule(row.original.ruleId)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rules,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pageLoading) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<Alert>
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
<AlertTitle className="font-semibold">About Rules</AlertTitle>
|
||||||
|
<AlertDescription className="mt-4">
|
||||||
|
<p className="mb-4">
|
||||||
|
Rules allow you to control access to your resource based
|
||||||
|
on a set of criteria. You can create rules to allow or
|
||||||
|
deny access based on IP address or URL path. Deny rules
|
||||||
|
take precedence over allow rules. If a request matches
|
||||||
|
both an allow and a deny rule, the deny rule will be
|
||||||
|
applied.
|
||||||
|
</p>
|
||||||
|
<InfoSections>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>Actions</InfoSectionTitle>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<Check className="text-green-500 w-4 h-4" />
|
||||||
|
Always Allow: Bypass all authentication
|
||||||
|
methods
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<X className="text-red-500 w-4 h-4" />
|
||||||
|
Always Deny: Block all requests; no
|
||||||
|
authentication can be attempted
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</InfoSection>
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
Matching Criteria
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
Match a specific IP address
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
Match a range of IP addresses in CIDR
|
||||||
|
notation
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
Match a URL path or pattern
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</InfoSection>
|
||||||
|
</InfoSections>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>Enable Rules</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
Enable or disable rule evaluation for this resource
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SwitchInput
|
||||||
|
id="rules-toggle"
|
||||||
|
label="Enable Rules"
|
||||||
|
defaultChecked={resource.applyRules}
|
||||||
|
onCheckedChange={async (val) => {
|
||||||
|
await saveApplyRules(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
Resource Rules Configuration
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
Configure rules to control access to your resource
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<Form {...addRuleForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={addRuleForm.handleSubmit(addRule)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={addRuleForm.control}
|
||||||
|
name="action"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Action</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ACCEPT">
|
||||||
|
{RuleAction.ACCEPT}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="DROP">
|
||||||
|
{RuleAction.DROP}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={addRuleForm.control}
|
||||||
|
name="match"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Match Type</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="IP">
|
||||||
|
IP
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="CIDR">
|
||||||
|
IP Range
|
||||||
|
</SelectItem>
|
||||||
|
{resource.http && (
|
||||||
|
<SelectItem value="PATH">
|
||||||
|
PATH
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={addRuleForm.control}
|
||||||
|
name="value"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<InfoPopup
|
||||||
|
text="Value"
|
||||||
|
info={
|
||||||
|
addRuleForm.watch(
|
||||||
|
"match"
|
||||||
|
) === "CIDR"
|
||||||
|
? "Enter an address in CIDR format (e.g., 103.21.244.0/22)"
|
||||||
|
: "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
disabled={loading || !rulesEnabled}
|
||||||
|
>
|
||||||
|
Add Rule
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column
|
||||||
|
.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row
|
||||||
|
.getVisibleCells()
|
||||||
|
.map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column
|
||||||
|
.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
No rules. Add a rule using the form.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
onClick={saveRules}
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Save Rules
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidCIDR(cidr: string): boolean {
|
||||||
|
// Match CIDR pattern (e.g., "192.168.0.0/24")
|
||||||
|
const cidrPattern =
|
||||||
|
/^([0-9]{1,3}\.){3}[0-9]{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/;
|
||||||
|
|
||||||
|
if (!cidrPattern.test(cidr)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate IP address part
|
||||||
|
const ipPart = cidr.split("/")[0];
|
||||||
|
const octets = ipPart.split(".");
|
||||||
|
|
||||||
|
return octets.every((octet) => {
|
||||||
|
const num = parseInt(octet, 10);
|
||||||
|
return num >= 0 && num <= 255;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidIP(ip: string): boolean {
|
||||||
|
const ipPattern = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/;
|
||||||
|
|
||||||
|
if (!ipPattern.test(ip)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const octets = ip.split(".");
|
||||||
|
|
||||||
|
return octets.every((octet) => {
|
||||||
|
const num = parseInt(octet, 10);
|
||||||
|
return num >= 0 && num <= 255;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidUrlGlobPattern(pattern: string): boolean {
|
||||||
|
// Remove leading slash if present
|
||||||
|
pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;
|
||||||
|
|
||||||
|
// Empty string is not valid
|
||||||
|
if (!pattern) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split path into segments
|
||||||
|
const segments = pattern.split("/");
|
||||||
|
|
||||||
|
// Check each segment
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const segment = segments[i];
|
||||||
|
|
||||||
|
// Empty segments are not allowed (double slashes)
|
||||||
|
if (!segment && i !== segments.length - 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If segment contains *, it must be exactly *
|
||||||
|
if (segment.includes("*") && segment !== "*") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for invalid characters
|
||||||
|
if (!/^[a-zA-Z0-9_*-]*$/.test(segment)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue