use strict zod objects and hide proto on targets

This commit is contained in:
Milo Schwartz 2024-11-14 00:00:17 -05:00
parent 44b932937f
commit ba3505a385
No known key found for this signature in database
14 changed files with 154 additions and 162 deletions

View file

@ -10,6 +10,7 @@ import {
import { and, eq, or } from "drizzle-orm"; import { and, eq, or } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger";
export async function verifySiteAccess( export async function verifySiteAccess(
req: Request, req: Request,
@ -28,6 +29,7 @@ export async function verifySiteAccess(
} }
if (isNaN(siteId)) { if (isNaN(siteId)) {
logger.debug(JSON.stringify(req.body));
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid site ID")); return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid site ID"));
} }

View file

@ -150,7 +150,6 @@ authenticated.get(
authenticated.post( authenticated.post(
"/resource/:resourceId", "/resource/:resourceId",
verifyResourceAccess, verifyResourceAccess,
verifySiteAccess,
verifyUserHasAction(ActionsEnum.updateResource), verifyUserHasAction(ActionsEnum.updateResource),
resource.updateResource resource.updateResource
); );

View file

@ -12,11 +12,13 @@ import config from "@server/config";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { defaultRoleAllowedActions } from "../role"; import { defaultRoleAllowedActions } from "../role";
const createOrgSchema = z.object({ const createOrgSchema = z
.object({
orgId: z.string(), orgId: z.string(),
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
// domain: z.string().min(1).max(255).optional(), // domain: z.string().min(1).max(255).optional(),
}); })
.strict();
const MAX_ORGS = 5; const MAX_ORGS = 5;

View file

@ -18,6 +18,7 @@ const updateOrgBodySchema = z
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
domain: z.string().min(1).max(255).optional(), domain: z.string().min(1).max(255).optional(),
}) })
.strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update", message: "At least one field must be provided for update",
}); });

View file

@ -25,10 +25,12 @@ const createResourceParamsSchema = z.object({
orgId: z.string(), orgId: z.string(),
}); });
const createResourceSchema = z.object({ const createResourceSchema = z
.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
subdomain: z.string().min(1).max(255).optional(), subdomain: z.string().min(1).max(255).optional(),
}); })
.strict();
export type CreateResourceResponse = Resource; export type CreateResourceResponse = Resource;

View file

@ -18,8 +18,9 @@ const updateResourceBodySchema = z
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
subdomain: z.string().min(1).max(255).optional(), subdomain: z.string().min(1).max(255).optional(),
ssl: z.boolean().optional(), ssl: z.boolean().optional(),
siteId: z.number(), // siteId: z.number(),
}) })
.strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update", message: "At least one field must be provided for update",
}); });

View file

@ -14,10 +14,12 @@ const createRoleParamsSchema = z.object({
orgId: z.string(), orgId: z.string(),
}); });
const createRoleSchema = z.object({ const createRoleSchema = z
.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
description: z.string().optional(), description: z.string().optional(),
}); })
.strict();
export const defaultRoleAllowedActions: ActionsEnum[] = [ export const defaultRoleAllowedActions: ActionsEnum[] = [
ActionsEnum.getOrg, ActionsEnum.getOrg,

View file

@ -18,6 +18,7 @@ const updateRoleBodySchema = z
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
description: z.string().optional(), description: z.string().optional(),
}) })
.strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update", message: "At least one field must be provided for update",
}); });

View file

@ -15,13 +15,15 @@ const createSiteParamsSchema = z.object({
orgId: z.string(), orgId: z.string(),
}); });
const createSiteSchema = z.object({ const createSiteSchema = z
.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
exitNodeId: z.number().int().positive(), exitNodeId: z.number().int().positive(),
subdomain: z.string().min(1).max(255).optional(), subdomain: z.string().min(1).max(255).optional(),
pubKey: z.string(), pubKey: z.string(),
subnet: z.string(), subnet: z.string(),
}); })
.strict();
export type CreateSiteResponse = { export type CreateSiteResponse = {
name: string; name: string;

View file

@ -23,6 +23,7 @@ const updateSiteBodySchema = z
megabytesIn: z.number().int().nonnegative().optional(), megabytesIn: z.number().int().nonnegative().optional(),
megabytesOut: z.number().int().nonnegative().optional(), megabytesOut: z.number().int().nonnegative().optional(),
}) })
.strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update", message: "At least one field must be provided for update",
}); });

View file

