diff --git a/Dockerfile b/Dockerfile index e26595d..dbdff02 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,5 @@ # Build stage FROM node:22-alpine AS build -ARG PASSWORD -ENV PASSWORD=${PASSWORD} ENV NODE_OPTIONS="--max-old-space-size=4096" WORKDIR /src COPY package*.json ./ diff --git a/nuxt.config.ts b/nuxt.config.ts index 9fde213..8636775 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,7 +1,7 @@ // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ runtimeConfig: { - adminPassword: process.env.PASSWORD + adminPassword: process.env.PASSWORD || '' }, modules: ['@nuxtjs/tailwindcss', '@nuxt/image', '@nuxt/icon', '@pinia/nuxt', 'nuxt-headlessui', 'nuxt-swiper', 'nuxt-umami'], umami: { @@ -32,7 +32,7 @@ export default defineNuxtConfig({ // Exceptions: admin needs SSR (dynamic), API needs CORS '/admin': { ssr: true }, - '/api/**': { cors: true }, + '/api/**': { cors: { origin: ['https://unboundedpress.org', 'https://localdev.unboundedpress.org', 'http://localhost:3000'] } }, }, nitro: { prerender: { crawlLinks: true} diff --git a/package-lock.json b/package-lock.json index 37a9d7d..bc2d365 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,12 @@ "@formkit/themes": "^1.7.2", "@formkit/vue": "^1.7.2", "@pinia/nuxt": "^0.11.3", - "mongodb": "^7.1.0", + "dompurify": "^3.4.2", "nuxt": "^4.3.1", "nuxt-swiper": "^1.2.2", "nuxt-umami": "^3.2.1", "pinia": "^3.0.4", + "sanitize-html": "^2.17.3", "sharp": "^0.34.5" }, "devDependencies": { @@ -28,7 +29,9 @@ "@nuxt/icon": "^2.2.1", "@nuxt/image": "^2.0.0", "@nuxtjs/tailwindcss": "^6.14.0", + "@types/dompurify": "^3.0.5", "@types/node": "^25.2.3", + "@types/sanitize-html": "^2.16.1", "nuxt-headlessui": "^1.2.2", "nuxt-icon": "^1.0.0-beta.7" } @@ -1983,15 +1986,6 @@ "node": ">=18" } }, - "node_modules/@mongodb-js/saslprep": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", - "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", - "license": "MIT", - "dependencies": { - "sparse-bitfield": "^3.0.3" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -4478,6 +4472,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4500,21 +4504,23 @@ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "license": "MIT" }, - "node_modules/@types/webidl-conversions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", - "license": "MIT" - }, - "node_modules/@types/whatwg-url": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", - "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "node_modules/@types/sanitize-html": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz", + "integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==", + "dev": true, "license": "MIT", "dependencies": { - "@types/webidl-conversions": "*" + "htmlparser2": "^10.1" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@unhead/vue": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.1.4.tgz", @@ -5457,15 +5463,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bson": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", - "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -6430,6 +6427,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", + "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -7191,6 +7197,25 @@ "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", "license": "MIT" }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, "node_modules/http-assert": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", @@ -7618,6 +7643,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-reference": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", @@ -8275,12 +8309,6 @@ "node": ">= 0.6" } }, - "node_modules/memory-pager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "license": "MIT" - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -8460,65 +8488,6 @@ "integrity": "sha512-aF7yRQr/Q0O2/4pIXm6PZ5G+jAd7QS4Yu8m+WEeEHGnbo+7mE36CbLSDQiXYV8bVL3NfmdeqPJct0tUlnjVSnA==", "license": "MIT" }, - "node_modules/mongodb": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz", - "integrity": "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==", - "license": "Apache-2.0", - "dependencies": { - "@mongodb-js/saslprep": "^1.3.0", - "bson": "^7.1.1", - "mongodb-connection-string-url": "^7.0.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.806.0", - "@mongodb-js/zstd": "^7.0.0", - "gcp-metadata": "^7.0.1", - "kerberos": "^7.0.0", - "mongodb-client-encryption": ">=7.0.0 <7.1.0", - "snappy": "^7.3.2", - "socks": "^2.8.6" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, - "node_modules/mongodb-connection-string-url": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", - "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", - "license": "Apache-2.0", - "dependencies": { - "@types/whatwg-url": "^13.0.0", - "whatwg-url": "^14.1.0" - }, - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -9537,6 +9506,12 @@ "dev": true, "license": "MIT" }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -10376,15 +10351,6 @@ "node": ">= 6" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -10914,6 +10880,32 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sanitize-html": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz", + "integrity": "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^10.1.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, + "node_modules/sanitize-html/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/sax": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", @@ -11201,15 +11193,6 @@ "node": ">=0.10.0" } }, - "node_modules/sparse-bitfield": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", - "license": "MIT", - "dependencies": { - "memory-pager": "^1.0.2" - } - }, "node_modules/speakingurl": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", @@ -11900,18 +11883,6 @@ "node": ">=6" } }, - "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -12905,34 +12876,12 @@ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "license": "MIT" }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index b84f128..51a293f 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "@nuxt/icon": "^2.2.1", "@nuxt/image": "^2.0.0", "@nuxtjs/tailwindcss": "^6.14.0", + "@types/dompurify": "^3.0.5", "@types/node": "^25.2.3", + "@types/sanitize-html": "^2.16.1", "nuxt-headlessui": "^1.2.2", "nuxt-icon": "^1.0.0-beta.7" }, @@ -27,10 +29,12 @@ "@formkit/themes": "^1.7.2", "@formkit/vue": "^1.7.2", "@pinia/nuxt": "^0.11.3", + "dompurify": "^3.4.2", "nuxt": "^4.3.1", "nuxt-swiper": "^1.2.2", "nuxt-umami": "^3.2.1", "pinia": "^3.0.4", + "sanitize-html": "^2.17.3", "sharp": "^0.34.5" } } diff --git a/pages/a_history_of_the_domino_problem.vue b/pages/a_history_of_the_domino_problem.vue index 9226732..309f015 100644 --- a/pages/a_history_of_the_domino_problem.vue +++ b/pages/a_history_of_the_domino_problem.vue @@ -164,7 +164,7 @@
- MAREIKE YIN-YEE LEE - visual artist + MAREIKE YIN-YEE LEE - visual artist
diff --git a/pages/admin.vue b/pages/admin.vue index 1f09317..2139747 100644 --- a/pages/admin.vue +++ b/pages/admin.vue @@ -153,6 +153,7 @@ import { collections } from '@/admin/schemas' const password = ref('') const authenticated = ref(false) +const authToken = ref('') const selectedView = ref('collections') const selectedCollection = ref('works') const selectedFolder = ref('scores') @@ -164,6 +165,10 @@ const isUploading = ref(false) const searchQuery = ref('') const fileSearchQuery = ref('') +function authHeaders() { + return { 'x-auth-token': authToken.value } +} + const searchFields = { works: ['title', 'type', 'instrument_tags'], publications: ['entryTags.title', 'entryTags.year', 'citationKey'], @@ -242,6 +247,7 @@ async function checkPassword(data) { }) if (result.valid) { authenticated.value = true + authToken.value = result.token loadItems() } else { alert('Incorrect password') @@ -254,6 +260,7 @@ async function checkPassword(data) { function logout() { authenticated.value = false + authToken.value = '' password.value = '' } @@ -272,13 +279,25 @@ function getTitle(item) { } async function loadItems() { - const data = await $fetch(`/api/admin/${selectedCollection.value}`) - items.value = data || [] + try { + const data = await $fetch(`/api/admin/${selectedCollection.value}`, { + headers: authHeaders() + }) + items.value = data || [] + } catch (e) { + if (e.response?.status === 401) logout() + } } async function loadFiles() { - const data = await $fetch(`/api/admin/files?folder=${selectedFolder.value}`) - files.value = data || [] + try { + const data = await $fetch(`/api/admin/files?folder=${selectedFolder.value}`, { + headers: authHeaders() + }) + files.value = data || [] + } catch (e) { + if (e.response?.status === 401) logout() + } } function createNew() { @@ -297,7 +316,8 @@ async function saveRawJson() { const method = parsed.id ? 'PUT' : 'POST' await useFetch(`/api/admin/${selectedCollection.value}`, { method, - body: parsed + body: parsed, + headers: authHeaders() }) rawJsonItem.value = null loadItems() @@ -309,7 +329,8 @@ async function saveRawJson() { async function deleteItem(item) { if (confirm('Are you sure you want to delete this item?')) { await useFetch(`/api/admin/${selectedCollection.value}/${item.id}`, { - method: 'DELETE' + method: 'DELETE', + headers: authHeaders() }) loadItems() } @@ -326,7 +347,8 @@ async function handleFileUpload(event) { try { await $fetch(`/api/admin/files/upload?folder=${selectedFolder.value}`, { method: 'POST', - body: formData + body: formData, + headers: authHeaders() }) loadFiles() } catch (e) { @@ -339,10 +361,15 @@ async function handleFileUpload(event) { async function deleteFile(file) { if (confirm(`Delete ${file.name}?`)) { - await $fetch(`/api/admin/files?folder=${selectedFolder.value}&file=${file.name}`, { - method: 'DELETE' - }) - loadFiles() + try { + await $fetch(`/api/admin/files?folder=${selectedFolder.value}&file=${file.name}`, { + method: 'DELETE', + headers: authHeaders() + }) + loadFiles() + } catch (e) { + if (e.response?.status === 401) logout() + } } } diff --git a/server/api/admin/[collection].js b/server/api/admin/[collection].js index d18a9a7..4820bd6 100644 --- a/server/api/admin/[collection].js +++ b/server/api/admin/[collection].js @@ -20,6 +20,8 @@ function generateId() { } export default defineEventHandler(async (event) => { + requireAuth(event) + const method = event.method const collection = event.context.params?.collection const id = event.context.params?.id @@ -51,7 +53,7 @@ export default defineEventHandler(async (event) => { } if (method === 'POST') { - const body = await readBody(event) + const body = sanitizeValue(await readBody(event)) const data = readData() body.id = generateId() data.push(body) @@ -60,7 +62,7 @@ export default defineEventHandler(async (event) => { } if (method === 'PUT') { - const body = await readBody(event) + const body = sanitizeValue(await readBody(event)) const data = readData() const index = data.findIndex(item => item.id === body.id) if (index !== -1) { diff --git a/server/api/admin/[collection]/[id].js b/server/api/admin/[collection]/[id].js index d8a34dd..e88c351 100644 --- a/server/api/admin/[collection]/[id].js +++ b/server/api/admin/[collection]/[id].js @@ -15,6 +15,8 @@ function getFilePath(collection) { } export default defineEventHandler(async (event) => { + requireAuth(event) + const method = event.method const collection = event.context.params?.collection const id = event.context.params?.id diff --git a/server/api/admin/files.js b/server/api/admin/files.js index 0bcb1fc..f1a43fa 100644 --- a/server/api/admin/files.js +++ b/server/api/admin/files.js @@ -1,5 +1,5 @@ -import { readdirSync, statSync, unlinkSync, writeFileSync, existsSync } from 'node:fs' -import { join, extname } from 'node:path' +import { readdirSync, statSync, unlinkSync, existsSync } from 'node:fs' +import { join, resolve } from 'node:path' const publicDir = './public' @@ -9,7 +9,19 @@ function getFolderPath(folder) { return join(publicDir, folder) } +function sanitizeFilename(filename) { + return filename.replace(/[^a-zA-Z0-9._-]/g, '_') +} + +function isPathInside(base, target) { + const resolvedBase = resolve(base) + const resolvedTarget = resolve(target) + return resolvedTarget.startsWith(resolvedBase) +} + export default defineEventHandler(async (event) => { + requireAuth(event) + const method = event.method const query = getQuery(event) const folder = query.folder @@ -36,11 +48,15 @@ export default defineEventHandler(async (event) => { } if (method === 'DELETE') { - const filename = query.file - if (!filename) { + const rawFilename = query.file + if (!rawFilename) { throw createError({ statusCode: 400, message: 'Filename required' }) } - const filePath = join(folderPath, filename) + const filename = sanitizeFilename(rawFilename) + const filePath = resolve(join(folderPath, filename)) + if (!isPathInside(resolve(folderPath), filePath)) { + throw createError({ statusCode: 403, message: 'Forbidden' }) + } if (existsSync(filePath)) { unlinkSync(filePath) return { success: true } diff --git a/server/api/admin/files/upload.post.js b/server/api/admin/files/upload.post.js index 11ed733..b7fef7a 100644 --- a/server/api/admin/files/upload.post.js +++ b/server/api/admin/files/upload.post.js @@ -1,11 +1,25 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { join, extname } from 'node:path' import { readMultipartFormData } from 'h3' const publicDir = './public' const allowedFolders = ['scores', 'pubs', 'album_art', 'images', 'hdp_images'] const allowedExtensions = ['.pdf', '.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.mp3', '.wav', '.ogg'] +const MAX_FILE_SIZE = 50 * 1024 * 1024 + +const mimeMap = { + '.pdf': 'application/pdf', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.ogg': 'audio/ogg', +} function getFolderPath(folder) { return join(publicDir, folder) @@ -16,6 +30,8 @@ function sanitizeFilename(filename) { } export default defineEventHandler(async (event) => { + requireAuth(event) + const query = getQuery(event) const folder = query.folder @@ -41,12 +57,20 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, message: 'No file data' }) } - const ext = fileField.filename ? fileField.filename.toLowerCase().slice(fileField.filename.lastIndexOf('.')) : '' + if (fileField.data.length > MAX_FILE_SIZE) { + throw createError({ statusCode: 400, message: 'File too large. Maximum size is 50MB' }) + } + + const ext = fileField.filename ? extname(fileField.filename).toLowerCase() : '' if (!allowedExtensions.includes(ext)) { throw createError({ statusCode: 400, message: `File type not allowed. Allowed: ${allowedExtensions.join(', ')}` }) } + if (fileField.type && mimeMap[ext] && fileField.type !== mimeMap[ext]) { + throw createError({ statusCode: 400, message: `File content type mismatch for ${ext} files` }) + } + const filename = fileField.filename ? sanitizeFilename(fileField.filename) : `upload${ext}` const filePath = join(folderPath, filename) diff --git a/server/api/auth/verify-password.post.ts b/server/api/auth/verify-password.post.ts index 8c7ebcf..a796682 100644 --- a/server/api/auth/verify-password.post.ts +++ b/server/api/auth/verify-password.post.ts @@ -1,9 +1,12 @@ export default defineEventHandler(async (event) => { + checkRateLimit(event) + const config = useRuntimeConfig() const body = await readBody(event) if (body.password === config.adminPassword) { - return { valid: true } + const token = createToken() + return { valid: true, token } } return { valid: false } diff --git a/server/routes/scores/[...path].ts b/server/routes/scores/[...path].ts index ef20578..51cb7bd 100644 --- a/server/routes/scores/[...path].ts +++ b/server/routes/scores/[...path].ts @@ -1,25 +1,23 @@ import { existsSync, createReadStream } from 'fs' -import { join } from 'path' -import { sendStream } from 'h3' -import { createError } from 'h3' +import { join, resolve } from 'path' +import { sendStream, createError } from 'h3' export default defineEventHandler(async (event) => { const url = event.path - - // Get the filename from the URL + const filename = url.replace('/scores/', '') - - // Check if file exists in public/scores/ - const filePath = join(process.cwd(), 'public/scores', filename) - - if (!existsSync(filePath)) { - throw createError({ - statusCode: 404, - statusMessage: 'Not Found' - }) + + const requestedPath = resolve(join(process.cwd(), 'public/scores', filename)) + const allowedBase = resolve(process.cwd(), 'public/scores') + + if (!requestedPath.startsWith(allowedBase)) { + throw createError({ statusCode: 403, statusMessage: 'Forbidden' }) } - - // Serve the file + + if (!existsSync(requestedPath)) { + throw createError({ statusCode: 404, statusMessage: 'Not Found' }) + } + event.node.res.statusCode = 200 - return sendStream(event, createReadStream(filePath)) + return sendStream(event, createReadStream(requestedPath)) }) diff --git a/server/utils/auth.ts b/server/utils/auth.ts new file mode 100644 index 0000000..f1fa661 --- /dev/null +++ b/server/utils/auth.ts @@ -0,0 +1,34 @@ +import { nanoid } from 'nanoid' +import { getHeader, createError } from 'h3' + +const tokens = new Map() + +const TOKEN_TTL = 24 * 60 * 60 * 1000 + +export function createToken(): string { + const token = nanoid(32) + tokens.set(token, Date.now() + TOKEN_TTL) + return token +} + +export function validateToken(token: string | undefined): boolean { + if (!token) return false + const expiry = tokens.get(token) + if (!expiry) return false + if (Date.now() > expiry) { + tokens.delete(token) + return false + } + return true +} + +export function removeToken(token: string): void { + tokens.delete(token) +} + +export function requireAuth(event: any): void { + const token = getHeader(event, 'x-auth-token') + if (!validateToken(token)) { + throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) + } +} diff --git a/server/utils/rateLimit.ts b/server/utils/rateLimit.ts new file mode 100644 index 0000000..94888c7 --- /dev/null +++ b/server/utils/rateLimit.ts @@ -0,0 +1,25 @@ +import { getRequestIP, createError } from 'h3' + +const attempts = new Map() + +const WINDOW_MS = 15 * 60 * 1000 +const MAX_ATTEMPTS = 10 + +export function checkRateLimit(event: any): void { + const ip = getRequestIP(event) || 'unknown' + const now = Date.now() + const record = attempts.get(ip) + + if (record && now < record.resetAt) { + if (record.count >= MAX_ATTEMPTS) { + throw createError({ + statusCode: 429, + statusMessage: 'Too Many Requests', + message: 'Too many attempts. Please try again later.', + }) + } + record.count++ + } else { + attempts.set(ip, { count: 1, resetAt: now + WINDOW_MS }) + } +} diff --git a/server/utils/sanitize.ts b/server/utils/sanitize.ts new file mode 100644 index 0000000..b9cc692 --- /dev/null +++ b/server/utils/sanitize.ts @@ -0,0 +1,23 @@ +import sanitizeHtml from 'sanitize-html' + +const allowedTags = ['em', 'span'] +const allowedAttributes = { + span: ['style'], +} + +export function sanitizeValue(value: unknown): unknown { + if (typeof value === 'string') { + return sanitizeHtml(value, { allowedTags, allowedAttributes }) + } + if (Array.isArray(value)) { + return value.map(sanitizeValue) + } + if (value && typeof value === 'object') { + const result: Record = {} + for (const [key, val] of Object.entries(value)) { + result[key] = sanitizeValue(val) + } + return result + } + return value +}