Compare commits

..

13 Commits

Author SHA1 Message Date
Balu Babu
4c9f8ed4e8 chore: added ping route in backend for healthcheck in prod.Dockerfile 2023-08-23 19:17:07 +05:30
Andrew Bastin
f8bbf320fb fix: move to alpine 3.16 to fix openssl related crashes 2023-08-22 12:55:19 +05:30
Andrew Bastin
633d98bbbc fix: prisma segfaulting on many cases 2023-08-22 12:54:27 +05:30
Andrew Bastin
44fabe6570 feat: exit the container if any of the child processes exit out 2023-08-22 12:54:27 +05:30
Andrew Bastin
8acfe8afb0 fix: add tini as the init process for a clean signal handling for the aio container 2023-08-22 12:54:27 +05:30
Andrew Bastin
e233f36ce0 fix: pass production and port environment variables for the backend functioning 2023-08-22 12:54:27 +05:30
Andrew Bastin
e1cbe6e003 chore: revert back environment variable prefixes to VITE_ 2023-08-22 12:53:49 +05:30
Andrew Bastin
1c35ea6e65 chore: rename frontend prod docker target into app 2023-08-22 12:53:18 +05:30
Andrew Bastin
6eb0426aca chore: correct VITE env prefix to APP 2023-08-22 12:53:18 +05:30
Andrew Bastin
fc0c113e00 chore: small corrections 2023-08-22 12:53:18 +05:30
Andrew Bastin
9e595ec594 feat: initial aio container implementation 2023-08-22 12:53:18 +05:30
Andrew Bastin
1b1a09c675 feat: updated frontend docker containers to allow for runtime environment variable definitions 2023-08-22 12:53:15 +05:30
Andrew Bastin
6454d83486 chore: simplify prod docker build process 2023-08-22 12:49:16 +05:30
1032 changed files with 42911 additions and 84730 deletions

View File

@@ -5,5 +5,5 @@
"features": { "features": {
"ghcr.io/NicoVIII/devcontainer-features/pnpm:1": {} "ghcr.io/NicoVIII/devcontainer-features/pnpm:1": {}
}, },
"postCreateCommand": "cp .env.example .env && pnpm i" "postCreateCommand": "mv .env.example .env && pnpm i"
} }

View File

@@ -12,8 +12,8 @@ SESSION_SECRET='add some secret here'
# Hoppscotch App Domain Config # Hoppscotch App Domain Config
REDIRECT_URL="http://localhost:3000" REDIRECT_URL="http://localhost:3000"
WHITELISTED_ORIGINS="http://localhost:3170,http://localhost:3000,http://localhost:3100" WHITELISTED_ORIGINS = "http://localhost:3170,http://localhost:3000,http://localhost:3100"
VITE_ALLOWED_AUTH_PROVIDERS=GOOGLE,GITHUB,MICROSOFT,EMAIL VITE_ALLOWED_AUTH_PROVIDERS = GOOGLE,GITHUB,MICROSOFT,EMAIL
# Google Auth Config # Google Auth Config
GOOGLE_CLIENT_ID="************************************************" GOOGLE_CLIENT_ID="************************************************"
@@ -59,6 +59,3 @@ VITE_BACKEND_API_URL=http://localhost:3170/v1
# Terms Of Service And Privacy Policy Links (Optional) # Terms Of Service And Privacy Policy Links (Optional)
VITE_APP_TOS_LINK=https://docs.hoppscotch.io/support/terms VITE_APP_TOS_LINK=https://docs.hoppscotch.io/support/terms
VITE_APP_PRIVACY_POLICY_LINK=https://docs.hoppscotch.io/support/privacy VITE_APP_PRIVACY_POLICY_LINK=https://docs.hoppscotch.io/support/privacy
# Set to `true` for subpath based access
ENABLE_SUBPATH_BASED_ACCESS=false

View File

@@ -1,84 +0,0 @@
name: "Push containers to Docker Hub on release"
on:
push:
tags:
- '*.*.*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup environment
run: cp .env.example .env
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push the backend container
uses: docker/build-push-action@v4
with:
context: .
file: ./prod.Dockerfile
target: backend
push: true
platforms: |
linux/amd64
linux/arm64
tags: |
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }}:latest
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }}:${{ github.ref_name }}
- name: Build and push the frontend container
uses: docker/build-push-action@v4
with:
context: .
file: ./prod.Dockerfile
target: app
push: true
platforms: |
linux/amd64
linux/arm64
tags: |
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }}:latest
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }}:${{ github.ref_name }}
- name: Build and push the admin dashboard container
uses: docker/build-push-action@v4
with:
context: .
file: ./prod.Dockerfile
target: sh_admin
push: true
platforms: |
linux/amd64
linux/arm64
tags: |
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }}:latest
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }}:${{ github.ref_name }}
- name: Build and push the AIO container
uses: docker/build-push-action@v4
with:
context: .
file: ./prod.Dockerfile
target: aio
push: true
platforms: |
linux/amd64
linux/arm64
tags: |
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_AIO_CONTAINER_NAME }}:latest
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_AIO_CONTAINER_NAME }}:${{ github.ref_name }}

View File

@@ -17,21 +17,22 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v3
- name: Setup environment - name: Setup environment
run: mv .env.example .env run: mv .env.example .env
- name: Setup node
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v2.2.4
with: with:
version: 8 version: 8
run_install: true run_install: true
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Run tests - name: Run tests
run: pnpm test run: pnpm test

View File

@@ -36,7 +36,7 @@ jobs:
# Deploy the ui site with netlify-cli # Deploy the ui site with netlify-cli
- name: Deploy to Netlify (ui) - name: Deploy to Netlify (ui)
run: npx netlify-cli@15.11.0 deploy --dir=packages/hoppscotch-ui/.histoire/dist --prod run: npx netlify-cli deploy --dir=packages/hoppscotch-ui/.histoire/dist --prod
env: env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_UI_SITE_ID }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_UI_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}

5
.gitignore vendored
View File

@@ -81,7 +81,10 @@ web_modules/
# dotenv environment variable files # dotenv environment variable files
.env .env
.env.* .env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/) # parcel-bundler cache (https://parceljs.org/)
.cache .cache

1
.npmrc
View File

@@ -1,2 +1 @@
shamefully-hoist=false shamefully-hoist=false
save-prefix=''

14
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"recommendations": [
"antfu.iconify",
"vue.volar",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"editorconfig.editorconfig",
"csstools.postcss",
"folke.vscode-monorepo-workspace"
],
"unwantedRecommendations": [
"octref.vetur"
]
}

View File

