From 158023df1a0cd88da43a8cd0b4b7a931fc52a17b Mon Sep 17 00:00:00 2001 From: King Omar Date: Fri, 10 Jul 2026 12:40:37 +1000 Subject: [PATCH] Initial build: CareersGateway portal with CRICOS search, auth, Konpare OSHC widget, Kondesk lead push Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 5 + migrations/001_init.sql | 41 + package-lock.json | 1603 +++++++++++++++++++++++++++++++++++++++ package.json | 17 + src/auth.js | 75 ++ src/cricos.js | 105 +++ src/index.js | 231 ++++++ src/kondesk.js | 76 ++ src/templates.js | 743 ++++++++++++++++++ wrangler.toml | 21 + 10 files changed, 2917 insertions(+) create mode 100644 .gitignore create mode 100644 migrations/001_init.sql create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/auth.js create mode 100644 src/cricos.js create mode 100644 src/index.js create mode 100644 src/kondesk.js create mode 100644 src/templates.js create mode 100644 wrangler.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd520b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.wrangler/ +dist/ +*.local +.env diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..52eb98e --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,41 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + full_name TEXT NOT NULL, + phone TEXT, + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + expires_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS inquiries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + full_name TEXT NOT NULL, + email TEXT NOT NULL, + phone TEXT, + service TEXT, + cricos_course_code TEXT, + cricos_course_name TEXT, + cricos_provider TEXT, + message TEXT, + kondesk_sent INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS cricos_cache ( + cache_key TEXT PRIMARY KEY, + results_json TEXT NOT NULL, + cached_at TEXT DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_inquiries_user ON inquiries(user_id); +CREATE INDEX IF NOT EXISTS idx_cricos_cached ON cricos_cache(cached_at); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3741ff2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1603 @@ +{ + "name": "careersgateway", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "careersgateway", + "version": "1.0.0", + "dependencies": { + "hono": "^4.12.28" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260702.1", + "wrangler": "^3.114.17" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", + "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz", + "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.14", + "workerd": "^1.20250124.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz", + "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz", + "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz", + "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz", + "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz", + "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild-plugins/node-globals-polyfill": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", + "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild-plugins/node-modules-polyfill": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz", + "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==", + "dev": true, + "license": "ISC", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "rollup-plugin-node-polyfills": "^0.2.1" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true, + "license": "MIT" + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/esbuild": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exsolve": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/hono": { + "version": "4.12.28", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", + "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "3.20250718.3", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", + "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "stoppable": "1.1.0", + "undici": "^5.28.5", + "workerd": "1.20250718.0", + "ws": "8.18.0", + "youch": "3.3.4", + "zod": "3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/rollup-plugin-inject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", + "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1", + "magic-string": "^0.25.3", + "rollup-pluginutils": "^2.8.1" + } + }, + "node_modules/rollup-plugin-node-polyfills": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", + "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-plugin-inject": "^3.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "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==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.14", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", + "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.1", + "ohash": "^2.0.10", + "pathe": "^2.0.3", + "ufo": "^1.5.4" + } + }, + "node_modules/workerd": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", + "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20250718.0", + "@cloudflare/workerd-darwin-arm64": "1.20250718.0", + "@cloudflare/workerd-linux-64": "1.20250718.0", + "@cloudflare/workerd-linux-arm64": "1.20250718.0", + "@cloudflare/workerd-windows-64": "1.20250718.0" + } + }, + "node_modules/wrangler": { + "version": "3.114.17", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.114.17.tgz", + "integrity": "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.3.4", + "@cloudflare/unenv-preset": "2.0.2", + "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@esbuild-plugins/node-modules-polyfill": "0.2.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.17.19", + "miniflare": "3.20250718.3", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.14", + "workerd": "1.20250718.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=16.17.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20250408.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^0.7.1", + "mustache": "^4.2.0", + "stacktracey": "^2.1.8" + } + }, + "node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fd99e2c --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "careersgateway", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "db:migrate": "wrangler d1 execute careersgateway-db --file=./migrations/001_init.sql" + }, + "dependencies": { + "hono": "^4.12.28" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260702.1", + "wrangler": "^3.114.17" + } +} diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..5f89401 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,75 @@ +const PBKDF2_ITERATIONS = 100000; + +async function hashPassword(password) { + const enc = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits']); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const bits = await crypto.subtle.deriveBits( + { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, + keyMaterial, 256 + ); + const hashArr = Array.from(new Uint8Array(bits)); + const saltArr = Array.from(salt); + return `pbkdf2:${PBKDF2_ITERATIONS}:${btoa(String.fromCharCode(...saltArr))}:${btoa(String.fromCharCode(...hashArr))}`; +} + +async function verifyPassword(password, stored) { + const [, iters, saltB64, hashB64] = stored.split(':'); + const enc = new TextEncoder(); + const salt = Uint8Array.from(atob(saltB64), c => c.charCodeAt(0)); + const expectedHash = Uint8Array.from(atob(hashB64), c => c.charCodeAt(0)); + const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits']); + const bits = await crypto.subtle.deriveBits( + { name: 'PBKDF2', salt, iterations: parseInt(iters), hash: 'SHA-256' }, + keyMaterial, 256 + ); + const actualHash = new Uint8Array(bits); + if (actualHash.length !== expectedHash.length) return false; + let diff = 0; + for (let i = 0; i < actualHash.length; i++) diff |= actualHash[i] ^ expectedHash[i]; + return diff === 0; +} + +function generateToken() { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +async function createSession(env, userId) { + const token = generateToken(); + const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days + await env.SESSIONS.put(token, JSON.stringify({ userId, expires: expires.toISOString() }), { + expirationTtl: 30 * 24 * 60 * 60 + }); + return { token, expires }; +} + +async function getSession(env, token) { + if (!token) return null; + const data = await env.SESSIONS.get(token); + if (!data) return null; + const session = JSON.parse(data); + if (new Date(session.expires) < new Date()) { + await env.SESSIONS.delete(token); + return null; + } + return session; +} + +async function getCurrentUser(env, c) { + const token = getCookie(c, 'session'); + if (!token) return null; + const session = await getSession(env, token); + if (!session) return null; + const user = await env.DB.prepare('SELECT id, email, full_name, phone FROM users WHERE id = ?') + .bind(session.userId).first(); + return user || null; +} + +function getCookie(c, name) { + const header = c.req.header('Cookie') || ''; + const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : null; +} + +export { hashPassword, verifyPassword, createSession, getSession, getCurrentUser, getCookie }; diff --git a/src/cricos.js b/src/cricos.js new file mode 100644 index 0000000..188f5c1 --- /dev/null +++ b/src/cricos.js @@ -0,0 +1,105 @@ +const CRICOS_BASE = 'https://cricos.education.gov.au'; +const CACHE_TTL_HOURS = 24; + +async function fetchViewState() { + const res = await fetch(`${CRICOS_BASE}/Course/CourseSearch.aspx`, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml', + } + }); + const html = await res.text(); + const cookies = res.headers.get('set-cookie') || ''; + + const vs = (html.match(/id="__VIEWSTATE"\s+value="([^"]+)"/) || [])[1] || ''; + const vsgen = (html.match(/id="__VIEWSTATEGENERATOR"\s+value="([^"]+)"/) || [])[1] || ''; + const ev = (html.match(/id="__EVENTVALIDATION"\s+value="([^"]+)"/) || [])[1] || ''; + return { vs, vsgen, ev, cookies }; +} + +function parseCricosResults(html) { + const courses = []; + // Match table rows in the results table + const tableMatch = html.match(/]*id="[^"]*grd[^"]*"[^>]*>([\s\S]*?)<\/table>/i) + || html.match(/]*class="[^"]*result[^"]*"[^>]*>([\s\S]*?)<\/table>/i) + || html.match(/]*>([\s\S]*?)<\/table>/gi); + + if (!tableMatch) return courses; + + const tableHtml = Array.isArray(tableMatch) ? tableMatch.find(t => t.includes('CRICOS') || t.includes('Course')) || tableMatch[0] : tableMatch[1] || tableMatch[0]; + + // Extract all rows with cells + const rows = tableHtml.match(/]*>([\s\S]*?)<\/tr>/gi) || []; + for (const row of rows) { + const cells = (row.match(/]*>([\s\S]*?)<\/td>/gi) || []) + .map(td => td.replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/ /g, ' ').trim()); + if (cells.length >= 4 && cells.some(c => c.length > 2)) { + courses.push({ + cricosCode: cells[0] || '', + courseName: cells[1] || '', + provider: cells[2] || '', + state: cells[3] || '', + duration: cells[4] || '', + fee: cells[5] || '', + }); + } + } + return courses.filter(c => c.cricosCode || c.courseName); +} + +async function searchCricos(env, params) { + const cacheKey = JSON.stringify(params); + const cached = await env.DB.prepare('SELECT results_json, cached_at FROM cricos_cache WHERE cache_key = ?') + .bind(cacheKey).first(); + + if (cached) { + const ageHours = (Date.now() - new Date(cached.cached_at).getTime()) / 3600000; + if (ageHours < CACHE_TTL_HOURS) { + return { results: JSON.parse(cached.results_json), fromCache: true }; + } + } + + try { + const { vs, vsgen, ev, cookies } = await fetchViewState(); + + const formData = new URLSearchParams({ + '__VIEWSTATE': vs, + '__VIEWSTATEGENERATOR': vsgen, + '__EVENTVALIDATION': ev, + 'ctl00$ContentPlaceHolder1$txtCourseName': params.courseName || '', + 'ctl00$ContentPlaceHolder1$txtCRICOSCourseCode': params.cricosCode || '', + 'ctl00$ContentPlaceHolder1$ddlState': params.state || '', + 'ctl00$ContentPlaceHolder1$ddlCourseLevel': params.courseLevel || '', + 'ctl00$ContentPlaceHolder1$ddlBroadField': params.broadField || '', + 'ctl00$ContentPlaceHolder1$ddlNarrowField': '', + 'ctl00$ContentPlaceHolder1$ddlDetailedField': '', + 'ctl00$ContentPlaceHolder1$ddlLanguage': '', + 'ctl00$ContentPlaceHolder1$btnSearch': 'Search', + }); + + const res = await fetch(`${CRICOS_BASE}/Course/CourseSearch.aspx`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': `${CRICOS_BASE}/Course/CourseSearch.aspx`, + 'Cookie': cookies, + }, + body: formData.toString(), + }); + + const html = await res.text(); + const results = parseCricosResults(html); + + await env.DB.prepare( + 'INSERT OR REPLACE INTO cricos_cache (cache_key, results_json, cached_at) VALUES (?, ?, datetime("now"))' + ).bind(cacheKey, JSON.stringify(results)).run(); + + return { results, fromCache: false }; + } catch (e) { + if (cached) return { results: JSON.parse(cached.results_json), fromCache: true, stale: true }; + return { results: [], error: e.message }; + } +} + +export { searchCricos }; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..653f616 --- /dev/null +++ b/src/index.js @@ -0,0 +1,231 @@ +import { Hono } from 'hono'; +import { hashPassword, verifyPassword, createSession, getCurrentUser, getCookie } from './auth.js'; +import { searchCricos } from './cricos.js'; +import { pushLeadToKondesk } from './kondesk.js'; +import { + homePage, coursesPage, registerPage, loginPage, + dashboardPage, contactPage, healthInsurancePage, servicesPage, esc +} from './templates.js'; + +const app = new Hono(); + +// ── Auth middleware (attaches user to context) ────────────────────────────── +app.use('*', async (c, next) => { + c.set('user', await getCurrentUser(c.env, c)); + await next(); +}); + +// ── HOME ──────────────────────────────────────────────────────────────────── +app.get('/', c => c.html(homePage(c.get('user')))); + +// ── REGISTER ──────────────────────────────────────────────────────────────── +app.get('/register', c => { + if (c.get('user')) return c.redirect('/dashboard'); + return c.html(registerPage()); +}); + +app.post('/register', async c => { + const form = await c.req.formData(); + const fullName = (form.get('full_name') || '').trim(); + const email = (form.get('email') || '').trim().toLowerCase(); + const phone = (form.get('phone') || '').trim(); + const password = form.get('password') || ''; + const confirm = form.get('confirm_password') || ''; + const vals = { full_name: fullName, email, phone }; + + if (!fullName || !email || !password) return c.html(registerPage('All required fields must be filled in.', vals)); + if (password.length < 8) return c.html(registerPage('Password must be at least 8 characters.', vals)); + if (password !== confirm) return c.html(registerPage('Passwords do not match.', vals)); + + const existing = await c.env.DB.prepare('SELECT id FROM users WHERE email = ?').bind(email).first(); + if (existing) return c.html(registerPage('An account with this email already exists.', vals)); + + const hash = await hashPassword(password); + const result = await c.env.DB.prepare( + 'INSERT INTO users (email, password_hash, full_name, phone) VALUES (?, ?, ?, ?)' + ).bind(email, hash, fullName, phone).run(); + + const { token, expires } = await createSession(c.env, result.meta.last_row_id); + c.header('Set-Cookie', `session=${token}; Path=/; HttpOnly; SameSite=Lax; Expires=${expires.toUTCString()}`); + return c.redirect('/dashboard'); +}); + +// ── LOGIN ─────────────────────────────────────────────────────────────────── +app.get('/login', c => { + if (c.get('user')) return c.redirect('/dashboard'); + const redirect = c.req.query('redirect') || ''; + return c.html(loginPage(null, redirect)); +}); + +app.post('/login', async c => { + const form = await c.req.formData(); + const email = (form.get('email') || '').trim().toLowerCase(); + const password = form.get('password') || ''; + const redirect = form.get('redirect') || '/dashboard'; + + const user = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(email).first(); + if (!user || !(await verifyPassword(password, user.password_hash))) { + return c.html(loginPage('Incorrect email or password.', redirect)); + } + + const { token, expires } = await createSession(c.env, user.id); + c.header('Set-Cookie', `session=${token}; Path=/; HttpOnly; SameSite=Lax; Expires=${expires.toUTCString()}`); + return c.redirect(redirect.startsWith('/') ? redirect : '/dashboard'); +}); + +// ── LOGOUT ────────────────────────────────────────────────────────────────── +app.get('/logout', async c => { + const token = getCookie(c, 'session'); + if (token) await c.env.SESSIONS.delete(token); + c.header('Set-Cookie', 'session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'); + return c.redirect('/'); +}); + +// ── CRICOS COURSE SEARCH ──────────────────────────────────────────────────── +app.get('/courses', async c => { + const params = { + courseName: c.req.query('courseName') || '', + cricosCode: c.req.query('cricosCode') || '', + state: c.req.query('state') || '', + courseLevel: c.req.query('courseLevel') || '', + }; + + const hasSearch = Object.values(params).some(v => v); + let results = [], fromCache = false, error = null; + + if (hasSearch) { + const r = await searchCricos(c.env, params); + results = r.results; + fromCache = r.fromCache; + error = r.error || null; + } + + return c.html(coursesPage(c.get('user'), results, params, fromCache, error)); +}); + +// ── DASHBOARD ─────────────────────────────────────────────────────────────── +app.get('/dashboard', async c => { + const user = c.get('user'); + if (!user) return c.redirect('/login?redirect=/dashboard'); + const { results } = await c.env.DB.prepare( + 'SELECT * FROM inquiries WHERE user_id = ? ORDER BY created_at DESC LIMIT 20' + ).bind(user.id).all(); + return c.html(dashboardPage(user, results)); +}); + +app.get('/dashboard/save', async c => { + const user = c.get('user'); + if (!user) return c.redirect('/login?redirect=/courses'); + const { code, name, provider } = { code: c.req.query('code'), name: c.req.query('name'), provider: c.req.query('provider') }; + await c.env.DB.prepare( + 'INSERT INTO inquiries (user_id, full_name, email, service, cricos_course_code, cricos_course_name, cricos_provider) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).bind(user.id, user.full_name, user.email, 'Saved Course', code||'', name||'', provider||'').run(); + return c.redirect('/dashboard'); +}); + +app.get('/dashboard/profile', async c => { + const user = c.get('user'); + if (!user) return c.redirect('/login'); + const { layout, esc } = await import('./templates.js'); + return c.html(layout('My Profile', ` +
+
+
+

My Profile

+

Update your account information

+
+
+
+
+
+ +
+
+
+
+ `, user)); +}); + +app.post('/dashboard/profile', async c => { + const user = c.get('user'); + if (!user) return c.redirect('/login'); + const form = await c.req.formData(); + const fullName = (form.get('full_name') || '').trim(); + const phone = (form.get('phone') || '').trim(); + const password = form.get('password') || ''; + + if (password) { + const hash = await hashPassword(password); + await c.env.DB.prepare('UPDATE users SET full_name=?, phone=?, password_hash=? WHERE id=?') + .bind(fullName, phone, hash, user.id).run(); + } else { + await c.env.DB.prepare('UPDATE users SET full_name=?, phone=? WHERE id=?') + .bind(fullName, phone, user.id).run(); + } + return c.redirect('/dashboard'); +}); + +// ── CONTACT / INQUIRY ──────────────────────────────────────────────────────── +app.get('/contact', c => { + const params = { + course: c.req.query('course') || '', + code: c.req.query('code') || '', + provider: c.req.query('provider') || '', + service: c.req.query('service') || '', + }; + return c.html(contactPage(c.get('user'), params)); +}); + +app.post('/contact', async c => { + const user = c.get('user'); + const form = await c.req.formData(); + const fullName = (form.get('full_name') || '').trim(); + const email = (form.get('email') || '').trim().toLowerCase(); + const phone = (form.get('phone') || '').trim(); + const service = form.get('service') || 'General Inquiry'; + const message = form.get('message') || ''; + const cricosCode = form.get('cricos_code') || ''; + const cricosName = form.get('cricos_course') || ''; + const cricosProvider = form.get('cricos_provider') || ''; + + if (!fullName || !email) { + return c.html(contactPage(user, {}, false, 'Name and email are required.')); + } + + const insert = await c.env.DB.prepare( + `INSERT INTO inquiries (user_id, full_name, email, phone, service, cricos_course_code, cricos_course_name, cricos_provider, message) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).bind(user?.id || null, fullName, email, phone, service, cricosCode, cricosName, cricosProvider, message).run(); + + const lead = { fullName, email, phone, service, cricosCode, courseName: cricosName, provider: cricosProvider, message }; + const pushed = await pushLeadToKondesk(c.env, lead); + + if (pushed.success) { + await c.env.DB.prepare('UPDATE inquiries SET kondesk_sent=1 WHERE id=?') + .bind(insert.meta.last_row_id).run(); + } + + const params = { course: cricosName, code: cricosCode, provider: cricosProvider }; + return c.html(contactPage(user, params, true)); +}); + +// ── HEALTH INSURANCE (KONPARE WIDGET) ──────────────────────────────────────── +app.get('/health-insurance', c => + c.html(healthInsurancePage(c.get('user'), c.env.KONPARE_KEY)) +); + +// ── SERVICES ───────────────────────────────────────────────────────────────── +app.get('/services', c => c.redirect('/')); +app.get('/services/:slug', c => { + const html = servicesPage(c.get('user'), c.req.param('slug')); + if (!html) return c.redirect('/'); + return c.html(html); +}); + +// ── 404 ────────────────────────────────────────────────────────────────────── +app.notFound(c => { + const { layout } = { layout: (t, b, u) => `${t}

