From 6de5133d6fd280b4e826eaef6115321a69867b3d Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 10:25:34 +0700 Subject: [PATCH 1/7] Add Validation Schemas --- app/composables/useAuthRequest.ts | 17 - package-lock.json | 589 +++++++++++++++++++++++++++--- package.json | 10 +- server/utils/noteInput.ts | 85 ----- server/utils/validation.ts | 26 ++ shared/schemas/attachments.ts | 11 + shared/schemas/auth.ts | 5 + shared/schemas/common.ts | 4 + shared/schemas/labels.ts | 19 + shared/schemas/notes.ts | 66 ++++ 10 files changed, 674 insertions(+), 158 deletions(-) delete mode 100644 app/composables/useAuthRequest.ts delete mode 100644 server/utils/noteInput.ts create mode 100644 server/utils/validation.ts create mode 100644 shared/schemas/attachments.ts create mode 100644 shared/schemas/auth.ts create mode 100644 shared/schemas/common.ts create mode 100644 shared/schemas/labels.ts create mode 100644 shared/schemas/notes.ts diff --git a/app/composables/useAuthRequest.ts b/app/composables/useAuthRequest.ts deleted file mode 100644 index bf2dfd5..0000000 --- a/app/composables/useAuthRequest.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const useAuthRequest = () => { - const nuxtApp = useNuxtApp() - - const execute = async (request: () => Promise): Promise => { - try { - return await request() - } catch (error) { - if (getErrorStatus(error) === 401) { - await nuxtApp.runWithContext(() => navigateTo('/login')) - return undefined - } - throw error - } - } - - return { execute } -} diff --git a/package-lock.json b/package-lock.json index 7249ffb..402acf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@tailwindcss/vite": "^4.3.2", "@vite-pwa/nuxt": "^1.1.1", "better-sqlite3": "^12.6.2", + "consola": "^3.4.2", "drizzle-orm": "^0.45.2", "jose": "^6.2.0", "meilisearch": "^0.55.0", @@ -25,14 +26,17 @@ "sharp": "^0.34.5", "tailwindcss": "^4.3.2", "vue": "^3.5.29", - "vue-router": "^5.4.0" + "vue-router": "^5.4.0", + "zod": "^4.6.7" }, "devDependencies": { "@nuxt/eslint": "^1.15.2", + "@nuxt/test-utils": "^4.3.3", "@types/better-sqlite3": "^7.6.13", "drizzle-kit": "^0.31.9", "eslint": "^10.6.0", "typescript": "^6.0.3", + "vitest": "^5.0.4", "vue-tsc": "^3.3.13" }, "engines": { @@ -1675,9 +1679,9 @@ } }, "node_modules/@clack/core": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz", - "integrity": "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", + "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", "license": "MIT", "dependencies": { "fast-wrap-ansi": "^0.2.0", @@ -1688,12 +1692,12 @@ } }, "node_modules/@clack/prompts": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.4.0.tgz", - "integrity": "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", + "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", "license": "MIT", "dependencies": { - "@clack/core": "1.3.1", + "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" @@ -4038,6 +4042,214 @@ "integrity": "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA==", "license": "MIT" }, + "node_modules/@nuxt/test-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@nuxt/test-utils/-/test-utils-4.3.3.tgz", + "integrity": "sha512-0Nxp6e+VJROAMGuyurTX3+ON+l3y79TuNDsciy+7TfCPx4GcitTrYsxjomi65jLVi0H32aEb2sloh/qmiwj6aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/prompts": "1.8.1", + "@nuxt/devtools-kit": "^3.4.2", + "@nuxt/kit": "^4.5.2", + "consola": "^3.4.2", + "defu": "^6.1.7", + "exsolve": "^1.1.1", + "fake-indexeddb": "^6.2.5", + "get-port-please": "^3.2.0", + "magic-string": "^1.4.2", + "ofetch": "^1.5.1", + "oxc-walker": "^1.1.1", + "package-manager-detector": "^1.8.0", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "radix3": "^1.1.2", + "std-env": "^4.2.0", + "tinyexec": "^1.3.1", + "ufo": "^1.6.4", + "unplugin": "^3.4.0", + "vitest-environment-nuxt": "2.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@cucumber/cucumber": ">=11.0.0", + "@jest/globals": ">=30.0.0", + "@playwright/test": "^1.43.1", + "@testing-library/vue": "^8.0.1", + "@vue/test-utils": "^2.4.2", + "h3": "^1.15.0 || ^2.0.0-0", + "happy-dom": ">=20.0.11", + "jsdom": ">=27.4.0", + "nuxt": "^3.21.0 || ^4.0.0 || ^5.0.0-0", + "playwright-core": "^1.43.1", + "vitest": "^4.0.2 || ^5.0.0", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "@cucumber/cucumber": { + "optional": true + }, + "@jest/globals": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "@testing-library/vue": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "@vue/test-utils": { + "optional": true + }, + "h3": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "nuxt": { + "optional": true + }, + "playwright-core": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@nuxt/test-utils/node_modules/@nuxt/devtools-kit": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@nuxt/devtools-kit/-/devtools-kit-3.4.3.tgz", + "integrity": "sha512-mG4OT+1v6JkaRj+L9bYI6TaWLqDwu4NE+//UL/annLsrGiAOXc9FlFAnIIuQojPZ+H1gJeuc1pc968z/jXwUlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nuxt/kit": "^4.5.1", + "execa": "^8.0.1" + }, + "peerDependencies": { + "vite": ">=6.0" + } + }, + "node_modules/@nuxt/test-utils/node_modules/@nuxt/kit": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.6.1.tgz", + "integrity": "sha512-ld5+/o4EMqyw6Qy+SdEYkezfprS87+p+xOBhnQgXwyrg1EdtILfLeudQ+hX6UyCnsoF6o0SOw3pjN1P4Tg9O0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "consola": "^3.4.2", + "defu": "^6.1.7", + "destr": "^2.0.5", + "errx": "^0.2.2", + "exsolve": "^1.1.3", + "ignore": "^7.0.12", + "klona": "^2.0.6", + "microdiff": "^1.6.0", + "nostics": "^1.3.0", + "package-manager-detector": "^1.9.0", + "pathe": "^2.0.3", + "rc9": "^3.1.0", + "scule": "^1.3.0", + "std-env": "^4.3.0", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "unctx": "^3.0.1", + "verkit": "^0.5.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@nuxt/schema": "^3.0.0 || ^4.0.0 || ^5.0.0-0", + "confbox": ">=0.1.0", + "dotenv": "*", + "giget": ">=3.1.0", + "jiti": "^2.0.0", + "mlly": "^1.2.0", + "oxc-parser": ">=0.56.0", + "rolldown": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/schema": { + "optional": true + }, + "confbox": { + "optional": true + }, + "dotenv": { + "optional": true + }, + "giget": { + "optional": true + }, + "jiti": { + "optional": true + }, + "mlly": { + "optional": true + }, + "oxc-parser": { + "optional": true + }, + "rolldown": { + "optional": true + } + } + }, + "node_modules/@nuxt/test-utils/node_modules/errx": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/errx/-/errx-0.2.2.tgz", + "integrity": "sha512-HaPN6cxm6iZ9bIbQQ5orChcw7SY5smUzM08ZMc6OtV4UQhrrc3Ms1G8Ww2n/9lXkWqiioNTssF8bCvchrSbFEQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nuxt/test-utils/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@nuxt/test-utils/node_modules/unctx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/unctx/-/unctx-3.0.1.tgz", + "integrity": "sha512-5RAt2etv7g362RXyd33R82gm9u/kbtQlpoaOs9Bgm4E32GRMJ0xjbZyvdxUTmiuHk3V1IQb1aUNrp5IWY+JaWw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "magic-string": ">=0.30.21", + "oxc-parser": ">=0.140.0", + "rolldown": "^1.1.5", + "unplugin": "^3.3.0" + }, + "peerDependenciesMeta": { + "magic-string": { + "optional": true + }, + "oxc-parser": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "unplugin": { + "optional": true + } + } + }, "node_modules/@nuxt/vite-builder": { "version": "4.4.8", "resolved": "https://registry.npmjs.org/@nuxt/vite-builder/-/vite-builder-4.4.8.tgz", @@ -6975,6 +7187,24 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -7738,6 +7968,64 @@ "vue": "^3.0.0" } }, + "node_modules/@vitest/mocker": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.4", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.28", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", @@ -8364,6 +8652,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-kit": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", @@ -9072,6 +9370,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/change-case": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", @@ -10804,9 +11112,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", - "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "license": "MIT" }, "node_modules/es-object-atoms": { @@ -11568,12 +11876,32 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", + "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", "license": "MIT" }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -12439,9 +12767,9 @@ "license": "BSD-3-Clause" }, "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "license": "MIT", "engines": { "node": ">= 4" @@ -13747,18 +14075,6 @@ "yallist": "^3.0.2" } }, - "node_modules/magic-regexp": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/magic-regexp/-/magic-regexp-0.11.0.tgz", - "integrity": "sha512-LG77Z/gVnwz7oaDpD4heX6ryl+lcr4l1B2gnP4MMvt2pGhGC1Dfj7dl1pXpP4ih+VQFLuAadeKVa+lARAzfW+Q==", - "license": "MIT", - "dependencies": { - "magic-string": "^0.30.21", - "regexp-tree": "^0.1.27", - "type-level-regexp": "~0.1.17", - "unplugin": "^3.0.0" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -13830,6 +14146,13 @@ "node": ">= 8" } }, + "node_modules/microdiff": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/microdiff/-/microdiff-1.6.0.tgz", + "integrity": "sha512-w7JWt8Bno6I8h0rEqlxr4lNG4UbT1FVtWo42wWosIiaJ+rP3pXh7shCYDnyqBrYx36LJ1CZ64p8A62aVp0sJpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -15294,18 +15617,19 @@ } }, "node_modules/oxc-walker": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/oxc-walker/-/oxc-walker-1.0.0.tgz", - "integrity": "sha512-eMsHflAGfOskpWxtp9xP/f5b96XLEU8ifTd2gOOCkdux9HMxKGy5S1ru0Gh1B3aPu+YbfmWUUVkcb7MrZz3XyQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/oxc-walker/-/oxc-walker-1.1.1.tgz", + "integrity": "sha512-Hwd7dq28zu/Er99+bpWp16CGwFrhyalJh0W3JOB7XpPvgWo3MqMi2ksWGKuzzA9zt50mJ2pIUwR5lpAdZ9ACNQ==", "license": "MIT", - "dependencies": { - "magic-regexp": "^0.11.0" - }, "peerDependencies": { + "@oxc-project/types": ">=0.98.0", "oxc-parser": ">=0.98.0", "rolldown": ">=1.0.0" }, "peerDependenciesMeta": { + "@oxc-project/types": { + "optional": true + }, "oxc-parser": { "optional": true }, @@ -15353,9 +15677,9 @@ "license": "BlueOak-1.0.0" }, "node_modules/package-manager-detector": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", - "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz", + "integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==", "dev": true, "license": "MIT" }, @@ -16169,12 +16493,12 @@ "license": "ISC" }, "node_modules/rc9": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", - "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.1.0.tgz", + "integrity": "sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==", "license": "MIT", "dependencies": { - "defu": "^6.1.6", + "defu": "^6.1.7", "destr": "^2.0.5" } }, @@ -16333,6 +16657,7 @@ "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, "license": "MIT", "bin": { "regexp-tree": "bin/regexp-tree" @@ -17246,9 +17571,9 @@ } }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "license": "MIT" }, "node_modules/stop-iteration-iterator": { @@ -17820,6 +18145,16 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/tinyclip": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.12.tgz", @@ -17830,9 +18165,9 @@ } }, "node_modules/tinyexec": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", - "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "license": "MIT", "engines": { "node": ">=18" @@ -17967,12 +18302,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-level-regexp": { - "version": "0.1.17", - "resolved": "https://registry.npmjs.org/type-level-regexp/-/type-level-regexp-0.1.17.tgz", - "integrity": "sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==", - "license": "MIT" - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -18625,6 +18954,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/verkit": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz", + "integrity": "sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/vite": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", @@ -19024,6 +19366,123 @@ "@types/estree": "^1.0.0" } }, + "node_modules/vitest": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest-environment-nuxt": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/vitest-environment-nuxt/-/vitest-environment-nuxt-2.0.0.tgz", + "integrity": "sha512-zEGFRiCAaRR3fHnqISHKMNTRvCzkQEI1XyFeqNgR2IBD0oYkfZ1rUHwi7C+h3Cns3KPykfB0av1B3MtLEbChDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nuxt/test-utils": "^4.0.0" + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/vitest/node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/vscode-uri": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", @@ -19312,6 +19771,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -19862,6 +20334,15 @@ "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 0134879..d54d3d2 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "typecheck": "nuxt typecheck", "db:generate": "drizzle-kit generate", "db:studio": "drizzle-kit studio", - "postinstall": "nuxt prepare" + "postinstall": "nuxt prepare", + "test": "vitest run", + "test:unit": "vitest run --project unit" }, "dependencies": { "@nuxtjs/color-mode": "^4.0.0", @@ -36,6 +38,7 @@ "@tailwindcss/vite": "^4.3.2", "@vite-pwa/nuxt": "^1.1.1", "better-sqlite3": "^12.6.2", + "consola": "^3.4.2", "drizzle-orm": "^0.45.2", "jose": "^6.2.0", "meilisearch": "^0.55.0", @@ -45,14 +48,17 @@ "sharp": "^0.34.5", "tailwindcss": "^4.3.2", "vue": "^3.5.29", - "vue-router": "^5.4.0" + "vue-router": "^5.4.0", + "zod": "^4.6.7" }, "devDependencies": { "@nuxt/eslint": "^1.15.2", + "@nuxt/test-utils": "^4.3.3", "@types/better-sqlite3": "^7.6.13", "drizzle-kit": "^0.31.9", "eslint": "^10.6.0", "typescript": "^6.0.3", + "vitest": "^5.0.4", "vue-tsc": "^3.3.13" } } diff --git a/server/utils/noteInput.ts b/server/utils/noteInput.ts deleted file mode 100644 index ca32b2c..0000000 --- a/server/utils/noteInput.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { NoteType } from '#shared/types/notes' -import { CONSTRAINTS } from '#shared/utils/constants' - -// Hand-rolled request validation for note bodies; turns malformed input into 400s instead of 500s. - -export interface ChecklistItemInput { - id?: string - text: string - isChecked: boolean -} - -export interface NoteInput { - type?: NoteType - title?: string - content?: string - isArchived?: boolean - isTrashed?: boolean - checklistItems?: ChecklistItemInput[] - labelIds?: string[] -} - -const NOTE_TYPES: readonly NoteType[] = ['text', 'checklist'] - -function badRequest(message: string): never { - throw createError({ statusCode: 400, statusMessage: message }) -} - -function optionalBoolean(value: unknown, field: string): boolean | undefined { - if (value === undefined) return undefined - if (typeof value !== 'boolean') badRequest(`${field} must be a boolean`) - return value -} - -function optionalString(value: unknown, field: string, maxLength: number): string | undefined { - if (value === undefined) return undefined - if (typeof value !== 'string') badRequest(`${field} must be a string`) - if (value.length > maxLength) badRequest(`${field} must be ${maxLength.toLocaleString('en-US')} characters or less`) - return value -} - -function parseChecklistItems(value: unknown): ChecklistItemInput[] | undefined { - if (value === undefined) return undefined - if (!Array.isArray(value)) badRequest('Checklist items must be an array') - if (value.length > CONSTRAINTS.CHECKLIST_MAX_ITEMS) { - badRequest(`Checklist may have at most ${CONSTRAINTS.CHECKLIST_MAX_ITEMS} items`) - } - return value.map((raw: unknown) => { - if (typeof raw !== 'object' || raw === null) badRequest('Invalid checklist item') - const item = raw as Record - if (typeof item.text !== 'string' || item.text.length > CONSTRAINTS.CHECKLIST_ITEM_MAX_LENGTH) { - badRequest(`Each checklist item text must be ${CONSTRAINTS.CHECKLIST_ITEM_MAX_LENGTH} characters or less`) - } - if (item.id !== undefined && (typeof item.id !== 'string' || !item.id)) badRequest('Invalid checklist item id') - return { - id: item.id as string | undefined, - text: item.text, - isChecked: optionalBoolean(item.isChecked, 'isChecked') ?? false, - } - }) -} - -function parseLabelIds(value: unknown): string[] | undefined { - if (value === undefined) return undefined - if (!Array.isArray(value) || value.some((id) => typeof id !== 'string')) badRequest('labelIds must be an array of strings') - return value as string[] -} - -export function parseNoteInput(body: unknown): NoteInput { - if (typeof body !== 'object' || body === null) badRequest('Request body must be an object') - const raw = body as Record - - if (raw.type !== undefined && !NOTE_TYPES.includes(raw.type as NoteType)) badRequest('Invalid note type') - const title = optionalString(raw.title, 'Title', CONSTRAINTS.TITLE_MAX_LENGTH) - if (title !== undefined && !title.trim()) badRequest('Title is required') - - return { - type: raw.type as NoteType | undefined, - title, - content: optionalString(raw.content, 'Content', CONSTRAINTS.CONTENT_MAX_LENGTH), - isArchived: optionalBoolean(raw.isArchived, 'isArchived'), - isTrashed: optionalBoolean(raw.isTrashed, 'isTrashed'), - checklistItems: parseChecklistItems(raw.checklistItems), - labelIds: parseLabelIds(raw.labelIds), - } -} diff --git a/server/utils/validation.ts b/server/utils/validation.ts new file mode 100644 index 0000000..23424fb --- /dev/null +++ b/server/utils/validation.ts @@ -0,0 +1,26 @@ +import type { H3Event } from 'h3' +import type { z } from 'zod' + +// h3's readValidatedBody wraps thrown errors in a generic "Validation Error", so validate +// here instead to return the first issue's message as a 400. +function validate(schema: T, data: unknown): z.output { + const result = schema.safeParse(data) + if (result.success) return result.data + throw createError({ + statusCode: 400, + message: result.error.issues[0]?.message ?? 'Invalid Request', + data: { issues: result.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })) }, + }) +} + +export async function readBodyAs(event: H3Event, schema: T) { + return validate(schema, await readBody(event)) +} + +export function getQueryAs(event: H3Event, schema: T) { + return validate(schema, getQuery(event)) +} + +export function getParamsAs(event: H3Event, schema: T) { + return validate(schema, getRouterParams(event, { decode: true })) +} diff --git a/shared/schemas/attachments.ts b/shared/schemas/attachments.ts new file mode 100644 index 0000000..1779451 --- /dev/null +++ b/shared/schemas/attachments.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +export const attachmentRenameSchema = z.object({ + filename: z + .string({ error: 'Filename is Required' }) + .refine((value) => value.trim() !== '', 'Filename is Required'), +}) + +export const attachmentReorderSchema = z.object({ + ids: z.array(z.string().min(1), { error: 'Attachment Order is Required' }).min(1, 'Attachment Order is Required'), +}) diff --git a/shared/schemas/auth.ts b/shared/schemas/auth.ts new file mode 100644 index 0000000..0765b4a --- /dev/null +++ b/shared/schemas/auth.ts @@ -0,0 +1,5 @@ +import { z } from 'zod' + +export const loginSchema = z.object({ + code: z.string({ error: 'Invalid OTP Format' }).regex(/^\d{6}$/, 'Invalid OTP Format'), +}) diff --git a/shared/schemas/common.ts b/shared/schemas/common.ts new file mode 100644 index 0000000..fe23a68 --- /dev/null +++ b/shared/schemas/common.ts @@ -0,0 +1,4 @@ +import { z } from 'zod' + +export const idParamsSchema = z.object({ id: z.string().min(1) }) +export const attachmentParamsSchema = z.object({ id: z.string().min(1), attId: z.string().min(1) }) diff --git a/shared/schemas/labels.ts b/shared/schemas/labels.ts new file mode 100644 index 0000000..ba3c9a2 --- /dev/null +++ b/shared/schemas/labels.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' +import { CONSTRAINTS } from '../utils/constants' + +export const labelInputSchema = z.object({ + name: z + .string({ error: 'Label Name is Required' }) + .trim() + .min(1, 'Label Name is Required') + .max(CONSTRAINTS.LABEL_MAX_LENGTH, `Label Name Must Be ${CONSTRAINTS.LABEL_MAX_LENGTH} Characters or Less`), +}) + +export const labelReorderSchema = z.object({ + order: z + .array(z.object({ + id: z.string().min(1), + position: z.number().nonnegative(), + }), { error: 'Label Order is Required' }) + .min(1, 'Label Order is Required'), +}) diff --git a/shared/schemas/notes.ts b/shared/schemas/notes.ts new file mode 100644 index 0000000..0211035 --- /dev/null +++ b/shared/schemas/notes.ts @@ -0,0 +1,66 @@ +import { z } from 'zod' +import { CONSTRAINTS } from '../utils/constants' + +const title = z + .string({ error: 'Title Must Be Text' }) + .max(CONSTRAINTS.TITLE_MAX_LENGTH, `Title Must Be ${CONSTRAINTS.TITLE_MAX_LENGTH} Characters or Less`) + .refine((value) => value.trim() !== '', 'Title is Required') + +const content = z + .string({ error: 'Content Must Be Text' }) + .max(CONSTRAINTS.CONTENT_MAX_LENGTH, 'Content Must Be 10,000 Characters or Less') + +export const checklistItemInputSchema = z.object({ + id: z.string().min(1).optional(), + text: z + .string({ error: 'Checklist Item Must Be Text' }) + .max(CONSTRAINTS.CHECKLIST_ITEM_MAX_LENGTH, `Checklist Items Must Be ${CONSTRAINTS.CHECKLIST_ITEM_MAX_LENGTH} Characters or Less`), + isChecked: z.boolean().default(false), +}) + +const checklistItems = z + .array(checklistItemInputSchema, { error: 'Invalid Checklist' }) + .max(CONSTRAINTS.CHECKLIST_MAX_ITEMS, `Checklist May Have at Most ${CONSTRAINTS.CHECKLIST_MAX_ITEMS} Items`) + +const labelIds = z.array(z.string(), { error: 'Invalid Labels' }) + +const noteType = z.enum(['text', 'checklist'], { error: 'Invalid Note Type' }) + +export const noteCreateSchema = z.object({ + type: noteType.default('text'), + title: z.string({ error: 'Title is Required' }).pipe(title), + content: content.default(''), + checklistItems: checklistItems.default([]), + labelIds: labelIds.default([]), +}) + +export const noteUpdateSchema = z.object({ + type: noteType.optional(), + title: title.optional(), + content: content.optional(), + isArchived: z.boolean({ error: 'isArchived Must Be a Boolean' }).optional(), + isTrashed: z.boolean({ error: 'isTrashed Must Be a Boolean' }).optional(), + checklistItems: checklistItems.optional(), + labelIds: labelIds.optional(), +}) + +const page = z.coerce.number().int().min(1).catch(1) + +export const notesListQuerySchema = z.object({ + archived: z.stringbool().catch(false), + trashed: z.stringbool().catch(false), + labelId: z.string().min(1).optional(), + page, +}) + +export const searchQuerySchema = z.object({ + q: z.string().catch(''), + context: z.enum(['notes', 'archive', 'trash']).catch('notes'), + labelId: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).catch(CONSTRAINTS.PAGINATION_LIMIT), + page, +}) + +export type NoteCreateInput = z.infer +export type NoteUpdateInput = z.infer +export type ChecklistItemInput = z.infer From 2a3189be7b6e926699e08eeac2b503b5e1c6450f Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 10:25:34 +0700 Subject: [PATCH 2/7] Add Server Logger and Reindex Flag --- server/plugins/migrations.ts | 15 +++++++++------ server/utils/logger.ts | 8 ++++++++ server/utils/notesSvc.ts | 3 ++- server/utils/realtime.ts | 3 ++- server/utils/searchSvc.ts | 3 ++- 5 files changed, 23 insertions(+), 9 deletions(-) create mode 100644 server/utils/logger.ts diff --git a/server/plugins/migrations.ts b/server/plugins/migrations.ts index 9bfe5cc..95634aa 100644 --- a/server/plugins/migrations.ts +++ b/server/plugins/migrations.ts @@ -3,6 +3,7 @@ import { getDb, runMigrations, schema } from '../utils/db' import { ensureNotesIndex } from '../utils/searchSvc' import { reindexAllNotes } from '../utils/notesSvc' import { getJwtSecret } from '../utils/auth' +import { logger } from '../utils/logger' /** Bump when the shape or scope of indexed documents changes to trigger a full reindex on startup. */ const SEARCH_INDEX_VERSION = '2' @@ -14,19 +15,21 @@ export default defineNitroPlugin(async () => { // A failed migration leaves the schema in an unknown state; refuse to start rather than serve from it runMigrations() - console.log('[db] Migrations applied successfully') + logger.db.success('Migrations applied') try { await ensureNotesIndex() - console.log('[search] Meilisearch index ready') + logger.search.success('Meilisearch index ready') } catch (e) { - console.warn('[search] Meilisearch not available, search will be disabled:', e) + logger.search.warn('Meilisearch not available, search will be disabled:', e instanceof Error ? e.message : e) return } const db = getDb() const stored = db.select().from(schema.settings).where(eq(schema.settings.key, SEARCH_INDEX_VERSION_KEY)).get() - if (stored?.value === SEARCH_INDEX_VERSION) return + // SEARCH_REINDEX=true forces a full rebuild, e.g. after deleting data/meili + const forced = process.env.SEARCH_REINDEX === 'true' + if (stored?.value === SEARCH_INDEX_VERSION && !forced) return try { const count = await reindexAllNotes() @@ -34,8 +37,8 @@ export default defineNitroPlugin(async () => { .values({ key: SEARCH_INDEX_VERSION_KEY, value: SEARCH_INDEX_VERSION }) .onConflictDoUpdate({ target: schema.settings.key, set: { value: SEARCH_INDEX_VERSION } }) .run() - console.log(`[search] Reindexed ${count} notes`) + logger.search.success(`Reindexed ${count} notes`) } catch (e) { - console.warn('[search] Reindex failed, will retry on next startup:', e) + logger.search.warn('Reindex failed, will retry on next startup:', e instanceof Error ? e.message : e) } }) diff --git a/server/utils/logger.ts b/server/utils/logger.ts new file mode 100644 index 0000000..17cb29f --- /dev/null +++ b/server/utils/logger.ts @@ -0,0 +1,8 @@ +import { consola } from 'consola' + +export const logger = { + db: consola.withTag('db'), + search: consola.withTag('search'), + files: consola.withTag('files'), + realtime: consola.withTag('realtime'), +} diff --git a/server/utils/notesSvc.ts b/server/utils/notesSvc.ts index 476e70d..6734e0a 100644 --- a/server/utils/notesSvc.ts +++ b/server/utils/notesSvc.ts @@ -1,6 +1,7 @@ import { asc, eq, inArray } from 'drizzle-orm' import type { Note } from '#shared/types/notes' import { getDb, schema } from './db' +import { logger } from './logger' import { indexNote, indexNotes, removeNoteFromIndex, type NoteSearchDocument } from './searchSvc' /** Load notes with their labels, checklist items and attachments, preserving the order of `noteIds`. */ @@ -71,7 +72,7 @@ export async function syncNoteToIndex(noteId: string): Promise { if (note) await indexNote(toSearchDocument(note)) else await removeNoteFromIndex(noteId) } catch (e) { - console.warn(`[search] Failed to sync note ${noteId}:`, e instanceof Error ? e.message : e) + logger.search.warn(`Failed to sync note ${noteId}:`, e instanceof Error ? e.message : e) } } diff --git a/server/utils/realtime.ts b/server/utils/realtime.ts index c37935b..0bfabe3 100644 --- a/server/utils/realtime.ts +++ b/server/utils/realtime.ts @@ -1,4 +1,5 @@ import type { RealtimeEvent } from '#shared/types/realtime' +import { logger } from './logger' type Listener = (event: RealtimeEvent) => void @@ -15,7 +16,7 @@ class RealtimeEventBus { try { listener(event) } catch (error) { - console.warn('[realtime] Listener failed:', error) + logger.realtime.warn('Listener failed:', error) } } } diff --git a/server/utils/searchSvc.ts b/server/utils/searchSvc.ts index a228622..4c4759e 100644 --- a/server/utils/searchSvc.ts +++ b/server/utils/searchSvc.ts @@ -1,4 +1,5 @@ import { MeiliSearch, MeiliSearchApiError } from 'meilisearch' +import { logger } from './logger' let _client: MeiliSearch | null = null @@ -14,7 +15,7 @@ export function getMeiliClient(): MeiliSearch { })(), apiKey: (() => { const key = process.env.MEILI_MASTER_KEY - if (!key) console.warn('[search] MEILI_MASTER_KEY is not set; requests to Meilisearch will be unauthenticated.') + if (!key) logger.search.warn('MEILI_MASTER_KEY is not set; requests to Meilisearch will be unauthenticated.') return key || '' })(), }) From 79473826020415a8336aceef3b2b1ce37b121753 Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 10:25:34 +0700 Subject: [PATCH 3/7] Validate Requests and Standardize Responses --- server/api/auth/login.post.ts | 16 +++---- server/api/auth/logout.post.ts | 2 +- server/api/auth/me.get.ts | 5 +- server/api/files/[id].get.ts | 12 +++-- server/api/labels/[id].delete.ts | 2 +- server/api/labels/[id].put.ts | 16 ++----- server/api/labels/index.get.ts | 4 +- server/api/labels/index.post.ts | 16 ++----- server/api/labels/reorder.put.ts | 26 +++------- server/api/notes/[id].delete.ts | 5 +- server/api/notes/[id].get.ts | 8 ++-- server/api/notes/[id].put.ts | 5 +- server/api/notes/[id]/attachments.post.ts | 15 +++--- .../notes/[id]/attachments/[attId].delete.ts | 17 ++----- .../api/notes/[id]/attachments/[attId].put.ts | 47 ++++++------------- .../api/notes/[id]/attachments/reorder.put.ts | 31 ++++++------ server/api/notes/index.get.ts | 16 +++---- server/api/notes/index.post.ts | 17 +++---- server/api/notes/search.get.ts | 38 ++++++--------- server/api/notes/trash.delete.ts | 7 +-- server/middleware/auth.ts | 4 +- server/utils/guards.ts | 24 ++++++---- server/utils/labelLinksSvc.ts | 2 +- 23 files changed, 136 insertions(+), 199 deletions(-) diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts index 2bde05e..0b3128f 100644 --- a/server/api/auth/login.post.ts +++ b/server/api/auth/login.post.ts @@ -1,11 +1,13 @@ import { getDb, schema } from '../../utils/db' import { eq } from 'drizzle-orm' +import { loginSchema } from '#shared/schemas/auth' import { setAuthCookie } from '../../utils/auth' import { verifyOtp } from '../../utils/otp' import { getLoginWait, recordLoginFailure, recordLoginSuccess } from '../../utils/loginThrottle' +import { readBodyAs } from '../../utils/validation' function tooManyAttempts(secondsLeft: number) { - return createError({ statusCode: 429, statusMessage: 'Too many attempts', data: { secondsLeft } }) + return createError({ statusCode: 429, message: 'Too Many Attempts', data: { secondsLeft } }) } export default defineEventHandler(async (event) => { @@ -14,24 +16,20 @@ export default defineEventHandler(async (event) => { const wait = getLoginWait(clientKey) if (wait > 0) throw tooManyAttempts(wait) - const body = await readBody(event) as { code?: unknown } | null - const code = body?.code - if (typeof code !== 'string' || !/^\d{6}$/.test(code)) { - throw createError({ statusCode: 400, statusMessage: 'Invalid OTP Format' }) - } + const { code } = await readBodyAs(event, loginSchema) const row = await getDb().select().from(schema.settings).where(eq(schema.settings.key, 'totp_secret')).get() if (!row) { - throw createError({ statusCode: 404, statusMessage: 'OTP Not Configured' }) + throw createError({ statusCode: 404, message: 'OTP Not Configured' }) } if (!(await verifyOtp(code, row.value))) { const { wait: nextWait, attemptsLeft } = recordLoginFailure(clientKey) if (nextWait > 0) throw tooManyAttempts(nextWait) - throw createError({ statusCode: 401, statusMessage: 'Invalid OTP', data: { attemptsLeft } }) + throw createError({ statusCode: 401, message: 'Invalid OTP', data: { attemptsLeft } }) } recordLoginSuccess(clientKey) await setAuthCookie(event) - return { success: true } + return sendNoContent(event) }) diff --git a/server/api/auth/logout.post.ts b/server/api/auth/logout.post.ts index f82b8fd..cd0d4be 100644 --- a/server/api/auth/logout.post.ts +++ b/server/api/auth/logout.post.ts @@ -2,5 +2,5 @@ import { clearAuthCookie } from '../../utils/auth' export default defineEventHandler((event) => { clearAuthCookie(event) - return { success: true } + return sendNoContent(event) }) diff --git a/server/api/auth/me.get.ts b/server/api/auth/me.get.ts index b7140c7..bab57a5 100644 --- a/server/api/auth/me.get.ts +++ b/server/api/auth/me.get.ts @@ -1,3 +1,2 @@ -export default defineEventHandler(() => { - return { ok: true } -}) +// Reaching this handler means the auth middleware accepted the session +export default defineEventHandler((event) => sendNoContent(event)) diff --git a/server/api/files/[id].get.ts b/server/api/files/[id].get.ts index 0228bb9..f0330b7 100644 --- a/server/api/files/[id].get.ts +++ b/server/api/files/[id].get.ts @@ -2,24 +2,26 @@ import { getDb, schema } from '../../utils/db' import { eq } from 'drizzle-orm' import { createReadStream, existsSync } from 'node:fs' import { getAbsolutePath } from '../../utils/filesSvc' -import { requireRouterParam } from '../../utils/guards' +import { idParamsSchema } from '#shared/schemas/common' +import { logger } from '../../utils/logger' +import { getParamsAs } from '../../utils/validation' import { INLINE_MIME_TYPES } from '#shared/utils/files' export default defineEventHandler(async (event) => { const db = getDb() - const id = requireRouterParam(event, 'id') + const { id } = getParamsAs(event, idParamsSchema) const query = getQuery(event) const thumb = query.thumb === 'true' const forceDownload = query.download === 'true' || query.download === '1' const att = await db.select().from(schema.attachments).where(eq(schema.attachments.id, id)).get() - if (!att) throw createError({ statusCode: 404, statusMessage: 'File not found' }) + if (!att) throw createError({ statusCode: 404, message: 'File Not Found' }) const useThumb = thumb && !!att.thumbnailPath && existsSync(getAbsolutePath(att.thumbnailPath)) const filePath = getAbsolutePath(useThumb ? att.thumbnailPath! : att.storagePath) if (!existsSync(filePath)) { - console.warn('[files] Missing file on disk', { id, storagePath: att.storagePath }) - throw createError({ statusCode: 404, statusMessage: 'File not found on disk' }) + logger.files.warn('Missing file on disk', { id, storagePath: att.storagePath }) + throw createError({ statusCode: 404, message: 'File Not Found' }) } const mimeType = useThumb ? 'image/webp' : att.mimeType diff --git a/server/api/labels/[id].delete.ts b/server/api/labels/[id].delete.ts index 3a74d6b..8c0d261 100644 --- a/server/api/labels/[id].delete.ts +++ b/server/api/labels/[id].delete.ts @@ -8,5 +8,5 @@ export default defineEventHandler(async (event) => { await getDb().delete(schema.labels).where(eq(schema.labels.id, label.id)) realtimeBus.publish({ type: 'label.deleted', data: { resource: 'labels', labelId: label.id } }) - return { success: true } + return sendNoContent(event) }) diff --git a/server/api/labels/[id].put.ts b/server/api/labels/[id].put.ts index a28bfae..5d080e9 100644 --- a/server/api/labels/[id].put.ts +++ b/server/api/labels/[id].put.ts @@ -1,21 +1,13 @@ import { getDb, schema } from '../../utils/db' import { and, eq, ne } from 'drizzle-orm' +import { labelInputSchema } from '#shared/schemas/labels' import { realtimeBus } from '../../utils/realtime' -import { CONSTRAINTS } from '#shared/utils/constants' import { requireLabel } from '../../utils/guards' +import { readBodyAs } from '../../utils/validation' export default defineEventHandler(async (event) => { - const body = await readBody(event) as { name?: unknown } | null - - if (typeof body?.name !== 'string' || !body.name.trim()) { - throw createError({ statusCode: 400, statusMessage: 'Label name is required' }) - } - if (body.name.trim().length > CONSTRAINTS.LABEL_MAX_LENGTH) { - throw createError({ statusCode: 400, statusMessage: `Label name must be ${CONSTRAINTS.LABEL_MAX_LENGTH} characters or fewer` }) - } - + const { name } = await readBodyAs(event, labelInputSchema) const label = await requireLabel(event) - const name = body.name.trim() const db = getDb() const duplicate = await db @@ -23,7 +15,7 @@ export default defineEventHandler(async (event) => { .from(schema.labels) .where(and(eq(schema.labels.name, name), ne(schema.labels.id, label.id))) .get() - if (duplicate) throw createError({ statusCode: 409, statusMessage: 'Label already exists' }) + if (duplicate) throw createError({ statusCode: 409, message: 'Label Already Exists' }) const updated = await db .update(schema.labels) diff --git a/server/api/labels/index.get.ts b/server/api/labels/index.get.ts index a15e646..08de6e9 100644 --- a/server/api/labels/index.get.ts +++ b/server/api/labels/index.get.ts @@ -2,7 +2,5 @@ import { getDb, schema } from '../../utils/db' import { asc } from 'drizzle-orm' export default defineEventHandler(async () => { - const db = getDb() - const rows = await db.select().from(schema.labels).orderBy(asc(schema.labels.position), asc(schema.labels.createdAt)).all() - return { labels: rows } + return getDb().select().from(schema.labels).orderBy(asc(schema.labels.position), asc(schema.labels.createdAt)).all() }) diff --git a/server/api/labels/index.post.ts b/server/api/labels/index.post.ts index 02b7520..d4be110 100644 --- a/server/api/labels/index.post.ts +++ b/server/api/labels/index.post.ts @@ -1,23 +1,16 @@ import { getDb, schema } from '../../utils/db' import { eq, max } from 'drizzle-orm' import { nanoid } from 'nanoid' +import { labelInputSchema } from '#shared/schemas/labels' import { realtimeBus } from '../../utils/realtime' -import { CONSTRAINTS } from '#shared/utils/constants' +import { readBodyAs } from '../../utils/validation' export default defineEventHandler(async (event) => { const db = getDb() - const body = await readBody(event) as { name?: unknown } | null + const { name } = await readBodyAs(event, labelInputSchema) - if (typeof body?.name !== 'string' || !body.name.trim()) { - throw createError({ statusCode: 400, statusMessage: 'Label name is required' }) - } - if (body.name.trim().length > CONSTRAINTS.LABEL_MAX_LENGTH) { - throw createError({ statusCode: 400, statusMessage: `Label name must be ${CONSTRAINTS.LABEL_MAX_LENGTH} characters or fewer` }) - } - - const name = body.name.trim() const existing = await db.select().from(schema.labels).where(eq(schema.labels.name, name)).get() - if (existing) throw createError({ statusCode: 409, statusMessage: 'Label already exists' }) + if (existing) throw createError({ statusCode: 409, message: 'Label Already Exists' }) const { maxPosition } = await db.select({ maxPosition: max(schema.labels.position) }).from(schema.labels).get() ?? {} const label = await db @@ -27,5 +20,6 @@ export default defineEventHandler(async (event) => { .get() realtimeBus.publish({ type: 'label.created', data: { resource: 'labels', label } }) + setResponseStatus(event, 201) return label }) diff --git a/server/api/labels/reorder.put.ts b/server/api/labels/reorder.put.ts index 43dac7c..bfb298f 100644 --- a/server/api/labels/reorder.put.ts +++ b/server/api/labels/reorder.put.ts @@ -1,30 +1,18 @@ import { getDb, schema } from '../../utils/db' import { eq } from 'drizzle-orm' +import { labelReorderSchema } from '#shared/schemas/labels' import { realtimeBus } from '../../utils/realtime' +import { readBodyAs } from '../../utils/validation' export default defineEventHandler(async (event) => { - const db = getDb() - const body = await readBody(event) as { order: Array<{ id: string; position: number }> } + const { order } = await readBodyAs(event, labelReorderSchema) - if (!Array.isArray(body?.order) || body.order.length === 0) { - throw createError({ statusCode: 400, statusMessage: 'order array required' }) - } - - for (const item of body.order) { - if (typeof item.id !== 'string' || !item.id) { - throw createError({ statusCode: 400, statusMessage: 'Each item must have a string id' }) - } - if (typeof item.position !== 'number' || !Number.isFinite(item.position) || item.position < 0) { - throw createError({ statusCode: 400, statusMessage: 'Each item position must be a non-negative number' }) - } - } - - db.transaction((tx) => { - for (const { id, position } of body.order) { + getDb().transaction((tx) => { + for (const { id, position } of order) { tx.update(schema.labels).set({ position }).where(eq(schema.labels.id, id)).run() } }) - realtimeBus.publish({ type: 'label.reordered', data: { resource: 'labels', order: body.order } }) - return { ok: true } + realtimeBus.publish({ type: 'label.reordered', data: { resource: 'labels', order } }) + return sendNoContent(event) }) diff --git a/server/api/notes/[id].delete.ts b/server/api/notes/[id].delete.ts index 61b605d..e85dca1 100644 --- a/server/api/notes/[id].delete.ts +++ b/server/api/notes/[id].delete.ts @@ -1,6 +1,7 @@ import { getDb, schema } from '../../utils/db' import { eq } from 'drizzle-orm' import { removeNoteFromIndex } from '../../utils/searchSvc' +import { logger } from '../../utils/logger' import { deleteNoteDir } from '../../utils/filesSvc' import { realtimeBus } from '../../utils/realtime' import { requireNote } from '../../utils/guards' @@ -11,8 +12,8 @@ export default defineEventHandler(async (event) => { // Remove the rows first so a failure never leaves a note pointing at deleted files getDb().delete(schema.notes).where(eq(schema.notes.id, id)).run() deleteNoteDir(id) - removeNoteFromIndex(id).catch((e) => console.warn('[search] Failed to remove note from index:', e?.message)) + removeNoteFromIndex(id).catch((e) => logger.search.warn('Failed to remove note from index:', e?.message)) realtimeBus.publish({ type: 'note.deleted', data: { resource: 'notes', noteId: id } }) - return { success: true } + return sendNoContent(event) }) diff --git a/server/api/notes/[id].get.ts b/server/api/notes/[id].get.ts index 26798b8..09144d3 100644 --- a/server/api/notes/[id].get.ts +++ b/server/api/notes/[id].get.ts @@ -1,8 +1,10 @@ import { getNoteWithMeta } from '../../utils/notesSvc' -import { requireRouterParam } from '../../utils/guards' +import { idParamsSchema } from '#shared/schemas/common' +import { getParamsAs } from '../../utils/validation' export default defineEventHandler(async (event) => { - const note = await getNoteWithMeta(requireRouterParam(event, 'id')) - if (!note) throw createError({ statusCode: 404, statusMessage: 'Note not found' }) + const { id } = getParamsAs(event, idParamsSchema) + const note = await getNoteWithMeta(id) + if (!note) throw createError({ statusCode: 404, message: 'Note Not Found' }) return note }) diff --git a/server/api/notes/[id].put.ts b/server/api/notes/[id].put.ts index 112c603..0379387 100644 --- a/server/api/notes/[id].put.ts +++ b/server/api/notes/[id].put.ts @@ -3,7 +3,8 @@ import { and, eq, inArray, ne } from 'drizzle-orm' import { nanoid } from 'nanoid' import { assertLabelsExist, replaceNoteLabels } from '../../utils/labelLinksSvc' import { getNoteWithMeta, syncNoteToIndex } from '../../utils/notesSvc' -import { parseNoteInput } from '../../utils/noteInput' +import { noteUpdateSchema } from '#shared/schemas/notes' +import { readBodyAs } from '../../utils/validation' import { realtimeBus } from '../../utils/realtime' import { requireNote } from '../../utils/guards' @@ -11,7 +12,7 @@ export default defineEventHandler(async (event) => { const db = getDb() const { id } = await requireNote(event) const { title, content, isArchived, isTrashed, type, checklistItems, labelIds: rawLabelIds } = - parseNoteInput(await readBody(event)) + await readBodyAs(event, noteUpdateSchema) const labelIds = rawLabelIds && assertLabelsExist(db, rawLabelIds) const now = new Date().toISOString() diff --git a/server/api/notes/[id]/attachments.post.ts b/server/api/notes/[id]/attachments.post.ts index c55827e..5383e43 100644 --- a/server/api/notes/[id]/attachments.post.ts +++ b/server/api/notes/[id]/attachments.post.ts @@ -25,10 +25,10 @@ const THUMBNAIL_QUALITY = 75 /** Reject oversized requests before the body is buffered into memory. */ function assertRequestSize(contentLength: string | undefined) { if (!contentLength) { - throw createError({ statusCode: 411, statusMessage: 'Content-Length header is required' }) + throw createError({ statusCode: 411, message: 'Content-Length Header is Required' }) } if (Number(contentLength) > MAX_FILE_SIZE + MULTIPART_OVERHEAD) { - throw createError({ statusCode: 413, statusMessage: 'Upload exceeds 100MB limit' }) + throw createError({ statusCode: 413, message: 'File is Too Large (Max. 100MB)' }) } } @@ -39,10 +39,10 @@ export default defineEventHandler(async (event) => { const parts = (await readMultipartFormData(event))?.filter((part) => part.filename && part.data) if (!parts || parts.length === 0) { - throw createError({ statusCode: 400, statusMessage: 'No files uploaded' }) + throw createError({ statusCode: 400, message: 'No Files Uploaded' }) } if (parts.length > MAX_FILES_PER_REQUEST) { - throw createError({ statusCode: 400, statusMessage: `At most ${MAX_FILES_PER_REQUEST} files per upload` }) + throw createError({ statusCode: 400, message: `At Most ${MAX_FILES_PER_REQUEST} Files per Upload` }) } const { existingCount } = db @@ -57,12 +57,12 @@ export default defineEventHandler(async (event) => { ensureFilesDir(noteId) for (const part of parts) { if (part.data.length > MAX_FILE_SIZE) { - throw createError({ statusCode: 413, statusMessage: `File ${part.filename} exceeds 100MB limit` }) + throw createError({ statusCode: 413, message: 'File is Too Large (Max. 100MB)' }) } let filename = sanitizeFilename(part.filename!) if (filename.length > MAX_FILENAME_LENGTH) { - throw createError({ statusCode: 400, statusMessage: `Maximum file name length is ${MAX_FILENAME_LENGTH} characters.` }) + throw createError({ statusCode: 400, message: `Filename Must Be ${MAX_FILENAME_LENGTH} Characters or Less` }) } const { mimeType, ext } = resolveUploadType(part.data, part.type, filename) if (ext) { @@ -115,5 +115,6 @@ export default defineEventHandler(async (event) => { void syncNoteToIndex(noteId) realtimeBus.publish({ type: 'attachment.uploaded', data: { resource: 'notes', noteId, attachments: rows } }) - return { attachments: rows } + setResponseStatus(event, 201) + return rows }) diff --git a/server/api/notes/[id]/attachments/[attId].delete.ts b/server/api/notes/[id]/attachments/[attId].delete.ts index f36a21c..2b07af6 100644 --- a/server/api/notes/[id]/attachments/[attId].delete.ts +++ b/server/api/notes/[id]/attachments/[attId].delete.ts @@ -2,22 +2,15 @@ import { getDb, schema } from '../../../../utils/db' import { eq } from 'drizzle-orm' import { deleteFile } from '../../../../utils/filesSvc' import { realtimeBus } from '../../../../utils/realtime' -import { requireRouterParam } from '../../../../utils/guards' +import { requireAttachment } from '../../../../utils/guards' export default defineEventHandler(async (event) => { - const db = getDb() - const noteId = requireRouterParam(event, 'id') - const attId = requireRouterParam(event, 'attId') + const att = await requireAttachment(event) - const att = await db.select().from(schema.attachments).where(eq(schema.attachments.id, attId)).get() - if (!att || att.noteId !== noteId) { - throw createError({ statusCode: 404, statusMessage: 'Attachment not found' }) - } - - await db.delete(schema.attachments).where(eq(schema.attachments.id, attId)) + await getDb().delete(schema.attachments).where(eq(schema.attachments.id, att.id)) deleteFile(att.storagePath) if (att.thumbnailPath) deleteFile(att.thumbnailPath) - realtimeBus.publish({ type: 'attachment.deleted', data: { resource: 'notes', noteId, attachmentId: attId } }) - return { success: true } + realtimeBus.publish({ type: 'attachment.deleted', data: { resource: 'notes', noteId: att.noteId, attachmentId: att.id } }) + return sendNoContent(event) }) diff --git a/server/api/notes/[id]/attachments/[attId].put.ts b/server/api/notes/[id]/attachments/[attId].put.ts index 7c99af1..ae2af49 100644 --- a/server/api/notes/[id]/attachments/[attId].put.ts +++ b/server/api/notes/[id]/attachments/[attId].put.ts @@ -1,47 +1,30 @@ -import { and, eq } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { getDb, schema } from '../../../../utils/db' +import { attachmentRenameSchema } from '#shared/schemas/attachments' +import { CONSTRAINTS } from '#shared/utils/constants' import { sanitizeFilename } from '../../../../utils/sanitize' import { syncNoteToIndex } from '../../../../utils/notesSvc' import { realtimeBus } from '../../../../utils/realtime' -import { requireRouterParam } from '../../../../utils/guards' -import { CONSTRAINTS } from '#shared/utils/constants' - -const MAX_FILENAME_LENGTH = CONSTRAINTS.FILENAME_MAX_LENGTH +import { requireAttachment } from '../../../../utils/guards' +import { readBodyAs } from '../../../../utils/validation' export default defineEventHandler(async (event) => { - const db = getDb() - const noteId = requireRouterParam(event, 'id') - const attId = requireRouterParam(event, 'attId') - const body = await readBody(event) - const rawName = typeof body?.filename === 'string' ? body.filename : '' - - if (!rawName.trim()) { - throw createError({ statusCode: 400, statusMessage: 'Filename is required' }) - } - - const safeName = sanitizeFilename(rawName) - if (safeName.length > MAX_FILENAME_LENGTH) { - throw createError({ statusCode: 400, statusMessage: `Filename too long (max ${MAX_FILENAME_LENGTH})` }) - } - - const att = await db - .select() - .from(schema.attachments) - .where(and(eq(schema.attachments.id, attId), eq(schema.attachments.noteId, noteId))) - .get() - if (!att) { - throw createError({ statusCode: 404, statusMessage: 'Attachment not found' }) + const { filename } = await readBodyAs(event, attachmentRenameSchema) + const safeName = sanitizeFilename(filename) + if (safeName.length > CONSTRAINTS.FILENAME_MAX_LENGTH) { + throw createError({ statusCode: 400, message: `Filename Must Be ${CONSTRAINTS.FILENAME_MAX_LENGTH} Characters or Less` }) } - const updated = await db + const att = await requireAttachment(event) + const updated = await getDb() .update(schema.attachments) .set({ filename: safeName }) - .where(eq(schema.attachments.id, attId)) + .where(eq(schema.attachments.id, att.id)) .returning() .get() - void syncNoteToIndex(noteId) + void syncNoteToIndex(att.noteId) - realtimeBus.publish({ type: 'attachment.updated', data: { resource: 'notes', noteId, attachment: updated } }) - return { attachment: updated } + realtimeBus.publish({ type: 'attachment.updated', data: { resource: 'notes', noteId: att.noteId, attachment: updated } }) + return updated }) diff --git a/server/api/notes/[id]/attachments/reorder.put.ts b/server/api/notes/[id]/attachments/reorder.put.ts index 88f6185..6eee8f9 100644 --- a/server/api/notes/[id]/attachments/reorder.put.ts +++ b/server/api/notes/[id]/attachments/reorder.put.ts @@ -1,27 +1,24 @@ import { and, eq } from 'drizzle-orm' import { getDb, schema } from '../../../../utils/db' +import { attachmentReorderSchema } from '#shared/schemas/attachments' import { realtimeBus } from '../../../../utils/realtime' -import { requireRouterParam } from '../../../../utils/guards' +import { requireNote } from '../../../../utils/guards' +import { readBodyAs } from '../../../../utils/validation' export default defineEventHandler(async (event) => { const db = getDb() - const noteId = requireRouterParam(event, 'id') - const body = await readBody(event) - const ids: string[] = Array.isArray(body?.ids) && body.ids.every((id: unknown) => typeof id === 'string') ? body.ids : [] + const { id: noteId } = await requireNote(event) + const { ids } = await readBodyAs(event, attachmentReorderSchema) - if (ids.length === 0) { - throw createError({ statusCode: 400, statusMessage: 'Attachment order is required' }) - } - - const attachments = await db - .select({ id: schema.attachments.id }) - .from(schema.attachments) - .where(eq(schema.attachments.noteId, noteId)) - .all() - - const existingIds = new Set(attachments.map((a) => a.id)) + const existingIds = new Set( + db.select({ id: schema.attachments.id }) + .from(schema.attachments) + .where(eq(schema.attachments.noteId, noteId)) + .all() + .map((a) => a.id) + ) if (ids.length !== existingIds.size || ids.some((id) => !existingIds.has(id))) { - throw createError({ statusCode: 400, statusMessage: 'Invalid attachment order' }) + throw createError({ statusCode: 400, message: 'Invalid Attachment Order' }) } db.transaction((tx) => { @@ -34,5 +31,5 @@ export default defineEventHandler(async (event) => { }) realtimeBus.publish({ type: 'attachment.reordered', data: { resource: 'notes', noteId, attachmentIds: ids } }) - return { success: true } + return sendNoContent(event) }) diff --git a/server/api/notes/index.get.ts b/server/api/notes/index.get.ts index 281a65a..1dd8f60 100644 --- a/server/api/notes/index.get.ts +++ b/server/api/notes/index.get.ts @@ -1,23 +1,19 @@ import { getDb, schema } from '../../utils/db' import { eq, and, desc, inArray } from 'drizzle-orm' import { getNotesWithMeta } from '../../utils/notesSvc' +import { notesListQuerySchema } from '#shared/schemas/notes' import { CONSTRAINTS } from '#shared/utils/constants' +import { getQueryAs } from '../../utils/validation' export default defineEventHandler(async (event) => { setHeader(event, 'Cache-Control', 'no-store') const db = getDb() - const query = getQuery(event) - - const isArchived = query.archived === 'true' - const isTrashed = query.trashed === 'true' - const labelId = query.label as string | undefined + const { archived, trashed, labelId, page } = getQueryAs(event, notesListQuerySchema) const limit = CONSTRAINTS.PAGINATION_LIMIT - const page = Math.max(parseInt(query.page as string) || 1, 1) - const offset = (page - 1) * limit const conditions = [ - eq(schema.notes.isArchived, isArchived), - eq(schema.notes.isTrashed, isTrashed), + eq(schema.notes.isArchived, archived), + eq(schema.notes.isTrashed, trashed), ] if (labelId) { @@ -34,7 +30,7 @@ export default defineEventHandler(async (event) => { .where(and(...conditions)) .orderBy(desc(schema.notes.createdAt)) .limit(limit) - .offset(offset) + .offset((page - 1) * limit) .all() const notes = await getNotesWithMeta(rows.map((r) => r.id)) diff --git a/server/api/notes/index.post.ts b/server/api/notes/index.post.ts index c68e772..f87cf3f 100644 --- a/server/api/notes/index.post.ts +++ b/server/api/notes/index.post.ts @@ -2,18 +2,14 @@ import { getDb, schema } from '../../utils/db' import { nanoid } from 'nanoid' import { assertLabelsExist, linkLabelsToNote } from '../../utils/labelLinksSvc' import { getNoteWithMeta, syncNoteToIndex } from '../../utils/notesSvc' -import { parseNoteInput } from '../../utils/noteInput' +import { noteCreateSchema } from '#shared/schemas/notes' +import { readBodyAs } from '../../utils/validation' import { realtimeBus } from '../../utils/realtime' export default defineEventHandler(async (event) => { const db = getDb() - const input = parseNoteInput(await readBody(event)) - if (input.title === undefined) { - throw createError({ statusCode: 400, statusMessage: 'Title is required' }) - } - const type = input.type ?? 'text' - const checklistItems = input.checklistItems ?? [] - const labelIds = assertLabelsExist(db, input.labelIds ?? []) + const { type, title, content, checklistItems, labelIds: rawLabelIds } = await readBodyAs(event, noteCreateSchema) + const labelIds = assertLabelsExist(db, rawLabelIds) const id = nanoid() const now = new Date().toISOString() @@ -22,8 +18,8 @@ export default defineEventHandler(async (event) => { tx.insert(schema.notes).values({ id, type, - title: input.title!, - content: input.content ?? '', + title, + content, createdAt: now, updatedAt: now, }).run() @@ -47,5 +43,6 @@ export default defineEventHandler(async (event) => { void syncNoteToIndex(id) realtimeBus.publish({ type: 'note.created', data: { resource: 'notes', note } }) + setResponseStatus(event, 201) return note }) diff --git a/server/api/notes/search.get.ts b/server/api/notes/search.get.ts index 460adca..ee3046c 100644 --- a/server/api/notes/search.get.ts +++ b/server/api/notes/search.get.ts @@ -2,33 +2,26 @@ import { searchNotes } from '../../utils/searchSvc' import { getNotesWithMeta } from '../../utils/notesSvc' import { getDb, schema } from '../../utils/db' import { eq } from 'drizzle-orm' -import { CONSTRAINTS } from '#shared/utils/constants' +import { searchQuerySchema } from '#shared/schemas/notes' +import { getQueryAs } from '../../utils/validation' + +const CONTEXT_FILTERS = { + notes: 'isArchived = false AND isTrashed = false', + archive: 'isArchived = true AND isTrashed = false', + trash: 'isTrashed = true', +} as const export default defineEventHandler(async (event) => { - const query = getQuery(event) - const q = (query.q as string) || '' - const context = (query.context as string) || 'notes' // notes | archive | trash - const labelId = query.labelId as string | undefined - const limit = Math.min(Math.max(parseInt(query.limit as string) || CONSTRAINTS.PAGINATION_LIMIT, 1), 100) - const page = Math.max(parseInt(query.page as string) || 1, 1) + const { q, context, labelId, limit, page } = getQueryAs(event, searchQuerySchema) const offset = (page - 1) * limit - if (!q.trim()) return { notes: [] } + if (!q.trim()) return { notes: [], page, total: 0, hasMore: false } - // Build Meilisearch filter based on context - let filter: string - if (context === 'archive') { - filter = 'isArchived = true AND isTrashed = false' - } else if (context === 'trash') { - filter = 'isTrashed = true' - } else { - filter = 'isArchived = false AND isTrashed = false' - } + let filter: string = CONTEXT_FILTERS[context] // Add label filter — escape label name to prevent filter injection if (labelId) { - const db = getDb() - const label = await db.select().from(schema.labels).where(eq(schema.labels.id, labelId)).get() + const label = await getDb().select().from(schema.labels).where(eq(schema.labels.id, labelId)).get() if (label) { const escapedName = label.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"') filter += ` AND labels = "${escapedName}"` @@ -36,10 +29,7 @@ export default defineEventHandler(async (event) => { } const results = await searchNotes(q, { filter, limit, offset }) - const ids = results.hits.map((h) => h.id as string) - - const notes = await getNotesWithMeta(ids) + const notes = await getNotesWithMeta(results.hits.map((h) => h.id as string)) const total = results.estimatedTotalHits || 0 - const hasMore = offset + limit < total - return { notes, query: q, page, total, hasMore } + return { notes, page, total, hasMore: offset + limit < total } }) diff --git a/server/api/notes/trash.delete.ts b/server/api/notes/trash.delete.ts index 7d0cf32..f10993c 100644 --- a/server/api/notes/trash.delete.ts +++ b/server/api/notes/trash.delete.ts @@ -1,11 +1,12 @@ import { getDb, schema } from '../../utils/db' import { eq } from 'drizzle-orm' import { removeNotesFromIndex } from '../../utils/searchSvc' +import { logger } from '../../utils/logger' import { deleteNoteDir } from '../../utils/filesSvc' import { realtimeBus } from '../../utils/realtime' /** Permanently delete every trashed note, not just the ones loaded in the client. */ -export default defineEventHandler(async () => { +export default defineEventHandler(async (event) => { const db = getDb() const noteIds = db.transaction((tx) => { const ids = tx @@ -20,9 +21,9 @@ export default defineEventHandler(async () => { for (const id of noteIds) deleteNoteDir(id) if (noteIds.length > 0) { - removeNotesFromIndex(noteIds).catch((e) => console.warn('[search] Failed to remove notes from index:', e?.message)) + removeNotesFromIndex(noteIds).catch((e) => logger.search.warn('Failed to remove notes from index:', e?.message)) realtimeBus.publish({ type: 'note.trash-emptied', data: { resource: 'notes', noteIds } }) } - return { deleted: noteIds.length } + return sendNoContent(event) }) diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 5de69b3..ab20e85 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -16,12 +16,12 @@ export default defineEventHandler(async (event) => { const cookie = getCookie(event, AUTH_COOKIE) if (!cookie) { - throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) + throw createError({ statusCode: 401, message: 'Unauthorized' }) } const payload = await verifyToken(cookie) if (!payload) { - throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) + throw createError({ statusCode: 401, message: 'Unauthorized' }) } // Sliding session: re-issue the token once a day instead of on every request diff --git a/server/utils/guards.ts b/server/utils/guards.ts index 1cdb652..6c0a1cc 100644 --- a/server/utils/guards.ts +++ b/server/utils/guards.ts @@ -1,25 +1,29 @@ import type { H3Event } from 'h3' import { eq } from 'drizzle-orm' +import { attachmentParamsSchema, idParamsSchema } from '#shared/schemas/common' import { getDb, schema } from './db' - -export function requireRouterParam(event: H3Event, name: string): string { - const value = getRouterParam(event, name) - if (!value) throw createError({ statusCode: 400, statusMessage: `Missing route parameter: ${name}` }) - return value -} +import { getParamsAs } from './validation' /** Load the note identified by the `id` route param, or throw 404. */ export async function requireNote(event: H3Event) { - const id = requireRouterParam(event, 'id') + const { id } = getParamsAs(event, idParamsSchema) const note = await getDb().select().from(schema.notes).where(eq(schema.notes.id, id)).get() - if (!note) throw createError({ statusCode: 404, statusMessage: 'Note not found' }) + if (!note) throw createError({ statusCode: 404, message: 'Note Not Found' }) return note } /** Load the label identified by the `id` route param, or throw 404. */ export async function requireLabel(event: H3Event) { - const id = requireRouterParam(event, 'id') + const { id } = getParamsAs(event, idParamsSchema) const label = await getDb().select().from(schema.labels).where(eq(schema.labels.id, id)).get() - if (!label) throw createError({ statusCode: 404, statusMessage: 'Label not found' }) + if (!label) throw createError({ statusCode: 404, message: 'Label Not Found' }) return label } + +/** Load the attachment identified by the `id` and `attId` route params, or throw 404. */ +export async function requireAttachment(event: H3Event) { + const { id, attId } = getParamsAs(event, attachmentParamsSchema) + const att = await getDb().select().from(schema.attachments).where(eq(schema.attachments.id, attId)).get() + if (!att || att.noteId !== id) throw createError({ statusCode: 404, message: 'Attachment Not Found' }) + return att +} diff --git a/server/utils/labelLinksSvc.ts b/server/utils/labelLinksSvc.ts index f66e04d..1a68362 100644 --- a/server/utils/labelLinksSvc.ts +++ b/server/utils/labelLinksSvc.ts @@ -11,7 +11,7 @@ export function assertLabelsExist(db: DbExecutor, labelIds: string[]): string[] .where(inArray(schema.labels.id, unique)) .all() if (found.length !== unique.length) { - throw createError({ statusCode: 400, statusMessage: 'Unknown label' }) + throw createError({ statusCode: 400, message: 'Unknown Label' }) } return unique } From ad3c2453864891945e14661b5969825ff0d9a6bd Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 10:25:34 +0700 Subject: [PATCH 4/7] Handle API Errors on Client --- app/components/attachment/FileList.vue | 13 +- app/components/attachment/ImageGrid.vue | 2 +- app/components/label/Manager.vue | 3 +- app/components/label/Picker.vue | 1 + app/components/note/EditorContent.vue | 2 +- app/composables/useLabels.ts | 52 +++++--- app/composables/useLightbox.ts | 8 +- app/composables/useNotes.ts | 156 ++++++++++++------------ app/composables/useSearch.ts | 2 +- app/pages/archive.vue | 3 +- app/pages/index.vue | 3 +- app/pages/label/[id].vue | 5 +- app/pages/trash.vue | 3 +- app/plugins/api.ts | 20 +++ app/utils/errors.ts | 23 +++- 15 files changed, 170 insertions(+), 126 deletions(-) create mode 100644 app/plugins/api.ts diff --git a/app/components/attachment/FileList.vue b/app/components/attachment/FileList.vue index 3aad163..f2ab011 100644 --- a/app/components/attachment/FileList.vue +++ b/app/components/attachment/FileList.vue @@ -129,12 +129,8 @@ const { dragOverIdx, onDragStart, onDragOver, onDrop, onDragEnd, startTouchDrag const nextItems = items.map((item, position) => ({ ...item, position })) localAttachments.value = nextItems if (!props.noteId) return - try { - await reorderAttachments(props.noteId, nextItems.map((att) => att.id)) - } catch (e) { - showSnackbar(getErrorMessage(e, 'Reorder Failed'), 'error') - localAttachments.value = [...props.attachments] - } + const ok = await reorderAttachments(props.noteId, nextItems.map((att) => att.id)) + if (!ok) localAttachments.value = [...props.attachments] }, }) @@ -173,12 +169,9 @@ const saveRename = async (att: Attachment) => { showSnackbar('Filename is Required', 'error') return } - try { - await renameAttachment(props.noteId, att.id, nextName) + if (await renameAttachment(props.noteId, att.id, nextName)) { showSnackbar('Attachment Renamed') cancelRename() - } catch (e) { - showSnackbar(getErrorMessage(e, 'Rename Failed'), 'error') } } diff --git a/app/components/attachment/ImageGrid.vue b/app/components/attachment/ImageGrid.vue index b52b796..b3aed65 100644 --- a/app/components/attachment/ImageGrid.vue +++ b/app/components/attachment/ImageGrid.vue @@ -76,7 +76,7 @@ const fallbackToOriginal = (e: Event, attId: string) => { const props = defineProps<{ attachments: Attachment[] deletable?: boolean - onDelete?: (id: string) => Promise + onDelete?: (id: string) => Promise maxVisible?: number }>() const emit = defineEmits<{ delete: [id: string] }>() diff --git a/app/components/label/Manager.vue b/app/components/label/Manager.vue index 011487a..04a67a6 100644 --- a/app/components/label/Manager.vue +++ b/app/components/label/Manager.vue @@ -138,8 +138,7 @@ const editName = ref('') const addLabel = async () => { const name = newName.value.trim() if (!name || name.length > MAX_LABEL_LENGTH) return - await createLabel(name) - newName.value = '' + if (await createLabel(name)) newName.value = '' } const startEdit = (label: Label) => { diff --git a/app/components/label/Picker.vue b/app/components/label/Picker.vue index 14b5e9e..8a37962 100644 --- a/app/components/label/Picker.vue +++ b/app/components/label/Picker.vue @@ -82,6 +82,7 @@ const toggle = (id: string) => { const addLabel = async () => { if (!newName.value.trim()) return const created = await createLabel(newName.value.trim()) + if (!created) return newName.value = '' // Auto-select the newly created label localSelected.value = [...localSelected.value, created.id] diff --git a/app/components/note/EditorContent.vue b/app/components/note/EditorContent.vue index 6bbf550..589b08b 100644 --- a/app/components/note/EditorContent.vue +++ b/app/components/note/EditorContent.vue @@ -220,7 +220,7 @@ const toggleType = () => { localType.value = localType.value === 'text' ? 'check const onLabel = (ids: string[]) => { localLabelIds.value = ids; emitUpdate() } const deleteAtt = async (attId: string) => { - if (props.note) await deleteAttachment(props.note.id, attId) + if (props.note) return deleteAttachment(props.note.id, attId) } defineExpose({ showTitleError: () => { titleError.value = true } }) diff --git a/app/composables/useLabels.ts b/app/composables/useLabels.ts index febb646..da28d6c 100644 --- a/app/composables/useLabels.ts +++ b/app/composables/useLabels.ts @@ -8,16 +8,14 @@ export const useLabels = () => { // transferred to the client, preventing hydration mismatches. const labels = useState('labels', () => []) - // During SSR, forwards the original request's cookies so auth works on hard refresh. - const apiFetch = useRequestFetch() + const apiFetch = useNuxtApp().$api const { subscribe } = useEventSource() const realtimeBound = useState('labels-realtime-bound', () => false) const { consume: consumeLocalLabelEvent, run: runLocalLabelMutation } = useLocalRealtimeEvents('local-label-events') const fetchLabels = async () => { - const data = await apiFetch<{ labels: Label[] }>('/api/labels') - labels.value = data.labels + labels.value = await apiFetch('/api/labels') } if (import.meta.client && !realtimeBound.value) { @@ -31,23 +29,30 @@ export const useLabels = () => { event.type !== 'reconnected' && (consumeLocalLabelEvent(eventKey) || (labelId ? consumeLocalLabelEvent(event.type) : false)) ) return - fetchLabels().catch(() => {}) + fetchLabels().catch((error) => console.warn('[realtime] Failed to refresh labels:', error)) } }) } + /** Resolves to the new label, or undefined on failure (an error snackbar is shown). */ const createLabel = async (name: string) => { - const label = await runLocalLabelMutation('label.created', () => - apiFetch