fix zod schemas

This commit is contained in:
Milo Schwartz 2025-01-29 00:03:10 -05:00
parent 0e04e82b88
commit 20f659db89
No known key found for this signature in database
7 changed files with 158 additions and 122 deletions

View file

@ -41,4 +41,4 @@ flags:
require_email_verification: false require_email_verification: false
disable_signup_without_invite: true disable_signup_without_invite: true
disable_user_create_org: true disable_user_create_org: true
allow_raw_resources: true allow_raw_resources: true

View file

@ -13,7 +13,7 @@ experimental:
plugins: plugins:
badger: badger:
moduleName: "github.com/fosrl/badger" moduleName: "github.com/fosrl/badger"
version: "v1.0.0-beta.2" version: "v1.0.0-beta.3"
log: log:
level: "INFO" level: "INFO"

View file

@ -17,6 +17,7 @@ import { eq, and } from "drizzle-orm";
import stoi from "@server/lib/stoi"; import stoi from "@server/lib/stoi";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
const createResourceParamsSchema = z const createResourceParamsSchema = z
.object({ .object({
@ -27,36 +28,43 @@ const createResourceParamsSchema = z
const createResourceSchema = z const createResourceSchema = z
.object({ .object({
subdomain: z subdomain: z.string().optional(),
.union([
z
.string()
.regex(
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/,
"Invalid subdomain format"
)
.min(1, "Subdomain must be at least 1 character long")
.transform((val) => val.toLowerCase()),
z.string().optional()
])
.optional(),
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
siteId: z.number(),
http: z.boolean(), http: z.boolean(),
protocol: z.string(), protocol: z.string(),
proxyPort: z.number().int().min(1).max(65535).optional(), proxyPort: z.number().optional()
}).refine( })
.refine(
(data) => { (data) => {
if (data.http === true) { if (!data.http) {
return true; return z
.number()
.int()
.min(1)
.max(65535)
.safeParse(data.proxyPort).success;
} }
return !!data.proxyPort; return true;
}, },
{ {
message: "Port number is required for non-HTTP resources", message: "Invalid port number",
path: ["proxyPort"] path: ["proxyPort"]
} }
)
.refine(
(data) => {
if (data.http) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{
message: "Invalid subdomain",
path: ["subdomain"]
}
); );
export type CreateResourceResponse = Resource; export type CreateResourceResponse = Resource;
export async function createResource( export async function createResource(
@ -134,7 +142,6 @@ export async function createResource(
); );
} }
} else { } else {
if (proxyPort === 443 || proxyPort === 80) { if (proxyPort === 443 || proxyPort === 80) {
return next( return next(
createHttpError( createHttpError(
@ -149,7 +156,7 @@ export async function createResource(
.select() .select()
.from(resources) .from(resources)
.where(eq(resources.fullDomain, fullDomain)); .where(eq(resources.fullDomain, fullDomain));
if (existingResource.length > 0) { if (existingResource.length > 0) {
return next( return next(
createHttpError( createHttpError(
@ -165,7 +172,7 @@ export async function createResource(
.insert(resources) .insert(resources)
.values({ .values({
siteId, siteId,
fullDomain: http? fullDomain : null, fullDomain: http ? fullDomain : null,
orgId, orgId,
name, name,
subdomain, subdomain,

View file

@ -156,13 +156,15 @@ export async function traefikConfigProvider(
: {}) : {})
}; };
const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || [];
config_output.http.routers![routerName] = { config_output.http.routers![routerName] = {
entryPoints: [ entryPoints: [
resource.ssl resource.ssl
? config.getRawConfig().traefik.https_entrypoint ? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint : config.getRawConfig().traefik.http_entrypoint
], ],
middlewares: [badgerMiddlewareName], middlewares: [badgerMiddlewareName, ...additionalMiddlewares],
service: serviceName, service: serviceName,
rule: `Host(\`${fullDomain}\`)`, rule: `Host(\`${fullDomain}\`)`,
...(resource.ssl ? { tls } : {}) ...(resource.ssl ? { tls } : {})

View file

@ -59,38 +59,39 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
const createResourceFormSchema = z const createResourceFormSchema = z
.object({ .object({
subdomain: z subdomain: z.string().optional(),
.union([
z
.string()
.regex(
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/,
"Invalid subdomain format"
)
.min(1, "Subdomain must be at least 1 character long")
.transform((val) => val.toLowerCase()),
z.string().optional()
])
.optional(),
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
siteId: z.number(), siteId: z.number(),
http: z.boolean(), http: z.boolean(),
protocol: z.string(), protocol: z.string(),
proxyPort: z.number().int().min(1).max(65535).optional() proxyPort: z.number().optional(),
}) })
.refine( .refine(
(data) => { (data) => {
if (data.http === true) { if (!data.http) {
return true; return z.number().int().min(1).max(65535).safeParse(data.proxyPort).success;
} }
return !!data.proxyPort; return true;
}, },
{ {
message: "Port number is required for non-HTTP resources", message: "Invalid port number",
path: ["proxyPort"] path: ["proxyPort"],
}
)
.refine(
(data) => {
if (data.http) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{
message: "Invalid subdomain",
path: ["subdomain"],
} }
); );

View file

@ -63,6 +63,7 @@ import {
} from "@app/components/Settings"; } from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { useSiteContext } from "@app/hooks/useSiteContext"; import { useSiteContext } from "@app/hooks/useSiteContext";
import { InfoPopup } from "@app/components/ui/info-popup";
// Regular expressions for validation // Regular expressions for validation
const DOMAIN_REGEX = const DOMAIN_REGEX =
@ -458,28 +459,28 @@ export default function ReverseProxyTargets(props: {
return ( return (
<SettingsContainer> <SettingsContainer>
{resource.http && ( {resource.http && (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
SSL Configuration SSL Configuration
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
Setup SSL to secure your connections with LetsEncrypt Setup SSL to secure your connections with
certificates LetsEncrypt certificates
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SwitchInput <SwitchInput
id="ssl-toggle" id="ssl-toggle"
label="Enable SSL (https)" label="Enable SSL (https)"
defaultChecked={resource.ssl} defaultChecked={resource.ssl}
onCheckedChange={async (val) => { onCheckedChange={async (val) => {
await saveSsl(val); await saveSsl(val);
}} }}
/> />
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)} )}
{/* Targets Section */} {/* Targets Section */}
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
@ -498,41 +499,45 @@ export default function ReverseProxyTargets(props: {
> >
<div className="grid grid-cols-2 md:grid-cols-3 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{resource.http && ( {resource.http && (
<FormField
<FormField control={addTargetForm.control}
control={addTargetForm.control} name="method"
name="method" render={({ field }) => (
render={({ field }) => ( <FormItem>
<FormItem> <FormLabel>Method</FormLabel>
<FormLabel>Method</FormLabel> <FormControl>
<FormControl> <Select
<Select value={
value={field.value || undefined} field.value ||
onValueChange={(value) => { undefined
addTargetForm.setValue( }
"method", onValueChange={(
value value
); ) => {
}} addTargetForm.setValue(
> "method",
<SelectTrigger id="method"> value
<SelectValue placeholder="Select method" /> );
</SelectTrigger> }}
<SelectContent> >
<SelectItem value="http"> <SelectTrigger id="method">
http <SelectValue placeholder="Select method" />
</SelectItem> </SelectTrigger>
<SelectItem value="https"> <SelectContent>
https <SelectItem value="http">
</SelectItem> http
</SelectContent> </SelectItem>
</Select> <SelectItem value="https">
</FormControl> https
<FormMessage /> </SelectItem>
</FormItem> </SelectContent>
)} </Select>
/> </FormControl>
)} <FormMessage />
</FormItem>
)}
/>
)}
<FormField <FormField
control={addTargetForm.control} control={addTargetForm.control}
@ -647,9 +652,9 @@ export default function ReverseProxyTargets(props: {
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
<SettingsSectionDescription> <p className="text-sm text-muted-foreground">
Multiple targets will get load balanced by Traefik. You can use this for high availability. Adding more than one target above will enable load balancing.
</SettingsSectionDescription> </p>
</SettingsSectionBody> </SettingsSectionBody>
<SettingsSectionFooter> <SettingsSectionFooter>
<Button <Button

View file

@ -36,24 +36,44 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
import CustomDomainInput from "../CustomDomainInput"; import CustomDomainInput from "../CustomDomainInput";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
const GeneralFormSchema = z.object({ const GeneralFormSchema = z
subdomain: z .object({
.union([ subdomain: z.string().optional(),
z name: z.string().min(1).max(255),
.string() proxyPort: z.number().optional(),
.regex( http: z.boolean()
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/, })
"Invalid subdomain format" .refine(
) (data) => {
.min(1, "Subdomain must be at least 1 character long") if (!data.http) {
.transform((val) => val.toLowerCase()), return z
z.string().optional() .number()
]) .int()
.optional(), .min(1)
name: z.string().min(1).max(255), .max(65535)
proxyPort: z.number().int().min(1).max(65535).optional() .safeParse(data.proxyPort).success;
}); }
return true;
},
{
message: "Invalid port number",
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (data.http) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{
message: "Invalid subdomain",
path: ["subdomain"]
}
);
type GeneralFormValues = z.infer<typeof GeneralFormSchema>; type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@ -77,7 +97,8 @@ export default function GeneralForm() {
defaultValues: { defaultValues: {
name: resource.name, name: resource.name,
subdomain: resource.subdomain ? resource.subdomain : undefined, subdomain: resource.subdomain ? resource.subdomain : undefined,
proxyPort: resource.proxyPort ? resource.proxyPort : undefined proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
http: resource.http
}, },
mode: "onChange" mode: "onChange"
}); });