404

Page not found

Go Home` }; + return c.html(`404 Not Found

404

Page not found

Go Home`, 404); +}); + +export default app; diff --git a/src/kondesk.js b/src/kondesk.js new file mode 100644 index 0000000..79cd8b7 --- /dev/null +++ b/src/kondesk.js @@ -0,0 +1,76 @@ +// Konpare/Kondesk lead push +// The Konpare API key is used for their widget embed (OSHC comparison) +// and for posting lead data when a course inquiry is submitted. +async function pushLeadToKondesk(env, lead) { + const payload = { + api_key: env.KONPARE_KEY, + lead: { + full_name: lead.fullName, + email: lead.email, + phone: lead.phone || '', + service: lead.service || 'Course Inquiry', + course_code: lead.cricosCode || '', + course_name: lead.courseName || '', + provider: lead.provider || '', + message: lead.message || '', + source: 'careers.bored.investments', + } + }; + + // Try Konpare lead submission endpoint + const endpoints = [ + 'https://app.konpare.online/api/leads', + 'https://app.konpare.online/api/v1/leads', + 'https://app.kondesk.com/api/leads', + ]; + + for (const url of endpoints) { + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${env.KONPARE_KEY}`, + 'x-api-key': env.KONPARE_KEY, + }, + body: JSON.stringify(payload), + }); + if (res.ok) return { success: true, endpoint: url }; + } catch (_) {} + } + + // Fallback: email notification + await sendEmailFallback(env, lead); + return { success: false, fallback: 'email' }; +} + +async function sendEmailFallback(env, lead) { + // Cloudflare Email Routing send — uses MailChannels via Workers + const emailBody = ` +New Lead from Careers Gateway Portal + +Name: ${lead.fullName} +Email: ${lead.email} +Phone: ${lead.phone || 'N/A'} +Service: ${lead.service || 'N/A'} +Course: ${lead.courseName || 'N/A'} (${lead.cricosCode || 'N/A'}) +Provider: ${lead.provider || 'N/A'} +Message: ${lead.message || 'N/A'} +Submitted: ${new Date().toLocaleString('en-AU', { timeZone: 'Australia/Sydney' })} + `.trim(); + + try { + await fetch('https://api.mailchannels.net/tx/v1/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: env.CONTACT_EMAIL, name: 'Careers Gateway' }] }], + from: { email: 'noreply@careersgateway.com.au', name: 'Careers Gateway Portal' }, + subject: `New Lead: ${lead.fullName} — ${lead.service || 'Course Inquiry'}`, + content: [{ type: 'text/plain', value: emailBody }], + }), + }); + } catch (_) {} +} + +export { pushLeadToKondesk }; diff --git a/src/templates.js b/src/templates.js new file mode 100644 index 0000000..a4a0724 --- /dev/null +++ b/src/templates.js @@ -0,0 +1,743 @@ +const CSS = ` + *{box-sizing:border-box;margin:0;padding:0} + body{font-family:'Segoe UI',Arial,sans-serif;color:#1a2744;background:#f8faff} + a{color:#1a5bb8;text-decoration:none} + a:hover{text-decoration:underline} + + /* NAV */ + nav{background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.08);position:sticky;top:0;z-index:100} + .nav-inner{max-width:1200px;margin:0 auto;padding:0 24px;display:flex;align-items:center;justify-content:space-between;height:68px} + .nav-logo{font-size:1.25rem;font-weight:700;color:#1a2744;display:flex;align-items:center;gap:10px} + .nav-logo span{color:#1a5bb8} + .nav-links{display:flex;gap:28px;align-items:center;list-style:none} + .nav-links a{font-size:.95rem;color:#1a2744;font-weight:500} + .nav-links a:hover{color:#1a5bb8;text-decoration:none} + .nav-cta{background:#1a5bb8;color:#fff!important;padding:9px 22px;border-radius:6px;font-weight:600!important} + .nav-cta:hover{background:#154fa0;text-decoration:none!important} + .nav-user{display:flex;align-items:center;gap:16px;font-size:.9rem} + + /* HERO */ + .hero{background:linear-gradient(135deg,#1a2744 0%,#1a5bb8 100%);color:#fff;padding:80px 24px;text-align:center} + .hero h1{font-size:2.8rem;font-weight:800;margin-bottom:16px;line-height:1.2} + .hero p{font-size:1.2rem;opacity:.9;max-width:600px;margin:0 auto 32px} + .hero-btns{display:flex;gap:16px;justify-content:center;flex-wrap:wrap} + .btn{display:inline-block;padding:13px 30px;border-radius:8px;font-weight:700;font-size:1rem;cursor:pointer;border:none;transition:.2s} + .btn-white{background:#fff;color:#1a5bb8} + .btn-white:hover{background:#e8f0fe;text-decoration:none} + .btn-outline{border:2px solid #fff;color:#fff;background:transparent} + .btn-outline:hover{background:rgba(255,255,255,.12);text-decoration:none} + .btn-primary{background:#1a5bb8;color:#fff} + .btn-primary:hover{background:#154fa0;text-decoration:none} + .btn-success{background:#16a34a;color:#fff} + .btn-success:hover{background:#15803d;text-decoration:none} + + /* SECTIONS */ + section{padding:64px 24px} + .container{max-width:1200px;margin:0 auto} + .section-title{font-size:2rem;font-weight:700;text-align:center;margin-bottom:12px;color:#1a2744} + .section-sub{text-align:center;color:#64748b;font-size:1.05rem;margin-bottom:48px} + + /* SERVICES GRID */ + .services-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:24px} + .service-card{background:#fff;border-radius:12px;padding:28px;box-shadow:0 2px 12px rgba(0,0,0,.06);transition:.2s;border:1px solid #e8f0fe} + .service-card:hover{transform:translateY(-3px);box-shadow:0 8px 24px rgba(26,91,184,.12)} + .service-icon{font-size:2rem;margin-bottom:12px} + .service-card h3{font-size:1.1rem;font-weight:700;margin-bottom:8px;color:#1a2744} + .service-card p{font-size:.9rem;color:#64748b;line-height:1.6} + .service-link{display:inline-block;margin-top:14px;font-size:.9rem;font-weight:600;color:#1a5bb8} + + /* STATS */ + .stats-bar{background:#1a5bb8;color:#fff;padding:40px 24px} + .stats-inner{max-width:1200px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:24px;text-align:center} + .stat-num{font-size:2.2rem;font-weight:800} + .stat-label{font-size:.9rem;opacity:.85;margin-top:4px} + + /* WHY US */ + .why-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:20px} + .why-card{background:#fff;border-radius:10px;padding:24px;border-left:4px solid #1a5bb8;box-shadow:0 2px 8px rgba(0,0,0,.05)} + .why-card h4{font-weight:700;margin-bottom:8px;color:#1a2744} + .why-card p{font-size:.9rem;color:#64748b} + + /* TESTIMONIALS */ + .testi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:24px} + .testi-card{background:#fff;border-radius:12px;padding:28px;box-shadow:0 2px 12px rgba(0,0,0,.06)} + .stars{color:#f59e0b;font-size:1.1rem;margin-bottom:12px} + .testi-text{color:#374151;font-style:italic;line-height:1.7;margin-bottom:16px} + .testi-name{font-weight:700;color:#1a2744} + + /* FORMS */ + .form-card{background:#fff;border-radius:16px;padding:40px;max-width:520px;margin:0 auto;box-shadow:0 4px 24px rgba(0,0,0,.08)} + .form-card h2{font-size:1.6rem;font-weight:700;margin-bottom:8px;text-align:center} + .form-card .sub{text-align:center;color:#64748b;margin-bottom:28px;font-size:.95rem} + .form-group{margin-bottom:18px} + .form-group label{display:block;font-weight:600;font-size:.9rem;margin-bottom:6px;color:#1a2744} + .form-group input,.form-group select,.form-group textarea{width:100%;padding:11px 14px;border:1.5px solid #d1d9e8;border-radius:8px;font-size:.95rem;transition:.2s;background:#fafbff} + .form-group input:focus,.form-group select:focus,.form-group textarea:focus{outline:none;border-color:#1a5bb8;background:#fff} + .form-group textarea{resize:vertical;min-height:100px} + .form-row{display:grid;grid-template-columns:1fr 1fr;gap:16px} + .form-submit{width:100%;padding:13px;background:#1a5bb8;color:#fff;border:none;border-radius:8px;font-size:1rem;font-weight:700;cursor:pointer;transition:.2s;margin-top:8px} + .form-submit:hover{background:#154fa0} + .form-footer{text-align:center;margin-top:20px;font-size:.9rem;color:#64748b} + .alert{padding:12px 16px;border-radius:8px;margin-bottom:18px;font-size:.9rem} + .alert-success{background:#dcfce7;color:#16a34a;border:1px solid #bbf7d0} + .alert-error{background:#fee2e2;color:#dc2626;border:1px solid #fecaca} + .alert-info{background:#dbeafe;color:#1d4ed8;border:1px solid #bfdbfe} + + /* COURSE SEARCH */ + .search-bar{background:#fff;border-radius:12px;padding:28px;box-shadow:0 2px 12px rgba(0,0,0,.06);margin-bottom:32px} + .search-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;align-items:end} + .search-grid .btn{padding:11px 24px;font-size:.95rem} + .results-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px} + .results-count{font-size:.95rem;color:#64748b} + .course-card{background:#fff;border-radius:10px;padding:22px;box-shadow:0 2px 8px rgba(0,0,0,.05);margin-bottom:16px;border:1px solid #e8f0fe;display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap} + .course-info h3{font-size:1.05rem;font-weight:700;color:#1a2744;margin-bottom:6px} + .course-meta{display:flex;gap:12px;flex-wrap:wrap;margin-top:8px} + .badge{display:inline-block;padding:3px 10px;border-radius:20px;font-size:.8rem;font-weight:600} + .badge-blue{background:#dbeafe;color:#1d4ed8} + .badge-green{background:#dcfce7;color:#166534} + .badge-gray{background:#f3f4f6;color:#374151} + .course-actions{display:flex;flex-direction:column;gap:8px;min-width:160px} + .btn-sm{padding:8px 16px;font-size:.85rem;border-radius:6px} + + /* DASHBOARD */ + .dash-grid{display:grid;grid-template-columns:240px 1fr;gap:28px;align-items:start} + .dash-sidebar{background:#fff;border-radius:12px;padding:24px;box-shadow:0 2px 8px rgba(0,0,0,.05)} + .dash-user{text-align:center;margin-bottom:24px;padding-bottom:20px;border-bottom:1px solid #e8f0fe} + .dash-avatar{width:64px;height:64px;background:#1a5bb8;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:1.6rem;font-weight:700;margin:0 auto 12px} + .dash-nav{list-style:none} + .dash-nav li{margin-bottom:4px} + .dash-nav a{display:block;padding:10px 14px;border-radius:8px;font-size:.95rem;color:#374151;font-weight:500;transition:.15s} + .dash-nav a:hover,.dash-nav a.active{background:#dbeafe;color:#1a5bb8;text-decoration:none} + .dash-main{display:flex;flex-direction:column;gap:24px} + .dash-card{background:#fff;border-radius:12px;padding:28px;box-shadow:0 2px 8px rgba(0,0,0,.05)} + .dash-card h3{font-size:1.1rem;font-weight:700;margin-bottom:18px;color:#1a2744;padding-bottom:12px;border-bottom:1px solid #e8f0fe} + .inquiry-row{padding:14px 0;border-bottom:1px solid #f1f5f9;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px} + .inquiry-row:last-child{border-bottom:none} + .inquiry-info{font-size:.9rem;color:#374151} + .inquiry-date{font-size:.8rem;color:#94a3b8} + + /* FOOTER */ + footer{background:#1a2744;color:#cbd5e1;padding:48px 24px 24px} + .footer-inner{max-width:1200px;margin:0 auto} + .footer-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:32px;margin-bottom:40px} + .footer-col h4{color:#fff;font-weight:700;margin-bottom:16px} + .footer-col ul{list-style:none} + .footer-col ul li{margin-bottom:8px} + .footer-col ul li a{color:#94a3b8;font-size:.9rem} + .footer-col ul li a:hover{color:#fff;text-decoration:none} + .footer-contact{font-size:.9rem;line-height:1.8;color:#94a3b8} + .footer-bottom{border-top:1px solid #2d3d5a;padding-top:20px;text-align:center;font-size:.85rem;color:#64748b} + + /* KONPARE WIDGET */ + .widget-wrap{background:#fff;border-radius:12px;padding:12px;box-shadow:0 2px 12px rgba(0,0,0,.08);overflow:hidden} + .widget-wrap iframe{width:100%;border:none;min-height:560px;display:block} + + @media(max-width:768px){ + .hero h1{font-size:1.9rem} + .nav-links{display:none} + .dash-grid{grid-template-columns:1fr} + .form-row{grid-template-columns:1fr} + .course-card{flex-direction:column} + .course-actions{flex-direction:row;min-width:auto} + } +`; + +function layout(title, body, user = null, extraHead = '') { + const navUser = user + ? `` + : ``; + return ` + + + +${esc(title)} — Careers Gateway Australia + +${extraHead} + + + +${body} + +`; +} + +function esc(str) { + return String(str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +function homePage(user) { + return layout('Your Gateway to Success', ` +
+
+

