├── .dockerignore ├── .gitattributes ├── .prettierignore ├── public ├── favicon.ico ├── js │ ├── lib │ │ └── .gitkeep │ └── main.js ├── apple-touch-icon.png ├── apple-touch-icon-precomposed.png ├── bootstrap-logo.svg ├── css │ └── main.scss └── privacy-policy.html ├── .prettierrc ├── controllers ├── home.js └── contact.js ├── docker-compose.yml ├── Dockerfile ├── test ├── fixtures │ ├── fixture_manifest.json │ ├── GET_https%3A%2F%2Fen.wikipedia.org%2Fw%2Fapi.php%3Faction%3Dquery%26format%3Djson%26origin%3D_%26prop%3Dpageimages%257Cpageterms%26titles%3DNode.js%26pithumbsize%3D400.json │ ├── POST_https%3A%2F%2Fapi.together.xyz%2Fv1%2Fchat%2Fcompletions_0774705f0cb5.json │ ├── GET_https%3A%2F%2Fapi.trakt.tv%2Fmovies%2Fa-house-of-dynamite-2025%3Fextended%3Dfull%252Cimages.json │ ├── GET_https%3A%2F%2Fen.wikipedia.org%2Fw%2Fapi.php%3Faction%3Dquery%26format%3Djson%26origin%3D_%26prop%3Dextracts%26explaintext%3D1%26titles%3DNode.js%26exintro%3D1.json │ ├── POST_https%3A%2F%2Fapi.openai.com%2Fv1%2Fmoderations_c6b4d54f3bd4.json │ ├── POST_https%3A%2F%2Fapi.openai.com%2Fv1%2Fmoderations_624f7df3dc5f.json │ ├── GET_https%3A%2F%2Fen.wikipedia.org%2Fw%2Fapi.php%3Faction%3Dparse%26format%3Djson%26origin%3D_%26page%3DNode.js%26prop%3Dsections.json │ ├── GET_https%3A%2F%2Fapi.trakt.tv%2Fmovies%2Ftrending%3Flimit%3D6%26extended%3Dimages.json │ └── GET_https%3A%2F%2Fen.wikipedia.org%2Fw%2Fapi.php%3Faction%3Dquery%26format%3Djson%26origin%3D_%26list%3Dsearch%26srsearch%3Djavascript%26srlimit%3D10.json ├── app-links.test.js ├── docs-links.test.js ├── tools │ ├── playwright-start-and-log.js │ ├── fixture-helpers.js │ ├── start-with-memory-db.js │ ├── server-fetch-fixtures.js │ └── server-axios-fixtures.js ├── e2e-nokey │ ├── scraping.e2e.test.js │ ├── rag.e2e.test.js │ └── wikipedia.e2e.test.js ├── app.test.js ├── e2e │ ├── tenor.e2e.test.js │ ├── nyt.e2e.test.js │ ├── openai-moderation.e2e.test.js │ ├── here-maps.e2e.test.js │ ├── chart.e2e.test.js │ └── twilio.e2e.test.js ├── .env.test ├── playwright.config.js ├── auth.opt.test.js ├── flash.test.js └── morgan.test.js ├── views ├── partials │ ├── footer.pug │ ├── flash.pug │ └── header.pug ├── account │ ├── forgot.pug │ ├── reset.pug │ ├── signup.pug │ └── login.pug ├── api │ ├── scraping.pug │ ├── google-drive.pug │ ├── quickbooks.pug │ ├── nyt.pug │ ├── upload.pug │ ├── facebook.pug │ ├── paypal.pug │ ├── lastfm.pug │ ├── google-sheets.pug │ ├── tenor.pug │ ├── steam.pug │ ├── tumblr.pug │ ├── chart.pug │ ├── lob.pug │ ├── foursquare.pug │ ├── twitch.pug │ ├── wikipedia.pug │ ├── here-maps.pug │ ├── stripe.pug │ └── twilio.pug ├── contact.pug ├── home.pug ├── ai │ ├── togetherai-classifier.pug │ ├── openai-moderation.pug │ └── index.pug └── layout.pug ├── models └── Session.js ├── .github ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── build.yml │ └── dependabot-automerge.yml ├── .gitignore ├── patches ├── passport-oauth2+1.8.0.patch ├── passport-openidconnect+0.1.2.patch ├── passport+0.7.0.patch ├── langchain+0.3.36.patch └── @langchain+mongodb+0.1.1.patch ├── LICENSE ├── SECURITY.md ├── .husky └── pre-commit ├── config ├── flash.js ├── nodemailer.js └── morgan.js ├── eslint.config.mjs ├── .env.example ├── PROD_CHECKLIST.md └── package.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahat/hackathon-starter/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/js/lib/.gitkeep: -------------------------------------------------------------------------------- 1 | # empty gitkeep file to assure creation of public/js/lib directory by git -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahat/hackathon-starter/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@prettier/plugin-pug"], 3 | "singleQuote": true, 4 | "printWidth": 300 5 | } 6 | -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahat/hackathon-starter/HEAD/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /public/js/main.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jquery, browser */ 2 | /* global $ */ 3 | 4 | $(() => { 5 | // Place JavaScript code here... 6 | }); 7 | -------------------------------------------------------------------------------- /controllers/home.js: -------------------------------------------------------------------------------- 1 | /** 2 | * GET / 3 | * Home page. 4 | */ 5 | exports.index = (req, res) => { 6 | res.render('home', { 7 | title: 'Home', 8 | siteURL: process.env.BASE_URL, 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | mongo: 4 | image: mongo 5 | web: 6 | build: . 7 | ports: 8 | - '8080:8080' 9 | environment: 10 | - MONGODB_URI=mongodb://mongo:27017/test 11 | links: 12 | - mongo 13 | depends_on: 14 | - mongo 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-slim 2 | 3 | WORKDIR /starter 4 | ENV NODE_ENV development 5 | 6 | COPY .env.example /starter/.env.example 7 | COPY . /starter 8 | 9 | RUN npm install -g pm2 && \ 10 | if [ "$NODE_ENV" = "production" ]; then \ 11 | npm install --omit=dev; \ 12 | else \ 13 | npm install; \ 14 | fi 15 | 16 | CMD ["pm2-runtime","app.js"] 17 | 18 | EXPOSE 8080 19 | -------------------------------------------------------------------------------- /test/fixtures/fixture_manifest.json: -------------------------------------------------------------------------------- 1 | [ 2 | "e2e-nokey/github-api.e2e.test.js", 3 | "e2e-nokey/pubchem.e2e.test.js", 4 | "e2e-nokey/scraping.e2e.test.js", 5 | "e2e-nokey/wikipedia.e2e.test.js", 6 | "e2e/chart.e2e.test.js", 7 | "e2e/foursquare.e2e.test.js", 8 | "e2e/nyt.e2e.test.js", 9 | "e2e/openai-moderation.e2e.test.js", 10 | "e2e/togetherai-classifier.e2e.test.js", 11 | "e2e/trakt.e2e.test.js", 12 | "e2e/tenor.e2e.test.js" 13 | ] 14 | -------------------------------------------------------------------------------- /views/partials/footer.pug: -------------------------------------------------------------------------------- 1 | footer.mt-auto.py-3.bg-light 2 | .container.d-flex.justify-content-between 3 | span © 2025 Company, Inc. All Rights Reserved 4 | ul.mb-0 5 | li.list-inline-item 6 | a(href='https://github.com/sahat/hackathon-starter') GitHub Project 7 | li.list-inline-item 8 | a(href='https://github.com/sahat/hackathon-starter/issues') Issues 9 | li.list-inline-item 10 | a(href='/privacy-policy.html') Privacy Policy 11 | li.list-inline-item 12 | a(href='/terms-of-use.html') Terms of Use 13 | -------------------------------------------------------------------------------- /models/Session.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const sessionSchema = new mongoose.Schema({ 4 | session: String, 5 | expires: Date, 6 | }); 7 | 8 | sessionSchema.statics = { 9 | /** 10 | * Removes all valid sessions for a given user 11 | * @param {string} userId 12 | * @returns {Promise} 13 | */ 14 | removeSessionByUserId(userId) { 15 | return this.deleteMany({ 16 | expires: { $gt: new Date() }, 17 | session: { $regex: userId }, 18 | }); 19 | }, 20 | }; 21 | 22 | const Session = mongoose.model('Session', sessionSchema); 23 | 24 | module.exports = Session; 25 | -------------------------------------------------------------------------------- /views/partials/flash.pug: -------------------------------------------------------------------------------- 1 | if messages.errors 2 | .alert.alert-danger.alert-dismissible(role='alert') 3 | each error in messages.errors 4 | div= error.msg 5 | button.btn-close(type='button', data-bs-dismiss='alert', aria-label='Close') 6 | 7 | if messages.info 8 | .alert.alert-primary.alert-dismissible(role='alert') 9 | each info in messages.info 10 | div= info.msg 11 | button.btn-close(type='button', data-bs-dismiss='alert', aria-label='Close') 12 | 13 | if messages.success 14 | .alert.alert-success.alert-dismissible(role='alert') 15 | each success in messages.success 16 | div= success.msg 17 | button.btn-close(type='button', data-bs-dismiss='alert', aria-label='Close') 18 | -------------------------------------------------------------------------------- /test/fixtures/GET_https%3A%2F%2Fen.wikipedia.org%2Fw%2Fapi.php%3Faction%3Dquery%26format%3Djson%26origin%3D_%26prop%3Dpageimages%257Cpageterms%26titles%3DNode.js%26pithumbsize%3D400.json: -------------------------------------------------------------------------------- 1 | { 2 | "batchcomplete": "", 3 | "query": { 4 | "pages": { 5 | "26415635": { 6 | "pageid": 26415635, 7 | "ns": 0, 8 | "title": "Node.js", 9 | "thumbnail": { "source": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d9/Node.js_logo.svg/500px-Node.js_logo.svg.png", "width": 400, "height": 245 }, 10 | "pageimage": "Node.js_logo.svg", 11 | "terms": { "alias": ["Node", "NodeJS"], "label": ["Node.js"], "description": ["JavaScript runtime environment"] } 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /views/account/forgot.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h3 Forgot Password 6 | form(method='POST') 7 | input(type='hidden', name='_csrf', value=_csrf) 8 | p.pb-4 Enter your email address below and we will send you password reset instructions. 9 | .form-group.row.mb-3 10 | label.col-md-3.col-form-label.font-weight-bold.text-right(for='email') Email 11 | .col-md-7 12 | input#email.form-control(type='email', name='email', placeholder='Email', autofocus, autocomplete='email', required) 13 | .form-group.row 14 | .col-md-3 15 | .col-md-7 16 | button.btn.btn-primary(type='submit') 17 | i.fas.fa-key.fa-sm.mr-2.me-2 18 | | Reset Password 19 | -------------------------------------------------------------------------------- /test/app-links.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const { getViewsChecks, checkList } = require('./tools/simple-link-image-check'); 3 | 4 | describe('app view links', function () { 5 | this.timeout(300000); 6 | 7 | it('has no broken links in pug views', async () => { 8 | const checks = getViewsChecks(); 9 | const deduped = checks; // already deduped by helper 10 | const { results, processed } = await checkList(deduped); 11 | if (results.length) { 12 | const lines = results.map((r) => `- ${r.url} (found in: ${r.sources.join(', ')}) => ${r.error || r.status}`).join('\n'); 13 | throw new Error(`Broken view links (${results.length} of ${processed}):\n${lines}`); 14 | } 15 | expect(results.length).to.equal(0); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /test/fixtures/POST_https%3A%2F%2Fapi.together.xyz%2Fv1%2Fchat%2Fcompletions_0774705f0cb5.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "oHE8xCb-4Yz4kd-996948b8ef03a34e", 3 | "object": "chat.completion", 4 | "created": 1761810231, 5 | "model": "meta-llama/Llama-3.3-70B-Instruct-Turbo-Free", 6 | "prompt": [], 7 | "choices": [ 8 | { 9 | "finish_reason": "stop", 10 | "seed": 1964835005694258200, 11 | "index": 0, 12 | "logprobs": null, 13 | "message": { 14 | "role": "assistant", 15 | "content": "{\n \"department\": \"Returns and Refunds\"\n}", 16 | "tool_calls": [] 17 | } 18 | } 19 | ], 20 | "usage": { 21 | "prompt_tokens": 195, 22 | "completion_tokens": 13, 23 | "total_tokens": 208, 24 | "cached_tokens": 0 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/docs-links.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const { getMarkdownChecks, checkList } = require('./tools/simple-link-image-check'); 3 | 4 | describe('docs links', function () { 5 | this.timeout(300000); 6 | 7 | it('has no broken links in markdown docs', async () => { 8 | const checks = getMarkdownChecks(); 9 | const deduped = checks; // already deduped by helper 10 | const { results, processed } = await checkList(deduped); 11 | if (results.length) { 12 | const lines = results.map((r) => `- ${r.url} (found in: ${r.sources.join(', ')}) => ${r.error || r.status}`).join('\n'); 13 | throw new Error(`Broken markdown links (${results.length} of ${processed}):\n${lines}`); 14 | } 15 | expect(results.length).to.equal(0); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /views/api/scraping.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.fab.fa-hacker-news.fa-sm.me-2(style='color: #ff6600') 7 | | Web Scraping 8 | 9 | .btn-group.mb-4.d-flex(role='group') 10 | a.btn.btn-primary.w-100(href='http://cheeriojs.github.io/cheerio/', target='_blank') 11 | i.fas.fa-info.fa-sm.me-2 12 | | Cheerio Docs 13 | a.btn.btn-primary.w-100(href='http://vimeo.com/31950192', target='_blank') 14 | i.fas.fa-film.fa-sm.me-2 15 | | Cheerio Screencast 16 | 17 | h3 Hacker News Frontpage 18 | table.table.table-condensed 19 | thead 20 | tr 21 | th № 22 | th Title 23 | tbody 24 | each link, index in links 25 | tr 26 | td= index + 1 27 | td!= link 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | day: 'thursday' 8 | time: '11:00' 9 | target-branch: 'master' 10 | open-pull-requests-limit: 10 11 | versioning-strategy: increase 12 | commit-message: 13 | prefix: 'chore' 14 | include: 'scope' 15 | groups: 16 | major-updates: 17 | update-types: ['major'] 18 | minor-updates: 19 | update-types: ['minor'] 20 | patch-updates: 21 | update-types: ['patch'] 22 | 23 | - package-ecosystem: 'github-actions' 24 | directory: '/' 25 | schedule: 26 | interval: 'monthly' 27 | target-branch: 'master' 28 | open-pull-requests-limit: 3 29 | commit-message: 30 | prefix: 'chore' 31 | include: 'scope' 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | 11 | pids 12 | logs 13 | results 14 | tmp 15 | 16 | # Optional npm cache directory 17 | .npm 18 | 19 | #Build 20 | public/css/main.css 21 | .nyc_output/* 22 | 23 | # API keys and secrets 24 | .env 25 | .env.example 26 | test/.env.test 27 | 28 | # Dependency directory 29 | node_modules 30 | bower_components 31 | 32 | # Uploads 33 | uploads 34 | 35 | # Ingestion folders 36 | rag_input 37 | 38 | # Editors 39 | .idea 40 | .vscode 41 | *.iml 42 | modules.xml 43 | *.ipr 44 | 45 | # Folder config file 46 | Desktop.ini 47 | 48 | # Recycle Bin used on file shares 49 | $RECYCLE.BIN/ 50 | 51 | # OS metadata 52 | .DS_Store 53 | Thumbs.db 54 | .DocumentRevisions-V100 55 | .fseventsd 56 | .Spotlight-V100 57 | .TemporaryItems 58 | .Trashes 59 | .VolumeIcon.icns 60 | .com.apple.timemachine.donotpresent 61 | -------------------------------------------------------------------------------- /views/api/google-drive.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.fab.fa-google-drive.fa-sm.me-2 7 | | Google Drive API 8 | 9 | .btn-group.d-flex(role='group') 10 | a.btn.btn-primary.w-100(href='https://developers.google.com/drive/api/v3/quickstart/nodejs', target='_blank') 11 | i.far.fa-check-square.fa-sm.me-2 12 | | Getting Started 13 | a.btn.btn-primary.w-100(href='https://console.developers.google.com/apis/dashboard', target='_blank') 14 | i.fas.fa-laptop.fa-sm.me-2 15 | | API Console 16 | 17 | br 18 | .pb-2.mt-2.mb-4.border-bottom 19 | h4 20 | | The list of files at the root of your Google Drive 21 | each file in files 22 | li 23 | img(src=file.iconLink) 24 | | 25 | | 26 | a(href=file.webViewLink, target='_blank') 27 | = file.name 28 | -------------------------------------------------------------------------------- /views/api/quickbooks.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.fas.fa-file-invoice-dollar.me-2 7 | | Quickbooks API 8 | 9 | .btn-group.mb-4.d-flex(role='group') 10 | a.btn.btn-primary.w-100(href='https://developer.intuit.com', target='_blank') 11 | i.far.fa-check-square.fa-sm.me-2 12 | | Intuit Developer Portal 13 | a.btn.btn-primary.w-100(href='https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/account', target='_blank') 14 | i.fas.fa-laptop.fa-sm.me-2 15 | | API Explorer 16 | 17 | .pb-2.mt-2.mb-4 18 | h3 Customers and Balances 19 | 20 | table.table.table-bordered.table-hover 21 | thead 22 | tr 23 | th Customer 24 | th Balance 25 | tbody 26 | each customer in customers 27 | tr 28 | td= customer.DisplayName 29 | td= customer.Balance 30 | -------------------------------------------------------------------------------- /patches/passport-oauth2+1.8.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/passport-oauth2/lib/strategy.js b/node_modules/passport-oauth2/lib/strategy.js 2 | index 8575b72..3cfd4d4 3 | --- a/node_modules/passport-oauth2/lib/strategy.js 4 | +++ b/node_modules/passport-oauth2/lib/strategy.js 5 | @@ -174,7 +174,10 @@ OAuth2Strategy.prototype.authenticate = function(req, options) { 6 | 7 | self._oauth2.getOAuthAccessToken(code, params, 8 | function(err, accessToken, refreshToken, params) { 9 | - if (err) { return self.error(self._createOAuthError('Failed to obtain access token', err)); } 10 | + if (err) { 11 | + console.warn("Failed to obtain access token: ", err); 12 | + return self.error(self._createOAuthError('Failed to obtain access token', err)); 13 | + } 14 | if (!accessToken) { return self.error(new Error('Failed to obtain access token')); } 15 | 16 | self._loadUserProfile(accessToken, function(err, profile) { 17 | -------------------------------------------------------------------------------- /views/account/reset.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h3 Reset Password 6 | form(method='POST') 7 | input(type='hidden', name='_csrf', value=_csrf) 8 | .form-group.row.mb-4 9 | label.col-md-3.col-form-label.font-weight-bold.text-right(for='password') New Password 10 | .col-md-7 11 | input#password.form-control(type='password', name='password', placeholder='New password', autofocus, autocomplete='new-password', minlength='8', required) 12 | .form-group.row.mb-4 13 | label.col-md-3.col-form-label.font-weight-bold.text-right(for='confirm') Confirm Password 14 | .col-md-7 15 | input#confirm.form-control(type='password', name='confirm', placeholder='Confirm password', autocomplete='new-password', minlength='8', required) 16 | .form-group.row 17 | .col-md-3 18 | .col-md-7 19 | button.btn.btn-primary.btn-reset(type='submit') 20 | i.far.fa-keyboard.fa-sm.mr-2.me-2 21 | | Change Password 22 | -------------------------------------------------------------------------------- /views/api/nyt.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.far.fa-building.fa-sm.me-2 7 | | New York Times API 8 | 9 | .btn-group.d-flex(role='group') 10 | a.btn.btn-primary.w-100(href='https://developer.nytimes.com/', target='_blank') 11 | i.far.fa-check-square.fa-sm.me-2 12 | | NYT Developer Network 13 | a.btn.btn-primary.w-100(href='https://developer.nytimes.com/apis', target='_blank') 14 | i.fas.fa-code-branch.fa-sm.me-2 15 | | API Endpoints 16 | 17 | h4 Young Adult Best Sellers 18 | table.table.table-striped.table-bordered 19 | thead 20 | tr 21 | th Rank 22 | th Title 23 | th.hidden-xs Description 24 | th Author 25 | th.hidden-xs ISBN-13 26 | tbody 27 | each book in books 28 | tr 29 | td= book.rank 30 | td= book.title 31 | td.hidden-xs= book.description 32 | td= book.author 33 | td.hidden-xs= book.primary_isbn13 34 | -------------------------------------------------------------------------------- /views/api/upload.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.fas.fa-upload.fa-sm.me-2 7 | | File Upload 8 | 9 | .btn-group.mb-4.d-flex(role='group') 10 | a.btn.btn-success.w-100(href='https://github.com/expressjs/multer', target='_blank') 11 | i.fas.fa-book.fa-sm.me-2 12 | | Multer Documentation 13 | a.btn.btn-success.w-100(href='http://codepen.io/search/pens?q=custom+file+upload&limit=all&type=type-pens', target='_blank') 14 | i.fas.fa-laptop.fa-sm.me-2 15 | | Customize File Upload 16 | 17 | h3 File Upload Form 18 | .row 19 | .col-md-6 20 | p All files will be uploaded to "/uploads" directory. 21 | form(role='form', enctype='multipart/form-data', method='POST') 22 | input(type='hidden', name='_csrf', value=_csrf) 23 | .form-group.mb-3 24 | label.col-form-label.font-weight-bold File Input 25 | .col-md-6 26 | input(type='file', name='myFile') 27 | button.btn.btn-primary(type='submit') Submit 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2025 Sahat Yalkabov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/tools/playwright-start-and-log.js: -------------------------------------------------------------------------------- 1 | // tools/start-and-log.js 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | const { spawn } = require('child_process'); 5 | 6 | const logPath = path.resolve(__dirname, '../..', 'tmp', 'playwright-webserver.log'); 7 | const out = fs.createWriteStream(logPath, { flags: 'w' }); 8 | 9 | // Spawn the real server using the same node executable 10 | const child = spawn(process.execPath, [path.join(__dirname, 'start-with-memory-db.js')], { 11 | stdio: ['ignore', 'pipe', 'pipe'], 12 | env: process.env, 13 | }); 14 | 15 | // Pipe both stdout and stderr to console and to the file 16 | child.stdout.on('data', (chunk) => { 17 | process.stdout.write(chunk); 18 | out.write(chunk); 19 | }); 20 | child.stderr.on('data', (chunk) => { 21 | process.stderr.write(chunk); 22 | out.write(chunk); 23 | }); 24 | 25 | // Forward exit code when child exits 26 | child.on('close', (code) => { 27 | out.end(); 28 | process.exit(code); 29 | }); 30 | 31 | // Ensure parent dies if child dies unexpectedly 32 | child.on('error', (err) => { 33 | console.error('Failed to start child process:', err); 34 | out.end(); 35 | process.exit(1); 36 | }); 37 | -------------------------------------------------------------------------------- /views/api/facebook.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.fab.fa-square-facebook.fa-sm.me-2(style='color: #335397') 7 | | Facebook API 8 | .btn-group.d-flex(role='group') 9 | a.btn.btn-primary.w-100(href='https://developers.facebook.com/docs/graph-api/quickstart/', target='_blank') 10 | i.far.fa-check-square.fa-sm.me-2 11 | | Quickstart 12 | a.btn.btn-primary.w-100(href='https://developers.facebook.com/tools/explorer', target='_blank') 13 | i.fab.fa-facebook.fa-sm.me-2 14 | | Graph API Explorer 15 | a.btn.btn-primary.w-100(href='https://developers.facebook.com/docs/graph-api/reference/', target='_blank') 16 | i.fas.fa-code-branch.fa-sm.me-2 17 | | API Reference 18 | 19 | h3 20 | i.far.fa-user.fa-sm 21 | | 22 | | My Profile 23 | img.thumbnail(src=`https://graph.facebook.com/${profile.id}/picture?type=large`, width='90', height='90') 24 | h4= profile.name 25 | h6 First Name: #{ profile.first_name } 26 | h6 Last Name: #{ profile.last_name } 27 | h6 Gender: #{ profile.gender } 28 | h6 Username: #{ profile.username } 29 | h6 Link: #{ profile.link } 30 | h6 Email: #{ profile.email } 31 | h6 Locale: #{ profile.locale } 32 | h6 Timezone: #{ profile.timezone } 33 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Checklist 4 | 5 | - [ ] I acknowledge that submissions that include copy-paste of AI-generated content taken at face value (PR text, code, commit message, documentation, etc.) most likely have errors and hence will be rejected entirely and marked as spam or invalid 6 | - [ ] I manually tested the change with a running instance, DB, and valid API keys where applicable 7 | - [ ] Added/updated tests if the existing tests do not cover this change 8 | - [ ] README or other relevant docs are updated 9 | - [ ] `--no-verify` was not used for the commit(s) 10 | - [ ] `npm run lint` passed locally without any errors 11 | - [ ] `npm test` passed locally without any errors 12 | - [ ] `npm run test:e2e:replay` passed locally without any errors 13 | - [ ] `npm run test:e2e:custom -- --project=chromium-nokey-live` passed locally without any errors 14 | - [ ] PR diff does not include unrelated changes 15 | - [ ] PR title follows Conventional Commits — https://www.conventionalcommits.org/en 16 | 17 | ## Description 18 | 19 | 20 | 21 | 22 | 23 | ## Screenshots of UI changes (browser) and logs/test results (console, terminal, shell, cmd) 24 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | latest | :white_check_mark: | 8 | | master | :white_check_mark: | 9 | | other | :x: | 10 | 11 | ## Reporting a Vulnerability 12 | 13 | PRIOR TO SUBMITTING SECURITY CONCERNS/REPORTS: 14 | 15 | 1. Research Wikipedia and other sources about hackathons to become more familiar with the potential uses of this project, the intended settings, and usage environments. 16 | 2. Read README.md entirely, including the introduction and the steps for obtaining API keys, which includes replacing the .env values. The provided values in the .env file are placeholders, not a batch of keys exposed through GitHub. 17 | 3. Read PROD_CHECKLIST.md. Hackathon projects are not production projects, and this checklist is to help users with their next steps to move from a prototype state to a production state. 18 | 19 | SUBMITTING SECURITY CONCERNS/REPORTS: 20 | 21 | 1. Complete the above steps 1 to 3. 22 | 2. If you still believe you have identified an issue, please submit it as a GitHub Issue at https://github.com/sahat/hackathon-starter/issues with the relevant information for discussion and clarification. 23 | Submissions requiring registration with third-party websites will be marked/reported as spam. 24 | -------------------------------------------------------------------------------- /views/partials/header.pug: -------------------------------------------------------------------------------- 1 | nav.navbar.navbar-expand-lg.navbar-dark.bg-dark 2 | .container 3 | a.navbar-brand(href='/') 4 | img.d-inline-block.align-text-top(src='/bootstrap-logo.svg', alt='', width='30', height='24') 5 | | 6 | | Hackathon Starter 7 | button.navbar-toggler(type='button', data-bs-toggle='collapse', data-bs-target='#navbarColor01', aria-controls='navbarColor01', aria-expanded='false', aria-label='Toggle navigation') 8 | span.navbar-toggler-icon 9 | #navbarColor01.collapse.navbar-collapse 10 | ul.navbar-nav.me-auto.mb-2.mb-lg-0 11 | li.nav-item 12 | a.nav-link(class=title === 'Home' ? 'active' : '', href='/') Home 13 | li.nav-item 14 | a.nav-link(class=title === 'AI Examples' ? 'active' : '', href='/ai') AI Examples 15 | li.nav-item 16 | a.nav-link(class=title === 'API Examples' ? 'active' : '', href='/api') API Examples 17 | li.nav-item 18 | a.nav-link(class=title === 'Contact' ? 'active' : '', href='/contact') Contact 19 | 20 | form.d-flex 21 | if !user 22 | a.btn.btn-outline-light.me-2(href='/login') Login 23 | a.btn.btn-primary(href='/signup') Create Account 24 | else 25 | a.btn.btn-primary.me-2(href='/account') My Account 26 | a.btn.btn-outline-danger(href='/logout') Sign out 27 | -------------------------------------------------------------------------------- /views/api/paypal.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.fab.fa-paypal.fa-sm.me-2(style='color: #1b4a7d') 7 | | PayPal API 8 | .btn-group.d-flex(role='group') 9 | a.btn.btn-primary.w-100(href='https://developer.paypal.com/docs/checkout/', target='_blank') 10 | i.far.fa-check-square.fa-sm.me-2 11 | | Checkout API 12 | a.btn.btn-primary.w-100(href='https://developer.paypal.com/docs/api/orders/v2/', target='_blank') 13 | i.fas.fa-code.fa-sm.me-2 14 | | Payments API 15 | h3 Sample Payment 16 | if result 17 | if canceled 18 | h4 Payment was canceled! 19 | if success 20 | h4 Payment was executed successfully! 21 | else 22 | h4 There was an error processing the payment. 23 | a(href='/api/paypal') 24 | button.btn.btn-primary New Payment 25 | else 26 | div 27 | p Redirects to PayPal and allows authorizing the sample payment. 28 | h4 Purchase Details: 29 | ul 30 | li 31 | strong Description: 32 | | #{ purchaseInfo.description } 33 | li 34 | strong Currency: 35 | | #{ purchaseInfo.amount.currency_code } 36 | li 37 | strong Amount: 38 | | #{ purchaseInfo.amount.value } 39 | a(href=approvalUrl) 40 | button.btn.btn-primary Authorize Payment 41 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Save the list of currently staged files 4 | STAGED_FILES=$(git diff --cached --name-only) 5 | 6 | # Check for staged files with unstaged modifications 7 | MODIFIED_FILES=$(git diff --name-only) 8 | 9 | # Find files that overlap between staged and modified without using process substitution 10 | CONFLICTING_FILES="" 11 | for file in $STAGED_FILES; do 12 | if echo "$MODIFIED_FILES" | grep -qx "$file"; then 13 | CONFLICTING_FILES="$CONFLICTING_FILES$file\n" 14 | fi 15 | done 16 | 17 | # Abort if there are conflicts 18 | if [ -n "$CONFLICTING_FILES" ]; then 19 | echo "Error: The following staged files have unstaged modifications, which can cause issues with the pre-commit eslint fix and prettier rewrite execution:" 20 | echo -e "$CONFLICTING_FILES" # Use -e for newline interpretation in echo 21 | echo "Please stage the changes or reset them before committing." 22 | echo "If this is a temporary local commit, you can also use the --no-verify flag to bypass the pre-commit test and linting. i.e. 'git commit --no-verify'" 23 | exit 1 # Abort commit 24 | fi 25 | 26 | # Run tests and linting 27 | npm test 28 | npm run lint 29 | 30 | # Re-stage files after lint fixes (only staged files) 31 | # Use a portable alternative for xargs 32 | echo "$STAGED_FILES" | while IFS= read -r file; do 33 | if [ -f "$file" ]; then 34 | git add "$file" 35 | fi 36 | done 37 | -------------------------------------------------------------------------------- /views/api/lastfm.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.far.fa-play-circle.fa-sm.me-2(style='color: #db1302') 7 | | Last.fm API 8 | .btn-group.d-flex(role='group') 9 | a.btn.btn-primary.w-100(href='https://github.com/jammus/lastfm-node#lastfm-node', target='_blank') 10 | i.far.fa-check-square.fa-sm.me-2 11 | | Last.fm Node Docs 12 | a.btn.btn-primary.w-100(href='http://www.last.fm/api/account/create', target='_blank') 13 | i.fas.fa-laptop.fa-sm.me-2 14 | | Create API Account 15 | a.btn.btn-primary.w-100(href='http://www.last.fm/api', target='_blank') 16 | i.fas.fa-code-branch.fa-sm.me-2 17 | | API Endpoints 18 | 19 | h3= artist.name 20 | h4 Top Albums 21 | each album in artist.topAlbums 22 | img(src='' + album.image.slice(-1)[0]['#text'], width=240, height=240) 23 | |   24 | 25 | h4 Tags 26 | each tag in artist.tags 27 | span.label.label-primary 28 | i.fas.fa-tag.fa-sm.me-2 29 | | #{ tag.name } | 30 | |   31 | p 32 | 33 | h4 Biography 34 | if artist.bio 35 | p!= artist.bio 36 | else 37 | p No biography 38 | 39 | h4 Top Tracks 40 | ol 41 | each track in artist.topTracks 42 | li 43 | a(href='' + track.url) #{ track.name } 44 | 45 | h4 Similar Artists 46 | ul.list-unstyled.list-inline 47 | each similarArtist in artist.similar 48 | li 49 | a(href='' + similarArtist.url) #{ similarArtist.name } 50 | -------------------------------------------------------------------------------- /views/api/google-sheets.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.fas.fa-table.fa-sm.me-2(style='color: #1b4a7d') 7 | | Google Sheets API 8 | h3 9 | | API References 10 | .btn-group.d-flex(role='group') 11 | a.btn.btn-primary.w-100(href='https://github.com/googleapis/google-api-nodejs-client#google-apis-nodejs-client', target='_blank') 12 | i.far.fa-check-square.fa-sm.me-2 13 | | Getting Started 14 | a.btn.btn-primary.w-100(href='https://console.developers.google.com/apis/dashboard', target='_blank') 15 | i.fas.fa-laptop.fa-sm.me-2 16 | | API Console 17 | a.btn.btn-primary.w-100(href='https://www.freecodecamp.org/news/cjn-google-sheets-as-json-endpoint', target='_blank') 18 | i.fas.fa-book.fa-sm.me-2 19 | | Exposing your Google Sheets 20 | 21 | br 22 | h3 23 | | Examples 24 | p 25 | | View data from a Google Spreadsheet at 26 | | 27 | a(href='https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit#gid=0', target='_blank') 28 | | URL 29 | p 30 | br 31 | .pb-2.mt-2.mb-4.border-bottom 32 | h4 33 | | Values in Google Sheets 34 | 35 | if values.length 36 | table(style='width: 100%', border='1') 37 | tr 38 | each row, i in values 39 | tr 40 | each cell, j in row 41 | if i == 0 42 | td(style='font-weight: bold')= cell 43 | else 44 | td= cell 45 | -------------------------------------------------------------------------------- /views/api/tenor.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | h2 5 | i.fas.fa-images.fa-sm.me-2 6 | | Tenor API 7 | 8 | .btn-group.d-flex(role='group') 9 | a.btn.btn-primary.w-100(href='https://developers.google.com/tenor/guides/quickstart', target='_blank') 10 | i.far.fa-check-square.fa-sm.me-2 11 | | Tenor API Getting Started 12 | a.btn.btn-primary.w-100(href='https://console.developers.google.com/apis/dashboard', target='_blank') 13 | i.fas.fa-laptop.fa-sm.me-2 14 | | Google API Console 15 | br 16 | 17 | .card.text-white.bg-info.mb-4 18 | .card-header 19 | h6.panel-title.mb-0 Search Tenor GIFs 20 | .card-body.text-dark.bg-white 21 | form(role='form', method='GET', action='/api/tenor') 22 | .form-group.mb-3 23 | label.col-form-label.font-weight-bold Search Term 24 | input.form-control(type='text', name='search', placeholder='Search for GIFs', value=search || '', required) 25 | button.btn.btn-primary.mt-2(type='submit') 26 | i.fas.fa-search.me-2 27 | | Search 28 | 29 | if gifs.length 30 | .card.text-white.bg-success.mb-4 31 | .card-header 32 | h6.panel-title.mb-0 Results for "#{ search }" 33 | .card-body.text-dark.bg-white 34 | .row 35 | each gif in gifs 36 | .col-md-4 37 | .card.mb-4 38 | img.card-img-top.img-fluid.rounded(style='width: 100%; height: 300px; object-fit: cover', src=gif.url, alt=gif.title) 39 | else 40 | .alert.alert-warning.mt-3 No GIFs found for "#{ search }". 41 | -------------------------------------------------------------------------------- /patches/passport-openidconnect+0.1.2.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/passport-openidconnect/lib/profile.js b/node_modules/passport-openidconnect/lib/profile.js 2 | index eeabf4e..87b917a 100644 3 | --- a/node_modules/passport-openidconnect/lib/profile.js 4 | +++ b/node_modules/passport-openidconnect/lib/profile.js 5 | @@ -16,7 +16,21 @@ exports.parse = function(json) { 6 | if (json.given_name) { profile.name.givenName = json.given_name; } 7 | if (json.middle_name) { profile.name.middleName = json.middle_name; } 8 | } 9 | + if (json.nickname) { profile.nickname = json.nickname; }; 10 | + if (json.preferred_username) { profile.preferred_username = json.preferred_username; }; 11 | + if (json.profile) { profile.profile = json.profile; }; 12 | + if (json.picture) { profile.photos = json.picture; } 13 | + if (json.picture) { profile.photos = json.picture; } 14 | if (json.email) { profile.emails = [ { value: json.email } ]; } 15 | + if (json.email_verified) { profile.email_verified = json.email_verified; } 16 | + if (json.gender) { profile.gender = json.gender; }; 17 | + if (json.birthdate) { profile.birthdate = json.birthdate; }; 18 | + if (json.zoneinfo) { profile.zoneinfo = json.zoneinfo; }; 19 | + if (json.locale) { profile.locale = json.locale; } 20 | + if (json.phone_number) { profile.phone_number = json.phone_number; }; 21 | + if (json.phone_number_verified) { profile.phone_number_verified = json.phone_number_verified; }; 22 | + if (json.address) { profile.address = json.address; }; 23 | + if (json.updated_at) { profile.updated_at = json.updated_at; }; 24 | 25 | return profile; 26 | }; 27 | -------------------------------------------------------------------------------- /config/flash.js: -------------------------------------------------------------------------------- 1 | const { format } = require('util'); 2 | 3 | // Flash Middleware as a replacement for express-flash / connect-flash 4 | // Those packages are unmaintained and have some issues. This is a simple 5 | // implementation that provides the same functionality. 6 | exports.flash = (req, res, next) => { 7 | if (req.flash) return next(); 8 | req.flash = (type, message, ...args) => { 9 | const flashMessages = (req.session.flash ||= {}); 10 | if (!type) { 11 | req.session.flash = {}; 12 | return { ...flashMessages }; 13 | } 14 | if (!message) { 15 | const retrieved = flashMessages[type] || []; 16 | delete flashMessages[type]; 17 | return retrieved; 18 | } 19 | const arr = (flashMessages[type] ||= []); 20 | if (args.length) arr.push(format(message, ...args)); 21 | else if (Array.isArray(message)) { 22 | arr.push(...message); 23 | return arr.length; 24 | } else arr.push(message); 25 | return arr; 26 | }; 27 | res.render = ((r) => 28 | function (...args) { 29 | // Retrieve and clear all flash messages for this render 30 | const raw = req.flash(); 31 | 32 | // Normalize to arrays of { msg } objects to match express-flash contract 33 | const messages = {}; 34 | for (const [type, list] of Object.entries(raw)) { 35 | const arr = Array.isArray(list) ? list : [list]; 36 | messages[type] = arr.map((item) => (item && typeof item === 'object' && 'msg' in item ? item : { msg: String(item) })); 37 | } 38 | 39 | res.locals.messages = messages; 40 | return r.apply(this, args); 41 | })(res.render); 42 | next(); 43 | }; 44 | -------------------------------------------------------------------------------- /test/fixtures/GET_https%3A%2F%2Fapi.trakt.tv%2Fmovies%2Fa-house-of-dynamite-2025%3Fextended%3Dfull%252Cimages.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "A House of Dynamite", 3 | "year": 2025, 4 | "ids": { "trakt": 1049823, "slug": "a-house-of-dynamite-2025", "imdb": "tt32376165", "tmdb": 1290159 }, 5 | "tagline": "Not if. When.", 6 | "overview": "When a single, unattributed missile is launched at the United States, a race begins to determine who is responsible and how to respond.", 7 | "released": "2025-10-10", 8 | "runtime": 112, 9 | "country": "us", 10 | "trailer": "https://youtube.com/watch?v=_wpw2QHJNco", 11 | "homepage": "http://www.ahouseofdynamitefilm.com", 12 | "status": "released", 13 | "rating": 6.62599, 14 | "votes": 2139, 15 | "comment_count": 66, 16 | "updated_at": "2025-10-29T17:16:52.000Z", 17 | "language": "en", 18 | "languages": ["en"], 19 | "available_translations": ["ar", "bg", "ca", "cs", "da", "de", "el", "en", "es", "fi", "fr", "he", "hi", "hr", "hu", "id", "it", "ja", "ko", "ms", "nl", "no", "pl", "pt", "ro", "ru", "sk", "sl", "sv", "th", "tl", "tr", "uk", "vi", "zh"], 20 | "genres": ["thriller", "war"], 21 | "subgenres": ["political-thriller"], 22 | "certification": "R", 23 | "original_title": "A House of Dynamite", 24 | "after_credits": false, 25 | "during_credits": false, 26 | "images": { 27 | "fanart": ["walter-r2.trakt.tv/images/movies/001/049/823/fanarts/medium/56530050e3.jpg.webp"], 28 | "poster": ["walter-r2.trakt.tv/images/movies/001/049/823/posters/thumb/1ee93ddb66.jpg.webp"], 29 | "logo": ["walter-r2.trakt.tv/images/movies/001/049/823/logos/medium/3b2fd9c5a0.png.webp"], 30 | "clearart": [], 31 | "banner": [], 32 | "thumb": [] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/fixtures/GET_https%3A%2F%2Fen.wikipedia.org%2Fw%2Fapi.php%3Faction%3Dquery%26format%3Djson%26origin%3D_%26prop%3Dextracts%26explaintext%3D1%26titles%3DNode.js%26exintro%3D1.json: -------------------------------------------------------------------------------- 1 | { 2 | "batchcomplete": "", 3 | "query": { 4 | "pages": { 5 | "26415635": { 6 | "pageid": 26415635, 7 | "ns": 0, 8 | "title": "Node.js", 9 | "extract": "Node.js is a cross-platform, open-source JavaScript runtime environment that can run on Windows, Linux, Unix, macOS, and more. Node.js runs on the V8 JavaScript engine, and executes JavaScript code outside a web browser. According to the Stack Overflow Developer Survey, Node.js is one of the most commonly used web technologies.\nNode.js lets developers use JavaScript to write command line tools and server-side scripting. The ability to run JavaScript code on the server is often used to generate dynamic web page content before the page is sent to the user's web browser. Consequently, Node.js represents a \"JavaScript everywhere\" paradigm, unifying web-application development around a single programming language, as opposed to using different languages for the server- versus client-side programming.\nNode.js has an event-driven architecture capable of asynchronous I/O. These design choices aim to optimize throughput and scalability in web applications with many input/output operations, as well as for real-time Web applications (e.g., real-time communication programs and browser games).\nThe Node.js distributed development project was previously governed by the Node.js Foundation, and has now merged with the JS Foundation to form the OpenJS Foundation. OpenJS Foundation is facilitated by the Linux Foundation's Collaborative Projects program." 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /config/nodemailer.js: -------------------------------------------------------------------------------- 1 | const nodemailer = require('nodemailer'); 2 | 3 | /** 4 | * Helper Function to Send Mail. 5 | */ 6 | exports.sendMail = (settings) => { 7 | const transportConfig = { 8 | host: process.env.SMTP_HOST, 9 | port: 465, 10 | secure: true, 11 | auth: { 12 | user: process.env.SMTP_USER, 13 | pass: process.env.SMTP_PASSWORD, 14 | }, 15 | }; 16 | 17 | let transporter = nodemailer.createTransport(transportConfig); 18 | 19 | return transporter 20 | .sendMail(settings.mailOptions) 21 | .then(() => { 22 | settings.req.flash(settings.successfulType, { msg: settings.successfulMsg }); 23 | }) 24 | .catch((err) => { 25 | if (err.message === 'self signed certificate in certificate chain') { 26 | console.log('WARNING: Self signed certificate in certificate chain. Retrying with the self signed certificate. Use a valid certificate if in production.'); 27 | transportConfig.tls = transportConfig.tls || {}; 28 | transportConfig.tls.rejectUnauthorized = false; 29 | transporter = nodemailer.createTransport(transportConfig); 30 | return transporter 31 | .sendMail(settings.mailOptions) 32 | .then(() => { 33 | settings.req.flash(settings.successfulType, { msg: settings.successfulMsg }); 34 | }) 35 | .catch((retryErr) => { 36 | console.log(settings.loggingError, retryErr); 37 | settings.req.flash(settings.errorType, { msg: settings.errorMsg }); 38 | return retryErr; 39 | }); 40 | } 41 | console.log(settings.loggingError, err); 42 | settings.req.flash(settings.errorType, { msg: settings.errorMsg }); 43 | return err; 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /test/e2e-nokey/scraping.e2e.test.js: -------------------------------------------------------------------------------- 1 | process.env.API_TEST_FILE = 'e2e-nokey/scraping.e2e.test.js'; 2 | const { test, expect } = require('@playwright/test'); 3 | const { registerTestInManifest, isInManifest } = require('../tools/fixture-helpers'); 4 | 5 | // Self-register this test in the manifest when recording 6 | registerTestInManifest('e2e-nokey/scraping.e2e.test.js'); 7 | 8 | // Skip this file during replay if it's not in the manifest 9 | if (process.env.API_MODE === 'replay' && !isInManifest('e2e-nokey/scraping.e2e.test.js')) { 10 | console.log('[fixtures] skipping e2e-nokey/scraping.e2e.test.js as it is not in manifest for replay mode - 1 test'); 11 | test.skip(true, 'Not in manifest for replay mode'); 12 | } 13 | 14 | test.describe('Web Scraping Integration', () => { 15 | test('should display scraped Hacker News links with proper page structure', async ({ page }) => { 16 | await page.goto('/api/scraping'); 17 | await page.waitForLoadState('networkidle'); 18 | 19 | // Verify page basics 20 | await expect(page).toHaveTitle(/Web Scraping/); 21 | await expect(page.locator('h2')).toContainText('Web Scraping'); 22 | await expect(page.locator('h3')).toContainText('Hacker News Frontpage'); 23 | 24 | // Verify table exists with headers 25 | const table = page.locator('table.table.table-condensed'); 26 | await expect(table).toBeVisible(); 27 | await expect(page.locator('thead tr th').nth(0)).toContainText('№'); 28 | await expect(page.locator('thead tr th').nth(1)).toContainText('Title'); 29 | 30 | // Verify scraped data 31 | const tableRows = page.locator('tbody tr'); 32 | const rowCount = await tableRows.count(); 33 | expect(rowCount).toBeGreaterThan(25); // usually the list is ~30 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /views/api/steam.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.fab.fa-square-steam.fa-sm.me-2 7 | | Steam Web API 8 | .btn-group.d-flex(role='group') 9 | a.btn.btn-primary.w-100(href='https://developer.valvesoftware.com/wiki/Steam_Web_API', target='_blank') 10 | i.far.fa-check-square.fa-sm.me-2 11 | | API Overview 12 | 13 | br 14 | 15 | .alert.alert-info 16 | h4 Steam ID 17 | p Displaying public information for Steam ID: #{ playerSummary.steamid }. 18 | 19 | h3 Profile Information 20 | .row 21 | .col-sm-2 22 | img(src=playerSummary.avatarfull, width='92', height='92') 23 | .col-sm-8 24 | span.lead #{ playerSummary.personaname } 25 | div Account since: #{ new Date(playerSummary.timecreated * 1000) } 26 | div Last Online: #{ new Date(playerSummary.lastlogoff * 1000) } 27 | div Online Status: 28 | if playerSummary.personastate === 1 29 | strong.text-success Online 30 | else 31 | strong.text-danger Offline 32 | 33 | if playerAchievements 34 | h3 #{ playerAchievements.gameName } Achievements 35 | ul.lead.list-unstyled 36 | each achievement in playerAchievements.achievements 37 | if achievement.achieved 38 | li.text-success= achievement.name 39 | else 40 | span.lead No player achievements, or the player achievements are not public 41 | 42 | if ownedGames.games 43 | h3 Owned Games 44 | span.lead #{ ownedGames.game_count } games 45 | br 46 | each game in ownedGames.games 47 | a(href='https://store.steampowered.com/app/' + game.appid) 48 | img.thumbnail(src='https://cdn.cloudflare.steamstatic.com/steam/apps/' + game.appid + '/header.jpg', width=92) 49 | -------------------------------------------------------------------------------- /test/app.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const { MongoMemoryServer } = require('mongodb-memory-server'); 3 | 4 | let mongoServer; 5 | let app; 6 | 7 | before(async () => { 8 | mongoServer = await MongoMemoryServer.create(); 9 | const mockMongoDBUri = await mongoServer.getUri(); 10 | process.env.MONGODB_URI = mockMongoDBUri; 11 | // If we require the app at the beginning of this file 12 | // it will try to connect to the database before the 13 | // MongoMemoryServer is started which can cause the testes to fail 14 | // Hence we are making an exception for linting this require statement 15 | /* eslint-disable global-require */ 16 | app = require('../app'); 17 | }); 18 | 19 | after(async () => { 20 | if (mongoServer) { 21 | await mongoServer.stop(); 22 | } 23 | }); 24 | 25 | describe('GET /', () => { 26 | it('should return 200 OK', (done) => { 27 | request(app).get('/').expect(200, done); 28 | }); 29 | }); 30 | 31 | describe('GET /login', () => { 32 | it('should return 200 OK', (done) => { 33 | request(app).get('/login').expect(200, done); 34 | }); 35 | }); 36 | 37 | describe('GET /signup', () => { 38 | it('should return 200 OK', (done) => { 39 | request(app).get('/signup').expect(200, done); 40 | }); 41 | }); 42 | 43 | describe('GET /forgot', () => { 44 | it('should return 200 OK', (done) => { 45 | request(app).get('/forgot').expect(200, done); 46 | }); 47 | }); 48 | 49 | describe('GET /api', () => { 50 | it('should return 200 OK', (done) => { 51 | request(app).get('/api').expect(200, done); 52 | }); 53 | }); 54 | 55 | describe('GET /contact', () => { 56 | it('should return 200 OK', (done) => { 57 | request(app).get('/contact').expect(200, done); 58 | }); 59 | }); 60 | 61 | describe('GET /random-url', () => { 62 | it('should return 404', (done) => { 63 | request(app).get('/reset').expect(404, done); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: ['master'] 6 | pull_request: 7 | branches: ['master'] 8 | 9 | permissions: 10 | contents: read 11 | pull-requests: read 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | build: 19 | runs-on: ${{ matrix.os }} 20 | env: 21 | RUN_E2E: ${{ vars.RUN_E2E }} # from repository settings -> Actions -> Variables 22 | strategy: 23 | matrix: 24 | node-version: [24.x] 25 | os: [ubuntu-latest, windows-latest] 26 | steps: 27 | - uses: actions/checkout@v6 28 | - name: Use Node.js ${{ matrix.node-version }} 29 | uses: actions/setup-node@v6 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | cache: 'npm' 33 | - run: npm install 34 | - run: npm run lint-check 35 | - run: npm run test 36 | 37 | # For testing in Windows CI, we need to limit the path to exclude the additional executables 38 | # that the default github runner has, but are not on a vanilla Windows OS installation. 39 | - if: ${{ (env.RUN_E2E == 'true' || github.repository == 'sahat/hackathon-starter') && matrix.os == 'windows-latest' }} 40 | env: 41 | PATH: 'C:\Windows\System32;C:\Windows' 42 | run: npm run test:e2e:replay 43 | 44 | # if not Windows, run normally 45 | - if: ${{ (env.RUN_E2E == 'true' || github.repository == 'sahat/hackathon-starter') && matrix.os != 'windows-latest' }} 46 | run: npm run test:e2e:replay 47 | 48 | - name: Upload tmp as an artifact (Playwrite artifacts, code coverage report, etc) 49 | if: always() 50 | uses: actions/upload-artifact@v5 51 | with: 52 | name: tmp-artifacts-${{ matrix.os }}-${{ github.job }}-${{ github.run_id }} 53 | path: tmp/** 54 | -------------------------------------------------------------------------------- /views/api/tumblr.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.fab.fa-tumblr-square.fa-sm.me-2 7 | | Tumblr API 8 | 9 | .btn-group.mb-4.d-flex(role='group') 10 | a.btn.btn-primary.w-100(href='https://www.tumblr.com/docs/en/api/v2', target='_blank') 11 | i.far.fa-check-square.fa-sm.me-2 12 | | API Docs 13 | 14 | .card.text-white.bg-success.mb-4 15 | .card-header 16 | h6.panel-title.mb-0 Your Profile 17 | .card-body.text-dark.bg-white 18 | .row 19 | .col-8 20 | h5 Name: #{ userInfo.name } 21 | ul.list-inline 22 | li.list-inline-item 23 | i.fas.fa-users.fa-sm.me-2 24 | | Following: #{ userInfo.following } 25 | li.list-inline-item 26 | i.far.fa-heart.fa-sm.me-2 27 | | Likes: #{ userInfo.likes } 28 | 29 | .card.text-white.bg-primary.mb-4 30 | .card-header 31 | h6.panel-title.mb-0 Blog Lookup Example 32 | .card-body.text-dark.bg-white 33 | .row 34 | .col-8 35 | if blog.avatar && blog.avatar.length > 0 36 | img(src=blog.avatar[0].url, alt='Avatar', width=50, height=50, style='float: left; margin-right: 10px') 37 | h4 38 | a(href=blog.url, target='_blank') #{ blog.name } 39 | h6 #{ blog.title } | #{ blog.description } 40 | br 41 | ul.list-inline 42 | li.list-inline-item 43 | i.far.fa-heart.fa-sm.me-2 44 | | Likes: #{ blog.likes } 45 | li.list-inline-item 46 | i.fas.fa-file-alt.fa-sm.me-2 47 | | Posts: #{ blog.posts } 48 | li.list-inline-item 49 | | Latest Post: #{ new Date(blog.updated * 1000).toLocaleString() } 50 | h4 Latest Photo Post 51 | each photo in photoset 52 | img.item(src=photo.original_size.url) 53 | -------------------------------------------------------------------------------- /views/contact.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block head 4 | if sitekey 5 | script(src='https://www.google.com/recaptcha/enterprise.js', async='', defer='') 6 | 7 | block content 8 | .pb-2.mt-2.mb-4.border-bottom 9 | h3 Contact Form 10 | 11 | form#contactForm(method='POST') 12 | input(type='hidden', name='_csrf', value=_csrf) 13 | if unknownUser 14 | .form-group.row.mb-3 15 | label.col-md-2.col-form-label.font-weight-bold(for='name') Name 16 | .col-md-8 17 | input#name.form-control(type='text', name='name', autocomplete='name', autofocus, required) 18 | .form-group.row.mb-3 19 | label.col-md-2.col-form-label.font-weight-bold(for='email') Email 20 | .col-md-8 21 | input#email.form-control(type='email', name='email', autocomplete='email', required) 22 | .form-group.row.mb-3 23 | label.col-md-2.col-form-label.font-weight-bold(for='message') Please describe the issue or your suggestion 24 | .col-md-8 25 | textarea#message.form-control(name='message', rows='7', autofocus=(!unknownUser).toString(), required) 26 | .form-group 27 | .offset-md-2.col-md-8.p-1 28 | if sitekey 29 | #recaptchaWidget.g-recaptcha(data-sitekey=sitekey) 30 | span#recaptchaError.text-danger.d-none.mt-2 Please complete the reCAPTCHA before submitting the form. 31 | br 32 | button#submitBtn.col-md-2.btn.btn-primary(type='submit') 33 | i.far.fa-envelope.fa-sm.me-2 34 | | Send 35 | script. 36 | document.getElementById('contactForm').addEventListener('submit', function (event) { 37 | const recaptchaError = document.getElementById('recaptchaError'); 38 | if (typeof grecaptcha !== 'undefined' && !grecaptcha.getResponse()) { 39 | event.preventDefault(); // Prevent form submission 40 | recaptchaError.classList.remove('d-none'); 41 | } else { 42 | recaptchaError.classList.add('d-none'); 43 | } 44 | }); 45 | -------------------------------------------------------------------------------- /test/fixtures/POST_https%3A%2F%2Fapi.openai.com%2Fv1%2Fmoderations_c6b4d54f3bd4.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "modr-8183", 3 | "model": "omni-moderation-latest", 4 | "results": [ 5 | { 6 | "flagged": true, 7 | "categories": { 8 | "harassment": false, 9 | "harassment/threatening": false, 10 | "sexual": false, 11 | "hate": false, 12 | "hate/threatening": false, 13 | "illicit": false, 14 | "illicit/violent": false, 15 | "self-harm/intent": false, 16 | "self-harm/instructions": false, 17 | "self-harm": false, 18 | "sexual/minors": false, 19 | "violence": true, 20 | "violence/graphic": false 21 | }, 22 | "category_scores": { 23 | "harassment": 0.2008409697358052, 24 | "harassment/threatening": 0.39794961186589634, 25 | "sexual": 0.00006667023092435894, 26 | "hate": 0.0450860244974903, 27 | "hate/threatening": 0.02949549237556915, 28 | "illicit": 0.1678726638357755, 29 | "illicit/violent": 0.08943962701548983, 30 | "self-harm/intent": 0.00031353376143913094, 31 | "self-harm/instructions": 1.4285517650093407e-6, 32 | "self-harm": 0.0005506238275354633, 33 | "sexual/minors": 4.683888424952456e-6, 34 | "violence": 0.953203577678541, 35 | "violence/graphic": 0.0015876537458761227 36 | }, 37 | "category_applied_input_types": { 38 | "harassment": ["text"], 39 | "harassment/threatening": ["text"], 40 | "sexual": ["text"], 41 | "hate": ["text"], 42 | "hate/threatening": ["text"], 43 | "illicit": ["text"], 44 | "illicit/violent": ["text"], 45 | "self-harm/intent": ["text"], 46 | "self-harm/instructions": ["text"], 47 | "self-harm": ["text"], 48 | "sexual/minors": ["text"], 49 | "violence": ["text"], 50 | "violence/graphic": ["text"] 51 | } 52 | } 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /test/fixtures/POST_https%3A%2F%2Fapi.openai.com%2Fv1%2Fmoderations_624f7df3dc5f.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "modr-4717", 3 | "model": "omni-moderation-latest", 4 | "results": [ 5 | { 6 | "flagged": false, 7 | "categories": { 8 | "harassment": false, 9 | "harassment/threatening": false, 10 | "sexual": false, 11 | "hate": false, 12 | "hate/threatening": false, 13 | "illicit": false, 14 | "illicit/violent": false, 15 | "self-harm/intent": false, 16 | "self-harm/instructions": false, 17 | "self-harm": false, 18 | "sexual/minors": false, 19 | "violence": false, 20 | "violence/graphic": false 21 | }, 22 | "category_scores": { 23 | "harassment": 8.139692624947503e-7, 24 | "harassment/threatening": 7.81148330637258e-8, 25 | "sexual": 1.0451548051737735e-6, 26 | "hate": 3.3931448129766124e-7, 27 | "hate/threatening": 4.450850519411503e-8, 28 | "illicit": 4.029456601378866e-7, 29 | "illicit/violent": 4.936988949458183e-7, 30 | "self-harm/intent": 7.183260399857925e-7, 31 | "self-harm/instructions": 6.8936104552113e-8, 32 | "self-harm": 1.3846004563753396e-6, 33 | "sexual/minors": 1.0348531401872454e-7, 34 | "violence": 9.223470110117277e-7, 35 | "violence/graphic": 2.72647027069593e-7 36 | }, 37 | "category_applied_input_types": { 38 | "harassment": ["text"], 39 | "harassment/threatening": ["text"], 40 | "sexual": ["text"], 41 | "hate": ["text"], 42 | "hate/threatening": ["text"], 43 | "illicit": ["text"], 44 | "illicit/violent": ["text"], 45 | "self-harm/intent": ["text"], 46 | "self-harm/instructions": ["text"], 47 | "self-harm": ["text"], 48 | "sexual/minors": ["text"], 49 | "violence": ["text"], 50 | "violence/graphic": ["text"] 51 | } 52 | } 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /public/bootstrap-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /views/api/chart.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block head 4 | script(src='/js/lib/chart.umd.js') 5 | 6 | block content 7 | .pb-2.mt-2.mb-4.border-bottom 8 | h2 9 | i.fas.fa-chart-bar.fa-sm.fa-sm.me-2(style='color: #ff6384') 10 | | Chart.js and Alpha Vantage 11 | 12 | .btn-group.d-flex(role='group') 13 | a.btn.btn-primary.w-100(href='https://www.chartjs.org/docs', target='_blank') 14 | i.fas.fa-chart-bar.fa-sm.me-2 15 | | Chart.js Docs 16 | a.btn.btn-primary.w-100(href='https://www.alphavantage.co/documentation', target='_blank') 17 | i.fas.fa-money-check-dollar.fa-sm.me-2 18 | | Alpha Vantage Docs 19 | .container 20 | .mt-2.mb-4 21 | h3 Chart.js — Line Chart Demo using data from Alpha Vantage 22 | | Alpha Vantage APIs are grouped into four categories: (1) Stock Time Series Data, (2) Physical and Digital/Crypto Currencies (e.g., Bitcoin), (3) Technical Indicators, and (4) Sector Performances. All APIs are realtime: the latest data points are derived from the current trading day. 23 | | ChartJS can render various chart types with different formatting. 24 | | This example plots the closing stock price of Microsoft for the last 100 days. 25 | p 26 | h6 #{ dataType } 27 | .mt-2.mb-4 28 | div(style='width: 90%; height: 80%; margin: 0 auto') 29 | canvas#chart 30 | script. 31 | var datesList = !{ dates }; 32 | var closingList = !{ closing }; 33 | var ctx = document.getElementById('chart').getContext('2d'); 34 | var myChart = new Chart(ctx, { 35 | type: 'line', 36 | data: { 37 | labels: datesList, 38 | datasets: [ 39 | { 40 | label: "Microsoft's Closing Stock Values", 41 | data: closingList, 42 | borderColor: '#3e95cd', 43 | backgroundColor: 'rgba(118,152,255,0.4)', 44 | }, 45 | ], 46 | }, 47 | options: { 48 | responsive: true, 49 | maintainAspectRatio: true, 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /patches/passport+0.7.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/passport/lib/sessionmanager.js b/node_modules/passport/lib/sessionmanager.js 2 | index 81b59b1..17807c4 3 | --- a/node_modules/passport/lib/sessionmanager.js 4 | +++ b/node_modules/passport/lib/sessionmanager.js 5 | @@ -7,6 +7,15 @@ function SessionManager(options, serializeUser) { 6 | } 7 | options = options || {}; 8 | 9 | + this._delegate = options.delegate || { 10 | + regenerate: function(req, cb){ 11 | + cb(); 12 | + }, 13 | + save: function(req, cb){ 14 | + cb(); 15 | + } 16 | + }; 17 | + 18 | this._key = options.key || 'passport'; 19 | this._serializeUser = serializeUser; 20 | } 21 | @@ -25,7 +34,7 @@ SessionManager.prototype.logIn = function(req, user, options, cb) { 22 | 23 | // regenerate the session, which is good practice to help 24 | // guard against forms of session fixation 25 | - req.session.regenerate(function(err) { 26 | + this._delegate.regenerate(req, function(err) { 27 | if (err) { 28 | return cb(err); 29 | } 30 | @@ -44,7 +53,7 @@ SessionManager.prototype.logIn = function(req, user, options, cb) { 31 | req.session[self._key].user = obj; 32 | // save the session before redirection to ensure page 33 | // load does not happen before session is saved 34 | - req.session.save(function(err) { 35 | + self._delegate.save(req, function(err) { 36 | if (err) { 37 | return cb(err); 38 | } 39 | @@ -73,14 +82,14 @@ SessionManager.prototype.logOut = function(req, options, cb) { 40 | } 41 | var prevSession = req.session; 42 | 43 | - req.session.save(function(err) { 44 | + this._delegate.save(req, function(err) { 45 | if (err) { 46 | return cb(err) 47 | } 48 | 49 | // regenerate the session, which is good practice to help 50 | // guard against forms of session fixation 51 | - req.session.regenerate(function(err) { 52 | + self._delegate.regenerate(req, function(err) { 53 | if (err) { 54 | return cb(err); 55 | } 56 | -------------------------------------------------------------------------------- /test/e2e/tenor.e2e.test.js: -------------------------------------------------------------------------------- 1 | process.env.API_TEST_FILE = 'e2e/tenor.e2e.test.js'; 2 | const { test, expect } = require('@playwright/test'); 3 | const { registerTestInManifest, isInManifest } = require('../tools/fixture-helpers'); 4 | 5 | // Self-register this test in the manifest when recording 6 | registerTestInManifest('e2e/tenor.e2e.test.js'); 7 | 8 | // Skip this file during replay if it's not in the manifest 9 | if (process.env.API_MODE === 'replay' && !isInManifest('e2e/tenor.e2e.test.js')) { 10 | console.log('[fixtures] skipping e2e/tenor.e2e.test.js as it is not in manifest for replay mode - 2 tests'); 11 | test.skip(true, 'Not in manifest for replay mode'); 12 | } 13 | 14 | test.describe('Tenor API', () => { 15 | let sharedPage; 16 | 17 | test.beforeAll(async ({ browser }) => { 18 | sharedPage = await browser.newPage(); 19 | await sharedPage.goto('/api/tenor'); 20 | await sharedPage.waitForLoadState('networkidle'); 21 | }); 22 | 23 | test.afterAll(async () => { 24 | if (sharedPage) await sharedPage.close(); 25 | }); 26 | 27 | test('should show results on a fresh page load', async () => { 28 | const resultsCard = sharedPage.locator('.card.text-white.bg-success'); 29 | await expect(resultsCard).toBeVisible(); 30 | const images = resultsCard.locator('img.card-img-top'); 31 | const imageCount = await images.count(); 32 | expect(imageCount).toBeGreaterThan(10); 33 | const src = await images.first().getAttribute('src'); 34 | expect(src).toBeTruthy(); 35 | }); 36 | 37 | test('should return search results for submissions', async () => { 38 | await sharedPage.fill('input[name="search"]', 'funny cat'); 39 | await sharedPage.click('button[type="submit"]'); 40 | await sharedPage.waitForLoadState('networkidle'); 41 | 42 | const resultsCard = sharedPage.locator('.card.text-white.bg-success'); 43 | await expect(resultsCard).toBeVisible(); 44 | const images = resultsCard.locator('img.card-img-top'); 45 | const imageCount = await images.count(); 46 | expect(imageCount).toBeGreaterThan(10); 47 | const src = await images.first().getAttribute('src'); 48 | expect(src).toBeTruthy(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /views/account/signup.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h3 Sign up 6 | form#signup-form(method='POST') 7 | input(type='hidden', name='_csrf', value=_csrf) 8 | .form-group.row.mb-3 9 | label.col-md-3.col-form-label.font-weight-bold.text-right(for='email') Email 10 | .col-md-7 11 | input#email.form-control(type='email', name='email', placeholder='Email', autofocus, autocomplete='email', required) 12 | .form-group.row.mb-3 13 | .col-md-6.offset-md-3 14 | .form-check 15 | input#passwordless.form-check-input(type='checkbox', name='passwordless') 16 | label.form-check-label(for='passwordless') Sign up without password (passwordless login via email) 17 | #password-fields 18 | .form-group.row.mb-3 19 | label.col-md-3.col-form-label.font-weight-bold.text-right(for='password') Password 20 | .col-md-7 21 | input#password.form-control(type='password', name='password', placeholder='Password', autocomplete='new-password', minlength='8', required) 22 | .form-group.row.mb-3 23 | label.col-md-3.col-form-label.font-weight-bold.text-right(for='confirmPassword') Confirm Password 24 | .col-md-7 25 | input#confirmPassword.form-control(type='password', name='confirmPassword', placeholder='Confirm Password', autocomplete='new-password', minlength='8', required) 26 | .form-group.row.mb-3 27 | .col-md-3 28 | .col-md-7 29 | button.btn.btn-success(type='submit') 30 | i.fas.fa-user-plus.fa-sm.me-2 31 | | Signup 32 | 33 | //- Handle checkbox toggle 34 | script. 35 | document.getElementById('passwordless').addEventListener('change', function () { 36 | const passwordFields = document.getElementById('password-fields'); 37 | const passwordInputs = passwordFields.querySelectorAll('input'); 38 | 39 | if (this.checked) { 40 | passwordFields.style.display = 'none'; 41 | passwordInputs.forEach((input) => input.removeAttribute('required')); 42 | } else { 43 | passwordFields.style.display = 'block'; 44 | passwordInputs.forEach((input) => input.setAttribute('required', '')); 45 | } 46 | }); 47 | -------------------------------------------------------------------------------- /public/css/main.scss: -------------------------------------------------------------------------------- 1 | @import 'node_modules/bootstrap/scss/bootstrap'; 2 | @import 'node_modules/bootstrap-social/bootstrap-social.scss'; 3 | @import 'node_modules/@fortawesome/fontawesome-free/scss/fontawesome'; 4 | @import 'node_modules/@fortawesome/fontawesome-free/scss/brands'; 5 | @import 'node_modules/@fortawesome/fontawesome-free/scss/regular'; 6 | @import 'node_modules/@fortawesome/fontawesome-free/scss/solid'; 7 | 8 | // Basic Twitch Button CSS 9 | .btn-twitch { 10 | background-color: #6441a5; 11 | color: #fff !important; 12 | 13 | &:hover, 14 | &:active { 15 | background-color: #503484; 16 | } 17 | } 18 | 19 | .btn-twitter { 20 | &:hover, 21 | &:active { 22 | color: #fff !important; 23 | } 24 | } 25 | 26 | .btn-twitter { 27 | color: #fff !important; 28 | 29 | &:hover, 30 | &:active { 31 | color: #fff !important; 32 | background-color: #0f97ea; 33 | } 34 | } 35 | 36 | .btn-google { 37 | &:hover, 38 | &:active { 39 | color: #fff !important; 40 | } 41 | } 42 | 43 | .btn-google { 44 | color: #fff !important; 45 | 46 | &:hover, 47 | &:active { 48 | color: #fff !important; 49 | background-color: #d93b27; 50 | } 51 | } 52 | 53 | .btn-discord { 54 | background-color: #5865f2; 55 | color: #fff !important; 56 | 57 | &:hover, 58 | &:active { 59 | background-color: #4752c4; 60 | color: #fff !important; 61 | } 62 | } 63 | 64 | // Multi-color Google icon for branding compliance 65 | .fa-google { 66 | background: 67 | linear-gradient(to bottom left, transparent 49%, #fbbc05 50%) 0 25%/48% 40%, 68 | linear-gradient(to top left, transparent 49%, #fbbc05 50%) 0 75%/48% 30%, 69 | linear-gradient(-30deg, transparent 53%, #ea4335 48%), 70 | linear-gradient(45deg, transparent 46%, #4285f4 48%), 71 | #34a853; 72 | background-repeat: no-repeat; 73 | -webkit-background-clip: text; 74 | background-clip: text; 75 | color: transparent; 76 | -webkit-text-fill-color: transparent; 77 | } 78 | 79 | .btn-google { 80 | background-color: #000000; 81 | color: #fff !important; 82 | border: solid #000000; 83 | 84 | &:hover, 85 | &:active { 86 | background-color: #000000; 87 | color: #fff !important; 88 | border: solid #000000; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /test/tools/fixture-helpers.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const crypto = require('crypto'); 4 | 5 | const MANIFEST_PATH = path.resolve(__dirname, '..', 'fixtures', 'fixture_manifest.json'); 6 | 7 | function hashBody(body) { 8 | try { 9 | const h = crypto.createHash('sha1'); 10 | if (Buffer.isBuffer(body)) { 11 | h.update(body); 12 | } else if (typeof body === 'string') { 13 | h.update(body); 14 | } else if (body && typeof body === 'object') { 15 | h.update(JSON.stringify(body)); 16 | } else if (body != null) { 17 | h.update(String(body)); 18 | } 19 | return h.digest('hex').slice(0, 12); 20 | } catch { 21 | return 'nohash'; 22 | } 23 | } 24 | 25 | function keyFor(method, url, body) { 26 | const upper = String(method || 'GET').toUpperCase(); 27 | const parsed = new URL(url); 28 | const sensitiveParams = ['apikey', 'api_key', 'api-key', 'key', 'token']; 29 | sensitiveParams.forEach((param) => parsed.searchParams.delete(param)); 30 | const cleanUrl = `${parsed.origin}${parsed.pathname}${parsed.search}`; 31 | // Encode, then replace Windows-forbidden filename characters with _ 32 | const safe = encodeURIComponent(cleanUrl).replace(/[<>:"/\\|?*]/g, '_'); 33 | if (upper === 'GET') { 34 | return `${upper}_${safe}.json`; 35 | } 36 | const hash = hashBody(body); 37 | return `${upper}_${safe}_${hash}.json`; 38 | } 39 | 40 | function registerTestInManifest(testFile) { 41 | try { 42 | if (process.env.API_MODE !== 'record') return; 43 | let list = []; 44 | try { 45 | list = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); 46 | if (!Array.isArray(list)) list = []; 47 | } catch {} 48 | if (!list.includes(testFile)) { 49 | list.push(testFile); 50 | fs.writeFileSync(MANIFEST_PATH, JSON.stringify(list, null, 2)); 51 | } 52 | } catch {} 53 | } 54 | 55 | function isInManifest(id) { 56 | try { 57 | if (!id) return false; 58 | if (!fs.existsSync(MANIFEST_PATH)) return false; 59 | const list = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); 60 | return Array.isArray(list) && list.includes(id); 61 | } catch { 62 | return false; 63 | } 64 | } 65 | 66 | module.exports = { hashBody, keyFor, registerTestInManifest, isInManifest }; 67 | -------------------------------------------------------------------------------- /views/home.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | block head 3 | //- Opengraph tags 4 | meta(property='og:title', content='Hackathon Starter') 5 | meta(property='og:description', content='A boilerplate for Node.js web applications.') 6 | meta(property='og:type', content='website') 7 | meta(property='og:url', content=siteURL) 8 | meta(property='og:image', content=`${siteURL}/bootstrap-logo.svg`) 9 | //- Twitter Card tags (optional but recommended) 10 | meta(name='twitter:card', content='summary_large_image') 11 | meta(name='twitter:title', content='Hackathon Starter') 12 | meta(name='twitter:description', content='A boilerplate for Node.js web applications.') 13 | meta(name='twitter:image', content=`${siteURL}/bootstrap-logo.svg`) 14 | 15 | block content 16 | h1 Hackathon Starter 17 | p.lead A boilerplate for Node.js web applications. 18 | hr 19 | .row 20 | .col-md-6 21 | h2 Heading 22 | p Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna mollis euismod. Donec sed odio dui. 23 | p 24 | a.btn.btn-primary(href='#', role='button') View details » 25 | .col-md-6 26 | h2 Heading 27 | p Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna mollis euismod. Donec sed odio dui. 28 | p 29 | a.btn.btn-primary(href='#', role='button') View details » 30 | .col-md-6 31 | h2 Heading 32 | p Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna mollis euismod. Donec sed odio dui. 33 | p 34 | a.btn.btn-primary(href='#', role='button') View details » 35 | .col-md-6 36 | h2 Heading 37 | p Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna mollis euismod. Donec sed odio dui. 38 | p 39 | a.btn.btn-primary(href='#', role='button') View details » 40 | -------------------------------------------------------------------------------- /views/api/lob.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.far.fa-envelope.fa-sm.me-2 7 | | Lob API 8 | .btn-group.d-flex(role='group') 9 | a.btn.btn-primary.w-100(href='https://lob.com/docs', target='_blank') 10 | i.far.fa-check-square.fa-sm.me-2 11 | | API Documentation 12 | a.btn.btn-primary.w-100(href='https://github.com/lob/lob-node', target='_blank') 13 | i.fas.fa-code.fa-sm.me-2 14 | | Lob Node Docs 15 | a.btn.btn-primary.w-100(href='https://dashboard.lob.com/register', target='_blank') 16 | i.fas.fa-cog.fa-sm.me-2 17 | | Create API Account 18 | .pb-2.mt-2.mb-4.border-bottom 19 | h3 Details of zip code: #{ zipDetails.zip_code } 20 | br 21 | p Note that Lob.com's test API key does not perform any verification, automatic correction, or standardization for addresses. The responses from their test API will always be the response for 22 | a(href='https://lob.com/docs#us-verification-test-environment', target='_blank') 23 | | 24 | | PO BOX 720114, San Francisco, CA 94172-0114 25 | br 26 | p ID: #{ zipDetails.id } 27 | p Zip Code Type: #{ zipDetails.zip_code_type } 28 | table.table.table-striped.table-bordered 29 | thead 30 | tr 31 | th City 32 | th State 33 | th County 34 | th County Fips 35 | th Preferred 36 | tbody 37 | each cities in zipDetails.cities 38 | tr 39 | td= cities.city 40 | td= cities.state 41 | td= cities.county 42 | td= cities.county_fips 43 | td= cities.preferred 44 | .pb-2.mt-2.mb-4.border-bottom 45 | h3 First-Class Mail (USPS) 46 | br 47 | | Letter ID: #{ uspsLetter.id } 48 | br 49 | | Will be mailed using: #{ uspsLetter.mail_type } 50 | br 51 | | With expected delivery date of: #{ uspsLetter.expected_delivery_date } 52 | #pdfviewer(style='display: none') 53 | object(width='600', height='850', type='application/pdf', data=uspsLetter.url) 54 | 55 | //Lob's back end has a few second delay from when they generate the letter to when it is availalble 56 | //Without this delay some of the PDF fetches result in 404s 57 | script(type='text/javascript'). 58 | window.onload = function () { 59 | setTimeout(function () { 60 | $('#pdfviewer').show(); 61 | }, 3000); 62 | }; 63 | -------------------------------------------------------------------------------- /views/api/foursquare.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.fab.fa-foursquare.fa-sm.me-2 7 | | Foursquare API 8 | 9 | .btn-group.mb-4.d-flex(role='group') 10 | a.btn.btn-primary.w-100(href='https://foursquare.com/developer', target='_blank') 11 | i.far.fa-check-square.fa-sm.me-2 12 | | Developer Info 13 | a.btn.btn-primary.w-100(href='https://docs.foursquare.com/', target='_blank') 14 | i.fas.fa-code-branch.fa-sm.me-2 15 | | API Docs 16 | if error 17 | .alert.alert-danger.mt-3 #{ error } 18 | else 19 | h3.text-primary Trending Venues 20 | p Near longitude: -122.342148, latitude: 47.609657 21 | table.table.table-striped.table-bordered 22 | thead 23 | tr 24 | th.d-xs 25 | th Name 26 | th.d-xs.d-sm Category 27 | th.d-xs Address 28 | th.d-xs Distance (meters) 29 | tbody 30 | each venue in trendingVenues 31 | tr 32 | td.d-xs 33 | if venue.categories && venue.categories.length > 0 34 | img(src=venue.categories[0].icon.prefix + '32' + venue.categories[0].icon.suffix, alt=venue.categories[0].name, width='32', height='32') 35 | else 36 | | N/A 37 | td= venue.name 38 | td.d-xs.d-sm= venue.categories && venue.categories.length > 0 ? venue.categories[0].name : 'N/A' 39 | td.d-xs= venue.location.formatted_address || 'N/A' 40 | td.d-xs= venue.distance 41 | br 42 | h3.text-primary Venue Details 43 | p 44 | i 45 | u #{ venueDetail.name } 46 | if venueDetail.categories && venueDetail.categories.length > 0 47 | | 48 | | is a #{ venueDetail.categories[0].name } 49 | | 50 | | located at #{ venueDetail.location.address || 'N/A' }, #{ venueDetail.location.locality || 'N/A' }, #{ venueDetail.location.region || 'N/A' }. (longitude: #{ venueDetail.longitude }, latitude: #{ venueDetail.latitude }) 51 | if venueDetail.related_places 52 | if venueDetail.related_places.children && venueDetail.related_places.children.length > 0 53 | p Related venues or businesses to #{ venueDetail.name }, which are mostly in the same building or the immediate area are: 54 | p(style='margin-left: 20px; white-space: pre-wrap') #{ venueDetail.related_places.children.map(place => place.name).join(', ') } 55 | -------------------------------------------------------------------------------- /views/ai/togetherai-classifier.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.fas.fa-network-wired.fa-sm.me-2(style='color: #6f42c1') 7 | | Together AI - One-shot LLM API call example 8 | 9 | .btn-group.mb-4.d-flex(role='group') 10 | a.btn.btn-primary.w-100(href='https://api.together.ai', target='_blank') 11 | i.fas.fa-table-columns.fa-sm.me-2 12 | | Together AI Dashboard 13 | a.btn.btn-primary.w-100(href='https://docs.together.ai/docs/inference', target='_blank') 14 | i.fas.fa-info.fa-sm.me-2 15 | | Together AI Docs 16 | a.btn.btn-primary.w-100(href='https://docs.together.ai/reference/chat-completions', target='_blank') 17 | i.fas.fa-book.fa-sm.me-2 18 | | API Reference 19 | 20 | p.text-muted 21 | | Analyze a customer message using #{ togetherAiModel } hosted on Together.AI to determine the appropriate department for routing. The system prompt provides classification instructions and requests the LLM to respond in JSON format, which the application can parse for further actions. 22 | 23 | .row 24 | .col-md-8.col-lg-6 25 | form(method='POST', action='/ai/togetherai-classifier') 26 | input(type='hidden', name='_csrf', value=_csrf) 27 | .mb-3 28 | label(for='inputText') Customer message 29 | textarea#inputText.form-control(name='inputText', maxlength='300', rows='4', required)= input 30 | .form-text.text-muted Maximum 300 characters. 31 | button.btn.btn-primary(type='submit') Classify Department 32 | 33 | if error 34 | .alert.alert-danger.mt-3= error 35 | 36 | if result 37 | .mt-4 38 | h5.text-secondary.mb-3 Classification (Routing) Result 39 | .d-flex.align-items-center.mb-2 40 | i.fas.fa-tag.me-2(style='color: #6f42c1') 41 | if result.department && result.department !== 'Unknown' 42 | span.fw-bold.text-primary.fs-4 Department: 43 | span.ms-2.fs-4= result.department 44 | else 45 | span.text-warning Could not determine department. 46 | if result.raw 47 | details 48 | summary Show raw model output 49 | pre.mt-2= result.raw 50 | if result.systemPrompt 51 | details 52 | summary Show system prompt 53 | pre.mt-2(style='white-space: pre-wrap; word-break: break-word')= result.systemPrompt 54 | if result.userPrompt 55 | details 56 | summary Show user prompt 57 | pre.mt-2= result.userPrompt 58 | -------------------------------------------------------------------------------- /test/e2e-nokey/rag.e2e.test.js: -------------------------------------------------------------------------------- 1 | const { test, expect } = require('@playwright/test'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | test.describe('RAG File Upload Integration', () => { 6 | test.describe.configure({ mode: 'serial' }); 7 | // Helper to remove 'test-*' files from RAG input and 'ingested' dirs 8 | const cleanupTestFiles = () => { 9 | const ragInputDir = path.join(__dirname, '../../rag_input'); 10 | const ingestedDir = path.join(ragInputDir, 'ingested'); 11 | 12 | // Remove any test artifacts in both directories 13 | [ragInputDir, ingestedDir].forEach((dir) => { 14 | if (fs.existsSync(dir)) { 15 | const files = fs.readdirSync(dir).filter((f) => f.startsWith('test-')); 16 | files.forEach((file) => { 17 | const filePath = path.join(dir, file); 18 | if (fs.existsSync(filePath)) { 19 | fs.unlinkSync(filePath); 20 | } 21 | }); 22 | } 23 | }); 24 | }; 25 | 26 | test.beforeEach(async () => { 27 | // Ensure a clean slate before each test run 28 | cleanupTestFiles(); 29 | }); 30 | 31 | test.afterEach(async () => { 32 | // Remove test artifacts after each test to keep state isolated 33 | cleanupTestFiles(); 34 | }); 35 | 36 | test('should validate question submission functionality', async ({ page }) => { 37 | // Navigate to RAG page 38 | await page.goto('/ai/rag'); 39 | await page.waitForLoadState('networkidle'); 40 | 41 | // Set empty value and remove 'required' to exercise server-side validation 42 | await page.fill('#question', ''); 43 | 44 | // Remove the required attribute to bypass client-side validation 45 | await page.evaluate(() => { 46 | const questionField = document.getElementById('question'); 47 | if (questionField) { 48 | questionField.removeAttribute('required'); 49 | } 50 | }); 51 | 52 | // Try to submit empty question by clicking the ask button 53 | await page.click('#ask-btn'); 54 | 55 | // Wait for redirect to complete and for flash messages to render 56 | await page.waitForLoadState('networkidle'); 57 | 58 | const errorAlert = page.locator('.alert-danger'); 59 | 60 | await expect(errorAlert).toBeVisible({ timeout: 3000 }); 61 | 62 | // Locate server-side validation error alert 63 | const hasError = (await errorAlert.count()) > 0; 64 | 65 | // Ensure error alert appears with expected validation message 66 | expect(hasError).toBeTruthy(); 67 | await expect(errorAlert).toBeVisible(); 68 | await expect(errorAlert).toContainText(/Please enter a question./i); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /views/api/twitch.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.fab.fa-twitch.fa-sm.me-2 7 | | Twitch API 8 | .btn-group.d-flex(role='group') 9 | a.btn.btn-primary.w-100(href='https://dev.twitch.tv/docs/api', target='_blank') 10 | i.far.fa-check-square.fa-sm.me-2 11 | | API Overview 12 | 13 | br 14 | .card.text-white.bg-success.mb-4 15 | .card-header 16 | h6.panel-title.mb-0 Your Profile 17 | .card-body.text-dark.bg-white 18 | .row 19 | .col-sm-2 20 | img(src=yourTwitchUserData.profile_image_url, width='150', height='150') 21 | .col-sm-8 22 | span.lead Name: #{ yourTwitchUserData.display_name } 23 | div Twitch ID: #{ yourTwitchUserData.login } 24 | div Description: #{ yourTwitchUserData.description } 25 | div Broadcaster Type: #{ yourTwitchUserData.broadcaster_type } 26 | div Follower Count: #{ twitchFollowers.total } 27 | 28 | br 29 | .card.text-white.bg-primary.mb-4 30 | .card-header 31 | h6.panel-title.mb-0 Top Streamer Playing Destiny 2 32 | .card-body.text-dark.bg-white 33 | .row 34 | .col-sm-2 35 | img(src=otherTwitchStreamerInfo.profile_image_url, width='150', height='150') 36 | .col-sm-8 37 | span.lead Name: #{ otherTwitchStreamStatus.user_name } 38 | div Twitch ID: #{ otherTwitchStreamStatus.user_login } 39 | div Twitch member since: #{ otherTwitchStreamerInfo.created_at } 40 | div Description: #{ otherTwitchStreamerInfo.description } 41 | div Broadcaster Type: #{ otherTwitchStreamerInfo.broadcaster_type } 42 | div Game: #{ otherTwitchStreamStatus.game_name } 43 | div Language: #{ otherTwitchStreamStatus.language } 44 | div Mature Content: #{ otherTwitchStreamStatus.is_mature ? 'Yes' : 'No' } 45 | if otherTwitchStreamStatus.type === 'live' 46 | br 47 | span.status.text-success 48 | i.fas.fa-circle.me-2 49 | | 50 | | Currently Online 51 | | 52 | | - viewers: #{ otherTwitchStreamStatus.viewer_count } - Stream started: #{ otherTwitchStreamStatus.started_at } 53 | div Stream Title: #{ otherTwitchStreamStatus.title } 54 | div 55 | span Stream tags: 56 | each tag in otherTwitchStreamStatus.tags 57 | span.badge.bg-primary.text-dark.mx-2= tag 58 | br 59 | img(src=otherTwitchStreamStatus.thumbnail_url.replace('{width}x{height}', '640x360'), width='640', height='360') 60 | else 61 | span.status.text-danger 62 | i.fas.fa-circle 63 | | Offline 64 | -------------------------------------------------------------------------------- /test/.env.test: -------------------------------------------------------------------------------- 1 | # 2 | # The App ID, Keys, or Secrets in this file are for predictable TESTING. 3 | # You DO NOT need to modify this file with valid keys. 4 | # 5 | BASE_URL=http://localhost:8080 6 | SITE_CONTACT_EMAIL=youremail@yourdomain.com 7 | TRANSACTION_EMAIL=youremail-OR-noreply@yourdomain.com 8 | 9 | SESSION_SECRET=test_session_secret 10 | 11 | SMTP_USER=test_smtp_user 12 | SMTP_PASSWORD=test_smtp_password 13 | SMTP_HOST=test.smtp.host.com 14 | 15 | ALPHA_VANTAGE_KEY=api-key 16 | 17 | DISCORD_CLIENT_ID=discord-client-id/discord-app-id 18 | DISCORD_CLIENT_SECRET=discord-client-secret 19 | 20 | # To suppress multiple tip/advertisement lines about the dotenvx service 21 | DOTENV_KEY="" 22 | 23 | FACEBOOK_ID=1234567890123456 24 | FACEBOOK_SECRET=test_facebook_secret 25 | FACEBOOK_PIXEL_ID= 26 | 27 | FOURSQUARE_ID=test_foursquare_id 28 | FOURSQUARE_SECRET=test_foursquare_secret 29 | 30 | GITHUB_ID=test_github_id 31 | GITHUB_SECRET=test_github_secret 32 | 33 | GOOGLE_ANALYTICS_ID= 34 | 35 | GOOGLE_PROJECT_ID=hackathon-starter-xxxxxx 36 | GOOGLE_CLIENT_ID=1234567890.apps.googleusercontent.com 37 | GOOGLE_CLIENT_SECRET=test_google_secret 38 | GOOGLE_API_KEY=your-google-api-key 39 | GOOGLE_MAP_API_KEY=test_google_map_api_key 40 | GOOGLE_RECAPTCHA_SITE_KEY=test_recaptcha_site_key 41 | 42 | HERE_API_KEY=test_here_api_key 43 | 44 | HUGGINGFACE_KEY=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 45 | HUGGINGFACE_EMBEDDING_MODEL=BAAI/bge-small-en-v1.5 46 | HUGGINGFACE_PROVIDER=hf-inference 47 | 48 | LASTFM_KEY=c8c0ea1c4a6b199b3429722512fbd17f # matching .env.example 49 | LASTFM_SECRET=test_lastfm_secret 50 | 51 | LINKEDIN_ID=test_linkedin_id 52 | LINKEDIN_SECRET=test_linkedin_secret 53 | 54 | MICROSOFT_CLIENT_ID=test_microsoft-client-id 55 | MICROSOFT_CLIENT_SECRET=test_microsoft-client-secret 56 | 57 | LOB_KEY=test_lob_key 58 | 59 | NYT_KEY=test_nyt_key 60 | 61 | OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 62 | 63 | PAYPAL_ID=test_paypal_id 64 | PAYPAL_SECRET=test_paypal_secret 65 | 66 | QUICKBOOKS_CLIENT_ID=test_quickbooks_client_id 67 | QUICKBOOKS_CLIENT_SECRET=test_quickbooks_client_secret 68 | 69 | STEAM_KEY=test_steam_key 70 | 71 | STRIPE_SKEY=sk_test_testkey1234567890 72 | STRIPE_PKEY=pk_test_testkey1234567890 73 | 74 | TOGETHERAI_API_KEY=sample-api-key 75 | TOGETHERAI_MODEL=meta-llama/Llama-3.3-70B-Instruct-Turbo-Free 76 | TOGETHERAI_VISION_MODEL=meta-llama/Llama-Vision-Free 77 | 78 | TRAKT_ID=test_trakt_id 79 | TRAKT_SECRET=test_trakt_secret 80 | 81 | TUMBLR_KEY=test_tumblr_key 82 | TUMBLR_SECRET=test_tumblr_secret 83 | 84 | TWILIO_SID=AC_test_twilio_sid 85 | TWILIO_TOKEN=test_twilio_token 86 | TWILIO_FROM_NUMBER=+15005550006 87 | 88 | TWITCH_CLIENT_ID=test_twitch_client_id 89 | TWITCH_CLIENT_SECRET=test_twitch_client_secret 90 | 91 | X_KEY=test_x_key 92 | X_SECRET=test_x_secret 93 | -------------------------------------------------------------------------------- /test/tools/start-with-memory-db.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // test/helpers/start-with-memory-db.js 3 | // Starts mongodb-memory-server and then starts the app (require('../../app')). 4 | 5 | const path = require('path'); 6 | const { spawnSync } = require('child_process'); 7 | const { MongoMemoryServer } = require('mongodb-memory-server'); 8 | const { installServerApiFixtures } = require('./server-fetch-fixtures'); 9 | const { installServerAxiosFixtures } = require('./server-axios-fixtures'); 10 | 11 | (async function main() { 12 | try { 13 | // If a real MONGODB_URI is already provided, prefer it. 14 | if (process.env.MONGODB_URI) { 15 | console.log('[start-with-memory-db] Using provided MONGODB_URI'); 16 | } else { 17 | const mongod = await MongoMemoryServer.create(); 18 | const uri = mongod.getUri(); 19 | process.env.MONGODB_URI = uri; 20 | // expose a flag so we can stop it if needed in advanced setups 21 | process.env.__MONGO_MEMORY_SERVER_RUNNING = '1'; 22 | console.log('[start-with-memory-db] Started MongoMemoryServer at', uri); 23 | 24 | // stop on exit 25 | const stop = async () => { 26 | try { 27 | await mongod.stop(); 28 | console.log('[start-with-memory-db] MongoMemoryServer stopped'); 29 | } catch (e) { 30 | console.error('[start-with-memory-db] Error stopping MongoMemoryServer', e); 31 | } 32 | process.exit(0); 33 | }; 34 | 35 | process.on('SIGINT', stop); 36 | process.on('SIGTERM', stop); 37 | process.on('exit', stop); 38 | } 39 | 40 | // Ensure BASE_URL and PORT are set for the app 41 | process.env.PORT = process.env.PORT || '8080'; 42 | process.env.BASE_URL = process.env.BASE_URL || `http://localhost:${process.env.PORT}`; 43 | 44 | // If scss build is necessary before starting the app (like npm start does), run it synchronously 45 | try { 46 | console.log('[start-with-memory-db] Building scss...'); 47 | spawnSync('npm', ['run', 'scss'], { stdio: 'inherit' }); 48 | } catch (e) { 49 | console.warn('[start-with-memory-db] SCSS build failed or not available:', e.message || e); 50 | } 51 | 52 | // Install server-side API fixtures (record/replay) before app loads 53 | try { 54 | installServerApiFixtures({ mode: process.env.API_MODE }); 55 | installServerAxiosFixtures({ mode: process.env.API_MODE }); 56 | } catch {} 57 | 58 | // Import the application after env is set so app.js picks up MONGODB_URI 59 | const urlMod = await import('url'); 60 | const { pathToFileURL } = urlMod; 61 | const appPath = path.join(__dirname, '..', '..', 'app.js'); 62 | await import(pathToFileURL(appPath).href); 63 | // keep process alive; app.listen is called inside app.js 64 | } catch (err) { 65 | console.error('[start-with-memory-db] Error starting:', err); 66 | process.exit(1); 67 | } 68 | })(); 69 | -------------------------------------------------------------------------------- /test/e2e/nyt.e2e.test.js: -------------------------------------------------------------------------------- 1 | process.env.API_TEST_FILE = 'e2e/nyt.e2e.test.js'; 2 | const { test, expect } = require('@playwright/test'); 3 | const { registerTestInManifest, isInManifest } = require('../tools/fixture-helpers'); 4 | 5 | // Self-register this test in the manifest when recording 6 | registerTestInManifest('e2e/nyt.e2e.test.js'); 7 | 8 | // Skip this file during replay if it's not in the manifest 9 | if (process.env.API_MODE === 'replay' && !isInManifest('e2e/nyt.e2e.test.js')) { 10 | console.log('[fixtures] skipping e2e/nyt.e2e.test.js as it is not in manifest for replay mode - 2 tests'); 11 | test.skip(true, 'Not in manifest for replay mode'); 12 | } 13 | 14 | test.describe('New York Times API Integration', () => { 15 | let sharedPage; 16 | 17 | test.beforeAll(async ({ browser }) => { 18 | sharedPage = await browser.newPage(); 19 | await sharedPage.goto('/api/nyt'); 20 | await sharedPage.waitForLoadState('networkidle'); 21 | }); 22 | 23 | test.afterAll(async () => { 24 | if (sharedPage) await sharedPage.close(); 25 | }); 26 | 27 | test('should render basic page content', async () => { 28 | // Basic page checks 29 | await expect(sharedPage).toHaveTitle(/New York Times API/); 30 | await expect(sharedPage.locator('h2')).toContainText('New York Times API'); 31 | // Locate the main table and verify header columns 32 | const bestSellersTable = sharedPage.locator('table.table'); 33 | await expect(bestSellersTable).toBeVisible(); 34 | 35 | //Check the content of the file 36 | const tableHeaders = bestSellersTable.locator('thead th'); 37 | await expect(tableHeaders).toHaveCount(5); 38 | await expect(tableHeaders.nth(0)).toContainText('Rank'); 39 | await expect(tableHeaders.nth(1)).toContainText('Title'); 40 | await expect(tableHeaders.nth(2)).toContainText('Description'); 41 | await expect(tableHeaders.nth(3)).toContainText('Author'); 42 | await expect(tableHeaders.nth(4)).toContainText('ISBN-13'); 43 | 44 | // Verify there is at least one row of data 45 | const tableRows = bestSellersTable.locator('tbody tr'); 46 | expect(await tableRows.count()).toBeGreaterThan(0); 47 | }); 48 | 49 | test('should display the details for the Rank 1 best seller', async () => { 50 | const bestSellersTable = sharedPage.locator('table.table'); 51 | await expect(bestSellersTable).toBeVisible(); 52 | 53 | // Locate the first row's data cells (td) 54 | const firstRowCells = bestSellersTable.locator('tbody tr').nth(0).locator('td'); 55 | 56 | // Verify Rank 57 | await expect(firstRowCells.nth(0)).toContainText('1'); 58 | const currentRank1Title = (await firstRowCells.nth(1).textContent()).trim(); 59 | await expect(firstRowCells.nth(1)).toContainText(currentRank1Title); 60 | expect(currentRank1Title.length).toBeGreaterThan(5); 61 | await expect(firstRowCells.nth(3)).toContainText(/\w+/); 62 | await expect(firstRowCells.nth(4)).toContainText(/\d{13}/); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /views/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html.h-100(lang='en') 3 | head 4 | meta(charset='utf-8') 5 | meta(http-equiv='X-UA-Compatible', content='IE=edge') 6 | meta(name='viewport', content='width=device-width, initial-scale=1.0') 7 | meta(name='csrf-token', content=_csrf) 8 | //- Facebook App ID 9 | meta(property='fb:app_id', content=FACEBOOK_ID) 10 | 11 | title #{ title } - Hackathon Starter 12 | link(rel='shortcut icon', href='/favicon.ico') 13 | //link(rel='stylesheet', href='/css/bootstrap.min.css') 14 | link(rel='stylesheet', href='/css/main.css') 15 | link(rel='stylesheet', href='https://cdn.jsdelivr.net/npm/cookieconsent@3/build/cookieconsent.min.css') 16 | block head 17 | 18 | body.d-flex.flex-column.h-100 19 | include partials/header 20 | 21 | .flex-shrink-0 22 | .container.mt-3 23 | include partials/flash 24 | block content 25 | 26 | include partials/footer 27 | 28 | script(src='/js/lib/jquery.min.js') 29 | script(src='/js/lib/bootstrap.min.js') 30 | script(src='/js/main.js') 31 | script(src='https://cdn.jsdelivr.net/npm/cookieconsent@3/build/cookieconsent.min.js') 32 | 33 | script(type='text/javascript'). 34 | window.cookieconsent.initialise({ 35 | palette: { 36 | popup: { 37 | background: '#f8f9fa', 38 | }, 39 | button: { 40 | background: '#343a40', 41 | }, 42 | }, 43 | position: 'bottom-right', 44 | content: { 45 | href: '/privacy-policy.html', 46 | }, 47 | }); 48 | 49 | //- Google Analytics GA4 50 | if GOOGLE_ANALYTICS_ID 51 | script(async, src=`https://www.googletagmanager.com/gtag/js?id=${GOOGLE_ANALYTICS_ID}`) 52 | script. 53 | window.dataLayer = window.dataLayer || []; 54 | function gtag() { 55 | dataLayer.push(arguments); 56 | } 57 | gtag('js', new Date()); 58 | gtag('config', '#{GOOGLE_ANALYTICS_ID}'); 59 | 60 | //- Facebook Pixel Code 61 | if FACEBOOK_PIXEL_ID 62 | script. 63 | !(function (f, b, e, v, n, t, s) { 64 | if (f.fbq) return; 65 | n = f.fbq = function () { 66 | n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments); 67 | }; 68 | if (!f._fbq) f._fbq = n; 69 | n.push = n; 70 | n.loaded = !0; 71 | n.version = '2.0'; 72 | n.queue = []; 73 | t = b.createElement(e); 74 | t.async = !0; 75 | t.src = v; 76 | s = b.getElementsByTagName(e)[0]; 77 | s.parentNode.insertBefore(t, s); 78 | })(window, document, 'script', 'https://connect.facebook.net/en_US/fbevents.js'); 79 | fbq('init', '#{FACEBOOK_PIXEL_ID}'); 80 | fbq('track', 'PageView'); 81 | noscript 82 | img(height='1', width='1', style='display: none', src=`https://www.facebook.com/tr?id=${FACEBOOK_PIXEL_ID}&ev=PageView&noscript=1`) 83 | -------------------------------------------------------------------------------- /test/e2e/openai-moderation.e2e.test.js: -------------------------------------------------------------------------------- 1 | process.env.API_TEST_FILE = 'e2e/openai-moderation.e2e.test.js'; 2 | const { test, expect } = require('@playwright/test'); 3 | const { registerTestInManifest, isInManifest } = require('../tools/fixture-helpers'); 4 | 5 | // Self-register this test in the manifest when recording 6 | registerTestInManifest('e2e/openai-moderation.e2e.test.js'); 7 | 8 | // Skip this file during replay if it's not in the manifest 9 | if (process.env.API_MODE === 'replay' && !isInManifest('e2e/openai-moderation.e2e.test.js')) { 10 | console.log('[fixtures] skipping e2e/openai-moderation.e2e.test.js as it is not in manifest for replay mode - 2 tests'); 11 | test.skip(true, 'Not in manifest for replay mode'); 12 | } 13 | 14 | test.describe('OpenAI Moderation API Integration', () => { 15 | test('should flag harmful content and display all moderation data', async ({ page }) => { 16 | await page.goto('/ai/openai-moderation'); 17 | await page.waitForLoadState('networkidle'); 18 | 19 | // Enter text that should be flagged as harmful (violent content) 20 | const harmfulText = 'I want to kill and hurt people violently.'; 21 | await page.fill('textarea#inputText', harmfulText); 22 | await page.click('button[type="submit"]'); 23 | await page.waitForLoadState('networkidle'); 24 | 25 | // Verify all moderation data elements 26 | await expect(page.locator('textarea#inputText')).toHaveValue(harmfulText); 27 | await expect(page.locator('h4')).toContainText('Moderation Result'); 28 | await expect(page.locator('.alert.alert-warning')).toContainText('flagged as harmful'); 29 | await expect(page.locator('h5')).toContainText('Category Scores'); 30 | await expect(page.locator('.badge.rounded-pill').first()).toBeVisible(); 31 | await expect(page.locator('h6')).toContainText('Flagged Categories'); 32 | await expect(page.locator('li.text-danger').first()).toBeVisible(); 33 | }); 34 | 35 | test('should not flag safe content and show all category data', async ({ page }) => { 36 | await page.goto('/ai/openai-moderation'); 37 | await page.waitForLoadState('networkidle'); 38 | 39 | // Enter safe, harmless text 40 | const safeText = 'I love reading books and learning new things. The weather is beautiful today.'; 41 | await page.fill('textarea#inputText', safeText); 42 | await page.click('button[type="submit"]'); 43 | await page.waitForLoadState('networkidle'); 44 | 45 | // Verify all moderation data elements 46 | await expect(page.locator('textarea#inputText')).toHaveValue(safeText); 47 | await expect(page.locator('h4')).toContainText('Moderation Result'); 48 | await expect(page.locator('.alert.alert-success')).toContainText('not flagged'); 49 | await expect(page.locator('h5')).toContainText('Category Scores'); 50 | await expect(page.locator('.badge.rounded-pill').first()).toBeVisible(); 51 | await expect(page.locator('h6')).toContainText('Flagged Categories'); 52 | await expect(page.locator('p.text-success')).toContainText('No categories were flagged'); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /views/ai/openai-moderation.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.fas.fa-robot.fa-sm.me-2(style='color: #10a37f') 7 | | OpenAI Moderation API 8 | 9 | .btn-group.mb-4.d-flex(role='group') 10 | a.btn.btn-primary.w-100(href='https://platform.openai.com/docs/guides/moderation', target='_blank') 11 | i.fas.fa-info.fa-sm.me-2 12 | | OpenAI Moderation Docs 13 | a.btn.btn-primary.w-100(href='https://platform.openai.com/docs/api-reference/moderations', target='_blank') 14 | i.fas.fa-book.fa-sm.me-2 15 | | API Reference 16 | 17 | p.text-muted 18 | | This example demonstrates how to use the OpenAI Moderation API to check if a user is providing harmful input (using the omni-moderation-latest model). The API utilizes OpenAI's GPT-based classifiers to assess whether content should be flagged across categories such as hate, violence, and self-harm. The output results provide granular probability scores to reflect the likelihood of content matching the detected category, enabling you to calibrate the moderation based on your use case or context. 19 | 20 | .row 21 | form(method='POST', action='/ai/openai-moderation') 22 | input(type='hidden', name='_csrf', value=_csrf) 23 | .mb-3 24 | label(for='inputText') Enter text to check for harmful content: 25 | textarea#inputText.form-control(name='inputText', rows='4', required)= input 26 | button.btn.btn-primary(type='submit') Check 27 | 28 | if error 29 | .alert.alert-danger.mt-3= error 30 | 31 | if result 32 | .mt-4 33 | h4 Moderation Result 34 | if result.flagged 35 | .alert.alert-warning The content was flagged as harmful. 36 | else 37 | .alert.alert-success The content was not flagged. 38 | 39 | h5 Category Scores 40 | .d-flex.flex-column 41 | each val, key in result.category_scores 42 | - 43 | // Compute color: green (#28a745) at 0, yellow at 0.5, red (#dc3545) at 1 44 | // We'll interpolate between green and red 45 | var score = typeof val === 'number' ? val : 0; 46 | var r = Math.round(40 + (220-40)*score); // 40->220 47 | var g = Math.round(167 + (53-167)*score); // 167->53 48 | var b = Math.round(69 + (197-69)*score); // 69->197 49 | var color = `rgb(${r},${g},${b})`; 50 | .d-flex.justify-content-between.align-items-center.mb-1.w-100(style='max-width: 400px') 51 | span.fw-bold= key 52 | span.badge.rounded-pill.px-3.py-2(style=`background-color:${color};color:#fff;font-size:1em;min-width:60px;display:inline-block`)= val.toFixed ? val.toFixed(2) : val 53 | br 54 | h6 Flagged Categories: 55 | if Object.values(result.categories).some((flagged) => flagged) 56 | ul 57 | each flagged, cat in result.categories 58 | if flagged 59 | li.text-danger= cat 60 | else 61 | p.text-success No categories were flagged. 62 | -------------------------------------------------------------------------------- /test/e2e/here-maps.e2e.test.js: -------------------------------------------------------------------------------- 1 | const { test, expect } = require('@playwright/test'); 2 | 3 | // Skip this suite entirely when running in record/replay fixture mode. 4 | // We intentionally do not use browser-side record/replay for HERE Maps. 5 | if (process.env.API_MODE === 'replay' || process.env.API_MODE === 'record') { 6 | console.log('[fixtures] skipping here-maps.e2e.test.js in record/replay mode (browser-side fixtures disabled) - 3 tests'); 7 | test.skip(true, 'Skipping HERE Maps tests in record/replay mode (browser-side fixtures disabled)'); 8 | } 9 | 10 | test.describe('HERE Maps API Integration', () => { 11 | let sharedPage; 12 | const tileRequests = []; 13 | 14 | test.beforeAll(async ({ browser }) => { 15 | sharedPage = await browser.newPage(); 16 | 17 | // Set up tile request monitoring BEFORE page loads 18 | sharedPage.on('response', async (response) => { 19 | const url = response.url(); 20 | if (url.includes('vector.hereapi.com') || url.includes('base.maps.api.here.com')) { 21 | tileRequests.push({ 22 | status: response.status(), 23 | ok: response.ok(), 24 | }); 25 | } 26 | }); 27 | 28 | await sharedPage.goto('/api/here-maps'); 29 | await sharedPage.waitForLoadState('networkidle'); 30 | }); 31 | 32 | test.afterAll(async () => { 33 | if (sharedPage) await sharedPage.close(); 34 | }); 35 | 36 | test('should initialize and render HERE Maps successfully', async () => { 37 | await sharedPage.waitForTimeout(3000); 38 | 39 | // Check if HERE Maps API loaded by verifying window.H object 40 | const hereMapsLoaded = await sharedPage.evaluate(() => typeof window.H !== 'undefined' && window.H !== null); 41 | expect(hereMapsLoaded).toBe(true); 42 | 43 | // Verify map canvas is rendered (HERE Maps uses Canvas for rendering) 44 | const hasCanvas = await sharedPage.locator('#map canvas').count(); 45 | expect(hasCanvas).toBeGreaterThan(0); 46 | 47 | // Verify HERE Maps copyright/attribution is visible 48 | const hasCopyright = await sharedPage.locator('#map').locator('text=/HERE|©/i').count(); 49 | expect(hasCopyright).toBeGreaterThan(0); 50 | }); 51 | 52 | test('should calculate and display straight line distance using client-side calculation', async () => { 53 | // Check for distance display element 54 | const distanceElement = sharedPage.locator('#directLineDistance'); 55 | await expect(distanceElement).toBeVisible(); 56 | 57 | // Verify distance value is calculated and displayed (client-side Haversine formula) 58 | const distanceText = await distanceElement.textContent(); 59 | const distance = parseFloat(distanceText); 60 | expect(distance).toBe(2.85); 61 | }); 62 | 63 | test('should successfully load HERE Maps tiles', async () => { 64 | // Tiles should have been loaded during beforeAll 65 | expect(tileRequests.length).toBeGreaterThan(0); 66 | const successfulTiles = tileRequests.filter((req) => req.ok); 67 | expect(successfulTiles.length).toBeGreaterThan(0); 68 | 69 | const hasCanvas = await sharedPage.locator('#map canvas').count(); 70 | expect(hasCanvas).toBeGreaterThan(0); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import chaiFriendly from 'eslint-plugin-chai-friendly'; 2 | import globals from 'globals'; 3 | import eslintConfigPrettier from 'eslint-config-prettier/flat'; 4 | import eslintPluginImport from 'eslint-plugin-import'; 5 | 6 | export default [ 7 | eslintConfigPrettier, // Disable Prettier‑handled style rules - prettier owns styling 8 | { 9 | ignores: ['tmp/**', 'tmp'], 10 | 11 | plugins: { 12 | 'chai-friendly': chaiFriendly, 13 | import: eslintPluginImport, 14 | }, 15 | 16 | languageOptions: { 17 | globals: { 18 | ...globals.node, 19 | ...globals.mocha, 20 | }, 21 | sourceType: 'module', 22 | }, 23 | 24 | rules: { 25 | // Plugin-specific rules 26 | 'chai-friendly/no-unused-expressions': 'error', 27 | 'import/no-unresolved': ['error', { commonjs: true, caseSensitive: true }], 28 | 'import/extensions': ['error', 'ignorePackages', { js: 'never', mjs: 'never', jsx: 'never' }], 29 | 'import/order': ['error', { groups: [['builtin', 'external', 'internal']], distinctGroup: true }], 30 | 'import/no-duplicates': 'error', 31 | 'import/prefer-default-export': 'error', 32 | 'import/no-named-as-default': 'error', 33 | 'import/no-named-as-default-member': 'error', 34 | 35 | // Quality rules (Airbnb-style, non-style) 36 | 'class-methods-use-this': 'error', 37 | //'consistent-return': 'error', 38 | 'default-case': 'error', 39 | 'default-param-last': 'error', 40 | 'dot-location': ['error', 'property'], 41 | 'no-cond-assign': ['error', 'except-parens'], 42 | 'no-constant-condition': 'error', 43 | 'no-constructor-return': 'error', 44 | 'no-empty-function': ['error', { allow: ['arrowFunctions'] }], 45 | //'no-param-reassign': ['error', { props: true }], 46 | //'no-shadow': ['error', { builtinGlobals: false }], 47 | 'no-throw-literal': 'error', 48 | 'no-useless-concat': 'error', 49 | 'prefer-const': 'error', 50 | 'prefer-destructuring': ['error', { object: true, array: false }], 51 | yoda: ['error', 'never'], 52 | 'no-use-before-define': ['error', { functions: false, classes: true, variables: true }], 53 | 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], 54 | 55 | // Logic and safety 56 | 'global-require': 'error', 57 | strict: ['error', 'never'], 58 | 'arrow-body-style': ['error', 'as-needed'], 59 | 'arrow-parens': ['error', 'always'], 60 | curly: ['error', 'multi-line'], 61 | 'dot-notation': 'error', 62 | eqeqeq: ['error', 'always', { null: 'ignore' }], 63 | 'no-alert': 'warn', 64 | 'no-else-return': ['error', { allowElseIf: false }], 65 | 'no-eval': 'error', 66 | 'no-loop-func': 'error', 67 | 'no-multi-spaces': 'error', 68 | 'no-new': 'error', 69 | 'no-restricted-properties': ['error', { object: 'Math', property: 'pow', message: 'Use ** instead.' }], 70 | 'no-return-assign': ['error', 'always'], 71 | 'no-self-compare': 'error', 72 | 'prefer-template': 'error', 73 | radix: 'error', 74 | 75 | // Overrides 76 | 'no-unused-vars': ['error', { argsIgnorePattern: 'next' }], 77 | }, 78 | }, 79 | ]; 80 | -------------------------------------------------------------------------------- /views/api/wikipedia.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.fab.fa-wikipedia-w.me-2 7 | | Wikipedia 8 | 9 | .btn-group.mb-4.d-flex(role='group') 10 | a.btn.btn-primary.w-100(href='https://api.wikimedia.org/wiki/Getting_started_with_Wikimedia_APIs') 11 | i.fas.fa-globe.fa-sm.me-2 12 | | Getting Started 13 | a.btn.btn-primary.w-100(href='https://api.wikimedia.org/wiki/API_catalog') 14 | i.fas.fa-folder-tree.fa-sm.me-2 15 | | Wikipedia API Catalog 16 | 17 | .card.text-white.bg-success.mb-4 18 | .card-header 19 | h6.panel-title.mb-0 Content Example: Node.js 20 | .card-body.text-dark.bg-white 21 | .row 22 | .col-md-4 23 | if pageFirstImage 24 | img.img-fluid.mb-3.border.rounded(src=pageFirstImage, alt=pageTitle) 25 | .col-md-8 26 | h3.mb-2 #{ pageTitle } 27 | if wikiLink 28 | p.text-muted.mb-3 29 | | Original Wikipedia Page - 30 | a(href=wikiLink, target='_blank', rel='noopener') #{ wikiLink } 31 | if error 32 | .alert.alert-danger.mt-3 #{ error } 33 | else if pageFirstSectionText 34 | p.text-break(style='white-space: pre-wrap')= pageFirstSectionText 35 | else 36 | p.text-muted No extract found for this topic. 37 | 38 | if pageSections && pageSections.length 39 | h5.mt-4 Sections 40 | p 41 | each section, idx in pageSections 42 | a(href=`${wikiLink}#${encodeURIComponent(section.replace(/\s+/g, '_'))}`, target='_blank', rel='noopener') #{ section } 43 | if idx < pageSections.length - 1 44 | | , 45 | else 46 | p.text-muted.mt-3 No sections found for this page. 47 | 48 | // Search UI card 49 | .card.text-white.bg-info.mb-4 50 | .card-header 51 | h6.panel-title.mb-0 Search Wikipedia 52 | .card-body.text-dark.bg-white 53 | .row 54 | .col-md-8 55 | form(role='form', method='GET', action='/api/wikipedia') 56 | .form-group.mb-3 57 | label.col-form-label.font-weight-bold Search Term 58 | input.form-control(type='text', name='q', placeholder='Search term', value=query || '', required) 59 | button.btn.btn-primary.mt-2(type='submit') 60 | i.fas.fa-search.me-2 61 | | Search 62 | 63 | // Results area (combined into the same card) 64 | if query 65 | if error 66 | .alert.alert-danger.mt-3 #{ error } 67 | else if searchResults && searchResults.length 68 | hr 69 | h6.mb-3 Results for "#{ query }" 70 | .list-group 71 | each result in searchResults 72 | a.list-group-item.list-group-item-action(href=`https://en.wikipedia.org/wiki/${encodeURIComponent(result.title)}`, target='_blank', rel='noopener') 73 | i.far.fa-file-lines.me-2.text-primary 74 | strong= result.title 75 | if result.snippet 76 | br 77 | small.text-muted= result.snippet 78 | else 79 | .alert.alert-warning.mt-3 No results found for "#{ query }". 80 | -------------------------------------------------------------------------------- /test/tools/server-fetch-fixtures.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Server-side fetch() API Fixture System 3 | * 4 | * This module monkey-patches the global fetch() function to record and replay HTTP responses 5 | * for server-side API calls made by Express controllers. It enables deterministic testing 6 | * without requiring live API credentials or network access. 7 | * 8 | * How it works: 9 | * - RECORD MODE (API_MODE=record): Intercepts fetch() calls, executes them normally, and saves 10 | * JSON responses to test/fixtures/ using sanitized URL-based filenames. 11 | * - REPLAY MODE (API_MODE=replay): Intercepts fetch() calls and returns saved fixtures instead 12 | * of making real network requests. Falls back to real fetch if fixture is missing (unless 13 | * API_STRICT_REPLAY=1, which blocks all non-fixture requests). 14 | * 15 | * Installation: This module is automatically installed in test/tools/start-with-memory-db.js 16 | * before the Express app loads, ensuring all server-side fetch calls are intercepted. 17 | * 18 | * Fixture keys: Generated by keyFor() helper, which sanitizes URLs (strips sensitive query 19 | * params like apikey/token) and adds body hashes for POST requests to ensure uniqueness. 20 | * 21 | * See also: server-axios-fixtures.js (same pattern for axios), fixture-helpers.js (shared utilities) 22 | */ 23 | 24 | const fs = require('fs'); 25 | const path = require('path'); 26 | const { keyFor } = require('./fixture-helpers'); 27 | 28 | const FIXTURES_DIR = path.resolve(__dirname, '..', 'fixtures'); 29 | 30 | function installServerApiFixtures({ mode = process.env.API_MODE } = {}) { 31 | const strict = process.env.API_STRICT_REPLAY === '1'; 32 | 33 | const origFetch = globalThis.fetch; 34 | 35 | if (mode === 'record') { 36 | globalThis.fetch = async (input, init = {}) => { 37 | const res = await origFetch(input, init); 38 | try { 39 | const url = typeof input === 'string' ? input : input.url; 40 | const method = (init.method || 'GET').toUpperCase(); 41 | const ct = (res.headers.get('content-type') || '').toLowerCase(); 42 | if (ct.includes('application/json')) { 43 | const body = await res.clone().text(); 44 | const file = path.join(FIXTURES_DIR, keyFor(method, url, init.body)); 45 | fs.writeFileSync(file, body, 'utf8'); 46 | } 47 | } catch {} 48 | return res; 49 | }; 50 | return; 51 | } 52 | 53 | if (mode === 'replay') { 54 | globalThis.fetch = async (input, init = {}) => { 55 | try { 56 | const url = typeof input === 'string' ? input : input.url; 57 | const method = (init.method || 'GET').toUpperCase(); 58 | const file = path.join(FIXTURES_DIR, keyFor(method, url, init.body)); 59 | if (fs.existsSync(file)) { 60 | const body = fs.readFileSync(file, 'utf8'); 61 | console.log(`[fixtures] server replay ${method} ${url}`); 62 | return new Response(body, { 63 | status: 200, 64 | headers: { 'content-type': 'application/json; charset=utf-8' }, 65 | }); 66 | } 67 | if (strict) { 68 | console.warn(`[fixtures] server strict-replay missing: ${method} ${url} — blocking network`); 69 | throw new Error(`Strict replay: missing fixture for ${method} ${url}`); 70 | } 71 | } catch {} 72 | return origFetch(input, init); 73 | }; 74 | } 75 | } 76 | 77 | module.exports = { installServerApiFixtures }; 78 | -------------------------------------------------------------------------------- /views/api/here-maps.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.fas.fa-map-location.me-2 7 | | HERE Maps API 8 | 9 | .btn-group.d-flex(role='group') 10 | a.btn.btn-primary.w-100(href='https://developer.here.com', target='_blank') 11 | i.far.fa-check-square.fa-sm.me-2 12 | | HERE Developer Portal 13 | a.btn.btn-primary.w-100(href='https://developer.here.com/documentation/map-image/topics/resource-map.html', target='_blank') 14 | i.fas.fa-laptop.fa-sm.me-2 15 | | Image Map Parameters 16 | 17 | br 18 | .pb-2.mt-2 19 | h3 Map using Here Interactive Map Service 20 | div(style='display: flex; justify-content: center') 21 | #map(style='width: 100vw; height: 50vh') 22 | 23 | div(style='display: flex; justify-content: center') 24 | | Straight line distance between the Fremont Troll and Seattle Art Museum is  25 | #directLineDistance 26 | |  miles. 27 | 28 | script(src='https://js.api.here.com/v3/3.1/mapsjs-core.js', type='text/javascript', charset='utf-8') 29 | script(src='https://js.api.here.com/v3/3.1/mapsjs-service.js', type='text/javascript', charset='utf-8') 30 | script(src='https://js.api.here.com/v3/3.1/mapsjs-mapevents.js', type='text/javascript', charset='utf-8') 31 | script(src='https://js.api.here.com/v3/3.1/mapsjs-ui.js', type='text/javascript', charset='utf-8') 32 | link(rel='stylesheet', type='text/css', href='https://js.api.here.com/v3/3.1/mapsjs-ui.css') 33 | 34 | script. 35 | const platform = new H.service.Platform({ 36 | useHTTPS: true, 37 | apikey: '#{apikey}', 38 | }); 39 | 40 | const defaultLayers = platform.createDefaultLayers(); 41 | const map = new H.Map(document.getElementById('map'), defaultLayers.vector.normal.map, { 42 | zoom: 12, 43 | center: { lat: 47.6573676, lng: -122.3126527 }, 44 | }); 45 | 46 | const mapEvents = new H.mapevents.MapEvents(map); 47 | const behavior = new H.mapevents.Behavior(mapEvents); 48 | 49 | // Create markers 50 | const marker1 = new H.map.Marker({ lat: 47.6516216, lng: -122.3498897 }); 51 | const marker2 = new H.map.Marker({ lat: 47.6123335, lng: -122.3314332 }); 52 | const marker3 = new H.map.Marker({ lat: 47.6162956, lng: -122.3555097 }); 53 | const marker4 = new H.map.Marker({ lat: 47.6205099, lng: -122.3514661 }); 54 | 55 | // Create line string for polygon 56 | const lineString = new H.geo.LineString(); 57 | lineString.pushLatLngAlt(47.6516216, -122.3498897); 58 | lineString.pushLatLngAlt(47.6123335, -122.3314332); 59 | lineString.pushLatLngAlt(47.6162956, -122.3555097); 60 | lineString.pushLatLngAlt(47.6205099, -122.3514661); 61 | 62 | // Create polygon 63 | const polygon = new H.map.Polygon(lineString, { style: { lineWidth: 2, strokeColor: 'black', fillColor: 'rgba(255, 0, 255, 0.5)' } }); 64 | 65 | // Create circle 66 | const circle = new H.map.Circle({ lat: 47.6205099, lng: -122.3514661 }, 3000, { style: { strokeColor: 'rgba(0,128,0, 0.6)', lineWidth: 1, fillColor: 'rgba(0, 128, 0, 0.3)' } }); 67 | 68 | // Add all objects to the map 69 | map.addObjects([marker1, marker2, marker3, marker4, polygon, circle]); 70 | 71 | // Calculate distance 72 | const start = marker1.getGeometry(); 73 | const end = marker2.getGeometry(); 74 | const distance = (start.distance(end) / 1609.344).toFixed(2); 75 | document.getElementById('directLineDistance').innerHTML = distance; 76 | -------------------------------------------------------------------------------- /views/ai/index.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h3 AI Integration Examples 6 | 7 | .row.g-3 8 | .col-md-4 9 | a.text-decoration-none(href='/ai/rag') 10 | .card.text-white.h-100(style='background-color: #d1e7ff') 11 | .card-body.d-flex.flex-column.flex-grow-1 12 | .d-flex.align-items-center.mb-2.flex-wrap.justify-content-center 13 | img.me-2(src='https://i.imgur.com/h9iDJCr.png', style='height: 30px; width: auto') 14 | img.me-2(src='https://i.imgur.com/pEjC3Oy.png', style='height: 30px; width: auto') 15 | img.me-2(src='https://i.imgur.com/mRw7TQs.png', style='height: 15px; width: auto') 16 | img.me-2(src='https://i.imgur.com/OEVF7HK.png', style='height: 35px; width: auto') 17 | img(src='https://i.imgur.com/vdNsjZu.png', style='height: 20px; width: auto') 18 | .text-dark.text-start.w-100 19 | h5.text-center Retrieval-Augmented Generation (RAG) 20 | ul.mb-0 21 | li LangChain pipeline and API integrations 22 | li Llama 3.3: Together.AI API 23 | li Embeddings: Hugging Face API 24 | li Vector and key-value store: MongoDB Atlas 25 | 26 | .col-md-4 27 | a.text-decoration-none(href='/ai/togetherai-camera') 28 | .card.text-white.h-100(style='background-color: #e0d1ff') 29 | .card-body.d-flex.flex-column.flex-grow-1 30 | .d-flex.align-items-center.mb-2.flex-wrap.justify-content-center 31 | img.me-2(src='https://i.imgur.com/pEjC3Oy.png', style='height: 30px; width: auto') 32 | img.me-2(src='https://i.imgur.com/mRw7TQs.png', style='height: 15px; width: auto') 33 | i.fa.fa-camera.fs-3.text-secondary.ms-2 34 | .text-dark.text-start.w-100 35 | h5.text-center Llama Vision Image Analysis + Camera 36 | ul.mb-0 37 | li Together.AI Vision API 38 | li Camera input integration 39 | li Multimodal inference 40 | .col-md-4 41 | a.text-decoration-none(href='/ai/togetherai-classifier') 42 | .card.text-white.h-100(style='background-color: #d1ffd6') 43 | .card-body.d-flex.flex-column.flex-grow-1 44 | .d-flex.align-items-center.mb-2.flex-wrap.justify-content-center 45 | img.me-2(src='https://i.imgur.com/pEjC3Oy.png', style='height: 30px; width: auto') 46 | img.me-2(src='https://i.imgur.com/mRw7TQs.png', style='height: 15px; width: auto') 47 | .text-dark.text-start.w-100 48 | h5.text-center Llama One-shot Text Classification 49 | ul.mb-0 50 | li Together.AI Llama Instruct API 51 | li Text classification 52 | li Fast, scalable inference 53 | 54 | .col-md-4 55 | a.text-decoration-none(href='/ai/openai-moderation') 56 | .card.text-white.h-100(style='background-color: #fff3cd') 57 | .card-body.d-flex.flex-column.flex-grow-1 58 | .d-flex.align-items-center.mb-2.flex-wrap.justify-content-center 59 | img(src='https://i.imgur.com/EP2SafD.png', style='height: 40px; width: auto') 60 | .text-dark.text-start.w-100 61 | h5.text-center OpenAI LLM Input Moderation 62 | ul.mb-0 63 | li OpenAI Moderation API 64 | li Real-time input filtering 65 | li Safe content enforcement 66 | -------------------------------------------------------------------------------- /test/playwright.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig, devices } = require('@playwright/test'); 2 | const dotenv = require('dotenv'); 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | // Preserve any MONGODB_URI that was set before loading dotenv, since it is set to the memory server starter 6 | const originalMongoUri = process.env.MONGODB_URI; 7 | const result = dotenv.config({ path: path.resolve(__dirname, '.env.test'), quiet: true }); 8 | // If MONGODB_URI was not set by the outer environment (originalMongoUri undefined) 9 | // but exists in the parsed dotenv, remove it so the memory-server starter can create a DB. 10 | if (!originalMongoUri && result && result.parsed && result.parsed.MONGODB_URI) { 11 | delete process.env.MONGODB_URI; 12 | } 13 | 14 | // Detect if a replay or record project is being run 15 | const isReplay = process.argv.some((arg) => arg === '--project=chromium-replay' || arg === '--project=chromium-nokey-replay'); 16 | if (isReplay) { 17 | process.env.API_MODE = 'replay'; 18 | process.env.API_STRICT_REPLAY = '1'; 19 | } 20 | const isRecord = process.argv.some((arg) => arg === '--project=chromium-record' || arg === '--project=chromium-nokey-record'); 21 | if (isRecord) { 22 | process.env.API_MODE = 'record'; 23 | } 24 | 25 | const webServerEnv = { 26 | ...process.env, 27 | SESSION_SECRET: process.env.SESSION_SECRET || 'test_session_secret', 28 | RATE_LIMIT_GLOBAL: '500', 29 | RATE_LIMIT_STRICT: '20', 30 | RATE_LIMIT_LOGIN: '50', 31 | }; 32 | 33 | // Create `tmp` dir in case if it doesn't exist yet 34 | // so `tee ../tmp/playwright-webserver.log` doesn't fail 35 | try { 36 | const tmpRoot = path.resolve(__dirname, '..', 'tmp'); 37 | if (!fs.existsSync(tmpRoot)) { 38 | fs.mkdirSync(tmpRoot); 39 | } 40 | } catch (e) { 41 | console.error('[playwright.config] Failed to create tmp directory:', e && e.message); 42 | } 43 | 44 | module.exports = defineConfig({ 45 | fullyParallel: true, 46 | forbidOnly: !!process.env.CI, 47 | retries: process.env.CI ? 2 : 0, 48 | workers: process.env.API_MODE === 'record' ? 1 : process.env.CI ? 1 : 2, 49 | outputDir: '../tmp/playwright-artifacts', 50 | reporter: [['html', { outputFolder: '../tmp/playwright-report', open: 'never' }]], 51 | use: { 52 | baseURL: process.env.BASE_URL, 53 | trace: 'on-first-retry', 54 | // headless: false, launchOptions: { slowMo: 200 }, // Uncomment to see the browser 55 | }, 56 | projects: [ 57 | { 58 | name: 'chromium', 59 | use: { ...devices['Desktop Chrome'] }, 60 | testMatch: ['e2e/*.e2e.test.js', 'e2e-nokey/*.e2e.test.js'], 61 | }, 62 | { 63 | name: 'chromium-record', 64 | use: { ...devices['Desktop Chrome'] }, 65 | testMatch: ['e2e/*.e2e.test.js', 'e2e-nokey/*.e2e.test.js'], 66 | }, 67 | { 68 | name: 'chromium-replay', 69 | use: { ...devices['Desktop Chrome'] }, 70 | testMatch: ['e2e/*.e2e.test.js', 'e2e-nokey/*.e2e.test.js'], 71 | }, 72 | { 73 | name: 'chromium-nokey-live', 74 | use: { ...devices['Desktop Chrome'] }, 75 | testMatch: ['e2e-nokey/*.e2e.test.js'], 76 | }, 77 | { 78 | name: 'chromium-nokey-record', 79 | use: { ...devices['Desktop Chrome'] }, 80 | testMatch: ['e2e-nokey/*.e2e.test.js'], 81 | }, 82 | { 83 | name: 'chromium-nokey-replay', 84 | use: { ...devices['Desktop Chrome'] }, 85 | testMatch: ['e2e-nokey/*.e2e.test.js'], 86 | }, 87 | ], 88 | webServer: { 89 | command: 'node ./tools/playwright-start-and-log.js', 90 | url: 'http://127.0.0.1:8080', 91 | reuseExistingServer: !process.env.CI, 92 | env: webServerEnv, 93 | }, 94 | }); 95 | -------------------------------------------------------------------------------- /patches/langchain+0.3.36.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/langchain/dist/embeddings/cache_backed.cjs b/node_modules/langchain/dist/embeddings/cache_backed.cjs 2 | index fcd1a1f..056c868 100644 3 | --- a/node_modules/langchain/dist/embeddings/cache_backed.cjs 4 | +++ b/node_modules/langchain/dist/embeddings/cache_backed.cjs 5 | @@ -66,16 +66,16 @@ class CacheBackedEmbeddings extends embeddings_1.Embeddings { 6 | writable: true, 7 | value: void 0 8 | }); 9 | + 10 | this.underlyingEmbeddings = fields.underlyingEmbeddings; 11 | this.documentEmbeddingStore = fields.documentEmbeddingStore; 12 | + this.queryEmbeddingStore = fields.queryEmbeddingStore ?? null; 13 | } 14 | /** 15 | * Embed query text. 16 | * 17 | - * This method does not support caching at the moment. 18 | - * 19 | - * Support for caching queries is easy to implement, but might make 20 | - * sense to hold off to see the most common patterns. 21 | + * Patched with caching support (CommonJS only) because 22 | + * caching queries is not yet supported in LangChain core. 23 | * 24 | * If the cache has an eviction policy, we may need to be a bit more careful 25 | * about sharing the cache between documents and queries. Generally, 26 | @@ -85,6 +85,15 @@ class CacheBackedEmbeddings extends embeddings_1.Embeddings { 27 | * @returns The embedding for the given text. 28 | */ 29 | async embedQuery(document) { 30 | + if (this.queryEmbeddingStore) { 31 | + const cachedEmbedding = await this.queryEmbeddingStore.mget([document]); 32 | + if (cachedEmbedding[0]) { 33 | + return cachedEmbedding[0]; 34 | + } 35 | + const embedding = await this.underlyingEmbeddings.embedQuery(document); 36 | + await this.queryEmbeddingStore.mset([[document, embedding]]); 37 | + return embedding; 38 | + } 39 | return this.underlyingEmbeddings.embedQuery(document); 40 | } 41 | /** 42 | @@ -123,6 +132,7 @@ class CacheBackedEmbeddings extends embeddings_1.Embeddings { 43 | * @param underlyingEmbeddings Embeddings used to populate the cache for new documents. 44 | * @param documentEmbeddingStore Stores raw document embedding values. Keys are hashes of the document content. 45 | * @param options.namespace Optional namespace for store keys. 46 | + * @param options.queryEmbeddingStore Optional - Stores raw query embedding values. Keys are hashes of the query content. 47 | * @returns A new CacheBackedEmbeddings instance. 48 | */ 49 | static fromBytesStore(underlyingEmbeddings, documentEmbeddingStore, options) { 50 | @@ -134,9 +144,18 @@ class CacheBackedEmbeddings extends embeddings_1.Embeddings { 51 | valueSerializer: (value) => encoder.encode(JSON.stringify(value)), 52 | valueDeserializer: (serializedValue) => JSON.parse(decoder.decode(serializedValue)), 53 | }); 54 | + const queryEncoderBackedStore = options.queryEmbeddingStore 55 | + ? new encoder_backed_js_1.EncoderBackedStore({ 56 | + store: options.queryEmbeddingStore, 57 | + keyEncoder: (key) => (options?.namespace ?? "") + (0, hash_1.insecureHash)(key), 58 | + valueSerializer: (value) => encoder.encode(JSON.stringify(value)), 59 | + valueDeserializer: (serializedValue) => JSON.parse(decoder.decode(serializedValue)), 60 | + }) 61 | + : null; 62 | return new this({ 63 | underlyingEmbeddings, 64 | documentEmbeddingStore: encoderBackedStore, 65 | + queryEmbeddingStore: queryEncoderBackedStore, 66 | }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/fixtures/GET_https%3A%2F%2Fen.wikipedia.org%2Fw%2Fapi.php%3Faction%3Dparse%26format%3Djson%26origin%3D_%26page%3DNode.js%26prop%3Dsections.json: -------------------------------------------------------------------------------- 1 | { 2 | "parse": { 3 | "title": "Node.js", 4 | "pageid": 26415635, 5 | "sections": [ 6 | { "toclevel": 1, "level": "2", "line": "History", "number": "1", "index": "1", "fromtitle": "Node.js", "byteoffset": 4509, "anchor": "History", "linkAnchor": "History" }, 7 | { "toclevel": 2, "level": "3", "line": "Branding", "number": "1.1", "index": "2", "fromtitle": "Node.js", "byteoffset": 11313, "anchor": "Branding", "linkAnchor": "Branding" }, 8 | { "toclevel": 1, "level": "2", "line": "Overview", "number": "2", "index": "3", "fromtitle": "Node.js", "byteoffset": 11916, "anchor": "Overview", "linkAnchor": "Overview" }, 9 | { "toclevel": 2, "level": "3", "line": "Platform architecture", "number": "2.1", "index": "4", "fromtitle": "Node.js", "byteoffset": 15713, "anchor": "Platform_architecture", "linkAnchor": "Platform_architecture" }, 10 | { "toclevel": 2, "level": "3", "line": "Industry support", "number": "2.2", "index": "5", "fromtitle": "Node.js", "byteoffset": 16754, "anchor": "Industry_support", "linkAnchor": "Industry_support" }, 11 | { "toclevel": 1, "level": "2", "line": "Releases", "number": "3", "index": "6", "fromtitle": "Node.js", "byteoffset": 20649, "anchor": "Releases", "linkAnchor": "Releases" }, 12 | { "toclevel": 1, "level": "2", "line": "Technical details", "number": "4", "index": "7", "fromtitle": "Node.js", "byteoffset": 25358, "anchor": "Technical_details", "linkAnchor": "Technical_details" }, 13 | { "toclevel": 2, "level": "3", "line": "Internals", "number": "4.1", "index": "8", "fromtitle": "Node.js", "byteoffset": 25498, "anchor": "Internals", "linkAnchor": "Internals" }, 14 | { "toclevel": 2, "level": "3", "line": "Threading", "number": "4.2", "index": "9", "fromtitle": "Node.js", "byteoffset": 26108, "anchor": "Threading", "linkAnchor": "Threading" }, 15 | { "toclevel": 2, "level": "3", "line": "V8", "number": "4.3", "index": "10", "fromtitle": "Node.js", "byteoffset": 28859, "anchor": "V8", "linkAnchor": "V8" }, 16 | { "toclevel": 2, "level": "3", "line": "Package management", "number": "4.4", "index": "11", "fromtitle": "Node.js", "byteoffset": 29277, "anchor": "Package_management", "linkAnchor": "Package_management" }, 17 | { "toclevel": 2, "level": "3", "line": "Event loop", "number": "4.5", "index": "12", "fromtitle": "Node.js", "byteoffset": 29526, "anchor": "Event_loop", "linkAnchor": "Event_loop" }, 18 | { "toclevel": 2, "level": "3", "line": "WebAssembly", "number": "4.6", "index": "13", "fromtitle": "Node.js", "byteoffset": 30355, "anchor": "WebAssembly", "linkAnchor": "WebAssembly" }, 19 | { "toclevel": 2, "level": "3", "line": "Native bindings", "number": "4.7", "index": "14", "fromtitle": "Node.js", "byteoffset": 30516, "anchor": "Native_bindings", "linkAnchor": "Native_bindings" }, 20 | { "toclevel": 1, "level": "2", "line": "Project governance", "number": "5", "index": "15", "fromtitle": "Node.js", "byteoffset": 32225, "anchor": "Project_governance", "linkAnchor": "Project_governance" }, 21 | { "toclevel": 1, "level": "2", "line": "References", "number": "6", "index": "16", "fromtitle": "Node.js", "byteoffset": 35146, "anchor": "References", "linkAnchor": "References" }, 22 | { "toclevel": 1, "level": "2", "line": "Further reading", "number": "7", "index": "17", "fromtitle": "Node.js", "byteoffset": 35179, "anchor": "Further_reading", "linkAnchor": "Further_reading" }, 23 | { "toclevel": 1, "level": "2", "line": "External links", "number": "8", "index": "18", "fromtitle": "Node.js", "byteoffset": 36320, "anchor": "External_links", "linkAnchor": "External_links" } 24 | ], 25 | "showtoc": "" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/tools/server-axios-fixtures.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Server-side Axios API Fixture System 3 | * 4 | * This module uses Axios interceptors to record and replay HTTP responses for server-side 5 | * API calls made by Express controllers. It enables deterministic testing without requiring 6 | * live API credentials or network access. 7 | * 8 | * How it works: 9 | * - RECORD MODE (API_MODE=record): Axios response interceptor captures successful responses 10 | * and saves JSON data to test/fixtures/ using sanitized URL-based filenames. 11 | * - REPLAY MODE (API_MODE=replay): Axios request interceptor short-circuits requests by 12 | * returning saved fixture data. If fixture exists, it rejects the request with a special 13 | * marker (isAxiosFixture:true), which the response error interceptor converts back to a 14 | * successful response. Falls back to real axios if fixture is missing (unless 15 | * API_STRICT_REPLAY=1, which blocks all non-fixture requests). 16 | * 17 | * Installation: This module is automatically installed in test/tools/start-with-memory-db.js 18 | * before the Express app loads, ensuring all server-side axios calls are intercepted. 19 | * 20 | * Fixture keys: Generated by keyFor() helper, which sanitizes URLs (strips sensitive query 21 | * params like apikey/token) and adds body hashes for POST requests to ensure uniqueness. 22 | * 23 | * See also: server-fetch-fixtures.js (same pattern for fetch), fixture-helpers.js (shared utilities) 24 | */ 25 | 26 | const fs = require('fs'); 27 | const path = require('path'); 28 | const axios = require('axios'); 29 | const { keyFor } = require('./fixture-helpers'); 30 | 31 | const FIXTURES_DIR = path.resolve(__dirname, '..', 'fixtures'); 32 | 33 | function installServerAxiosFixtures({ mode = process.env.API_MODE } = {}) { 34 | const strict = process.env.API_STRICT_REPLAY === '1'; 35 | 36 | if (mode === 'record') { 37 | axios.interceptors.response.use((response) => { 38 | try { 39 | const { config, headers, data } = response; 40 | const ct = (headers && headers['content-type']) || ''; 41 | if (typeof data === 'object' || ct.toLowerCase().includes('application/json')) { 42 | const file = path.join(FIXTURES_DIR, keyFor(config.method || 'GET', config.url, config.data)); 43 | fs.writeFileSync(file, typeof data === 'string' ? data : JSON.stringify(data), 'utf8'); 44 | } 45 | } catch {} 46 | return response; 47 | }); 48 | } else if (mode === 'replay') { 49 | axios.interceptors.request.use((config) => { 50 | try { 51 | const file = path.join(FIXTURES_DIR, keyFor(config.method || 'GET', config.url, config.data)); 52 | if (fs.existsSync(file)) { 53 | const data = JSON.parse(fs.readFileSync(file, 'utf8')); 54 | console.log(`[fixtures] server replay ${(config.method || 'GET').toUpperCase()} ${config.url}`); 55 | return Promise.reject({ 56 | isAxiosFixture: true, 57 | config, 58 | response: { config, status: 200, statusText: 'OK', headers: { 'content-type': 'application/json' }, data, request: {} }, 59 | }); 60 | } 61 | if (strict) { 62 | console.warn(`[fixtures] server strict-replay missing: ${(config.method || 'GET').toUpperCase()} ${config.url} — blocking network`); 63 | throw new Error(`Strict replay: missing fixture for ${(config.method || 'GET').toUpperCase()} ${config.url}`); 64 | } 65 | } catch {} 66 | return config; 67 | }); 68 | 69 | axios.interceptors.response.use( 70 | (response) => response, 71 | (error) => (error?.isAxiosFixture ? Promise.resolve(error.response) : Promise.reject(error)), 72 | ); 73 | } 74 | } 75 | 76 | module.exports = { installServerAxiosFixtures }; 77 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # 2 | # The App ID, Keys, or Secrets in this file are placeholders. 3 | # You would need to register with API providers that tour application needs and use your own keys. 4 | # You can override the values using environment variables if you wish. Some developers prefer that 5 | # to avoid accidental check-in of credentials to code repos. 6 | # (i.e. enter the following in console: "export SMTP_PASSWORD=blah_blah_blah" ) 7 | # Most hosting providers provide means to set environment variables as well. 8 | # 9 | BASE_URL=http://localhost:8080 10 | MONGODB_URI=mongodb://localhost:27017/test 11 | SITE_CONTACT_EMAIL=youremail@yourdomain.com 12 | TRANSACTION_EMAIL=youremail-OR-noreply@yourdomain.com 13 | 14 | SESSION_SECRET=Your Session Secret goes here 15 | 16 | SMTP_USER=your-smtp-username 17 | SMTP_PASSWORD=your-smtp-password 18 | SMTP_HOST=your.smtp.host.com 19 | 20 | # 21 | # API KEYS 22 | # 23 | ALPHA_VANTAGE_KEY=api-key 24 | 25 | DISCORD_CLIENT_ID=discord-client-id/discord-app-id 26 | DISCORD_CLIENT_SECRET=discord-client-secret 27 | 28 | # To suppress multiple tip/advertisement lines about the dotenvx service 29 | DOTENV_KEY="" 30 | 31 | FACEBOOK_ID=754220301289665 32 | FACEBOOK_SECRET=41860e58c256a3d7ad8267d3c1939a4a 33 | # FB Pixel ID is optional if you are trying to do customer rtracking 34 | FACEBOOK_PIXEL_ID= 35 | 36 | FOURSQUARE_APIKEY=foursquare-service-key 37 | 38 | GITHUB_ID=cb448b1d4f0c743a1e36 39 | GITHUB_SECRET=815aa4606f476444691c5f1c16b9c70da6714dc6 40 | 41 | # Google Analytics is optional if you are trying to do customer rtracking 42 | GOOGLE_ANALYTICS_ID= 43 | 44 | # Google credentials for OAuth, APIs such as Gogole Docs, Google Sheets, etc. 45 | GOOGLE_PROJECT_ID=hackathon-starter-xxxxxx 46 | GOOGLE_CLIENT_ID=828110519058.apps.googleusercontent.com 47 | GOOGLE_CLIENT_SECRET=JdZsIaWhUFIchmC1a_IZzOHb 48 | GOOGLE_API_KEY=your-google-api-key 49 | GOOGLE_MAP_API_KEY=google-map-api-key 50 | GOOGLE_RECAPTCHA_SITE_KEY= 51 | 52 | HERE_API_KEY=9bxxxxxxxxxxxxxxxxJFHg 53 | 54 | HUGGINGFACE_KEY=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 55 | HUGGINGFACE_EMBEDDING_MODEL=BAAI/bge-small-en-v1.5 56 | HUGGINGFACE_PROVIDER=hf-inference 57 | 58 | LASTFM_KEY=c8c0ea1c4a6b199b3429722512fbd17f 59 | LASTFM_SECRET=is cb7857b8fba83f819ea46ca13681fe71 60 | 61 | LINKEDIN_ID=77chexmowru601 62 | LINKEDIN_SECRET=szdC8lN2s2SuMSy8 63 | 64 | MICROSOFT_CLIENT_ID=a533430d-c980-4ffc-8c74-d1b123456780 65 | MICROSOFT_CLIENT_SECRET=HbX8R~0PXopRmDgfliVl~6fEHTtFg1NC98xmEssZ 66 | 67 | LOB_KEY=test_814e892b199d65ef6dbb3e4ad24689559ca 68 | 69 | NYT_KEY=9548be6f3a64163d23e1539f067fcabd:5:68537648 70 | 71 | OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 72 | 73 | PAYPAL_ID=AdGE8hDyixVoHmbhASqAThfbBcrbcgiJPBwlAM7u7Kfq3YU-iPGc6BXaTppt 74 | PAYPAL_SECRET=EPN0WxB5PaRaumTB1ZpCuuTqLqIlF6_EWUcAbZV99Eu86YeNBVm9KVsw_Ez5 75 | 76 | QUICKBOOKS_CLIENT_ID=ABQSXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxnIxiz 77 | QUICKBOOKS_CLIENT_SECRET=Kux3xxxxxxxxxxxxxxxxxxxxxxxxxxxxj58mqYCD 78 | 79 | STEAM_KEY=D1240DEF4D41D416FD291D0075B6ED3F 80 | 81 | STRIPE_SKEY=sk_test_BQokikJOvBiI2HlWgH4olfQ2 82 | STRIPE_PKEY=pk_test_6pRNASCoBOKtIshFeQd4XMUh 83 | 84 | TOGETHERAI_API_KEY=sample-api-key 85 | TOGETHERAI_MODEL=meta-llama/Llama-3.3-70B-Instruct-Turbo-Free 86 | TOGETHERAI_VISION_MODEL=meta-llama/Llama-Vision-Free 87 | 88 | TRAKT_ID=trakt-client-id 89 | TRAKT_SECRET=trackt-client-secret 90 | 91 | TUMBLR_KEY=FaXbGf5gkhswzDqSMYI42QCPYoHsu5MIDciAhTyYjehotQpJvM 92 | TUMBLR_SECRET=QpCTs5IMMCsCImwdvFiqyGtIZwowF5o3UXonjPoNp4HVtJAL4o 93 | 94 | TWILIO_SID=AC6f0edc4c47becc6d0a952536fc9a6025 95 | TWILIO_TOKEN=a67170ff7afa2df3f4c7d97cd240d0f3 96 | TWILIO_FROM_NUMBER=+15005550006 97 | 98 | TWITCH_CLIENT_ID=khdxxxxxxxxxxxxxxxxxxxxxxxxvqd 99 | TWITCH_CLIENT_SECRET=exhyxxxxxxxxxxxxxxxxxxxxxxudn5 100 | 101 | X_KEY=6NNBDyJ2TavL407A3lWxPFKBI 102 | X_SECRET=ZHaYyK3DQCqv49Z9ofsYdqiUgeoICyh6uoBgFfu7OeYC7wTQKa 103 | -------------------------------------------------------------------------------- /PROD_CHECKLIST.md: -------------------------------------------------------------------------------- 1 | If you are done with your hackathon and thinking about launching your project into production, or if you are just using this boilerplate to start your soon to be in production application, this document is a checklist to help you get your application production ready. 2 | 3 | - Remove unused code and configs 4 | - Add a proxy such as Cloudflare in front of your production deployment. Adjust the numberOfProxies logic in app.js if needed 5 | - Update the session cookie configs with sameSite attribute, domain, and path 6 | - Add Terms of Service and Privacy Policy 7 | - Update `LICENSE.md` and the relevant license field in package.json if applicable - See [npm's doc](https://docs.npmjs.com/files/package.json#license). 8 | - Add [sitemap.xml](https://en.wikipedia.org/wiki/Sitemaps) and [robots.txt](https://moz.com/learn/seo/robotstxt) 9 | - Update Google Analytics ID 10 | - Add Facebook App/Pixel ID 11 | - Add Winston Logging, and replace console.log statements with Winston; have a process for monitoring errors to identify bugs or other issues after launch. 12 | - SEO and Social Media Improvements 13 | - Create a deployment pipeline with a pre-prod/integration test stage. 14 | - (optional) Add email verification _Some experimental data has shown that bogus email addresses are not a significant problem in many cases_ 15 | - (optional) Add a filter with [disposable-email-domains](https://www.npmjs.com/package/disposable-email-domains). _Some experimental data has shown that use of disposable emails is typically rare, and in many cases it might not be worth adding the filter._ 16 | 17 | ### Remove unused code and configs 18 | 19 | The following is a list of various code that you may not potentially be using and you could remove depending on your application: 20 | 21 | - Unused keys from .env file 22 | - /controllers/api.js entirely 23 | - /views/api entirely 24 | - app.js: 25 | - multer 26 | - apiController 27 | - Openshift env references 28 | - csrf check exception for /api/upload 29 | - All API example routes 30 | - OAuth routes for authentications that you are not using (i.e. GitHub, LinkedIn, etc. based on your app) 31 | - All OAuth authorization routes 32 | - passport.js all references and functions related to: 33 | - Github, LinkedIn, OpenID, OAuth, OAuth2 34 | - model/User.js 35 | - key pairs for Github, LinkedIn, Steam 36 | - package.json 37 | - @octokit/rest, lastfm, lob, multer, node-linkedin, passport-github2, passport-linkedin-oauth2, passport-oauth, paypal-rest-sdk, stripe, twilio 38 | - /test 39 | - Replace E2E and API example tests with new tests for your application 40 | - views/account/login.pug 41 | - Some or all of the last form-group set, which are the social login choices 42 | - views/account/profile.pug 43 | - Link/unlink buttons for GitHub, LinkedIn, Steam 44 | - Remove README, changelog, this guide, Docker-related files if not using them 45 | - Create a domain whitelist for your app in Here's developer portal if you are using the HERE Maps API. 46 | - Add unit tests so you can test and incorporate dependency and upstream updates with less effort. GPT tools may create some good unit tests with very low effort. 47 | 48 | ### Search Engine Optimization (SEO) 49 | 50 | Note that SEO only applies to the pages that will be publicly visible with no authentication. Note that some of the following fields need to be added to the HTML header section similar to the page [title](https://github.com/sahat/hackathon-starter/blob/master/views/layout.pug#L9) 51 | 52 | - Add Open Graph fields for SEO 53 | Open Graph data: 54 | ``` 55 | 56 | 57 | 58 | 59 | ``` 60 | - Add a page description, which will show up in the search results of the search engine. 61 | 62 | ``` 63 | 64 | ``` 65 | -------------------------------------------------------------------------------- /views/account/login.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h3 Sign in 6 | form(method='POST') 7 | input(type='hidden', name='_csrf', value=_csrf) 8 | .form-group.row.mb-3 9 | label.col-md-3.col-form-label.font-weight-bold.text-right(for='email') Email 10 | .col-md-7 11 | input#email.form-control(type='email', name='email', placeholder='Email', autofocus, autocomplete='email', required) 12 | .form-group.row.mb-3 13 | .col-md-3.offset-md-3 14 | .form-check 15 | input#loginByEmailLink.form-check-input(type='checkbox', name='loginByEmailLink') 16 | label.form-check-label(for='loginByEmailLink') Login by Email Link 17 | .form-group.row.mb-3.password-group 18 | label.col-md-3.col-form-label.font-weight-bold.text-right(for='password') Password 19 | .col-md-7 20 | input#password.form-control(type='password', name='password', placeholder='Password', autocomplete='current-password', required) 21 | .form-group.row 22 | .col-md-7.offset-md-3 23 | button.col-md-2.btn.btn-primary(type='submit') 24 | i.far.fa-user.fa-sm.me-2 25 | | Login 26 | a.btn.btn-link(href='/forgot') Forgot your password? 27 | .form-group.row 28 | .col-md-7.offset-md-3.d-grid.gap-2 29 | hr 30 | .form-group.row 31 | .col-md-3.offset-md-3.d-grid.gap-2 32 | a.btn.btn-block.btn-google.btn-social(href='/auth/google') 33 | i.fab.fa-google.fa-xs 34 | | Sign in with Google 35 | //- Microsoft branding requirements: https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-branding-in-apps 36 | a.btn.btn-block.btn-microsoft.btn-social(href='/auth/microsoft') 37 | svg.ms-logo(xmlns='http://www.w3.org/2000/svg', width='32', height='32', viewBox='0 0 21 21', style='margin-right: 10px; vertical-align: middle') 38 | rect(x='1', y='1', width='9', height='9', fill='#f25022') 39 | rect(x='1', y='11', width='9', height='9', fill='#00a4ef') 40 | rect(x='11', y='1', width='9', height='9', fill='#7fba00') 41 | rect(x='11', y='11', width='9', height='9', fill='#ffb900') 42 | | Sign in with Microsoft 43 | a.btn.btn-block.btn-facebook.btn-social(href='/auth/facebook') 44 | i.fab.fa-facebook-f.fa-sm 45 | | Sign in with Facebook 46 | a.btn.btn-block.btn-twitter.btn-social(href='/auth/x') 47 | i.fab.fa-x-twitter.fa-sm 48 | | Sign in with X 49 | a.btn.btn-block.btn-linkedin.btn-social(href='/auth/linkedin') 50 | i.fab.fa-linkedin-in.fa-sm 51 | | Sign in with LinkedIn 52 | a.btn.btn-block.btn-twitch.btn-social(href='/auth/twitch') 53 | i.fab.fa-twitch.fa-sm 54 | | Sign in with Twitch 55 | a.btn.btn-block.btn-github.btn-social(href='/auth/github') 56 | i.fab.fa-github.fa-sm 57 | | Sign in with GitHub 58 | a.btn.btn-block.btn-discord.btn-social(href='/auth/discord') 59 | i.fab.fa-discord 60 | | Sign in with Discord 61 | 62 | script. 63 | document.getElementById('loginByEmailLink').addEventListener('change', function () { 64 | const passwordGroup = document.querySelector('.password-group'); 65 | const passwordInput = document.getElementById('password'); 66 | if (this.checked) { 67 | passwordGroup.style.display = 'none'; 68 | passwordInput.removeAttribute('required'); 69 | // Don't clear the password here 70 | } else { 71 | passwordGroup.style.display = 'flex'; 72 | passwordInput.setAttribute('required', ''); 73 | } 74 | }); 75 | 76 | // Only clear password at form submission if email link is selected 77 | document.querySelector('form').addEventListener('submit', function (e) { 78 | if (document.getElementById('loginByEmailLink').checked) { 79 | document.getElementById('password').value = ''; 80 | } 81 | }); 82 | -------------------------------------------------------------------------------- /views/api/stripe.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.fab.fa-cc-stripe.fa-sm.me-2 7 | | Stripe API 8 | 9 | .btn-group.d-flex(role='group') 10 | a.btn.btn-primary.w-100(href='https://stripe.com/docs/api', target='_blank') 11 | i.fas.fa-code.fa-sm.me-2 12 | | API Reference 13 | a.btn.btn-primary.w-100(href='https://manage.stripe.com/account/apikeys', target='_blank') 14 | i.fas.fa-cog.fa-sm.me-2 15 | | Get API Keys 16 | 17 | br 18 | 19 | form(method='POST') 20 | input(type='hidden', name='_csrf', value=_csrf) 21 | script.stripe-button(src='https://checkout.stripe.com/checkout.js', data-key=publishableKey, data-image='https://static.tumblr.com/nljhkjv/z0Jlpk23i/logo', data-name='Hackathon Starter', data-description='Caramel Macchiato ($3.95)', data-amount='395') 22 | 23 | h3 24 | i.far.fa-credit-card.fa-sm.me-2 25 | | Test Cards 26 | p In test mode, you can use these test cards to simulate a successful transaction: 27 | 28 | table.table.table-striped.table-bordered.table-sm 29 | thead 30 | tr 31 | th Number 32 | th Card type 33 | tbody 34 | tr 35 | td 4242 4242 4242 4242 36 | td Visa 37 | tr 38 | td 4012 8888 8888 1881 39 | td Visa 40 | tr 41 | td 5555 5555 5555 4444 42 | td MasterCard 43 | tr 44 | td 5105 1051 0510 5100 45 | td MasterCard 46 | tr 47 | td 3782 822463 10005 48 | td American Express 49 | tr 50 | td 3714 496353 98431 51 | td American Express 52 | tr 53 | td 6011 1111 1111 1117 54 | td Discover 55 | tr 56 | td 6011 0009 9013 9424 57 | td Discover 58 | tr 59 | td 3056 9309 0259 04 60 | td Diners Club 61 | tr 62 | td 3852 0000 0232 37 63 | td Diners Club 64 | tr 65 | td 3530 1113 3330 0000 66 | td JCB 67 | tr 68 | td 3566 0020 2036 0505 69 | td JCB 70 | 71 | .card.text-white.bg-primary 72 | .card-header Stripe Successful Charge Example 73 | .card-body.text-dark.bg-white 74 | p This is the response you will get when customer's card has been charged successfully. 75 | | You could use some of the data below for logging purposes. 76 | pre.card.bg-light. 77 | { id: 'ch_103qzW2eZvKYlo2CiYcKs6Sw', 78 | object: 'charge', 79 | created: 1397510564, 80 | livemode: false, 81 | paid: true, 82 | amount: 395, 83 | currency: 'usd', 84 | refunded: false, 85 | card: 86 | { id: 'card_103qzW2eZvKYlo2CJ2Ss4kwS', 87 | object: 'card', 88 | last4: '4242', 89 | type: 'Visa', 90 | exp_month: 11, 91 | exp_year: 2015, 92 | fingerprint: 'Xt5EWLLDS7FJjR1c', 93 | customer: null, 94 | country: 'US', 95 | name: 'sahat@me.com', 96 | address_line1: null, 97 | address_line2: null, 98 | address_city: null, 99 | address_state: null, 100 | address_zip: null, 101 | address_country: null, 102 | cvc_check: 'pass', 103 | address_line1_check: null, 104 | address_zip_check: null }, 105 | captured: true, 106 | refunds: [], 107 | balance_transaction: 'txn_103qzW2eZvKYlo2CNEcJV8SN', 108 | failure_message: null, 109 | failure_code: null, 110 | amount_refunded: 0, 111 | customer: null, 112 | invoice: null, 113 | description: 'sahat@me.com', 114 | dispute: null, 115 | metadata: {}, 116 | statement_description: null } 117 | -------------------------------------------------------------------------------- /views/api/twilio.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | .pb-2.mt-2.mb-4.border-bottom 5 | h2 6 | i.fas.fa-phone.fa-sm.me-2(style='color: #f00') 7 | | Twilio API 8 | 9 | .btn-group.mb-4.d-flex(role='group') 10 | a.btn.btn-primary.w-100(href='https://www.twilio.com/docs/libraries/reference/twilio-node/', target='_blank') 11 | i.far.fa-check-square.fa-sm.me-2 12 | | Twilio Node 13 | a.btn.btn-primary.w-100(href='https://www.twilio.com/docs/sms/debugging-tools', target='_blank') 14 | i.fas.fa-laptop.fa-sm.me-2 15 | | Twilio Debugging Tools 16 | a.btn.btn-primary.w-100(href='https://www.twilio.com/docs/api/rest', target='_blank') 17 | i.fas.fa-code-branch.fa-sm.me-2 18 | | REST API 19 | 20 | if isSandbox 21 | .alert.alert-warning(role='alert') 22 | strong Warning: 23 | | 24 | | The instance is configured to send SMS from a Twillio sandbox phone number: 25 | b= ` ${fromNumber}` 26 | | . No actual SMS will be sent. 27 | p 28 | i.fas.fa-hand-point-right.fa-sm.me-2 29 | | 30 | | Tip: Ensure you are using Test credentials instead of Live credentials during development to avoid charges for using sandbox numbers as they are invalid for production use. See 31 | a(href='https://console.twilio.com/us1/account/keys-credentials/api-keys', target='_blank') Auth Tokens page of your Console 32 | | 33 | | for both your Test and Live credentials. 34 | .mt-4 35 | .alert.alert-secondary 36 | h6 Example Numbers to Text 37 | p.mb-0 38 | | You can enter a valid phone number or use one of Twilio's 39 | a(href='https://www.twilio.com/docs/iam/test-credentials#test-sms-capable-numbers', target='_blank') test phone numbers 40 | | 41 | | for simulating errors during development. For example: 42 | ul.mb-0 43 | li 44 | | 45 | | To: (any valid sms capable US number - no text will be sent with a sandbox/test setup) 46 | li 47 | | 48 | | To: +15005550006 49 | | 50 | | : This phone number is valid for testing. 51 | li 52 | | 53 | | To: +15005550001 54 | | 55 | | : This phone number is invalid. 56 | li 57 | | 58 | | To: +15005550002 59 | | 60 | | : Can not route SMS to this number. 61 | li 62 | | 63 | | To: +15005550003 64 | | 65 | | : Your account doesn't have the international permissions necessary to call this number. 66 | li 67 | | 68 | | To: +15005550004 69 | | 70 | | : This number is blocked for your account. 71 | li 72 | | 73 | | To: +15005550009 74 | | 75 | | : This number is incapable of receiving SMS messages. 76 | else 77 | .alert.alert-info(role='alert') 78 | | Texts will be sent from the sender number 79 | b= ` ${fromNumber}` 80 | | . This is live mode, and actual SMS will be sent. 81 | 82 | .row 83 | .col-sm-6.mb-4 84 | form(role='form', method='POST') 85 | input(type='hidden', name='_csrf', value=_csrf) 86 | .form-group.row.mb-3 87 | label.col-md-4.col-form-label.font-weight-bold(for='number') Phone Number to text: 88 | .col-md-6 89 | input.form-control(type='text', name='number', placeholder='e.g., +1234567890') 90 | .form-group.row.mb-3 91 | label.col-md-4.col-form-label.font-weight-bold(for='message') Message: 92 | .col-md-6 93 | input.form-control(type='text', name='message', placeholder='Your message here') 94 | .form-group.row 95 | .col-md-4 96 | .col-md-6 97 | button.btn.btn-primary(type='submit') 98 | i.fas.fa-location-arrow.fa-sm.me-2 99 | | Send Message 100 | -------------------------------------------------------------------------------- /public/privacy-policy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Privacy Policy for Hackathon Starter 9 | 10 | 11 | 12 | 13 |
14 |

