security audit: server-side auth, path traversal fixes, rate limiting, upload safeguards
This commit is contained in:
parent
15012d8beb
commit
bd250eb210
|
|
@ -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 ./
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
243
package-lock.json
generated
243
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="mb-5 py-10">
|
||||
<NuxtLink class="text-3xl font-bold" to='http://www.mareikelee.com/'>MAREIKE YIN-YEE LEE - visual artist</NuxtLink>
|
||||
<NuxtLink class="text-3xl font-bold" to='https://www.mareikelee.com/'>MAREIKE YIN-YEE LEE - visual artist</NuxtLink>
|
||||
<div class="grid grid-cols-[20%,70%] p-5">
|
||||
<nuxt-img src="/hdp_images/mareike.jpg"/>
|
||||
<div class="px-5">
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
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}`)
|
||||
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}?`)) {
|
||||
try {
|
||||
await $fetch(`/api/admin/files?folder=${selectedFolder.value}&file=${file.name}`, {
|
||||
method: 'DELETE'
|
||||
method: 'DELETE',
|
||||
headers: authHeaders()
|
||||
})
|
||||
loadFiles()
|
||||
} catch (e) {
|
||||
if (e.response?.status === 401) logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
const requestedPath = resolve(join(process.cwd(), 'public/scores', filename))
|
||||
const allowedBase = resolve(process.cwd(), 'public/scores')
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Not Found'
|
||||
})
|
||||
if (!requestedPath.startsWith(allowedBase)) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
|
||||
}
|
||||
|
||||
if (!existsSync(requestedPath)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
|
||||
}
|
||||
|
||||
// Serve the file
|
||||
event.node.res.statusCode = 200
|
||||
return sendStream(event, createReadStream(filePath))
|
||||
return sendStream(event, createReadStream(requestedPath))
|
||||
})
|
||||
|
|
|
|||
34
server/utils/auth.ts
Normal file
34
server/utils/auth.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { nanoid } from 'nanoid'
|
||||
import { getHeader, createError } from 'h3'
|
||||
|
||||
const tokens = new Map<string, number>()
|
||||
|
||||
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' })
|
||||
}
|
||||
}
|
||||
25
server/utils/rateLimit.ts
Normal file
25
server/utils/rateLimit.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { getRequestIP, createError } from 'h3'
|
||||
|
||||
const attempts = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
23
server/utils/sanitize.ts
Normal file
23
server/utils/sanitize.ts
Normal file
|
|
@ -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<string, unknown> = {}
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
result[key] = sanitizeValue(val)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return value
|
||||
}
|
||||
Loading…
Reference in a new issue