Your Gateway to Success
in Australia 🇦🇺

+

Guiding individuals and businesses through education, migration, and career pathways with certified, multilingual professionals.

+ +
+
+ +
+
+
5,000+
Clients Helped
+
98%
Success Rate
+
15+
Years Experience
+
50+
Partner Institutions
+
+
+ +
+
+

Our Services

+

Comprehensive support for every stage of your Australian journey

+
+ ${[ + ['🎓','Education & Visa Services','Expert guidance on student visas, course selection, and institution enrolment through CRICOS-registered providers.','/services/education'], + ['🌏','Migration Services','Skilled migration, family visas, employer nominations, and permanent residency pathways.','/services/migration'], + ['💼','Recruitment & Labour Hire','Connecting skilled professionals with Australian employers. RPL-recognised workforce solutions.','/services/recruitment'], + ['📋','RPL Assessment','Recognition of Prior Learning for tradespeople, nurses, engineers, and other professionals.','/services/rpl'], + ['✅','Skills Assessment','Formal skills assessments for visa and registration purposes across multiple industry bodies.','/services/skills'], + ['🏥','Health Insurance (OSHC/OVHC)','Compare and buy Overseas Student Health Cover and Overseas Visitor Health Cover instantly.','/health-insurance'], + ['💰','Easy Tax Return','Simple, fast Australian tax returns for international students and working holiday visa holders.','/services/tax'], + ['❤️','Aged Care Services','Placement and support services for aged care pathways and qualifications.','/services/aged-care'], + ].map(([icon,title,desc,link]) => ` +
+
${icon}
+

${title}

+

${desc}

+ Learn more → +
+ `).join('')} +
+
+
+ +
+
+