Privacy Policy for Hackathon Starter

15 | 16 |

At Hackathon Starter, accessible from our website, one of our main priorities is the privacy of our users. This Privacy Policy document explains the types of information we collect, how we use that data, and how users can manage their information.

17 | 18 |

19 | If you have additional questions or require more information about our Privacy Policy, do not hesitate to contact us. Our Privacy Policy was generated with the help of 20 | GDPR Privacy Policy Generator from GDPRPrivacyPolicy.net. 21 |

22 | 23 |

Data Collection & Usage

24 |

We collect information from third-party services such as Google and Meta, including user email, profile data, and other relevant information for authentication purposes. This data is used solely to provide and enhance the functionality of our application.

25 | 26 |

Automatically Collected Information

27 |

We also collect certain information automatically, including browser type, IP address, server logs, and usage patterns to optimize and improve user experience.

28 | 29 |

Data Sharing

30 |

We do not sell user data to third parties. We may share certain information with trusted partners who assist in providing and improving our services, but only as necessary for core functionality.

31 | 32 |

Security & Data Protection

33 |

We implement industry-standard security measures, including encryption and access controls, to protect user data from unauthorized access, breaches, and misuse.

34 | 35 |

Data Retention & Deletion

36 |

37 | We retain personal data only as long as necessary to fulfill the purposes outlined in this policy. Users may request data deletion at any time via the **“Delete My Account”** button on the **“My Account”** page or by contacting us through our 38 | Contact Page. 39 |

