diff --git a/node/password-expiry/.gitignore b/node/password-expiry/.gitignore new file mode 100644 index 00000000..6a7d6d8e --- /dev/null +++ b/node/password-expiry/.gitignore @@ -0,0 +1,130 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* \ No newline at end of file diff --git a/node/password-expiry/.prettierrc.json b/node/password-expiry/.prettierrc.json new file mode 100644 index 00000000..0a725205 --- /dev/null +++ b/node/password-expiry/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": true +} diff --git a/node/password-expiry/README.md b/node/password-expiry/README.md new file mode 100644 index 00000000..9852f443 --- /dev/null +++ b/node/password-expiry/README.md @@ -0,0 +1,71 @@ +# ⚡ Node.js Password Expiry Function + +Send an email to remind users to change their password on a regular interval. + +## 🧰 Usage + +### / + +- Send an email to remind users to change their password on a regular interval. If the function fails, the users that failed to receive an email will be logged in console. + +**Response** + +Sample `200` Response: + +```json +{ + "ok": true +} +``` + +Sample `500` Response: + +```json +{ + "ok": false +} +``` + +## ⚙️ Configuration + +You can set CRON to control how often the function is executed. For example, `0 0 * * *` will run the function every day at midnight. + +| Setting | Value | +| ----------------- | ------------- | +| Runtime | Node (18.0) | +| Entrypoint | `src/main.js` | +| Build Commands | `npm install` | +| Permissions | `any` | +| CRON | `0 0 * * *` | +| Timeout (Seconds) | 15 | + +## 🔒 Environment Variables + +### APPWRITE_API_KEY + +The API Key to talk to Appwrite backend APIs. + +| Question | Answer | +| ------------- | -------------------------------------------------------------------------------------------------- | +| Required | Yes | +| Sample Value | `d1efb...aec35` | +| Documentation | [Appwrite: Getting Started for Server](https://appwrite.io/docs/getting-started-for-server#apiKey) | + +### APPWRITE_ENDPOINT + +The URL endpoint of the Appwrite server. If not provided, it defaults to the Appwrite Cloud server: `https://cloud.appwrite.io/v1`. + +| Question | Answer | +| ------------ | ------------------------------ | +| Required | No | +| Sample Value | `https://cloud.appwrite.io/v1` | + +### MAX_PASSWORD_AGE + +The maximum number of days a password can be used before the user is forced to change it. + +| Question | Answer | +| ------------- | --------------------- | +| Required | No | +| Default Value | `90` | +| Sample Value | `https://short.app/s` | diff --git a/node/password-expiry/env.d.ts b/node/password-expiry/env.d.ts new file mode 100644 index 00000000..f1545b55 --- /dev/null +++ b/node/password-expiry/env.d.ts @@ -0,0 +1,14 @@ +declare global { + namespace NodeJS { + interface ProcessEnv { + APPWRITE_ENDPOINT?: string; + APPWRITE_FUNCTION_PROJECT_ID: string; + APPWRITE_API_KEY?: string; + STMP_DSN?: string; + RESET_PASSWORD_URL?: string; + MAX_PASSWORD_AGE?: string; + } + } +} + +export {}; diff --git a/node/password-expiry/package-lock.json b/node/password-expiry/package-lock.json new file mode 100644 index 00000000..19976766 --- /dev/null +++ b/node/password-expiry/package-lock.json @@ -0,0 +1,141 @@ +{ + "name": "starter-template", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "starter-template", + "version": "1.0.0", + "dependencies": { + "node-appwrite": "^9.0.0", + "nodemailer": "^6.9.7" + }, + "devDependencies": { + "prettier": "^3.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", + "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-appwrite": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-9.0.0.tgz", + "integrity": "sha512-iTcHbuaJfr6bP/HFkRVV+FcaumKkbINqZyypQdl+tYxv6Dx0bkB/YKUXGYfTkgP18TLPWQQB++OGQhi98dlo2w==", + "dependencies": { + "axios": "^1.3.6", + "form-data": "^4.0.0" + } + }, + "node_modules/nodemailer": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.7.tgz", + "integrity": "sha512-rUtR77ksqex/eZRLmQ21LKVH5nAAsVicAtAYudK7JgwenEDZ0UIQ1adUGqErz7sMkWYxWTTU1aeP2Jga6WQyJw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/prettier": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.0.tgz", + "integrity": "sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + } + } +} diff --git a/node/password-expiry/package.json b/node/password-expiry/package.json new file mode 100644 index 00000000..47ee33e7 --- /dev/null +++ b/node/password-expiry/package.json @@ -0,0 +1,17 @@ +{ + "name": "password-expiry", + "version": "1.0.0", + "description": "", + "main": "src/main.js", + "type": "module", + "scripts": { + "format": "prettier --write ." + }, + "dependencies": { + "node-appwrite": "^9.0.0", + "nodemailer": "^6.9.7" + }, + "devDependencies": { + "prettier": "^3.0.0" + } +} diff --git a/node/password-expiry/src/main.js b/node/password-expiry/src/main.js new file mode 100644 index 00000000..bdbcbf07 --- /dev/null +++ b/node/password-expiry/src/main.js @@ -0,0 +1,76 @@ +import { throwIfMissing } from './utils.js'; +import { Client, Users, Query } from 'node-appwrite'; +import nodemailer from 'nodemailer'; + +export default async ({ res, log, error }) => { + throwIfMissing(process.env, [ + 'APPWRITE_FUNCTION_PROJECT_ID', + 'APPWRITE_API_KEY', + 'MAX_PASSWORD_AGE', + 'RESET_PASSWORD_URL', + 'STMP_DSN', + ]); + + const client = new Client(); + client + .setEndpoint( + process.env.APPWRITE_ENDPOINT ?? 'https://cloud.appwrite.io/v1' + ) + .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID) + .setKey(process.env.APPWRITE_API_KEY); + + const users = new Users(client); + + const expiryPeriodMs = 1000; + const beforeTimeMs = Date.now() - expiryPeriodMs; + const beforeDateTime = new Date(beforeTimeMs).toISOString(); + + const usersWithExpiredPasswords = await users.list([ + Query.lessThanEqual('passwordUpdate', beforeDateTime), + ]); + + const dsn = new URL(process.env.STMP_DSN); + + log(dsn.username); + + const transport = nodemailer.createTransport({ + host: dsn.hostname, + port: dsn.port || 587, + auth: { + user: dsn.username.replace(/%40/g, '@'), + pass: dsn.password, + }, + }); + + if (usersWithExpiredPasswords.length === 0) { + log('Exiting - no users to notify'); + return res.json({ + ok: true, + }); + } + + let count = 0; + for (const user of usersWithExpiredPasswords) { + try { + await transport.sendMail({ + from: dsn.searchParams.get('from'), + to: 'luke@appwrite.io', + subject: 'Your password needs to be updated', + text: `Hi ${ + user.name + },\n\nYour password needs to be updated. Please log in to ${ + process.env.RESET_PASSWORD_URL + } to update your password.\n\nThanks,\n${dsn.searchParams.get('from')}`, + }); + count += 1; + } catch (err) { + error(`Failed to send email to user with id ${user.$id}: ${err}`); + } + } + + log(`Sent ${count} email to users`); + + return res.json({ + ok: true, + }); +}; diff --git a/node/password-expiry/src/utils.js b/node/password-expiry/src/utils.js new file mode 100644 index 00000000..dcca7015 --- /dev/null +++ b/node/password-expiry/src/utils.js @@ -0,0 +1,17 @@ +/** + * Throws an error if any of the keys are missing from the object + * @param {*} obj + * @param {string[]} keys + * @throws {Error} + */ +export function throwIfMissing(obj, keys) { + const missing = []; + for (let key of keys) { + if (!(key in obj) || !obj[key]) { + missing.push(key); + } + } + if (missing.length > 0) { + throw new Error(`Missing required fields: ${missing.join(', ')}`); + } +}