Find Your Course

+

Search thousands of CRICOS-registered courses from Australian institutions

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ +
+
+

Why Choose Us?

+

Five reasons thousands trust Careers Gateway

+
+ ${[ + ['🏅','MARA Registered Agents','All migration advice from registered MARA agents — fully compliant and trustworthy.'], + ['🌐','Multilingual Support','We speak your language. Services available in English, Hindi, Nepali, and more.'], + ['🔄','End-to-End Service','From course selection to visa lodgement to arrival — one team, the whole journey.'], + ['📊','Proven Track Record','98% success rate across thousands of student and migration visa applications.'], + ['💡','Transparent Pricing','No hidden fees. Clear, upfront pricing with written agreements for all services.'], + ].map(([icon,title,desc]) => ` +
+

${icon} ${title}

+

${desc}

+
+ `).join('')} +
+
+
+ +
+
+

What Our Clients Say

+

Real stories from real people

+
+ ${[ + ['Priya S.','Sydney','Careers Gateway made my student visa process so easy. They found me the perfect nursing course and handled everything. Couldn\'t recommend them more highly!'], + ['Raj M.','Melbourne','From RPL assessment to skills recognition — the team was professional and quick. I got my qualification recognised within 3 months.'], + ['Anita K.','Brisbane','The migration team helped my whole family get permanent residency. They were patient, thorough, and available whenever we needed them.'], + ].map(([name,city,text]) => ` +
+
★★★★★
+

"${text}"

+
${name} — ${city}
+
+ `).join('')} +
+
+
+ +
+
+