@@ -6,8 +6,8 @@ We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status, identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual nationality, personal appearance, race, religion, or sexual identity
identity and orientation. and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community. diverse, inclusive, and healthy community.
@@ -22,17 +22,17 @@ community include:
* Giving and gracefully accepting constructive feedback * Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, * Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall * Focusing on what is best not just for us as individuals, but for the
community overall community
Examples of unacceptable behavior include: Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of * The use of sexualized language or imagery, and sexual attention or
any kind advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks * Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment * Public or private harassment
* Publishing others' private information, such as a physical or email address, * Publishing others' private information, such as a physical or email
without their explicit permission address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a * Other conduct which could reasonably be considered inappropriate in a
professional setting professional setting
@@ -82,15 +82,15 @@ behavior was inappropriate. A public apology may be requested.
### 2. Warning ### 2. Warning
**Community Impact**: A violation through a single incident or series of **Community Impact**: A violation through a single incident or series
actions. of actions.
**Consequence**: A warning with consequences for continued behavior. No **Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent like social media. Violating these terms may lead to a temporary or
ban. permanent ban.
### 3. Temporary Ban ### 3. Temporary Ban
@@ -106,27 +106,23 @@ Violating these terms may lead to a permanent ban.
### 4. Permanent Ban ### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community **Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals. individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the **Consequence**: A permanent ban from any sort of public interaction within
community. the community.
## Attribution ## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at version 2.0, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by Community Impact Guidelines were inspired by [Mozilla's code of conduct
[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. enforcement ladder](https://github.com/mozilla/diversity).
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org [homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity For answers to common questions about this code of conduct, see the FAQ at
[FAQ]: https://www.contributor-covenant.org/faq https://www.contributor-covenant.org/faq. Translations are available at
[translations]: https://www.contributor-covenant.org/translations https://www.contributor-covenant.org/translations.

190
README.md
View File

@@ -2,18 +2,23 @@
<a href="https://hoppscotch.io"> <a href="https://hoppscotch.io">
<img <img
src="https://avatars.githubusercontent.com/u/56705483" src="https://avatars.githubusercontent.com/u/56705483"
alt="Hoppscotch" alt="Hoppscotch Logo"
height="64" height="64"
/> />
</a> </a>
<h3> <br />
<p>
<h3>
<b>
Hoppscotch
</b>
</h3>
</p>
<p>
<b> <b>
Hoppscotch Open source API development ecosystem
</b> </b>
</h3> </p>
<b>
Open Source API Development Ecosystem
</b>
<p> <p>
[![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen?logo=github)](CODE_OF_CONDUCT.md) [![Website](https://img.shields.io/website?url=https%3A%2F%2Fhoppscotch.io&logo=hoppscotch)](https://hoppscotch.io) [![Tests](https://github.com/hoppscotch/hoppscotch/actions/workflows/tests.yml/badge.svg)](https://github.com/hoppscotch/hoppscotch/actions) [![Tweet](https://img.shields.io/twitter/url?url=https%3A%2F%2Fhoppscotch.io%2F)](https://twitter.com/share?text=%F0%9F%91%BD%20Hoppscotch%20%E2%80%A2%20Open%20source%20API%20development%20ecosystem%20-%20Helps%20you%20create%20requests%20faster,%20saving%20precious%20time%20on%20development.&url=https://hoppscotch.io&hashtags=hoppscotch&via=hoppscotch_io) [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen?logo=github)](CODE_OF_CONDUCT.md) [![Website](https://img.shields.io/website?url=https%3A%2F%2Fhoppscotch.io&logo=hoppscotch)](https://hoppscotch.io) [![Tests](https://github.com/hoppscotch/hoppscotch/actions/workflows/tests.yml/badge.svg)](https://github.com/hoppscotch/hoppscotch/actions) [![Tweet](https://img.shields.io/twitter/url?url=https%3A%2F%2Fhoppscotch.io%2F)](https://twitter.com/share?text=%F0%9F%91%BD%20Hoppscotch%20%E2%80%A2%20Open%20source%20API%20development%20ecosystem%20-%20Helps%20you%20create%20requests%20faster,%20saving%20precious%20time%20on%20development.&url=https://hoppscotch.io&hashtags=hoppscotch&via=hoppscotch_io)
@@ -29,18 +34,23 @@
</p> </p>
<br /> <br />
<p> <p>
<a href="https://hoppscotch.io"> <a href="https://hoppscotch.io/#gh-light-mode-only" target="_blank">
<picture> <img
<source media="(prefers-color-scheme: dark)" srcset="./packages/hoppscotch-common/public/images/banner-dark.png"> src="./packages/hoppscotch-common/public/images/banner-light.png"
<source media="(prefers-color-scheme: light)" srcset="./packages/hoppscotch-common/public/images/banner-light.png"> alt="Hoppscotch"
<img alt="Hoppscotch" src="./packages/hoppscotch-common/public/images/banner-dark.png"> width="100%"
</picture> />
</a>
<a href="https://hoppscotch.io/#gh-dark-mode-only" target="_blank">
<img
src="./packages/hoppscotch-common/public/images/banner-dark.png"
alt="Hoppscotch"
width="100%"
/>
</a> </a>
</p> </p>
</div> </div>
_We highly recommend you take a look at the [**Hoppscotch Documentation**](https://docs.hoppscotch.io) to learn more about the app._
#### **Support** #### **Support**
[![Chat on Discord](https://img.shields.io/badge/chat-Discord-7289DA?logo=discord)](https://hoppscotch.io/discord) [![Chat on Telegram](https://img.shields.io/badge/chat-Telegram-2CA5E0?logo=telegram)](https://hoppscotch.io/telegram) [![Discuss on GitHub](https://img.shields.io/badge/discussions-GitHub-333333?logo=github)](https://github.com/hoppscotch/hoppscotch/discussions) [![Chat on Discord](https://img.shields.io/badge/chat-Discord-7289DA?logo=discord)](https://hoppscotch.io/discord) [![Chat on Telegram](https://img.shields.io/badge/chat-Telegram-2CA5E0?logo=telegram)](https://hoppscotch.io/telegram) [![Discuss on GitHub](https://img.shields.io/badge/discussions-GitHub-333333?logo=github)](https://github.com/hoppscotch/hoppscotch/discussions)
@@ -49,9 +59,9 @@ _We highly recommend you take a look at the [**Hoppscotch Documentation**](https
❤️ **Lightweight:** Crafted with minimalistic UI design. ❤️ **Lightweight:** Crafted with minimalistic UI design.
⚡️ **Fast:** Send requests and get responses in real time. ⚡️ **Fast:** Send requests and get/copy responses in real-time.
🗄️ **HTTP Methods:** Request methods define the type of action you are requesting to be performed. **HTTP Methods**
- `GET` - Requests retrieve resource information - `GET` - Requests retrieve resource information
- `POST` - The server creates a new entry in a database - `POST` - The server creates a new entry in a database
@@ -64,15 +74,17 @@ _We highly recommend you take a look at the [**Hoppscotch Documentation**](https
- `TRACE` - Performs a message loop-back test along the path to the target resource - `TRACE` - Performs a message loop-back test along the path to the target resource
- `<custom>` - Some APIs use custom request methods such as `LIST`. Type in your custom methods. - `<custom>` - Some APIs use custom request methods such as `LIST`. Type in your custom methods.
🌈 **Theming:** Customizable combinations for background, foreground, and accent colors — [customize now](https://hoppscotch.io/settings). 🌈 **Make it yours:** Customizable combinations for background, foreground, and accent colors — [customize now](https://hoppscotch.io/settings).
- Choose a theme: System preference, Light, Dark, and Black **Theming**
- Choose accent colors: Green, Teal, Blue, Indigo, Purple, Yellow, Orange, Red, and Pink
- Choose a theme: System (default), Light, Dark, and Black
- Choose accent color: Green (default), Teal, Blue, Indigo, Purple, Yellow, Orange, Red, and Pink
- Distraction-free Zen mode - Distraction-free Zen mode
_Customized themes are synced with your cloud/local session._ _Customized themes are synced with cloud / local session_
🔥 **PWA:** Install as a [Progressive Web App](https://web.dev/progressive-web-apps) on your device. 🔥 **PWA:** Install as a [PWA](https://web.dev/what-are-pwas/) on your device.
- Instant loading with Service Workers - Instant loading with Service Workers
- Offline support - Offline support
@@ -95,7 +107,7 @@ _Customized themes are synced with your cloud/local session._
📡 **Server-Sent Events:** Receive a stream of updates from a server over an HTTP connection without resorting to polling. 📡 **Server-Sent Events:** Receive a stream of updates from a server over an HTTP connection without resorting to polling.
🌩 **Socket.IO:** Send and Receive data with the SocketIO server. 🌩 **Socket.IO:** Send and Receive data with SocketIO server.
🦟 **MQTT:** Subscribe and Publish to topics of an MQTT Broker. 🦟 **MQTT:** Subscribe and Publish to topics of an MQTT Broker.
@@ -115,7 +127,7 @@ _Customized themes are synced with your cloud/local session._
- OAuth 2.0 - OAuth 2.0
- OIDC Access Token/PKCE - OIDC Access Token/PKCE
📢 **Headers:** Describes the format the body of your request is being sent in. 📢 **Headers:** Describes the format the body of your request is being sent as.
📫 **Parameters:** Use request parameters to set varying parts in simulated requests. 📫 **Parameters:** Use request parameters to set varying parts in simulated requests.
@@ -125,14 +137,14 @@ _Customized themes are synced with your cloud/local session._
- FormData, JSON, and many more - FormData, JSON, and many more
- Toggle between key-value and RAW input parameter list - Toggle between key-value and RAW input parameter list
📮 **Response:** Contains the status line, headers, and the message/response body. 👋 **Response:** Contains the status line, headers, and the message/response body.
- Copy the response to the clipboard - Copy response to clipboard
- Download the response as a file - Download response as a file
- View response headers - View response headers
- View raw and preview HTML, image, JSON, and XML responses - View raw and preview of HTML, image, JSON, XML responses
**History:** Request entries are synced with your cloud/local session storage. **History:** Request entries are synced with cloud / local session storage to restore with a single click.
📁 **Collections:** Keep your API requests organized with collections and folders. Reuse them with a single click. 📁 **Collections:** Keep your API requests organized with collections and folders. Reuse them with a single click.
@@ -140,32 +152,7 @@ _Customized themes are synced with your cloud/local session._
- Nested folders - Nested folders
- Export and import as a file or GitHub gist - Export and import as a file or GitHub gist
_Collections are synced with your cloud/local session storage._ _Collections are synced with cloud / local session storage_
📜 **Pre-Request Scripts:** Snippets of code associated with a request that is executed before the request is sent.
- Set environment variables
- Include timestamp in the request headers
- Send a random alphanumeric string in the URL parameters
- Any JavaScript functions
👨‍👩‍👧‍👦 **Teams:** Helps you collaborate across your teams to design, develop, and test APIs faster.
- Create unlimited teams
- Create unlimited shared collections
- Create unlimited team members
- Role-based access control
- Cloud sync
- Multiple devices
👥 **Workspaces:** Organize your personal and team collections environments into workspaces. Easily switch between workspaces to manage multiple projects.
- Create unlimited workspaces
- Switch between personal and team workspaces
⌨️ **Keyboard Shortcuts:** Optimized for efficiency.
> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/documentation/features/shortcuts)**
🌐 **Proxy:** Enable Proxy Mode from Settings to access blocked APIs. 🌐 **Proxy:** Enable Proxy Mode from Settings to access blocked APIs.
@@ -174,31 +161,60 @@ _Collections are synced with your cloud/local session storage._
- Access APIs served in non-HTTPS (`http://`) endpoints - Access APIs served in non-HTTPS (`http://`) endpoints
- Use your Proxy URL - Use your Proxy URL
_Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/hoppscotch/proxyscotch)** - **[Privacy Policy](https://docs.hoppscotch.io/support/privacy)**._ _Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/hoppscotch/proxyscotch)** - **[Privacy Policy](https://docs.hoppscotch.io/support/privacy)**_
📜 **Pre-Request Scripts β:** Snippets of code associated with a request that is executed before the request is sent.
- Set environment variables
- Include timestamp in the request headers
- Send a random alphanumeric string in the URL parameters
- Any JavaScript functions
📄 **API Documentation:** Create and share dynamic API documentation easily, quickly.
1. Add your requests to Collections and Folders
2. Export Collections and easily share your APIs with the rest of your team
3. Import Collections and Generate Documentation on-the-go
⌨️ **Keyboard Shortcuts:** Optimized for efficiency.
> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/documentation/features/shortcuts)**
🌎 **i18n:** Experience the app in your language. 🌎 **i18n:** Experience the app in your language.
Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) for details on our [`CODE OF CONDUCT`](CODE_OF_CONDUCT.md) and the process for submitting pull requests to us. Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) for details on our [`CODE OF CONDUCT`](CODE_OF_CONDUCT.md), and the process for submitting pull requests to us.
☁️ **Auth + Sync:** Sign in and sync your data in real-time across all your devices. 📦 **Add-ons:** Official add-ons for hoppscotch.
**Sign in with:** - **[Proxy](https://github.com/hoppscotch/proxyscotch)** - A simple proxy server created for Hoppscotch
- **[CLI β](https://github.com/hoppscotch/hopp-cli)** - A CLI solution for Hoppscotch
- **[Browser Extensions](https://github.com/hoppscotch/hoppscotch-extension)** - Browser extensions that simplifies access to Hoppscotch
[![Firefox](https://raw.github.com/alrra/browser-logos/master/src/firefox/firefox_16x16.png) **Firefox**](https://addons.mozilla.org/en-US/firefox/addon/hoppscotch) &nbsp;|&nbsp; [![Chrome](https://raw.github.com/alrra/browser-logos/master/src/chrome/chrome_16x16.png) **Chrome**](https://chrome.google.com/webstore/detail/hoppscotch-extension-for-c/amknoiejhlmhancpahfcfcfhllgkpbld)
> **Extensions fixes `CORS` issues.**
- **[Hopp-Doc-Gen](https://github.com/hoppscotch/hopp-doc-gen)** - An API doc generator CLI for Hoppscotch
_Add-ons are developed and maintained under **[Hoppscotch Organization](https://github.com/hoppscotch)**._
☁️ **Auth + Sync:** Sign in and sync your data in real-time.
**Sign in with**
- GitHub - GitHub
- Google - Google
- Microsoft - Microsoft
- Email - Email
- SSO (Single Sign-On)[^EE]
**🔄 Synchronize your data:** Handoff to continue tasks on your other devices. **Synchronize your data**
- Workspaces
- History - History
- Collections - Collections
- Environments - Environments
- Settings - Settings
**Post-Request Tests:** Write tests associated with a request that is executed after the request's response. **Post-Request Tests β:** Write tests associated with a request that is executed after the request's response.
- Check the status code as an integer - Check the status code as an integer
- Filter response headers - Filter response headers
@@ -206,7 +222,7 @@ Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) f
- Set environment variables - Set environment variables
- Write JavaScript code - Write JavaScript code
🌱 **Environments:** Environment variables allow you to store and reuse values in your requests and scripts. 🌱 **Environments** : Environment variables allow you to store and reuse values in your requests and scripts.
- Unlimited environments and variables - Unlimited environments and variables
- Initialize through the pre-request script - Initialize through the pre-request script
@@ -225,31 +241,22 @@ Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) f
</details> </details>
👨‍👩‍👧‍👦 **Teams β:** Helps you collaborate across your team to design, develop, and test APIs faster.
- Unlimited teams
- Unlimited shared collections
- Unlimited team members
- Role-based access control
- Cloud sync
- Multiple devices
🚚 **Bulk Edit:** Edit key-value pairs in bulk. 🚚 **Bulk Edit:** Edit key-value pairs in bulk.
- Entries are separated by newline - Entries are separated by newline
- Keys and values are separated by `:` - Keys and values are separated by `:`
- Prepend `#` to any row you want to add but keep disabled - Prepend `#` to any row you want to add but keep disabled
🎛️ **Admin dashboard:** Manage your team and invite members. **For more features, please read our [documentation](https://docs.hoppscotch.io).**
- Insights
- Manage users
- Manage teams
📦 **Add-ons:** Official add-ons for hoppscotch.
- **[Hoppscotch CLI](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-cli)** - Command-line interface for Hoppscotch.
- **[Proxy](https://github.com/hoppscotch/proxyscotch)** - A simple proxy server created for Hoppscotch.
- **[Browser Extensions](https://github.com/hoppscotch/hoppscotch-extension)** - Browser extensions that enhance your Hoppscotch experience.
[![Firefox](https://raw.github.com/alrra/browser-logos/master/src/firefox/firefox_16x16.png) **Firefox**](https://addons.mozilla.org/en-US/firefox/addon/hoppscotch) &nbsp;|&nbsp; [![Chrome](https://raw.github.com/alrra/browser-logos/master/src/chrome/chrome_16x16.png) **Chrome**](https://chrome.google.com/webstore/detail/hoppscotch-extension-for-c/amknoiejhlmhancpahfcfcfhllgkpbld)
> **Extensions fix `CORS` issues.**
_Add-ons are developed and maintained under **[Hoppscotch Organization](https://github.com/hoppscotch)**._
**For a complete list of features, please read our [documentation](https://docs.hoppscotch.io).**
## **Demo** ## **Demo**
@@ -261,9 +268,18 @@ _Add-ons are developed and maintained under **[Hoppscotch Organization](https://
2. Click "Send" to simulate the request 2. Click "Send" to simulate the request
3. View the response 3. View the response
## **Built with**
- [HTML](https://developer.mozilla.org/en-US/docs/Web/HTML)
- [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS), [SCSS](https://sass-lang.com), [Windi CSS](https://windicss.org)
- [JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript)
- [TypeScript](https://www.typescriptlang.org)
- [Vue](https://vuejs.org)
- [Vite](https://vitejs.dev)
## **Developing** ## **Developing**
Follow our [self-hosting documentation](https://docs.hoppscotch.io/documentation/self-host/getting-started) to get started with the development environment. Follow our [self-hosting guide](https://docs.hoppscotch.io/documentation/self-host/getting-started) to get started with the development environment.
## **Contributing** ## **Contributing**
@@ -281,7 +297,7 @@ See the [`CHANGELOG`](CHANGELOG.md) file for details.
## **Authors** ## **Authors**
This project owes its existence to the collective efforts of all those who contribute — [contribute now](CONTRIBUTING.md). This project exists thanks to all the people who contribute — [contribute](CONTRIBUTING.md).
<div align="center"> <div align="center">
<a href="https://github.com/hoppscotch/hoppscotch/graphs/contributors"> <a href="https://github.com/hoppscotch/hoppscotch/graphs/contributors">
@@ -293,6 +309,4 @@ This project owes its existence to the collective efforts of all those who contr
## **License** ## **License**
This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) see the [`LICENSE`](LICENSE) file for details. This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [`LICENSE`](LICENSE) file for details.
[^EE]: Enterprise edition feature. [Learn more](https://docs.hoppscotch.io/documentation/self-host/getting-started).

View File

@@ -2,9 +2,8 @@
This document outlines security procedures and general policies for the Hoppscotch project. This document outlines security procedures and general policies for the Hoppscotch project.
- [Security Policy](#security-policy) 1. [Reporting a security vulnerability](#reporting-a-security-vulnerability)
- [Reporting a security vulnerability](#reporting-a-security-vulnerability) 3. [Incident response process](#incident-response-process)
- [Incident response process](#incident-response-process)
## Reporting a security vulnerability ## Reporting a security vulnerability

View File

@@ -9,24 +9,26 @@ Before you start working on a new language, please look through the [open pull r
if there is no existing translation, you can create a new one by following these steps: if there is no existing translation, you can create a new one by following these steps:
1. **[Fork the repository](https://github.com/hoppscotch/hoppscotch/fork).** 1. **[Fork the repository](https://github.com/hoppscotch/hoppscotch/fork).**
2. **Checkout the `main` branch for latest translations.** 2. **Checkout the `i18n` branch for latest translations.**
3. **Create a new branch for your translation with base branch `main`.** 3. **Create a new branch for your translation with base branch `i18n`.**
4. **Create target language file in the [`/packages/hoppscotch-common/locales`](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-common/locales) directory.** 4. **Create target language file in the [`/packages/hoppscotch-common/locales`](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-common/locales) directory.**
5. **Copy the contents of the source file [`/packages/hoppscotch-common/locales/en.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/locales/en.json) to the target language file.** 5. **Copy the contents of the source file [`/packages/hoppscotch-common/locales/en.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/locales/en.json) to the target language file.**
6. **Translate the strings in the target language file.** 6. **Translate the strings in the target language file.**
7. **Add your language entry to [`/packages/hoppscotch-common/languages.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/languages.json).** 7. **Add your language entry to [`/packages/hoppscotch-common/languages.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/languages.json).**
8. **Save and commit changes.** 8. **Save & commit changes.**
9. **Send a pull request.** 9. **Send a pull request.**
_You may send a pull request before all steps above are complete: e.g., you may want to ask for help with translations, or getting tests to pass. However, your pull request will not be merged until all steps above are complete._ _You may send a pull request before all steps above are complete: e.g., you may want to ask for help with translations, or getting tests to pass. However, your pull request will not be merged until all steps above are complete._
`i18n` branch will be merged into `main` branch once every week.
Completing an initial translation of the whole site is a fairly large task. One way to break that task up is to work with other translators through pull requests on your fork. You can also [add collaborators to your fork](https://help.github.com/en/github/setting-up-and-managing-your-github-user-account/inviting-collaborators-to-a-personal-repository) if you'd like to invite other translators to commit directly to your fork and share responsibility for merging pull requests. Completing an initial translation of the whole site is a fairly large task. One way to break that task up is to work with other translators through pull requests on your fork. You can also [add collaborators to your fork](https://help.github.com/en/github/setting-up-and-managing-your-github-user-account/inviting-collaborators-to-a-personal-repository) if you'd like to invite other translators to commit directly to your fork and share responsibility for merging pull requests.
## Updating a translation ## Updating a translation
### Corrections ### Corrections
If you notice spelling or grammar errors, typos, or opportunities for better phrasing, open a pull request with your suggested fix. If you see a problem that you aren't sure of or don't have time to fix, [open an issue](https://github.com/hoppscotch/hoppscotch/issues/new/choose). If you notice spelling or grammar errors, typos, or opportunities for better phrasing, open a pull request with your suggested fix. If you see a problem that you aren't sure of or don't have time to fix, open an issue.
### Broken links ### Broken links

View File

@@ -1,19 +0,0 @@
:3000 {
try_files {path} /
root * /site/selfhost-web
file_server
}
:3100 {
try_files {path} /
root * /site/sh-admin-multiport-setup
file_server
}
:3170 {
reverse_proxy localhost:8080
}
:80 {
respond 404
}

View File

@@ -1,37 +0,0 @@
:3000 {
respond 404
}
:3100 {
respond 404
}
:3170 {
reverse_proxy localhost:8080
}
:80 {
# Serve the `selfhost-web` SPA by default
root * /site/selfhost-web
file_server
handle_path /admin* {
root * /site/sh-admin-subpath-access
file_server
# Ensures any non-existent file in the server is routed to the SPA
try_files {path} /
}
# Handle requests under `/backend*` path
handle_path /backend* {
reverse_proxy localhost:8080
}
# Catch-all route for unknown paths, serves `selfhost-web` SPA
handle {
root * /site/selfhost-web
file_server
try_files {path} /
}
}

11
aio.Caddyfile Normal file
View File

@@ -0,0 +1,11 @@
:3000 {
try_files {path} /
root * /site/selfhost-web
file_server
}
:3100 {
try_files {path} /
root * /site/sh-admin
file_server
}

View File

@@ -49,8 +49,7 @@ execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`)
fs.rmSync("build.env") fs.rmSync("build.env")
const caddyFileName = process.env.ENABLE_SUBPATH_BASED_ACCESS === 'true' ? 'aio-subpath-access.Caddyfile' : 'aio-multiport-setup.Caddyfile' const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"], "App/Admin Dashboard Caddy")
const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", `/etc/caddy/${caddyFileName}`, "--adapter", "caddyfile"], "App/Admin Dashboard Caddy")
const backendProcess = runChildProcessWithPrefix("pnpm", ["run", "start:prod"], "Backend Server") const backendProcess = runChildProcessWithPrefix("pnpm", ["run", "start:prod"], "Backend Server")
caddyProcess.on("exit", (code) => { caddyProcess.on("exit", (code) => {

View File

@@ -1,48 +0,0 @@
# THIS IS NOT TO BE USED FOR PERSONAL DEPLOYMENTS!
# Internal Docker Compose Image used for internal testing deployments
version: "3.7"
services:
hoppscotch-db:
image: postgres:15
user: postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: testpass
POSTGRES_DB: hoppscotch
healthcheck:
test:
[
"CMD-SHELL",
"sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'"
]
interval: 5s
timeout: 5s
retries: 10
hoppscotch-aio:
container_name: hoppscotch-aio
build:
dockerfile: prod.Dockerfile
context: .
target: aio
environment:
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch
- ENABLE_SUBPATH_BASED_ACCESS=true
env_file:
- ./.env
depends_on:
hoppscotch-db:
condition: service_healthy
command: ["sh", "-c", "pnpm exec prisma migrate deploy && node /usr/src/app/aio_run.mjs"]
healthcheck:
test:
- CMD
- curl
- '-f'
- 'http://localhost:80'
interval: 2s
timeout: 10s
retries: 30

View File

@@ -7,108 +7,6 @@ services:
# This service runs the backend app in the port 3170 # This service runs the backend app in the port 3170
hoppscotch-backend: hoppscotch-backend:
container_name: hoppscotch-backend container_name: hoppscotch-backend
build:
dockerfile: prod.Dockerfile
context: .
target: backend
env_file:
- ./.env
restart: always
environment:
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
- PORT=8080
volumes:
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
# - ./packages/hoppscotch-backend/:/usr/src/app
- /usr/src/app/node_modules/
depends_on:
hoppscotch-db:
condition: service_healthy
ports:
- "3180:80"
- "3170:3170"
# The main hoppscotch app. This will be hosted at port 3000
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
# the SH admin dashboard server at packages/hoppscotch-selfhost-web/Caddyfile
hoppscotch-app:
container_name: hoppscotch-app
build:
dockerfile: prod.Dockerfile
context: .
target: app
env_file:
- ./.env
depends_on:
- hoppscotch-backend
ports:
- "3080:80"
- "3000:3000"
# The Self Host dashboard for managing the app. This will be hosted at port 3100
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
# the SH admin dashboard server at packages/hoppscotch-sh-admin/Caddyfile
hoppscotch-sh-admin:
container_name: hoppscotch-sh-admin
build:
dockerfile: prod.Dockerfile
context: .
target: sh_admin
env_file:
- ./.env
depends_on:
- hoppscotch-backend
ports:
- "3280:80"
- "3100:3100"
# The service that spins up all 3 services at once in one container
hoppscotch-aio:
container_name: hoppscotch-aio
restart: unless-stopped
build:
dockerfile: prod.Dockerfile
context: .
target: aio
env_file:
- ./.env
depends_on:
hoppscotch-db:
condition: service_healthy
ports:
- "3000:3000"
- "3100:3100"
- "3170:3170"
- "3080:80"
# The preset DB service, you can delete/comment the below lines if
# you are using an external postgres instance
# This will be exposed at port 5432
hoppscotch-db:
image: postgres:15
ports:
- "5432:5432"
user: postgres
environment:
# The default user defined by the docker image
POSTGRES_USER: postgres
# NOTE: Please UPDATE THIS PASSWORD!
POSTGRES_PASSWORD: testpass
POSTGRES_DB: hoppscotch
healthcheck:
test:
[
"CMD-SHELL",
"sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'"
]
interval: 5s
timeout: 5s
retries: 10
# All the services listed below are deprececated
hoppscotch-old-backend:
container_name: hoppscotch-old-backend
build: build:
dockerfile: packages/hoppscotch-backend/Dockerfile dockerfile: packages/hoppscotch-backend/Dockerfile
context: . context: .
@@ -130,26 +28,54 @@ services:
ports: ports:
- "3170:3000" - "3170:3000"
hoppscotch-old-app: # The main hoppscotch app. This will be hosted at port 3000
container_name: hoppscotch-old-app # NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
# the SH admin dashboard server at packages/hoppscotch-selfhost-web/Caddyfile
hoppscotch-app:
container_name: hoppscotch-app
build: build:
dockerfile: packages/hoppscotch-selfhost-web/Dockerfile dockerfile: packages/hoppscotch-selfhost-web/Dockerfile
context: . context: .
env_file: env_file:
- ./.env - ./.env
depends_on: depends_on:
- hoppscotch-old-backend - hoppscotch-backend
ports: ports:
- "3000:8080" - "3000:8080"
hoppscotch-old-sh-admin: # The Self Host dashboard for managing the app. This will be hosted at port 3100
container_name: hoppscotch-old-sh-admin # NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
# the SH admin dashboard server at packages/hoppscotch-sh-admin/Caddyfile
hoppscotch-sh-admin:
container_name: hoppscotch-sh-admin
build: build:
dockerfile: packages/hoppscotch-sh-admin/Dockerfile dockerfile: packages/hoppscotch-sh-admin/Dockerfile
context: . context: .
env_file: env_file:
- ./.env - ./.env
depends_on: depends_on:
- hoppscotch-old-backend - hoppscotch-backend
ports: ports:
- "3100:8080" - "3100:8080"
# The preset DB service, you can delete/comment the below lines if
# you are using an external postgres instance
# This will be exposed at port 5432
hoppscotch-db:
image: postgres:15
ports:
- "5432:5432"
user: postgres
environment:
# The default user defined by the docker image
POSTGRES_USER: postgres
# NOTE: Please UPDATE THIS PASSWORD!
POSTGRES_PASSWORD: testpass
POSTGRES_DB: hoppscotch
healthcheck:
test: ["CMD-SHELL", "sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'"]
interval: 5s
timeout: 5s
retries: 10

View File

@@ -1,18 +0,0 @@
#!/bin/bash
curlCheck() {
if ! curl -s --head "$1" | head -n 1 | grep -q "HTTP/1.[01] [23].."; then
echo "URL request failed!"
exit 1
else
echo "URL request succeeded!"
fi
}
if [ "$ENABLE_SUBPATH_BASED_ACCESS" = "true" ]; then
curlCheck "http://localhost:80/backend/ping"
else
curlCheck "http://localhost:3000"
curlCheck "http://localhost:3100"
curlCheck "http://localhost:3170/ping"
fi

View File

@@ -22,26 +22,15 @@
"workspaces": [ "workspaces": [
"./packages/*" "./packages/*"
], ],
"devDependencies": { "dependencies": {
"@commitlint/cli": "16.3.0", "husky": "^7.0.4",
"@commitlint/config-conventional": "16.2.4", "lint-staged": "^12.3.8"
"@hoppscotch/ui": "0.1.0",
"@types/node": "17.0.27",
"cross-env": "7.0.3",
"http-server": "14.1.1",
"husky": "7.0.4",
"lint-staged": "12.4.0"
}, },
"pnpm": { "devDependencies": {
"overrides": { "@commitlint/cli": "^16.2.3",
"vue": "3.3.9" "@commitlint/config-conventional": "^16.2.1",
}, "@types/node": "^17.0.24",
"packageExtensions": { "cross-env": "^7.0.3",
"httpsnippet@3.0.1": { "http-server": "^14.1.1"
"dependencies": {
"ajv": "6.12.3"
}
}
}
} }
} }

View File

@@ -17,16 +17,16 @@
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"@codemirror/language": "6.10.1", "@codemirror/language": "^6.9.0",
"@lezer/highlight": "1.2.0", "@lezer/highlight": "^1.1.6",
"@lezer/lr": "1.3.14" "@lezer/lr": "^1.3.10"
}, },
"devDependencies": { "devDependencies": {
"@lezer/generator": "1.5.1", "@lezer/generator": "^1.5.0",
"mocha": "9.2.2", "mocha": "^9.2.2",
"rollup": "3.29.4", "rollup": "^2.70.2",
"rollup-plugin-dts": "6.0.2", "rollup-plugin-dts": "^4.2.1",
"rollup-plugin-ts": "3.4.5", "rollup-plugin-ts": "^2.0.7",
"typescript": "5.2.2" "typescript": "^4.6.3"
} }
} }

24
packages/dioc/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

141
packages/dioc/README.md Normal file
View File

@@ -0,0 +1,141 @@
# dioc
A small and lightweight dependency injection / inversion of control system.
### About
`dioc` is a really simple **DI/IOC** system where you write services (which are singletons per container) that can depend on each other and emit events that can be listened upon.
### Demo
```ts
import { Service, Container } from "dioc"
// Here is a simple service, which you can define by extending the Service class
// and providing an ID static field (of type string)
export class PersistenceService extends Service {
// This should be unique for each container
public static ID = "PERSISTENCE_SERVICE"
public read(key: string): string | undefined {
// ...
}
public write(key: string, value: string) {
// ...
}
}
type TodoServiceEvent =
| { type: "TODO_CREATED"; index: number }
| { type: "TODO_DELETED"; index: number }
// Services have a built in event system
// Define the generic argument to say what are the possible emitted values
export class TodoService extends Service<TodoServiceEvent> {
public static ID = "TODO_SERVICE"
// Inject persistence service into this service
private readonly persistence = this.bind(PersistenceService)
public todos = []
// Service constructors cannot have arguments
constructor() {
super()
this.todos = JSON.parse(this.persistence.read("todos") ?? "[]")
}
public addTodo(text: string) {
// ...
// You can access services via the bound fields
this.persistence.write("todos", JSON.stringify(this.todos))
// This is how you emit an event
this.emit({
type: "TODO_CREATED",
index,
})
}
public removeTodo(index: number) {
// ...
this.emit({
type: "TODO_DELETED",
index,
})
}
}
// Services need a container to run in
const container = new Container()
// You can initialize and get services using Container#bind
// It will automatically initialize the service (and its dependencies)
const todoService = container.bind(TodoService) // Returns an instance of TodoService
```
### Demo (Unit Test)
`dioc/testing` contains `TestContainer` which lets you bind mocked services to the container.
```ts
import { TestContainer } from "dioc/testing"
import { TodoService, PersistenceService } from "./demo.ts" // The above demo code snippet
import { describe, it, expect, vi } from "vitest"
describe("TodoService", () => {
it("addTodo writes to persistence", () => {
const container = new TestContainer()
const writeFn = vi.fn()
// The first parameter is the service to mock and the second parameter
// is the mocked service fields and functions
container.bindMock(PersistenceService, {
read: () => undefined, // Not really important for this test
write: writeFn,
})
// the peristence service bind in TodoService will now use the
// above defined mocked implementation
const todoService = container.bind(TodoService)
todoService.addTodo("sup")
expect(writeFn).toHaveBeenCalledOnce()
expect(writeFn).toHaveBeenCalledWith("todos", JSON.stringify(["sup"]))
})
})
```
### Demo (Vue)
`dioc/vue` contains a Vue Plugin and a `useService` composable that allows Vue components to use the defined services.
In the app entry point:
```ts
import { createApp } from "vue"
import { diocPlugin } from "dioc/vue"
const app = createApp()
app.use(diocPlugin, {
container: new Container(), // You can pass in the container you want to provide to the components here
})
```
In your Vue components:
```vue
<script setup>
import { TodoService } from "./demo.ts" // The above demo
import { useService } from "dioc/vue"
const todoService = useService(TodoService) // Returns an instance of the TodoService class
</script>
```

2
packages/dioc/index.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export { default } from "./dist/main.d.ts"
export * from "./dist/main.d.ts"

View File

@@ -0,0 +1,147 @@
import { Service } from "./service"
import { Observable, Subject } from 'rxjs'
/**
* Stores the current container instance in the current operating context.
*
* NOTE: This should not be used outside of dioc library code
*/
export let currentContainer: Container | null = null
/**
* The events emitted by the container
*
* `SERVICE_BIND` - emitted when a service is bound to the container directly or as a dependency to another service
* `SERVICE_INIT` - emitted when a service is initialized
*/
export type ContainerEvent =
| {
type: 'SERVICE_BIND';
/** The Service ID of the service being bounded (the dependency) */
boundeeID: string;
/**
* The Service ID of the bounder that is binding the boundee (the dependent)
*
* NOTE: This will be undefined if the service is bound directly to the container
*/
bounderID: string | undefined
}
| {
type: 'SERVICE_INIT';
/** The Service ID of the service being initialized */
serviceID: string
}
/**
* The dependency injection container, allows for services to be initialized and maintains the dependency trees.
*/
export class Container {
/** Used during the `bind` operation to detect circular dependencies */
private bindStack: string[] = []
/** The map of bound services to their IDs */
protected boundMap = new Map<string, Service<unknown>>()
/** The RxJS observable representing the event stream */
protected event$ = new Subject<ContainerEvent>()
/**
* Returns whether a container has the given service bound
* @param service The service to check for
*/
public hasBound<
T extends typeof Service<any> & { ID: string }
>(service: T): boolean {
return this.boundMap.has(service.ID)
}
/**
* Returns the service bound to the container with the given ID or if not found, undefined.
*
* NOTE: This is an advanced method and should not be used as much as possible.
*
* @param serviceID The ID of the service to get
*/
public getBoundServiceWithID(serviceID: string): Service<unknown> | undefined {
return this.boundMap.get(serviceID)
}
/**
* Binds a service to the container. This is equivalent to marking a service as a dependency.
* @param service The class reference of a service to bind
* @param bounder The class reference of the service that is binding the service (if bound directly to the container, this should be undefined)
*/
public bind<T extends typeof Service<any> & { ID: string }>(
service: T,
bounder: ((typeof Service<T>) & { ID: string }) | undefined = undefined
): InstanceType<T> {
// We need to store the current container in a variable so that we can restore it after the bind operation
const oldCurrentContainer = currentContainer;
currentContainer = this;
// If the service is already bound, return the existing instance
if (this.hasBound(service)) {
this.event$.next({
type: 'SERVICE_BIND',
boundeeID: service.ID,
bounderID: bounder?.ID // Return the bounder ID if it is defined, else assume its the container
})
return this.boundMap.get(service.ID) as InstanceType<T> // Casted as InstanceType<T> because service IDs and types are expected to match
}
// Detect circular dependency and throw error
if (this.bindStack.findIndex((serviceID) => serviceID === service.ID) !== -1) {
const circularServices = `${this.bindStack.join(' -> ')} -> ${service.ID}`
throw new Error(`Circular dependency detected.\nChain: ${circularServices}`)
}
// Push the service ID onto the bind stack to detect circular dependencies
this.bindStack.push(service.ID)
// Initialize the service and emit events
// NOTE: We need to cast the service to any as TypeScript thinks that the service is abstract
const instance: Service<any> = new (service as any)()
this.boundMap.set(service.ID, instance)
this.bindStack.pop()
this.event$.next({
type: 'SERVICE_INIT',
serviceID: service.ID,
})
this.event$.next({
type: 'SERVICE_BIND',
boundeeID: service.ID,
bounderID: bounder?.ID
})
// Restore the current container
currentContainer = oldCurrentContainer;
// We expect the return type to match the service definition
return instance as InstanceType<T>
}
/**
* Returns an iterator of the currently bound service IDs and their instances
*/
public getBoundServices(): IterableIterator<[string, Service<any>]> {
return this.boundMap.entries()
}
/**
* Returns the public container event stream
*/
public getEventStream(): Observable<ContainerEvent> {
return this.event$.asObservable()
}
}

View File

@@ -0,0 +1,2 @@
export * from "./container"
export * from "./service"

View File

@@ -0,0 +1,65 @@
import { Observable, Subject } from 'rxjs'
import { Container, currentContainer } from './container'
/**
* A Dioc service that can bound to a container and can bind dependency services.
*
* NOTE: Services cannot have a constructor that takes arguments.
*
* @template EventDef The type of events that can be emitted by the service. These will be accessible by event streams
*/
export abstract class Service<EventDef = {}> {
/**
* The internal event stream of the service
*/
private event$ = new Subject<EventDef>()
/** The container the service is bound to */
#container: Container
constructor() {
if (!currentContainer) {
throw new Error(
`Tried to initialize service with no container (ID: ${ (this.constructor as any).ID })`
)
}
this.#container = currentContainer
}
/**
* Binds a dependency service into this service.
* @param service The class reference of the service to bind
*/
protected bind<T extends typeof Service<any> & { ID: string }>(service: T): InstanceType<T> {
if (!currentContainer) {
throw new Error('No currentContainer defined.')
}
return currentContainer.bind(service, this.constructor as typeof Service<any> & { ID: string })
}
/**
* Returns the container the service is bound to
*/
protected getContainer(): Container {
return this.#container
}
/**
* Emits an event on the service's event stream
* @param event The event to emit
*/
protected emit(event: EventDef) {
this.event$.next(event)
}
/**
* Returns the event stream of the service
*/
public getEventStream(): Observable<EventDef> {
return this.event$.asObservable()
}
}

View File

@@ -0,0 +1,33 @@
import { Container, Service } from "./main";
/**
* A container that can be used for writing tests, contains additional methods
* for binding suitable for writing tests. (see `bindMock`).
*/
export class TestContainer extends Container {
/**
* Binds a mock service to the container.
*
* @param service
* @param mock
*/
public bindMock<
T extends typeof Service<any> & { ID: string },
U extends Partial<InstanceType<T>>
>(service: T, mock: U): U {
if (this.boundMap.has(service.ID)) {
throw new Error(`Service '${service.ID}' already bound to container. Did you already call bindMock on this ?`)
}
this.boundMap.set(service.ID, mock as any)
this.event$.next({
type: "SERVICE_BIND",
boundeeID: service.ID,
bounderID: undefined,
})
return mock
}
}

34
packages/dioc/lib/vue.ts Normal file
View File

@@ -0,0 +1,34 @@
import { Plugin, inject } from "vue"
import { Container } from "./container"
import { Service } from "./service"
const VUE_CONTAINER_KEY = Symbol()
// TODO: Some Vue version issue with plugin generics is breaking type checking
/**
* The Vue Dioc Plugin, this allows the composables to work and access the container
*
* NOTE: Make sure you add `vue` as dependency to be able to use this plugin (duh)
*/
export const diocPlugin: Plugin = {
install(app, { container }) {
app.provide(VUE_CONTAINER_KEY, container)
}
}
/**
* A composable that binds a service to a Vue Component
*
* @param service The class reference of the service to bind
*/
export function useService<
T extends typeof Service<any> & { ID: string }
>(service: T): InstanceType<T> {
const container = inject(VUE_CONTAINER_KEY) as Container | undefined | null
if (!container) {
throw new Error("Container not found, did you forget to install the dioc plugin?")
}
return container.bind(service)
}

View File

@@ -0,0 +1,54 @@
{
"name": "dioc",
"private": true,
"version": "0.1.0",
"type": "module",
"files": [
"dist",
"index.d.ts"
],
"main": "./dist/counter.umd.cjs",
"module": "./dist/counter.js",
"types": "./index.d.ts",
"exports": {
".": {
"types": "./dist/main.d.ts",
"require": "./dist/index.cjs",
"import": "./dist/index.js"
},
"./vue": {
"types": "./dist/vue.d.ts",
"require": "./dist/vue.cjs",
"import": "./dist/vue.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"require": "./dist/testing.cjs",
"import": "./dist/testing.js"
}
},
"scripts": {
"dev": "vite",
"build": "vite build && tsc --emitDeclarationOnly",
"prepare": "pnpm run build",
"test": "vitest run",
"do-test": "pnpm run test",
"test:watch": "vitest"
},
"devDependencies": {
"typescript": "^4.9.4",
"vite": "^4.0.4",
"vitest": "^0.29.3"
},
"dependencies": {
"rxjs": "^7.8.1"
},
"peerDependencies": {
"vue": "^3.2.25"
},
"peerDependenciesMeta": {
"vue": {
"optional": true
}
}
}

View File

@@ -0,0 +1,262 @@
import { it, expect, describe, vi } from "vitest"
import { Service } from "../lib/service"
import { Container, currentContainer, ContainerEvent } from "../lib/container"
class TestServiceA extends Service {
public static ID = "TestServiceA"
}
class TestServiceB extends Service {
public static ID = "TestServiceB"
// Marked public to allow for testing
public readonly serviceA = this.bind(TestServiceA)
}
describe("Container", () => {
describe("getBoundServiceWithID", () => {
it("returns the service instance if it is bound to the container", () => {
const container = new Container()
const service = container.bind(TestServiceA)
expect(container.getBoundServiceWithID(TestServiceA.ID)).toBe(service)
})
it("returns undefined if the service is not bound to the container", () => {
const container = new Container()
expect(container.getBoundServiceWithID(TestServiceA.ID)).toBeUndefined()
})
})
describe("bind", () => {
it("correctly binds the service to it", () => {
const container = new Container()
const service = container.bind(TestServiceA)
// @ts-expect-error getContainer is defined as a protected property, but we are leveraging it here to check
expect(service.getContainer()).toBe(container)
})
it("after bind, the current container is set back to its previous value", () => {
const originalValue = currentContainer
const container = new Container()
container.bind(TestServiceA)
expect(currentContainer).toBe(originalValue)
})
it("dependent services are registered in the same container", () => {
const container = new Container()
const serviceB = container.bind(TestServiceB)
// @ts-expect-error getContainer is defined as a protected property, but we are leveraging it here to check
expect(serviceB.serviceA.getContainer()).toBe(container)
})
it("binding an already initialized service returns the initialized instance (services are singletons)", () => {
const container = new Container()
const serviceA = container.bind(TestServiceA)
const serviceA2 = container.bind(TestServiceA)
expect(serviceA).toBe(serviceA2)
})
it("binding a service which is a dependency of another service returns the same instance created from the dependency resolution (services are singletons)", () => {
const container = new Container()
const serviceB = container.bind(TestServiceB)
const serviceA = container.bind(TestServiceA)
expect(serviceB.serviceA).toBe(serviceA)
})
it("binding an initialized service as a dependency returns the same instance", () => {
const container = new Container()
const serviceA = container.bind(TestServiceA)
const serviceB = container.bind(TestServiceB)
expect(serviceB.serviceA).toBe(serviceA)
})
it("container emits an init event when an uninitialized service is initialized via bind and event only called once", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_INIT" }],
void
>()
container.getEventStream().subscribe((ev) => {
if (ev.type === "SERVICE_INIT") {
serviceFunc(ev)
}
})
const instance = container.bind(TestServiceA)
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
type: "SERVICE_INIT",
serviceID: TestServiceA.ID,
})
})
it("the bind event emitted has an undefined bounderID when the service is bound directly to the container", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_BIND" }],
void
>()
container.getEventStream().subscribe((ev) => {
if (ev.type === "SERVICE_BIND") {
serviceFunc(ev)
}
})
container.bind(TestServiceA)
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceA.ID,
bounderID: undefined,
})
})
it("the bind event emitted has the correct bounderID when the service is bound to another service", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_BIND" }],
void
>()
container.getEventStream().subscribe((ev) => {
// We only care about the bind event of TestServiceA
if (ev.type === "SERVICE_BIND" && ev.boundeeID === TestServiceA.ID) {
serviceFunc(ev)
}
})
container.bind(TestServiceB)
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceA.ID,
bounderID: TestServiceB.ID,
})
})
})
describe("hasBound", () => {
it("returns true if the given service is bound to the container", () => {
const container = new Container()
container.bind(TestServiceA)
expect(container.hasBound(TestServiceA)).toEqual(true)
})
it("returns false if the given service is not bound to the container", () => {
const container = new Container()
expect(container.hasBound(TestServiceA)).toEqual(false)
})
it("returns true when the service is bound because it is a dependency of another service", () => {
const container = new Container()
container.bind(TestServiceB)
expect(container.hasBound(TestServiceA)).toEqual(true)
})
})
describe("getEventStream", () => {
it("returns an observable which emits events correctly when services are initialized", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_INIT" }],
void
>()
container.getEventStream().subscribe((ev) => {
if (ev.type === "SERVICE_INIT") {
serviceFunc(ev)
}
})
container.bind(TestServiceB)
expect(serviceFunc).toHaveBeenCalledTimes(2)
expect(serviceFunc).toHaveBeenNthCalledWith(1, <ContainerEvent>{
type: "SERVICE_INIT",
serviceID: TestServiceA.ID,
})
expect(serviceFunc).toHaveBeenNthCalledWith(2, <ContainerEvent>{
type: "SERVICE_INIT",
serviceID: TestServiceB.ID,
})
})
it("returns an observable which emits events correctly when services are bound", () => {
const container = new Container()
const serviceFunc = vi.fn<
[ContainerEvent & { type: "SERVICE_BIND" }],
void
>()
container.getEventStream().subscribe((ev) => {
if (ev.type === "SERVICE_BIND") {
serviceFunc(ev)
}
})
container.bind(TestServiceB)
expect(serviceFunc).toHaveBeenCalledTimes(2)
expect(serviceFunc).toHaveBeenNthCalledWith(1, <ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceA.ID,
bounderID: TestServiceB.ID,
})
expect(serviceFunc).toHaveBeenNthCalledWith(2, <ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceB.ID,
bounderID: undefined,
})
})
})
describe("getBoundServices", () => {
it("returns an iterator over all services bound to the container in the format [service id, service instance]", () => {
const container = new Container()
const instanceB = container.bind(TestServiceB)
const instanceA = instanceB.serviceA
expect(Array.from(container.getBoundServices())).toEqual([
[TestServiceA.ID, instanceA],
[TestServiceB.ID, instanceB],
])
})
it("returns an empty iterator if no services are bound", () => {
const container = new Container()
expect(Array.from(container.getBoundServices())).toEqual([])
})
})
})

View File

@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest"
import { Service, Container } from "../lib/main"
class TestServiceA extends Service {
public static ID = "TestServiceA"
}
class TestServiceB extends Service<"test"> {
public static ID = "TestServiceB"
// Marked public to allow for testing
public readonly serviceA = this.bind(TestServiceA)
public emitTestEvent() {
this.emit("test")
}
}
describe("Service", () => {
describe("constructor", () => {
it("throws an error if the service is initialized without a container", () => {
expect(() => new TestServiceA()).toThrowError(
"Tried to initialize service with no container (ID: TestServiceA)"
)
})
})
describe("bind", () => {
it("correctly binds the dependency service using the container", () => {
const container = new Container()
const serviceA = container.bind(TestServiceA)
const serviceB = container.bind(TestServiceB)
expect(serviceB.serviceA).toBe(serviceA)
})
})
describe("getContainer", () => {
it("returns the container the service is bound to", () => {
const container = new Container()
const serviceA = container.bind(TestServiceA)
// @ts-expect-error getContainer is a protected member, we are just using it to help with testing
expect(serviceA.getContainer()).toBe(container)
})
})
describe("getEventStream", () => {
it("returns the valid event stream of the service", () => {
const container = new Container()
const serviceB = container.bind(TestServiceB)
const serviceFunc = vi.fn()
serviceB.getEventStream().subscribe(serviceFunc)
serviceB.emitTestEvent()
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith("test")
})
})
})

View File

@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from "vitest"
import { TestContainer } from "../lib/testing"
import { Service } from "../lib/service"
import { ContainerEvent } from "../lib/container"
class TestServiceA extends Service {
public static ID = "TestServiceA"
public test() {
return "real"
}
}
class TestServiceB extends Service {
public static ID = "TestServiceB"
// declared public to help with testing
public readonly serviceA = this.bind(TestServiceA)
public test() {
return this.serviceA.test()
}
}
describe("TestContainer", () => {
describe("bindMock", () => {
it("returns the fake service defined", () => {
const container = new TestContainer()
const fakeService = {
test: () => "fake",
}
const result = container.bindMock(TestServiceA, fakeService)
expect(result).toBe(fakeService)
})
it("new services bound to the container get the mock service", () => {
const container = new TestContainer()
const fakeServiceA = {
test: () => "fake",
}
container.bindMock(TestServiceA, fakeServiceA)
const serviceB = container.bind(TestServiceB)
expect(serviceB.serviceA).toBe(fakeServiceA)
})
it("container emits SERVICE_BIND event", () => {
const container = new TestContainer()
const fakeServiceA = {
test: () => "fake",
}
const serviceFunc = vi.fn<[ContainerEvent, void]>()
container.getEventStream().subscribe((ev) => {
serviceFunc(ev)
})
container.bindMock(TestServiceA, fakeServiceA)
expect(serviceFunc).toHaveBeenCalledOnce()
expect(serviceFunc).toHaveBeenCalledWith(<ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceA.ID,
bounderID: undefined,
})
})
it("throws if service already bound", () => {
const container = new TestContainer()
const fakeServiceA = {
test: () => "fake",
}
container.bindMock(TestServiceA, fakeServiceA)
expect(() => {
container.bindMock(TestServiceA, fakeServiceA)
}).toThrowError(
"Service 'TestServiceA' already bound to container. Did you already call bindMock on this ?"
)
})
})
})

2
packages/dioc/testing.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export { default } from "./dist/testing.d.ts"
export * from "./dist/testing.d.ts"

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM"],
"moduleResolution": "Node",
"strict": true,
"declaration": true,
"sourceMap": true,
"outDir": "dist",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true
},
"include": ["lib"]
}

View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
export default defineConfig({
build: {
lib: {
entry: {
index: './lib/main.ts',
vue: './lib/vue.ts',
testing: './lib/testing.ts',
},
},
rollupOptions: {
external: ['vue'],
}
},
})

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
}
})

2
packages/dioc/vue.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export { default } from "./dist/vue.d.ts"
export * from "./dist/vue.d.ts"

View File

@@ -1,4 +1,4 @@
FROM node:20.12.2 AS builder FROM node:18.8.0 AS builder
WORKDIR /usr/src/app WORKDIR /usr/src/app

View File

@@ -1,3 +0,0 @@
:80 :3170 {
reverse_proxy localhost:8080
}

View File

@@ -3,7 +3,9 @@
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src", "sourceRoot": "src",
"compilerOptions": { "compilerOptions": {
"assets": [{ "include": "mailer/templates/**/*", "outDir": "dist" }], "assets": [
"**/*.hbs"
],
"watchAssets": true "watchAssets": true
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoppscotch-backend", "name": "hoppscotch-backend",
"version": "2024.3.3", "version": "2023.4.8",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -24,84 +24,80 @@
"do-test": "pnpm run test" "do-test": "pnpm run test"
}, },
"dependencies": { "dependencies": {
"@apollo/server": "4.9.5", "@nestjs-modules/mailer": "^1.8.1",
"@nestjs-modules/mailer": "1.9.1", "@nestjs/apollo": "^10.1.6",
"@nestjs/apollo": "12.0.9", "@nestjs/common": "^9.2.1",
"@nestjs/common": "10.2.7", "@nestjs/core": "^9.2.1",
"@nestjs/config": "3.1.1", "@nestjs/graphql": "^10.1.6",
"@nestjs/core": "10.2.7", "@nestjs/jwt": "^10.0.1",
"@nestjs/graphql": "12.0.9", "@nestjs/passport": "^9.0.0",
"@nestjs/jwt": "10.1.1", "@nestjs/platform-express": "^9.2.1",
"@nestjs/passport": "10.0.2", "@nestjs/throttler": "^4.0.0",
"@nestjs/platform-express": "10.2.7", "@prisma/client": "^4.16.2",
"@nestjs/schedule": "4.0.1", "apollo-server-express": "^3.11.1",
"@nestjs/terminus": "10.2.3", "apollo-server-plugin-base": "^3.7.1",
"@nestjs/throttler": "5.0.1", "argon2": "^0.30.3",
"@prisma/client": "5.8.1", "bcrypt": "^5.1.0",
"argon2": "0.30.3", "cookie": "^0.5.0",
"bcrypt": "5.1.0", "cookie-parser": "^1.4.6",
"cookie": "0.5.0", "express": "^4.17.1",
"cookie-parser": "1.4.6", "express-session": "^1.17.3",
"cron": "3.1.6", "fp-ts": "^2.13.1",
"express": "4.18.2", "graphql": "^15.5.0",
"express-session": "1.17.3", "graphql-query-complexity": "^0.12.0",
"fp-ts": "2.13.1", "graphql-redis-subscriptions": "^2.5.0",
"graphql": "16.8.1", "graphql-subscriptions": "^2.0.0",
"graphql-query-complexity": "0.12.0", "handlebars": "^4.7.7",
"graphql-redis-subscriptions": "2.6.0", "io-ts": "^2.2.16",
"graphql-subscriptions": "2.0.0", "luxon": "^3.2.1",
"handlebars": "4.7.7", "nodemailer": "^6.9.1",
"io-ts": "2.2.16", "passport": "^0.6.0",
"luxon": "3.2.1", "passport-github2": "^0.1.12",
"nodemailer": "6.9.1", "passport-google-oauth20": "^2.0.0",
"passport": "0.6.0", "passport-jwt": "^4.0.1",
"passport-github2": "0.1.12", "passport-local": "^1.0.0",
"passport-google-oauth20": "2.0.0", "passport-microsoft": "^1.0.0",
"passport-jwt": "4.0.1", "prisma": "^4.16.2",
"passport-local": "1.0.0", "reflect-metadata": "^0.1.13",
"passport-microsoft": "1.0.0", "rimraf": "^3.0.2",
"posthog-node": "3.6.3", "rxjs": "^7.6.0"
"prisma": "5.8.1",
"reflect-metadata": "0.1.13",
"rimraf": "3.0.2",
"rxjs": "7.6.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "10.2.1", "@nestjs/cli": "^9.1.5",
"@nestjs/schematics": "10.0.3", "@nestjs/schematics": "^9.0.3",
"@nestjs/testing": "10.2.7", "@nestjs/testing": "^9.2.1",
"@relmify/jest-fp-ts": "2.0.2", "@relmify/jest-fp-ts": "^2.0.2",
"@types/argon2": "0.15.0", "@types/argon2": "^0.15.0",
"@types/bcrypt": "5.0.0", "@types/bcrypt": "^5.0.0",
"@types/cookie": "0.5.1", "@types/cookie": "^0.5.1",
"@types/cookie-parser": "1.4.3", "@types/cookie-parser": "^1.4.3",
"@types/express": "4.17.14", "@types/express": "^4.17.14",
"@types/jest": "29.4.0", "@types/jest": "^29.4.0",
"@types/luxon": "3.2.0", "@types/luxon": "^3.2.0",
"@types/node": "18.11.10", "@types/node": "^18.11.10",
"@types/nodemailer": "6.4.7", "@types/nodemailer": "^6.4.7",
"@types/passport-github2": "1.2.5", "@types/passport-github2": "^1.2.5",
"@types/passport-google-oauth20": "2.0.11", "@types/passport-google-oauth20": "^2.0.11",
"@types/passport-jwt": "3.0.8", "@types/passport-jwt": "^3.0.8",
"@types/passport-microsoft": "0.0.0", "@types/passport-microsoft": "^0.0.0",
"@types/supertest": "2.0.12", "@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "5.45.0", "@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "5.45.0", "@typescript-eslint/parser": "^5.45.0",
"cross-env": "7.0.3", "cross-env": "^7.0.3",
"eslint": "8.29.0", "eslint": "^8.29.0",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "4.2.1", "eslint-plugin-prettier": "^4.2.1",
"jest": "29.4.1", "jest": "^29.4.1",
"jest-mock-extended": "3.0.1", "jest-mock-extended": "^3.0.1",
"jwt": "link:@types/nestjs/jwt", "jwt": "link:@types/nestjs/jwt",
"prettier": "2.8.4", "prettier": "^2.8.4",
"source-map-support": "0.5.21", "source-map-support": "^0.5.21",
"supertest": "6.3.2", "supertest": "^6.3.2",
"ts-jest": "29.0.5", "ts-jest": "29.0.5",
"ts-loader": "9.4.2", "ts-loader": "^9.4.2",
"ts-node": "10.9.1", "ts-node": "^10.9.1",
"tsconfig-paths": "4.1.1", "tsconfig-paths": "4.1.1",
"typescript": "4.9.3" "typescript": "^4.9.3"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [

View File

@@ -1,15 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[id]` on the table `Shortcode` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Shortcode" ADD COLUMN "embedProperties" JSONB,
ADD COLUMN "updatedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- CreateIndex
CREATE UNIQUE INDEX "Shortcode_id_key" ON "Shortcode"("id");
-- AddForeignKey
ALTER TABLE "Shortcode" ADD CONSTRAINT "Shortcode_creatorUid_fkey" FOREIGN KEY ("creatorUid") REFERENCES "User"("uid") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -1,14 +0,0 @@
-- CreateTable
CREATE TABLE "InfraConfig" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"value" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedOn" TIMESTAMP(3) NOT NULL,
CONSTRAINT "InfraConfig_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "InfraConfig_name_key" ON "InfraConfig"("name");

View File

@@ -1,5 +0,0 @@
-- AlterTable
ALTER TABLE "TeamCollection" ADD COLUMN "data" JSONB;
-- AlterTable
ALTER TABLE "UserCollection" ADD COLUMN "data" JSONB;

View File

@@ -1,22 +0,0 @@
-- This is a custom migration file which is not generated by Prisma.
-- The aim of this migration is to add text search indices to the TeamCollection and TeamRequest tables.
-- Create Extension
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Create GIN Trigram Index for Team Collection title
CREATE INDEX
"TeamCollection_title_trgm_idx"
ON
"TeamCollection"
USING
GIN (title gin_trgm_ops);
-- Create GIN Trigram Index for Team Collection title
CREATE INDEX
"TeamRequest_title_trgm_idx"
ON
"TeamRequest"
USING
GIN (title gin_trgm_ops);

View File

@@ -41,41 +41,37 @@ model TeamInvitation {
} }
model TeamCollection { model TeamCollection {
id String @id @default(cuid()) id String @id @default(cuid())
parentID String? parentID String?
data Json? parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id]) children TeamCollection[] @relation("TeamCollectionChildParent")
children TeamCollection[] @relation("TeamCollectionChildParent")
requests TeamRequest[] requests TeamRequest[]
teamID String teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
title String title String
orderIndex Int orderIndex Int
createdOn DateTime @default(now()) @db.Timestamp(3) createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3) updatedOn DateTime @updatedAt @db.Timestamp(3)
} }
model TeamRequest { model TeamRequest {
id String @id @default(cuid()) id String @id @default(cuid())
collectionID String collectionID String
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade) collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
teamID String teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
title String title String
request Json request Json
orderIndex Int orderIndex Int
createdOn DateTime @default(now()) @db.Timestamp(3) createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3) updatedOn DateTime @updatedAt @db.Timestamp(3)
} }
model Shortcode { model Shortcode {
id String @id @unique id String @id
request Json request Json
embedProperties Json? creatorUid String?
creatorUid String? createdOn DateTime @default(now())
User User? @relation(fields: [creatorUid], references: [uid])
createdOn DateTime @default(now())
updatedOn DateTime @default(now()) @updatedAt
@@unique(fields: [id, creatorUid], name: "creator_uid_shortcode_unique") @@unique(fields: [id, creatorUid], name: "creator_uid_shortcode_unique")
} }
@@ -106,7 +102,6 @@ model User {
currentGQLSession Json? currentGQLSession Json?
createdOn DateTime @default(now()) @db.Timestamp(3) createdOn DateTime @default(now()) @db.Timestamp(3)
invitedUsers InvitedUsers[] invitedUsers InvitedUsers[]
shortcodes Shortcode[]
} }
model Account { model Account {
@@ -197,7 +192,6 @@ model UserCollection {
userUid String userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
title String title String
data Json?
orderIndex Int orderIndex Int
type ReqType type ReqType
createdOn DateTime @default(now()) @db.Timestamp(3) createdOn DateTime @default(now()) @db.Timestamp(3)
@@ -209,12 +203,3 @@ enum TeamMemberRole {
VIEWER VIEWER
EDITOR EDITOR
} }
model InfraConfig {
id String @id @default(cuid())
name String @unique
value String?
active Boolean @default(true) // Use case: Let's say, Admin wants to disable Google SSO, but doesn't want to delete the config
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}

View File

@@ -1,66 +0,0 @@
#!/usr/local/bin/node
// @ts-check
import { spawn } from 'child_process';
import process from 'process';
function runChildProcessWithPrefix(command, args, prefix) {
const childProcess = spawn(command, args);
childProcess.stdout.on('data', (data) => {
const output = data.toString().trim().split('\n');
output.forEach((line) => {
console.log(`${prefix} | ${line}`);
});
});
childProcess.stderr.on('data', (data) => {
const error = data.toString().trim().split('\n');
error.forEach((line) => {
console.error(`${prefix} | ${line}`);
});
});
childProcess.on('close', (code) => {
console.log(`${prefix} Child process exited with code ${code}`);
});
childProcess.on('error', (stuff) => {
console.error('error');
console.error(stuff);
});
return childProcess;
}
const caddyProcess = runChildProcessWithPrefix(
'caddy',
['run', '--config', '/etc/caddy/backend.Caddyfile', '--adapter', 'caddyfile'],
'App/Admin Dashboard Caddy',
);
const backendProcess = runChildProcessWithPrefix(
'pnpm',
['run', 'start:prod'],
'Backend Server',
);
caddyProcess.on('exit', (code) => {
console.log(`Exiting process because Caddy Server exited with code ${code}`);
process.exit(code);
});
backendProcess.on('exit', (code) => {
console.log(
`Exiting process because Backend Server exited with code ${code}`,
);
process.exit(code);
});
process.on('SIGINT', () => {
console.log('SIGINT received, exiting...');
caddyProcess.kill('SIGINT');
backendProcess.kill('SIGINT');
process.exit(0);
});

View File

@@ -1,9 +1,4 @@
import { ObjectType, OmitType } from '@nestjs/graphql'; import { ObjectType } from '@nestjs/graphql';
import { User } from 'src/user/user.model';
@ObjectType() @ObjectType()
export class Admin extends OmitType(User, [ export class Admin {}
'isAdmin',
'currentRESTSession',
'currentGQLSession',
]) {}

View File

@@ -4,29 +4,26 @@ import { AdminService } from './admin.service';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { PubSubModule } from '../pubsub/pubsub.module'; import { PubSubModule } from '../pubsub/pubsub.module';
import { UserModule } from '../user/user.module'; import { UserModule } from '../user/user.module';
import { MailerModule } from '../mailer/mailer.module';
import { TeamModule } from '../team/team.module'; import { TeamModule } from '../team/team.module';
import { TeamInvitationModule } from '../team-invitation/team-invitation.module'; import { TeamInvitationModule } from '../team-invitation/team-invitation.module';
import { TeamEnvironmentsModule } from '../team-environments/team-environments.module'; import { TeamEnvironmentsModule } from '../team-environments/team-environments.module';
import { TeamCollectionModule } from '../team-collection/team-collection.module'; import { TeamCollectionModule } from '../team-collection/team-collection.module';
import { TeamRequestModule } from '../team-request/team-request.module'; import { TeamRequestModule } from '../team-request/team-request.module';
import { InfraResolver } from './infra.resolver';
import { ShortcodeModule } from 'src/shortcode/shortcode.module';
import { InfraConfigModule } from 'src/infra-config/infra-config.module';
@Module({ @Module({
imports: [ imports: [
PrismaModule, PrismaModule,
PubSubModule, PubSubModule,
UserModule, UserModule,
MailerModule,
TeamModule, TeamModule,
TeamInvitationModule, TeamInvitationModule,
TeamEnvironmentsModule, TeamEnvironmentsModule,
TeamCollectionModule, TeamCollectionModule,
TeamRequestModule, TeamRequestModule,
ShortcodeModule,
InfraConfigModule,
], ],
providers: [InfraResolver, AdminResolver, AdminService], providers: [AdminResolver, AdminService],
exports: [AdminService], exports: [AdminService],
}) })
export class AdminModule {} export class AdminModule {}

View File

@@ -21,13 +21,15 @@ import { InvitedUser } from './invited-user.model';
import { GqlUser } from '../decorators/gql-user.decorator'; import { GqlUser } from '../decorators/gql-user.decorator';
import { PubSubService } from '../pubsub/pubsub.service'; import { PubSubService } from '../pubsub/pubsub.service';
import { Team, TeamMember } from '../team/team.model'; import { Team, TeamMember } from '../team/team.model';
import { User } from '../user/user.model';
import { TeamInvitation } from '../team-invitation/team-invitation.model';
import { PaginationArgs } from '../types/input-types.args';
import { import {
AddUserToTeamArgs, AddUserToTeamArgs,
ChangeUserRoleInTeamArgs, ChangeUserRoleInTeamArgs,
} from './input-types.args'; } from './input-types.args';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { SkipThrottle } from '@nestjs/throttler'; import { SkipThrottle } from '@nestjs/throttler';
import { UserDeletionResult } from 'src/user/user.model';
@UseGuards(GqlThrottlerGuard) @UseGuards(GqlThrottlerGuard)
@Resolver(() => Admin) @Resolver(() => Admin)
@@ -47,6 +49,188 @@ export class AdminResolver {
return admin; return admin;
} }
@ResolveField(() => [User], {
description: 'Returns a list of all admin users in infra',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async admins() {
const admins = await this.adminService.fetchAdmins();
return admins;
}
@ResolveField(() => User, {
description: 'Returns a user info by UID',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async userInfo(
@Args({
name: 'userUid',
type: () => ID,
description: 'The user UID',
})
userUid: string,
): Promise<AuthUser> {
const user = await this.adminService.fetchUserInfo(userUid);
if (E.isLeft(user)) throwErr(user.left);
return user.right;
}
@ResolveField(() => [User], {
description: 'Returns a list of all the users in infra',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async allUsers(
@Parent() admin: Admin,
@Args() args: PaginationArgs,
): Promise<AuthUser[]> {
const users = await this.adminService.fetchUsers(args.cursor, args.take);
return users;
}
@ResolveField(() => [InvitedUser], {
description: 'Returns a list of all the invited users',
})
async invitedUsers(@Parent() admin: Admin): Promise<InvitedUser[]> {
const users = await this.adminService.fetchInvitedUsers();
return users;
}
@ResolveField(() => [Team], {
description: 'Returns a list of all the teams in the infra',
})
async allTeams(
@Parent() admin: Admin,
@Args() args: PaginationArgs,
): Promise<Team[]> {
const teams = await this.adminService.fetchAllTeams(args.cursor, args.take);
return teams;
}
@ResolveField(() => Team, {
description: 'Returns a team info by ID when requested by Admin',
})
async teamInfo(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which info to fetch',
})
teamID: string,
): Promise<Team> {
const team = await this.adminService.getTeamInfo(teamID);
if (E.isLeft(team)) throwErr(team.left);
return team.right;
}
@ResolveField(() => Number, {
description: 'Return count of all the members in a team',
})
async membersCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
nullable: false,
})
teamID: string,
): Promise<number> {
const teamMembersCount = await this.adminService.membersCountInTeam(teamID);
return teamMembersCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored collections in a team',
})
async collectionCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const teamCollCount = await this.adminService.collectionCountInTeam(teamID);
return teamCollCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored requests in a team',
})
async requestCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const teamReqCount = await this.adminService.requestCountInTeam(teamID);
return teamReqCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored environments in a team',
})
async environmentCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const envsCount = await this.adminService.environmentCountInTeam(teamID);
return envsCount;
}
@ResolveField(() => [TeamInvitation], {
description: 'Return all the pending invitations in a team',
})
async pendingInvitationCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
) {
const invitations = await this.adminService.pendingInvitationCountInTeam(
teamID,
);
return invitations;
}
@ResolveField(() => Number, {
description: 'Return total number of Users in organization',
})
async usersCount() {
return this.adminService.getUsersCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Teams in organization',
})
async teamsCount() {
return this.adminService.getTeamsCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Team Collections in organization',
})
async teamCollectionsCount() {
return this.adminService.getTeamCollectionsCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Team Requests in organization',
})
async teamRequestsCount() {
return this.adminService.getTeamRequestsCount();
}
/* Mutations */ /* Mutations */
@Mutation(() => InvitedUser, { @Mutation(() => InvitedUser, {
@@ -70,26 +254,8 @@ export class AdminResolver {
return invitedUser.right; return invitedUser.right;
} }
@Mutation(() => Boolean, {
description: 'Revoke a user invites by invitee emails',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async revokeUserInvitationsByAdmin(
@Args({
name: 'inviteeEmails',
description: 'Invitee Emails',
type: () => [String],
})
inviteeEmails: string[],
): Promise<boolean> {
const invite = await this.adminService.revokeUserInvitations(inviteeEmails);
if (E.isLeft(invite)) throwErr(invite.left);
return invite.right;
}
@Mutation(() => Boolean, { @Mutation(() => Boolean, {
description: 'Delete an user account from infra', description: 'Delete an user account from infra',
deprecationReason: 'Use removeUsersByAdmin instead',
}) })
@UseGuards(GqlAuthGuard, GqlAdminGuard) @UseGuards(GqlAuthGuard, GqlAdminGuard)
async removeUserByAdmin( async removeUserByAdmin(
@@ -100,33 +266,12 @@ export class AdminResolver {
}) })
userUID: string, userUID: string,
): Promise<boolean> { ): Promise<boolean> {
const removedUser = await this.adminService.removeUserAccount(userUID); const invitedUser = await this.adminService.removeUserAccount(userUID);
if (E.isLeft(removedUser)) throwErr(removedUser.left); if (E.isLeft(invitedUser)) throwErr(invitedUser.left);
return removedUser.right; return invitedUser.right;
} }
@Mutation(() => [UserDeletionResult], {
description: 'Delete user accounts from infra',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async removeUsersByAdmin(
@Args({
name: 'userUIDs',
description: 'users UID',
type: () => [ID],
})
userUIDs: string[],
): Promise<UserDeletionResult[]> {
const deletionResults = await this.adminService.removeUserAccounts(
userUIDs,
);
if (E.isLeft(deletionResults)) throwErr(deletionResults.left);
return deletionResults.right;
}
@Mutation(() => Boolean, { @Mutation(() => Boolean, {
description: 'Make user an admin', description: 'Make user an admin',
deprecationReason: 'Use makeUsersAdmin instead',
}) })
@UseGuards(GqlAuthGuard, GqlAdminGuard) @UseGuards(GqlAuthGuard, GqlAdminGuard)
async makeUserAdmin( async makeUserAdmin(
@@ -142,51 +287,8 @@ export class AdminResolver {
return admin.right; return admin.right;
} }
@Mutation(() => Boolean, {
description: 'Make users an admin',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async makeUsersAdmin(
@Args({
name: 'userUIDs',
description: 'users UID',
type: () => [ID],
})
userUIDs: string[],
): Promise<boolean> {
const isUpdated = await this.adminService.makeUsersAdmin(userUIDs);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return isUpdated.right;
}
@Mutation(() => Boolean, {
description: 'Update user display name',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async updateUserDisplayNameByAdmin(
@Args({
name: 'userUID',
description: 'users UID',
type: () => ID,
})
userUID: string,
@Args({
name: 'displayName',
description: 'users display name',
})
displayName: string,
): Promise<boolean> {
const isUpdated = await this.adminService.updateUserDisplayName(
userUID,
displayName,
);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return isUpdated.right;
}
@Mutation(() => Boolean, { @Mutation(() => Boolean, {
description: 'Remove user as admin', description: 'Remove user as admin',
deprecationReason: 'Use demoteUsersByAdmin instead',
}) })
@UseGuards(GqlAuthGuard, GqlAdminGuard) @UseGuards(GqlAuthGuard, GqlAdminGuard)
async removeUserAsAdmin( async removeUserAsAdmin(
@@ -202,23 +304,6 @@ export class AdminResolver {
return admin.right; return admin.right;
} }
@Mutation(() => Boolean, {
description: 'Remove users as admin',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async demoteUsersByAdmin(
@Args({
name: 'userUIDs',
description: 'users UID',
type: () => [ID],
})
userUIDs: string[],
): Promise<boolean> {
const isUpdated = await this.adminService.demoteUsersByAdmin(userUIDs);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return isUpdated.right;
}
@Mutation(() => Team, { @Mutation(() => Team, {
description: description:
'Create a new team by providing the user uid to nominate as Team owner', 'Create a new team by providing the user uid to nominate as Team owner',
@@ -343,23 +428,6 @@ export class AdminResolver {
return true; return true;
} }
@Mutation(() => Boolean, {
description: 'Revoke Shortcode by ID',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async revokeShortcodeByAdmin(
@Args({
name: 'code',
description: 'The shortcode to delete',
type: () => ID,
})
code: string,
): Promise<boolean> {
const res = await this.adminService.deleteShortcode(code);
if (E.isLeft(res)) throwErr(res.left);
return true;
}
/* Subscriptions */ /* Subscriptions */
@Subscription(() => InvitedUser, { @Subscription(() => InvitedUser, {

View File

@@ -1,7 +1,7 @@
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { PubSubService } from '../pubsub/pubsub.service'; import { PubSubService } from '../pubsub/pubsub.service';
import { mockDeep } from 'jest-mock-extended'; import { mockDeep } from 'jest-mock-extended';
import { InvitedUsers, User as DbUser } from '@prisma/client'; import { InvitedUsers } from '@prisma/client';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service';
import { TeamService } from '../team/team.service'; import { TeamService } from '../team/team.service';
import { TeamEnvironmentsService } from '../team-environments/team-environments.service'; import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
@@ -13,15 +13,8 @@ import { PrismaService } from 'src/prisma/prisma.service';
import { import {
DUPLICATE_EMAIL, DUPLICATE_EMAIL,
INVALID_EMAIL, INVALID_EMAIL,
ONLY_ONE_ADMIN_ACCOUNT,
USER_ALREADY_INVITED, USER_ALREADY_INVITED,
USER_INVITATION_DELETION_FAILED,
USER_NOT_FOUND,
} from '../errors'; } from '../errors';
import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import * as E from 'fp-ts/Either';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>(); const mockPubSub = mockDeep<PubSubService>();
@@ -32,8 +25,6 @@ const mockTeamRequestService = mockDeep<TeamRequestService>();
const mockTeamInvitationService = mockDeep<TeamInvitationService>(); const mockTeamInvitationService = mockDeep<TeamInvitationService>();
const mockTeamCollectionService = mockDeep<TeamCollectionService>(); const mockTeamCollectionService = mockDeep<TeamCollectionService>();
const mockMailerService = mockDeep<MailerService>(); const mockMailerService = mockDeep<MailerService>();
const mockShortcodeService = mockDeep<ShortcodeService>();
const mockConfigService = mockDeep<ConfigService>();
const adminService = new AdminService( const adminService = new AdminService(
mockUserService, mockUserService,
@@ -45,8 +36,6 @@ const adminService = new AdminService(
mockPubSub as any, mockPubSub as any,
mockPrisma as any, mockPrisma as any,
mockMailerService, mockMailerService,
mockShortcodeService,
mockConfigService,
); );
const invitedUsers: InvitedUsers[] = [ const invitedUsers: InvitedUsers[] = [
@@ -63,87 +52,20 @@ const invitedUsers: InvitedUsers[] = [
invitedOn: new Date(), invitedOn: new Date(),
}, },
]; ];
const dbAdminUsers: DbUser[] = [
{
uid: 'uid 1',
displayName: 'displayName',
email: 'email@email.com',
photoURL: 'photoURL',
isAdmin: true,
refreshToken: 'refreshToken',
currentRESTSession: '',
currentGQLSession: '',
createdOn: new Date(),
},
{
uid: 'uid 2',
displayName: 'displayName',
email: 'email@email.com',
photoURL: 'photoURL',
isAdmin: true,
refreshToken: 'refreshToken',
currentRESTSession: '',
currentGQLSession: '',
createdOn: new Date(),
},
];
const dbNonAminUser: DbUser = {
uid: 'uid 3',
displayName: 'displayName',
email: 'email@email.com',
photoURL: 'photoURL',
isAdmin: false,
refreshToken: 'refreshToken',
currentRESTSession: '',
currentGQLSession: '',
createdOn: new Date(),
};
describe('AdminService', () => { describe('AdminService', () => {
describe('fetchInvitedUsers', () => { describe('fetchInvitedUsers', () => {
test('should resolve right and apply pagination correctly', async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]);
// @ts-ignore
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
const paginationArgs: OffsetPaginationArgs = { take: 5, skip: 2 };
const results = await adminService.fetchInvitedUsers(paginationArgs);
expect(mockPrisma.invitedUsers.findMany).toHaveBeenCalledWith({
...paginationArgs,
orderBy: {
invitedOn: 'desc',
},
where: {
NOT: {
inviteeEmail: {
in: [dbAdminUsers[0].email],
},
},
},
});
});
test('should resolve right and return an array of invited users', async () => { test('should resolve right and return an array of invited users', async () => {
const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 };
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]);
// @ts-ignore
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers); mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
const results = await adminService.fetchInvitedUsers(paginationArgs); const results = await adminService.fetchInvitedUsers();
expect(results).toEqual(invitedUsers); expect(results).toEqual(invitedUsers);
}); });
test('should resolve left and return an empty array if invited users not found', async () => { test('should resolve left and return an empty array if invited users not found', async () => {
const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 };
mockPrisma.invitedUsers.findMany.mockResolvedValue([]); mockPrisma.invitedUsers.findMany.mockResolvedValue([]);
const results = await adminService.fetchInvitedUsers(paginationArgs); const results = await adminService.fetchInvitedUsers();
expect(results).toEqual([]); expect(results).toEqual([]);
}); });
}); });
@@ -206,58 +128,6 @@ describe('AdminService', () => {
}); });
}); });
describe('revokeUserInvitations', () => {
test('should resolve left and return error if email not invited', async () => {
mockPrisma.invitedUsers.deleteMany.mockRejectedValueOnce(
'RecordNotFound',
);
const result = await adminService.revokeUserInvitations([
'test@gmail.com',
]);
expect(result).toEqualLeft(USER_INVITATION_DELETION_FAILED);
});
test('should resolve right and return deleted invitee email', async () => {
const adminUid = 'adminUid';
mockPrisma.invitedUsers.deleteMany.mockResolvedValueOnce({ count: 1 });
const result = await adminService.revokeUserInvitations([
invitedUsers[0].inviteeEmail,
]);
expect(mockPrisma.invitedUsers.deleteMany).toHaveBeenCalledWith({
where: {
inviteeEmail: { in: [invitedUsers[0].inviteeEmail] },
},
});
expect(result).toEqualRight(true);
});
});
describe('removeUsersAsAdmin', () => {
test('should resolve right and make admins to users', async () => {
mockUserService.fetchAdminUsers.mockResolvedValueOnce(dbAdminUsers);
mockUserService.removeUsersAsAdmin.mockResolvedValueOnce(E.right(true));
return expect(
await adminService.demoteUsersByAdmin([dbAdminUsers[0].uid]),
).toEqualRight(true);
});
test('should resolve left and return error if only one admin in the infra', async () => {
mockUserService.fetchAdminUsers.mockResolvedValueOnce(dbAdminUsers);
mockUserService.removeUsersAsAdmin.mockResolvedValueOnce(E.right(true));
return expect(
await adminService.demoteUsersByAdmin(
dbAdminUsers.map((user) => user.uid),
),
).toEqualLeft(ONLY_ONE_ADMIN_ACCOUNT);
});
});
describe('getUsersCount', () => { describe('getUsersCount', () => {
test('should return count of all users in the organization', async () => { test('should return count of all users in the organization', async () => {
mockUserService.getUsersCount.mockResolvedValueOnce(10); mockUserService.getUsersCount.mockResolvedValueOnce(10);

View File

@@ -6,16 +6,13 @@ import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import { validateEmail } from '../utils'; import { validateEmail } from '../utils';
import { import {
ADMIN_CAN_NOT_BE_DELETED,
DUPLICATE_EMAIL, DUPLICATE_EMAIL,
EMAIL_FAILED, EMAIL_FAILED,
INVALID_EMAIL, INVALID_EMAIL,
ONLY_ONE_ADMIN_ACCOUNT, ONLY_ONE_ADMIN_ACCOUNT,
TEAM_INVITE_ALREADY_MEMBER, TEAM_INVITE_ALREADY_MEMBER,
TEAM_INVITE_NO_INVITE_FOUND, TEAM_INVITE_NO_INVITE_FOUND,
USERS_NOT_FOUND,
USER_ALREADY_INVITED, USER_ALREADY_INVITED,
USER_INVITATION_DELETION_FAILED,
USER_IS_ADMIN, USER_IS_ADMIN,
USER_NOT_FOUND, USER_NOT_FOUND,
} from '../errors'; } from '../errors';
@@ -27,10 +24,6 @@ import { TeamRequestService } from '../team-request/team-request.service';
import { TeamEnvironmentsService } from '../team-environments/team-environments.service'; import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
import { TeamInvitationService } from '../team-invitation/team-invitation.service'; import { TeamInvitationService } from '../team-invitation/team-invitation.service';
import { TeamMemberRole } from '../team/team.model'; import { TeamMemberRole } from '../team/team.model';
import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import { UserDeletionResult } from 'src/user/user.model';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
@@ -44,8 +37,6 @@ export class AdminService {
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly mailerService: MailerService, private readonly mailerService: MailerService,
private readonly shortcodeService: ShortcodeService,
private readonly configService: ConfigService,
) {} ) {}
/** /**
@@ -53,30 +44,12 @@ export class AdminService {
* @param cursorID Users uid * @param cursorID Users uid
* @param take number of users to fetch * @param take number of users to fetch
* @returns an Either of array of user or error * @returns an Either of array of user or error
* @deprecated use fetchUsersV2 instead
*/ */
async fetchUsers(cursorID: string, take: number) { async fetchUsers(cursorID: string, take: number) {
const allUsers = await this.userService.fetchAllUsers(cursorID, take); const allUsers = await this.userService.fetchAllUsers(cursorID, take);
return allUsers; return allUsers;
} }
/**
* Fetch all the users in the infra.
* @param searchString search on users displayName or email
* @param paginationOption pagination options
* @returns an Either of array of user or error
*/
async fetchUsersV2(
searchString: string,
paginationOption: OffsetPaginationArgs,
) {
const allUsers = await this.userService.fetchAllUsersV2(
searchString,
paginationOption,
);
return allUsers;
}
/** /**
* Invite a user to join the infra. * Invite a user to join the infra.
* @param adminUID Admin's UID * @param adminUID Admin's UID
@@ -101,10 +74,10 @@ export class AdminService {
try { try {
await this.mailerService.sendUserInvitationEmail(inviteeEmail, { await this.mailerService.sendUserInvitationEmail(inviteeEmail, {
template: 'user-invitation', template: 'code-your-own',
variables: { variables: {
inviteeEmail: inviteeEmail, inviteeEmail: inviteeEmail,
magicLink: `${this.configService.get('VITE_BASE_URL')}`, magicLink: `${process.env.VITE_BASE_URL}`,
}, },
}); });
} catch (e) { } catch (e) {
@@ -133,68 +106,14 @@ export class AdminService {
return E.right(invitedUser); return E.right(invitedUser);
} }
/**
* Update the display name of a user
* @param userUid Who's display name is being updated
* @param displayName New display name of the user
* @returns an Either of boolean or error
*/
async updateUserDisplayName(userUid: string, displayName: string) {
const updatedUser = await this.userService.updateUserDisplayName(
userUid,
displayName,
);
if (E.isLeft(updatedUser)) return E.left(updatedUser.left);
return E.right(true);
}
/**
* Revoke infra level user invitations
* @param inviteeEmails Invitee's emails
* @param adminUid Admin Uid
* @returns an Either of boolean or error string
*/
async revokeUserInvitations(inviteeEmails: string[]) {
try {
await this.prisma.invitedUsers.deleteMany({
where: {
inviteeEmail: { in: inviteeEmails },
},
});
return E.right(true);
} catch (error) {
return E.left(USER_INVITATION_DELETION_FAILED);
}
}
/** /**
* Fetch the list of invited users by the admin. * Fetch the list of invited users by the admin.
* @returns an Either of array of `InvitedUser` object or error * @returns an Either of array of `InvitedUser` object or error
*/ */
async fetchInvitedUsers(paginationOption: OffsetPaginationArgs) { async fetchInvitedUsers() {
const userEmailObjs = await this.prisma.user.findMany({ const invitedUsers = await this.prisma.invitedUsers.findMany();
select: {
email: true,
},
});
const pendingInvitedUsers = await this.prisma.invitedUsers.findMany({ const users: InvitedUser[] = invitedUsers.map(
take: paginationOption.take,
skip: paginationOption.skip,
orderBy: {
invitedOn: 'desc',
},
where: {
NOT: {
inviteeEmail: {
in: userEmailObjs.map((user) => user.email),
},
},
},
});
const users: InvitedUser[] = pendingInvitedUsers.map(
(user) => <InvitedUser>{ ...user }, (user) => <InvitedUser>{ ...user },
); );
@@ -414,7 +333,6 @@ export class AdminService {
* Remove a user account by UID * Remove a user account by UID
* @param userUid User UID * @param userUid User UID
* @returns an Either of boolean or error * @returns an Either of boolean or error
* @deprecated use removeUserAccounts instead
*/ */
async removeUserAccount(userUid: string) { async removeUserAccount(userUid: string) {
const user = await this.userService.findUserById(userUid); const user = await this.userService.findUserById(userUid);
@@ -427,73 +345,10 @@ export class AdminService {
return E.right(delUser.right); return E.right(delUser.right);
} }
/**
* Remove user (not Admin) accounts by UIDs
* @param userUIDs User UIDs
* @returns an Either of boolean or error
*/
async removeUserAccounts(userUIDs: string[]) {
const userDeleteResult: UserDeletionResult[] = [];
// step 1: fetch all users
const allUsersList = await this.userService.findUsersByIds(userUIDs);
if (allUsersList.length === 0) return E.left(USERS_NOT_FOUND);
// step 2: admin user can not be deleted without removing admin status/role
allUsersList.forEach((user) => {
if (user.isAdmin) {
userDeleteResult.push({
userUID: user.uid,
isDeleted: false,
errorMessage: ADMIN_CAN_NOT_BE_DELETED,
});
}
});
const nonAdminUsers = allUsersList.filter((user) => !user.isAdmin);
let deletedUserEmails: string[] = [];
// step 3: delete non-admin users
const deletionPromises = nonAdminUsers.map((user) => {
return this.userService
.deleteUserByUID(user)()
.then((res) => {
if (E.isLeft(res)) {
return {
userUID: user.uid,
isDeleted: false,
errorMessage: res.left,
} as UserDeletionResult;
}
deletedUserEmails.push(user.email);
return {
userUID: user.uid,
isDeleted: true,
errorMessage: null,
} as UserDeletionResult;
});
});
const promiseResult = await Promise.allSettled(deletionPromises);
// step 4: revoke all the invites sent to the deleted users
await this.revokeUserInvitations(deletedUserEmails);
// step 5: return the result
promiseResult.forEach((result) => {
if (result.status === 'fulfilled') {
userDeleteResult.push(result.value);
}
});
return E.right(userDeleteResult);
}
/** /**
* Make a user an admin * Make a user an admin
* @param userUid User UID * @param userUid User UID
* @returns an Either of boolean or error * @returns an Either of boolean or error
* @deprecated use makeUsersAdmin instead
*/ */
async makeUserAdmin(userUID: string) { async makeUserAdmin(userUID: string) {
const admin = await this.userService.makeAdmin(userUID); const admin = await this.userService.makeAdmin(userUID);
@@ -501,22 +356,10 @@ export class AdminService {
return E.right(true); return E.right(true);
} }
/**
* Make users to admin
* @param userUid User UIDs
* @returns an Either of boolean or error
*/
async makeUsersAdmin(userUIDs: string[]) {
const isUpdated = await this.userService.makeAdmins(userUIDs);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(true);
}
/** /**
* Remove user as admin * Remove user as admin
* @param userUid User UID * @param userUid User UID
* @returns an Either of boolean or error * @returns an Either of boolean or error
* @deprecated use demoteUsersByAdmin instead
*/ */
async removeUserAsAdmin(userUID: string) { async removeUserAsAdmin(userUID: string) {
const adminUsers = await this.userService.fetchAdminUsers(); const adminUsers = await this.userService.fetchAdminUsers();
@@ -527,26 +370,6 @@ export class AdminService {
return E.right(true); return E.right(true);
} }
/**
* Remove users as admin
* @param userUIDs User UIDs
* @returns an Either of boolean or error
*/
async demoteUsersByAdmin(userUIDs: string[]) {
const adminUsers = await this.userService.fetchAdminUsers();
const remainingAdmins = adminUsers.filter(
(adminUser) => !userUIDs.includes(adminUser.uid),
);
if (remainingAdmins.length < 1) {
return E.left(ONLY_ONE_ADMIN_ACCOUNT);
}
const isUpdated = await this.userService.removeUsersAsAdmin(userUIDs);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(isUpdated.right);
}
/** /**
* Fetch list of all the Users in org * Fetch list of all the Users in org
* @returns number of users in the org * @returns number of users in the org
@@ -609,35 +432,4 @@ export class AdminService {
return E.right(teamInvite.right); return E.right(teamInvite.right);
} }
/**
* Fetch all created ShortCodes
*
* @param args Pagination arguments
* @param userEmail User email
* @returns ShortcodeWithUserEmail
*/
async fetchAllShortcodes(
cursorID: string,
take: number,
userEmail: string = null,
) {
return this.shortcodeService.fetchAllShortcodes(
{ cursor: cursorID, take },
userEmail,
);
}
/**
* Delete a Shortcode
*
* @param shortcodeID ID of Shortcode being deleted
* @returns Boolean on successful deletion
*/
async deleteShortcode(shortcodeID: string) {
const result = await this.shortcodeService.deleteShortcode(shortcodeID);
if (E.isLeft(result)) return E.left(result.left);
return E.right(result.right);
}
} }

View File

@@ -1,11 +0,0 @@
import { Injectable, ExecutionContext, CanActivate } from '@nestjs/common';
@Injectable()
export class RESTAdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
return user.isAdmin;
}
}

View File

@@ -1,10 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Admin } from './admin.model';
@ObjectType()
export class Infra {
@Field(() => Admin, {
description: 'Admin who executed the action',
})
executedBy: Admin;
}

View File

@@ -1,362 +0,0 @@
import { UseGuards } from '@nestjs/common';
import {
Args,
ID,
Mutation,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { Infra } from './infra.model';
import { AdminService } from './admin.service';
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
import { GqlAdminGuard } from './guards/gql-admin.guard';
import { User } from 'src/user/user.model';
import { AuthUser } from 'src/types/AuthUser';
import { throwErr } from 'src/utils';
import * as E from 'fp-ts/Either';
import { Admin } from './admin.model';
import {
OffsetPaginationArgs,
PaginationArgs,
} from 'src/types/input-types.args';
import { InvitedUser } from './invited-user.model';
import { Team } from 'src/team/team.model';
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
import { GqlAdmin } from './decorators/gql-admin.decorator';
import { ShortcodeWithUserEmail } from 'src/shortcode/shortcode.model';
import { InfraConfig } from 'src/infra-config/infra-config.model';
import { InfraConfigService } from 'src/infra-config/infra-config.service';
import {
EnableAndDisableSSOArgs,
InfraConfigArgs,
} from 'src/infra-config/input-args';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { ServiceStatus } from 'src/infra-config/helper';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => Infra)
export class InfraResolver {
constructor(
private adminService: AdminService,
private infraConfigService: InfraConfigService,
) {}
@Query(() => Infra, {
description: 'Fetch details of the Infrastructure',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
infra(@GqlAdmin() admin: Admin) {
const infra: Infra = { executedBy: admin };
return infra;
}
@ResolveField(() => [User], {
description: 'Returns a list of all admin users in infra',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async admins() {
const admins = await this.adminService.fetchAdmins();
return admins;
}
@ResolveField(() => User, {
description: 'Returns a user info by UID',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async userInfo(
@Args({
name: 'userUid',
type: () => ID,
description: 'The user UID',
})
userUid: string,
): Promise<AuthUser> {
const user = await this.adminService.fetchUserInfo(userUid);
if (E.isLeft(user)) throwErr(user.left);
return user.right;
}
@ResolveField(() => [User], {
description: 'Returns a list of all the users in infra',
deprecationReason: 'Use allUsersV2 instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async allUsers(@Args() args: PaginationArgs): Promise<AuthUser[]> {
const users = await this.adminService.fetchUsers(args.cursor, args.take);
return users;
}
@ResolveField(() => [User], {
description: 'Returns a list of all the users in infra',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async allUsersV2(
@Args({
name: 'searchString',
nullable: true,
description: 'Search on users displayName or email',
})
searchString: string,
@Args() paginationOption: OffsetPaginationArgs,
): Promise<AuthUser[]> {
const users = await this.adminService.fetchUsersV2(
searchString,
paginationOption,
);
return users;
}
@ResolveField(() => [InvitedUser], {
description: 'Returns a list of all the invited users',
})
async invitedUsers(
@Args() args: OffsetPaginationArgs,
): Promise<InvitedUser[]> {
const users = await this.adminService.fetchInvitedUsers(args);
return users;
}
@ResolveField(() => [Team], {
description: 'Returns a list of all the teams in the infra',
})
async allTeams(@Args() args: PaginationArgs): Promise<Team[]> {
const teams = await this.adminService.fetchAllTeams(args.cursor, args.take);
return teams;
}
@ResolveField(() => Team, {
description: 'Returns a team info by ID when requested by Admin',
})
async teamInfo(
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which info to fetch',
})
teamID: string,
): Promise<Team> {
const team = await this.adminService.getTeamInfo(teamID);
if (E.isLeft(team)) throwErr(team.left);
return team.right;
}
@ResolveField(() => Number, {
description: 'Return count of all the members in a team',
})
async membersCountInTeam(
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
nullable: false,
})
teamID: string,
): Promise<number> {
const teamMembersCount = await this.adminService.membersCountInTeam(teamID);
return teamMembersCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored collections in a team',
})
async collectionCountInTeam(
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const teamCollCount = await this.adminService.collectionCountInTeam(teamID);
return teamCollCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored requests in a team',
})
async requestCountInTeam(
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const teamReqCount = await this.adminService.requestCountInTeam(teamID);
return teamReqCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored environments in a team',
})
async environmentCountInTeam(
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const envsCount = await this.adminService.environmentCountInTeam(teamID);
return envsCount;
}
@ResolveField(() => [TeamInvitation], {
description: 'Return all the pending invitations in a team',
})
async pendingInvitationCountInTeam(
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
) {
const invitations = await this.adminService.pendingInvitationCountInTeam(
teamID,
);
return invitations;
}
@ResolveField(() => Number, {
description: 'Return total number of Users in organization',
})
async usersCount() {
return this.adminService.getUsersCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Teams in organization',
})
async teamsCount() {
return this.adminService.getTeamsCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Team Collections in organization',
})
async teamCollectionsCount() {
return this.adminService.getTeamCollectionsCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Team Requests in organization',
})
async teamRequestsCount() {
return this.adminService.getTeamRequestsCount();
}
@ResolveField(() => [ShortcodeWithUserEmail], {
description: 'Returns a list of all the shortcodes in the infra',
})
async allShortcodes(
@Args() args: PaginationArgs,
@Args({
name: 'userEmail',
nullable: true,
description: 'Users email to filter shortcodes by',
})
userEmail: string,
) {
return await this.adminService.fetchAllShortcodes(
args.cursor,
args.take,
userEmail,
);
}
@Query(() => [InfraConfig], {
description: 'Retrieve configuration details for the instance',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async infraConfigs(
@Args({
name: 'configNames',
type: () => [InfraConfigEnum],
description: 'Configs to fetch',
})
names: InfraConfigEnum[],
) {
const infraConfigs = await this.infraConfigService.getMany(names);
if (E.isLeft(infraConfigs)) throwErr(infraConfigs.left);
return infraConfigs.right;
}
@Query(() => [String], {
description: 'Allowed Auth Provider list',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
allowedAuthProviders() {
return this.infraConfigService.getAllowedAuthProviders();
}
/* Mutations */
@Mutation(() => [InfraConfig], {
description: 'Update Infra Configs',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async updateInfraConfigs(
@Args({
name: 'infraConfigs',
type: () => [InfraConfigArgs],
description: 'InfraConfigs to update',
})
infraConfigs: InfraConfigArgs[],
) {
const updatedRes = await this.infraConfigService.updateMany(infraConfigs);
if (E.isLeft(updatedRes)) throwErr(updatedRes.left);
return updatedRes.right;
}
@Mutation(() => Boolean, {
description: 'Enable or disable analytics collection',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async toggleAnalyticsCollection(
@Args({
name: 'status',
type: () => ServiceStatus,
description: 'Toggle analytics collection',
})
analyticsCollectionStatus: ServiceStatus,
) {
const res = await this.infraConfigService.toggleAnalyticsCollection(
analyticsCollectionStatus,
);
if (E.isLeft(res)) throwErr(res.left);
return res.right;
}
@Mutation(() => Boolean, {
description: 'Reset Infra Configs with default values (.env)',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async resetInfraConfigs() {
const resetRes = await this.infraConfigService.reset();
if (E.isLeft(resetRes)) throwErr(resetRes.left);
return true;
}
@Mutation(() => Boolean, {
description: 'Enable or Disable SSO for login/signup',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async enableAndDisableSSO(
@Args({
name: 'providerInfo',
type: () => [EnableAndDisableSSOArgs],
description: 'SSO provider and status',
})
providerInfo: EnableAndDisableSSOArgs[],
) {
const isUpdated = await this.infraConfigService.enableAndDisableSSO(
providerInfo,
);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return true;
}
}

View File

@@ -19,73 +19,55 @@ import { UserCollectionModule } from './user-collection/user-collection.module';
import { ShortcodeModule } from './shortcode/shortcode.module'; import { ShortcodeModule } from './shortcode/shortcode.module';
import { COOKIES_NOT_FOUND } from './errors'; import { COOKIES_NOT_FOUND } from './errors';
import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller'; import { AppController } from './app/app.controller';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { InfraConfigModule } from './infra-config/infra-config.module';
import { loadInfraConfiguration } from './infra-config/helper';
import { MailerModule } from './mailer/mailer.module';
import { PosthogModule } from './posthog/posthog.module';
import { ScheduleModule } from '@nestjs/schedule';
import { HealthModule } from './health/health.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ GraphQLModule.forRoot<ApolloDriverConfig>({
isGlobal: true, buildSchemaOptions: {
load: [async () => loadInfraConfiguration()], numberScalarMode: 'integer',
}),
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
return {
buildSchemaOptions: {
numberScalarMode: 'integer',
},
playground: configService.get('PRODUCTION') !== 'true',
autoSchemaFile: true,
installSubscriptionHandlers: true,
subscriptions: {
'subscriptions-transport-ws': {
path: '/graphql',
onConnect: (_, websocket) => {
try {
const cookies = subscriptionContextCookieParser(
websocket.upgradeReq.headers.cookie,
);
return {
headers: { ...websocket?.upgradeReq?.headers, cookies },
};
} catch (error) {
throw new HttpException(COOKIES_NOT_FOUND, 400, {
cause: new Error(COOKIES_NOT_FOUND),
});
}
},
},
},
context: ({ req, res, connection }) => ({
req,
res,
connection,
}),
};
}, },
}), cors: {
ThrottlerModule.forRootAsync({ origin: process.env.WHITELISTED_ORIGINS.split(','),
imports: [ConfigModule], credentials: true,
inject: [ConfigService], },
useFactory: async (configService: ConfigService) => [ playground: process.env.PRODUCTION !== 'true',
{ debug: process.env.PRODUCTION !== 'true',
ttl: +configService.get('RATE_LIMIT_TTL'), autoSchemaFile: true,
limit: +configService.get('RATE_LIMIT_MAX'), installSubscriptionHandlers: true,
subscriptions: {
'subscriptions-transport-ws': {
path: '/graphql',
onConnect: (_, websocket) => {
try {
const cookies = subscriptionContextCookieParser(
websocket.upgradeReq.headers.cookie,
);
return {
headers: { ...websocket?.upgradeReq?.headers, cookies },
};
} catch (error) {
throw new HttpException(COOKIES_NOT_FOUND, 400, {
cause: new Error(COOKIES_NOT_FOUND),
});
}
},
}, },
], },
context: ({ req, res, connection }) => ({
req,
res,
connection,
}),
driver: ApolloDriver,
}),
ThrottlerModule.forRoot({
ttl: +process.env.RATE_LIMIT_TTL,
limit: +process.env.RATE_LIMIT_MAX,
}), }),
MailerModule.register(),
UserModule, UserModule,
AuthModule.register(), AuthModule,
AdminModule, AdminModule,
UserSettingsModule, UserSettingsModule,
UserEnvironmentsModule, UserEnvironmentsModule,
@@ -98,10 +80,6 @@ import { HealthModule } from './health/health.module';
TeamInvitationModule, TeamInvitationModule,
UserCollectionModule, UserCollectionModule,
ShortcodeModule, ShortcodeModule,
InfraConfigModule,
PosthogModule,
ScheduleModule.forRoot(),
HealthModule,
], ],
providers: [GQLComplexityPlugin], providers: [GQLComplexityPlugin],
controllers: [AppController], controllers: [AppController],

View File

@@ -2,6 +2,7 @@ import {
Body, Body,
Controller, Controller,
Get, Get,
InternalServerErrorException,
Post, Post,
Query, Query,
Request, Request,
@@ -18,29 +19,23 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { GqlUser } from 'src/decorators/gql-user.decorator'; import { GqlUser } from 'src/decorators/gql-user.decorator';
import { AuthUser } from 'src/types/AuthUser'; import { AuthUser } from 'src/types/AuthUser';
import { RTCookie } from 'src/decorators/rt-cookie.decorator'; import { RTCookie } from 'src/decorators/rt-cookie.decorator';
import { AuthProvider, authCookieHandler, authProviderCheck } from './helper'; import {
AuthProvider,
authCookieHandler,
authProviderCheck,
throwHTTPErr,
} from './helper';
import { GoogleSSOGuard } from './guards/google-sso.guard'; import { GoogleSSOGuard } from './guards/google-sso.guard';
import { GithubSSOGuard } from './guards/github-sso.guard'; import { GithubSSOGuard } from './guards/github-sso.guard';
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard'; import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard'; import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import { SkipThrottle } from '@nestjs/throttler'; import { SkipThrottle } from '@nestjs/throttler';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
@UseGuards(ThrottlerBehindProxyGuard) @UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'auth', version: '1' }) @Controller({ path: 'auth', version: '1' })
export class AuthController { export class AuthController {
constructor( constructor(private authService: AuthService) {}
private authService: AuthService,
private configService: ConfigService,
) {}
@Get('providers')
async getAuthProviders() {
const providers = await this.authService.getAuthProviders();
return { providers };
}
/** /**
** Route to initiate magic-link auth for a users email ** Route to initiate magic-link auth for a users email
@@ -50,14 +45,8 @@ export class AuthController {
@Body() authData: SignInMagicDto, @Body() authData: SignInMagicDto,
@Query('origin') origin: string, @Query('origin') origin: string,
) { ) {
if ( if (!authProviderCheck(AuthProvider.EMAIL))
!authProviderCheck(
AuthProvider.EMAIL,
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
)
) {
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 }); throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
}
const deviceIdToken = await this.authService.signInMagicLink( const deviceIdToken = await this.authService.signInMagicLink(
authData.email, authData.email,

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { UserModule } from 'src/user/user.module'; import { UserModule } from 'src/user/user.module';
import { MailerModule } from 'src/mailer/mailer.module';
import { PrismaModule } from 'src/prisma/prisma.module'; import { PrismaModule } from 'src/prisma/prisma.module';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@@ -11,55 +12,25 @@ import { GoogleStrategy } from './strategies/google.strategy';
import { GithubStrategy } from './strategies/github.strategy'; import { GithubStrategy } from './strategies/github.strategy';
import { MicrosoftStrategy } from './strategies/microsoft.strategy'; import { MicrosoftStrategy } from './strategies/microsoft.strategy';
import { AuthProvider, authProviderCheck } from './helper'; import { AuthProvider, authProviderCheck } from './helper';
import { ConfigModule, ConfigService } from '@nestjs/config';
import {
isInfraConfigTablePopulated,
loadInfraConfiguration,
} from 'src/infra-config/helper';
import { InfraConfigModule } from 'src/infra-config/infra-config.module';
@Module({ @Module({
imports: [ imports: [
PrismaModule, PrismaModule,
UserModule, UserModule,
MailerModule,
PassportModule, PassportModule,
JwtModule.registerAsync({ JwtModule.register({
imports: [ConfigModule], secret: process.env.JWT_SECRET,
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET'),
}),
}), }),
InfraConfigModule,
], ],
providers: [AuthService, JwtStrategy, RTJwtStrategy], providers: [
AuthService,
JwtStrategy,
RTJwtStrategy,
...(authProviderCheck(AuthProvider.GOOGLE) ? [GoogleStrategy] : []),
...(authProviderCheck(AuthProvider.GITHUB) ? [GithubStrategy] : []),
...(authProviderCheck(AuthProvider.MICROSOFT) ? [MicrosoftStrategy] : []),
],
controllers: [AuthController], controllers: [AuthController],
}) })
export class AuthModule { export class AuthModule {}
static async register() {
const isInfraConfigPopulated = await isInfraConfigTablePopulated();
if (!isInfraConfigPopulated) {
return { module: AuthModule };
}
const env = await loadInfraConfiguration();
const allowedAuthProviders = env.INFRA.VITE_ALLOWED_AUTH_PROVIDERS;
const providers = [
...(authProviderCheck(AuthProvider.GOOGLE, allowedAuthProviders)
? [GoogleStrategy]
: []),
...(authProviderCheck(AuthProvider.GITHUB, allowedAuthProviders)
? [GithubStrategy]
: []),
...(authProviderCheck(AuthProvider.MICROSOFT, allowedAuthProviders)
? [MicrosoftStrategy]
: []),
];
return {
module: AuthModule,
providers,
};
}
}

View File

@@ -21,26 +21,15 @@ import { VerifyMagicDto } from './dto/verify-magic.dto';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { ConfigService } from '@nestjs/config';
import { InfraConfigService } from 'src/infra-config/infra-config.service';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
const mockUser = mockDeep<UserService>(); const mockUser = mockDeep<UserService>();
const mockJWT = mockDeep<JwtService>(); const mockJWT = mockDeep<JwtService>();
const mockMailer = mockDeep<MailerService>(); const mockMailer = mockDeep<MailerService>();
const mockConfigService = mockDeep<ConfigService>();
const mockInfraConfigService = mockDeep<InfraConfigService>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
const authService = new AuthService( const authService = new AuthService(mockUser, mockPrisma, mockJWT, mockMailer);
mockUser,
mockPrisma,
mockJWT,
mockMailer,
mockConfigService,
mockInfraConfigService,
);
const currentTime = new Date(); const currentTime = new Date();
@@ -102,8 +91,6 @@ describe('signInMagicLink', () => {
mockUser.createUserViaMagicLink.mockResolvedValue(user); mockUser.createUserViaMagicLink.mockResolvedValue(user);
// create new entry in VerificationToken table // create new entry in VerificationToken table
mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData); mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData);
// Read env variable 'MAGIC_LINK_TOKEN_VALIDITY' from config service
mockConfigService.get.mockReturnValue('3');
const result = await authService.signInMagicLink( const result = await authService.signInMagicLink(
'dwight@dundermifflin.com', 'dwight@dundermifflin.com',

View File

@@ -24,12 +24,10 @@ import {
RefreshTokenPayload, RefreshTokenPayload,
} from 'src/types/AuthTokens'; } from 'src/types/AuthTokens';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { RESTError } from 'src/types/RESTError'; import { AuthError } from 'src/types/AuthError';
import { AuthUser, IsAdmin } from 'src/types/AuthUser'; import { AuthUser, IsAdmin } from 'src/types/AuthUser';
import { VerificationToken } from '@prisma/client'; import { VerificationToken } from '@prisma/client';
import { Origin } from './helper'; import { Origin } from './helper';
import { ConfigService } from '@nestjs/config';
import { InfraConfigService } from 'src/infra-config/infra-config.service';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -38,8 +36,6 @@ export class AuthService {
private prismaService: PrismaService, private prismaService: PrismaService,
private jwtService: JwtService, private jwtService: JwtService,
private readonly mailerService: MailerService, private readonly mailerService: MailerService,
private readonly configService: ConfigService,
private infraConfigService: InfraConfigService,
) {} ) {}
/** /**
@@ -50,12 +46,10 @@ export class AuthService {
*/ */
private async generateMagicLinkTokens(user: AuthUser) { private async generateMagicLinkTokens(user: AuthUser) {
const salt = await bcrypt.genSalt( const salt = await bcrypt.genSalt(
parseInt(this.configService.get('TOKEN_SALT_COMPLEXITY')), parseInt(process.env.TOKEN_SALT_COMPLEXITY),
); );
const expiresOn = DateTime.now() const expiresOn = DateTime.now()
.plus({ .plus({ hours: parseInt(process.env.MAGIC_LINK_TOKEN_VALIDITY) })
hours: parseInt(this.configService.get('MAGIC_LINK_TOKEN_VALIDITY')),
})
.toISO() .toISO()
.toString(); .toString();
@@ -101,13 +95,13 @@ export class AuthService {
*/ */
private async generateRefreshToken(userUid: string) { private async generateRefreshToken(userUid: string) {
const refreshTokenPayload: RefreshTokenPayload = { const refreshTokenPayload: RefreshTokenPayload = {
iss: this.configService.get('VITE_BASE_URL'), iss: process.env.VITE_BASE_URL,
sub: userUid, sub: userUid,
aud: [this.configService.get('VITE_BASE_URL')], aud: [process.env.VITE_BASE_URL],
}; };
const refreshToken = await this.jwtService.sign(refreshTokenPayload, { const refreshToken = await this.jwtService.sign(refreshTokenPayload, {
expiresIn: this.configService.get('REFRESH_TOKEN_VALIDITY'), //7 Days expiresIn: process.env.REFRESH_TOKEN_VALIDITY, //7 Days
}); });
const refreshTokenHash = await argon2.hash(refreshToken); const refreshTokenHash = await argon2.hash(refreshToken);
@@ -117,7 +111,7 @@ export class AuthService {
userUid, userUid,
); );
if (E.isLeft(updatedUser)) if (E.isLeft(updatedUser))
return E.left(<RESTError>{ return E.left(<AuthError>{
message: updatedUser.left, message: updatedUser.left,
statusCode: HttpStatus.NOT_FOUND, statusCode: HttpStatus.NOT_FOUND,
}); });
@@ -133,9 +127,9 @@ export class AuthService {
*/ */
async generateAuthTokens(userUid: string) { async generateAuthTokens(userUid: string) {
const accessTokenPayload: AccessTokenPayload = { const accessTokenPayload: AccessTokenPayload = {
iss: this.configService.get('VITE_BASE_URL'), iss: process.env.VITE_BASE_URL,
sub: userUid, sub: userUid,
aud: [this.configService.get('VITE_BASE_URL')], aud: [process.env.VITE_BASE_URL],
}; };
const refreshToken = await this.generateRefreshToken(userUid); const refreshToken = await this.generateRefreshToken(userUid);
@@ -143,7 +137,7 @@ export class AuthService {
return E.right(<AuthTokens>{ return E.right(<AuthTokens>{
access_token: await this.jwtService.sign(accessTokenPayload, { access_token: await this.jwtService.sign(accessTokenPayload, {
expiresIn: this.configService.get('ACCESS_TOKEN_VALIDITY'), //1 Day expiresIn: process.env.ACCESS_TOKEN_VALIDITY, //1 Day
}), }),
refresh_token: refreshToken.right, refresh_token: refreshToken.right,
}); });
@@ -224,18 +218,18 @@ export class AuthService {
let url: string; let url: string;
switch (origin) { switch (origin) {
case Origin.ADMIN: case Origin.ADMIN:
url = this.configService.get('VITE_ADMIN_URL'); url = process.env.VITE_ADMIN_URL;
break; break;
case Origin.APP: case Origin.APP:
url = this.configService.get('VITE_BASE_URL'); url = process.env.VITE_BASE_URL;
break; break;
default: default:
// if origin is invalid by default set URL to Hoppscotch-App // if origin is invalid by default set URL to Hoppscotch-App
url = this.configService.get('VITE_BASE_URL'); url = process.env.VITE_BASE_URL;
} }
await this.mailerService.sendEmail(email, { await this.mailerService.sendEmail(email, {
template: 'user-invitation', template: 'code-your-own',
variables: { variables: {
inviteeEmail: email, inviteeEmail: email,
magicLink: `${url}/enter?token=${generatedTokens.token}`, magicLink: `${url}/enter?token=${generatedTokens.token}`,
@@ -255,7 +249,7 @@ export class AuthService {
*/ */
async verifyMagicLinkTokens( async verifyMagicLinkTokens(
magicLinkIDTokens: VerifyMagicDto, magicLinkIDTokens: VerifyMagicDto,
): Promise<E.Right<AuthTokens> | E.Left<RESTError>> { ): Promise<E.Right<AuthTokens> | E.Left<AuthError>> {
const passwordlessTokens = await this.validatePasswordlessTokens( const passwordlessTokens = await this.validatePasswordlessTokens(
magicLinkIDTokens, magicLinkIDTokens,
); );
@@ -373,7 +367,7 @@ export class AuthService {
if (usersCount === 1) { if (usersCount === 1) {
const elevatedUser = await this.usersService.makeAdmin(user.uid); const elevatedUser = await this.usersService.makeAdmin(user.uid);
if (E.isLeft(elevatedUser)) if (E.isLeft(elevatedUser))
return E.left(<RESTError>{ return E.left(<AuthError>{
message: elevatedUser.left, message: elevatedUser.left,
statusCode: HttpStatus.NOT_FOUND, statusCode: HttpStatus.NOT_FOUND,
}); });
@@ -383,8 +377,4 @@ export class AuthService {
return E.right(<IsAdmin>{ isAdmin: false }); return E.right(<IsAdmin>{ isAdmin: false });
} }
getAuthProviders() {
return this.infraConfigService.getAllowedAuthProviders();
}
} }

View File

@@ -1,28 +1,16 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck } from '../helper'; import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
@Injectable() @Injectable()
export class GithubSSOGuard extends AuthGuard('github') implements CanActivate { export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {
constructor(private readonly configService: ConfigService) {
super();
}
canActivate( canActivate(
context: ExecutionContext, context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> { ): boolean | Promise<boolean> | Observable<boolean> {
if ( if (!authProviderCheck(AuthProvider.GITHUB))
!authProviderCheck(
AuthProvider.GITHUB,
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
)
) {
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 }); throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
}
return super.canActivate(context); return super.canActivate(context);
} }

View File

@@ -1,28 +1,16 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck } from '../helper'; import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
@Injectable() @Injectable()
export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate { export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {
constructor(private readonly configService: ConfigService) {
super();
}
canActivate( canActivate(
context: ExecutionContext, context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> { ): boolean | Promise<boolean> | Observable<boolean> {
if ( if (!authProviderCheck(AuthProvider.GOOGLE))
!authProviderCheck(
AuthProvider.GOOGLE,
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
)
) {
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 }); throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
}
return super.canActivate(context); return super.canActivate(context);
} }

View File

@@ -1,34 +1,22 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck } from '../helper'; import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
@Injectable() @Injectable()
export class MicrosoftSSOGuard export class MicrosoftSSOGuard
extends AuthGuard('microsoft') extends AuthGuard('microsoft')
implements CanActivate implements CanActivate
{ {
constructor(private readonly configService: ConfigService) {
super();
}
canActivate( canActivate(
context: ExecutionContext, context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> { ): boolean | Promise<boolean> | Observable<boolean> {
if ( if (!authProviderCheck(AuthProvider.MICROSOFT))
!authProviderCheck(
AuthProvider.MICROSOFT,
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
)
) {
throwHTTPErr({ throwHTTPErr({
message: AUTH_PROVIDER_NOT_SPECIFIED, message: AUTH_PROVIDER_NOT_SPECIFIED,
statusCode: 404, statusCode: 404,
}); });
}
return super.canActivate(context); return super.canActivate(context);
} }

View File

@@ -1,11 +1,11 @@
import { HttpException, HttpStatus } from '@nestjs/common'; import { HttpException, HttpStatus } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { AuthError } from 'src/types/AuthError';
import { AuthTokens } from 'src/types/AuthTokens'; import { AuthTokens } from 'src/types/AuthTokens';
import { Response } from 'express'; import { Response } from 'express';
import * as cookie from 'cookie'; import * as cookie from 'cookie';
import { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors'; import { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import { ConfigService } from '@nestjs/config';
enum AuthTokenType { enum AuthTokenType {
ACCESS_TOKEN = 'access_token', ACCESS_TOKEN = 'access_token',
@@ -24,6 +24,15 @@ export enum AuthProvider {
EMAIL = 'EMAIL', EMAIL = 'EMAIL',
} }
/**
* This function allows throw to be used as an expression
* @param errMessage Message present in the error message
*/
export function throwHTTPErr(errorData: AuthError): never {
const { message, statusCode } = errorData;
throw new HttpException(message, statusCode);
}
/** /**
* Sets and returns the cookies in the response object on successful authentication * Sets and returns the cookies in the response object on successful authentication
* @param res Express Response Object * @param res Express Response Object
@@ -36,17 +45,15 @@ export const authCookieHandler = (
redirect: boolean, redirect: boolean,
redirectUrl: string | null, redirectUrl: string | null,
) => { ) => {
const configService = new ConfigService();
const currentTime = DateTime.now(); const currentTime = DateTime.now();
const accessTokenValidity = currentTime const accessTokenValidity = currentTime
.plus({ .plus({
milliseconds: parseInt(configService.get('ACCESS_TOKEN_VALIDITY')), milliseconds: parseInt(process.env.ACCESS_TOKEN_VALIDITY),
}) })
.toMillis(); .toMillis();
const refreshTokenValidity = currentTime const refreshTokenValidity = currentTime
.plus({ .plus({
milliseconds: parseInt(configService.get('REFRESH_TOKEN_VALIDITY')), milliseconds: parseInt(process.env.REFRESH_TOKEN_VALIDITY),
}) })
.toMillis(); .toMillis();
@@ -68,12 +75,10 @@ export const authCookieHandler = (
} }
// check to see if redirectUrl is a whitelisted url // check to see if redirectUrl is a whitelisted url
const whitelistedOrigins = configService const whitelistedOrigins = process.env.WHITELISTED_ORIGINS.split(',');
.get('WHITELISTED_ORIGINS')
.split(',');
if (!whitelistedOrigins.includes(redirectUrl)) if (!whitelistedOrigins.includes(redirectUrl))
// if it is not redirect by default to REDIRECT_URL // if it is not redirect by default to REDIRECT_URL
redirectUrl = configService.get('REDIRECT_URL'); redirectUrl = process.env.REDIRECT_URL;
return res.status(HttpStatus.OK).redirect(redirectUrl); return res.status(HttpStatus.OK).redirect(redirectUrl);
}; };
@@ -107,16 +112,13 @@ export const subscriptionContextCookieParser = (rawCookies: string) => {
* @param provider Provider we want to check the presence of * @param provider Provider we want to check the presence of
* @returns Boolean if provider specified is present or not * @returns Boolean if provider specified is present or not
*/ */
export function authProviderCheck( export function authProviderCheck(provider: string) {
provider: string,
VITE_ALLOWED_AUTH_PROVIDERS: string,
) {
if (!provider) { if (!provider) {
throwErr(AUTH_PROVIDER_NOT_SPECIFIED); throwErr(AUTH_PROVIDER_NOT_SPECIFIED);
} }
const envVariables = VITE_ALLOWED_AUTH_PROVIDERS const envVariables = process.env.VITE_ALLOWED_AUTH_PROVIDERS
? VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) => ? process.env.VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
provider.trim().toUpperCase(), provider.trim().toUpperCase(),
) )
: []; : [];

View File

@@ -5,20 +5,18 @@ import { AuthService } from '../auth.service';
import { UserService } from 'src/user/user.service'; import { UserService } from 'src/user/user.service';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { ConfigService } from '@nestjs/config';
@Injectable() @Injectable()
export class GithubStrategy extends PassportStrategy(Strategy) { export class GithubStrategy extends PassportStrategy(Strategy) {
constructor( constructor(
private authService: AuthService, private authService: AuthService,
private usersService: UserService, private usersService: UserService,
private configService: ConfigService,
) { ) {
super({ super({
clientID: configService.get('INFRA.GITHUB_CLIENT_ID'), clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: configService.get('INFRA.GITHUB_CLIENT_SECRET'), clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: configService.get('INFRA.GITHUB_CALLBACK_URL'), callbackURL: process.env.GITHUB_CALLBACK_URL,
scope: [configService.get('INFRA.GITHUB_SCOPE')], scope: [process.env.GITHUB_SCOPE],
store: true, store: true,
}); });
} }

View File

@@ -5,20 +5,18 @@ import { UserService } from 'src/user/user.service';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import { AuthService } from '../auth.service'; import { AuthService } from '../auth.service';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { ConfigService } from '@nestjs/config';
@Injectable() @Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy) { export class GoogleStrategy extends PassportStrategy(Strategy) {
constructor( constructor(
private usersService: UserService, private usersService: UserService,
private authService: AuthService, private authService: AuthService,
private configService: ConfigService,
) { ) {
super({ super({
clientID: configService.get('INFRA.GOOGLE_CLIENT_ID'), clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: configService.get('INFRA.GOOGLE_CLIENT_SECRET'), clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: configService.get('INFRA.GOOGLE_CALLBACK_URL'), callbackURL: process.env.GOOGLE_CALLBACK_URL,
scope: configService.get('INFRA.GOOGLE_SCOPE').split(','), scope: process.env.GOOGLE_SCOPE.split(','),
passReqToCallback: true, passReqToCallback: true,
store: true, store: true,
}); });

View File

@@ -15,14 +15,10 @@ import {
INVALID_ACCESS_TOKEN, INVALID_ACCESS_TOKEN,
USER_NOT_FOUND, USER_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { ConfigService } from '@nestjs/config';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor( constructor(private usersService: UserService) {
private usersService: UserService,
private configService: ConfigService,
) {
super({ super({
jwtFromRequest: ExtractJwt.fromExtractors([ jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => { (request: Request) => {
@@ -33,7 +29,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
return ATCookie; return ATCookie;
}, },
]), ]),
secretOrKey: configService.get('JWT_SECRET'), secretOrKey: process.env.JWT_SECRET,
}); });
} }

View File

@@ -5,21 +5,19 @@ import { AuthService } from '../auth.service';
import { UserService } from 'src/user/user.service'; import { UserService } from 'src/user/user.service';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { ConfigService } from '@nestjs/config';
@Injectable() @Injectable()
export class MicrosoftStrategy extends PassportStrategy(Strategy) { export class MicrosoftStrategy extends PassportStrategy(Strategy) {
constructor( constructor(
private authService: AuthService, private authService: AuthService,
private usersService: UserService, private usersService: UserService,
private configService: ConfigService,
) { ) {
super({ super({
clientID: configService.get('INFRA.MICROSOFT_CLIENT_ID'), clientID: process.env.MICROSOFT_CLIENT_ID,
clientSecret: configService.get('INFRA.MICROSOFT_CLIENT_SECRET'), clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
callbackURL: configService.get('INFRA.MICROSOFT_CALLBACK_URL'), callbackURL: process.env.MICROSOFT_CALLBACK_URL,
scope: [configService.get('INFRA.MICROSOFT_SCOPE')], scope: [process.env.MICROSOFT_SCOPE],
tenant: configService.get('INFRA.MICROSOFT_TENANT'), tenant: process.env.MICROSOFT_TENANT,
store: true, store: true,
}); });
} }

View File

@@ -14,14 +14,10 @@ import {
USER_NOT_FOUND, USER_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import { ConfigService } from '@nestjs/config';
@Injectable() @Injectable()
export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
constructor( constructor(private usersService: UserService) {
private usersService: UserService,
private configService: ConfigService,
) {
super({ super({
jwtFromRequest: ExtractJwt.fromExtractors([ jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => { (request: Request) => {
@@ -32,7 +28,7 @@ export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
return RTCookie; return RTCookie;
}, },
]), ]),
secretOrKey: configService.get('JWT_SECRET'), secretOrKey: process.env.JWT_SECRET,
}); });
} }

View File

@@ -10,14 +10,6 @@ export const DUPLICATE_EMAIL = 'email/both_emails_cannot_be_same' as const;
export const ONLY_ONE_ADMIN_ACCOUNT = export const ONLY_ONE_ADMIN_ACCOUNT =
'admin/only_one_admin_account_found' as const; 'admin/only_one_admin_account_found' as const;
/**
* Admin user can not be deleted
* To delete the admin user, first make the Admin user a normal user
* (AdminService)
*/
export const ADMIN_CAN_NOT_BE_DELETED =
'admin/admin_can_not_be_deleted' as const;
/** /**
* Token Authorization failed (Check 'Authorization' Header) * Token Authorization failed (Check 'Authorization' Header)
* (GqlAuthGuard) * (GqlAuthGuard)
@@ -36,13 +28,6 @@ export const JSON_INVALID = 'json_invalid';
*/ */
export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified'; export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified';
/**
* Auth Provider not specified
* (Auth)
*/
export const AUTH_PROVIDER_NOT_CONFIGURED =
'auth/provider_not_configured_correctly';
/** /**
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file * Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file
*/ */
@@ -84,12 +69,6 @@ export const USER_ALREADY_INVITED = 'admin/user_already_invited' as const;
*/ */
export const USER_UPDATE_FAILED = 'user/update_failed' as const; export const USER_UPDATE_FAILED = 'user/update_failed' as const;
/**
* User display name validation failure
* (UserService)
*/
export const USER_SHORT_DISPLAY_NAME = 'user/short_display_name' as const;
/** /**
* User deletion failure * User deletion failure
* (UserService) * (UserService)
@@ -113,13 +92,6 @@ export const USER_IS_OWNER = 'user/is_owner' as const;
*/ */
export const USER_IS_ADMIN = 'user/is_admin' as const; export const USER_IS_ADMIN = 'user/is_admin' as const;
/**
* User invite deletion failure error due to invitation not found
* (AdminService)
*/
export const USER_INVITATION_DELETION_FAILED =
'user/invitation_deletion_failed' as const;
/** /**
* Teams not found * Teams not found
* (TeamsService) * (TeamsService)
@@ -234,12 +206,6 @@ export const TEAM_COL_NOT_SAME_PARENT =
export const TEAM_COL_SAME_NEXT_COLL = export const TEAM_COL_SAME_NEXT_COLL =
'team_coll/collection_and_next_collection_are_same'; 'team_coll/collection_and_next_collection_are_same';
/**
* Team Collection search failed
* (TeamCollectionService)
*/
export const TEAM_COL_SEARCH_FAILED = 'team_coll/team_collection_search_failed';
/** /**
* Team Collection Re-Ordering Failed * Team Collection Re-Ordering Failed
* (TeamCollectionService) * (TeamCollectionService)
@@ -288,20 +254,6 @@ export const TEAM_COLL_INVALID_JSON = 'team_coll/invalid_json';
*/ */
export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const; export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const;
/**
* The Team Collection data is not valid
* (TeamCollectionService)
*/
export const TEAM_COLL_DATA_INVALID =
'team_coll/team_coll_data_invalid' as const;
/**
* Team Collection parent tree generation failed
* (TeamCollectionService)
*/
export const TEAM_COLL_PARENT_TREE_GEN_FAILED =
'team_coll/team_coll_parent_tree_generation_failed';
/** /**
* Tried to perform an action on a request that doesn't accept their member role level * Tried to perform an action on a request that doesn't accept their member role level
* (GqlRequestTeamMemberGuard) * (GqlRequestTeamMemberGuard)
@@ -327,19 +279,6 @@ export const TEAM_REQ_INVALID_TARGET_COLL_ID =
*/ */
export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const; export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const;
/**
* Team Request search failed
* (TeamRequestService)
*/
export const TEAM_REQ_SEARCH_FAILED = 'team_req/team_request_search_failed';
/**
* Team Request parent tree generation failed
* (TeamRequestService)
*/
export const TEAM_REQ_PARENT_TREE_GEN_FAILED =
'team_req/team_req_parent_tree_generation_failed';
/** /**
* No Postmark Sender Email defined * No Postmark Sender Email defined
* (AuthService) * (AuthService)
@@ -379,6 +318,18 @@ export const TEAM_INVITATION_NOT_FOUND =
*/ */
export const SHORTCODE_NOT_FOUND = 'shortcode/not_found' as const; export const SHORTCODE_NOT_FOUND = 'shortcode/not_found' as const;
/**
* Invalid ShortCode format
* (ShortcodeService)
*/
export const SHORTCODE_INVALID_JSON = 'shortcode/invalid_json' as const;
/**
* ShortCode already exists in DB
* (ShortcodeService)
*/
export const SHORTCODE_ALREADY_EXISTS = 'shortcode/already_exists' as const;
/** /**
* Invalid or non-existent TEAM ENVIRONMENT ID * Invalid or non-existent TEAM ENVIRONMENT ID
* (TeamEnvironmentsService) * (TeamEnvironmentsService)
@@ -646,13 +597,6 @@ export const USER_COLL_REORDERING_FAILED =
export const USER_COLL_SAME_NEXT_COLL = export const USER_COLL_SAME_NEXT_COLL =
'user_coll/user_collection_and_next_user_collection_are_same' as const; 'user_coll/user_collection_and_next_user_collection_are_same' as const;
/**
* The User Collection data is not valid
* (UserCollectionService)
*/
export const USER_COLL_DATA_INVALID =
'user_coll/user_coll_data_invalid' as const;
/** /**
* The User Collection does not belong to the logged-in user * The User Collection does not belong to the logged-in user
* (UserCollectionService) * (UserCollectionService)
@@ -677,87 +621,3 @@ export const MAILER_SMTP_URL_UNDEFINED = 'mailer/smtp_url_undefined' as const;
*/ */
export const MAILER_FROM_ADDRESS_UNDEFINED = export const MAILER_FROM_ADDRESS_UNDEFINED =
'mailer/from_address_undefined' as const; 'mailer/from_address_undefined' as const;
/**
* SharedRequest invalid request JSON format
* (ShortcodeService)
*/
export const SHORTCODE_INVALID_REQUEST_JSON =
'shortcode/request_invalid_format' as const;
/**
* SharedRequest invalid properties JSON format
* (ShortcodeService)
*/
export const SHORTCODE_INVALID_PROPERTIES_JSON =
'shortcode/properties_invalid_format' as const;
/**
* SharedRequest invalid properties not found
* (ShortcodeService)
*/
export const SHORTCODE_PROPERTIES_NOT_FOUND =
'shortcode/properties_not_found' as const;
/**
* Infra Config not found
* (InfraConfigService)
*/
export const INFRA_CONFIG_NOT_FOUND = 'infra_config/not_found' as const;
/**
* Infra Config update failed
* (InfraConfigService)
*/
export const INFRA_CONFIG_UPDATE_FAILED = 'infra_config/update_failed' as const;
/**
* Infra Config not listed for onModuleInit creation
* (InfraConfigService)
*/
export const INFRA_CONFIG_NOT_LISTED =
'infra_config/properly_not_listed' as const;
/**
* Infra Config reset failed
* (InfraConfigService)
*/
export const INFRA_CONFIG_RESET_FAILED = 'infra_config/reset_failed' as const;
/**
* Infra Config invalid input for Config variable
* (InfraConfigService)
*/
export const INFRA_CONFIG_INVALID_INPUT = 'infra_config/invalid_input' as const;
/**
* Infra Config service (auth provider/mailer/audit logs) not configured
* (InfraConfigService)
*/
export const INFRA_CONFIG_SERVICE_NOT_CONFIGURED =
'infra_config/service_not_configured' as const;
/**
* Infra Config update/fetch operation not allowed
* (InfraConfigService)
*/
export const INFRA_CONFIG_OPERATION_NOT_ALLOWED =
'infra_config/operation_not_allowed';
/**
* Error message for when the database table does not exist
* (InfraConfigService)
*/
export const DATABASE_TABLE_NOT_EXIST =
'Database migration not found. Please check the documentation for assistance: https://docs.hoppscotch.io/documentation/self-host/community-edition/install-and-build#running-migrations';
/**
* PostHog client is not initialized
* (InfraConfigService)
*/
export const POSTHOG_CLIENT_NOT_INITIALIZED = 'posthog/client_not_initialized';
/**
* Inputs supplied are invalid
*/
export const INVALID_PARAMS = 'invalid_parameters' as const;

View File

@@ -27,7 +27,6 @@ import { UserRequestUserCollectionResolver } from './user-request/resolvers/user
import { UserEnvsUserResolver } from './user-environment/user.resolver'; import { UserEnvsUserResolver } from './user-environment/user.resolver';
import { UserHistoryUserResolver } from './user-history/user.resolver'; import { UserHistoryUserResolver } from './user-history/user.resolver';
import { UserSettingsUserResolver } from './user-settings/user.resolver'; import { UserSettingsUserResolver } from './user-settings/user.resolver';
import { InfraResolver } from './admin/infra.resolver';
/** /**
* All the resolvers present in the application. * All the resolvers present in the application.
@@ -35,7 +34,6 @@ import { InfraResolver } from './admin/infra.resolver';
* NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate * NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate
*/ */
const RESOLVERS = [ const RESOLVERS = [
InfraResolver,
AdminResolver, AdminResolver,
ShortcodeResolver, ShortcodeResolver,
TeamResolver, TeamResolver,
@@ -95,7 +93,9 @@ export async function emitGQLSchemaFile() {
numberScalarMode: 'integer', numberScalarMode: 'integer',
}); });
const schemaString = printSchema(schema); const schemaString = printSchema(schema, {
commentDescriptions: true,
});
logger.log(`Writing schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`); logger.log(`Writing schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`);

View File

@@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard { export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
protected async getTracker(req: Record<string, any>): Promise<string> { protected getTracker(req: Record<string, any>): string {
return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs
// learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For#directives
} }
} }

View File

@@ -1,24 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import {
HealthCheck,
HealthCheckService,
PrismaHealthIndicator,
} from '@nestjs/terminus';
import { PrismaService } from 'src/prisma/prisma.service';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private prismaHealth: PrismaHealthIndicator,
private prisma: PrismaService,
) {}
@Get()
@HealthCheck()
check() {
return this.health.check([
async () => this.prismaHealth.pingCheck('database', this.prisma),
]);
}
}

View File

@@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { PrismaModule } from 'src/prisma/prisma.module';
import { TerminusModule } from '@nestjs/terminus';
@Module({
imports: [PrismaModule, TerminusModule],
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -1,259 +0,0 @@
import { AuthProvider } from 'src/auth/helper';
import {
AUTH_PROVIDER_NOT_CONFIGURED,
DATABASE_TABLE_NOT_EXIST,
} from 'src/errors';
import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { throwErr } from 'src/utils';
import { randomBytes } from 'crypto';
export enum ServiceStatus {
ENABLE = 'ENABLE',
DISABLE = 'DISABLE',
}
const AuthProviderConfigurations = {
[AuthProvider.GOOGLE]: [
InfraConfigEnum.GOOGLE_CLIENT_ID,
InfraConfigEnum.GOOGLE_CLIENT_SECRET,
InfraConfigEnum.GOOGLE_CALLBACK_URL,
InfraConfigEnum.GOOGLE_SCOPE,
],
[AuthProvider.GITHUB]: [
InfraConfigEnum.GITHUB_CLIENT_ID,
InfraConfigEnum.GITHUB_CLIENT_SECRET,
InfraConfigEnum.GITHUB_CALLBACK_URL,
InfraConfigEnum.GITHUB_SCOPE,
],
[AuthProvider.MICROSOFT]: [
InfraConfigEnum.MICROSOFT_CLIENT_ID,
InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
InfraConfigEnum.MICROSOFT_CALLBACK_URL,
InfraConfigEnum.MICROSOFT_SCOPE,
InfraConfigEnum.MICROSOFT_TENANT,
],
[AuthProvider.EMAIL]: [
InfraConfigEnum.MAILER_SMTP_URL,
InfraConfigEnum.MAILER_ADDRESS_FROM,
],
};
/**
* Load environment variables from the database and set them in the process
*
* @Description Fetch the 'infra_config' table from the database and return it as an object
* (ConfigModule will set the environment variables in the process)
*/
export async function loadInfraConfiguration() {
try {
const prisma = new PrismaService();
const infraConfigs = await prisma.infraConfig.findMany();
let environmentObject: Record<string, any> = {};
infraConfigs.forEach((infraConfig) => {
environmentObject[infraConfig.name] = infraConfig.value;
});
return { INFRA: environmentObject };
} catch (error) {
// Prisma throw error if 'Can't reach at database server' OR 'Table does not exist'
// Reason for not throwing error is, we want successful build during 'postinstall' and generate dist files
return { INFRA: {} };
}
}
/**
* Read the default values from .env file and return them as an array
* @returns Array of default infra configs
*/
export async function getDefaultInfraConfigs(): Promise<
{ name: InfraConfigEnum; value: string }[]
> {
const prisma = new PrismaService();
// Prepare rows for 'infra_config' table with default values (from .env) for each 'name'
const infraConfigDefaultObjs: { name: InfraConfigEnum; value: string }[] = [
{
name: InfraConfigEnum.MAILER_SMTP_URL,
value: process.env.MAILER_SMTP_URL,
},
{
name: InfraConfigEnum.MAILER_ADDRESS_FROM,
value: process.env.MAILER_ADDRESS_FROM,
},
{
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
value: process.env.GOOGLE_CLIENT_ID,
},
{
name: InfraConfigEnum.GOOGLE_CLIENT_SECRET,
value: process.env.GOOGLE_CLIENT_SECRET,
},
{
name: InfraConfigEnum.GOOGLE_CALLBACK_URL,
value: process.env.GOOGLE_CALLBACK_URL,
},
{
name: InfraConfigEnum.GOOGLE_SCOPE,
value: process.env.GOOGLE_SCOPE,
},
{
name: InfraConfigEnum.GITHUB_CLIENT_ID,
value: process.env.GITHUB_CLIENT_ID,
},
{
name: InfraConfigEnum.GITHUB_CLIENT_SECRET,
value: process.env.GITHUB_CLIENT_SECRET,
},
{
name: InfraConfigEnum.GITHUB_CALLBACK_URL,
value: process.env.GITHUB_CALLBACK_URL,
},
{
name: InfraConfigEnum.GITHUB_SCOPE,
value: process.env.GITHUB_SCOPE,
},
{
name: InfraConfigEnum.MICROSOFT_CLIENT_ID,
value: process.env.MICROSOFT_CLIENT_ID,
},
{
name: InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
value: process.env.MICROSOFT_CLIENT_SECRET,
},
{
name: InfraConfigEnum.MICROSOFT_CALLBACK_URL,
value: process.env.MICROSOFT_CALLBACK_URL,
},
{
name: InfraConfigEnum.MICROSOFT_SCOPE,
value: process.env.MICROSOFT_SCOPE,
},
{
name: InfraConfigEnum.MICROSOFT_TENANT,
value: process.env.MICROSOFT_TENANT,
},
{
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
value: getConfiguredSSOProviders(),
},
{
name: InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
value: false.toString(),
},
{
name: InfraConfigEnum.ANALYTICS_USER_ID,
value: generateAnalyticsUserId(),
},
{
name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
value: (await prisma.infraConfig.count()) === 0 ? 'true' : 'false',
},
];
return infraConfigDefaultObjs;
}
/**
* Get the missing entries in the 'infra_config' table
* @returns Array of InfraConfig
*/
export async function getMissingInfraConfigEntries() {
const prisma = new PrismaService();
const [dbInfraConfigs, infraConfigDefaultObjs] = await Promise.all([
prisma.infraConfig.findMany(),
getDefaultInfraConfigs(),
]);
const missingEntries = infraConfigDefaultObjs.filter(
(config) =>
!dbInfraConfigs.some((dbConfig) => dbConfig.name === config.name),
);
return missingEntries;
}
/**
* Verify if 'infra_config' table is loaded with all entries
* @returns boolean
*/
export async function isInfraConfigTablePopulated(): Promise<boolean> {
const prisma = new PrismaService();
try {
const propsRemainingToInsert = await getMissingInfraConfigEntries();
if (propsRemainingToInsert.length > 0) {
console.log(
'Infra Config table is not populated with all entries. Populating now...',
);
return false;
}
return true;
} catch (error) {
return false;
}
}
/**
* Stop the app after 5 seconds
* (Docker will re-start the app)
*/
export function stopApp() {
console.log('Stopping app in 5 seconds...');
setTimeout(() => {
console.log('Stopping app now...');
process.kill(process.pid, 'SIGTERM');
}, 5000);
}
/**
* Get the configured SSO providers
* @returns Array of configured SSO providers
*/
export function getConfiguredSSOProviders() {
const allowedAuthProviders: string[] =
process.env.VITE_ALLOWED_AUTH_PROVIDERS.split(',');
let configuredAuthProviders: string[] = [];
const addProviderIfConfigured = (provider) => {
const configParameters: string[] = AuthProviderConfigurations[provider];
const isConfigured = configParameters.every((configParameter) => {
return process.env[configParameter];
});
if (isConfigured) configuredAuthProviders.push(provider);
};
allowedAuthProviders.forEach((provider) => addProviderIfConfigured(provider));
if (configuredAuthProviders.length === 0) {
throwErr(AUTH_PROVIDER_NOT_CONFIGURED);
} else if (allowedAuthProviders.length !== configuredAuthProviders.length) {
const unConfiguredAuthProviders = allowedAuthProviders.filter(
(provider) => {
return !configuredAuthProviders.includes(provider);
},
);
console.log(
`${unConfiguredAuthProviders.join(
',',
)} SSO auth provider(s) are not configured properly. Do configure them from Admin Dashboard.`,
);
}
return configuredAuthProviders.join(',');
}
/**
* Generate a hashed valued for analytics
* @returns Generated hashed value
*/
export function generateAnalyticsUserId() {
const hashedUserID = randomBytes(20).toString('hex');
return hashedUserID;
}

View File

@@ -1,47 +0,0 @@
import { Controller, Get, HttpStatus, Put, UseGuards } from '@nestjs/common';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import { InfraConfigService } from './infra-config.service';
import * as E from 'fp-ts/Either';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import { RESTAdminGuard } from 'src/admin/guards/rest-admin.guard';
import { RESTError } from 'src/types/RESTError';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { throwHTTPErr } from 'src/utils';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'site', version: '1' })
export class SiteController {
constructor(private infraConfigService: InfraConfigService) {}
@Get('setup')
@UseGuards(JwtAuthGuard, RESTAdminGuard)
async fetchSetupInfo() {
const status = await this.infraConfigService.get(
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
);
if (E.isLeft(status))
throwHTTPErr(<RESTError>{
message: status.left,
statusCode: HttpStatus.NOT_FOUND,
});
return status.right;
}
@Put('setup')
@UseGuards(JwtAuthGuard, RESTAdminGuard)
async setSetupAsComplete() {
const res = await this.infraConfigService.update(
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
false.toString(),
false,
);
if (E.isLeft(res))
throwHTTPErr(<RESTError>{
message: res.left,
statusCode: HttpStatus.FORBIDDEN,
});
return res.right;
}
}

View File

@@ -1,29 +0,0 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { AuthProvider } from 'src/auth/helper';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { ServiceStatus } from './helper';
@ObjectType()
export class InfraConfig {
@Field({
description: 'Infra Config Name',
})
name: InfraConfigEnum;
@Field({
description: 'Infra Config Value',
})
value: string;
}
registerEnumType(InfraConfigEnum, {
name: 'InfraConfigEnum',
});
registerEnumType(AuthProvider, {
name: 'AuthProvider',
});
registerEnumType(ServiceStatus, {
name: 'ServiceStatus',
});

View File

@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { InfraConfigService } from './infra-config.service';
import { PrismaModule } from 'src/prisma/prisma.module';
import { SiteController } from './infra-config.controller';
@Module({
imports: [PrismaModule],
providers: [InfraConfigService],
exports: [InfraConfigService],
controllers: [SiteController],
})
export class InfraConfigModule {}

View File

@@ -1,223 +0,0 @@
import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfigService } from './infra-config.service';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import {
INFRA_CONFIG_NOT_FOUND,
INFRA_CONFIG_OPERATION_NOT_ALLOWED,
INFRA_CONFIG_UPDATE_FAILED,
} from 'src/errors';
import { ConfigService } from '@nestjs/config';
import * as helper from './helper';
import { InfraConfig as dbInfraConfig } from '@prisma/client';
import { InfraConfig } from './infra-config.model';
const mockPrisma = mockDeep<PrismaService>();
const mockConfigService = mockDeep<ConfigService>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const infraConfigService = new InfraConfigService(
mockPrisma,
mockConfigService,
);
const INITIALIZED_DATE_CONST = new Date();
const dbInfraConfigs: dbInfraConfig[] = [
{
id: '3',
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
value: 'abcdefghijkl',
active: true,
createdOn: INITIALIZED_DATE_CONST,
updatedOn: INITIALIZED_DATE_CONST,
},
{
id: '4',
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
value: 'google',
active: true,
createdOn: INITIALIZED_DATE_CONST,
updatedOn: INITIALIZED_DATE_CONST,
},
];
const infraConfigs: InfraConfig[] = [
{
name: dbInfraConfigs[0].name as InfraConfigEnum,
value: dbInfraConfigs[0].value,
},
{
name: dbInfraConfigs[1].name as InfraConfigEnum,
value: dbInfraConfigs[1].value,
},
];
beforeEach(() => {
mockReset(mockPrisma);
});
describe('InfraConfigService', () => {
describe('update', () => {
it('should update the infra config without backend server restart', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.update.mockResolvedValueOnce({
id: '',
name,
value,
active: true,
createdOn: new Date(),
updatedOn: new Date(),
});
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
const result = await infraConfigService.update(name, value);
expect(helper.stopApp).not.toHaveBeenCalled();
expect(result).toEqualRight({ name, value });
});
it('should update the infra config with backend server restart', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.update.mockResolvedValueOnce({
id: '',
name,
value,
active: true,
createdOn: new Date(),
updatedOn: new Date(),
});
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
const result = await infraConfigService.update(name, value, true);
expect(helper.stopApp).toHaveBeenCalledTimes(1);
expect(result).toEqualRight({ name, value });
});
it('should update the infra config', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.update.mockResolvedValueOnce({
id: '',
name,
value,
active: true,
createdOn: new Date(),
updatedOn: new Date(),
});
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
const result = await infraConfigService.update(name, value);
expect(result).toEqualRight({ name, value });
});
it('should pass correct params to prisma update', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
await infraConfigService.update(name, value);
expect(mockPrisma.infraConfig.update).toHaveBeenCalledWith({
where: { name },
data: { value },
});
expect(mockPrisma.infraConfig.update).toHaveBeenCalledTimes(1);
});
it('should throw an error if the infra config update failed', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.update.mockRejectedValueOnce('null');
const result = await infraConfigService.update(name, value);
expect(result).toEqualLeft(INFRA_CONFIG_UPDATE_FAILED);
});
});
describe('get', () => {
it('should get the infra config', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.findUniqueOrThrow.mockResolvedValueOnce({
id: '',
name,
value,
active: true,
createdOn: new Date(),
updatedOn: new Date(),
});
const result = await infraConfigService.get(name);
expect(result).toEqualRight({ name, value });
});
it('should pass correct params to prisma findUnique', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
await infraConfigService.get(name);
expect(mockPrisma.infraConfig.findUniqueOrThrow).toHaveBeenCalledWith({
where: { name },
});
expect(mockPrisma.infraConfig.findUniqueOrThrow).toHaveBeenCalledTimes(1);
});
it('should throw an error if the infra config does not exist', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
mockPrisma.infraConfig.findUniqueOrThrow.mockRejectedValueOnce('null');
const result = await infraConfigService.get(name);
expect(result).toEqualLeft(INFRA_CONFIG_NOT_FOUND);
});
});
describe('getMany', () => {
it('should throw error if any disallowed names are provided', async () => {
const disallowedNames = [InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS];
const result = await infraConfigService.getMany(disallowedNames);
expect(result).toEqualLeft(INFRA_CONFIG_OPERATION_NOT_ALLOWED);
});
it('should resolve right with disallowed names if `checkDisallowed` parameter passed', async () => {
const disallowedNames = [InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS];
const dbInfraConfigResponses = dbInfraConfigs.filter((dbConfig) =>
disallowedNames.includes(dbConfig.name as InfraConfigEnum),
);
mockPrisma.infraConfig.findMany.mockResolvedValueOnce(
dbInfraConfigResponses,
);
const result = await infraConfigService.getMany(disallowedNames, false);
expect(result).toEqualRight(
infraConfigs.filter((i) => disallowedNames.includes(i.name)),
);
});
it('should return right with infraConfigs if Prisma query succeeds', async () => {
const allowedNames = [InfraConfigEnum.GOOGLE_CLIENT_ID];
const dbInfraConfigResponses = dbInfraConfigs.filter((dbConfig) =>
allowedNames.includes(dbConfig.name as InfraConfigEnum),
);
mockPrisma.infraConfig.findMany.mockResolvedValueOnce(
dbInfraConfigResponses,
);
const result = await infraConfigService.getMany(allowedNames);
expect(result).toEqualRight(
infraConfigs.filter((i) => allowedNames.includes(i.name)),
);
});
});
});

View File

@@ -1,423 +0,0 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InfraConfig } from './infra-config.model';
import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfig as DBInfraConfig } from '@prisma/client';
import * as E from 'fp-ts/Either';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import {
AUTH_PROVIDER_NOT_SPECIFIED,
DATABASE_TABLE_NOT_EXIST,
INFRA_CONFIG_INVALID_INPUT,
INFRA_CONFIG_NOT_FOUND,
INFRA_CONFIG_RESET_FAILED,
INFRA_CONFIG_UPDATE_FAILED,
INFRA_CONFIG_SERVICE_NOT_CONFIGURED,
INFRA_CONFIG_OPERATION_NOT_ALLOWED,
} from 'src/errors';
import {
throwErr,
validateSMTPEmail,
validateSMTPUrl,
validateUrl,
} from 'src/utils';
import { ConfigService } from '@nestjs/config';
import {
ServiceStatus,
getDefaultInfraConfigs,
getMissingInfraConfigEntries,
stopApp,
} from './helper';
import { EnableAndDisableSSOArgs, InfraConfigArgs } from './input-args';
import { AuthProvider } from 'src/auth/helper';
@Injectable()
export class InfraConfigService implements OnModuleInit {
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
) {}
// Following fields are not updatable by `infraConfigs` Mutation. Use dedicated mutations for these fields instead.
EXCLUDE_FROM_UPDATE_CONFIGS = [
InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
InfraConfigEnum.ANALYTICS_USER_ID,
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
];
// Following fields can not be fetched by `infraConfigs` Query. Use dedicated queries for these fields instead.
EXCLUDE_FROM_FETCH_CONFIGS = [
InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
InfraConfigEnum.ANALYTICS_USER_ID,
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
];
async onModuleInit() {
await this.initializeInfraConfigTable();
}
/**
* Initialize the 'infra_config' table with values from .env
* @description This function create rows 'infra_config' in very first time (only once)
*/
async initializeInfraConfigTable() {
try {
const propsToInsert = await getMissingInfraConfigEntries();
if (propsToInsert.length > 0) {
await this.prisma.infraConfig.createMany({ data: propsToInsert });
stopApp();
}
} catch (error) {
if (error.code === 'P1001') {
// Prisma error code for 'Can't reach at database server'
// We're not throwing error here because we want to allow the app to run 'pnpm install'
} else if (error.code === 'P2021') {
// Prisma error code for 'Table does not exist'
throwErr(DATABASE_TABLE_NOT_EXIST);
} else {
throwErr(error);
}
}
}
/**
* Typecast a database InfraConfig to a InfraConfig model
* @param dbInfraConfig database InfraConfig
* @returns InfraConfig model
*/
cast(dbInfraConfig: DBInfraConfig) {
return <InfraConfig>{
name: dbInfraConfig.name,
value: dbInfraConfig.value ?? '',
};
}
/**
* Get all the InfraConfigs as map
* @returns InfraConfig map
*/
async getInfraConfigsMap() {
const infraConfigs = await this.prisma.infraConfig.findMany();
const infraConfigMap: Record<string, string> = {};
infraConfigs.forEach((config) => {
infraConfigMap[config.name] = config.value;
});
return infraConfigMap;
}
/**
* Update InfraConfig by name
* @param name Name of the InfraConfig
* @param value Value of the InfraConfig
* @param restartEnabled If true, restart the app after updating the InfraConfig
* @returns InfraConfig model
*/
async update(name: InfraConfigEnum, value: string, restartEnabled = false) {
const isValidate = this.validateEnvValues([{ name, value }]);
if (E.isLeft(isValidate)) return E.left(isValidate.left);
try {
const infraConfig = await this.prisma.infraConfig.update({
where: { name },
data: { value },
});
if (restartEnabled) stopApp();
return E.right(this.cast(infraConfig));
} catch (e) {
return E.left(INFRA_CONFIG_UPDATE_FAILED);
}
}
/**
* Update InfraConfigs by name
* @param infraConfigs InfraConfigs to update
* @returns InfraConfig model
*/
async updateMany(infraConfigs: InfraConfigArgs[]) {
for (let i = 0; i < infraConfigs.length; i++) {
if (this.EXCLUDE_FROM_UPDATE_CONFIGS.includes(infraConfigs[i].name))
return E.left(INFRA_CONFIG_OPERATION_NOT_ALLOWED);
}
const isValidate = this.validateEnvValues(infraConfigs);
if (E.isLeft(isValidate)) return E.left(isValidate.left);
try {
await this.prisma.$transaction(async (tx) => {
for (let i = 0; i < infraConfigs.length; i++) {
await tx.infraConfig.update({
where: { name: infraConfigs[i].name },
data: { value: infraConfigs[i].value },
});
}
});
stopApp();
return E.right(infraConfigs);
} catch (e) {
return E.left(INFRA_CONFIG_UPDATE_FAILED);
}
}
/**
* Check if the service is configured or not
* @param service Service can be Auth Provider, Mailer, Audit Log etc.
* @param configMap Map of all the infra configs
* @returns Either true or false
*/
isServiceConfigured(
service: AuthProvider,
configMap: Record<string, string>,
) {
switch (service) {
case AuthProvider.GOOGLE:
return (
configMap.GOOGLE_CLIENT_ID &&
configMap.GOOGLE_CLIENT_SECRET &&
configMap.GOOGLE_CALLBACK_URL &&
configMap.GOOGLE_SCOPE
);
case AuthProvider.GITHUB:
return (
configMap.GITHUB_CLIENT_ID &&
configMap.GITHUB_CLIENT_SECRET &&
configMap.GITHUB_CALLBACK_URL &&
configMap.GITHUB_SCOPE
);
case AuthProvider.MICROSOFT:
return (
configMap.MICROSOFT_CLIENT_ID &&
configMap.MICROSOFT_CLIENT_SECRET &&
configMap.MICROSOFT_CALLBACK_URL &&
configMap.MICROSOFT_SCOPE &&
configMap.MICROSOFT_TENANT
);
case AuthProvider.EMAIL:
return configMap.MAILER_SMTP_URL && configMap.MAILER_ADDRESS_FROM;
default:
return false;
}
}
/**
* Enable or Disable Analytics Collection
*
* @param status Status to enable or disable
* @returns Boolean of status of analytics collection
*/
async toggleAnalyticsCollection(status: ServiceStatus) {
const isUpdated = await this.update(
InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
status === ServiceStatus.ENABLE ? 'true' : 'false',
);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(isUpdated.right.value === 'true');
}
/**
* Enable or Disable SSO for login/signup
* @param provider Auth Provider to enable or disable
* @param status Status to enable or disable
* @returns Either true or an error
*/
async enableAndDisableSSO(providerInfo: EnableAndDisableSSOArgs[]) {
const allowedAuthProviders = this.configService
.get<string>('INFRA.VITE_ALLOWED_AUTH_PROVIDERS')
.split(',');
let updatedAuthProviders = allowedAuthProviders;
const infraConfigMap = await this.getInfraConfigsMap();
providerInfo.forEach(({ provider, status }) => {
if (status === ServiceStatus.ENABLE) {
const isConfigured = this.isServiceConfigured(provider, infraConfigMap);
if (!isConfigured) {
throwErr(INFRA_CONFIG_SERVICE_NOT_CONFIGURED);
}
updatedAuthProviders.push(provider);
} else if (status === ServiceStatus.DISABLE) {
updatedAuthProviders = updatedAuthProviders.filter(
(p) => p !== provider,
);
}
});
updatedAuthProviders = [...new Set(updatedAuthProviders)];
if (updatedAuthProviders.length === 0) {
return E.left(AUTH_PROVIDER_NOT_SPECIFIED);
}
const isUpdated = await this.update(
InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
updatedAuthProviders.join(','),
true,
);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(true);
}
/**
* Get InfraConfig by name
* @param name Name of the InfraConfig
* @returns InfraConfig model
*/
async get(name: InfraConfigEnum) {
try {
const infraConfig = await this.prisma.infraConfig.findUniqueOrThrow({
where: { name },
});
return E.right(this.cast(infraConfig));
} catch (e) {
return E.left(INFRA_CONFIG_NOT_FOUND);
}
}
/**
* Get InfraConfigs by names
* @param names Names of the InfraConfigs
* @param checkDisallowedKeys If true, check if the names are allowed to fetch by client
* @returns InfraConfig model
*/
async getMany(names: InfraConfigEnum[], checkDisallowedKeys: boolean = true) {
if (checkDisallowedKeys) {
// Check if the names are allowed to fetch by client
for (let i = 0; i < names.length; i++) {
if (this.EXCLUDE_FROM_FETCH_CONFIGS.includes(names[i]))
return E.left(INFRA_CONFIG_OPERATION_NOT_ALLOWED);
}
}
try {
const infraConfigs = await this.prisma.infraConfig.findMany({
where: { name: { in: names } },
});
return E.right(infraConfigs.map((p) => this.cast(p)));
} catch (e) {
return E.left(INFRA_CONFIG_NOT_FOUND);
}
}
/**
* Get allowed auth providers for login/signup
* @returns string[]
*/
getAllowedAuthProviders() {
return this.configService
.get<string>('INFRA.VITE_ALLOWED_AUTH_PROVIDERS')
.split(',');
}
/**
* Reset all the InfraConfigs to their default values (from .env)
*/
async reset() {
// These are all the infra-configs that should not be reset
const RESET_EXCLUSION_LIST = [
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
InfraConfigEnum.ANALYTICS_USER_ID,
InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
];
try {
const infraConfigDefaultObjs = await getDefaultInfraConfigs();
const updatedInfraConfigDefaultObjs = infraConfigDefaultObjs.filter(
(p) => RESET_EXCLUSION_LIST.includes(p.name) === false,
);
await this.prisma.infraConfig.deleteMany({
where: {
name: {
in: updatedInfraConfigDefaultObjs.map((p) => p.name),
},
},
});
await this.prisma.infraConfig.createMany({
data: updatedInfraConfigDefaultObjs,
});
stopApp();
return E.right(true);
} catch (e) {
return E.left(INFRA_CONFIG_RESET_FAILED);
}
}
/**
* Validate the values of the InfraConfigs
*/
validateEnvValues(
infraConfigs: {
name: InfraConfigEnum;
value: string;
}[],
) {
for (let i = 0; i < infraConfigs.length; i++) {
switch (infraConfigs[i].name) {
case InfraConfigEnum.MAILER_SMTP_URL:
const isValidUrl = validateSMTPUrl(infraConfigs[i].value);
if (!isValidUrl) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MAILER_ADDRESS_FROM:
const isValidEmail = validateSMTPEmail(infraConfigs[i].value);
if (!isValidEmail) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GOOGLE_CLIENT_ID:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GOOGLE_CLIENT_SECRET:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GOOGLE_CALLBACK_URL:
if (!validateUrl(infraConfigs[i].value))
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GOOGLE_SCOPE:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GITHUB_CLIENT_ID:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GITHUB_CLIENT_SECRET:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GITHUB_CALLBACK_URL:
if (!validateUrl(infraConfigs[i].value))
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GITHUB_SCOPE:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_CLIENT_ID:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_CLIENT_SECRET:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_CALLBACK_URL:
if (!validateUrl(infraConfigs[i].value))
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_SCOPE:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_TENANT:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
default:
break;
}
}
return E.right(true);
}
}

View File

@@ -1,30 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { ServiceStatus } from './helper';
import { AuthProvider } from 'src/auth/helper';
@InputType()
export class InfraConfigArgs {
@Field(() => InfraConfigEnum, {
description: 'Infra Config Name',
})
name: InfraConfigEnum;
@Field({
description: 'Infra Config Value',
})
value: string;
}
@InputType()
export class EnableAndDisableSSOArgs {
@Field(() => AuthProvider, {
description: 'Auth Provider',
})
provider: AuthProvider;
@Field(() => ServiceStatus, {
description: 'Auth Provider Status',
})
status: ServiceStatus;
}

View File

@@ -8,7 +8,7 @@ export type MailDescription = {
}; };
export type UserMagicLinkMailDescription = { export type UserMagicLinkMailDescription = {
template: 'user-invitation'; template: 'code-your-own';
variables: { variables: {
inviteeEmail: string; inviteeEmail: string;
magicLink: string; magicLink: string;
@@ -16,7 +16,7 @@ export type UserMagicLinkMailDescription = {
}; };
export type AdminUserInvitationMailDescription = { export type AdminUserInvitationMailDescription = {
template: 'user-invitation'; template: 'code-your-own';
variables: { variables: {
inviteeEmail: string; inviteeEmail: string;
magicLink: string; magicLink: string;

View File

@@ -1,4 +1,4 @@
import { Global, Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MailerModule as NestMailerModule } from '@nestjs-modules/mailer'; import { MailerModule as NestMailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter'; import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { MailerService } from './mailer.service'; import { MailerService } from './mailer.service';
@@ -7,42 +7,24 @@ import {
MAILER_FROM_ADDRESS_UNDEFINED, MAILER_FROM_ADDRESS_UNDEFINED,
MAILER_SMTP_URL_UNDEFINED, MAILER_SMTP_URL_UNDEFINED,
} from 'src/errors'; } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { loadInfraConfiguration } from 'src/infra-config/helper';
@Global()
@Module({ @Module({
imports: [], imports: [
NestMailerModule.forRoot({
transport:
process.env.MAILER_SMTP_URL ?? throwErr(MAILER_SMTP_URL_UNDEFINED),
defaults: {
from:
process.env.MAILER_ADDRESS_FROM ??
throwErr(MAILER_FROM_ADDRESS_UNDEFINED),
},
template: {
dir: __dirname + '/templates',
adapter: new HandlebarsAdapter(),
},
}),
],
providers: [MailerService], providers: [MailerService],
exports: [MailerService], exports: [MailerService],
}) })
export class MailerModule { export class MailerModule {}
static async register() {
const env = await loadInfraConfiguration();
let mailerSmtpUrl = env.INFRA.MAILER_SMTP_URL;
let mailerAddressFrom = env.INFRA.MAILER_ADDRESS_FROM;
if (!env.INFRA.MAILER_SMTP_URL || !env.INFRA.MAILER_ADDRESS_FROM) {
const config = new ConfigService();
mailerSmtpUrl = config.get('MAILER_SMTP_URL');
mailerAddressFrom = config.get('MAILER_ADDRESS_FROM');
}
return {
module: MailerModule,
imports: [
NestMailerModule.forRoot({
transport: mailerSmtpUrl ?? throwErr(MAILER_SMTP_URL_UNDEFINED),
defaults: {
from: mailerAddressFrom ?? throwErr(MAILER_FROM_ADDRESS_UNDEFINED),
},
template: {
dir: __dirname + '/templates',
adapter: new HandlebarsAdapter(),
},
}),
],
};
}
}

View File

@@ -25,9 +25,9 @@ export class MailerService {
): string { ): string {
switch (mailDesc.template) { switch (mailDesc.template) {
case 'team-invitation': case 'team-invitation':
return `A user has invited you to join a team workspace in Hoppscotch`; return `${mailDesc.variables.invitee} invited you to join ${mailDesc.variables.invite_team_name} in Hoppscotch`;
case 'user-invitation': case 'code-your-own':
return 'Sign in to Hoppscotch'; return 'Sign in to Hoppscotch';
} }
} }

View File

@@ -14,7 +14,7 @@
--> -->
<style type="text/css" rel="stylesheet" media="all"> <style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */ /* Base ------------------------------ */
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap"); @import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
body { body {
width: 100% !important; width: 100% !important;
@@ -22,25 +22,19 @@
margin: 0; margin: 0;
-webkit-text-size-adjust: none; -webkit-text-size-adjust: none;
} }
a { a {
color: #3869D4; color: #3869D4;
} }
a.nohighlight {
color: inherit !important;
text-decoration: none !important;
cursor: default !important;
}
a img { a img {
border: none; border: none;
} }
td { td {
word-break: break-word; word-break: break-word;
} }
.preheader { .preheader {
display: none !important; display: none !important;
visibility: hidden; visibility: hidden;
@@ -53,13 +47,13 @@
overflow: hidden; overflow: hidden;
} }
/* Type ------------------------------ */ /* Type ------------------------------ */
body, body,
td, td,
th { th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
} }
h1 { h1 {
margin-top: 0; margin-top: 0;
color: #333333; color: #333333;
@@ -67,7 +61,7 @@
font-weight: bold; font-weight: bold;
text-align: left; text-align: left;
} }
h2 { h2 {
margin-top: 0; margin-top: 0;
color: #333333; color: #333333;
@@ -75,7 +69,7 @@
font-weight: bold; font-weight: bold;
text-align: left; text-align: left;
} }
h3 { h3 {
margin-top: 0; margin-top: 0;
color: #333333; color: #333333;
@@ -83,12 +77,12 @@
font-weight: bold; font-weight: bold;
text-align: left; text-align: left;
} }
td, td,
th { th {
font-size: 16px; font-size: 16px;
} }
p, p,
ul, ul,
ol, ol,
@@ -97,25 +91,25 @@
font-size: 16px; font-size: 16px;
line-height: 1.625; line-height: 1.625;
} }
p.sub { p.sub {
font-size: 13px; font-size: 13px;
} }
/* Utilities ------------------------------ */ /* Utilities ------------------------------ */
.align-right { .align-right {
text-align: right; text-align: right;
} }
.align-left { .align-left {
text-align: left; text-align: left;
} }
.align-center { .align-center {
text-align: center; text-align: center;
} }
/* Buttons ------------------------------ */ /* Buttons ------------------------------ */
.button { .button {
background-color: #3869D4; background-color: #3869D4;
border-top: 10px solid #3869D4; border-top: 10px solid #3869D4;
@@ -130,7 +124,7 @@
-webkit-text-size-adjust: none; -webkit-text-size-adjust: none;
box-sizing: border-box; box-sizing: border-box;
} }
.button--green { .button--green {
background-color: #22BC66; background-color: #22BC66;
border-top: 10px solid #22BC66; border-top: 10px solid #22BC66;
@@ -138,7 +132,7 @@
border-bottom: 10px solid #22BC66; border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66; border-left: 18px solid #22BC66;
} }
.button--red { .button--red {
background-color: #FF6136; background-color: #FF6136;
border-top: 10px solid #FF6136; border-top: 10px solid #FF6136;
@@ -146,7 +140,7 @@
border-bottom: 10px solid #FF6136; border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136; border-left: 18px solid #FF6136;
} }
@media only screen and (max-width: 500px) { @media only screen and (max-width: 500px) {
.button { .button {
width: 100% !important; width: 100% !important;
@@ -154,21 +148,21 @@
} }
} }
/* Attribute list ------------------------------ */ /* Attribute list ------------------------------ */
.attributes { .attributes {
margin: 0 0 21px; margin: 0 0 21px;
} }
.attributes_content { .attributes_content {
background-color: #F4F4F7; background-color: #F4F4F7;
padding: 16px; padding: 16px;
} }
.attributes_item { .attributes_item {
padding: 0; padding: 0;
} }
/* Related Items ------------------------------ */ /* Related Items ------------------------------ */
.related { .related {
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -177,31 +171,31 @@
-premailer-cellpadding: 0; -premailer-cellpadding: 0;
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
} }
.related_item { .related_item {
padding: 10px 0; padding: 10px 0;
color: #CBCCCF; color: #CBCCCF;
font-size: 15px; font-size: 15px;
line-height: 18px; line-height: 18px;
} }
.related_item-title { .related_item-title {
display: block; display: block;
margin: .5em 0 0; margin: .5em 0 0;
} }
.related_item-thumb { .related_item-thumb {
display: block; display: block;
padding-bottom: 10px; padding-bottom: 10px;
} }
.related_heading { .related_heading {
border-top: 1px solid #CBCCCF; border-top: 1px solid #CBCCCF;
text-align: center; text-align: center;
padding: 25px 0 10px; padding: 25px 0 10px;
} }
/* Discount Code ------------------------------ */ /* Discount Code ------------------------------ */
.discount { .discount {
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -212,33 +206,33 @@
background-color: #F4F4F7; background-color: #F4F4F7;
border: 2px dashed #CBCCCF; border: 2px dashed #CBCCCF;
} }
.discount_heading { .discount_heading {
text-align: center; text-align: center;
} }
.discount_body { .discount_body {
text-align: center; text-align: center;
font-size: 15px; font-size: 15px;
} }
/* Social Icons ------------------------------ */ /* Social Icons ------------------------------ */
.social { .social {
width: auto; width: auto;
} }
.social td { .social td {
padding: 0; padding: 0;
width: auto; width: auto;
} }
.social_icon { .social_icon {
height: 20px; height: 20px;
margin: 0 8px 10px 8px; margin: 0 8px 10px 8px;
padding: 0; padding: 0;
} }
/* Data table ------------------------------ */ /* Data table ------------------------------ */
.purchase { .purchase {
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -247,7 +241,7 @@
-premailer-cellpadding: 0; -premailer-cellpadding: 0;
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
} }
.purchase_content { .purchase_content {
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -256,50 +250,50 @@
-premailer-cellpadding: 0; -premailer-cellpadding: 0;
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
} }
.purchase_item { .purchase_item {
padding: 10px 0; padding: 10px 0;
color: #51545E; color: #51545E;
font-size: 15px; font-size: 15px;
line-height: 18px; line-height: 18px;
} }
.purchase_heading { .purchase_heading {
padding-bottom: 8px; padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC; border-bottom: 1px solid #EAEAEC;
} }
.purchase_heading p { .purchase_heading p {
margin: 0; margin: 0;
color: #85878E; color: #85878E;
font-size: 12px; font-size: 12px;
} }
.purchase_footer { .purchase_footer {
padding-top: 15px; padding-top: 15px;
border-top: 1px solid #EAEAEC; border-top: 1px solid #EAEAEC;
} }
.purchase_total { .purchase_total {
margin: 0; margin: 0;
text-align: right; text-align: right;
font-weight: bold; font-weight: bold;
color: #333333; color: #333333;
} }
.purchase_total--label { .purchase_total--label {
padding: 0 15px 0 0; padding: 0 15px 0 0;
} }
body { body {
background-color: #F2F4F6; background-color: #F2F4F6;
color: #51545E; color: #51545E;
} }
p { p {
color: #51545E; color: #51545E;
} }
.email-wrapper { .email-wrapper {
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -309,7 +303,7 @@
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
background-color: #F2F4F6; background-color: #F2F4F6;
} }
.email-content { .email-content {
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -319,16 +313,16 @@
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
} }
/* Masthead ----------------------- */ /* Masthead ----------------------- */
.email-masthead { .email-masthead {
padding: 25px 0; padding: 25px 0;
text-align: center; text-align: center;
} }
.email-masthead_logo { .email-masthead_logo {
width: 94px; width: 94px;
} }
.email-masthead_name { .email-masthead_name {
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
@@ -337,7 +331,7 @@
text-shadow: 0 1px 0 white; text-shadow: 0 1px 0 white;
} }
/* Body ------------------------------ */ /* Body ------------------------------ */
.email-body { .email-body {
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -346,7 +340,7 @@
-premailer-cellpadding: 0; -premailer-cellpadding: 0;
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
} }
.email-body_inner { .email-body_inner {
width: 570px; width: 570px;
margin: 0 auto; margin: 0 auto;
@@ -356,7 +350,7 @@
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
background-color: #FFFFFF; background-color: #FFFFFF;
} }
.email-footer { .email-footer {
width: 570px; width: 570px;
margin: 0 auto; margin: 0 auto;
@@ -366,11 +360,11 @@
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
text-align: center; text-align: center;
} }
.email-footer p { .email-footer p {
color: #A8AAAF; color: #A8AAAF;
} }
.body-action { .body-action {
width: 100%; width: 100%;
margin: 30px auto; margin: 30px auto;
@@ -380,25 +374,25 @@
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
text-align: center; text-align: center;
} }
.body-sub { .body-sub {
margin-top: 25px; margin-top: 25px;
padding-top: 25px; padding-top: 25px;
border-top: 1px solid #EAEAEC; border-top: 1px solid #EAEAEC;
} }
.content-cell { .content-cell {
padding: 45px; padding: 45px;
} }
/*Media Queries ------------------------------ */ /*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
.email-body_inner, .email-body_inner,
.email-footer { .email-footer {
width: 100% !important; width: 100% !important;
} }
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body, body,
.email-body, .email-body,

View File

@@ -14,7 +14,7 @@
--> -->
<style type="text/css" rel="stylesheet" media="all"> <style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */ /* Base ------------------------------ */
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap"); @import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
body { body {
width: 100% !important; width: 100% !important;
@@ -22,25 +22,19 @@
margin: 0; margin: 0;
-webkit-text-size-adjust: none; -webkit-text-size-adjust: none;
} }
a { a {
color: #3869D4; color: #3869D4;
} }
a.nohighlight {
color: inherit !important;
text-decoration: none !important;
cursor: default !important;
}
a img { a img {
border: none; border: none;
} }
td { td {
word-break: break-word; word-break: break-word;
} }
.preheader { .preheader {
display: none !important; display: none !important;
visibility: hidden; visibility: hidden;
@@ -53,13 +47,13 @@
overflow: hidden; overflow: hidden;
} }
/* Type ------------------------------ */ /* Type ------------------------------ */
body, body,
td, td,
th { th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
} }
h1 { h1 {
margin-top: 0; margin-top: 0;
color: #333333; color: #333333;
@@ -67,7 +61,7 @@
font-weight: bold; font-weight: bold;
text-align: left; text-align: left;
} }
h2 { h2 {
margin-top: 0; margin-top: 0;
color: #333333; color: #333333;
@@ -75,7 +69,7 @@
font-weight: bold; font-weight: bold;
text-align: left; text-align: left;
} }
h3 { h3 {
margin-top: 0; margin-top: 0;
color: #333333; color: #333333;
@@ -83,12 +77,12 @@
font-weight: bold; font-weight: bold;
text-align: left; text-align: left;
} }
td, td,
th { th {
font-size: 16px; font-size: 16px;
} }
p, p,
ul, ul,
ol, ol,
@@ -97,25 +91,25 @@
font-size: 16px; font-size: 16px;
line-height: 1.625; line-height: 1.625;
} }
p.sub { p.sub {
font-size: 13px; font-size: 13px;
} }
/* Utilities ------------------------------ */ /* Utilities ------------------------------ */
.align-right { .align-right {
text-align: right; text-align: right;
} }
.align-left { .align-left {
text-align: left; text-align: left;
} }
.align-center { .align-center {
text-align: center; text-align: center;
} }
/* Buttons ------------------------------ */ /* Buttons ------------------------------ */
.button { .button {
background-color: #3869D4; background-color: #3869D4;
border-top: 10px solid #3869D4; border-top: 10px solid #3869D4;
@@ -130,7 +124,7 @@
-webkit-text-size-adjust: none; -webkit-text-size-adjust: none;
box-sizing: border-box; box-sizing: border-box;
} }
.button--green { .button--green {
background-color: #22BC66; background-color: #22BC66;
border-top: 10px solid #22BC66; border-top: 10px solid #22BC66;
@@ -138,7 +132,7 @@
border-bottom: 10px solid #22BC66; border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66; border-left: 18px solid #22BC66;
} }
.button--red { .button--red {
background-color: #FF6136; background-color: #FF6136;
border-top: 10px solid #FF6136; border-top: 10px solid #FF6136;
@@ -146,7 +140,7 @@
border-bottom: 10px solid #FF6136; border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136; border-left: 18px solid #FF6136;
} }
@media only screen and (max-width: 500px) { @media only screen and (max-width: 500px) {
.button { .button {
width: 100% !important; width: 100% !important;
@@ -154,21 +148,21 @@
} }
} }
/* Attribute list ------------------------------ */ /* Attribute list ------------------------------ */
.attributes { .attributes {
margin: 0 0 21px; margin: 0 0 21px;
} }
.attributes_content { .attributes_content {
background-color: #F4F4F7; background-color: #F4F4F7;
padding: 16px; padding: 16px;
} }
.attributes_item { .attributes_item {
padding: 0; padding: 0;
} }
/* Related Items ------------------------------ */ /* Related Items ------------------------------ */
.related { .related {
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -177,31 +171,31 @@
-premailer-cellpadding: 0; -premailer-cellpadding: 0;
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
} }
.related_item { .related_item {
padding: 10px 0; padding: 10px 0;
color: #CBCCCF; color: #CBCCCF;
font-size: 15px; font-size: 15px;
line-height: 18px; line-height: 18px;
} }
.related_item-title { .related_item-title {
display: block; display: block;
margin: .5em 0 0; margin: .5em 0 0;
} }
.related_item-thumb { .related_item-thumb {
display: block; display: block;
padding-bottom: 10px; padding-bottom: 10px;
} }
.related_heading { .related_heading {
border-top: 1px solid #CBCCCF; border-top: 1px solid #CBCCCF;
text-align: center; text-align: center;
padding: 25px 0 10px; padding: 25px 0 10px;
} }
/* Discount Code ------------------------------ */ /* Discount Code ------------------------------ */
.discount { .discount {
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -212,33 +206,33 @@
background-color: #F4F4F7; background-color: #F4F4F7;
border: 2px dashed #CBCCCF; border: 2px dashed #CBCCCF;
} }
.discount_heading { .discount_heading {
text-align: center; text-align: center;
} }
.discount_body { .discount_body {
text-align: center; text-align: center;
font-size: 15px; font-size: 15px;
} }
/* Social Icons ------------------------------ */ /* Social Icons ------------------------------ */
.social { .social {
width: auto; width: auto;
} }
.social td { .social td {
padding: 0; padding: 0;
width: auto; width: auto;
} }
.social_icon { .social_icon {
height: 20px; height: 20px;
margin: 0 8px 10px 8px; margin: 0 8px 10px 8px;
padding: 0; padding: 0;
} }
/* Data table ------------------------------ */ /* Data table ------------------------------ */
.purchase { .purchase {
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -247,7 +241,7 @@
-premailer-cellpadding: 0; -premailer-cellpadding: 0;
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
} }
.purchase_content { .purchase_content {
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -256,50 +250,50 @@
-premailer-cellpadding: 0; -premailer-cellpadding: 0;
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
} }
.purchase_item { .purchase_item {
padding: 10px 0; padding: 10px 0;
color: #51545E; color: #51545E;
font-size: 15px; font-size: 15px;
line-height: 18px; line-height: 18px;
} }
.purchase_heading { .purchase_heading {
padding-bottom: 8px; padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC; border-bottom: 1px solid #EAEAEC;
} }
.purchase_heading p { .purchase_heading p {
margin: 0; margin: 0;
color: #85878E; color: #85878E;
font-size: 12px; font-size: 12px;
} }
.purchase_footer { .purchase_footer {
padding-top: 15px; padding-top: 15px;
border-top: 1px solid #EAEAEC; border-top: 1px solid #EAEAEC;
} }
.purchase_total { .purchase_total {
margin: 0; margin: 0;
text-align: right; text-align: right;
font-weight: bold; font-weight: bold;
color: #333333; color: #333333;
} }
.purchase_total--label { .purchase_total--label {
padding: 0 15px 0 0; padding: 0 15px 0 0;
} }
body { body {
background-color: #F2F4F6; background-color: #F2F4F6;
color: #51545E; color: #51545E;
} }
p { p {
color: #51545E; color: #51545E;
} }
.email-wrapper { .email-wrapper {
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -309,7 +303,7 @@
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
background-color: #F2F4F6; background-color: #F2F4F6;
} }
.email-content { .email-content {
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -319,16 +313,16 @@
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
} }
/* Masthead ----------------------- */ /* Masthead ----------------------- */
.email-masthead { .email-masthead {
padding: 25px 0; padding: 25px 0;
text-align: center; text-align: center;
} }
.email-masthead_logo { .email-masthead_logo {
width: 94px; width: 94px;
} }
.email-masthead_name { .email-masthead_name {
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
@@ -337,7 +331,7 @@
text-shadow: 0 1px 0 white; text-shadow: 0 1px 0 white;
} }
/* Body ------------------------------ */ /* Body ------------------------------ */
.email-body { .email-body {
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -346,7 +340,7 @@
-premailer-cellpadding: 0; -premailer-cellpadding: 0;
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
} }
.email-body_inner { .email-body_inner {
width: 570px; width: 570px;
margin: 0 auto; margin: 0 auto;
@@ -356,7 +350,7 @@
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
background-color: #FFFFFF; background-color: #FFFFFF;
} }
.email-footer { .email-footer {
width: 570px; width: 570px;
margin: 0 auto; margin: 0 auto;
@@ -366,11 +360,11 @@
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
text-align: center; text-align: center;
} }
.email-footer p { .email-footer p {
color: #A8AAAF; color: #A8AAAF;
} }
.body-action { .body-action {
width: 100%; width: 100%;
margin: 30px auto; margin: 30px auto;
@@ -380,25 +374,25 @@
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
text-align: center; text-align: center;
} }
.body-sub { .body-sub {
margin-top: 25px; margin-top: 25px;
padding-top: 25px; padding-top: 25px;
border-top: 1px solid #EAEAEC; border-top: 1px solid #EAEAEC;
} }
.content-cell { .content-cell {
padding: 45px; padding: 45px;
} }
/*Media Queries ------------------------------ */ /*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
.email-body_inner, .email-body_inner,
.email-footer { .email-footer {
width: 100% !important; width: 100% !important;
} }
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body, body,
.email-body, .email-body,
@@ -464,7 +458,7 @@
<td class="content-cell"> <td class="content-cell">
<div class="f-fallback"> <div class="f-fallback">
<h1>Hi there,</h1> <h1>Hi there,</h1>
<p><a class="nohighlight" name="invitee" href="#">{{invitee}}</a> with <a class="nohighlight" name="invite_team_name" href="#">{{invite_team_name}}</a> has invited you to use Hoppscotch to collaborate with them. Click the button below to set up your account and get started:</p> <p>{{invitee}} with {{invite_team_name}} has invited you to use Hoppscotch to collaborate with them. Click the button below to set up your account and get started:</p>
<!-- Action --> <!-- Action -->
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0"> <table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
<tr> <tr>
@@ -490,7 +484,7 @@
Welcome aboard, <br /> Welcome aboard, <br />
Your friends at Hoppscotch Your friends at Hoppscotch
</p> </p>
<p><strong>P.S.</strong> If you don't associate with <a class="nohighlight" name="invitee" href="#">{{invitee}}</a> or <a class="nohighlight" name="invite_team_name" href="#">{{invite_team_name}}</a>, just ignore this email.</p> <p><strong>P.S.</strong> If you don't associate with {{invitee}} or {{invite_team_name}}, just ignore this email.</p>
<!-- Sub copy --> <!-- Sub copy -->
<table class="body-sub"> <table class="body-sub">
<tr> <tr>

View File

@@ -6,24 +6,18 @@ import { VersioningType } from '@nestjs/common';
import * as session from 'express-session'; import * as session from 'express-session';
import { emitGQLSchemaFile } from './gql-schema'; import { emitGQLSchemaFile } from './gql-schema';
import { checkEnvironmentAuthProvider } from './utils'; import { checkEnvironmentAuthProvider } from './utils';
import { ConfigService } from '@nestjs/config';
async function bootstrap() { async function bootstrap() {
console.log(`Running in production: ${process.env.PRODUCTION}`);
console.log(`Port: ${process.env.PORT}`);
checkEnvironmentAuthProvider();
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
console.log(`Running in production: ${configService.get('PRODUCTION')}`);
console.log(`Port: ${configService.get('PORT')}`);
checkEnvironmentAuthProvider(
configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS') ??
configService.get('VITE_ALLOWED_AUTH_PROVIDERS'),
);
app.use( app.use(
session({ session({
secret: configService.get('SESSION_SECRET'), secret: process.env.SESSION_SECRET,
}), }),
); );
@@ -34,18 +28,18 @@ async function bootstrap() {
}), }),
); );
if (configService.get('PRODUCTION') === 'false') { if (process.env.PRODUCTION === 'false') {
console.log('Enabling CORS with development settings'); console.log('Enabling CORS with development settings');
app.enableCors({ app.enableCors({
origin: configService.get('WHITELISTED_ORIGINS').split(','), origin: process.env.WHITELISTED_ORIGINS.split(','),
credentials: true, credentials: true,
}); });
} else { } else {
console.log('Enabling CORS with production settings'); console.log('Enabling CORS with production settings');
app.enableCors({ app.enableCors({
origin: configService.get('WHITELISTED_ORIGINS').split(','), origin: process.env.WHITELISTED_ORIGINS.split(','),
credentials: true, credentials: true,
}); });
} }
@@ -53,13 +47,7 @@ async function bootstrap() {
type: VersioningType.URI, type: VersioningType.URI,
}); });
app.use(cookieParser()); app.use(cookieParser());
await app.listen(configService.get('PORT') || 3170); await app.listen(process.env.PORT || 3170);
// Graceful shutdown
process.on('SIGTERM', async () => {
console.info('SIGTERM signal received');
await app.close();
});
} }
if (!process.env.GENERATE_GQL_SCHEMA) { if (!process.env.GENERATE_GQL_SCHEMA) {

View File

@@ -1,9 +1,8 @@
import { GraphQLSchemaHost } from '@nestjs/graphql'; import { GraphQLSchemaHost } from '@nestjs/graphql';
import { import {
ApolloServerPlugin, ApolloServerPlugin,
BaseContext,
GraphQLRequestListener, GraphQLRequestListener,
} from '@apollo/server'; } from 'apollo-server-plugin-base';
import { Plugin } from '@nestjs/apollo'; import { Plugin } from '@nestjs/apollo';
import { GraphQLError } from 'graphql'; import { GraphQLError } from 'graphql';
import { import {
@@ -18,7 +17,7 @@ const COMPLEXITY_LIMIT = 50;
export class GQLComplexityPlugin implements ApolloServerPlugin { export class GQLComplexityPlugin implements ApolloServerPlugin {
constructor(private gqlSchemaHost: GraphQLSchemaHost) {} constructor(private gqlSchemaHost: GraphQLSchemaHost) {}
async requestDidStart(): Promise<GraphQLRequestListener<BaseContext>> { async requestDidStart(): Promise<GraphQLRequestListener> {
const { schema } = this.gqlSchemaHost; const { schema } = this.gqlSchemaHost;
return { return {

View File

@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { PosthogService } from './posthog.service';
import { PrismaModule } from 'src/prisma/prisma.module';
@Module({
imports: [PrismaModule],
providers: [PosthogService],
})
export class PosthogModule {}

View File

@@ -1,58 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PostHog } from 'posthog-node';
import { Cron, CronExpression, SchedulerRegistry } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from 'src/prisma/prisma.service';
import { CronJob } from 'cron';
import { POSTHOG_CLIENT_NOT_INITIALIZED } from 'src/errors';
import { throwErr } from 'src/utils';
@Injectable()
export class PosthogService {
private postHogClient: PostHog;
private POSTHOG_API_KEY = 'phc_9CipPajQC22mSkk2wxe2TXsUA0Ysyupe8dt5KQQELqx';
constructor(
private readonly configService: ConfigService,
private readonly prismaService: PrismaService,
private schedulerRegistry: SchedulerRegistry,
) {}
async onModuleInit() {
if (this.configService.get('INFRA.ALLOW_ANALYTICS_COLLECTION') === 'true') {
console.log('Initializing PostHog');
this.postHogClient = new PostHog(this.POSTHOG_API_KEY, {
host: 'https://eu.posthog.com',
});
// Schedule the cron job only if analytics collection is allowed
this.scheduleCronJob();
}
}
private scheduleCronJob() {
const job = new CronJob(CronExpression.EVERY_WEEK, async () => {
await this.capture();
});
this.schedulerRegistry.addCronJob('captureAnalytics', job);
job.start();
}
async capture() {
if (!this.postHogClient) {
throwErr(POSTHOG_CLIENT_NOT_INITIALIZED);
}
this.postHogClient.capture({
distinctId: this.configService.get('INFRA.ANALYTICS_USER_ID'),
event: 'sh_instance',
properties: {
type: 'COMMUNITY',
total_user_count: await this.prismaService.user.count(),
total_workspace_count: await this.prismaService.team.count(),
version: this.configService.get('npm_package_version'),
},
});
console.log('Sent event to PostHog');
}
}

View File

@@ -21,8 +21,8 @@ import {
} from 'src/team-request/team-request.model'; } from 'src/team-request/team-request.model';
import { TeamInvitation } from 'src/team-invitation/team-invitation.model'; import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
import { InvitedUser } from '../admin/invited-user.model'; import { InvitedUser } from '../admin/invited-user.model';
import { UserCollection } from '@prisma/client';
import { import {
UserCollection,
UserCollectionRemovedData, UserCollectionRemovedData,
UserCollectionReorderData, UserCollectionReorderData,
} from 'src/user-collection/user-collections.model'; } from 'src/user-collection/user-collections.model';
@@ -69,7 +69,5 @@ export type TopicDef = {
[topic: `team_req/${string}/req_deleted`]: string; [topic: `team_req/${string}/req_deleted`]: string;
[topic: `team/${string}/invite_added`]: TeamInvitation; [topic: `team/${string}/invite_added`]: TeamInvitation;
[topic: `team/${string}/invite_removed`]: string; [topic: `team/${string}/invite_removed`]: string;
[ [topic: `shortcode/${string}/${'created' | 'revoked'}`]: Shortcode;
topic: `shortcode/${string}/${'created' | 'revoked' | 'updated'}`
]: Shortcode;
}; };

View File

@@ -1,10 +1,9 @@
import { Field, ID, ObjectType } from '@nestjs/graphql'; import { Field, ID, ObjectType } from '@nestjs/graphql';
import { User } from 'src/user/user.model';
@ObjectType() @ObjectType()
export class Shortcode { export class Shortcode {
@Field(() => ID, { @Field(() => ID, {
description: 'The 12 digit alphanumeric code', description: 'The shortcode. 12 digit alphanumeric.',
}) })
id: string; id: string;
@@ -13,57 +12,8 @@ export class Shortcode {
}) })
request: string; request: string;
@Field({
description: 'JSON string representing the properties for an embed',
nullable: true,
})
properties: string;
@Field({ @Field({
description: 'Timestamp of when the Shortcode was created', description: 'Timestamp of when the Shortcode was created',
}) })
createdOn: Date; createdOn: Date;
} }
@ObjectType()
export class ShortcodeCreator {
@Field({
description: 'Uid of user who created the shortcode',
})
uid: string;
@Field({
description: 'Email of user who created the shortcode',
})
email: string;
}
@ObjectType()
export class ShortcodeWithUserEmail {
@Field(() => ID, {
description: 'The 12 digit alphanumeric code',
})
id: string;
@Field({
description: 'JSON string representing the request data',
})
request: string;
@Field({
description: 'JSON string representing the properties for an embed',
nullable: true,
})
properties: string;
@Field({
description: 'Timestamp of when the Shortcode was created',
})
createdOn: Date;
@Field({
description: 'Details of user who created the shortcode',
nullable: true,
})
creator: ShortcodeCreator;
}

View File

@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PrismaModule } from 'src/prisma/prisma.module'; import { PrismaModule } from 'src/prisma/prisma.module';
import { PubSubModule } from 'src/pubsub/pubsub.module'; import { PubSubModule } from 'src/pubsub/pubsub.module';
import { UserModule } from 'src/user/user.module'; import { UserModule } from 'src/user/user.module';
@@ -6,7 +7,14 @@ import { ShortcodeResolver } from './shortcode.resolver';
import { ShortcodeService } from './shortcode.service'; import { ShortcodeService } from './shortcode.service';
@Module({ @Module({
imports: [PrismaModule, UserModule, PubSubModule], imports: [
PrismaModule,
UserModule,
PubSubModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
}),
],
providers: [ShortcodeService, ShortcodeResolver], providers: [ShortcodeService, ShortcodeResolver],
exports: [ShortcodeService], exports: [ShortcodeService],
}) })

View File

@@ -1,5 +1,6 @@
import { import {
Args, Args,
Context,
ID, ID,
Mutation, Mutation,
Query, Query,
@@ -8,25 +9,28 @@ import {
} from '@nestjs/graphql'; } from '@nestjs/graphql';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { UseGuards } from '@nestjs/common'; import { UseGuards } from '@nestjs/common';
import { Shortcode, ShortcodeWithUserEmail } from './shortcode.model'; import { Shortcode } from './shortcode.model';
import { ShortcodeService } from './shortcode.service'; import { ShortcodeService } from './shortcode.service';
import { UserService } from 'src/user/user.service';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import { GqlUser } from 'src/decorators/gql-user.decorator'; import { GqlUser } from 'src/decorators/gql-user.decorator';
import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
import { User } from 'src/user/user.model'; import { User } from 'src/user/user.model';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { AuthUser } from '../types/AuthUser'; import { AuthUser } from '../types/AuthUser';
import { JwtService } from '@nestjs/jwt';
import { PaginationArgs } from 'src/types/input-types.args'; import { PaginationArgs } from 'src/types/input-types.args';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { SkipThrottle } from '@nestjs/throttler'; import { SkipThrottle } from '@nestjs/throttler';
import { GqlAdminGuard } from 'src/admin/guards/gql-admin.guard';
@UseGuards(GqlThrottlerGuard) @UseGuards(GqlThrottlerGuard)
@Resolver(() => Shortcode) @Resolver(() => Shortcode)
export class ShortcodeResolver { export class ShortcodeResolver {
constructor( constructor(
private readonly shortcodeService: ShortcodeService, private readonly shortcodeService: ShortcodeService,
private readonly userService: UserService,
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
private jwtService: JwtService,
) {} ) {}
/* Queries */ /* Queries */
@@ -60,53 +64,20 @@ export class ShortcodeResolver {
@Mutation(() => Shortcode, { @Mutation(() => Shortcode, {
description: 'Create a shortcode for the given request.', description: 'Create a shortcode for the given request.',
}) })
@UseGuards(GqlAuthGuard)
async createShortcode( async createShortcode(
@GqlUser() user: AuthUser,
@Args({ @Args({
name: 'request', name: 'request',
description: 'JSON string of the request object', description: 'JSON string of the request object',
}) })
request: string, request: string,
@Args({ @Context() ctx: any,
name: 'properties',
description: 'JSON string of the properties of the embed',
nullable: true,
})
properties: string,
) { ) {
const decodedAccessToken = this.jwtService.verify(
ctx.req.cookies['access_token'],
);
const result = await this.shortcodeService.createShortcode( const result = await this.shortcodeService.createShortcode(
request, request,
properties, decodedAccessToken?.sub,
user,
);
if (E.isLeft(result)) throwErr(result.left);
return result.right;
}
@Mutation(() => Shortcode, {
description: 'Update a user generated Shortcode',
})
@UseGuards(GqlAuthGuard)
async updateEmbedProperties(
@GqlUser() user: AuthUser,
@Args({
name: 'code',
type: () => ID,
description: 'The Shortcode to update',
})
code: string,
@Args({
name: 'properties',
description: 'JSON string of the properties of the embed',
})
properties: string,
) {
const result = await this.shortcodeService.updateEmbedProperties(
code,
user.uid,
properties,
); );
if (E.isLeft(result)) throwErr(result.left); if (E.isLeft(result)) throwErr(result.left);
@@ -122,7 +93,7 @@ export class ShortcodeResolver {
@Args({ @Args({
name: 'code', name: 'code',
type: () => ID, type: () => ID,
description: 'The shortcode to remove', description: 'The shortcode to resolve',
}) })
code: string, code: string,
) { ) {
@@ -143,16 +114,6 @@ export class ShortcodeResolver {
return this.pubsub.asyncIterator(`shortcode/${user.uid}/created`); return this.pubsub.asyncIterator(`shortcode/${user.uid}/created`);
} }
@Subscription(() => Shortcode, {
description: 'Listen for Shortcode updates',
resolve: (value) => value,
})
@SkipThrottle()
@UseGuards(GqlAuthGuard)
myShortcodesUpdated(@GqlUser() user: AuthUser) {
return this.pubsub.asyncIterator(`shortcode/${user.uid}/updated`);
}
@Subscription(() => Shortcode, { @Subscription(() => Shortcode, {
description: 'Listen for shortcode deletion', description: 'Listen for shortcode deletion',
resolve: (value) => value, resolve: (value) => value,

View File

@@ -1,16 +1,13 @@
import { mockDeep, mockReset } from 'jest-mock-extended'; import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { import {
INVALID_EMAIL, SHORTCODE_ALREADY_EXISTS,
SHORTCODE_INVALID_PROPERTIES_JSON, SHORTCODE_INVALID_JSON,
SHORTCODE_INVALID_REQUEST_JSON,
SHORTCODE_NOT_FOUND, SHORTCODE_NOT_FOUND,
SHORTCODE_PROPERTIES_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { Shortcode, ShortcodeWithUserEmail } from './shortcode.model'; import { Shortcode } from './shortcode.model';
import { ShortcodeService } from './shortcode.service'; import { ShortcodeService } from './shortcode.service';
import { UserService } from 'src/user/user.service'; import { UserService } from 'src/user/user.service';
import { AuthUser } from 'src/types/AuthUser';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
@@ -25,7 +22,7 @@ const mockFB = {
doc: mockDocFunc, doc: mockDocFunc,
}, },
}; };
const mockUserService = new UserService(mockPrisma as any, mockPubSub as any); const mockUserService = new UserService(mockFB as any, mockPubSub as any);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
@@ -41,34 +38,18 @@ beforeEach(() => {
}); });
const createdOn = new Date(); const createdOn = new Date();
const user: AuthUser = { const shortCodeWithOutUser = {
uid: '123344',
email: 'dwight@dundermifflin.com',
displayName: 'Dwight Schrute',
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false,
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
createdOn: createdOn,
currentGQLSession: {},
currentRESTSession: {},
};
const mockEmbed = {
id: '123', id: '123',
request: '{}', request: '{}',
embedProperties: '{}',
createdOn: createdOn, createdOn: createdOn,
creatorUid: user.uid, creatorUid: null,
updatedOn: createdOn,
}; };
const mockShortcode = { const shortCodeWithUser = {
id: '123', id: '123',
request: '{}', request: '{}',
embedProperties: null,
createdOn: createdOn, createdOn: createdOn,
creatorUid: user.uid, creatorUid: 'user_uid_1',
updatedOn: createdOn,
}; };
const shortcodes = [ const shortcodes = [
@@ -77,67 +58,33 @@ const shortcodes = [
request: { request: {
hello: 'there', hello: 'there',
}, },
embedProperties: { creatorUid: 'testuser',
foo: 'bar',
},
creatorUid: user.uid,
createdOn: new Date(), createdOn: new Date(),
updatedOn: createdOn,
}, },
{ {
id: 'blablabla1', id: 'blablabla1',
request: { request: {
hello: 'there', hello: 'there',
}, },
embedProperties: { creatorUid: 'testuser',
foo: 'bar',
},
creatorUid: user.uid,
createdOn: new Date(), createdOn: new Date(),
updatedOn: createdOn,
},
];
const shortcodesWithUserEmail = [
{
id: 'blablabla',
request: {
hello: 'there',
},
embedProperties: {
foo: 'bar',
},
creatorUid: user.uid,
createdOn: new Date(),
updatedOn: createdOn,
User: user,
},
{
id: 'blablabla1',
request: {
hello: 'there',
},
embedProperties: {
foo: 'bar',
},
creatorUid: user.uid,
createdOn: new Date(),
updatedOn: createdOn,
User: user,
}, },
]; ];
describe('ShortcodeService', () => { describe('ShortcodeService', () => {
describe('getShortCode', () => { describe('getShortCode', () => {
test('should return a valid Shortcode with valid Shortcode ID', async () => { test('should return a valid shortcode with valid shortcode ID', async () => {
mockPrisma.shortcode.findFirstOrThrow.mockResolvedValueOnce(mockEmbed); mockPrisma.shortcode.findFirstOrThrow.mockResolvedValueOnce(
shortCodeWithOutUser,
);
const result = await shortcodeService.getShortCode(mockEmbed.id); const result = await shortcodeService.getShortCode(
shortCodeWithOutUser.id,
);
expect(result).toEqualRight(<Shortcode>{ expect(result).toEqualRight(<Shortcode>{
id: mockEmbed.id, id: shortCodeWithOutUser.id,
createdOn: mockEmbed.createdOn, createdOn: shortCodeWithOutUser.createdOn,
request: JSON.stringify(mockEmbed.request), request: JSON.stringify(shortCodeWithOutUser.request),
properties: JSON.stringify(mockEmbed.embedProperties),
}); });
}); });
@@ -152,10 +99,10 @@ describe('ShortcodeService', () => {
}); });
describe('fetchUserShortCodes', () => { describe('fetchUserShortCodes', () => {
test('should return list of Shortcode with valid inputs and no cursor', async () => { test('should return list of shortcodes with valid inputs and no cursor', async () => {
mockPrisma.shortcode.findMany.mockResolvedValueOnce(shortcodes); mockPrisma.shortcode.findMany.mockResolvedValueOnce(shortcodes);
const result = await shortcodeService.fetchUserShortCodes(user.uid, { const result = await shortcodeService.fetchUserShortCodes('testuser', {
cursor: null, cursor: null,
take: 10, take: 10,
}); });
@@ -163,22 +110,20 @@ describe('ShortcodeService', () => {
{ {
id: shortcodes[0].id, id: shortcodes[0].id,
request: JSON.stringify(shortcodes[0].request), request: JSON.stringify(shortcodes[0].request),
properties: JSON.stringify(shortcodes[0].embedProperties),
createdOn: shortcodes[0].createdOn, createdOn: shortcodes[0].createdOn,
}, },
{ {
id: shortcodes[1].id, id: shortcodes[1].id,
request: JSON.stringify(shortcodes[1].request), request: JSON.stringify(shortcodes[1].request),
properties: JSON.stringify(shortcodes[1].embedProperties),
createdOn: shortcodes[1].createdOn, createdOn: shortcodes[1].createdOn,
}, },
]); ]);
}); });
test('should return list of Shortcode with valid inputs and cursor', async () => { test('should return list of shortcodes with valid inputs and cursor', async () => {
mockPrisma.shortcode.findMany.mockResolvedValue([shortcodes[1]]); mockPrisma.shortcode.findMany.mockResolvedValue([shortcodes[1]]);
const result = await shortcodeService.fetchUserShortCodes(user.uid, { const result = await shortcodeService.fetchUserShortCodes('testuser', {
cursor: 'blablabla', cursor: 'blablabla',
take: 10, take: 10,
}); });
@@ -186,7 +131,6 @@ describe('ShortcodeService', () => {
{ {
id: shortcodes[1].id, id: shortcodes[1].id,
request: JSON.stringify(shortcodes[1].request), request: JSON.stringify(shortcodes[1].request),
properties: JSON.stringify(shortcodes[1].embedProperties),
createdOn: shortcodes[1].createdOn, createdOn: shortcodes[1].createdOn,
}, },
]); ]);
@@ -195,7 +139,7 @@ describe('ShortcodeService', () => {
test('should return an empty array for an invalid cursor', async () => { test('should return an empty array for an invalid cursor', async () => {
mockPrisma.shortcode.findMany.mockResolvedValue([]); mockPrisma.shortcode.findMany.mockResolvedValue([]);
const result = await shortcodeService.fetchUserShortCodes(user.uid, { const result = await shortcodeService.fetchUserShortCodes('testuser', {
cursor: 'invalidcursor', cursor: 'invalidcursor',
take: 10, take: 10,
}); });
@@ -227,111 +171,77 @@ describe('ShortcodeService', () => {
}); });
describe('createShortcode', () => { describe('createShortcode', () => {
test('should throw SHORTCODE_INVALID_REQUEST_JSON error if incoming request data is invalid', async () => { test('should throw SHORTCODE_INVALID_JSON error if incoming request data is invalid', async () => {
const result = await shortcodeService.createShortcode( const result = await shortcodeService.createShortcode(
'invalidRequest', 'invalidRequest',
null, 'user_uid_1',
user,
); );
expect(result).toEqualLeft(SHORTCODE_INVALID_REQUEST_JSON); expect(result).toEqualLeft(SHORTCODE_INVALID_JSON);
}); });
test('should throw SHORTCODE_INVALID_PROPERTIES_JSON error if incoming properties data is invalid', async () => { test('should successfully create a new shortcode with valid user uid', async () => {
const result = await shortcodeService.createShortcode( // generateUniqueShortCodeID --> getShortCode
'{}',
'invalid_data',
user,
);
expect(result).toEqualLeft(SHORTCODE_INVALID_PROPERTIES_JSON);
});
test('should successfully create a new Embed with valid user uid', async () => {
// generateUniqueShortCodeID --> getShortcode
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
'NotFoundError', 'NotFoundError',
); );
mockPrisma.shortcode.create.mockResolvedValueOnce(mockEmbed); mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser);
const result = await shortcodeService.createShortcode('{}', '{}', user); const result = await shortcodeService.createShortcode('{}', 'user_uid_1');
expect(result).toEqualRight(<Shortcode>{ expect(result).toEqualRight({
id: mockEmbed.id, id: shortCodeWithUser.id,
createdOn: mockEmbed.createdOn, createdOn: shortCodeWithUser.createdOn,
request: JSON.stringify(mockEmbed.request), request: JSON.stringify(shortCodeWithUser.request),
properties: JSON.stringify(mockEmbed.embedProperties),
}); });
}); });
test('should successfully create a new ShortCode with valid user uid', async () => { test('should successfully create a new shortcode with null user uid', async () => {
// generateUniqueShortCodeID --> getShortcode // generateUniqueShortCodeID --> getShortCode
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
'NotFoundError', 'NotFoundError',
); );
mockPrisma.shortcode.create.mockResolvedValueOnce(mockShortcode); mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser);
const result = await shortcodeService.createShortcode('{}', null, user); const result = await shortcodeService.createShortcode('{}', null);
expect(result).toEqualRight(<Shortcode>{ expect(result).toEqualRight({
id: mockShortcode.id, id: shortCodeWithUser.id,
createdOn: mockShortcode.createdOn, createdOn: shortCodeWithUser.createdOn,
request: JSON.stringify(mockShortcode.request), request: JSON.stringify(shortCodeWithOutUser.request),
properties: mockShortcode.embedProperties,
}); });
}); });
test('should send pubsub message to `shortcode/{uid}/created` on successful creation of a Shortcode', async () => { test('should send pubsub message to `shortcode/{uid}/created` on successful creation of shortcode', async () => {
// generateUniqueShortCodeID --> getShortcode // generateUniqueShortCodeID --> getShortCode
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
'NotFoundError', 'NotFoundError',
); );
mockPrisma.shortcode.create.mockResolvedValueOnce(mockShortcode); mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser);
const result = await shortcodeService.createShortcode('{}', null, user);
const result = await shortcodeService.createShortcode('{}', 'user_uid_1');
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`shortcode/${mockShortcode.creatorUid}/created`, `shortcode/${shortCodeWithUser.creatorUid}/created`,
<Shortcode>{ {
id: mockShortcode.id, id: shortCodeWithUser.id,
createdOn: mockShortcode.createdOn, createdOn: shortCodeWithUser.createdOn,
request: JSON.stringify(mockShortcode.request), request: JSON.stringify(shortCodeWithUser.request),
properties: mockShortcode.embedProperties,
},
);
});
test('should send pubsub message to `shortcode/{uid}/created` on successful creation of an Embed', async () => {
// generateUniqueShortCodeID --> getShortcode
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
'NotFoundError',
);
mockPrisma.shortcode.create.mockResolvedValueOnce(mockEmbed);
const result = await shortcodeService.createShortcode('{}', '{}', user);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`shortcode/${mockEmbed.creatorUid}/created`,
<Shortcode>{
id: mockEmbed.id,
createdOn: mockEmbed.createdOn,
request: JSON.stringify(mockEmbed.request),
properties: JSON.stringify(mockEmbed.embedProperties),
}, },
); );
}); });
}); });
describe('revokeShortCode', () => { describe('revokeShortCode', () => {
test('should return true on successful deletion of Shortcode with valid inputs', async () => { test('should return true on successful deletion of shortcode with valid inputs', async () => {
mockPrisma.shortcode.delete.mockResolvedValueOnce(mockEmbed); mockPrisma.shortcode.delete.mockResolvedValueOnce(shortCodeWithUser);
const result = await shortcodeService.revokeShortCode( const result = await shortcodeService.revokeShortCode(
mockEmbed.id, shortCodeWithUser.id,
mockEmbed.creatorUid, shortCodeWithUser.creatorUid,
); );
expect(mockPrisma.shortcode.delete).toHaveBeenCalledWith({ expect(mockPrisma.shortcode.delete).toHaveBeenCalledWith({
where: { where: {
creator_uid_shortcode_unique: { creator_uid_shortcode_unique: {
creatorUid: mockEmbed.creatorUid, creatorUid: shortCodeWithUser.creatorUid,
id: mockEmbed.id, id: shortCodeWithUser.id,
}, },
}, },
}); });
@@ -339,53 +249,52 @@ describe('ShortcodeService', () => {
expect(result).toEqualRight(true); expect(result).toEqualRight(true);
}); });
test('should return SHORTCODE_NOT_FOUND error when Shortcode is invalid and user uid is valid', async () => { test('should return SHORTCODE_NOT_FOUND error when shortcode is invalid and user uid is valid', async () => {
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
expect( expect(
shortcodeService.revokeShortCode('invalid', 'testuser'), shortcodeService.revokeShortCode('invalid', 'testuser'),
).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND);
}); });
test('should return SHORTCODE_NOT_FOUND error when Shortcode is valid and user uid is invalid', async () => { test('should return SHORTCODE_NOT_FOUND error when shortcode is valid and user uid is invalid', async () => {
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
expect( expect(
shortcodeService.revokeShortCode('blablablabla', 'invalidUser'), shortcodeService.revokeShortCode('blablablabla', 'invalidUser'),
).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND);
}); });
test('should return SHORTCODE_NOT_FOUND error when both Shortcode and user uid are invalid', async () => { test('should return SHORTCODE_NOT_FOUND error when both shortcode and user uid are invalid', async () => {
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
expect( expect(
shortcodeService.revokeShortCode('invalid', 'invalid'), shortcodeService.revokeShortCode('invalid', 'invalid'),
).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND);
}); });
test('should send pubsub message to `shortcode/{uid}/revoked` on successful deletion of Shortcode', async () => { test('should send pubsub message to `shortcode/{uid}/revoked` on successful deletion of shortcode', async () => {
mockPrisma.shortcode.delete.mockResolvedValueOnce(mockEmbed); mockPrisma.shortcode.delete.mockResolvedValueOnce(shortCodeWithUser);
const result = await shortcodeService.revokeShortCode( const result = await shortcodeService.revokeShortCode(
mockEmbed.id, shortCodeWithUser.id,
mockEmbed.creatorUid, shortCodeWithUser.creatorUid,
); );
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`shortcode/${mockEmbed.creatorUid}/revoked`, `shortcode/${shortCodeWithUser.creatorUid}/revoked`,
{ {
id: mockEmbed.id, id: shortCodeWithUser.id,
createdOn: mockEmbed.createdOn, createdOn: shortCodeWithUser.createdOn,
request: JSON.stringify(mockEmbed.request), request: JSON.stringify(shortCodeWithUser.request),
properties: JSON.stringify(mockEmbed.embedProperties),
}, },
); );
}); });
}); });
describe('deleteUserShortCodes', () => { describe('deleteUserShortCodes', () => {
test('should successfully delete all users Shortcodes with valid user uid', async () => { test('should successfully delete all users shortcodes with valid user uid', async () => {
mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 1 }); mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 1 });
const result = await shortcodeService.deleteUserShortCodes( const result = await shortcodeService.deleteUserShortCodes(
mockEmbed.creatorUid, shortCodeWithUser.creatorUid,
); );
expect(result).toEqual(1); expect(result).toEqual(1);
}); });
@@ -394,180 +303,9 @@ describe('ShortcodeService', () => {
mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 0 }); mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 0 });
const result = await shortcodeService.deleteUserShortCodes( const result = await shortcodeService.deleteUserShortCodes(
mockEmbed.creatorUid, shortCodeWithUser.creatorUid,
); );
expect(result).toEqual(0); expect(result).toEqual(0);
}); });
}); });
describe('updateShortcode', () => {
test('should return SHORTCODE_PROPERTIES_NOT_FOUND error when updatedProps in invalid', async () => {
const result = await shortcodeService.updateEmbedProperties(
mockEmbed.id,
user.uid,
'',
);
expect(result).toEqualLeft(SHORTCODE_PROPERTIES_NOT_FOUND);
});
test('should return SHORTCODE_PROPERTIES_NOT_FOUND error when updatedProps in invalid JSON format', async () => {
const result = await shortcodeService.updateEmbedProperties(
mockEmbed.id,
user.uid,
'{kk',
);
expect(result).toEqualLeft(SHORTCODE_INVALID_PROPERTIES_JSON);
});
test('should return SHORTCODE_NOT_FOUND error when Shortcode ID is invalid', async () => {
mockPrisma.shortcode.update.mockRejectedValue('RecordNotFound');
const result = await shortcodeService.updateEmbedProperties(
'invalidID',
user.uid,
'{}',
);
expect(result).toEqualLeft(SHORTCODE_NOT_FOUND);
});
test('should successfully update a Shortcodes with valid inputs', async () => {
mockPrisma.shortcode.update.mockResolvedValueOnce({
...mockEmbed,
embedProperties: '{"foo":"bar"}',
});
const result = await shortcodeService.updateEmbedProperties(
mockEmbed.id,
user.uid,
'{"foo":"bar"}',
);
expect(result).toEqualRight({
id: mockEmbed.id,
createdOn: mockEmbed.createdOn,
request: JSON.stringify(mockEmbed.request),
properties: JSON.stringify('{"foo":"bar"}'),
});
});
test('should send pubsub message to `shortcode/{uid}/updated` on successful Update of Shortcode', async () => {
mockPrisma.shortcode.update.mockResolvedValueOnce({
...mockEmbed,
embedProperties: '{"foo":"bar"}',
});
const result = await shortcodeService.updateEmbedProperties(
mockEmbed.id,
user.uid,
'{"foo":"bar"}',
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`shortcode/${mockEmbed.creatorUid}/updated`,
{
id: mockEmbed.id,
createdOn: mockEmbed.createdOn,
request: JSON.stringify(mockEmbed.request),
properties: JSON.stringify('{"foo":"bar"}'),
},
);
});
});
describe('deleteShortcode', () => {
test('should return true on successful deletion of Shortcode with valid inputs', async () => {
mockPrisma.shortcode.delete.mockResolvedValueOnce(mockEmbed);
const result = await shortcodeService.deleteShortcode(mockEmbed.id);
expect(result).toEqualRight(true);
});
test('should return SHORTCODE_NOT_FOUND error when Shortcode is invalid', async () => {
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
expect(shortcodeService.deleteShortcode('invalid')).resolves.toEqualLeft(
SHORTCODE_NOT_FOUND,
);
});
});
describe('fetchAllShortcodes', () => {
test('should return list of Shortcodes with valid inputs and no cursor', async () => {
mockPrisma.shortcode.findMany.mockResolvedValueOnce(
shortcodesWithUserEmail,
);
const result = await shortcodeService.fetchAllShortcodes(
{
cursor: null,
take: 10,
},
user.email,
);
expect(result).toEqual(<ShortcodeWithUserEmail[]>[
{
id: shortcodesWithUserEmail[0].id,
request: JSON.stringify(shortcodesWithUserEmail[0].request),
properties: JSON.stringify(
shortcodesWithUserEmail[0].embedProperties,
),
createdOn: shortcodesWithUserEmail[0].createdOn,
creator: {
uid: user.uid,
email: user.email,
},
},
{
id: shortcodesWithUserEmail[1].id,
request: JSON.stringify(shortcodesWithUserEmail[1].request),
properties: JSON.stringify(
shortcodesWithUserEmail[1].embedProperties,
),
createdOn: shortcodesWithUserEmail[1].createdOn,
creator: {
uid: user.uid,
email: user.email,
},
},
]);
});
test('should return list of Shortcode with valid inputs and cursor', async () => {
mockPrisma.shortcode.findMany.mockResolvedValue([
shortcodesWithUserEmail[1],
]);
const result = await shortcodeService.fetchAllShortcodes(
{
cursor: 'blablabla',
take: 10,
},
user.email,
);
expect(result).toEqual(<ShortcodeWithUserEmail[]>[
{
id: shortcodes[1].id,
request: JSON.stringify(shortcodes[1].request),
properties: JSON.stringify(shortcodes[1].embedProperties),
createdOn: shortcodes[1].createdOn,
creator: {
uid: user.uid,
email: user.email,
},
},
]);
});
test('should return an empty array for an invalid cursor', async () => {
mockPrisma.shortcode.findMany.mockResolvedValue([]);
const result = await shortcodeService.fetchAllShortcodes(
{
cursor: 'invalidcursor',
take: 10,
},
user.email,
);
expect(result).toHaveLength(0);
});
});
}); });

View File

@@ -1,16 +1,12 @@
import { Injectable, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleInit } from '@nestjs/common';
import * as T from 'fp-ts/Task'; import * as T from 'fp-ts/Task';
import * as O from 'fp-ts/Option';
import * as TO from 'fp-ts/TaskOption'; import * as TO from 'fp-ts/TaskOption';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { import { SHORTCODE_INVALID_JSON, SHORTCODE_NOT_FOUND } from 'src/errors';
SHORTCODE_INVALID_PROPERTIES_JSON,
SHORTCODE_INVALID_REQUEST_JSON,
SHORTCODE_NOT_FOUND,
SHORTCODE_PROPERTIES_NOT_FOUND,
} from 'src/errors';
import { UserDataHandler } from 'src/user/user.data.handler'; import { UserDataHandler } from 'src/user/user.data.handler';
import { Shortcode, ShortcodeWithUserEmail } from './shortcode.model'; import { Shortcode } from './shortcode.model';
import { Shortcode as DBShortCode } from '@prisma/client'; import { Shortcode as DBShortCode } from '@prisma/client';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { UserService } from 'src/user/user.service'; import { UserService } from 'src/user/user.service';
@@ -50,14 +46,10 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
* @param shortcodeInfo Prisma Shortcode type * @param shortcodeInfo Prisma Shortcode type
* @returns GQL Shortcode * @returns GQL Shortcode
*/ */
private cast(shortcodeInfo: DBShortCode): Shortcode { private returnShortCode(shortcodeInfo: DBShortCode): Shortcode {
return <Shortcode>{ return <Shortcode>{
id: shortcodeInfo.id, id: shortcodeInfo.id,
request: JSON.stringify(shortcodeInfo.request), request: JSON.stringify(shortcodeInfo.request),
properties:
shortcodeInfo.embedProperties != null
? JSON.stringify(shortcodeInfo.embedProperties)
: null,
createdOn: shortcodeInfo.createdOn, createdOn: shortcodeInfo.createdOn,
}; };
} }
@@ -102,7 +94,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
const shortcodeInfo = await this.prisma.shortcode.findFirstOrThrow({ const shortcodeInfo = await this.prisma.shortcode.findFirstOrThrow({
where: { id: shortcode }, where: { id: shortcode },
}); });
return E.right(this.cast(shortcodeInfo)); return E.right(this.returnShortCode(shortcodeInfo));
} catch (error) { } catch (error) {
return E.left(SHORTCODE_NOT_FOUND); return E.left(SHORTCODE_NOT_FOUND);
} }
@@ -112,22 +104,14 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
* Create a new ShortCode * Create a new ShortCode
* *
* @param request JSON string of request details * @param request JSON string of request details
* @param userInfo user UI * @param userUID user UID, if present
* @param properties JSON string of embed properties, if present
* @returns Either of ShortCode or error * @returns Either of ShortCode or error
*/ */
async createShortcode( async createShortcode(request: string, userUID: string | null) {
request: string, const shortcodeData = stringToJson(request);
properties: string | null = null, if (E.isLeft(shortcodeData)) return E.left(SHORTCODE_INVALID_JSON);
userInfo: AuthUser,
) {
const requestData = stringToJson(request);
if (E.isLeft(requestData) || !requestData.right)
return E.left(SHORTCODE_INVALID_REQUEST_JSON);
const parsedProperties = stringToJson(properties); const user = await this.userService.findUserById(userUID);
if (E.isLeft(parsedProperties))
return E.left(SHORTCODE_INVALID_PROPERTIES_JSON);
const generatedShortCode = await this.generateUniqueShortCodeID(); const generatedShortCode = await this.generateUniqueShortCodeID();
if (E.isLeft(generatedShortCode)) return E.left(generatedShortCode.left); if (E.isLeft(generatedShortCode)) return E.left(generatedShortCode.left);
@@ -135,9 +119,8 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
const createdShortCode = await this.prisma.shortcode.create({ const createdShortCode = await this.prisma.shortcode.create({
data: { data: {
id: generatedShortCode.right, id: generatedShortCode.right,
request: requestData.right, request: shortcodeData.right,
embedProperties: parsedProperties.right ?? undefined, creatorUid: O.isNone(user) ? null : user.value.uid,
creatorUid: userInfo.uid,
}, },
}); });
@@ -145,11 +128,11 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
if (createdShortCode.creatorUid) { if (createdShortCode.creatorUid) {
this.pubsub.publish( this.pubsub.publish(
`shortcode/${createdShortCode.creatorUid}/created`, `shortcode/${createdShortCode.creatorUid}/created`,
this.cast(createdShortCode), this.returnShortCode(createdShortCode),
); );
} }
return E.right(this.cast(createdShortCode)); return E.right(this.returnShortCode(createdShortCode));
} }
/** /**
@@ -167,20 +150,20 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
orderBy: { orderBy: {
createdOn: 'desc', createdOn: 'desc',
}, },
skip: args.cursor ? 1 : 0, skip: 1,
take: args.take, take: args.take,
cursor: args.cursor ? { id: args.cursor } : undefined, cursor: args.cursor ? { id: args.cursor } : undefined,
}); });
const fetchedShortCodes: Shortcode[] = shortCodes.map((code) => const fetchedShortCodes: Shortcode[] = shortCodes.map((code) =>
this.cast(code), this.returnShortCode(code),
); );
return fetchedShortCodes; return fetchedShortCodes;
} }
/** /**
* Delete a ShortCode created by User of uid * Delete a ShortCode
* *
* @param shortcode ShortCode * @param shortcode ShortCode
* @param uid User Uid * @param uid User Uid
@@ -199,7 +182,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
this.pubsub.publish( this.pubsub.publish(
`shortcode/${deletedShortCodes.creatorUid}/revoked`, `shortcode/${deletedShortCodes.creatorUid}/revoked`,
this.cast(deletedShortCodes), this.returnShortCode(deletedShortCodes),
); );
return E.right(true); return E.right(true);
@@ -222,118 +205,4 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
return deletedShortCodes.count; return deletedShortCodes.count;
} }
/**
* Delete a Shortcode
*
* @param shortcodeID ID of Shortcode being deleted
* @returns Boolean on successful deletion
*/
async deleteShortcode(shortcodeID: string) {
try {
await this.prisma.shortcode.delete({
where: {
id: shortcodeID,
},
});
return E.right(true);
} catch (error) {
return E.left(SHORTCODE_NOT_FOUND);
}
}
/**
* Update a created Shortcode
* @param shortcodeID Shortcode ID
* @param uid User Uid
* @returns Updated Shortcode
*/
async updateEmbedProperties(
shortcodeID: string,
uid: string,
updatedProps: string,
) {
if (!updatedProps) return E.left(SHORTCODE_PROPERTIES_NOT_FOUND);
const parsedProperties = stringToJson(updatedProps);
if (E.isLeft(parsedProperties) || !parsedProperties.right)
return E.left(SHORTCODE_INVALID_PROPERTIES_JSON);
try {
const updatedShortcode = await this.prisma.shortcode.update({
where: {
creator_uid_shortcode_unique: {
creatorUid: uid,
id: shortcodeID,
},
},
data: {
embedProperties: parsedProperties.right,
},
});
this.pubsub.publish(
`shortcode/${updatedShortcode.creatorUid}/updated`,
this.cast(updatedShortcode),
);
return E.right(this.cast(updatedShortcode));
} catch (error) {
return E.left(SHORTCODE_NOT_FOUND);
}
}
/**
* Fetch all created ShortCodes
*
* @param args Pagination arguments
* @param userEmail User email
* @returns ShortcodeWithUserEmail
*/
async fetchAllShortcodes(
args: PaginationArgs,
userEmail: string | null = null,
) {
const shortCodes = await this.prisma.shortcode.findMany({
where: userEmail
? {
User: {
email: userEmail,
},
}
: undefined,
orderBy: {
createdOn: 'desc',
},
skip: args.cursor ? 1 : 0,
take: args.take,
cursor: args.cursor ? { id: args.cursor } : undefined,
include: {
User: true,
},
});
const fetchedShortCodes: ShortcodeWithUserEmail[] = shortCodes.map(
(code) => {
return <ShortcodeWithUserEmail>{
id: code.id,
request: JSON.stringify(code.request),
properties:
code.embedProperties != null
? JSON.stringify(code.embedProperties)
: null,
createdOn: code.createdOn,
creator: code.User
? {
uid: code.User.uid,
email: code.User.email,
}
: null,
};
},
);
return fetchedShortCodes;
}
} }

View File

@@ -1,14 +0,0 @@
// Type of data returned from the query to obtain all search results
export type SearchQueryReturnType = {
id: string;
title: string;
type: 'collection' | 'request';
method?: string;
};
// Type of data returned from the query to obtain all parents
export type ParentTreeQueryReturnType = {
id: string;
parentID: string;
title: string;
};

View File

@@ -14,13 +14,6 @@ export class CreateRootTeamCollectionArgs {
@Field({ name: 'title', description: 'Title of the new collection' }) @Field({ name: 'title', description: 'Title of the new collection' })
title: string; title: string;
@Field({
name: 'data',
description: 'JSON string representing the collection data',
nullable: true,
})
data: string;
} }
@ArgsType() @ArgsType()
@@ -33,13 +26,6 @@ export class CreateChildTeamCollectionArgs {
@Field({ name: 'childTitle', description: 'Title of the new collection' }) @Field({ name: 'childTitle', description: 'Title of the new collection' })
childTitle: string; childTitle: string;
@Field({
name: 'data',
description: 'JSON string representing the collection data',
nullable: true,
})
data: string;
} }
@ArgsType() @ArgsType()
@@ -47,14 +33,12 @@ export class RenameTeamCollectionArgs {
@Field(() => ID, { @Field(() => ID, {
name: 'collectionID', name: 'collectionID',
description: 'ID of the collection', description: 'ID of the collection',
deprecationReason: 'Switch to updateTeamCollection mutation instead',
}) })
collectionID: string; collectionID: string;
@Field({ @Field({
name: 'newTitle', name: 'newTitle',
description: 'The updated title of the collection', description: 'The updated title of the collection',
deprecationReason: 'Switch to updateTeamCollection mutation instead',
}) })
newTitle: string; newTitle: string;
} }
@@ -114,26 +98,3 @@ export class ReplaceTeamCollectionArgs {
}) })
parentCollectionID?: string; parentCollectionID?: string;
} }
@ArgsType()
export class UpdateTeamCollectionArgs {
@Field(() => ID, {
name: 'collectionID',
description: 'ID of the collection',
})
collectionID: string;
@Field({
name: 'newTitle',
description: 'The updated title of the collection',
nullable: true,
})
newTitle: string;
@Field({
name: 'data',
description: 'JSON string representing the collection data',
nullable: true,
})
data: string;
}

View File

@@ -1,54 +0,0 @@
import {
Controller,
Get,
HttpStatus,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { TeamCollectionService } from './team-collection.service';
import * as E from 'fp-ts/Either';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator';
import { TeamMemberRole } from '@prisma/client';
import { RESTTeamMemberGuard } from 'src/team/guards/rest-team-member.guard';
import { throwHTTPErr } from 'src/utils';
import { RESTError } from 'src/types/RESTError';
import { INVALID_PARAMS } from 'src/errors';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'team-collection', version: '1' })
export class TeamCollectionController {
constructor(private readonly teamCollectionService: TeamCollectionService) {}
@Get('search/:teamID')
@RequiresTeamRole(
TeamMemberRole.VIEWER,
TeamMemberRole.EDITOR,
TeamMemberRole.OWNER,
)
@UseGuards(JwtAuthGuard, RESTTeamMemberGuard)
async searchByTitle(
@Query('searchQuery') searchQuery: string,
@Param('teamID') teamID: string,
@Query('take') take: string,
@Query('skip') skip: string,
) {
if (!teamID || !searchQuery) {
return <RESTError>{
message: INVALID_PARAMS,
statusCode: HttpStatus.BAD_REQUEST,
};
}
const res = await this.teamCollectionService.searchByTitle(
searchQuery.trim(),
teamID,
parseInt(take),
parseInt(skip),
);
if (E.isLeft(res)) throwHTTPErr(res.left);
return res.right;
}
}

Some files were not shown because too many files have changed in this diff Show More