Browse Source

add ability to update clients

pull/1572/head
Bernd Storath 3 months ago
parent
commit
a84b2f8258
No known key found for this signature in database GPG Key ID: D6C85685A555540F
  1. 1
      src/app/components/base/Switch.vue
  2. 11
      src/app/components/form/ActionField.vue
  3. 5
      src/app/components/form/ArrayField.vue
  4. 20
      src/app/components/form/DateField.vue
  5. 9
      src/app/components/form/Element.vue
  6. 1
      src/app/components/form/NumberField.vue
  7. 1
      src/app/components/form/TextField.vue
  8. 4
      src/app/pages/admin/config.vue
  9. 100
      src/app/pages/clients/[id].vue
  10. 13
      src/app/utils/api.ts
  11. 9
      src/i18n/locales/en.json
  12. 3
      src/package.json
  13. 44
      src/pnpm-lock.yaml
  14. 15
      src/server/api/client/[clientId]/index.post.ts
  15. 59
      src/server/utils/WireGuard.ts
  16. 31
      src/server/utils/apiHelper.ts
  17. 2
      src/server/utils/release.ts
  18. 181
      src/server/utils/types.ts
  19. 39
      src/services/database/lowdb.ts
  20. 22
      src/services/database/repositories/client.ts

1
src/app/components/base/Switch.vue

@ -2,6 +2,7 @@
<SwitchRoot
:id="id"
v-model:checked="data"
:name="id"
class="relative flex h-6 w-10 cursor-default rounded-full bg-gray-200 shadow-sm focus-within:outline focus-within:outline-red-700 data-[state=checked]:bg-red-800 dark:bg-neutral-400"
>
<SwitchThumb

11
src/app/components/form/ActionField.vue

@ -1,11 +1,18 @@
<template>
<input
:value="label"
type="button"
:type="type ?? 'button'"
:formmethod="formmethod"
:formaction="formaction"
class="col-span-2 rounded-lg border-2 border-gray-100 py-2 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>
</template>
<script lang="ts" setup>
defineProps<{ label: string }>();
defineProps<{
label: string;
type?: string;
formaction?: string;
formmethod?: string;
}>();
</script>

5
src/app/components/form/ArrayField.vue