@ -15,13 +15,15 @@ const createTargetParamsSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive()), resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
}); });
const createTargetSchema = z.object({ const createTargetSchema = z
.object({
ip: z.string().ip(), ip: z.string().ip(),
method: z.string().min(1).max(10), method: z.string().min(1).max(10),
port: z.number().int().min(1).max(65535), port: z.number().int().min(1).max(65535),
protocol: z.string().optional(), protocol: z.string().optional(),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
}); })
.strict();
export type CreateTargetResponse = Target; export type CreateTargetResponse = Target;
@ -104,6 +106,7 @@ export async function createTarget(
.insert(targets) .insert(targets)
.values({ .values({
resourceId, resourceId,
protocol: "tcp", // hard code for now
...targetData, ...targetData,
}) })
.returning(); .returning();

View file

@ -15,12 +15,12 @@ const updateTargetParamsSchema = z.object({
const updateTargetBodySchema = z const updateTargetBodySchema = z
.object({ .object({
// ip: z.string().ip().optional(), // for now we cant update the ip; you will have to delete ip: z.string().ip().optional(), // for now we cant update the ip; you will have to delete
method: z.string().min(1).max(10).optional(), method: z.string().min(1).max(10).optional(),
port: z.number().int().min(1).max(65535).optional(), port: z.number().int().min(1).max(65535).optional(),
protocol: z.string().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
}) })
.strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update", message: "At least one field must be provided for update",
}); });

View file

