diff --git a/.gitignore b/.gitignore index d70bb9c..1e5247d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules dist .DS_Store *.local +data +server/data diff --git a/index.html b/index.html index 8c5f79c..5a69397 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,10 @@ - L484 Vue Tailwind + + + + L484
diff --git a/package-lock.json b/package-lock.json index 748beda..b34b45a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "dependencies": { "@vitejs/plugin-vue": "^5.2.1", - "nostr-tools": "^2.23.3", + "applesauce-signers": "^5.1.0", + "nostr-tools": "^2.10.4", "vite": "^6.0.5", "vue": "^3.5.13" }, @@ -533,44 +534,62 @@ } }, "node_modules/@noble/ciphers": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", - "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", + "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@noble/curves": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", - "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", "license": "MIT", "dependencies": { - "@noble/hashes": "2.0.1" + "@noble/hashes": "1.3.2" }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", "engines": { - "node": ">= 20.19.0" + "node": ">= 16" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", "license": "MIT", "engines": { - "node": ">= 20.19.0" + "node": ">= 16" }, "funding": { "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/secp256k1": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.2.tgz", + "integrity": "sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -935,36 +954,51 @@ ] }, "node_modules/@scure/base": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", - "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + }, + "node_modules/@scure/bip32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz", + "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", "license": "MIT", + "dependencies": { + "@noble/curves": "~1.1.0", + "@noble/hashes": "~1.3.1", + "@scure/base": "~1.1.0" + }, "funding": { "url": "https://paulmillr.com/funding/" } }, - "node_modules/@scure/bip32": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", - "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz", + "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", "license": "MIT", "dependencies": { - "@noble/curves": "2.0.1", - "@noble/hashes": "2.0.1", - "@scure/base": "2.0.0" + "@noble/hashes": "1.3.1" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@scure/bip39": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", - "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz", + "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", "license": "MIT", "dependencies": { - "@noble/hashes": "2.0.1", - "@scure/base": "2.0.0" + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -1110,6 +1144,100 @@ "node": ">= 8" } }, + "node_modules/applesauce-core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/applesauce-core/-/applesauce-core-5.2.0.tgz", + "integrity": "sha512-aSuM6q6/Gs2FGUqytlHDjKZpSst2xKaT0vMXUQFWUctECNIxvwy6/hTDDInukMuI9mrQdjnO781ZJJgghI7RNw==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fast-deep-equal": "^3.1.3", + "hash-sum": "^2.0.0", + "nanoid": "^5.0.9", + "nostr-tools": "~2.19", + "rxjs": "^7.8.1" + }, + "funding": { + "type": "lightning", + "url": "lightning:nostrudel@geyser.fund" + } + }, + "node_modules/applesauce-core/node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/applesauce-core/node_modules/nostr-tools": { + "version": "2.19.4", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.19.4.tgz", + "integrity": "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "^0.5.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.1", + "@scure/base": "1.1.1", + "@scure/bip32": "1.3.1", + "@scure/bip39": "1.2.1", + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/applesauce-signers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/applesauce-signers/-/applesauce-signers-5.1.0.tgz", + "integrity": "sha512-sdQe6J1txYV1GVX8/zSGZDyyXuuZomePHSfUDozZmNAnXhCXE0wqVfhLK0yegVMnomSgoeDUCsGmJiTE2BHqoQ==", + "license": "MIT", + "dependencies": { + "@noble/secp256k1": "^1.7.1", + "applesauce-core": "^5.1.0", + "debug": "^4.4.0", + "nanoid": "^5.0.9", + "rxjs": "^7.8.2" + }, + "funding": { + "type": "lightning", + "url": "lightning:nostrudel@geyser.fund" + } + }, + "node_modules/applesauce-signers/node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -1326,6 +1454,23 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1426,6 +1571,12 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1530,6 +1681,12 @@ "node": ">=10.13.0" } }, + "node_modules/hash-sum": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-2.0.0.tgz", + "integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==", + "license": "MIT" + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -1669,6 +1826,12 @@ "node": ">=8.6" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -1717,17 +1880,19 @@ } }, "node_modules/nostr-tools": { - "version": "2.23.3", - "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.3.tgz", - "integrity": "sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==", + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.10.4.tgz", + "integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==", "license": "Unlicense", "dependencies": { - "@noble/ciphers": "2.1.1", - "@noble/curves": "2.0.1", - "@noble/hashes": "2.0.1", - "@scure/base": "2.0.0", - "@scure/bip32": "2.0.1", - "@scure/bip39": "2.0.1", + "@noble/ciphers": "^0.5.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.1", + "@scure/base": "1.1.1", + "@scure/bip32": "1.3.1", + "@scure/bip39": "1.2.1" + }, + "optionalDependencies": { "nostr-wasm": "0.1.0" }, "peerDependencies": { @@ -2119,6 +2284,15 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2291,6 +2465,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/package.json b/package.json index 89c693a..47c36ca 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,17 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", + "dev": "node server/dev.js", + "dev:vite": "vite --host", + "dev:server": "node server/server.js", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "start": "node server/server.js" }, "dependencies": { "@vitejs/plugin-vue": "^5.2.1", - "nostr-tools": "^2.23.3", + "applesauce-signers": "^5.1.0", + "nostr-tools": "^2.10.4", "vite": "^6.0.5", "vue": "^3.5.13" }, diff --git a/public/images/header-logo.svg b/public/images/header-logo.svg index 5d394a2..93c1880 100644 --- a/public/images/header-logo.svg +++ b/public/images/header-logo.svg @@ -1,8 +1,8 @@ - - - - - - + + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..57fda87 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "L484 Membership", + "short_name": "L484", + "description": "Private L484 membership card and agreement portal.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#000000", + "theme_color": "#000000", + "icons": [ + { + "src": "/images/small-logo.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..6f0192e --- /dev/null +++ b/public/sw.js @@ -0,0 +1,39 @@ +const CACHE_NAME = 'l484-pwa-v1' +const APP_SHELL = [ + '/', + '/manifest.webmanifest', + '/images/small-logo.svg', + '/images/header-logo.svg', + '/images/pattern.jpg', +] + +self.addEventListener('install', (event) => { + event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))) + self.skipWaiting() +}) + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))), + ), + ) + self.clients.claim() +}) + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url) + if (url.pathname.startsWith('/api/')) return + + event.respondWith( + caches.match(event.request).then((cached) => + cached || fetch(event.request).then((response) => { + if (event.request.method === 'GET' && response.ok) { + const clone = response.clone() + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)) + } + return response + }), + ), + ) +}) diff --git a/server/dev.js b/server/dev.js new file mode 100644 index 0000000..1861eff --- /dev/null +++ b/server/dev.js @@ -0,0 +1,32 @@ +import { spawn } from 'node:child_process' + +const processes = [ + spawn(process.execPath, ['server/server.js'], { + stdio: 'inherit', + env: { ...process.env, PORT: process.env.PORT || '3001' }, + }), + spawn(process.execPath, ['node_modules/vite/bin/vite.js', '--host'], { + stdio: 'inherit', + env: process.env, + }), +] + +let isShuttingDown = false + +const shutdown = (code = 0) => { + if (isShuttingDown) return + isShuttingDown = true + for (const child of processes) { + if (!child.killed) child.kill('SIGTERM') + } + process.exit(code) +} + +for (const child of processes) { + child.on('exit', (code) => { + if (!isShuttingDown && code !== 0) shutdown(code || 1) + }) +} + +process.on('SIGINT', () => shutdown(0)) +process.on('SIGTERM', () => shutdown(0)) diff --git a/server/encryption.js b/server/encryption.js new file mode 100644 index 0000000..a66f853 --- /dev/null +++ b/server/encryption.js @@ -0,0 +1,55 @@ +import crypto from 'node:crypto' + +const configuredKey = process.env.MEMBERSHIP_ENCRYPTION_KEY +const fallbackKey = '0000000000000000000000000000000000000000000000000000000000000000' + +if (!configuredKey) { + console.warn('MEMBERSHIP_ENCRYPTION_KEY is not set. Using a development fallback key.') +} + +const getKey = () => { + const key = Buffer.from(configuredKey || fallbackKey, 'hex') + if (key.length !== 32) { + throw new Error('MEMBERSHIP_ENCRYPTION_KEY must be 64 hex characters.') + } + return key +} + +export const encryptField = (value = '') => { + if (!value) return '' + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv('aes-256-gcm', getKey(), iv) + const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}` +} + +export const decryptField = (value = '') => { + if (!value || !String(value).includes(':')) return value || '' + const parts = String(value).split(':') + if (parts.length !== 3 || parts[0].length !== 32 || parts[1].length !== 32) return value + + const [ivHex, tagHex, encryptedHex] = parts + const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), Buffer.from(ivHex, 'hex')) + decipher.setAuthTag(Buffer.from(tagHex, 'hex')) + return Buffer.concat([ + decipher.update(Buffer.from(encryptedHex, 'hex')), + decipher.final(), + ]).toString('utf8') +} + +export const encryptMembership = (member) => ({ + ...member, + fullName: encryptField(member.fullName), + email: encryptField(member.email || ''), + phone: encryptField(member.phone || ''), + signature: encryptField(member.signature || ''), +}) + +export const decryptMembership = (member) => ({ + ...member, + fullName: decryptField(member.fullName), + email: decryptField(member.email || ''), + phone: decryptField(member.phone || ''), + signature: decryptField(member.signature || ''), +}) diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..2c0e37c --- /dev/null +++ b/server/server.js @@ -0,0 +1,217 @@ +import fs from 'node:fs/promises' +import { existsSync, createReadStream } from 'node:fs' +import http from 'node:http' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { decryptMembership, encryptMembership } from './encryption.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, '..') +const dataDir = path.join(rootDir, 'server', 'data') +const membershipsFile = path.join(dataDir, 'memberships.json') +const distDir = path.join(rootDir, 'dist') +const port = Number(process.env.PORT || 3001) +const adminPubkeys = [ + '7b849efa5604b58d50c419637b9873847dbf957081d526136c3a49b7357cd617', + '2be35e9237eaf98fe0a7d1ce3ceb666dccde9c8cc800ff52f138a62c91d90783', +] + +let memberships = [] + +const json = (res, status, body) => { + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }) + res.end(JSON.stringify(body)) +} + +const ensureStore = async () => { + await fs.mkdir(dataDir, { recursive: true, mode: 0o700 }) + if (!existsSync(membershipsFile)) { + await fs.writeFile(membershipsFile, '[]', { mode: 0o600 }) + } +} + +const loadMemberships = async () => { + await ensureStore() + memberships = JSON.parse(await fs.readFile(membershipsFile, 'utf8')) +} + +const saveMemberships = async () => { + await fs.writeFile(membershipsFile, JSON.stringify(memberships, null, 2), { mode: 0o600 }) +} + +const readBody = async (req) => { + const chunks = [] + let size = 0 + for await (const chunk of req) { + size += chunk.length + if (size > 8 * 1024 * 1024) throw new Error('Request body too large') + chunks.push(chunk) + } + return chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {} +} + +const cleanText = (value, max = 160) => + String(value || '') + .normalize('NFKC') + .replace(/[\u0000-\u001f\u007f<>`{}[\]\\]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max) + +const validateMember = (data) => { + const member = { + membershipId: cleanText(data.membershipId, 32).toUpperCase(), + userId: cleanText(data.userId, 80), + fullName: cleanText(data.fullName, 80), + email: cleanText(data.email, 160).toLowerCase(), + phone: cleanText(data.phone, 32), + signature: String(data.signature || ''), + signedDate: String(data.signedDate || ''), + createdAt: String(data.createdAt || new Date().toISOString()), + expiresAt: String(data.expiresAt || ''), + status: data.status === 'inactive' ? 'inactive' : 'active', + npub: cleanText(data.npub, 80), + nsecHash: cleanText(data.nsecHash, 64).toLowerCase(), + } + const errors = [] + if (!/^L484-\d{4}-[A-Z0-9]{6}$/.test(member.membershipId)) errors.push('Invalid membership ID.') + if (member.fullName.length < 2) errors.push('Full name is required.') + if (member.email && !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(member.email)) errors.push('Invalid email.') + if (member.phone && !/^[0-9()+.\-\s]{7,32}$/.test(member.phone)) errors.push('Invalid phone.') + if (!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(member.signature)) errors.push('Signature is required.') + if (Number.isNaN(new Date(member.signedDate).getTime())) errors.push('Invalid signed date.') + if (Number.isNaN(new Date(member.createdAt).getTime())) errors.push('Invalid created date.') + if (member.expiresAt && Number.isNaN(new Date(member.expiresAt).getTime())) errors.push('Invalid expiry date.') + if (!/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(member.npub)) errors.push('Invalid npub.') + if (!/^[0-9a-f]{64}$/.test(member.nsecHash)) errors.push('Invalid nsec hash.') + + if (!member.expiresAt) { + const expiresAt = new Date(member.createdAt) + expiresAt.setFullYear(expiresAt.getFullYear() + 1) + member.expiresAt = expiresAt.toISOString() + } + + return { member, errors } +} + +const requireAdmin = (req, res) => { + const auth = req.headers.authorization || '' + const pubkey = auth.startsWith('Bearer ') ? auth.slice(7).trim().toLowerCase() : '' + if (!adminPubkeys.includes(pubkey)) { + json(res, 403, { error: 'Admin access required.' }) + return false + } + return true +} + +const serveStatic = (req, res) => { + const requested = decodeURIComponent(new URL(req.url, `http://${req.headers.host}`).pathname) + const filePath = requested === '/' + ? path.join(distDir, 'index.html') + : path.join(distDir, requested) + const safePath = filePath.startsWith(distDir) && existsSync(filePath) ? filePath : path.join(distDir, 'index.html') + const ext = path.extname(safePath) + const types = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.avif': 'image/avif', + '.json': 'application/json', + } + res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream' }) + createReadStream(safePath).pipe(res) +} + +const handleApi = async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`) + + if (req.method === 'GET' && url.pathname === '/api/health') { + return json(res, 200, { ok: true }) + } + + if (req.method === 'POST' && url.pathname === '/api/membership/create') { + const { member, errors } = validateMember(await readBody(req)) + if (errors.length) return json(res, 400, { error: 'Validation failed.', errors }) + + const existingIndex = memberships.findIndex((item) => + item.membershipId === member.membershipId || + item.userId === member.userId || + item.npub === member.npub || + item.nsecHash === member.nsecHash + ) + const encrypted = encryptMembership(member) + if (existingIndex >= 0) { + memberships[existingIndex] = encrypted + } else { + memberships.unshift(encrypted) + } + await saveMemberships() + return json(res, existingIndex >= 0 ? 200 : 201, { success: true, membership: member }) + } + + if (req.method === 'GET' && url.pathname === '/api/membership/check') { + const membershipId = cleanText(url.searchParams.get('membershipId'), 32).toUpperCase() + const userId = cleanText(url.searchParams.get('userId'), 80) + const npub = cleanText(url.searchParams.get('npub'), 80) + const found = memberships.find((item) => + (membershipId && item.membershipId === membershipId) || + (userId && item.userId === userId) || + (npub && item.npub === npub) + ) + return json(res, 200, found + ? { hasMembership: true, membership: decryptMembership(found) } + : { hasMembership: false }) + } + + if (req.method === 'POST' && url.pathname === '/api/membership/recover') { + const body = await readBody(req) + const nsecHash = cleanText(body.nsecHash, 64).toLowerCase() + const found = memberships.find((item) => item.nsecHash === nsecHash) + return json(res, found ? 200 : 404, found + ? { success: true, membership: decryptMembership(found) } + : { error: 'No membership found for that nsec.' }) + } + + if (req.method === 'GET' && url.pathname === '/api/memberships') { + if (!requireAdmin(req, res)) return + return json(res, 200, { + success: true, + memberships: memberships.map(decryptMembership), + }) + } + + if (req.method === 'DELETE' && url.pathname === '/api/membership') { + if (!requireAdmin(req, res)) return + const { membershipId } = await readBody(req) + const id = cleanText(membershipId, 32).toUpperCase() + const before = memberships.length + memberships = memberships.filter((item) => item.membershipId !== id) + await saveMemberships() + return json(res, 200, { success: true, deleted: before - memberships.length }) + } + + json(res, 404, { error: 'Not found.' }) +} + +await loadMemberships() + +http.createServer(async (req, res) => { + try { + if (req.url.startsWith('/api/')) { + await handleApi(req, res) + } else { + serveStatic(req, res) + } + } catch (error) { + console.error(error) + json(res, 500, { error: error.message || 'Server error.' }) + } +}).listen(port, '127.0.0.1', () => { + console.log(`L484 server listening on http://127.0.0.1:${port}`) +}) diff --git a/src/App.vue b/src/App.vue index 67fbb12..d7587bb 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,6 +1,14 @@