mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-11 04:52:06 +10:00
use arktype for clientside validation
This commit is contained in:
35
.vscode/settings.json
vendored
35
.vscode/settings.json
vendored
@ -1,18 +1,21 @@
|
|||||||
{
|
{
|
||||||
"spellchecker.ignoreWordsList": [
|
"spellchecker.ignoreWordsList": ["mTLS", "Wireguard"],
|
||||||
"mTLS",
|
"sqltools.connections": [
|
||||||
"Wireguard"
|
{
|
||||||
],
|
"previewLimit": 50,
|
||||||
"sqltools.connections": [
|
"server": "localhost",
|
||||||
{
|
"port": 5432,
|
||||||
"previewLimit": 50,
|
"driver": "PostgreSQL",
|
||||||
"server": "localhost",
|
"name": "drop",
|
||||||
"port": 5432,
|
"database": "drop",
|
||||||
"driver": "PostgreSQL",
|
"username": "drop",
|
||||||
"name": "drop",
|
"password": "drop"
|
||||||
"database": "drop",
|
}
|
||||||
"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$"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"@prisma/client": "^6.1.0",
|
"@prisma/client": "^6.1.0",
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
"argon2": "^0.41.1",
|
"argon2": "^0.41.1",
|
||||||
|
"arktype": "^2.1.10",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cookie-es": "^1.2.2",
|
"cookie-es": "^1.2.2",
|
||||||
|
|||||||
@ -188,6 +188,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { XCircleIcon } from "@heroicons/vue/24/solid";
|
import { XCircleIcon } from "@heroicons/vue/24/solid";
|
||||||
|
import { type } from "arktype";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -208,14 +209,20 @@ const username = ref(invitation.data.value?.username);
|
|||||||
const password = ref("");
|
const password = ref("");
|
||||||
const confirmPassword = ref(undefined);
|
const confirmPassword = ref(undefined);
|
||||||
|
|
||||||
const mailRegex = /^\S+@\S+\.\S+$/;
|
const emailValidator = type("string.email");
|
||||||
const validEmail = computed(() => mailRegex.test(email.value ?? ""));
|
const validEmail = computed(
|
||||||
const validUsername = computed(
|
() => !(emailValidator(email.value) instanceof type.errors)
|
||||||
() =>
|
);
|
||||||
(username.value?.length ?? 0) >= 5 &&
|
|
||||||
username.value?.toLowerCase() == username.value
|
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(
|
const validConfirmPassword = computed(
|
||||||
() => password.value == confirmPassword.value
|
() => password.value == confirmPassword.value
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,9 +4,15 @@ import { createHashArgon2 } from "~/server/internal/security/simple";
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import * as jdenticon from "jdenticon";
|
import * as jdenticon from "jdenticon";
|
||||||
import objectHandler from "~/server/internal/objects";
|
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 userValidator = type({
|
||||||
const mailRegex = /^\S+@\S+\.\S+$/;
|
username: "string >= 5",
|
||||||
|
email: "string.email",
|
||||||
|
password: "string >= 14",
|
||||||
|
"displayName?": "string | undefined",
|
||||||
|
});
|
||||||
|
|
||||||
export default defineEventHandler(async (h3) => {
|
export default defineEventHandler(async (h3) => {
|
||||||
const body = await readBody(h3);
|
const body = await readBody(h3);
|
||||||
@ -27,59 +33,24 @@ export default defineEventHandler(async (h3) => {
|
|||||||
statusMessage: "Invalid or expired invitation.",
|
statusMessage: "Invalid or expired invitation.",
|
||||||
});
|
});
|
||||||
|
|
||||||
const useInvitationOrBodyRequirement = (
|
const user = userValidator(body);
|
||||||
field: keyof Invitation,
|
if (user instanceof type.errors) {
|
||||||
check: (v: string) => boolean
|
// hover out.summary to see validation errors
|
||||||
) => {
|
console.error(user.summary);
|
||||||
if (invitation[field]) {
|
|
||||||
return invitation[field].toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const v: string = body[field]?.toString();
|
|
||||||
const valid = check(v);
|
|
||||||
return valid ? v : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
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({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: "Username is invalid. Must be more than 5 characters.",
|
statusMessage: user.summary,
|
||||||
});
|
|
||||||
if (username.toLowerCase() != username)
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: "Username must be all lowercase",
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (email === undefined)
|
// reuse items from invite
|
||||||
throw createError({
|
if (invitation.username !== null) user.username = invitation.username;
|
||||||
statusCode: 400,
|
if (invitation.email !== null) user.email = invitation.email;
|
||||||
statusMessage: "Invalid email. Must follow the format you@example.com",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!password)
|
const existing = await prisma.user.count({
|
||||||
throw createError({
|
where: { username: user.username },
|
||||||
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)
|
if (existing > 0)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
@ -91,12 +62,12 @@ export default defineEventHandler(async (h3) => {
|
|||||||
const profilePictureId = uuidv4();
|
const profilePictureId = uuidv4();
|
||||||
await objectHandler.createFromSource(
|
await objectHandler.createFromSource(
|
||||||
profilePictureId,
|
profilePictureId,
|
||||||
async () => jdenticon.toPng(username, 256),
|
async () => jdenticon.toPng(user.username, 256),
|
||||||
{},
|
{},
|
||||||
[`internal:read`, `${userId}:write`]
|
[`internal:read`, `${userId}:write`]
|
||||||
);
|
);
|
||||||
|
|
||||||
const hash = await createHashArgon2(password);
|
const hash = await createHashArgon2(user.password);
|
||||||
const [linkMec] = await prisma.$transaction([
|
const [linkMec] = await prisma.$transaction([
|
||||||
prisma.linkedAuthMec.create({
|
prisma.linkedAuthMec.create({
|
||||||
data: {
|
data: {
|
||||||
@ -106,9 +77,9 @@ export default defineEventHandler(async (h3) => {
|
|||||||
user: {
|
user: {
|
||||||
create: {
|
create: {
|
||||||
id: userId,
|
id: userId,
|
||||||
username,
|
username: user.username,
|
||||||
displayName,
|
displayName: user.displayName ?? user.username,
|
||||||
email,
|
email: user.email,
|
||||||
profilePicture: profilePictureId,
|
profilePicture: profilePictureId,
|
||||||
admin: invitation.isAdmin,
|
admin: invitation.isAdmin,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "../.nuxt/tsconfig.server.json"
|
"extends": "../.nuxt/tsconfig.server.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"exactOptionalPropertyTypes": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
// https://nuxt.com/docs/guide/concepts/typescript
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
"extends": "./.nuxt/tsconfig.json"
|
"extends": "./.nuxt/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"exactOptionalPropertyTypes": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
yarn.lock
20
yarn.lock
@ -20,6 +20,18 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@antfu/utils/-/utils-0.7.10.tgz#ae829f170158e297a9b6a28f161a8e487d00814d"
|
resolved "https://registry.yarnpkg.com/@antfu/utils/-/utils-0.7.10.tgz#ae829f170158e297a9b6a28f161a8e487d00814d"
|
||||||
integrity sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==
|
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":
|
"@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"
|
version "7.26.2"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.26.2.tgz#4b5fab97d33338eff916235055f0ebc21e573a85"
|
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"
|
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
|
||||||
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
|
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:
|
ast-kit@^1.0.1, ast-kit@^1.3.0:
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/ast-kit/-/ast-kit-1.3.0.tgz#37c8b7418b6c59b1e593d7790dc6c2b1c0814761"
|
resolved "https://registry.yarnpkg.com/ast-kit/-/ast-kit-1.3.0.tgz#37c8b7418b6c59b1e593d7790dc6c2b1c0814761"
|
||||||
|
|||||||
Reference in New Issue
Block a user