@ -17,7 +17,7 @@ import { AxiosResponse } from "axios";
import { ListTargetsResponse } from "@server/routers/target/listTargets"; import { ListTargetsResponse } from "@server/routers/target/listTargets";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { set, z } from "zod"; import { z } from "zod";
import { import {
Form, Form,
FormControl, FormControl,
@ -27,7 +27,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { CreateTargetResponse, updateTarget } from "@server/routers/target"; import { CreateTargetResponse } from "@server/routers/target";
import { import {
ColumnDef, ColumnDef,
getFilteredRowModel, getFilteredRowModel,
@ -51,7 +51,6 @@ import { useResourceContext } from "@app/hooks/useResourceContext";
import { ArrayElement } from "@server/types/ArrayElement"; import { ArrayElement } from "@server/types/ArrayElement";
import { Dot } from "lucide-react"; import { Dot } from "lucide-react";
import { formatAxiosError } from "@app/lib/utils"; import { formatAxiosError } from "@app/lib/utils";
import { escape } from "querystring";
const addTargetSchema = z.object({ const addTargetSchema = z.object({
ip: z.string().ip(), ip: z.string().ip(),
@ -62,15 +61,18 @@ const addTargetSchema = z.object({
message: "Port must be a number", message: "Port must be a number",
}) })
.transform((val) => Number(val)), .transform((val) => Number(val)),
protocol: z.string(), // protocol: z.string(),
}); });
type AddTargetFormValues = z.infer<typeof addTargetSchema>; type AddTargetFormValues = z.infer<typeof addTargetSchema>;
type LocalTarget = ArrayElement<ListTargetsResponse["targets"]> & { type LocalTarget = Omit<
ArrayElement<ListTargetsResponse["targets"]> & {
new?: boolean; new?: boolean;
updated?: boolean; updated?: boolean;
}; },
"protocol"
>;
export default function ReverseProxyTargets(props: { export default function ReverseProxyTargets(props: {
params: Promise<{ resourceId: number }>; params: Promise<{ resourceId: number }>;
@ -84,13 +86,15 @@ export default function ReverseProxyTargets(props: {
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]); const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
const [sslEnabled, setSslEnabled] = useState(resource.ssl); const [sslEnabled, setSslEnabled] = useState(resource.ssl);
const [loading, setLoading] = useState(false);
const addTargetForm = useForm({ const addTargetForm = useForm({
resolver: zodResolver(addTargetSchema), resolver: zodResolver(addTargetSchema),
defaultValues: { defaultValues: {
ip: "", ip: "",
method: "http", method: "http",
port: "80", port: "80",
protocol: "TCP", // protocol: "TCP",
}, },
}); });
@ -153,101 +157,51 @@ export default function ReverseProxyTargets(props: {
} }
async function saveAll() { async function saveAll() {
const res = await api try {
.post(`/resource/${params.resourceId}`, { ssl: sslEnabled }) setLoading(true);
.catch((err) => {
console.error(err); const res = await api.post(`/resource/${params.resourceId}`, {
toast({ ssl: sslEnabled,
variant: "destructive",
title: "Failed to update resource",
description: formatAxiosError(
err,
"Failed to update resource"
),
}); });
})
.then(() => {
updateResource({ ssl: sslEnabled }); updateResource({ ssl: sslEnabled });
});
for (const target of targets) { for (const target of targets) {
const data = { const data = {
ip: target.ip, ip: target.ip,
port: target.port, port: target.port,
// protocol: target.protocol,
method: target.method, method: target.method,
protocol: target.protocol,
enabled: target.enabled, enabled: target.enabled,
}; };
if (target.new) { if (target.new) {
await api const res = await api.put<
.put<AxiosResponse<CreateTargetResponse>>( AxiosResponse<CreateTargetResponse>
`/resource/${params.resourceId}/target`, >(`/resource/${params.resourceId}/target`, data);
} else if (target.updated) {
const res = await api.post(
`/target/${target.targetId}`,
data data
) );
.then((res) => { }
setTargets(
targets.map((t) => { setTargets([
if ( ...targets.map((t) => {
t.new &&
t.targetId === res.data.data.targetId
) {
return { return {
...t, ...t,
new: false, new: false,
updated: false,
}; };
} }),
return t; ]);
})
);
})
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: "Failed to add target",
description: formatAxiosError(
err,
"Failed to add target"
),
});
});
} else if (target.updated) {
const res = await api
.post(`/target/${target.targetId}`, data)
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: "Failed to update target",
description: formatAxiosError(
err,
"Failed to update target"
),
});
});
}
} }
for (const targetId of targetsToRemove) { for (const targetId of targetsToRemove) {
await api await api.delete(`/target/${targetId}`);
.delete(`/target/${targetId}`)
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: "Failed to remove target",
description: formatAxiosError(
err,
"Failed to remove target"
),
});
})
.then((res) => {
setTargets( setTargets(
targets.filter((target) => target.targetId !== targetId) targets.filter((target) => target.targetId !== targetId)
); );
});
} }
toast({ toast({
@ -256,6 +210,19 @@ export default function ReverseProxyTargets(props: {
}); });
setTargetsToRemove([]); setTargetsToRemove([]);
} 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<LocalTarget>[] = [ const columns: ColumnDef<LocalTarget>[] = [
@ -306,24 +273,24 @@ export default function ReverseProxyTargets(props: {
</Select> </Select>
), ),
}, },
{ // {
accessorKey: "protocol", // accessorKey: "protocol",
header: "Protocol", // header: "Protocol",
cell: ({ row }) => ( // cell: ({ row }) => (
<Select // <Select
defaultValue={row.original.protocol!} // defaultValue={row.original.protocol!}
onValueChange={(value) => // onValueChange={(value) =>
updateTarget(row.original.targetId, { protocol: value }) // updateTarget(row.original.targetId, { protocol: value })
} // }
> // >
<SelectTrigger>{row.original.protocol}</SelectTrigger> // <SelectTrigger>{row.original.protocol}</SelectTrigger>
<SelectContent> // <SelectContent>
<SelectItem value="TCP">TCP</SelectItem> // <SelectItem value="TCP">TCP</SelectItem>
<SelectItem value="UDP">UDP</SelectItem> // <SelectItem value="UDP">UDP</SelectItem>
</SelectContent> // </SelectContent>
</Select> // </Select>
), // ),
}, // },
{ {
accessorKey: "enabled", accessorKey: "enabled",
header: "Enabled", header: "Enabled",
@ -341,7 +308,14 @@ export default function ReverseProxyTargets(props: {
cell: ({ row }) => ( cell: ({ row }) => (
<> <>
<div className="flex items-center justify-end space-x-2"> <div className="flex items-center justify-end space-x-2">
{row.original.new && <Dot />} <Dot
className={
row.original.new || row.original.updated
? "opacity-100"
: "opacity-0"
}
/>
<Button <Button
variant="outline" variant="outline"
onClick={() => removeTarget(row.original.targetId)} onClick={() => removeTarget(row.original.targetId)}
@ -475,7 +449,7 @@ export default function ReverseProxyTargets(props: {
</FormItem> </FormItem>
)} )}
/> />
<FormField {/* <FormField
control={addTargetForm.control} control={addTargetForm.control}
name="protocol" name="protocol"
render={({ field }) => ( render={({ field }) => (
@ -511,7 +485,7 @@ export default function ReverseProxyTargets(props: {
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> /> */}
</div> </div>
<Button type="submit" variant="gray"> <Button type="submit" variant="gray">
Add Target Add Target
@ -569,7 +543,9 @@ export default function ReverseProxyTargets(props: {
</div> </div>
<div className="mt-8"> <div className="mt-8">
<Button onClick={saveAll}>Save Changes</Button> <Button onClick={saveAll} loading={loading} disabled={loading}>
Save Changes
</Button>
</div> </div>
</div> </div>
); );

View file

@ -61,7 +61,7 @@ export default function GeneralForm() {
resolver: zodResolver(GeneralFormSchema), resolver: zodResolver(GeneralFormSchema),
defaultValues: { defaultValues: {
name: resource.name, name: resource.name,
siteId: resource.siteId!, // siteId: resource.siteId!,
}, },
mode: "onChange", mode: "onChange",
}); });
@ -84,7 +84,7 @@ export default function GeneralForm() {
`resource/${resource?.resourceId}`, `resource/${resource?.resourceId}`,
{ {
name: data.name, name: data.name,
siteId: data.siteId, // siteId: data.siteId,
} }
) )
.catch((e) => { .catch((e) => {
@ -137,7 +137,7 @@ export default function GeneralForm() {
</FormItem> </FormItem>
)} )}
/> />
<FormField {/* <FormField
control={form.control} control={form.control}
name="siteId" name="siteId"
render={({ field }) => ( render={({ field }) => (
@ -213,7 +213,7 @@ export default function GeneralForm() {
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> /> */}
<Button <Button
type="submit" type="submit"
loading={saveLoading} loading={saveLoading}