Ground-Zerro / Phobos Public
Code Issues Pull requests Actions Releases View on GitHub ↗
6.4 KB typescript
import type { ZodSchema } from 'zod';
import z from 'zod';
import type { H3Event, EventHandlerRequest } from 'h3';
import { isIP } from 'is-ip';
import isCidr from 'is-cidr';

export type ID = number;

/**
 * return the string as is
 *
 * used for i18n ally
 */
export const t = (v: string) => v;

export const safeStringRefine = z
  .string()
  .refine(
    (v) => v !== '__proto__' && v !== 'constructor' && v !== 'prototype',
    { message: t('zod.stringMalformed') }
  );

export const EnabledSchema = z.boolean({ message: t('zod.enabled') });

export const MtuSchema = z
  .number({ message: t('zod.mtu') })
  // min for IPv6 is 1280, but we allow lower for IPv4
  .min(1024, { message: t('zod.mtu') })
  .max(9000, { message: t('zod.mtu') });

export const PersistentKeepaliveSchema = z
  .number({ message: t('zod.persistentKeepalive') })
  .min(0, t('zod.persistentKeepalive'))
  .max(65535, t('zod.persistentKeepalive'));

export const AddressSchema = z
  .string({ message: t('zod.address') })
  .min(1, { message: t('zod.address') })
  .pipe(safeStringRefine);

export const DnsSchema = z.array(AddressSchema, { message: t('zod.dns') });

export const AllowedIpsSchema = z
  .array(AddressSchema, { message: t('zod.allowedIps') })
  .min(1, { message: t('zod.allowedIps') });

// Validation for firewall IP entries
const FirewallIpEntrySchema = z
  .string({ message: t('zod.client.firewallIps') })
  .min(1, { message: t('zod.client.firewallIps') })
  .refine(
    (entry) => {
      // Check if protocol suffix is present
      const hasProto = /\/(tcp|udp)$/i.test(entry);
      const entryWithoutProto = entry.replace(/\/(tcp|udp)$/i, '');

      // If protocol was specified without a port, it's invalid
      if (hasProto) {
        // Protocol requires port, so check for IP:port format
        const portMatch = entryWithoutProto.match(/^(.+):(\d+)$/);
        if (!portMatch) {
          return false;
        }
        const [, ipPart, portPart] = portMatch;
        const port = parseInt(portPart!, 10);
        const cleanIp = ipPart!.replace(/^\[|\]$/g, '');
        return (isIP(cleanIp) || isCidr(cleanIp)) && port >= 1 && port <= 65535;
      }

      // Check if it's just IP or CIDR first (handles IPv6 addresses)
      if (isIP(entryWithoutProto) || isCidr(entryWithoutProto)) {
        return true;
      }

      // Check if it's bracketed IPv6 without port: [::1]
      const bracketedMatch = entryWithoutProto.match(/^\[(.+)\]$/);
      if (bracketedMatch) {
        const innerIp = bracketedMatch[1];
        return isIP(innerIp!) || isCidr(innerIp!);
      }

      // Check if it's IP:port format (IPv4:port or [IPv6]:port)
      const portMatch = entryWithoutProto.match(/^(.+):(\d+)$/);
      if (portMatch) {
        const [, ipPart, portPart] = portMatch;
        const port = parseInt(portPart!, 10);

        // Remove IPv6 brackets if present
        const cleanIp = ipPart!.replace(/^\[|\]$/g, '');

        // Validate IP and port
        return (isIP(cleanIp) || isCidr(cleanIp)) && port >= 1 && port <= 65535;
      }

      return false;
    },
    {
      message: t('zod.client.firewallIpsInvalid'),
    }
  );

export const FirewallIpsSchema = z.array(FirewallIpEntrySchema, {
  message: t('zod.client.firewallIps'),
});

export const FileSchema = z.object({
  file: z.string({ message: t('zod.file') }),
});

export const HookSchema = z
  .string({ message: t('zod.hook') })
  .pipe(safeStringRefine);

export const schemaForType =
  <T>() =>
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  <S extends z.ZodType<T, any, any>>(arg: S) => {
    return arg;
  };

export function validateZod<T>(
  schema: ZodSchema<T>,
  event: H3Event<EventHandlerRequest>
) {
  return async (data: unknown) => {
    try {
      return await schema.parseAsync(data);
    } catch (error) {
      let message = 'Unexpected Error';
      if (error instanceof z.ZodError) {
        const t = await useTranslation(event);

        message = error.issues
          .map((v) => {
            let m = v.message;

            if (t) {
              let newMessage = null;
              if (v.message.startsWith('zod.')) {
                switch (v.code) {
                  case 'too_small':
                    switch (v.origin) {
                      case 'string':
                        newMessage = t('zod.generic.stringMin', [
                          t(v.message),
                          v.minimum,
                        ]);
                        break;
                      case 'number':
                        newMessage = t('zod.generic.numberMin', [
                          t(v.message),
                          v.minimum,
                        ]);
                        break;
                    }
                    break;
                  case 'invalid_type': {
                    if (v.input === null || v.input === undefined) {
                      newMessage = t('zod.generic.required', [
                        v.path.join('.'),
                      ]);
                    } else {
                      switch (v.expected) {
                        case 'string':
                          newMessage = t('zod.generic.validString', [
                            t(v.message),
                          ]);
                          break;
                        case 'boolean':
                          newMessage = t('zod.generic.validBoolean', [
                            t(v.message),
                          ]);
                          break;
                        case 'number':
                          newMessage = t('zod.generic.validNumber', [
                            t(v.message),
                          ]);
                          break;
                        case 'array':
                          newMessage = t('zod.generic.validArray', [
                            t(v.message),
                          ]);
                          break;
                      }
                    }
                    break;
                  }
                }
              }
              if (newMessage) {
                m = newMessage;
              } else {
                m = t(v.message);
              }
            }

            return m;
          })
          .join('; ');
      }
      throw new Error(message);
    }
  };
}

/**
 * exhaustive check
 */
export function assertUnreachable(_: never): never {
  throw new Error("Didn't expect to get here");
}

export const typesTestExports = { FirewallIpEntrySchema };