@ -6,19 +6,20 @@
<div v-for="(item, i) in data" :key="item">
<input
:value="item"
:name="name"
type="text"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
@input="update(i)"
/>
<input type="button" value="-" @click="del(i)" />
</div>
<input type="button" value="Add" @click="add" />
</div>
<input type="button" value="Add" @click="add" />
</template>
<script lang="ts" setup>
const data = defineModel<string[]>();
defineProps<{ emptyText?: string[] }>();
defineProps<{ emptyText?: string[]; name: string }>();
function update(i: number) {
return (v: string) => {

20
src/app/components/form/DateField.vue

@ -0,0 +1,20 @@
<template>
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<input
:id="id"
v-model="data"
:name="id"
type="text"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>
</template>
<script lang="ts" setup>
defineProps<{ id: string; label: string }>();
// TODO: proper datefield
const data = defineModel<string | null>();
</script>

9
src/app/components/form/Element.vue

@ -0,0 +1,9 @@
<template>
<form :action="action" :method="method">
<slot />
</form>
</template>
<script lang="ts" setup>
defineProps<{ method: string; action: string }>();
</script>

1
src/app/components/form/NumberField.vue

@ -5,6 +5,7 @@
<input
:id="id"
v-model.number="data"
:name="id"
type="number"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>

1
src/app/components/form/TextField.vue

@ -5,6 +5,7 @@
<input
:id="id"
v-model="data"
:name="id"
type="text"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>

4
src/app/pages/admin/config.vue

@ -7,11 +7,11 @@
</FormGroup>
<FormGroup>
<FormHeading>Allowed IPs</FormHeading>
<FormArrayField v-model="data.allowedIps" />
<FormArrayField v-model="data.allowedIps" name="allowedIps" />
</FormGroup>
<FormGroup>
<FormHeading>DNS</FormHeading>
<FormArrayField v-model="data.defaultDns" />
<FormArrayField v-model="data.defaultDns" name="defaultDns" />
</FormGroup>
<FormGroup>
<FormHeading>Advanced</FormHeading>

100
src/app/pages/clients/[id].vue

@ -5,45 +5,64 @@
<PanelHeadTitle :text="data.name" />
</PanelHead>
<PanelBody>
<FormGroup>
<FormHeading>
{{ $t('me.sectionGeneral') }}
</FormHeading>
<FormTextField id="name" v-model.trim="data.name" label="Name" />
<FormSwitchField
id="enabled"
v-model="data.enabled"
label="Enabled"
/>
</FormGroup>
<FormGroup>
<FormHeading>Address</FormHeading>
<FormTextField id="ipv4" v-model.trim="data.address4" label="IPv4" />
<FormTextField id="ipv6" v-model.trim="data.address6" label="IPv6" />
</FormGroup>
<FormGroup>
<FormHeading>Allowed IPs</FormHeading>
<FormArrayField v-model="data.allowedIPs" />
</FormGroup>
<FormGroup>
<FormHeading>Server Allowed IPs</FormHeading>
<FormArrayField v-model="data.serverAllowedIPs" />
</FormGroup>
<FormGroup></FormGroup>
<FormGroup>
<FormHeading>Advanced</FormHeading>
<FormNumberField id="mtu" v-model="data.mtu" label="MTU" />
<FormNumberField
id="keepalive"
v-model="data.persistentKeepalive"
label="Persistent Keepalive"
/>
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>
<FormActionField label="Delete!" />
<FormActionField label="Revert!" @click="revert" />
</FormGroup>
<FormElement :action="submitAction" method="post">
<FormGroup>
<FormHeading>
{{ $t('me.sectionGeneral') }}
</FormHeading>
<FormTextField id="name" v-model.trim="data.name" label="Name" />
<FormSwitchField
id="enabled"
v-model="data.enabled"
label="Enabled"
/>
<FormDateField
id="expiresAt"
v-model.trim="data.expiresAt"
label="Expire Date"
/>
</FormGroup>
<FormGroup>
<FormHeading>Address</FormHeading>
<FormTextField
id="address4"
v-model.trim="data.address4"
label="IPv4"
/>
<FormTextField
id="address6"
v-model.trim="data.address6"
label="IPv6"
/>
</FormGroup>
<FormGroup>
<FormHeading>Allowed IPs</FormHeading>
<FormArrayField v-model="data.allowedIPs" name="allowedIPs" />
</FormGroup>
<FormGroup>
<FormHeading>Server Allowed IPs</FormHeading>
<FormArrayField
v-model="data.serverAllowedIPs"
name="serverAllowedIPs"
/>
</FormGroup>
<FormGroup></FormGroup>
<FormGroup>
<FormHeading>Advanced</FormHeading>
<FormNumberField id="mtu" v-model="data.mtu" label="MTU" />
<FormNumberField
id="persistentKeepalive"
v-model="data.persistentKeepalive"
label="Persistent Keepalive"
/>
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
<FormActionField type="submit" formmethod="delete" label="Delete" />
</FormGroup>
</FormElement>
</PanelBody>
</Panel>
</main>
@ -54,6 +73,9 @@ const authStore = useAuthStore();
authStore.update();
const route = useRoute();
const id = route.params.id as string;
const submitAction = computed(() => `/api/client/${id}`);
const { data: _data, refresh } = await useFetch(`/api/client/${id}`, {
method: 'get',
});

13
src/app/utils/api.ts

@ -57,19 +57,6 @@ class API {
});
}
async updateClientExpireDate({
clientId,
expireDate,
}: {
clientId: string;
expireDate: string | null;
}) {
return $fetch(`/api/client/${clientId}/expireDate`, {
method: 'put',
body: { expireDate },
});
}
async restoreConfiguration(file: string) {
return $fetch('/api/wireguard/restore', {
method: 'put',

9
src/i18n/locales/en.json

@ -39,8 +39,17 @@
"migration": "Restore the backup"
},
"zod": {
"stringMalformed": "String is malformed",
"id": "Client ID must be a valid UUID",
"address": "IP Address must be a valid string",
"addressMin": "IP Address must be a be at least 1 Character",
"address4": "IPv4 Address must be a valid string",
"address4Min": "IPv4 Address must be a be at least 1 Character",
"address6": "IPv6 Address must be a valid string",
"address6Min": "IPv6 Address must be a be at least 1 Character",
"allowedIPs": "Allowed IPs must be a valid array of strings",
"allowedIPsMin": "Allowed IPs must have at least 1 item",
"serverAllowedIPs": "Allowed IPs must be a valid array of strings",
"name": "Name must be a valid string",
"nameMin": "Name must be at least 1 Character",
"file": "File must be a valid string",

3
src/package.json

@ -41,7 +41,8 @@
"timeago.js": "^4.0.2",
"vue": "latest",
"vue3-apexcharts": "^1.8.0",
"zod": "^3.24.1"
"zod": "^3.24.1",
"zod-form-data": "^2.0.5"
},
"devDependencies": {
"@nuxt/eslint-config": "^0.7.3",

44
src/pnpm-lock.yaml

@ -83,6 +83,9 @@ importers:
zod:
specifier: ^3.24.1
version: 3.24.1
zod-form-data:
specifier: ^2.0.5
version: 2.0.5([email protected])
devDependencies:
'@nuxt/eslint-config':
specifier: ^0.7.3
@ -691,16 +694,20 @@ packages:
resolution: {integrity: sha512-6GT1BJ852gZ0gItNZN2krX5QAmea+cmdjMvsWohArAZ3GmHdnNANEcF9JjPXAMRtQ6Ux5E269ymamg/+WU6tQA==}
engines: {node: '>= 16'}
'@intlify/[email protected]beta.2':
resolution: {integrity: sha512-/cJHP1n45Zlf9tbm/hudLrUwXzJZngR9OMTQk32H1S4lBjM2996wzKTHuLbaJJlJZNTTjnfWZUHPb+F6sE6p1Q==}
'@intlify/[email protected]rc.1':
resolution: {integrity: sha512-TGw2uBfuTFTegZf/BHtUQBEKxl7Q/dVGLoqRIdw8lFsp9g/53sYn5iD+0HxIzdYjbWL6BTJMXCPUHp9PxDTRPw==}
engines: {node: '>= 16'}
'@intlify/[email protected]':
resolution: {integrity: sha512-bmsP4L2HqBF6i6uaMqJMcFBONVjKt+siGluRq4Ca4C0q7W2eMaVZr8iCgF9dKbcVXutftkC7D6z2SaSMmLiDyA==}
engines: {node: '>= 16'}
'@intlify/[email protected]':
resolution: {integrity: sha512-N6ngJfFaVA0l2iLtx/SymgHOBW4wiS5Pyue7YmY/G+mrGjesi+S+U+u/Xlv6pZa/YIBfeM4QB07lI7rz1YqKLg==}
'@intlify/[email protected]':
resolution: {integrity: sha512-8tR1xe7ZEbkabTuE/tNhzpolygUn9OaYp9yuYAF4MgDNZg06C3Qny80bes2/e9/Wm3aVkPUlCw6WgU7mQd0yEg==}
engines: {node: '>= 16'}
'@intlify/[email protected]':
resolution: {integrity: sha512-lH164+aDDptHZ3dBDbIhRa1dOPQUp+83iugpc+1upTOWCnwyC1PVis6rSWNMMJ8VQxvtHQB9JMib48K55y0PvQ==}
engines: {node: '>= 16'}
'@intlify/[email protected]':
@ -4534,6 +4541,11 @@ packages:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
[email protected]:
resolution: {integrity: sha512-T7dV6lTBCwkd8PyvJVCnjXKpgXomU8gEm/TcvEZY7qNdRhIo9T17HrdlHIK68PzTAYaV2HxR9rgwpTSWv0L+QQ==}
peerDependencies:
zod: '>= 3.11.0'
[email protected]:
resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==}
@ -5010,8 +5022,8 @@ snapshots:
'@intlify/[email protected]([email protected]([email protected]([email protected])))':
dependencies:
'@intlify/message-compiler': 11.0.0-beta.2
'@intlify/shared': 11.0.0-beta.2
'@intlify/message-compiler': 11.0.0-rc.1
'@intlify/shared': 11.0.0-rc.1
acorn: 8.14.0
escodegen: 2.1.0
estree-walker: 2.0.2
@ -5042,21 +5054,23 @@ snapshots:
'@intlify/shared': 10.0.5
source-map-js: 1.2.1
'@intlify/[email protected]beta.2':
'@intlify/[email protected]rc.1':
dependencies:
'@intlify/shared': 11.0.0-beta.2
'@intlify/shared': 11.0.0-rc.1
source-map-js: 1.2.1
'@intlify/[email protected]': {}
'@intlify/[email protected]': {}
'@intlify/[email protected]': {}
'@intlify/[email protected]': {}
'@intlify/[email protected](@vue/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]([email protected]([email protected])))([email protected]([email protected]))':
dependencies:
'@eslint-community/eslint-utils': 4.4.1([email protected]([email protected]))
'@intlify/bundle-utils': 10.0.0([email protected]([email protected]([email protected])))
'@intlify/shared': 10.0.5
'@intlify/vue-i18n-extensions': 7.0.0(@intlify/shared@10.0.5)(@vue/[email protected])([email protected]([email protected]([email protected])))([email protected]([email protected]))
'@intlify/shared': 11.0.1
'@intlify/vue-i18n-extensions': 7.0.0(@intlify/shared@11.0.1)(@vue/[email protected])([email protected]([email protected]([email protected])))([email protected]([email protected]))
'@rollup/pluginutils': 5.1.4([email protected])
'@typescript-eslint/scope-manager': 8.18.1
'@typescript-eslint/typescript-estree': 8.18.1([email protected])
@ -5080,11 +5094,11 @@ snapshots:
'@intlify/[email protected]': {}
'@intlify/[email protected](@intlify/shared@10.0.5)(@vue/[email protected])([email protected]([email protected]([email protected])))([email protected]([email protected]))':
'@intlify/[email protected](@intlify/shared@11.0.1)(@vue/[email protected])([email protected]([email protected]([email protected])))([email protected]([email protected]))':
dependencies:
'@babel/parser': 7.26.3
optionalDependencies:
'@intlify/shared': 10.0.5
'@intlify/shared': 11.0.1
'@vue/compiler-dom': 3.5.13
vue: 3.5.13([email protected])
vue-i18n: 10.0.5([email protected]([email protected]))
@ -9412,4 +9426,8 @@ snapshots:
compress-commons: 6.0.2
readable-stream: 4.5.2
[email protected]([email protected]):
dependencies:
zod: 3.24.1
[email protected]: {}

15
src/server/api/client/[clientId]/index.post.ts

@ -0,0 +1,15 @@
export default defineEventHandler(async (event) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(clientIdType)
);
const data = await readValidatedFormData(
event,
validateZod(clientUpdateType, event)
);
await WireGuard.updateClient({
clientId,
client: { ...data, expiresAt: data.expiresAt ?? null },
});
return;
});