Ready to Start Your Journey?

+

Book a free consultation with our experts today

+ +
+
+ `, user); +} + +function coursesPage(user, results, params, fromCache, error) { + const hasSearch = Object.values(params).some(v => v); + const stateOptions = ['NSW','VIC','QLD','WA','SA','TAS','ACT','NT']; + const levelOptions = [['1','Bachelor Degree'],['2','Master Degree'],['3','Doctoral Degree'],['4','Diploma'],['5','Certificate'],['6','Advanced Diploma'],['7','Graduate Certificate'],['8','Graduate Diploma']]; + + return layout('CRICOS Course Search', ` +
+
+

🔍 CRICOS Course Search

+

Search thousands of courses from CRICOS-registered Australian institutions

+
+
+
+
+ + + ${!hasSearch ? ` +
+
🎓
+

Search CRICOS-Registered Courses

+

Enter a course name, state, or level above to find courses from Australian institutions

+
+ ` : error ? ` +
Unable to fetch CRICOS data right now. Please try again shortly.
+ ` : results.length === 0 ? ` +
+
🔎
+

No courses found

+

Try broadening your search — use fewer keywords or remove filters

+
+ ` : ` +
+
${results.length} courses found ${fromCache ? '(cached)' : ''}
+ ${user ? '' : 'Save courses — Register free'} +
+ ${results.map(c => ` +
+
+

${esc(c.courseName || 'Course')}

+
${esc(c.provider || '')}
+
+ ${c.cricosCode ? `CRICOS: ${esc(c.cricosCode)}` : ''} + ${c.state ? `📍 ${esc(c.state)}` : ''} + ${c.duration ? `⏱ ${esc(c.duration)}` : ''} + ${c.fee ? `💰 ${esc(c.fee)}` : ''} +
+
+
+ Book Consultation + ${user ? `💾 Save Course` : ''} +
+
+ `).join('')} + `} + +
+

