Merge pull request #5908 from auzcoder/fix/auth-rate-limit-and-permissions

feat: rate limit auth endpoints and scope user proxy host permissions
This commit is contained in:
jc21 authored and GitHub committed 2026-11-05 23:05:08 +10:00
commit 2f5342b427
23 files changed
+571 -104

No files matched your search

+14
View File
@@ -54,6 +54,11 @@ const internalDeadHost = {
thisData.advanced_config = "";
}
const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
if (!isAdmin && thisData.advanced_config && thisData.advanced_config.trim() !== "") {
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
}
const row = await deadHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
// Add to audit log
@@ -151,6 +156,15 @@ const internalDeadHost = {
thisData = internalHost.cleanSslHstsData(thisData, row);
const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
if (
!isAdmin &&
typeof data.advanced_config !== "undefined" &&
data.advanced_config !== row.advanced_config
) {
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
}
// do the row update
await deadHostModel.query().where({ id: data.id }).patch(data);
+63 -7
View File
@@ -3,6 +3,7 @@ import errs from "../lib/error.js";
import { castJsonIfNeed } from "../lib/helpers.js";
import utils from "../lib/utils.js";
import proxyHostModel from "../models/proxy_host.js";
import userPermissionModel from "../models/user_permission.js";
import internalAuditLog from "./audit-log.js";
import internalCertificate from "./certificate.js";
import internalHost from "./host.js";
@@ -57,6 +58,18 @@ const internalProxyHost = {
thisData.advanced_config = "";
}
const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
if (!isAdmin && thisData.advanced_config && thisData.advanced_config.trim() !== "") {
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
}
if (
!isAdmin &&
Array.isArray(thisData.locations) &&
thisData.locations.some((loc) => loc?.advanced_config && loc.advanced_config.trim() !== "")
) {
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
}
return proxyHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
})
.then((row) => {
@@ -183,6 +196,29 @@ const internalProxyHost = {
thisData = internalHost.cleanSslHstsData(thisData, row);
const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
if (
!isAdmin &&
typeof thisData.advanced_config !== "undefined" &&
thisData.advanced_config !== row.advanced_config
) {
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
}
if (!isAdmin && Array.isArray(thisData.locations)) {
const existingLocations = Array.isArray(row.locations) ? row.locations : [];
for (const loc of thisData.locations) {
const existingLoc = existingLocations.find((l) => l.path === loc.path);
const existingAdv = existingLoc?.advanced_config || "";
if (typeof loc.advanced_config === "undefined") {
loc.advanced_config = existingAdv;
} else if ((loc.advanced_config || "") !== existingAdv) {
throw new errs.PermissionError(
"You do not have permission to modify advanced configuration",
);
}
}
}
return proxyHostModel
.query()
.where({ id: thisData.id })
@@ -243,7 +279,14 @@ const internalProxyHost = {
.first();
if (access_data.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
const permissions = access.getPermissions ? access.getPermissions() : {};
const allowedIds = permissions.meta?.proxy_host_ids || permissions.meta?.proxyHostIds || [];
query.andWhere((builder) => {
builder.where("owner_user_id", access.token.getUserId(1));
if (allowedIds.length > 0) {
builder.orWhereIn("id", allowedIds);
}
});
}
if (typeof thisData.expand !== "undefined" && thisData.expand !== null) {
@@ -427,7 +470,14 @@ const internalProxyHost = {
.orderBy(castJsonIfNeed("domain_names"), "ASC");
if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
const permissions = access.getPermissions ? access.getPermissions() : {};
const allowedIds = permissions.meta?.proxy_host_ids || permissions.meta?.proxyHostIds || [];
query.andWhere((builder) => {
builder.where("owner_user_id", access.token.getUserId(1));
if (allowedIds.length > 0) {
builder.orWhereIn("id", allowedIds);
}
});
}
// Query is used for searching
@@ -455,16 +505,22 @@ const internalProxyHost = {
* @param {String} visibility
* @returns {Promise}
*/
getCount: (user_id, visibility) => {
getCount: async (user_id, visibility) => {
const query = proxyHostModel.query().count("id as count").where("is_deleted", 0);
if (visibility !== "all") {
query.andWhere("owner_user_id", user_id);
const perm = await userPermissionModel.query().where("user_id", user_id).first();
const allowedIds = perm?.meta?.proxy_host_ids || perm?.meta?.proxyHostIds || [];
query.andWhere((builder) => {
builder.where("owner_user_id", user_id);
if (allowedIds.length > 0) {
builder.orWhereIn("id", allowedIds);
}
});
}
return query.first().then((row) => {
return Number.parseInt(row.count, 10);
});
const row = await query.first();
return Number.parseInt(row.count, 10);
},
};
+16 -2
View File
@@ -53,8 +53,13 @@ const internalRedirectionHost = {
// Fix for db field not having a default value
// for this optional field.
if (typeof data.advanced_config === "undefined") {
data.advanced_config = "";
if (typeof thisData.advanced_config === "undefined") {
thisData.advanced_config = "";
}
const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
if (!isAdmin && thisData.advanced_config && thisData.advanced_config.trim() !== "") {
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
}
return redirectionHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
@@ -183,6 +188,15 @@ const internalRedirectionHost = {
thisData = internalHost.cleanSslHstsData(thisData, row);
const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
if (
!isAdmin &&
typeof thisData.advanced_config !== "undefined" &&
thisData.advanced_config !== row.advanced_config
) {
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
}
return redirectionHostModel
.query()
.where({ id: thisData.id })
+4 -3
View File
@@ -444,7 +444,7 @@ const internalUser = {
return internalUser.get(access, { id: data.id });
})
.then((user) => {
if (user.id !== data.id) {
if (Number(user.id) !== Number(data.id)) {
// Sanity check that something crazy hasn't happened
throw new errs.InternalValidationError(
`User could not be updated, IDs do not match: ${user.id} !== ${data.id}`,
@@ -454,6 +454,7 @@ const internalUser = {
return user;
})
.then((user) => {
const permData = _.omit(data, ["id"]);
// Get perms row, patch if it exists
return userPermissionModel
.query()
@@ -465,10 +466,10 @@ const internalUser = {
return userPermissionModel
.query()
.where("user_id", user.id)
.patchAndFetchById(existing_auth.id, _.assign({ user_id: user.id }, data));
.patchAndFetchById(existing_auth.id, _.assign({ user_id: user.id }, permData));
}
// insert
return userPermissionModel.query().insertAndFetch(_.assign({ user_id: user.id }, data));
return userPermissionModel.query().insertAndFetch(_.assign({ user_id: user.id }, permData));
})
.then((permissions) => {
// Add to Audit Log
+14 -2
View File
@@ -139,7 +139,13 @@ export default function (tokenString) {
const query = proxyHostModel.query().select("id").andWhere("is_deleted", 0);
if (permissions.visibility === "user") {
query.andWhere("owner_user_id", tokenUserId);
const allowedIds = permissions.meta?.proxy_host_ids || permissions.meta?.proxyHostIds || [];
query.andWhere((builder) => {
builder.where("owner_user_id", tokenUserId);
if (allowedIds.length > 0) {
builder.orWhereIn("id", allowedIds);
}
});
}
const rows = await query;
@@ -213,6 +219,10 @@ export default function (tokenString) {
return {
token: Token,
getPermissions: () => permissions,
getRoles: () => userRoles,
hasRole: (role) => userRoles.includes(role),
/**
*
* @param {Boolean} [allowInternal]
@@ -220,7 +230,8 @@ export default function (tokenString) {
*/
load: async (allowInternal) => {
if (tokenString) {
return await Token.load(tokenString);
await this.init();
return tokenData;
}
allowInternalAccess = allowInternal;
return allowInternal || null;
@@ -255,6 +266,7 @@ export default function (tokenString) {
permission_streams: permissions.streams,
permission_access_lists: permissions.access_lists,
permission_certificates: permissions.certificates,
permission_meta: permissions.meta,
},
};
+89
View File
@@ -0,0 +1,89 @@
import { isCI } from "../config.js";
/**
* In-memory sliding window rate limiter for authentication endpoints
*/
const ipRequests = new Map();
// Clean up old entries every 2 minutes
const cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [ip, record] of ipRequests.entries()) {
if (now - record.startTime > record.windowMs * 2) {
ipRequests.delete(ip);
}
}
}, 120000);
cleanupTimer.unref();
export default function createRateLimiter(options = {}) {
const windowMs = options.windowMs || 60 * 1000; // 1 minute default
const max = options.max || 10; // 10 requests default
const message = options.message || "Too many attempts from this IP, please try again later.";
const skipSuccessfulRequests = options.skipSuccessfulRequests !== false;
return (req, res, next) => {
// Bypass rate limiting in CI, test environments, or when explicitly disabled
if (
isCI() ||
process.env.CI === "true" ||
process.env.NODE_ENV === "test" ||
process.env.DISABLE_RATE_LIMIT === "true"
) {
return next();
}
const socketAddr = req.socket?.remoteAddress || "unknown";
const isTrustedLocalProxy =
socketAddr === "127.0.0.1" || socketAddr === "::1" || socketAddr === "::ffff:127.0.0.1";
const forwardedFor = isTrustedLocalProxy ? req.headers["x-forwarded-for"]?.split(",")[0].trim() : null;
const clientIp = forwardedFor || socketAddr;
const now = Date.now();
let record = ipRequests.get(clientIp);
if (!record || now - record.startTime > windowMs) {
record = {
count: 1,
startTime: now,
windowMs,
};
ipRequests.set(clientIp, record);
if (skipSuccessfulRequests) {
const currentRecord = record;
res.on("finish", () => {
if (res.statusCode < 400 && currentRecord.count > 0) {
currentRecord.count--;
}
});
}
return next();
}
record.count++;
if (skipSuccessfulRequests) {
const currentRecord = record;
res.on("finish", () => {
if (res.statusCode < 400 && currentRecord.count > 0) {
currentRecord.count--;
}
});
}
if (record.count > max) {
res.setHeader("Retry-After", Math.ceil((record.startTime + windowMs - now) / 1000));
return res.status(429).json({
error: {
code: 429,
message,
},
});
}
next();
};
}
@@ -0,0 +1,41 @@
import { migrate as logger } from "../logger.js";
const migrateName = "user_permission_meta";
/**
* Migrate
*
* @param {Object} knex
* @returns {Promise}
*/
const up = (knex) => {
logger.info(`[${migrateName}] Migrating Up...`);
return knex.schema
.alterTable("user_permission", (table) => {
table.json("meta").nullable();
})
.then(() => {
logger.info(`[${migrateName}] user_permission Table altered`);
});
};
/**
* Undo Migrate
*
* @param {Object} knex
* @returns {Promise}
*/
const down = (knex) => {
logger.info(`[${migrateName}] Migrating Down...`);
return knex.schema
.alterTable("user_permission", (table) => {
table.dropColumn("meta");
})
.then(() => {
logger.info(`[${migrateName}] user_permission Table altered`);
});
};
export { up, down };
+4
View File
@@ -24,6 +24,10 @@ class UserPermission extends Model {
static get tableName() {
return "user_permission";
}
static get jsonAttributes() {
return ["meta"];
}
}
export default UserPermission;
+10 -2
View File
@@ -1,10 +1,18 @@
import express from "express";
import internalToken from "../internal/token.js";
import jwtdecode from "../lib/express/jwt-decode.js";
import createRateLimiter from "../lib/express/rate-limit.js";
import apiValidator from "../lib/validator/api.js";
import { debug, express as logger } from "../logger.js";
import { getValidationSchema } from "../schema/index.js";
const tokenRateLimiter = createRateLimiter({
windowMs: 60 * 1000,
max: 10,
message: "Too many login attempts. Please try again in 1 minute.",
skipSuccessfulRequests: true,
});
const router = express.Router({
caseSensitive: true,
strict: true,
@@ -42,7 +50,7 @@ router
*
* Create a new Token
*/
.post(async (req, res, next) => {
.post(tokenRateLimiter, async (req, res, next) => {
try {
const data = await apiValidator(getValidationSchema("/tokens", "post"), req.body);
const result = await internalToken.getTokenFromEmail(data);
@@ -64,7 +72,7 @@ router
*
* Verify 2FA code and get full token
*/
.post(async (req, res, next) => {
.post(tokenRateLimiter, async (req, res, next) => {
try {
const { challenge_token, code } = await apiValidator(getValidationSchema("/tokens/2fa", "post"), req.body);
const result = await internalToken.verify2FA(challenge_token, code);
+1 -1
View File
@@ -260,7 +260,7 @@ router
.put(async (req, res, next) => {
try {
const payload = await apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body);
payload.id = req.params.user_id;
payload.id = Number.parseInt(req.params.user_id, 10);
const result = await internalUser.setPermissions(res.locals.access, payload);
res.status(200).send(result);
} catch (err) {
@@ -44,6 +44,25 @@
"description": "Certificates Permissions",
"enum": ["hidden", "view", "manage"],
"example": "hidden"
},
"meta": {
"type": "object",
"description": "Permission metadata",
"example": {
"proxy_host_ids": [1]
},
"properties": {
"proxy_host_ids": {
"type": "array",
"description": "Allowed Proxy Host IDs",
"example": [1],
"items": {
"type": "integer",
"description": "Proxy Host ID",
"example": 1
}
}
}
}
}
}
@@ -35,7 +35,10 @@
"dead_hosts": "hidden",
"proxy_hosts": "manage",
"redirection_hosts": "hidden",
"streams": "hidden"
"streams": "hidden",
"meta": {
"proxy_host_ids": [1]
}
}
}
}
+5
View File
@@ -16,6 +16,11 @@ export interface UserPermissions {
streams: string;
accessLists: string;
certificates: string;
meta?: {
proxyHostIds?: number[];
proxy_host_ids?: number[];
[key: string]: any;
};
}
export interface User {
@@ -30,8 +30,9 @@ interface Row {
interface Props {
initialValues: ProxyLocation[];
name?: string;
allowAdvanced?: boolean;
}
export function LocationsFields({ initialValues, name = "locations" }: Props) {
export function LocationsFields({ initialValues, name = "locations", allowAdvanced = true }: Props) {
const [rows, setRows] = useState<Row[]>(() => (initialValues || []).map((value, id) => ({ id, value })));
const { setFieldValue } = useFormikContext();
const [expanded, setExpanded] = useState<number[]>([]);
@@ -217,7 +218,7 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
{isOpen && (
<div className="card-body" id={bodyId}>
<div className="row">
<div className="col-md-10">
<div className={allowAdvanced ? "col-md-10" : "col-md-12"}>
<div className="input-group mb-3">
<span className="input-group-text">Location</span>
<input
@@ -230,23 +231,25 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
/>
</div>
</div>
<div className="col-md-2 text-end">
<button
type="button"
className="btn p-0"
title="Advanced"
aria-expanded={advVisible.includes(row.id)}
onClick={() => toggleAdvVisible(row.id)}
>
<IconSettings size={20} />
</button>
</div>
{allowAdvanced && (
<div className="col-md-2 text-end">
<button
type="button"
className="btn p-0"
title="Advanced"
aria-expanded={advVisible.includes(row.id)}
onClick={() => toggleAdvVisible(row.id)}
>
<IconSettings size={20} />
</button>
</div>
)}
</div>
<div className="row">
<div className="col-md-3">
<div className="col-sm-3 col-md-3">
<div className="mb-3">
<label
className="form-label"
className="form-label text-nowrap"
htmlFor={`location-forwardScheme-${row.id}`}
>
<T id="host.forward-scheme" />
@@ -264,10 +267,10 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
</select>
</div>
</div>
<div className="col-md-6">
<div className="col-sm-5 col-md-5">
<div className="mb-3">
<label
className="form-label"
className="form-label text-nowrap"
htmlFor={`location-forwardHost-${row.id}`}
>
<T id="proxy-host.forward-host" />
@@ -285,10 +288,10 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
/>
</div>
</div>
<div className="col-md-3">
<div className="col-sm-4 col-md-4">
<div className="mb-3">
<label
className="form-label"
className="form-label text-nowrap"
htmlFor={`location-forwardPort-${row.id}`}
>
<T id="host.forward-port" />
@@ -316,7 +319,7 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
onFormChange={(value) => handleAccessListChange(row.id, value)}
inheritHost
/>
{advVisible.includes(row.id) && (
{allowAdvanced && advVisible.includes(row.id) && (
<div className="">
<CodeEditor
language="nginx"
+9
View File
@@ -644,12 +644,21 @@
"password.show": {
"defaultMessage": "Show Password"
},
"permissions.allowed-proxy-hosts": {
"defaultMessage": "Allowed Proxy Hosts"
},
"permissions.allowed-proxy-hosts-help": {
"defaultMessage": "Grants additional access to selected proxy hosts created by other users (in addition to the user's own created hosts)."
},
"permissions.hidden": {
"defaultMessage": "Hidden"
},
"permissions.manage": {
"defaultMessage": "Manage"
},
"permissions.no-proxy-hosts": {
"defaultMessage": "No proxy hosts found"
},
"permissions.view": {
"defaultMessage": "View Only"
},
+9
View File
@@ -644,12 +644,21 @@
"password.show": {
"defaultMessage": "Näita parooli"
},
"permissions.allowed-proxy-hosts": {
"defaultMessage": "Lubatud puhverserverid"
},
"permissions.allowed-proxy-hosts-help": {
"defaultMessage": "Annab täiendava ligipääsu teiste kasutajate loodud valitud puhverserveritele (lisaks kasutaja enda loodud kirjetele)."
},
"permissions.hidden": {
"defaultMessage": "Peidetud"
},
"permissions.manage": {
"defaultMessage": "Halda"
},
"permissions.no-proxy-hosts": {
"defaultMessage": "Ühtegi puhverserverit ei leitud"
},
"permissions.view": {
"defaultMessage": "Ainult vaatamine"
},
+12
View File
@@ -437,6 +437,9 @@
"footer.github-fork": {
"defaultMessage": "Fork dự án này trên Github"
},
"form.unsaved-changes": {
"defaultMessage": "Bạn có các thay đổi chưa được lưu. Bạn có chắc chắn muốn hủy bỏ chúng không?"
},
"host.flags.block-exploits": {
"defaultMessage": "Chặn các hoạt động khai thác phổ biến"
},
@@ -641,12 +644,21 @@
"password.show": {
"defaultMessage": "Hiện mật khẩu"
},
"permissions.allowed-proxy-hosts": {
"defaultMessage": "Máy chủ proxy được phép"
},
"permissions.allowed-proxy-hosts-help": {
"defaultMessage": "Cấp thêm quyền truy cập vào các máy chủ proxy đã chọn do người dùng khác tạo (ngoài các mục do chính người dùng tạo)."
},
"permissions.hidden": {
"defaultMessage": "Ẩn"
},
"permissions.manage": {
"defaultMessage": "Quản lý"
},
"permissions.no-proxy-hosts": {
"defaultMessage": "Không tìm thấy máy chủ proxy nào"
},
"permissions.view": {
"defaultMessage": "Chỉ xem"
},
+29 -17
View File
@@ -13,7 +13,7 @@ import {
SSLOptionsFields,
} from "src/components";
import { FormModal } from "src/components/Form";
import { useDeadHost, useSetDeadHost } from "src/hooks";
import { useDeadHost, useSetDeadHost, useUser } from "src/hooks";
import { T } from "src/locale";
import { showObjectSuccess } from "src/notifications";
@@ -25,11 +25,14 @@ interface Props extends InnerModalProps {
id: number | "new";
}
const DeadHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
const { data: currentUser } = useUser("me");
const { data, isLoading, error } = useDeadHost(id);
const { mutate: setDeadHost } = useSetDeadHost();
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const isAdmin = currentUser?.roles?.includes("admin");
const onSubmit = async (values: any, { setSubmitting }: any) => {
if (isSubmitting) return;
setIsSubmitting(true);
@@ -40,6 +43,11 @@ const DeadHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
...values,
};
if (!isAdmin) {
delete payload.advanced_config;
delete payload.advancedConfig;
}
setDeadHost(payload, {
onError: (err: any) => setErrorMsg(<T id={err.message} />),
onSuccess: () => {
@@ -121,19 +129,21 @@ const DeadHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
<T id="column.ssl" />
</a>
</li>
<li className="nav-item ms-auto" role="presentation">
<a
href="#tab-advanced"
className="nav-link"
title="Settings"
data-bs-toggle="tab"
aria-selected="false"
tabIndex={-1}
role="tab"
>
<IconSettings size={20} />
</a>
</li>
{isAdmin && (
<li className="nav-item ms-auto" role="presentation">
<a
href="#tab-advanced"
className="nav-link"
title="Settings"
data-bs-toggle="tab"
aria-selected="false"
tabIndex={-1}
role="tab"
>
<IconSettings size={20} />
</a>
</li>
)}
</ul>
</div>
<div className="card-body">
@@ -149,9 +159,11 @@ const DeadHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
/>
<SSLOptionsFields color="bg-red" />
</div>
<div className="tab-pane" id="tab-advanced" role="tabpanel">
<NginxConfigField />
</div>
{isAdmin && (
<div className="tab-pane" id="tab-advanced" role="tabpanel">
<NginxConfigField />
</div>
)}
</div>
</div>
</div>
+81 -9
View File
@@ -8,7 +8,7 @@ import Modal from "react-bootstrap/Modal";
import { setPermissions } from "src/api/backend";
import { Button, Loading } from "src/components";
import { FormModal } from "src/components/Form";
import { useUser } from "src/hooks";
import { useProxyHosts, useUser } from "src/hooks";
import { T } from "src/locale";
import styles from "./PermissionsModal.module.css";
@@ -23,6 +23,7 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => {
const queryClient = useQueryClient();
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
const { data, isLoading, error } = useUser(id);
const { data: proxyHosts } = useProxyHosts();
const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = async (values: any, { setSubmitting }: any) => {
@@ -152,18 +153,24 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => {
preventHide={isSubmitting}
initialValues={
{
visibility: data.permissions?.visibility,
accessLists: data.permissions?.accessLists,
certificates: data.permissions?.certificates,
deadHosts: data.permissions?.deadHosts,
proxyHosts: data.permissions?.proxyHosts,
redirectionHosts: data.permissions?.redirectionHosts,
streams: data.permissions?.streams,
visibility: data.permissions?.visibility || "user",
accessLists: data.permissions?.accessLists || "hidden",
certificates: data.permissions?.certificates || "hidden",
deadHosts: data.permissions?.deadHosts || "hidden",
proxyHosts: data.permissions?.proxyHosts || "hidden",
redirectionHosts: data.permissions?.redirectionHosts || "hidden",
streams: data.permissions?.streams || "hidden",
meta: {
proxy_host_ids:
data.permissions?.meta?.proxyHostIds ||
data.permissions?.meta?.proxy_host_ids ||
[],
},
} as any
}
onSubmit={onSubmit}
>
{({ onHide }) => (
{({ values, setFieldValue, onHide }: any) => (
<Form>
<Modal.Header closeButton>
<Modal.Title>
@@ -227,6 +234,71 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => {
{({ field, form }: any) => getPermissionButtons(field, form)}
</Field>
</div>
{values.visibility === "user" && values.proxyHosts !== "hidden" && (
<div className="mb-3 ps-3 border-start border-2 border-primary">
<label htmlFor="ignored-allowed-hosts" className="form-label mb-1">
<T id="permissions.allowed-proxy-hosts" />
</label>
<div className="text-secondary small mb-2">
<T id="permissions.allowed-proxy-hosts-help" />
</div>
<div
style={{
maxHeight: "180px",
overflowY: "auto",
border: "1px solid var(--tblr-border-color, #e6e7e9)",
borderRadius: "4px",
padding: "8px",
backgroundColor: "var(--tblr-bg-surface-secondary, #f8fafc)",
}}
>
{proxyHosts && proxyHosts.length > 0 ? (
proxyHosts.map((host) => {
const selectedIds: number[] =
values.meta?.proxy_host_ids || [];
const isChecked = selectedIds.includes(host.id);
return (
<div key={host.id} className="form-check mb-1">
<input
type="checkbox"
className="form-check-input"
id={`perm-proxy-host-${host.id}`}
checked={isChecked}
onChange={(e) => {
const newIds = e.target.checked
? [...selectedIds, host.id]
: selectedIds.filter(
(hId) => hId !== host.id,
);
setFieldValue(
"meta.proxy_host_ids",
newIds,
);
}}
/>
<label
className="form-check-label user-select-none"
htmlFor={`perm-proxy-host-${host.id}`}
>
<span className="fw-semibold">
{host.domainNames?.join(", ")}
</span>
<span className="text-secondary small ms-2">
&rarr; {host.forwardScheme}://
{host.forwardHost}:{host.forwardPort}
</span>
</label>
</div>
);
})
) : (
<div className="text-secondary small">
<T id="permissions.no-proxy-hosts" />
</div>
)}
</div>
</div>
)}
<div className="mb-3">
<label htmlFor="ignored" className="form-label">
<T id="redirection-hosts" />
+51 -23
View File
@@ -37,6 +37,8 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const isAdmin = currentUser?.roles?.includes("admin");
const onSubmit = async (values: any, { setSubmitting }: any) => {
if (isSubmitting) return;
setIsSubmitting(true);
@@ -48,6 +50,19 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
};
payload.locations = (values.locations || []).filter((location: any) => location.path?.trim() !== "");
if (!isAdmin) {
delete payload.advanced_config;
delete payload.advancedConfig;
const existingLocations = data?.locations || [];
payload.locations = payload.locations.map((loc: any) => {
const existingLoc = existingLocations.find((l: any) => l.path === loc.path);
return {
...loc,
advancedConfig: existingLoc?.advancedConfig || "",
};
});
}
setProxyHost(payload, {
onError: (err: any) => setErrorMsg(<T id={err.message} />),
onSuccess: () => {
@@ -154,19 +169,21 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
<T id="column.ssl" />
</a>
</li>
<li className="nav-item ms-auto" role="presentation">
<a
href="#tab-advanced"
className="nav-link"
title="Settings"
data-bs-toggle="tab"
aria-selected="false"
tabIndex={-1}
role="tab"
>
<IconSettings size={20} />
</a>
</li>
{isAdmin && (
<li className="nav-item ms-auto" role="presentation">
<a
href="#tab-advanced"
className="nav-link"
title="Settings"
data-bs-toggle="tab"
aria-selected="false"
tabIndex={-1}
role="tab"
>
<IconSettings size={20} />
</a>
</li>
)}
</ul>
</div>
<div className="card-body">
@@ -174,12 +191,12 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
<div className="tab-pane active show" id="tab-details" role="tabpanel">
<DomainNamesField isWildcardPermitted dnsProviderWildcardSupported />
<div className="row">
<div className="col-md-3">
<div className="col-sm-3 col-md-3">
<Field name="forwardScheme">
{({ field, form }: any) => (
<div className="mb-3">
<label
className="form-label"
className="form-label text-nowrap"
htmlFor="forwardScheme"
>
<T id="host.forward-scheme" />
@@ -205,11 +222,14 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
)}
</Field>
</div>
<div className="col-md-6">
<div className="col-sm-5 col-md-5">
<Field name="forwardHost" validate={validateString(1, 255)}>
{({ field, form }: any) => (
<div className="mb-3">
<label className="form-label" htmlFor="forwardHost">
<label
className="form-label text-nowrap"
htmlFor="forwardHost"
>
<T id="proxy-host.forward-host" />
</label>
<input
@@ -232,11 +252,14 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
)}
</Field>
</div>
<div className="col-md-3">
<div className="col-sm-4 col-md-4">
<Field name="forwardPort" validate={validateNumber(1, 65535)}>
{({ field, form }: any) => (
<div className="mb-3">
<label className="form-label" htmlFor="forwardPort">
<label
className="form-label text-nowrap"
htmlFor="forwardPort"
>
<T id="host.forward-port" />
</label>
<input
@@ -341,7 +364,10 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
</div>
</div>
<div className="tab-pane" id="tab-locations" role="tabpanel">
<LocationsFields initialValues={data?.locations || []} />
<LocationsFields
initialValues={data?.locations || []}
allowAdvanced={!!isAdmin}
/>
</div>
<div className="tab-pane" id="tab-ssl" role="tabpanel">
<SSLCertificateField
@@ -351,9 +377,11 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
/>
<SSLOptionsFields color="bg-lime" forProxyHost={true} />
</div>
<div className="tab-pane" id="tab-advanced" role="tabpanel">
<NginxConfigField />
</div>
{isAdmin && (
<div className="tab-pane" id="tab-advanced" role="tabpanel">
<NginxConfigField />
</div>
)}
</div>
</div>
</div>
+29 -17
View File
@@ -14,7 +14,7 @@ import {
SSLOptionsFields,
} from "src/components";
import { FormModal } from "src/components/Form";
import { useRedirectionHost, useSetRedirectionHost } from "src/hooks";
import { useRedirectionHost, useSetRedirectionHost, useUser } from "src/hooks";
import { T } from "src/locale";
import { validateString } from "src/modules/Validations";
import { showObjectSuccess } from "src/notifications";
@@ -27,11 +27,14 @@ interface Props extends InnerModalProps {
id: number | "new";
}
const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
const { data: currentUser } = useUser("me");
const { data, isLoading, error } = useRedirectionHost(id);
const { mutate: setRedirectionHost } = useSetRedirectionHost();
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const isAdmin = currentUser?.roles?.includes("admin");
const onSubmit = async (values: any, { setSubmitting }: any) => {
if (isSubmitting) return;
setIsSubmitting(true);
@@ -42,6 +45,11 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
...values,
};
if (!isAdmin) {
delete payload.advanced_config;
delete payload.advancedConfig;
}
setRedirectionHost(payload, {
onError: (err: any) => setErrorMsg(<T id={err.message} />),
onSuccess: () => {
@@ -134,19 +142,21 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
<T id="column.ssl" />
</a>
</li>
<li className="nav-item ms-auto" role="presentation">
<a
href="#tab-advanced"
className="nav-link"
title="Settings"
data-bs-toggle="tab"
aria-selected="false"
tabIndex={-1}
role="tab"
>
<IconSettings size={20} />
</a>
</li>
{isAdmin && (
<li className="nav-item ms-auto" role="presentation">
<a
href="#tab-advanced"
className="nav-link"
title="Settings"
data-bs-toggle="tab"
aria-selected="false"
tabIndex={-1}
role="tab"
>
<IconSettings size={20} />
</a>
</li>
)}
</ul>
</div>
<div className="card-body">
@@ -326,9 +336,11 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
/>
<SSLOptionsFields color="bg-yellow" />
</div>
<div className="tab-pane" id="tab-advanced" role="tabpanel">
<NginxConfigField />
</div>
{isAdmin && (
<div className="tab-pane" id="tab-advanced" role="tabpanel">
<NginxConfigField />
</div>
)}
</div>
</div>
</div>
+43
View File
@@ -90,6 +90,49 @@ export default function Table({
return <RolesFormatter roles={info.getValue()} />;
},
}),
columnHelper.accessor((row: any) => row.permissions, {
id: "permissions",
header: intl.formatMessage({ id: "action.permissions" }),
cell: (info: any) => {
const permissions = info.getValue();
const isAdminUser = info.row.original.roles?.includes("admin");
if (isAdminUser) {
return (
<span className="badge bg-green-lt">
<T id="column.satisfy-all" />
</span>
);
}
if (!permissions) {
return <span className="text-muted">-</span>;
}
const items = [
{ key: "proxyHosts", label: "proxy-hosts", val: permissions.proxyHosts },
{ key: "redirectionHosts", label: "redirection-hosts", val: permissions.redirectionHosts },
{ key: "deadHosts", label: "dead-hosts", val: permissions.deadHosts },
{ key: "streams", label: "streams", val: permissions.streams },
{ key: "accessLists", label: "access-lists", val: permissions.accessLists },
{ key: "certificates", label: "certificates", val: permissions.certificates },
];
return (
<div className="d-flex flex-wrap gap-1">
{items.map((it) => {
if (it.val === "hidden") return null;
const isManage = it.val === "manage";
return (
<span
key={it.key}
className={`badge ${isManage ? "bg-blue-lt" : "bg-secondary-lt"}`}
title={`${intl.formatMessage({ id: it.label })}: ${it.val}`}
>
<T id={it.label} />: {it.val}
</span>
);
})}
</div>
);
},
}),
columnHelper.accessor((row: any) => row.isDisabled, {
id: "isDisabled",
header: intl.formatMessage({ id: "column.status" }),
+1
View File
@@ -7,6 +7,7 @@ export default defineConfig({
allowCypressEnv: false,
requestTimeout: 30000,
defaultCommandTimeout: 20000,
taskTimeout: 90000,
reporter: "cypress-multi-reporters",
reporterOptions: {
configFile: "multi-reporter.json"