40 | 41 |

Restricted Data Usage

42 |

User data collected through our app is used **exclusively** for authentication and enhancing app functionality. It is **not** used for targeted advertising, sold to data brokers, or processed for purposes beyond improving user experience.

43 | 44 |

Compliance with Meta Policies

45 |

Our privacy policy is available via a publicly accessible URL and is not geo-blocked. The privacy policy link provided in our Meta App Dashboard ensures compliance with Meta’s requirements.

46 | 47 |

General Data Protection Regulation (GDPR)

48 |

We are a Data Controller of your information. Our legal basis for collecting and using personal information depends on the specific context in which we collect the data:

49 | 55 | 56 |

Children’s Privacy

57 |

Hackathon Starter does not knowingly collect personal information from children under the age of 13. If you believe a child has provided us with their information, please contact us, and we will promptly remove the data.

58 | 59 |

Consent

60 |

By using our website, you hereby consent to our Privacy Policy and agree to its terms.

61 |
62 | 63 | 64 | -------------------------------------------------------------------------------- /controllers/contact.js: -------------------------------------------------------------------------------- 1 | const validator = require('validator'); 2 | const nodemailerConfig = require('../config/nodemailer'); 3 | 4 | async function validateReCAPTCHA(token) { 5 | const projectId = process.env.GOOGLE_PROJECT_ID; 6 | const siteKey = process.env.GOOGLE_RECAPTCHA_SITE_KEY; 7 | const apiKey = process.env.GOOGLE_API_KEY; 8 | const url = `https://recaptchaenterprise.googleapis.com/v1/projects/${projectId}/assessments?key=${apiKey}`; 9 | const body = { 10 | event: { 11 | token, 12 | siteKey, 13 | }, 14 | }; 15 | const resp = await fetch(url, { 16 | method: 'POST', 17 | headers: { 'Content-Type': 'application/json' }, 18 | body: JSON.stringify(body), 19 | }); 20 | const data = await resp.json(); 21 | return { 22 | valid: data.tokenProperties?.valid === true, 23 | score: data.riskAnalysis?.score ?? null, 24 | action: data.tokenProperties?.action ?? null, 25 | invalidReason: data.tokenProperties?.invalidReason ?? null, 26 | }; 27 | } 28 | 29 | /** 30 | * GET /contact 31 | * Contact form page. 32 | */ 33 | exports.getContact = (req, res) => { 34 | const unknownUser = !req.user; 35 | 36 | if (!process.env.GOOGLE_RECAPTCHA_SITE_KEY) { 37 | console.warn('\x1b[33mWARNING: GOOGLE_RECAPTCHA_SITE_KEY is missing. Add a key to your .env, env variable, or use a WebApp Firewall with an interactive challenge before going to production.\x1b[0m'); 38 | } 39 | 40 | res.render('contact', { 41 | title: 'Contact', 42 | sitekey: process.env.GOOGLE_RECAPTCHA_SITE_KEY || null, // Pass null if the key is missing 43 | unknownUser, 44 | }); 45 | }; 46 | 47 | /** 48 | * POST /contact 49 | * Send a contact form via Nodemailer. 50 | */ 51 | exports.postContact = async (req, res, next) => { 52 | const validationErrors = []; 53 | let fromName; 54 | let fromEmail; 55 | if (!req.user) { 56 | if (validator.isEmpty(req.body.name)) validationErrors.push({ msg: 'Please enter your name' }); 57 | if (!validator.isEmail(req.body.email)) validationErrors.push({ msg: 'Please enter a valid email address.' }); 58 | } 59 | if (validator.isEmpty(req.body.message)) validationErrors.push({ msg: 'Please enter your message.' }); 60 | 61 | if (!process.env.GOOGLE_RECAPTCHA_SITE_KEY) { 62 | console.warn('\x1b[33mWARNING: GOOGLE_RECAPTCHA_SITE_KEY is missing. Add a key to your .env or use a WebApp Firewall for CAPTCHA validation before going to production.\x1b[0m'); 63 | } else if (!validator.isEmpty(req.body['g-recaptcha-response'])) { 64 | try { 65 | const reCAPTCHAResponse = await validateReCAPTCHA(req.body['g-recaptcha-response']); 66 | if (!reCAPTCHAResponse.valid) { 67 | validationErrors.push({ msg: 'reCAPTCHA validation failed.' }); 68 | } 69 | } catch (error) { 70 | console.error('Error validating reCAPTCHA:', error); 71 | validationErrors.push({ msg: 'Error validating reCAPTCHA. Please try again.' }); 72 | } 73 | } else { 74 | validationErrors.push({ msg: 'reCAPTCHA response was missing.' }); 75 | } 76 | 77 | if (validationErrors.length) { 78 | req.flash('errors', validationErrors); 79 | return res.redirect('/contact'); 80 | } 81 | 82 | if (!req.user) { 83 | fromName = req.body.name; 84 | fromEmail = req.body.email; 85 | } else { 86 | fromName = req.user.profile.name || ''; 87 | fromEmail = req.user.email; 88 | } 89 | 90 | const sendContactEmail = async () => { 91 | const mailOptions = { 92 | to: process.env.SITE_CONTACT_EMAIL, 93 | from: `${fromName} <${fromEmail}>`, 94 | subject: 'Contact Form | Hackathon Starter', 95 | text: req.body.message, 96 | }; 97 | 98 | const mailSettings = { 99 | successfulType: 'info', 100 | successfulMsg: 'Email has been sent successfully!', 101 | loggingError: 'ERROR: Could not send contact email after security downgrade.\n', 102 | errorType: 'errors', 103 | errorMsg: 'Error sending the message. Please try again shortly.', 104 | mailOptions, 105 | req, 106 | }; 107 | 108 | return nodemailerConfig.sendMail(mailSettings); 109 | }; 110 | 111 | try { 112 | await sendContactEmail(); 113 | res.redirect('/contact'); 114 | } catch (error) { 115 | next(error); 116 | } 117 | }; 118 | -------------------------------------------------------------------------------- /test/e2e-nokey/wikipedia.e2e.test.js: -------------------------------------------------------------------------------- 1 | process.env.API_TEST_FILE = 'e2e-nokey/wikipedia.e2e.test.js'; 2 | const { test, expect } = require('@playwright/test'); 3 | const { registerTestInManifest, isInManifest } = require('../tools/fixture-helpers'); 4 | 5 | // Self-register this test in the manifest when recording 6 | registerTestInManifest('e2e-nokey/wikipedia.e2e.test.js'); 7 | 8 | // Skip this file during replay if it's not in the manifest 9 | if (process.env.API_MODE === 'replay' && !isInManifest('e2e-nokey/wikipedia.e2e.test.js')) { 10 | console.log('[fixtures] skipping e2e-nokey/wikipedia.e2e.test.js as it is not in manifest for replay mode - 2 tests'); 11 | test.skip(true, 'Not in manifest for replay mode'); 12 | } 13 | 14 | test.describe('Wikipedia Example', () => { 15 | let sharedPage; 16 | 17 | test.beforeAll(async ({ browser }) => { 18 | sharedPage = await browser.newPage(); 19 | await sharedPage.goto('/api/wikipedia'); 20 | await sharedPage.waitForLoadState('networkidle'); 21 | }); 22 | 23 | test.afterAll(async () => { 24 | if (sharedPage) await sharedPage.close(); 25 | }); 26 | 27 | test('should display Content Example: Node.js elements', async () => { 28 | // Basic page checks 29 | await expect(sharedPage).toHaveTitle(/Wikipedia/); 30 | await expect(sharedPage.locator('h2')).toContainText('Wikipedia'); 31 | 32 | // Content Example card (Node.js) 33 | const contentCard = sharedPage.locator('.card.text-white.bg-success'); 34 | await expect(contentCard).toBeVisible(); 35 | await expect(contentCard.locator('.card-header h6')).toContainText('Content Example: Node.js'); 36 | 37 | // Title and original wiki link 38 | const titleEl = contentCard.locator('.card-body h3'); 39 | await expect(titleEl).toBeVisible(); 40 | await expect(titleEl).toContainText('Node.js'); 41 | 42 | const wikiLink = contentCard.locator('a[href="https://en.wikipedia.org/wiki/Node.js"]'); 43 | await expect(wikiLink).toBeVisible(); 44 | 45 | // Ensure there is a page image and it points to a valid URL 46 | const imageEl = contentCard.locator('.card-body img'); 47 | await expect(imageEl).toBeVisible(); 48 | if (process.env.API_MODE !== 'replay') { 49 | // we are not saving images when recording, so don't expect in replay 50 | const imageLoaded = await imageEl.evaluate((img) => img.complete && img.naturalWidth > 0); 51 | expect(imageLoaded).toBeTruthy(); 52 | } 53 | 54 | // Ensure there is an extract paragraph with sufficient text 55 | const extractPara = contentCard.locator('.card-body p.text-break'); 56 | await expect(extractPara).toBeVisible(); 57 | const extractText = (await extractPara.textContent()) || ''; 58 | expect(extractText.trim().length).toBeGreaterThan(50); 59 | 60 | const sectionLinks = contentCard.locator('.card-body p a[href^="https://en.wikipedia.org/wiki/Node.js#"]'); 61 | expect(await sectionLinks.count()).toBeGreaterThan(5); 62 | }); 63 | 64 | test('should search for "javascript" and display results', async () => { 65 | // Perform the search via the UI on the already loaded page 66 | await sharedPage.fill('input.form-control[name="q"]', 'javascript'); 67 | await sharedPage.click('.card.text-white.bg-info button[type="submit"]'); 68 | await sharedPage.waitForLoadState('networkidle'); 69 | 70 | // Results area 71 | const results = sharedPage.locator('.card.text-white.bg-info .list-group a.list-group-item'); 72 | await expect(results.first()).toBeVisible({ timeout: 10000 }); 73 | const count = await results.count(); 74 | 75 | expect(count).toBeGreaterThan(5); 76 | 77 | // Verify first result structure and title exactly matches expected "JavaScript" 78 | const first = results.nth(0); 79 | await expect(first.locator('strong')).toBeVisible(); 80 | await expect(first.locator('small.text-muted')).toBeVisible(); 81 | 82 | // Title must be exactly 'JavaScript' (the top result for this query) 83 | const firstTitle = (await first.locator('strong').textContent()) || ''; 84 | expect(firstTitle.trim()).toBe('JavaScript'); 85 | 86 | // Link should point to the JavaScript wikipedia article 87 | const href = await first.getAttribute('href'); 88 | expect(href).toBeTruthy(); 89 | expect(href).toMatch(/https?:\/\/en\.wikipedia\.org\/wiki\/JavaScript/); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-automerge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot Automerge 2 | 3 | on: 4 | workflow_run: 5 | workflows: ['Node.js CI'] 6 | types: [completed] 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | dependabot-automerge: 14 | if: > 15 | github.event.workflow_run.conclusion == 'success' && 16 | github.event.workflow_run.event == 'pull_request' && 17 | github.event.workflow_run.actor.login == 'dependabot[bot]' 18 | runs-on: ubuntu-latest 19 | permissions: 20 | contents: write 21 | pull-requests: write 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v6 25 | - name: Automerge Dependabot PRs if all checks have passed 26 | shell: bash 27 | env: 28 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | PR_NUM: ${{ fromJSON(toJson(github.event.workflow_run.pull_requests))[0].number }} 30 | REPO: ${{ github.repository }} 31 | run: | 32 | echo "Attempting to merge PR #${PR_NUM} in ${REPO}" 33 | gh pr merge "$PR_NUM" --squash --admin 34 | 35 | Sync-patches-after-dependabot-automerge: 36 | needs: [dependabot-automerge] 37 | runs-on: ubuntu-latest 38 | env: 39 | RUN_E2E: ${{ vars.RUN_E2E }} # from repository settings -> Actions -> Variables 40 | permissions: 41 | contents: write 42 | pull-requests: write 43 | steps: 44 | - name: Checkout repository 45 | uses: actions/checkout@v6 46 | with: 47 | ref: master 48 | 49 | - name: Set up Node.js 50 | uses: actions/setup-node@v6 51 | with: 52 | node-version: 'lts/*' 53 | cache: 'npm' 54 | 55 | - name: Rename patch-package files to match current versions 56 | id: rename-patches 57 | shell: bash 58 | run: | 59 | shopt -s nullglob 60 | 61 | get_version() { 62 | jq -r ".dependencies[\"$1\"] // .devDependencies[\"$1\"]" package.json 63 | } 64 | 65 | CHANGED=0 66 | 67 | for PATCH in patches/*.patch; do 68 | BASE=$(basename "$PATCH" .patch) 69 | 70 | NAME_WITHOUT_VERSION="${BASE%+*}" 71 | if [[ "$NAME_WITHOUT_VERSION" == @*+* ]]; then 72 | PACKAGE="${NAME_WITHOUT_VERSION/+//}" 73 | else 74 | PACKAGE="$NAME_WITHOUT_VERSION" 75 | fi 76 | 77 | VERSION=$(get_version "$PACKAGE") 78 | 79 | if [ "$VERSION" == "null" ]; then 80 | echo "Skipping $PACKAGE — not found in package.json" 81 | continue 82 | fi 83 | 84 | VERSION="${VERSION#^}" 85 | NEW_NAME="$(echo "$PACKAGE" | sed 's|/|+|g')+${VERSION}.patch" 86 | 87 | if [ "$BASE.patch" != "$NEW_NAME" ]; then 88 | echo "Renaming $BASE.patch -> $NEW_NAME" 89 | git mv "$PATCH" "patches/$NEW_NAME" 90 | CHANGED=1 91 | fi 92 | done 93 | 94 | # Expose whether any files changed as a step output so it can be safely 95 | # referenced by later step `if` conditions without static analyzer warnings. 96 | echo "changed=$CHANGED" >> $GITHUB_OUTPUT 97 | 98 | - name: Install dependencies 99 | if: ${{ steps.rename-patches.outputs.changed == '1' }} 100 | run: npm ci 101 | 102 | - name: Run tests 103 | if: ${{ steps.rename-patches.outputs.changed == '1' }} 104 | run: npm test 105 | 106 | - name: Run e2e tests 107 | if: ${{ steps.rename-patches.outputs.changed == '1' && (env.RUN_E2E == 'true' || github.repository == 'sahat/hackathon-starter') }} 108 | run: npm run test:e2e:replay 109 | 110 | - name: Run e2e tests that don't require API keys against live APIs 111 | if: ${{ steps.rename-patches.outputs.changed == '1' && (env.RUN_E2E == 'true' || github.repository == 'sahat/hackathon-starter') }} 112 | run: npm run test:e2e:custom -- --project=chromium-nokey-live 113 | 114 | - name: Commit and push patch renames 115 | if: ${{ steps.rename-patches.outputs.changed == '1' }} 116 | run: | 117 | git config user.name "github-actions" 118 | git config user.email "github-actions@github.com" 119 | git add patches/ 120 | git commit -m "chore: sync patch-package filenames with current versions" 121 | git push 122 | -------------------------------------------------------------------------------- /test/auth.opt.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const sinon = require('sinon'); 3 | const moment = require('moment'); 4 | const mongoose = require('mongoose'); 5 | const path = require('path'); 6 | require('dotenv').config({ path: path.join(__dirname, '.env.test') }); 7 | const { _saveOAuth2UserTokens } = require('../config/passport'); 8 | const User = require('../models/User'); 9 | 10 | describe('Microsoft OAuth Integration Tests:', () => { 11 | let req; 12 | let userStub; 13 | 14 | beforeEach((done) => { 15 | const user = new User({ 16 | _id: new mongoose.Types.ObjectId(), 17 | email: 'test@example.com', 18 | microsoft: 'microsoft-id-123', 19 | tokens: [], 20 | }); 21 | 22 | user.save = sinon.stub().resolves(); 23 | user.markModified = sinon.spy(); 24 | 25 | userStub = sinon.stub(User, 'findById').resolves(user); 26 | 27 | req = { 28 | user, 29 | }; 30 | done(); 31 | }); 32 | 33 | afterEach((done) => { 34 | userStub.restore(); 35 | done(); 36 | }); 37 | 38 | it('should save Microsoft OAuth tokens correctly', (done) => { 39 | const accessToken = 'microsoft-access-token'; 40 | const refreshToken = 'microsoft-refresh-token'; 41 | const accessTokenExpiration = 3600; 42 | const refreshTokenExpiration = 86400; 43 | const providerName = 'microsoft'; 44 | const tokenConfig = { microsoft: 'microsoft-id-123' }; 45 | 46 | _saveOAuth2UserTokens(req, accessToken, refreshToken, accessTokenExpiration, refreshTokenExpiration, providerName, tokenConfig) 47 | .then(() => { 48 | expect(req.user.tokens).to.have.lengthOf(1); 49 | expect(req.user.tokens[0]).to.include({ 50 | kind: 'microsoft', 51 | accessToken: 'microsoft-access-token', 52 | refreshToken: 'microsoft-refresh-token', 53 | }); 54 | expect(req.user.microsoft).to.equal('microsoft-id-123'); 55 | expect(req.user.markModified.calledWith('tokens')).to.be.true; 56 | expect(req.user.save.calledOnce).to.be.true; 57 | done(); 58 | }) 59 | .catch(done); 60 | }); 61 | 62 | it('should handle Microsoft OAuth token refresh scenario', (done) => { 63 | // Setup existing expired Microsoft token 64 | req.user.tokens.push({ 65 | kind: 'microsoft', 66 | accessToken: 'expired-microsoft-token', 67 | refreshToken: 'valid-microsoft-refresh-token', 68 | accessTokenExpires: moment().subtract(1, 'hour').format(), 69 | }); 70 | 71 | const accessToken = 'new-microsoft-access-token'; 72 | const refreshToken = 'new-microsoft-refresh-token'; 73 | const accessTokenExpiration = 3600; 74 | const refreshTokenExpiration = 86400; 75 | const providerName = 'microsoft'; 76 | 77 | _saveOAuth2UserTokens(req, accessToken, refreshToken, accessTokenExpiration, refreshTokenExpiration, providerName) 78 | .then(() => { 79 | expect(req.user.tokens).to.have.lengthOf(1); 80 | expect(req.user.tokens[0].accessToken).to.equal('new-microsoft-access-token'); 81 | expect(req.user.tokens[0].refreshToken).to.equal('new-microsoft-refresh-token'); 82 | done(); 83 | }) 84 | .catch(done); 85 | }); 86 | 87 | it('should preserve other provider tokens when updating Microsoft tokens', (done) => { 88 | // Setup existing tokens for different providers 89 | req.user.tokens = [ 90 | { 91 | kind: 'google', 92 | accessToken: 'google-token', 93 | accessTokenExpires: moment().add(1, 'hour').format(), 94 | }, 95 | { 96 | kind: 'github', 97 | accessToken: 'github-token', 98 | accessTokenExpires: moment().add(1, 'hour').format(), 99 | }, 100 | ]; 101 | 102 | const accessToken = 'new-microsoft-token'; 103 | const refreshToken = 'microsoft-refresh-token'; 104 | const accessTokenExpiration = 3600; 105 | const refreshTokenExpiration = 86400; 106 | const providerName = 'microsoft'; 107 | 108 | _saveOAuth2UserTokens(req, accessToken, refreshToken, accessTokenExpiration, refreshTokenExpiration, providerName) 109 | .then(() => { 110 | expect(req.user.tokens).to.have.lengthOf(3); 111 | expect(req.user.tokens.find((t) => t.kind === 'google').accessToken).to.equal('google-token'); 112 | expect(req.user.tokens.find((t) => t.kind === 'github').accessToken).to.equal('github-token'); 113 | expect(req.user.tokens.find((t) => t.kind === 'microsoft').accessToken).to.equal('new-microsoft-token'); 114 | done(); 115 | }) 116 | .catch(done); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /test/flash.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const sinon = require('sinon'); 3 | const { flash } = require('../config/flash'); 4 | 5 | describe('flash middleware core tests', () => { 6 | let req, res, next; 7 | beforeEach(() => { 8 | req = { session: {} }; 9 | res = { 10 | locals: {}, 11 | render: sinon.spy(), 12 | }; 13 | next = sinon.spy(); 14 | }); 15 | 16 | it('should store a single flash message', () => { 17 | flash(req, res, next); 18 | req.flash('info', 'hello'); 19 | expect(req.session.flash.info).to.deep.equal(['hello']); 20 | }); 21 | 22 | it('should accumulate multiple flash messages of same type', () => { 23 | flash(req, res, next); 24 | req.flash('info', 'first'); 25 | req.flash('info', 'second'); 26 | expect(req.session.flash.info).to.deep.equal(['first', 'second']); 27 | }); 28 | 29 | it('should store multiple messages passed as array', () => { 30 | flash(req, res, next); 31 | req.flash('warning', ['msg1', 'msg2']); 32 | expect(req.session.flash.warning).to.deep.equal(['msg1', 'msg2']); 33 | }); 34 | 35 | it('should store messages of different types separately', () => { 36 | flash(req, res, next); 37 | req.flash('info', 'info1'); 38 | req.flash('error', 'error1'); 39 | expect(req.session.flash.info).to.deep.equal(['info1']); 40 | expect(req.session.flash.error).to.deep.equal(['error1']); 41 | }); 42 | 43 | it('should retrieve and clear messages of a type', () => { 44 | flash(req, res, next); 45 | req.flash('info', 'hello'); 46 | const msgs = req.flash('info'); 47 | expect(msgs).to.deep.equal(['hello']); 48 | expect(req.session.flash.info).to.be.undefined; 49 | }); 50 | 51 | it('should retrieve all messages grouped by type', () => { 52 | flash(req, res, next); 53 | req.flash('info', 'i1'); 54 | req.flash('error', 'e1'); 55 | const all = req.flash(); 56 | expect(all).to.deep.equal({ info: ['i1'], error: ['e1'] }); 57 | expect(req.session.flash).to.deep.equal({}); 58 | }); 59 | 60 | it('should return empty array for unknown type', () => { 61 | flash(req, res, next); 62 | const msgs = req.flash('unknown'); 63 | expect(msgs).to.deep.equal([]); 64 | }); 65 | 66 | it('should attach messages to res.locals on render', () => { 67 | flash(req, res, next); 68 | req.flash('info', 'hello'); 69 | res.render('index'); 70 | expect(res.locals.messages).to.deep.equal({ info: [{ msg: 'hello' }] }); 71 | // calling again clears messages 72 | res.render('index'); 73 | expect(res.locals.messages).to.deep.equal({}); 74 | }); 75 | }); 76 | 77 | describe('flash middleware integration behavior', () => { 78 | let req, res, next; 79 | beforeEach(() => { 80 | req = { session: {} }; 81 | res = { 82 | locals: {}, 83 | render: sinon.spy(), 84 | }; 85 | next = sinon.spy(); 86 | }); 87 | 88 | it('should consume messages after read', () => { 89 | flash(req, res, next); 90 | req.flash('info', 'Hello'); // set a message 91 | // First read 92 | let messages = req.flash('info'); 93 | expect(messages).to.deep.equal(['Hello']); 94 | // Second read should be empty 95 | messages = req.flash('info'); 96 | expect(messages).to.deep.equal([]); 97 | }); 98 | 99 | it('should expose messages to res.locals.messages for views', () => { 100 | flash(req, res, next); 101 | req.flash('info', 'Hello'); 102 | res.render('index'); 103 | expect(res.locals.messages).to.deep.equal({ info: [{ msg: 'Hello' }] }); 104 | expect(req.session.flash).to.deep.equal({}); 105 | }); 106 | 107 | it('should have no messages by default', () => { 108 | flash(req, res, next); 109 | res.render('index'); 110 | expect(res.locals.messages).to.deep.equal({}); 111 | }); 112 | 113 | it('should isolate messages between sessions', () => { 114 | const session1 = { flash: {} }; 115 | const session2 = { flash: {} }; 116 | 117 | const req1 = { session: session1 }; 118 | const res1 = { locals: {}, render: sinon.spy() }; 119 | 120 | const req2 = { session: session2 }; 121 | const res2 = { locals: {}, render: sinon.spy() }; 122 | 123 | flash(req1, res1, next); 124 | flash(req2, res2, next); 125 | 126 | req1.flash('info', 'Message for session1'); 127 | req2.flash('info', 'Message for session2'); 128 | 129 | // Each session sees only its own messages 130 | expect(req1.flash('info')).to.deep.equal(['Message for session1']); 131 | expect(req2.flash('info')).to.deep.equal(['Message for session2']); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /test/e2e/chart.e2e.test.js: -------------------------------------------------------------------------------- 1 | const testFileName = 'e2e/chart.e2e.test.js'; 2 | process.env.API_TEST_FILE = testFileName; 3 | const { test, expect } = require('@playwright/test'); 4 | const { registerTestInManifest, isInManifest } = require('../tools/fixture-helpers'); 5 | 6 | // Self-register this test in the manifest when recording 7 | registerTestInManifest(testFileName); 8 | 9 | if (process.env.API_MODE && process.env.API_MODE === 'replay' && !isInManifest(testFileName)) { 10 | console.log(`[fixtures] skipping ${testFileName} as it is not in manifest for replay mode`); 11 | test.skip(true, 'Not in manifest for replay mode'); 12 | } 13 | 14 | test.describe('Chart.js and Alpha Vantage API Integration', () => { 15 | let sharedPage; 16 | 17 | test.beforeAll(async ({ browser }) => { 18 | sharedPage = await browser.newPage(); 19 | await sharedPage.goto('/api/chart'); 20 | await sharedPage.waitForLoadState('networkidle'); 21 | await sharedPage.waitForTimeout(2000); // Wait for chart to render 22 | }); 23 | 24 | test.afterAll(async () => { 25 | if (sharedPage) await sharedPage.close(); 26 | }); 27 | 28 | test('should render Chart.js with Microsoft stock data', async () => { 29 | // Check for canvas element 30 | const canvas = sharedPage.locator('canvas#chart'); 31 | await expect(canvas).toBeVisible(); 32 | 33 | // Verify canvas has been initialized with Chart.js and has data 34 | const chartValidation = await sharedPage.evaluate(() => { 35 | const canvas = document.getElementById('chart'); 36 | const chart = window.Chart.getChart(canvas); 37 | 38 | if (!chart) return { isInitialized: false, hasData: false }; 39 | 40 | const { labels } = chart.data; 41 | const [dataset] = chart.data.datasets; 42 | 43 | return { 44 | isInitialized: true, 45 | hasData: labels?.length > 0 && dataset?.data?.length > 0, 46 | datasetLabel: dataset?.label, 47 | labelsCount: labels?.length, 48 | type: chart.config.type, 49 | }; 50 | }); 51 | 52 | // Verify chart is initialized 53 | expect(chartValidation.isInitialized).toBe(true); 54 | 55 | // Verify chart data is populated 56 | expect(chartValidation.hasData).toBe(true); 57 | 58 | // Verify chart has correct dataset label 59 | expect(chartValidation.datasetLabel).toContain("Microsoft's Closing Stock Values"); 60 | 61 | // Verify chart type 62 | expect(chartValidation.type).toBe('line'); 63 | 64 | // Verify data count (Alpha Vantage returns 100 data points) 65 | expect(chartValidation.labelsCount).toBe(100); 66 | }); 67 | 68 | test('should display valid stock data with correct structure', async () => { 69 | // Get chart data details 70 | const chartDataInfo = await sharedPage.evaluate(() => { 71 | const canvas = document.getElementById('chart'); 72 | const chart = Chart.getChart(canvas); 73 | 74 | return { 75 | labelsCount: chart.data.labels.length, 76 | dataCount: chart.data.datasets[0].data.length, 77 | firstLabel: chart.data.labels[0], 78 | lastLabel: chart.data.labels[chart.data.labels.length - 1], 79 | firstValue: chart.data.datasets[0].data[0], 80 | }; 81 | }); 82 | 83 | // Verify data integrity 84 | expect(chartDataInfo.labelsCount).toBe(100); 85 | expect(chartDataInfo.dataCount).toBe(100); 86 | 87 | // Verify date format (YYYY-MM-DD) 88 | expect(chartDataInfo.firstLabel).toMatch(/^\d{4}-\d{2}-\d{2}$/); 89 | expect(chartDataInfo.lastLabel).toMatch(/^\d{4}-\d{2}-\d{2}$/); 90 | 91 | // Verify stock values are valid numbers 92 | expect(parseFloat(chartDataInfo.firstValue)).not.toBeNaN(); 93 | expect(parseFloat(chartDataInfo.firstValue)).toBeGreaterThan(0); 94 | }); 95 | 96 | test('should use live data from Alpha Vantage API', async () => { 97 | // Verify we're using live data, not fallback 98 | const dataTypeText = await sharedPage.locator('h6').textContent(); 99 | expect(dataTypeText).toBe('Using data from Alpha Vantage'); 100 | 101 | // Get the date range from chart data 102 | const dateInfo = await sharedPage.evaluate(() => { 103 | const canvas = document.getElementById('chart'); 104 | const chart = Chart.getChart(canvas); 105 | const { labels } = chart.data; 106 | return { 107 | firstDate: labels[0], 108 | lastDate: labels[labels.length - 1], 109 | }; 110 | }); 111 | 112 | // Verify dates are NOT the hardcoded fallback data 113 | // Fallback data range: 2023-03-02 to 2023-07-25 114 | expect(dateInfo.lastDate).not.toBe('2023-07-25'); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /config/morgan.js: -------------------------------------------------------------------------------- 1 | const logger = require('morgan'); 2 | const Bowser = require('bowser'); 3 | 4 | // Color definitions for console output 5 | const colors = { 6 | red: '\x1b[31m', 7 | green: '\x1b[32m', 8 | yellow: '\x1b[33m', 9 | cyan: '\x1b[36m', 10 | reset: '\x1b[0m', 11 | }; 12 | 13 | // Custom colored status token 14 | logger.token('colored-status', (req, res) => { 15 | const status = res.statusCode; 16 | let color; 17 | if (status >= 500) color = colors.red; 18 | else if (status >= 400) color = colors.yellow; 19 | else if (status >= 300) color = colors.cyan; 20 | else color = colors.green; 21 | 22 | return color + status + colors.reset; 23 | }); 24 | 25 | // Custom token for timestamp without timezone offset 26 | logger.token('short-date', () => { 27 | const now = new Date(); 28 | return now.toLocaleString('sv').replace(',', ''); 29 | }); 30 | 31 | // Custom token for simplified user agent using Bowser 32 | logger.token('parsed-user-agent', (req) => { 33 | const userAgent = req.headers['user-agent']; 34 | if (!userAgent) return 'Unknown'; 35 | const parsedUA = Bowser.parse(userAgent); 36 | const osName = parsedUA.os.name || 'Unknown'; 37 | const browserName = parsedUA.browser.name || 'Unknown'; 38 | 39 | // Get major version number 40 | const version = parsedUA.browser.version || ''; 41 | const majorVersion = version.split('.')[0]; 42 | 43 | return `${osName}/${browserName} v${majorVersion}`; 44 | }); 45 | 46 | // Track bytes actually sent 47 | logger.token('bytes-sent', (req, res) => { 48 | // Check for original uncompressed size first 49 | let length = 50 | res.getHeader('X-Original-Content-Length') || // Some compression middlewares add this 51 | res.get('x-content-length') || // Alternative header 52 | res.getHeader('Content-Length'); 53 | 54 | // For static files 55 | if (!length && res.locals && res.locals.stat) { 56 | length = res.locals.stat.size; 57 | } 58 | 59 | // For response bodies (API responses) 60 | if (!length && res._contentLength) { 61 | length = res._contentLength; 62 | } 63 | 64 | // If we found a length, format it 65 | if (length && Number.isNaN(Number(length)) === false) { 66 | return `${(parseInt(length, 10) / 1024).toFixed(2)}KB`; 67 | } 68 | 69 | // For chunked responses 70 | const transferEncoding = res.getHeader('Transfer-Encoding'); 71 | if (transferEncoding === 'chunked') { 72 | return 'chunked'; 73 | } 74 | 75 | return '-'; 76 | }); 77 | 78 | // Track partial response info 79 | logger.token('transfer-state', (req, res) => { 80 | if (!res._header) return 'NO_RESPONSE'; 81 | if (res.finished) return 'COMPLETE'; 82 | return 'PARTIAL'; 83 | }); 84 | 85 | // Define the custom request log format 86 | // In development/test environments, include the full IP address in the logs to facilitate debugging, 87 | // especially when collaborating with other developers testing the running instance. 88 | // In production, omit the IP address to reduce the risk of leaking sensitive information and to support 89 | // compliance with GDPR and other privacy regulations. 90 | // Also using a function so we can test it in our unit tests. 91 | const getMorganFormat = () => 92 | process.env.NODE_ENV === 'production' ? ':short-date :method :url :colored-status :response-time[0]ms :bytes-sent :transfer-state - :parsed-user-agent' : ':short-date :method :url :colored-status :response-time[0]ms :bytes-sent :transfer-state :remote-addr :parsed-user-agent'; 93 | 94 | // Set the format once at initialization for the actual middleware so we don't have to evaluate on each call 95 | const morganFormat = getMorganFormat(); 96 | 97 | // Create a middleware to capture original content length 98 | const captureContentLength = (req, res, next) => { 99 | const originalWrite = res.write; 100 | const originalEnd = res.end; 101 | let length = 0; 102 | 103 | res.write = (...args) => { 104 | const [chunk] = args; 105 | if (chunk) { 106 | length += chunk.length; 107 | } 108 | return originalWrite.apply(res, args); 109 | }; 110 | 111 | res.end = (...args) => { 112 | const [chunk] = args; 113 | if (chunk) { 114 | length += chunk.length; 115 | } 116 | if (length > 0) { 117 | res._contentLength = length; 118 | } 119 | return originalEnd.apply(res, args); 120 | }; 121 | 122 | next(); 123 | }; 124 | 125 | exports.morganLogger = () => (req, res, next) => { 126 | captureContentLength(req, res, () => { 127 | logger(morganFormat, { 128 | immediate: false, 129 | })(req, res, next); 130 | }); 131 | }; 132 | 133 | // Expose for testing 134 | exports._getMorganFormat = getMorganFormat; 135 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackathon-starter", 3 | "version": "9.0.0", 4 | "description": "A boilerplate for Node.js web applications", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/sahat/hackathon-starter.git" 8 | }, 9 | "license": "MIT", 10 | "author": "Sahat Yalkabov", 11 | "contributors": [ 12 | "Yashar Fakhari (https://github.com/YasharF)" 13 | ], 14 | "scripts": { 15 | "clean-install": "node -e \"fs.rmSync('node_modules', { recursive: true, force: true }); fs.rmSync('package-lock.json', { force: true }); fs.rmSync('tmp', { recursive: true, force: true });\" && npm install", 16 | "lint": "eslint \"**/*.js\" --fix && prettier . --write", 17 | "lint-check": "eslint \"**/*.js\" && prettier . --check", 18 | "postinstall": "patch-package && npm run scss", 19 | "prepare": "node -e \"if(process.env.NODE_ENV!=='production'){require('child_process').execSync('husky',{stdio:'inherit'})}\"", 20 | "scss": "sass --no-source-map --silence-deprecation=import --quiet-deps --load-path=./ --update ./public/css:./public/css", 21 | "start": "npm run scss && node app.js", 22 | "test": "c8 --temp-directory=tmp/coverage --reporter=html --reports-dir=tmp/coverage mocha --timeout=60000 --exit --exclude \"test/*links.test.js\"", 23 | "test:e2e:live": "playwright test --config=test/playwright.config.js --project=chromium", 24 | "test:e2e:replay": "playwright test --config=test/playwright.config.js --project=chromium-replay", 25 | "test:e2e:custom": "playwright test --config=test/playwright.config.js", 26 | "pretest:e2e:live": "npx playwright install chromium", 27 | "pretest:e2e:replay": "npx playwright install chromium", 28 | "pretest:e2e:custom": "npx playwright install chromium", 29 | "test:image-link": "mocha --timeout 300000 \"test/*links.test.js\"" 30 | }, 31 | "dependencies": { 32 | "@fortawesome/fontawesome-free": "^7.1.0", 33 | "@googleapis/drive": "^19.2.1", 34 | "@googleapis/sheets": "^12.0.0", 35 | "@huggingface/inference": "^4.13.5", 36 | "@langchain/community": "^0.3.58", 37 | "@langchain/core": "^0.3.79", 38 | "@langchain/mongodb": "0.1.1", 39 | "@langchain/textsplitters": "^0.1.0", 40 | "@lob/lob-typescript-sdk": "^1.3.5", 41 | "@node-rs/bcrypt": "^1.10.7", 42 | "@octokit/rest": "^22.0.1", 43 | "@passport-js/passport-twitter": "^1.0.10", 44 | "@popperjs/core": "^2.11.8", 45 | "bootstrap": "^5.3.8", 46 | "bootstrap-social": "github:SeattleDevs/bootstrap-social", 47 | "bowser": "^2.13.1", 48 | "chart.js": "^4.5.1", 49 | "cheerio": "^1.1.2", 50 | "compression": "^1.8.1", 51 | "connect-mongo": "^5.1.0", 52 | "dotenv": "^17.2.3", 53 | "errorhandler": "^1.5.1", 54 | "express": "^5.2.1", 55 | "express-rate-limit": "^8.2.1", 56 | "express-session": "^1.18.2", 57 | "jquery": "^3.7.1", 58 | "langchain": "0.3.36", 59 | "lastfm": "^0.9.4", 60 | "lusca": "^1.7.0", 61 | "mailchecker": "^6.0.19", 62 | "moment": "^2.30.1", 63 | "mongodb": "^6.21.0", 64 | "mongoose": "^8.20.2", 65 | "morgan": "^1.10.1", 66 | "multer": "^2.0.2", 67 | "nodemailer": "^7.0.11", 68 | "oauth": "^0.10.2", 69 | "passport": "^0.7.0", 70 | "passport-facebook": "^3.0.0", 71 | "passport-github2": "^0.1.12", 72 | "passport-google-oauth": "^2.0.0", 73 | "passport-local": "^1.0.0", 74 | "passport-oauth": "^1.0.0", 75 | "passport-oauth2-refresh": "^2.2.0", 76 | "passport-openidconnect": "^0.1.2", 77 | "passport-steam-openid": "^1.1.8", 78 | "patch-package": "^8.0.1", 79 | "pdfjs-dist": "^5.4.449", 80 | "pug": "^3.0.3", 81 | "sass": "^1.96.0", 82 | "stripe": "^20.0.0", 83 | "twilio": "^5.10.7", 84 | "twitch-passport": "^1.0.6", 85 | "validator": "^13.15.23" 86 | }, 87 | "devDependencies": { 88 | "@eslint/compat": "^2.0.0", 89 | "@eslint/eslintrc": "^3.3.3", 90 | "@eslint/js": "^9.36.0", 91 | "@playwright/test": "^1.57.0", 92 | "@prettier/plugin-pug": "^3.4.2", 93 | "c8": "^10.1.3", 94 | "chai": "^6.2.1", 95 | "eslint": "^9.39.1", 96 | "eslint-config-prettier": "^10.1.8", 97 | "eslint-plugin-chai-friendly": "^1.1.0", 98 | "eslint-plugin-import": "^2.32.0", 99 | "globals": "^16.5.0", 100 | "husky": "^9.1.7", 101 | "mocha": "^11.7.5", 102 | "mongodb-memory-server": "^10.4.1", 103 | "prettier": "^3.7.4", 104 | "sinon": "^21.0.0", 105 | "supertest": "^7.1.4" 106 | }, 107 | "overrides": { 108 | "dotenv": "^17.2.3", 109 | "fetch-blob": "github:SeattleDevs/fetch-blob", 110 | "formdata-node": "^6.0.3" 111 | }, 112 | "engines": { 113 | "node": ">=24.11.0" 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /test/e2e/twilio.e2e.test.js: -------------------------------------------------------------------------------- 1 | process.env.API_TEST_FILE = 'e2e/twilio.e2e.test.js'; 2 | const { test, expect } = require('@playwright/test'); 3 | 4 | // Skip this test in record/replay modes 5 | if (process.env.API_MODE === 'replay' || process.env.API_MODE === 'record') { 6 | console.log('[fixtures] skipping twillio.e2e.test.js in record/replay mode (mix of jwt auth, legacy http and axios) - 2 tests'); 7 | test.skip(true, 'Skipping Twillio tests in record/replay mode (mix of jwt auth, legacy http and axios)'); 8 | } 9 | 10 | test.describe('Twilio API Integration', () => { 11 | let sharedPage; 12 | 13 | test.beforeAll(async ({ browser }) => { 14 | sharedPage = await browser.newPage(); 15 | await sharedPage.goto('/api/twilio'); 16 | await sharedPage.waitForLoadState('networkidle'); 17 | }); 18 | 19 | test.afterAll(async () => { 20 | if (sharedPage) await sharedPage.close(); 21 | }); 22 | 23 | test('should launch app, navigate to Twilio API page, and render basic page elements', async () => { 24 | // Basic page checks 25 | await expect(sharedPage).toHaveTitle(/Twilio API/); 26 | await expect(sharedPage.locator('h2')).toContainText('Twilio API'); 27 | 28 | // Check for API documentation links 29 | await expect(sharedPage.locator('.btn-group a[href*="https://www.twilio.com/docs/libraries/reference/twilio-node/"]')).toBeVisible(); 30 | await expect(sharedPage.locator('.btn-group a[href*="https://www.twilio.com/docs/sms/debugging-tools"]')).toBeVisible(); 31 | await expect(sharedPage.locator('.btn-group a[href*="https://www.twilio.com/docs/api/rest"]')).toBeVisible(); 32 | 33 | await expect(sharedPage.locator('text=/Twilio Node/i')).toBeVisible(); 34 | await expect(sharedPage.locator('text=/Twilio Debugging Tools/i')).toBeVisible(); 35 | await expect(sharedPage.locator('text=/REST API/i')).toBeVisible(); 36 | 37 | // Check for existence of form inputs 38 | const phoneNumberLabel = sharedPage.locator('label[for="number"]'); 39 | await expect(phoneNumberLabel).toBeVisible(); 40 | await expect(phoneNumberLabel).toContainText(/phone number/i); 41 | await expect(sharedPage.locator('input[name="number"]')).toBeVisible(); 42 | 43 | const messageLabel = sharedPage.locator('label[for="message"]'); 44 | await expect(messageLabel).toBeVisible(); 45 | await expect(messageLabel).toContainText(/message/i); 46 | await expect(sharedPage.locator('input[name="message"]')).toBeVisible(); 47 | 48 | const submitButton = sharedPage.locator('button[type="submit"]'); 49 | await expect(submitButton).toBeVisible(); 50 | await expect(submitButton).toContainText('Send Message'); 51 | }); 52 | 53 | test('should display warning and that no SMS will be sent', async () => { 54 | const warningDiv = sharedPage.locator('div.alert.alert-warning'); 55 | await expect(warningDiv).toBeVisible(); 56 | await expect(warningDiv).toContainText('Warning'); 57 | 58 | // Check the "from" sandbox number 59 | await expect(warningDiv).toContainText(/\+15005550006/); 60 | await expect(warningDiv).toContainText(/no actual sms.*sent/i); 61 | 62 | // Check for existence of example numbers to text 63 | const secondaryDiv = sharedPage.locator('div.alert.alert-secondary'); 64 | await expect(secondaryDiv).toBeVisible(); 65 | await expect(secondaryDiv).toContainText('Example Numbers to Text'); 66 | }); 67 | 68 | // Data for simulation of sending messages with appropriate responses 69 | const testNumToResp = [ 70 | { num: '+12345678900', response: 'sent successfully' }, // any valid US number 71 | { num: '+15005550006', response: 'sent successfully' }, 72 | { num: '+15005550001', response: 'number is invalid' }, 73 | { num: '+15005550002', response: 'cannot route a message' }, 74 | { num: '+15005550003', response: 'cannot send international messages' }, 75 | { num: '+15005550004', response: 'can not send messages to it' }, 76 | { num: '+15005550009', response: 'number is incapable of receiving SMS messages' }, 77 | ]; 78 | 79 | for (const { num, response } of testNumToResp) { 80 | test(`test number ${num} should respond with: ${response}`, async ({ page }) => { 81 | // Navigate to Twilio API page 82 | await page.goto('/api/twilio'); 83 | await page.waitForLoadState('networkidle'); 84 | 85 | // Fill inputs and submit form 86 | await page.fill('input[name="number"]', num); 87 | await page.fill('input[name="message"]', 'Hello, from Twilio.'); 88 | await page.click('button[type="submit"]'); 89 | await page.waitForLoadState('networkidle'); 90 | 91 | // Check for appropriate response 92 | const alertDiv = page.locator('div.alert.alert-dismissible'); 93 | await expect(alertDiv).toBeVisible(); 94 | await expect(alertDiv).toContainText(response); 95 | }); 96 | } 97 | }); 98 | -------------------------------------------------------------------------------- /test/fixtures/GET_https%3A%2F%2Fapi.trakt.tv%2Fmovies%2Ftrending%3Flimit%3D6%26extended%3Dimages.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "watchers": 1238, 4 | "movie": { 5 | "title": "A House of Dynamite", 6 | "year": 2025, 7 | "ids": { "trakt": 1049823, "slug": "a-house-of-dynamite-2025", "imdb": "tt32376165", "tmdb": 1290159 }, 8 | "images": { 9 | "fanart": ["walter-r2.trakt.tv/images/movies/001/049/823/fanarts/medium/56530050e3.jpg.webp"], 10 | "poster": ["walter-r2.trakt.tv/images/movies/001/049/823/posters/thumb/1ee93ddb66.jpg.webp"], 11 | "logo": ["walter-r2.trakt.tv/images/movies/001/049/823/logos/medium/3b2fd9c5a0.png.webp"], 12 | "clearart": [], 13 | "banner": [], 14 | "thumb": [] 15 | } 16 | } 17 | }, 18 | { 19 | "watchers": 816, 20 | "movie": { 21 | "title": "The Long Walk", 22 | "year": 2025, 23 | "ids": { "trakt": 449045, "slug": "the-long-walk-2025", "imdb": "tt10374610", "tmdb": 604079 }, 24 | "images": { 25 | "fanart": ["walter-r2.trakt.tv/images/movies/000/449/045/fanarts/medium/be03d6d379.jpg.webp"], 26 | "poster": ["walter-r2.trakt.tv/images/movies/000/449/045/posters/thumb/eea79cb06f.jpg.webp"], 27 | "logo": ["walter-r2.trakt.tv/images/movies/000/449/045/logos/medium/a499a20340.png.webp"], 28 | "clearart": ["walter-r2.trakt.tv/images/movies/000/449/045/cleararts/medium/5714074e92.png.webp"], 29 | "banner": ["walter-r2.trakt.tv/images/movies/000/449/045/banners/medium/9ffe4d9dc0.jpg.webp"], 30 | "thumb": ["walter-r2.trakt.tv/images/movies/000/449/045/thumbs/medium/14d3aa5763.jpg.webp"] 31 | } 32 | } 33 | }, 34 | { 35 | "watchers": 367, 36 | "movie": { 37 | "title": "Weapons", 38 | "year": 2025, 39 | "ids": { "trakt": 867094, "slug": "weapons-2025", "imdb": "tt26581740", "tmdb": 1078605 }, 40 | "images": { 41 | "fanart": ["walter-r2.trakt.tv/images/movies/000/867/094/fanarts/medium/5925fc9e80.jpg.webp"], 42 | "poster": ["walter-r2.trakt.tv/images/movies/000/867/094/posters/thumb/70d5a3734e.jpg.webp"], 43 | "logo": ["walter-r2.trakt.tv/images/movies/000/867/094/logos/medium/667e0b7354.png.webp"], 44 | "clearart": ["walter-r2.trakt.tv/images/movies/000/867/094/cleararts/medium/5a69034a6b.png.webp"], 45 | "banner": ["walter-r2.trakt.tv/images/movies/000/867/094/banners/medium/964a565533.jpg.webp"], 46 | "thumb": ["walter-r2.trakt.tv/images/movies/000/867/094/thumbs/medium/86b6ad799a.jpg.webp"] 47 | } 48 | } 49 | }, 50 | { 51 | "watchers": 317, 52 | "movie": { 53 | "title": "TRON: Ares", 54 | "year": 2025, 55 | "ids": { "trakt": 385396, "slug": "tron-ares-2025", "imdb": "tt6604188", "tmdb": 533533 }, 56 | "images": { 57 | "fanart": ["walter-r2.trakt.tv/images/movies/000/385/396/fanarts/medium/160295fe98.jpg.webp"], 58 | "poster": ["walter-r2.trakt.tv/images/movies/000/385/396/posters/thumb/9198618b70.jpg.webp"], 59 | "logo": ["walter-r2.trakt.tv/images/movies/000/385/396/logos/medium/d81dd46897.png.webp"], 60 | "clearart": [], 61 | "banner": ["walter-r2.trakt.tv/images/movies/000/385/396/banners/medium/bbdd83539c.jpg.webp"], 62 | "thumb": ["walter-r2.trakt.tv/images/movies/000/385/396/thumbs/medium/9f64043b10.jpg.webp"] 63 | } 64 | } 65 | }, 66 | { 67 | "watchers": 300, 68 | "movie": { 69 | "title": "The Roses", 70 | "year": 2025, 71 | "ids": { "trakt": 1029272, "slug": "the-roses-2025", "imdb": "tt31973693", "tmdb": 1267905 }, 72 | "images": { 73 | "fanart": ["walter-r2.trakt.tv/images/movies/001/029/272/fanarts/medium/3e4837e92f.jpg.webp"], 74 | "poster": ["walter-r2.trakt.tv/images/movies/001/029/272/posters/thumb/d4e3aa4013.jpg.webp"], 75 | "logo": ["walter-r2.trakt.tv/images/movies/001/029/272/logos/medium/6c6f3749ff.png.webp"], 76 | "clearart": [], 77 | "banner": ["walter-r2.trakt.tv/images/movies/001/029/272/banners/medium/20db03aad0.jpg.webp"], 78 | "thumb": [] 79 | } 80 | } 81 | }, 82 | { 83 | "watchers": 294, 84 | "movie": { 85 | "title": "The Fantastic 4: First Steps", 86 | "year": 2025, 87 | "ids": { "trakt": 460087, "slug": "the-fantastic-4-first-steps-2025", "imdb": "tt10676052", "tmdb": 617126 }, 88 | "images": { 89 | "fanart": ["walter-r2.trakt.tv/images/movies/000/460/087/fanarts/medium/7b0102ea5a.jpg.webp"], 90 | "poster": ["walter-r2.trakt.tv/images/movies/000/460/087/posters/thumb/9d9aaac07e.jpg.webp"], 91 | "logo": ["walter-r2.trakt.tv/images/movies/000/460/087/logos/medium/428524c416.png.webp"], 92 | "clearart": ["walter-r2.trakt.tv/images/movies/000/460/087/cleararts/medium/2350b8023a.png.webp"], 93 | "banner": ["walter-r2.trakt.tv/images/movies/000/460/087/banners/medium/f127538d51.jpg.webp"], 94 | "thumb": ["walter-r2.trakt.tv/images/movies/000/460/087/thumbs/medium/ccde65ee19.jpg.webp"] 95 | } 96 | } 97 | } 98 | ] 99 | -------------------------------------------------------------------------------- /patches/@langchain+mongodb+0.1.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@langchain/mongodb/dist/index.cjs b/node_modules/@langchain/mongodb/dist/index.cjs 2 | index 3b3fff7..8c43aec 100644 3 | --- a/node_modules/@langchain/mongodb/dist/index.cjs 4 | +++ b/node_modules/@langchain/mongodb/dist/index.cjs 5 | @@ -17,3 +17,4 @@ Object.defineProperty(exports, "__esModule", { value: true }); 6 | __exportStar(require("./chat_history.cjs"), exports); 7 | __exportStar(require("./vectorstores.cjs"), exports); 8 | __exportStar(require("./storage.cjs"), exports); 9 | +__exportStar(require("./semantic_cache.cjs"), exports); 10 | diff --git a/node_modules/@langchain/mongodb/dist/semantic_cache.cjs b/node_modules/@langchain/mongodb/dist/semantic_cache.cjs 11 | new file mode 100644 12 | index 0000000..7c87375 13 | --- /dev/null 14 | +++ b/node_modules/@langchain/mongodb/dist/semantic_cache.cjs 15 | @@ -0,0 +1,92 @@ 16 | +const { BaseCache } = require("@langchain/core/caches"); 17 | + 18 | +class MongoDBAtlasSemanticCache extends BaseCache { 19 | + constructor(collection, embeddingModel, { 20 | + indexName = "default", // Optional index name, defaults to "default" 21 | + scoreThreshold = null, // Optional similarity score filter 22 | + waitUntilReady = null // Optional delay for indexing readiness 23 | + } = {}) { 24 | + super(); 25 | + this.collection = collection; 26 | + this.embeddingModel = embeddingModel; 27 | + this.indexName = indexName; 28 | + this.scoreThreshold = scoreThreshold; 29 | + this.waitUntilReady = waitUntilReady; 30 | + } 31 | + 32 | + async lookup(prompt, llmString) { 33 | + const embedding = MongoDBAtlasSemanticCache.fixArrayPrecision(await this.getEmbedding(prompt)); 34 | + const searchQuery = { 35 | + queryVector: embedding, 36 | + index: this.indexName, 37 | + path: "embedding", 38 | + limit: 1, 39 | + numCandidates: 20, 40 | + }; 41 | + const searchResponse = await this.collection.aggregate([ 42 | + { $vectorSearch: searchQuery }, 43 | + { $set: { score: { $meta: "vectorSearchScore" } } }, 44 | + { $match: { llm_string: MongoDBAtlasSemanticCache.extractModelName(llmString ?? "") } }, 45 | + { $limit: 1 } 46 | + ]).toArray(); 47 | + if (searchResponse.length === 0 || (this.scoreThreshold !== null && searchResponse[0].score < this.scoreThreshold)) { 48 | + return null; 49 | + } 50 | + return searchResponse[0].return_val; 51 | + } 52 | + 53 | + async update(prompt, llmString, returnVal) { 54 | + const embedding = await this.getEmbedding(prompt); 55 | + await this.collection.insertOne({ 56 | + prompt, 57 | + llm_string: MongoDBAtlasSemanticCache.extractModelName(llmString), 58 | + return_val: returnVal, 59 | + embedding 60 | + }); 61 | + // Wait for indexing if a waitUntilReady delay is specified 62 | + if (this.waitUntilReady) { 63 | + await new Promise(resolve => setTimeout(resolve, this.waitUntilReady * 1000)); // Convert float to milliseconds 64 | + } 65 | + } 66 | + 67 | + async getEmbedding(text) { 68 | + return await this.embeddingModel.embedQuery(text); 69 | + } 70 | + 71 | + async clear(filters = {}) { 72 | + await this.collection.deleteMany(filters); 73 | + } 74 | + 75 | + static extractModelName(llmString) { 76 | + let safeLLMString = "unknown_model"; // Default fallback 77 | + const match = llmString.match(/(?:^|,)model_name:"([^"]+)"|(?:^|,)model:"([^"]+)"/); 78 | + if (match) { 79 | + safeLLMString = match[1] ?? match[2]; // Prioritize model_name, fallback to model 80 | + } 81 | + return safeLLMString; 82 | + } 83 | + 84 | + /** 85 | + * Copied from vectorestores.cjs 86 | + * 87 | + * Static method to fix the precision of the array that ensures that 88 | + * every number in this array is always float when casted to other types. 89 | + * This is needed since MongoDB Atlas Vector Search does not cast integer 90 | + * inside vector search to float automatically. 91 | + * This method shall introduce a hint of error but should be safe to use 92 | + * since introduced error is very small, only applies to integer numbers 93 | + * returned by embeddings, and most embeddings shall not have precision 94 | + * as high as 15 decimal places. 95 | + * @param array Array of number to be fixed. 96 | + * @returns 97 | + */ 98 | + static fixArrayPrecision(array) { 99 | + if (!Array.isArray(array)) { 100 | + console.error("fixArrayPrecision received an invalid input:", array); 101 | + return []; 102 | + } 103 | + return array.map((value) => (Number.isInteger(value) ? value + 0.000000000000001 : value)); 104 | + } 105 | +} 106 | + 107 | +exports.MongoDBAtlasSemanticCache = MongoDBAtlasSemanticCache; 108 | -------------------------------------------------------------------------------- /test/fixtures/GET_https%3A%2F%2Fen.wikipedia.org%2Fw%2Fapi.php%3Faction%3Dquery%26format%3Djson%26origin%3D_%26list%3Dsearch%26srsearch%3Djavascript%26srlimit%3D10.json: -------------------------------------------------------------------------------- 1 | { 2 | "batchcomplete": "", 3 | "continue": { "sroffset": 10, "continue": "-||" }, 4 | "query": { 5 | "searchinfo": { "totalhits": 4626, "suggestion": "java script", "suggestionsnippet": "java script" }, 6 | "search": [ 7 | { 8 | "ns": 0, 9 | "title": "JavaScript", 10 | "pageid": 9845, 11 | "size": 86320, 12 | "wordcount": 7934, 13 | "snippet": "JavaScript (JS) is a programming language and core technology of the Web, alongside HTML and CSS. It was created by Brendan Eich in 1995. Ninety-nine percent", 14 | "timestamp": "2025-10-22T15:48:31Z" 15 | }, 16 | { 17 | "ns": 0, 18 | "title": "JSON", 19 | "pageid": 1575082, 20 | "size": 47688, 21 | "wordcount": 4928, 22 | "snippet": "JSON (JavaScript Object Notation, pronounced /\u02c8d\u0292e\u026as\u0259n/ or /\u02c8d\u0292e\u026a\u02ccs\u0252n/) is an open standard file format and data interchange format that uses human-readable", 23 | "timestamp": "2025-10-27T15:44:33Z" 24 | }, 25 | { 26 | "ns": 0, 27 | "title": "Ajax (programming)", 28 | "pageid": 1610950, 29 | "size": 19474, 30 | "wordcount": 1764, 31 | "snippet": "Ajax (also AJAX /\u02c8e\u026ad\u0292\u00e6ks/; short for "asynchronous JavaScript and XML") is a set of web development techniques that uses various web technologies on the", 32 | "timestamp": "2025-08-14T01:53:40Z" 33 | }, 34 | { 35 | "ns": 0, 36 | "title": "Percent-encoding", 37 | "pageid": 1829286, 38 | "size": 19111, 39 | "wordcount": 1959, 40 | "snippet": "URL encoding, officially known as percent-encoding, is a method to encode arbitrary data in a uniform resource identifier (URI) using only the US-ASCII", 41 | "timestamp": "2025-10-27T14:09:41Z" 42 | }, 43 | { 44 | "ns": 0, 45 | "title": "WebKit", 46 | "pageid": 689524, 47 | "size": 51144, 48 | "wordcount": 4119, 49 | "snippet": "WebKit as implemented by Google in the Chromium project. Its JavaScript engine, JavascriptCore, also powers the Bun server-side JS runtime, as opposed", 50 | "timestamp": "2025-10-28T18:24:18Z" 51 | }, 52 | { 53 | "ns": 0, 54 | "title": "List of JavaScript engines", 55 | "pageid": 1770496, 56 | "size": 45438, 57 | "wordcount": 2288, 58 | "snippet": "The first engines for JavaScript were mere interpreters of the source code, but all relevant modern engines use just-in-time compilation for improved performance", 59 | "timestamp": "2025-10-27T12:06:42Z" 60 | }, 61 | { 62 | "ns": 0, 63 | "title": "V8 (JavaScript engine)", 64 | "pageid": 19140716, 65 | "size": 13231, 66 | "wordcount": 1085, 67 | "snippet": "V8 is a JavaScript and WebAssembly engine developed by Google for its Chrome browser. V8 is free and open-source software that is part of the Chromium", 68 | "timestamp": "2025-10-28T04:23:52Z" 69 | }, 70 | { 71 | "ns": 0, 72 | "title": "JavaScript library", 73 | "pageid": 10081669, 74 | "size": 9293, 75 | "wordcount": 863, 76 | "snippet": "A JavaScript library is a library of pre-written JavaScript code that allows for easier development of JavaScript-based applications, especially for AJAX", 77 | "timestamp": "2025-06-29T20:49:43Z" 78 | }, 79 | { 80 | "ns": 0, 81 | "title": "Unobtrusive JavaScript", 82 | "pageid": 9136218, 83 | "size": 14950, 84 | "wordcount": 1519, 85 | "snippet": "Unobtrusive JavaScript is a general approach to the use of client-side JavaScript in web pages so that if JavaScript features are partially or fully absent", 86 | "timestamp": "2024-12-19T18:25:29Z" 87 | }, 88 | { 89 | "ns": 0, 90 | "title": "Uniform Resource Identifier", 91 | "pageid": 32146, 92 | "size": 30925, 93 | "wordcount": 4076, 94 | "snippet": "A Uniform Resource Identifier (URI), formerly Universal Resource Identifier, is a unique sequence of characters that identifies an abstract or physical", 95 | "timestamp": "2025-10-29T16:51:42Z" 96 | } 97 | ] 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /test/morgan.test.js: -------------------------------------------------------------------------------- 1 | const morgan = require('morgan'); 2 | const { expect } = require('chai'); 3 | const sinon = require('sinon'); 4 | 5 | // Import the morgan configuration to ensure tokens are registered 6 | const { _getMorganFormat } = require('../config/morgan'); 7 | 8 | describe('Morgan Configuration Tests', () => { 9 | let req; 10 | let res; 11 | let clock; 12 | const originalEnv = process.env.NODE_ENV; 13 | 14 | beforeEach(() => { 15 | // Mock request 16 | req = { 17 | method: 'GET', 18 | url: '/test', 19 | headers: { 20 | 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 21 | 'remote-addr': '127.0.0.1', 22 | }, 23 | ip: '127.0.0.1', 24 | }; 25 | 26 | // Enhanced mock response 27 | res = { 28 | statusCode: 200, 29 | _header: true, // Set to true to indicate headers sent 30 | finished: true, // Set to true to indicate response complete 31 | _headers: {}, // for storing headers 32 | getHeader(name) { 33 | return this._headers[name.toLowerCase()]; 34 | }, 35 | get(name) { 36 | return this.getHeader(name); 37 | }, 38 | setHeader(name, value) { 39 | this._headers[name.toLowerCase()] = value; 40 | }, 41 | }; 42 | 43 | // Fix the date for consistent testing 44 | clock = sinon.useFakeTimers(new Date('2024-01-01T12:00:00').getTime()); 45 | }); 46 | 47 | afterEach(() => { 48 | clock.restore(); 49 | sinon.restore(); 50 | process.env.NODE_ENV = originalEnv; 51 | }); 52 | 53 | describe('Custom Token: colored-status', () => { 54 | it('should color status codes correctly', () => { 55 | const testCases = [ 56 | { status: 200, color: '\x1b[32m' }, // green 57 | { status: 304, color: '\x1b[36m' }, // cyan 58 | { status: 404, color: '\x1b[33m' }, // yellow 59 | { status: 500, color: '\x1b[31m' }, // red 60 | ]; 61 | 62 | testCases.forEach(({ status, color }) => { 63 | res.statusCode = status; 64 | const formatter = morgan.compile(':colored-status'); 65 | const output = formatter(morgan, req, res); 66 | expect(output).to.equal(`${color}${status}\x1b[0m`); 67 | }); 68 | }); 69 | }); 70 | 71 | describe('Custom Token: short-date', () => { 72 | it('should format date correctly', () => { 73 | const formatter = morgan.compile(':short-date'); 74 | const output = formatter(morgan, req, res); 75 | expect(output).to.equal('2024-01-01 12:00:00'); 76 | }); 77 | }); 78 | 79 | describe('Custom Token: parsed-user-agent', () => { 80 | it('should parse user agent correctly', () => { 81 | const formatter = morgan.compile(':parsed-user-agent'); 82 | const output = formatter(morgan, req, res); 83 | expect(output).to.equal('Windows/Chrome v120'); 84 | }); 85 | 86 | it('should handle unknown user agent', () => { 87 | req.headers['user-agent'] = undefined; 88 | const formatter = morgan.compile(':parsed-user-agent'); 89 | const output = formatter(morgan, req, res); 90 | expect(output).to.equal('Unknown'); 91 | }); 92 | }); 93 | 94 | describe('Custom Token: bytes-sent', () => { 95 | it('should format bytes correctly', () => { 96 | res.setHeader('Content-Length', '2048'); 97 | const formatter = morgan.compile(':bytes-sent'); 98 | const output = formatter(morgan, req, res); 99 | expect(output).to.equal('2.00KB'); 100 | }); 101 | 102 | it('should handle missing content length', () => { 103 | const formatter = morgan.compile(':bytes-sent'); 104 | const output = formatter(morgan, req, res); 105 | expect(output).to.equal('-'); 106 | }); 107 | }); 108 | 109 | describe('Custom Token: transfer-state', () => { 110 | it('should show correct transfer state', () => { 111 | const formatter = morgan.compile(':transfer-state'); 112 | const output = formatter(morgan, req, res); 113 | expect(output).to.equal('COMPLETE'); 114 | }); 115 | }); 116 | 117 | describe('Complete Morgan Format', () => { 118 | // const { _getMorganFormat } = require('../config/morgan'); 119 | 120 | it('should combine all tokens correctly in development', () => { 121 | process.env.NODE_ENV = 'development'; 122 | const formatter = morgan.compile(_getMorganFormat()); 123 | const output = formatter(morgan, req, res); 124 | expect(output).to.include('127.0.0.1'); // Should include IP in development 125 | }); 126 | 127 | it('should exclude IP address in production', () => { 128 | process.env.NODE_ENV = 'production'; 129 | const formatter = morgan.compile(_getMorganFormat()); 130 | const output = formatter(morgan, req, res); 131 | expect(output).to.not.include('127.0.0.1'); // Should not include IP 132 | expect(output).to.include(' - '); // Should have hyphen instead 133 | }); 134 | }); 135 | }); 136 | --------------------------------------------------------------------------------