diff --git a/.env.example b/.env.example index f317b2908..fcd1231b8 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ # Keys # Required key for platform encryption/decryption ops +# THIS IS A SAMPLE ENCRYPTION KEY AND SHOULD NOT BE USED FOR PRODUCTION ENCRYPTION_KEY=6c1fe4e407b8911c104518103505b218 # JWT @@ -30,11 +31,11 @@ MONGO_PASSWORD=example # Required SITE_URL=http://localhost:8080 -# Mail/SMTP - SMTP_HOST='smtp-server' -SMTP_PORT='1025' -SMTP_NAME='local' -SMTP_USERNAME='team@infisical.com' +# Mail/SMTP +SMTP_HOST= +SMTP_PORT= +SMTP_NAME= +SMTP_USERNAME= SMTP_PASSWORD= # Integration diff --git a/.github/workflows/release-standalone-docker-img.yml b/.github/workflows/release-standalone-docker-img.yml new file mode 100644 index 000000000..c3279c0d3 --- /dev/null +++ b/.github/workflows/release-standalone-docker-img.yml @@ -0,0 +1,45 @@ +name: Release standalone docker image +on: + push: + tags: + - "infisical-standalone/v*.*.*" + +jobs: + infisical-standalone: + name: Build infisical standalone image + runs-on: ubuntu-latest + steps: + - name: Extract version from tag + id: extract_version + run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical/}" + - name: โ˜๏ธ Checkout source + uses: actions/checkout@v3 + - name: ๐Ÿ“ฆ Install dependencies to test all dependencies + run: npm ci --only-production + working-directory: backend + - name: ๐Ÿงช Run tests + run: npm run test:ci + working-directory: backend + - name: Save commit hashes for tag + id: commit + uses: pr-mpt/actions-commit-hash@v2 + - name: ๐Ÿ”ง Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: ๐Ÿ‹ Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Depot CLI + uses: depot/setup-action@v1 + - name: ๐Ÿ“ฆ Build backend and export to Docker + uses: depot/build-push-action@v1 + with: + project: 64mmf0n610 + token: ${{ secrets.DEPOT_PROJECT_TOKEN }} + push: true + context: . + tags: | + infisical/standalone-infisical:latest + platforms: linux/amd64,linux/arm64 + file: Dockerfile.standalone-infisical diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical new file mode 100644 index 000000000..49b74415e --- /dev/null +++ b/Dockerfile.standalone-infisical @@ -0,0 +1,102 @@ +ARG POSTHOG_HOST=https://app.posthog.com +ARG POSTHOG_API_KEY=posthog-api-key + +FROM node:16-alpine AS frontend-dependencies + +WORKDIR /app + +COPY frontend/package.json frontend/package-lock.json frontend/next.config.js ./ + +# Install dependencies +RUN npm ci --only-production --ignore-scripts + +# Rebuild the source code only when needed +FROM node:16-alpine AS frontend-builder +WORKDIR /app + +# Copy dependencies +COPY --from=frontend-dependencies /app/node_modules ./node_modules +# Copy all files +COPY /frontend . + +ENV NODE_ENV production +ENV NEXT_PUBLIC_ENV production +ARG POSTHOG_HOST +ENV NEXT_PUBLIC_POSTHOG_HOST $POSTHOG_HOST +ARG POSTHOG_API_KEY +ENV NEXT_PUBLIC_POSTHOG_API_KEY $POSTHOG_API_KEY + +# Build +RUN npm run build + +# Production image +FROM node:16-alpine AS frontend-runner +WORKDIR /app + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +RUN mkdir -p /app/.next/cache/images && chown nextjs:nodejs /app/.next/cache/images +VOLUME /app/.next/cache/images + +ARG POSTHOG_API_KEY +ENV NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY \ + BAKED_NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY + +COPY --chown=nextjs:nodejs --chmod=555 frontend/scripts ./scripts +COPY --from=frontend-builder /app/public ./public +RUN chown nextjs:nodejs ./public/data +COPY --from=frontend-builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=frontend-builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +ENV NEXT_TELEMETRY_DISABLED 1 + +## +## BACKEND +## +FROM node:16-alpine AS backend-build + +WORKDIR /app + +COPY backend/package*.json ./ +RUN npm ci --only-production + +COPY /backend . +RUN npm run build + +# Production stage +FROM node:16-alpine AS backend-runner + +WORKDIR /app + +COPY backend/package*.json ./ +RUN npm ci --only-production + +COPY --from=backend-build /app . + +# Production stage +FROM node:14-alpine AS production + +WORKDIR / + +# Install PM2 +RUN npm install -g pm2 +# Copy ecosystem.config.js +COPY ecosystem.config.js . + +RUN apk add --no-cache nginx + +COPY nginx/default-stand-alone-docker.conf /etc/nginx/nginx.conf + +COPY --from=backend-runner /app /backend + +COPY --from=frontend-runner /app/ /app/ + +EXPOSE 80 +ENV HTTPS_ENABLED false + +CMD ["pm2-runtime", "start", "ecosystem.config.js"] + + diff --git a/README.md b/README.md index 1f7d85307..ae863df21 100644 --- a/README.md +++ b/README.md @@ -78,16 +78,16 @@ To set up and run Infisical locally, make sure you have Git and Docker installed Linux/macOS: ```console -git clone https://github.com/Infisical/infisical && cd "$(basename $_ .git)" && cp .env.example .env && docker-compose -f docker-compose.dev.yml up --build +git clone https://github.com/Infisical/infisical && cd "$(basename $_ .git)" && cp .env.example .env && docker-compose -f docker-compose.yml up ``` Windows Command Prompt: ```console -git clone https://github.com/Infisical/infisical && cd infisical && copy .env.example .env && docker-compose -f docker-compose.dev.yml up --build +git clone https://github.com/Infisical/infisical && cd infisical && copy .env.example .env && docker-compose -f docker-compose.yml up ``` -Login to the web app at `http://localhost:8080` by entering the test user email `test@localhost.local` and password `testInfisical1`. +Create an account at `http://localhost:80` ## Open-source vs. paid diff --git a/backend/src/controllers/v1/membershipOrgController.ts b/backend/src/controllers/v1/membershipOrgController.ts index 18247f10f..e5714516e 100644 --- a/backend/src/controllers/v1/membershipOrgController.ts +++ b/backend/src/controllers/v1/membershipOrgController.ts @@ -188,7 +188,7 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => { }); if (!(await getSmtpConfigured())) { - completeInviteLink = `${siteUrl + '/signupinvite'}?token=${token}&to=${inviteeEmail}` + completeInviteLink = `${siteUrl + '/signupinvite'}?token=${token}&to=${inviteeEmail}&organization_id=${organization._id}` } } @@ -217,10 +217,10 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => { export const verifyUserToOrganization = async (req: Request, res: Response) => { let user, token; try { - const { - email, + const { + email, organizationId, - code + code } = req.body; user = await User.findOne({ email }).select('+publicKey'); diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 000000000..5c8cc6832 --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,32 @@ +module.exports = { + apps: [ + { + name: 'frontend', + script: "./scripts/start.sh", + instances: 1, + cwd: "./app", + interpreter: 'sh', + exec_mode: "fork", + autorestart: true, + watch: false, + max_memory_restart: '500M', + }, + { + name: 'backend', + script: 'npm', + args: 'run start', + cwd: "./backend", + instances: 1, + exec_mode: "fork", + autorestart: true, + watch: false, + max_memory_restart: '500M', + }, + { + name: "nginx", + script: "nginx", + args: "-g 'daemon off;'", + exec_interpreter: "none", + }, + ], +}; \ No newline at end of file diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index c8af1ce46..d807b45b7 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -1,3 +1,4 @@ +import Link from 'next/link'; import { useRouter } from 'next/router'; import { faAngleRight } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -21,6 +22,7 @@ import { Select, SelectItem, Tooltip } from '../v2'; * @param {string} obj.onEnvChange - the action that happens when an env is changed * @returns */ +// TODO(akhilmhdh): simply this header and nav system later export default function NavHeader({ pageName, isProjectRelated, @@ -38,7 +40,7 @@ export default function NavHeader({ }): JSX.Element { const { currentWorkspace } = useWorkspace(); const { currentOrg } = useOrganization(); - const router = useRouter() + const router = useRouter(); return (
@@ -59,31 +61,40 @@ export default function NavHeader({ )} - {pageName === 'Secrets' - ? {pageName} - :
{pageName}
} - {currentEnv && - <> - -
- - - -
- } + {pageName === 'Secrets' ? ( + + {pageName} + + ) : ( +
{pageName}
+ )} + {currentEnv && ( + <> + +
+ + + +
+ + )}
); } diff --git a/frontend/src/hooks/api/secrets/index.ts b/frontend/src/hooks/api/secrets/index.ts index c27cecfb1..013b9aef5 100644 --- a/frontend/src/hooks/api/secrets/index.ts +++ b/frontend/src/hooks/api/secrets/index.ts @@ -1 +1,6 @@ -export { useBatchSecretsOp, useGetProjectSecrets, useGetSecretVersion } from './queries'; +export { + useBatchSecretsOp, + useGetProjectSecrets, + useGetProjectSecretsByKey, + useGetSecretVersion +} from './queries'; diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index 031c661d1..ecc26c63d 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -19,7 +19,10 @@ import { export const secretKeys = { // this is also used in secretSnapshot part - getProjectSecret: (workspaceId: string, env: string | string[]) => [{ workspaceId, env }, 'secrets'], + getProjectSecret: (workspaceId: string, env: string | string[]) => [ + { workspaceId, env }, + 'secrets' + ], getSecretVersion: (secretId: string) => [{ secretId }, 'secret-versions'] }; @@ -32,11 +35,11 @@ const fetchProjectEncryptedSecrets = async (workspaceId: string, env: string | s } }); return data.secrets; - } - + } + if (typeof env === 'object') { let allEnvData: any = []; - + // eslint-disable-next-line no-restricted-syntax for (const envPoint of env) { // eslint-disable-next-line no-await-in-loop @@ -48,13 +51,12 @@ const fetchProjectEncryptedSecrets = async (workspaceId: string, env: string | s }); allEnvData = allEnvData.concat(data.secrets); } - + return allEnvData; - // eslint-disable-next-line no-else-return + // eslint-disable-next-line no-else-return } else { return null; } - }; export const useGetProjectSecrets = ({ @@ -117,7 +119,10 @@ export const useGetProjectSecrets = ({ }; if (encSecret.type === 'personal') { - personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = { id: encSecret._id, value: secretValue }; + personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = { + id: encSecret._id, + value: secretValue + }; } else { if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) { sharedSecrets.push(decryptedSecret); @@ -126,17 +131,106 @@ export const useGetProjectSecrets = ({ } }); sharedSecrets.forEach((val) => { - if (personalSecrets?.[val.key]) { - val.idOverride = personalSecrets[val.key].id; - val.valueOverride = personalSecrets[val.key].value; + const dupKey = `${val.key}-${val.env}`; + if (personalSecrets?.[dupKey]) { + val.idOverride = personalSecrets[dupKey].id; + val.valueOverride = personalSecrets[dupKey].value; val.overrideAction = 'modified'; } }); - return { secrets: sharedSecrets }; } }); +export const useGetProjectSecretsByKey = ({ + workspaceId, + env, + decryptFileKey, + isPaused +}: GetProjectSecretsDTO) => + useQuery({ + // wait for all values to be available + enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused, + queryKey: secretKeys.getProjectSecret(workspaceId, env), + queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env), + select: (data) => { + const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string; + const latestKey = decryptFileKey; + const key = decryptAssymmetric({ + ciphertext: latestKey.encryptedKey, + nonce: latestKey.nonce, + publicKey: latestKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const sharedSecrets: Record = {}; + const personalSecrets: Record = {}; + // this used for add-only mode in dashboard + // type won't be there thus only one key is shown + const duplicateSecretKey: Record = {}; + const uniqSecKeys: Record = {}; + data.forEach((encSecret: EncryptedSecret) => { + const secretKey = decryptSymmetric({ + ciphertext: encSecret.secretKeyCiphertext, + iv: encSecret.secretKeyIV, + tag: encSecret.secretKeyTag, + key + }); + if (!uniqSecKeys?.[secretKey]) uniqSecKeys[secretKey] = true; + + const secretValue = decryptSymmetric({ + ciphertext: encSecret.secretValueCiphertext, + iv: encSecret.secretValueIV, + tag: encSecret.secretValueTag, + key + }); + + const secretComment = decryptSymmetric({ + ciphertext: encSecret.secretCommentCiphertext, + iv: encSecret.secretCommentIV, + tag: encSecret.secretCommentTag, + key + }); + + const decryptedSecret = { + _id: encSecret._id, + env: encSecret.environment, + key: secretKey, + value: secretValue, + tags: encSecret.tags, + comment: secretComment, + createdAt: encSecret.createdAt, + updatedAt: encSecret.updatedAt + }; + + if (encSecret.type === 'personal') { + personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = { + id: encSecret._id, + value: secretValue + }; + } else { + if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) { + if (!sharedSecrets?.[secretKey]) sharedSecrets[secretKey] = []; + sharedSecrets[secretKey].push(decryptedSecret); + } + duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true; + } + }); + Object.keys(sharedSecrets).forEach((secName) => { + sharedSecrets[secName].forEach((val) => { + const dupKey = `${val.key}-${val.env}`; + if (personalSecrets?.[dupKey]) { + val.idOverride = personalSecrets[dupKey].id; + val.valueOverride = personalSecrets[dupKey].value; + val.overrideAction = 'modified'; + } + }); + }); + + return { secrets: sharedSecrets, uniqueSecCount: Object.keys(uniqSecKeys).length }; + } + }); + const fetchEncryptedSecretVersion = async (secretId: string, offset: number, limit: number) => { const { data } = await apiRequest.get<{ secretVersions: EncryptedSecretVersion[] }>( `/api/v1/secret/${secretId}/secret-versions`, diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 567fecc9a..eb1157e91 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -62,6 +62,7 @@ type SecretTagArg = { _id: string; name: string; slug: string }; export type UpdateSecretArg = { _id: string; type: 'shared' | 'personal'; + secretName: string; secretKeyCiphertext: string; secretKeyIV: string; secretKeyTag: string; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 39a80b7b4..4c1f15ebd 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -8,11 +8,7 @@ import { Controller, useForm } from 'react-hook-form'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; -import { - faBookOpen, - faMobile, - faPlus, -} from '@fortawesome/free-solid-svg-icons'; +import { faBookOpen, faMobile, faPlus } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { yupResolver } from '@hookform/resolvers/yup'; import queryString from 'query-string'; @@ -110,7 +106,6 @@ export const AppLayout = ({ children }: LayoutProps) => { ) { router.push('/noprojects'); } else if (router.asPath !== '/noprojects') { - // const pathSegments = router.asPath.split('/').filter(segment => segment.length > 0); // let intendedWorkspaceId; @@ -123,8 +118,8 @@ export const AppLayout = ({ children }: LayoutProps) => { // .split('/') // [router.asPath.split('/').length - 1].split('?')[0]; // } - - const pathSegments = router.asPath.split('/').filter(segment => segment.length > 0); + + const pathSegments = router.asPath.split('/').filter((segment) => segment.length > 0); let intendedWorkspaceId; if (pathSegments.length >= 2 && pathSegments[0] === 'dashboard') { @@ -140,7 +135,7 @@ export const AppLayout = ({ children }: LayoutProps) => { // const lastPathSegment = router.asPath.split('/').pop().split('?'); // [intendedWorkspaceId] = lastPathSegment; } - + if (!intendedWorkspaceId) return; if (!['callback', 'create', 'authorize'].includes(intendedWorkspaceId)) { @@ -149,7 +144,8 @@ export const AppLayout = ({ children }: LayoutProps) => { // If a user is not a member of a workspace they are trying to access, just push them to one of theirs if ( - !['callback', 'create', 'authorize'].includes(intendedWorkspaceId) && userWorkspaces[0]?._id !== undefined && + !['callback', 'create', 'authorize'].includes(intendedWorkspaceId) && + userWorkspaces[0]?._id !== undefined && !userWorkspaces .map((workspace: { _id: string }) => workspace._id) .includes(intendedWorkspaceId) @@ -240,21 +236,21 @@ export const AppLayout = ({ children }: LayoutProps) => { return ( <> -
+