use arktype for clientside validation

This commit is contained in:
Huskydog9988
2025-03-22 19:37:28 -04:00
parent 257cdacad4
commit c1272dc7a7
7 changed files with 87 additions and 79 deletions

13
.vscode/settings.json vendored
View File

@ -1,8 +1,5 @@
{
"spellchecker.ignoreWordsList": [
"mTLS",
"Wireguard"
],
"spellchecker.ignoreWordsList": ["mTLS", "Wireguard"],
"sqltools.connections": [
{
"previewLimit": 50,
@ -14,5 +11,11 @@
"username": "drop",
"password": "drop"
}
]
],
// allow autocomplete for ArkType expressions like "string | num"
"editor.quickSuggestions": {
"strings": "on"
},
// prioritize ArkType's "type" for autoimports
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"]
}

View File

@ -19,6 +19,7 @@
"@prisma/client": "^6.1.0",
"@tailwindcss/vite": "^4.0.6",
"argon2": "^0.41.1",
"arktype": "^2.1.10",
"axios": "^1.7.7",
"bcryptjs": "^2.4.3",
"cookie-es": "^1.2.2",

View File

@ -188,6 +188,7 @@
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/24/solid";
import { type } from "arktype";
const route = useRoute();
const router = useRouter();
@ -208,14 +209,20 @@ const username = ref(invitation.data.value?.username);
const password = ref("");
const confirmPassword = ref(undefined);
const mailRegex = /^\S+@\S+\.\S+$/;
const validEmail = computed(() => mailRegex.test(email.value ?? ""));
const validUsername = computed(
() =>
(username.value?.length ?? 0) >= 5 &&
username.value?.toLowerCase() == username.value
const emailValidator = type("string.email");
const validEmail = computed(
() => !(emailValidator(email.value) instanceof type.errors)
);
const usernameValidator = type("string.lower.preformatted >= 5");
const validUsername = computed(
() => !(usernameValidator(username.value) instanceof type.errors)
);
const passwordValidator = type("string >= 14");
const validPassword = computed(
() => !(passwordValidator(password.value) instanceof type.errors)
);
const validPassword = computed(() => (password.value?.length ?? 0) >= 14);
const validConfirmPassword = computed(
() => password.value == confirmPassword.value
);

View File

@ -4,9 +4,15 @@ import { createHashArgon2 } from "~/server/internal/security/simple";
import { v4 as uuidv4 } from "uuid";
import * as jdenticon from "jdenticon";
import objectHandler from "~/server/internal/objects";
import { type } from "arktype";
import { writeNonLiteralDefaultMessage } from "arktype/internal/parser/shift/operator/default.ts";
// Only really a simple test, in case people mistype their emails
const mailRegex = /^\S+@\S+\.\S+$/;
const userValidator = type({
username: "string >= 5",
email: "string.email",
password: "string >= 14",
"displayName?": "string | undefined",
});
export default defineEventHandler(async (h3) => {
const body = await readBody(h3);
@ -27,59 +33,24 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Invalid or expired invitation.",
});
const useInvitationOrBodyRequirement = (
field: keyof Invitation,
check: (v: string) => boolean
) => {
if (invitation[field]) {
return invitation[field].toString();
const user = userValidator(body);
if (user instanceof type.errors) {
// hover out.summary to see validation errors
console.error(user.summary);
throw createError({
statusCode: 400,
statusMessage: user.summary,
});
}
const v: string = body[field]?.toString();
const valid = check(v);
return valid ? v : undefined;
};
// reuse items from invite
if (invitation.username !== null) user.username = invitation.username;
if (invitation.email !== null) user.email = invitation.email;
const username = useInvitationOrBodyRequirement(
"username",
(e) => e.length >= 5
);
const email = useInvitationOrBodyRequirement("email", (e) =>
mailRegex.test(e)
);
const password = body.password;
const displayName = body.displayName || username;
if (username === undefined)
throw createError({
statusCode: 400,
statusMessage: "Username is invalid. Must be more than 5 characters.",
const existing = await prisma.user.count({
where: { username: user.username },
});
if (username.toLowerCase() != username)
throw createError({
statusCode: 400,
statusMessage: "Username must be all lowercase",
});
if (email === undefined)
throw createError({
statusCode: 400,
statusMessage: "Invalid email. Must follow the format you@example.com",
});
if (!password)
throw createError({
statusCode: 400,
statusMessage: "Password empty or missing.",
});
if (password.length < 14)
throw createError({
statusCode: 400,
statusMessage: "Password must be 14 or more characters.",
});
const existing = await prisma.user.count({ where: { username: username } });
if (existing > 0)
throw createError({
statusCode: 400,
@ -91,12 +62,12 @@ export default defineEventHandler(async (h3) => {
const profilePictureId = uuidv4();
await objectHandler.createFromSource(
profilePictureId,
async () => jdenticon.toPng(username, 256),
async () => jdenticon.toPng(user.username, 256),
{},
[`internal:read`, `${userId}:write`]
);
const hash = await createHashArgon2(password);
const hash = await createHashArgon2(user.password);
const [linkMec] = await prisma.$transaction([
prisma.linkedAuthMec.create({
data: {
@ -106,9 +77,9 @@ export default defineEventHandler(async (h3) => {
user: {
create: {
id: userId,
username,
displayName,
email,
username: user.username,
displayName: user.displayName ?? user.username,
email: user.email,
profilePicture: profilePictureId,
admin: invitation.isAdmin,
},

View File

@ -1,3 +1,6 @@
{
"extends": "../.nuxt/tsconfig.server.json"
"extends": "../.nuxt/tsconfig.server.json",
"compilerOptions": {
"exactOptionalPropertyTypes": true
}
}

View File

@ -1,4 +1,7 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"exactOptionalPropertyTypes": true
}
}

View File

@ -20,6 +20,18 @@
resolved "https://registry.yarnpkg.com/@antfu/utils/-/utils-0.7.10.tgz#ae829f170158e297a9b6a28f161a8e487d00814d"
integrity sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==
"@ark/schema@0.45.0":
version "0.45.0"
resolved "https://registry.yarnpkg.com/@ark/schema/-/schema-0.45.0.tgz#5d5da5dfe94ca45d36d54513fe0c53566483a1e7"
integrity sha512-3XlMWkZbEjh0YsF92vnnRNCWNRNhRKDTf6XhugyCXH0YRFuM+w1vFLDbB2JLfZloEd7i5cbqsLaDLzyBZbPrSg==
dependencies:
"@ark/util" "0.45.0"
"@ark/util@0.45.0":
version "0.45.0"
resolved "https://registry.yarnpkg.com/@ark/util/-/util-0.45.0.tgz#2c55394a6af7865aeeb22924f301e28084aea4c0"
integrity sha512-Z1gHEGbpPzLtPmYb932t2B++6YonlUi1Fa14IQ4vhsGMWhd81Mi1miUmdZXW4fNI/wg1saT7H2/5cAuONgTXhg==
"@babel/code-frame@^7.12.13", "@babel/code-frame@^7.22.13", "@babel/code-frame@^7.24.7", "@babel/code-frame@^7.25.9", "@babel/code-frame@^7.26.0", "@babel/code-frame@^7.26.2":
version "7.26.2"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.26.2.tgz#4b5fab97d33338eff916235055f0ebc21e573a85"
@ -2106,6 +2118,14 @@ argparse@^2.0.1:
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
arktype@^2.1.10:
version "2.1.10"
resolved "https://registry.yarnpkg.com/arktype/-/arktype-2.1.10.tgz#7a2fb85d1e8fbb22077134993a12e9b12d34ef6e"
integrity sha512-KqbrzI9qIGrQUClifyS1HpUp/oTSRtGDvnMKzwg2TAvxRpynY1mn/ubXaxAAdGPOM8V3pBqwb01Z6TcXqhBxzQ==
dependencies:
"@ark/schema" "0.45.0"
"@ark/util" "0.45.0"
ast-kit@^1.0.1, ast-kit@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/ast-kit/-/ast-kit-1.3.0.tgz#37c8b7418b6c59b1e593d7790dc6c2b1c0814761"