Need help choosing the right course?

+

Our education advisors speak your language and know the Australian system inside out

+ Book Free Consultation +
+
+
+ `, user); +} + +function registerPage(error, values = {}) { + return layout('Create Account', ` +
+
+

Create Your Account

+

Track courses, save inquiries, and get personalised support

+ ${error ? `
${esc(error)}
` : ''} +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ `); +} + +function loginPage(error, redirect = '') { + return layout('Login', ` +
+
+

Welcome Back

+

Login to your Careers Gateway account

+ ${error ? `
${esc(error)}
` : ''} +
+ +
+ + +
+
+ + +
+ +
+ +
+
+ `); +} + +function dashboardPage(user, inquiries) { + const initials = user.full_name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0,2); + return layout('Dashboard', ` +
+
+
+
+
+
${esc(initials)}
+
${esc(user.full_name)}
+
${esc(user.email)}
+
+ +
+
+
+

Welcome back, ${esc(user.full_name.split(' ')[0])}! 👋

+
+
+
${inquiries.length}
+
Inquiries
+
+
+
${inquiries.filter(i=>i.kondesk_sent).length}
+
Processed
+
+
+
+
+

My Inquiries

+ ${inquiries.length === 0 ? ` +
+
📋
+

No inquiries yet. Book a consultation or search courses.

+
+ ` : inquiries.map(i => ` +
+
+
${esc(i.service||'General Inquiry')}${i.cricos_course_name ? ` — ${esc(i.cricos_course_name)}` : ''}
+ ${i.cricos_provider ? `
${esc(i.cricos_provider)}
` : ''} +
${new Date(i.created_at).toLocaleDateString('en-AU',{day:'numeric',month:'short',year:'numeric'})}
+
+ ${i.kondesk_sent ? '✓ Processing' : 'Received'} +
+ `).join('')} +
+ +
+
+
+
+ `, user); +} + +function contactPage(user, params = {}, success = false, error = '') { + const services = ['Education & Visa Services','Migration Services','Recruitment & Labour Hire','RPL Assessment','Skills Assessment','Health Insurance (OSHC/OVHC)','Easy Tax Return','Aged Care Services','Course Inquiry','General Inquiry']; + return layout('Contact & Book Consultation', ` +
+
+