59
src/server/utils/WireGuard.ts

@ -4,8 +4,10 @@ import crypto from 'node:crypto';
import QRCode from 'qrcode';
import CRC32 from 'crc-32';
import type { NewClient } from '~~/services/database/repositories/client';
import { isIPv4 } from 'is-ip';
import type {
CreateClient,
UpdateClient,
} from '~~/services/database/repositories/client';
const DEBUG = debug('WireGuard');
@ -143,7 +145,7 @@ class WireGuard {
// Create Client
const id = crypto.randomUUID();
const client: NewClient = {
const client: CreateClient = {
id,
name,
address4,
@ -208,56 +210,15 @@ class WireGuard {
await this.saveConfig();
}
async updateClientName({
async updateClient({
clientId,
name,
client,
}: {
clientId: string;
name: string;
client: UpdateClient;
}) {
await Database.client.updateName(clientId, name);
await this.saveConfig();
}
async updateClientAddress({
clientId,
address4,
}: {
clientId: string;
address4: string;
}) {
if (!isIPv4(address4)) {
throw createError({
statusCode: 400,
statusMessage: `Invalid Address: ${address4}`,
});
}
await Database.client.updateAddress4(clientId, address4);
await this.saveConfig();
}
async updateClientExpireDate({
clientId,
expireDate,
}: {
clientId: string;
expireDate: string | null;
}) {
let updatedDate: string | null = null;
if (expireDate) {
const date = new Date(expireDate);
date.setHours(23);
date.setMinutes(59);
date.setSeconds(59);
updatedDate = date.toISOString();
}
await Database.client.updateExpirationDate(clientId, updatedDate);
// TODO: validate ipv4, v6, expire date etc
await Database.client.update(clientId, client);
await this.saveConfig();
}

31
src/server/utils/apiHelper.ts

@ -0,0 +1,31 @@
import type { H3Event, InferEventInput } from 'h3';
export async function readValidatedFormData<
Event extends H3Event = H3Event,
T = InferEventInput<'body', Event, null>,
>(event: Event, validate: (data: FormData) => T) {
const _form = await readFormData(event);
return validateData(_form, validate);
}
async function validateData<T, K>(data: T, fn: (data: T) => K) {
try {
const res = await fn(data);
if (res === false) {
throw createValidationError();
}
return res;
} catch (error) {
throw createValidationError(error);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function createValidationError(validateError?: any) {
throw createError({
status: 400,
statusMessage: 'Validation Error',
message: validateError?.message || 'Validation Error',
data: validateError,
});
}

2
src/server/utils/release.ts

@ -30,7 +30,7 @@ async function fetchLatestRelease() {
try {
const response = await $fetch<GithubRelease>(
'https://api.github.com/repos/wg-easy/wg-easy/releases/latest',
{ method: 'get' }
{ method: 'get', timeout: 5000 }
);
if (!response) {
throw new Error('Empty Response');

181
src/server/utils/types.ts

@ -1,67 +1,26 @@
import type { ZodSchema } from 'zod';
import type { ZodSchema, ZodTypeDef } from 'zod';
import { z, ZodError } from 'zod';
import type { H3Event, EventHandlerRequest } from 'h3';
import { LOCALES } from '#shared/locales';
import { zfd } from 'zod-form-data';
// TODO: use i18n for messages
// TODO: make objects strict
const objectMessage = 'zod.body';
const safeStringRefine = z
.string()
.refine(
(v) => v !== '__proto__' && v !== 'constructor' && v !== 'prototype',
{ message: 'String is malformed' }
{ message: 'zod.stringMalformed' }
);
const id = z.string().uuid('zod.id').pipe(safeStringRefine);
const address4 = z.string({ message: 'zod.address4' }).pipe(safeStringRefine);
const name = z
.string({ message: 'zod.name' })
.min(1, 'zod.nameMin')
.pipe(safeStringRefine);
const file = z.string({ message: 'zod.file' }).pipe(safeStringRefine);
const file_ = z.instanceof(File, { message: 'zod.file' });
const username = z
.string({ message: 'zod.username' })
.min(8, 'zod.usernameMin') // i18n key
.pipe(safeStringRefine);
const password = z
.string({ message: 'zod.password' })
.min(12, 'zod.passwordMin') // i18n key
.regex(/[A-Z]/, 'zod.passwordUppercase') // i18n key
.regex(/[a-z]/, 'zod.passwordLowercase') // i18n key
.regex(/\d/, 'zod.passwordNumber') // i18n key
.regex(/[!@#$%^&*(),.?":{}|<>]/, 'zod.passwordSpecial') // i18n key
.pipe(safeStringRefine);
const accept = z.boolean().refine((val) => val === true, {
message: 'zod.accept',
}); // i18n key
const remember = z.boolean({ message: 'zod.remember' }); // i18n key
const expireDate = z
.string({ message: 'zod.expireDate' }) // i18n key
.min(1, 'zod.expireDateMin') // i18n key
.pipe(safeStringRefine)
.nullable();
const oneTimeLink = z
.string({ message: 'zod.otl' }) // i18n key
.min(1, 'zod.otlMin') // i18n key
.pipe(safeStringRefine);
const langs = LOCALES.map((lang) => lang.code);
const lang = z.enum(['', ...langs]);
const statistics = z.object(
{
enabled: z.boolean({ message: 'zod.statBool' }), // i18n key
chartType: z.number({ message: 'zod.statNumber' }), // i18n key
},
{ message: 'zod.stat' } // i18n key
);
export const langType = z.object({
lang: lang,
});
const host = z
.string({ message: 'zod.host' })
@ -73,47 +32,24 @@ const port = z
.min(1, 'zod.portMin')
.max(65535, 'zod.portMax');
const objectMessage = 'zod.body'; // i18n key
const langs = LOCALES.map((lang) => lang.code);
const lang = z.enum(['', ...langs]);
export const langType = z.object({
lang: lang,
});
export const hostPortType = z.object({
host: host,
port: port,
});
const id = z.string().uuid('zod.id').pipe(safeStringRefine);
export const clientIdType = z.object(
{
clientId: id,
},
{ message: "This shouldn't happen" }
);
export const address4Type = z.object(
{
address4: address4,
},
{ message: objectMessage }
);
export const nameType = z.object(
{
name: name,
},
{ message: objectMessage }
);
export const expireDateType = z.object(
{
expireDate: expireDate,
},
{ message: objectMessage }
);
const oneTimeLink = z
.string({ message: 'zod.otl' })
.min(1, 'zod.otlMin')
.pipe(safeStringRefine);
export const oneTimeLinkType = z.object(
{
@ -122,6 +58,17 @@ export const oneTimeLinkType = z.object(
{ message: objectMessage }
);
const name = z
.string({ message: 'zod.name' })
.min(1, 'zod.nameMin')
.pipe(safeStringRefine);
const expireDate = z
.string({ message: 'zod.expireDate' })
.min(1, 'zod.expireDateMin')
.pipe(safeStringRefine)
.nullable();
export const createType = z.object(
{
name: name,
@ -130,6 +77,9 @@ export const createType = z.object(
{ message: objectMessage }
);
const file = z.string({ message: 'zod.file' }).pipe(safeStringRefine);
const file_ = z.instanceof(File, { message: 'zod.file' });
export const fileType = z.object(
{
file: file,
@ -143,6 +93,22 @@ export const fileType_ = z.object(
{ message: objectMessage }
);
const username = z
.string({ message: 'zod.username' })
.min(8, 'zod.usernameMin')
.pipe(safeStringRefine);
const password = z
.string({ message: 'zod.password' })
.min(12, 'zod.passwordMin')
.regex(/[A-Z]/, 'zod.passwordUppercase')
.regex(/[a-z]/, 'zod.passwordLowercase')
.regex(/\d/, 'zod.passwordNumber')
.regex(/[!@#$%^&*(),.?":{}|<>]/, 'zod.passwordSpecial')
.pipe(safeStringRefine);
const remember = z.boolean({ message: 'zod.remember' });
export const credentialsType = z.object(
{
username: username,
@ -160,6 +126,10 @@ export const passwordType = z.object(
{ message: objectMessage }
);
const accept = z.boolean().refine((val) => val === true, {
message: 'zod.accept',
});
export const passwordSetupType = z.object(
{
username: username,
@ -169,15 +139,48 @@ export const passwordSetupType = z.object(
{ message: objectMessage }
);
export const statisticsType = z.object(
{
statistics: statistics,
},
{ message: objectMessage }
);
const address = z
.string({ message: 'zod.address' })
.min(1, { message: 'zod.addressMin' })
.pipe(safeStringRefine);
const address4 = z
.string({ message: 'zod.address4' })
.min(1, { message: 'zod.address4Min' })
.pipe(safeStringRefine);
const address6 = z
.string({ message: 'zod.address6' })
.min(1, { message: 'zod.address6Min' })
.pipe(safeStringRefine);
/** expects formdata, strict */
export const clientUpdateType = zfd.formData({
name: zfd.text(name),
enabled: zfd.checkbox(),
expiresAt: zfd.text(expireDate.optional()),
address4: zfd.text(address4),
address6: zfd.text(address6),
allowedIPs: zfd.repeatable(
z
.array(zfd.text(address), { message: 'zod.allowedIPs' })
.min(1, { message: 'zod.allowedIPsMin' })
),
serverAllowedIPs: zfd.repeatable(
z.array(zfd.text(address), { message: 'zod.serverAllowedIPs' })
),
mtu: zfd.numeric(),
persistentKeepalive: zfd.numeric(),
});
// from https://github.com/airjp73/rvf/blob/7e7c35d98015ea5ecff5affaf89f78296e84e8b9/packages/zod-form-data/src/helpers.ts#L117
type FormDataLikeInput = {
[Symbol.iterator](): IterableIterator<[string, FormDataEntryValue]>;
entries(): IterableIterator<[string, FormDataEntryValue]>;
};
export function validateZod<T>(
schema: ZodSchema<T>,
schema: ZodSchema<T> | ZodSchema<T, ZodTypeDef, FormData | FormDataLikeInput>,
event?: H3Event<EventHandlerRequest>
) {
return async (data: unknown) => {
@ -197,7 +200,7 @@ export function validateZod<T>(
let m = v.message;
if (t) {
m = t(m); // m key else v.message
m = t(m);
}
return m;

39
src/services/database/lowdb.ts

@ -15,7 +15,8 @@ import { migrationRunner } from './migrations';
import {
ClientRepository,
type Client,
type NewClient,
type UpdateClient,
type CreateClient,
type OneTimeLink,
} from './repositories/client';
import { SystemRepository, type Lang } from './repositories/system';
@ -173,7 +174,7 @@ class LowDBClient extends ClientRepository {
return makeReadonly(this.#db.data.clients[id]);
}
async create(client: NewClient) {
async create(client: CreateClient) {
DEBUG('Create Client');
const now = new Date().toISOString();
const newClient: Client = { ...client, createdAt: now, updatedAt: now };
@ -200,24 +201,6 @@ class LowDBClient extends ClientRepository {
});
}
async updateName(id: string, name: string) {
DEBUG('Update Client Name');
await this.#db.update((data) => {
if (data.clients[id]) {
data.clients[id].name = name;
}
});
}
async updateAddress4(id: string, address4: string) {
DEBUG('Update Client Address4');
await this.#db.update((data) => {
if (data.clients[id]) {
data.clients[id].address4 = address4;
}
});
}
async updateExpirationDate(id: string, expirationDate: string | null) {
DEBUG('Update Client Expiration Date');
await this.#db.update((data) => {
@ -249,6 +232,22 @@ class LowDBClient extends ClientRepository {
}
});
}
async update(id: string, client: UpdateClient) {
DEBUG('Create Client');
const now = new Date().toISOString();
await this.#db.update((data) => {
const oldClient = data.clients[id];
if (!oldClient) {
return;
}
data.clients[id] = {
...oldClient,
...client,
updatedAt: now,
};
});
}
}
export default class LowDB extends DatabaseProvider {

22
src/services/database/repositories/client.ts

@ -28,7 +28,17 @@ export type Client = {
mtu: number;
};
export type NewClient = Omit<Client, 'createdAt' | 'updatedAt'>;
export type CreateClient = Omit<Client, 'createdAt' | 'updatedAt'>;
export type UpdateClient = Omit<
Client,
| 'createdAt'
| 'updatedAt'
| 'id'
| 'oneTimeLink'
| 'privateKey'
| 'publicKey'
| 'preSharedKey'
>;
/**
* Interface for client-related database operations.
@ -38,18 +48,14 @@ export abstract class ClientRepository {
abstract findAll(): Promise<DeepReadonly<Record<string, Client>>>;
abstract findById(id: string): Promise<DeepReadonly<Client | undefined>>;
abstract create(client: NewClient): Promise<void>;
abstract create(client: CreateClient): Promise<void>;
abstract delete(id: string): Promise<void>;
abstract toggle(id: string, enable: boolean): Promise<void>;
abstract updateName(id: string, name: string): Promise<void>;
abstract updateAddress4(id: string, address4: string): Promise<void>;
abstract updateExpirationDate(
id: string,
expirationDate: string | null
): Promise<void>;
abstract deleteOneTimeLink(id: string): Promise<void>;
abstract createOneTimeLink(
id: string,
oneTimeLink: OneTimeLink
): Promise<void>;
abstract update(id: string, client: UpdateClient): Promise<void>;
}

Loading…
Cancel
Save