Book a Free Consultation

+

Our team will get back to you within 24 hours

+
+
+
+
+
+
+

Send Us a Message

+

Fill in your details and we'll be in touch soon

+ ${success ? '
✅ Your inquiry has been submitted! Our team will contact you within 24 hours.
' : ''} + ${error ? `
${esc(error)}
` : ''} + ${params.course ? `
🎓 ${esc(params.course)}${params.code ? ` (CRICOS: ${esc(params.code)})` : ''} — ${esc(params.provider||'')}
` : ''} +
+ + + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+

📞 Contact Details

+
+
📍 Sydney, NSW, Australia
+ + +
🕐 Mon–Fri: 9am–6pm AEST
+
+
+
+

✅ What Happens Next

+
    +
  1. We receive your inquiry
  2. +
  3. A specialist is assigned
  4. +
  5. You're contacted within 24h
  6. +
  7. Free initial consultation
  8. +
+
+
+
+
+
+ + `, user); +} + +function healthInsurancePage(user, konpareKey) { + return layout('Health Insurance — OSHC & OVHC', ` +
+
+

🏥 Health Insurance for Students & Visitors

+

Compare and buy OSHC & OVHC policies instantly through our Konpare-powered portal

+
+
+
+
+
+ ${[ + ['🏥','OSHC','Overseas Student Health Cover','Required for all international student visa holders in Australia'], + ['🌏','OVHC','Overseas Visitors Health Cover','For visitors, working holiday makers, and sponsored workers'], + ['💊','Cover Includes','Hospital, GP, Prescriptions','Plus emergency ambulance in most policies'], + ['⚡','Instant','Online Purchase','Get your certificate of insurance immediately'], + ].map(([icon,short,title,desc]) => ` +
+
${icon}
+
${short}
+

${title}

+

${desc}

+
+ `).join('')} +
+ +

Compare & Buy Now

+
+ +
+ +
+

🔒 Secure payments powered by Konpare. Policies from all major Australian health insurers.

+
+
+
+ `, user); +} + +function servicesPage(user, service) { + const pages = { + education: { title: 'Education & Visa Services', icon: '🎓', desc: 'We help international students find and enrol in CRICOS-registered courses at Australian universities, TAFE colleges, and private providers. Our education counsellors assess your academic background, career goals, and visa eligibility to recommend the best pathway for you.', items: ['CRICOS course selection and application','Student visa (subclass 500) lodgement','Enrolment support and CoE assistance','Pre-departure briefings','Airport pickup and arrival support'] }, + migration: { title: 'Migration Services', icon: '🌏', desc: 'Our MARA-registered migration agents provide expert advice on all Australian visa subclasses. From skilled migration to family reunification, we handle your case end-to-end.', items: ['Skilled Independent (189, 190, 491)','Employer Nomination Scheme (186, 187)','Family visas (309, 100, 820, 801)','Partner and spouse visas','Bridging and tourist visas'] }, + recruitment: { title: 'Recruitment & Labour Hire', icon: '💼', desc: 'We connect skilled professionals with Australian employers across healthcare, construction, IT, and hospitality sectors.', items: ['Job placement for skilled migrants','Labour hire for construction and agriculture','Healthcare and nursing recruitment','Resume and interview preparation','Work rights advice'] }, + rpl: { title: 'RPL Assessment', icon: '📋', desc: 'Recognition of Prior Learning (RPL) lets you convert your overseas work experience into Australian qualifications without re-studying.', items: ['Trade qualifications (Certificate III/IV)','Healthcare and nursing qualifications','Engineering and IT assessments','Portfolio preparation support','Fast-track pathways available'] }, + skills: { title: 'Skills Assessment', icon: '✅', desc: 'Formal skills assessments required for skilled migration visas, assessed by bodies such as Engineers Australia, TRA, VETASSESS, ANMAC, and more.', items: ['Engineers Australia','Trades Recognition Australia (TRA)','VETASSESS (professional occupations)','ANMAC (nursing and midwifery)','AIM, CPAA, CPA Australia'] }, + tax: { title: 'Easy Tax Return', icon: '💰', desc: 'Quick, affordable Australian tax returns for international students and working holiday visa holders.', items: ['Tax file number (TFN) applications','Annual tax return lodgement','Working holiday tax refunds','HECS/HELP debt queries','Superannuation withdrawals on departure'] }, + 'aged-care': { title: 'Aged Care Services', icon: '❤️', desc: 'Pathways into Australia\'s growing aged care sector, including qualification recognition and job placement.', items: ['Certificate III in Individual Support','AHPRA registration assistance','Job placement in aged care facilities','Visa pathways for aged care workers','Ongoing compliance support'] }, + }; + const page = pages[service]; + if (!page) return null; + return layout(page.title, ` +
+
+
${page.icon}
+

${page.title}

+
+
+
+
+
+
+

${page.desc}

+

What's Included

+
    + ${page.items.map(item => `
  • ${item}
  • `).join('')} +
+
+
+
+

Get Started Today

+

Book a free initial consultation with one of our ${page.title} specialists

+ Book Free Consultation +
No obligation · Response within 24h
+
+
+
+
+
+ `, user); +} + +export { layout, esc, homePage, coursesPage, registerPage, loginPage, dashboardPage, contactPage, healthInsurancePage, servicesPage }; diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..ab8f92c --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,21 @@ +name = "careersgateway" +main = "src/index.js" +compatibility_date = "2024-11-01" +compatibility_flags = ["nodejs_compat"] + +[[custom_domains]] +pattern = "careers.bored.investments" + +[[d1_databases]] +binding = "DB" +database_name = "careersgateway-db" +database_id = "d898aeb4-5568-48d5-ba20-4244e467b5de" + +[[kv_namespaces]] +binding = "SESSIONS" +id = "5b40ceec3c6446409351805028e63453" + +[vars] +SITE_NAME = "Careers Gateway" +CONTACT_EMAIL = "info@careersgateway.com.au" +KONPARE_KEY = "ViL_0iMMCdHrXNoEQKFm4P5092ISFcYzEyLmf5MFBKds7Hw5BmORaXu3oGsMB5Lk"