├── server ├── migrations │ └── .gitkeep ├── .eslintignore ├── src │ ├── commons │ │ ├── force-chalk-colors.ts │ │ ├── enum-to-array.ts │ │ ├── utils │ │ │ ├── array-set.ts │ │ │ ├── set-replacer.ts │ │ │ ├── month-diff.ts │ │ │ ├── ensure-empty-directory.ts │ │ │ ├── base64.ts │ │ │ ├── wrap-async-middleware.ts │ │ │ └── exec-async.ts │ │ ├── types │ │ │ ├── duration.ts │ │ │ └── constructor.ts │ │ ├── multer │ │ │ └── types.ts │ │ ├── validators │ │ │ ├── to-url.ts │ │ │ ├── is-certificate.ts │ │ │ ├── is-url.ts │ │ │ ├── is-rsa-private-key.ts │ │ │ ├── is-cron-expression.ts │ │ │ └── is-moment-timezone.ts │ │ ├── errors │ │ │ ├── invalid-environment-error.ts │ │ │ ├── not-found-error.ts │ │ │ ├── forbidden-error.ts │ │ │ ├── unauthorized-error.ts │ │ │ ├── app-error.ts │ │ │ ├── http-error.ts │ │ │ └── bad-request-error.ts │ │ ├── express │ │ │ ├── handlers │ │ │ │ └── no-content.ts │ │ │ └── guard.ts │ │ ├── axios │ │ │ ├── axios.ts │ │ │ ├── axios-error.ts │ │ │ └── ensure-stack-trace.ts │ │ └── express-joi │ │ │ ├── params.ts │ │ │ ├── query.ts │ │ │ └── body.ts │ ├── auth │ │ ├── passport │ │ │ ├── auth-methods.ts │ │ │ └── providers │ │ │ │ ├── github │ │ │ │ └── types │ │ │ │ │ ├── github-email.ts │ │ │ │ │ └── github-org.ts │ │ │ │ ├── gitea │ │ │ │ └── types │ │ │ │ │ ├── gitea-oauth-token-grant.ts │ │ │ │ │ ├── gitea-user.ts │ │ │ │ │ └── gitea-org.ts │ │ │ │ └── gitlab │ │ │ │ └── types │ │ │ │ ├── gitlab-oauth-token-grant.ts │ │ │ │ └── gitlab-group.ts │ │ ├── utils │ │ │ ├── get-user.ts │ │ │ ├── get-user.spec.ts │ │ │ ├── get-user-from-socket.ts │ │ │ └── get-user-from-socket.spec.ts │ │ ├── serialize-user.ts │ │ ├── handlers │ │ │ ├── get-auth-methods.ts │ │ │ ├── sign-out.ts │ │ │ └── redirect-to-ui.ts │ │ ├── guards │ │ │ ├── is-owner.ts │ │ │ ├── is-admin.ts │ │ │ ├── is-admin-or-owner.ts │ │ │ ├── api-guard.ts │ │ │ ├── is-admin-or-owner-guard.ts │ │ │ └── auth-guard.ts │ │ ├── passport.ts │ │ └── auth.ts │ ├── utils │ │ ├── uuid.ts │ │ ├── id.ts │ │ ├── arrays-utils.ts │ │ ├── generate-token-value.ts │ │ ├── slugify.ts │ │ ├── basic-auth.ts │ │ ├── basic-auth.spec.ts │ │ ├── get-logo-url.ts │ │ └── get-pagination.ts │ ├── entities │ │ ├── sites │ │ │ ├── password.ts │ │ │ ├── header.ts │ │ │ ├── get-branch-domain.ts │ │ │ ├── serialize-site-token.ts │ │ │ ├── yaml-config │ │ │ │ └── site-config.ts │ │ │ ├── get-site-url.ts │ │ │ ├── get-branch-url.ts │ │ │ ├── get-site-main-domain.ts │ │ │ ├── guards │ │ │ │ ├── is-site-token-valid.ts │ │ │ │ ├── can-admin-site.ts │ │ │ │ ├── can-admin-site-guard.ts │ │ │ │ ├── branch-exists-guard.ts │ │ │ │ └── site-exists-guard.ts │ │ │ ├── serialize-branch.ts │ │ │ ├── branch.ts │ │ │ ├── get-redirect-url.ts │ │ │ ├── serialize-redirect.ts │ │ │ ├── handlers │ │ │ │ ├── hooks │ │ │ │ │ ├── list-site-events.ts │ │ │ │ │ └── site-hook.ts │ │ │ │ └── tokens │ │ │ │ │ └── list-tokens.ts │ │ │ ├── serialize-site.ts │ │ │ └── hash-password.ts │ │ ├── orgs │ │ │ ├── invite.ts │ │ │ ├── serialize-invite.ts │ │ │ ├── serialize-org.ts │ │ │ ├── guards │ │ │ │ ├── is-org-member.ts │ │ │ │ ├── max-org-guard.ts │ │ │ │ ├── is-org-member-guard.ts │ │ │ │ ├── can-write-org-guard.ts │ │ │ │ └── org-exists-guard.ts │ │ │ ├── serialize-user-org.ts │ │ │ └── org.ts │ │ ├── api │ │ │ ├── serialize-endpoint.ts │ │ │ ├── serialize-api-token.ts │ │ │ ├── handlers │ │ │ │ ├── endpoints │ │ │ │ │ └── list-api-endpoints.ts │ │ │ │ └── tokens │ │ │ │ │ └── list-api-tokens.ts │ │ │ └── guards │ │ │ │ └── api-token-exists-guard.ts │ │ ├── members │ │ │ ├── member.ts │ │ │ ├── serialize-member.ts │ │ │ ├── routes.ts │ │ │ └── guards │ │ │ │ ├── can-admin-member-guard.ts │ │ │ │ └── member-exists-guard.ts │ │ ├── releases │ │ │ ├── release.ts │ │ │ ├── serialize-release.ts │ │ │ └── guards │ │ │ │ ├── can-admin-release.ts │ │ │ │ └── can-admin-release-guard.ts │ │ ├── teams │ │ │ ├── serialize-team-member.ts │ │ │ ├── serialize-team.ts │ │ │ ├── team.ts │ │ │ └── guards │ │ │ │ ├── can-read-team-guard.ts │ │ │ │ ├── can-admin-team-guard.ts │ │ │ │ ├── team-exists-guard.ts │ │ │ │ └── can-read-team.ts │ │ ├── users │ │ │ ├── user.ts │ │ │ ├── handlers │ │ │ │ ├── get-user-handler.ts │ │ │ │ └── invalidate-tokens.ts │ │ │ ├── routes.ts │ │ │ └── guards │ │ │ │ └── user-exists-guard.ts │ │ ├── invites │ │ │ └── serialize-invite.ts │ │ └── forms │ │ │ ├── serialize-form.ts │ │ │ ├── form.ts │ │ │ └── submit-email-form.ts │ ├── caddy │ │ ├── definitions │ │ │ ├── apps.d.ts │ │ │ ├── storage.d.ts │ │ │ ├── apps │ │ │ │ ├── http │ │ │ │ │ └── route.d.ts │ │ │ │ └── pki.d.ts │ │ │ ├── admin.d.ts │ │ │ └── config.d.ts │ │ ├── utils │ │ │ └── get-reverse-proxy-dial.ts │ │ └── config │ │ │ ├── fallback.ts │ │ │ ├── get-error-routes.ts │ │ │ └── api-route.ts │ ├── system │ │ ├── handlers │ │ │ ├── system-info.ts │ │ │ └── system-env.ts │ │ └── routes.ts │ ├── emails │ │ ├── templates │ │ │ ├── form-submission.hbs │ │ │ ├── invite.hbs │ │ │ └── README.md │ │ └── methods │ │ │ └── send-invite.ts │ ├── upload.ts │ ├── storage │ │ ├── get-file-path.ts │ │ ├── delete-file.ts │ │ └── store-file.ts │ ├── hooks │ │ ├── handlers │ │ │ ├── mattermost │ │ │ │ ├── get-mattermost-message.ts │ │ │ │ └── handle-mattermost-hook.ts │ │ │ ├── slack │ │ │ │ ├── get-slack-message.ts │ │ │ │ └── handle-slack-hook.ts │ │ │ └── web │ │ │ │ └── handle-web-hook.ts │ │ ├── serialize-hook-delivery.ts │ │ ├── hook-delivery.ts │ │ └── serialize-hook.ts │ ├── prometheus │ │ └── metrics │ │ │ ├── up.ts │ │ │ └── user-count.ts │ ├── posthog │ │ ├── posthog.ts │ │ ├── send-heartbeat.ts │ │ └── init-posthog.ts │ ├── index.ts │ ├── events │ │ └── emit-event.ts │ ├── typings.d.ts │ ├── db │ │ ├── db.ts │ │ ├── migrate │ │ │ ├── roll-forward.ts │ │ │ ├── roll-forward.spec.ts │ │ │ ├── roll-backwards.spec.ts │ │ │ ├── roll-backwards.ts │ │ │ └── migrate.ts │ │ └── build-mongo-uri.ts │ ├── constants.ts │ └── socket │ │ └── handle-socket-event.ts ├── tests │ ├── utils │ │ ├── matchers.ts │ │ ├── spyon-verifytoken.ts │ │ ├── spyon-isadmin.ts │ │ ├── spyon-isowner.ts │ │ └── spyon-isadminorowner.ts │ ├── nock.ts │ └── build-mock.ts ├── migrate-mongo-config.js ├── tsconfig.json └── .env.example ├── ui ├── src │ ├── components │ │ ├── orgs │ │ │ ├── OrgView.module.scss │ │ │ ├── staff │ │ │ │ ├── invites │ │ │ │ │ ├── AddInvite.module.scss │ │ │ │ │ ├── invite.ts │ │ │ │ │ └── Invites.module.scss │ │ │ │ ├── members │ │ │ │ │ ├── org-member.ts │ │ │ │ │ ├── Members.module.scss │ │ │ │ │ └── get-members.ts │ │ │ │ └── Staff.tsx │ │ │ ├── org.ts │ │ │ └── settings │ │ │ │ ├── OrgSettings.tsx │ │ │ │ ├── OrgLogo.module.scss │ │ │ │ └── OrgLogo.tsx │ │ ├── user │ │ │ ├── UserView.module.scss │ │ │ └── api-tokens │ │ │ │ ├── ApiScopes.module.scss │ │ │ │ ├── api-endpoint.ts │ │ │ │ ├── api-token.ts │ │ │ │ └── ApiTokenList.module.scss │ │ ├── sites │ │ │ ├── SiteView.module.scss │ │ │ ├── settings │ │ │ │ ├── SelectMainBranch.module.scss │ │ │ │ ├── SiteLogo.tsx │ │ │ │ ├── DomainForm.module.scss │ │ │ │ └── SecuritySettings.tsx │ │ │ ├── tokens │ │ │ │ ├── AddToken.module.scss │ │ │ │ ├── token.ts │ │ │ │ ├── TokenList.module.scss │ │ │ │ ├── Tokens.tsx │ │ │ │ └── get-tokens.ts │ │ │ ├── SiteCard.module.scss │ │ │ ├── branches │ │ │ │ ├── header.ts │ │ │ │ ├── redirects │ │ │ │ │ └── branch-redirects-form-data.tsx │ │ │ │ ├── branch.ts │ │ │ │ ├── BranchList.module.scss │ │ │ │ ├── get-branches.ts │ │ │ │ ├── Branches.tsx │ │ │ │ ├── branch-redirect.ts │ │ │ │ └── settings │ │ │ │ │ └── BranchSettings.tsx │ │ │ ├── SiteList.module.scss │ │ │ ├── releases │ │ │ │ ├── Search.module.scss │ │ │ │ ├── release.ts │ │ │ │ └── get-releases.ts │ │ │ ├── get-team-sites.ts │ │ │ ├── search │ │ │ │ └── SearchModal.module.scss │ │ │ └── SiteCard.tsx │ │ ├── teams │ │ │ ├── TeamView.module.scss │ │ │ ├── TeamList.module.scss │ │ │ ├── members │ │ │ │ ├── team-member.ts │ │ │ │ ├── Members.module.scss │ │ │ │ ├── add │ │ │ │ │ └── AddMember.module.scss │ │ │ │ └── get-members.ts │ │ │ ├── team.ts │ │ │ └── settings │ │ │ │ └── TeamSettings.tsx │ │ ├── Logo.module.scss │ │ ├── SubHeader.module.scss │ │ ├── hooks │ │ │ ├── HookTypeIcon.module.scss │ │ │ ├── form │ │ │ │ ├── configs │ │ │ │ │ └── Slack.module.scss │ │ │ │ └── HookForm.module.scss │ │ │ ├── deliveries │ │ │ │ └── hook-delivery.ts │ │ │ ├── hook.ts │ │ │ ├── HookList.module.scss │ │ │ ├── HookProvider.tsx │ │ │ └── Hooks.tsx │ │ ├── UserHome.tsx │ │ ├── auth │ │ │ ├── methods │ │ │ │ ├── SignInWithGitHub.module.scss │ │ │ │ ├── SignInWithGitlab.module.scss │ │ │ │ ├── SignInWithGoogle.module.scss │ │ │ │ ├── SignInWithGitea.module.scss │ │ │ │ ├── SignInButton.tsx │ │ │ │ ├── SignInWithGitea.tsx │ │ │ │ ├── SignInWithGithub.tsx │ │ │ │ ├── SignInWithGitlab.tsx │ │ │ │ └── SignInWithGoogle.tsx │ │ │ ├── user-org.ts │ │ │ ├── IsOwner.tsx │ │ │ ├── IsAdmin.tsx │ │ │ ├── SignIn.module.scss │ │ │ ├── UserInfo.module.scss │ │ │ └── Orgs.module.scss │ │ ├── sidebar │ │ │ ├── SideBar.module.scss │ │ │ ├── SideBar.tsx │ │ │ ├── Teams.module.scss │ │ │ └── Sites.module.scss │ │ ├── invites │ │ │ ├── user-invite.ts │ │ │ └── UserInviteView.module.scss │ │ ├── commons │ │ │ └── Logo.module.scss │ │ ├── SubHeader.tsx │ │ ├── icons │ │ │ ├── FormIcon.tsx │ │ │ ├── HookIcon.tsx │ │ │ ├── OrgIcon.tsx │ │ │ ├── TokenIcon.tsx │ │ │ ├── HeaderIcon.tsx │ │ │ ├── ReleaseIcon.tsx │ │ │ ├── SecurityIcon.tsx │ │ │ ├── SiteIcon.tsx │ │ │ ├── SettingsIcon.tsx │ │ │ ├── BranchIcon.tsx │ │ │ ├── InviteIcon.tsx │ │ │ ├── TeamIcon.tsx │ │ │ ├── UserIcon.tsx │ │ │ ├── RedirectIcon.tsx │ │ │ ├── OrgMemberIcon.tsx │ │ │ ├── HookDeliveryIcon.tsx │ │ │ └── TeamMemberIcon.tsx │ │ ├── Home.module.scss │ │ └── Footer.module.scss │ ├── Hello.module.scss │ ├── providers │ │ ├── BlurProvider.module.scss │ │ ├── history.ts │ │ └── axios.ts │ ├── commons │ │ ├── components │ │ │ ├── Pagination.module.scss │ │ │ ├── DocsLink.scss │ │ │ ├── NotFound.tsx │ │ │ ├── Bubble.module.scss │ │ │ ├── CopyToClipboard.module.scss │ │ │ ├── dropdown │ │ │ │ ├── DropdownSeparator.module.scss │ │ │ │ ├── DropdownSeparator.tsx │ │ │ │ ├── DropdownLink.module.scss │ │ │ │ └── DropDown.module.scss │ │ │ ├── modals │ │ │ │ ├── CloseModal.module.scss │ │ │ │ ├── CardModal.module.scss │ │ │ │ └── CloseModal.tsx │ │ │ ├── FullPageCentered.tsx │ │ │ ├── CenteredLoader.tsx │ │ │ ├── Toasts.tsx │ │ │ ├── FullPageLoader.tsx │ │ │ ├── KeyboardShortcut.module.scss │ │ │ ├── AlertError.tsx │ │ │ ├── Hint.module.scss │ │ │ ├── forms │ │ │ │ └── InputError.tsx │ │ │ ├── Loader.tsx │ │ │ ├── ProgressBar.module.scss │ │ │ ├── ExternalLink.tsx │ │ │ ├── EmptyList.module.scss │ │ │ ├── Gauge.module.scss │ │ │ ├── Currency.tsx │ │ │ ├── EmptyList.tsx │ │ │ ├── ButtonIcon.module.scss │ │ │ ├── Tooltip.tsx │ │ │ ├── Bubble.tsx │ │ │ ├── LoadMore.module.scss │ │ │ ├── AdBlockWarning.tsx │ │ │ ├── KeyboardShortcut.tsx │ │ │ ├── DocsLink.tsx │ │ │ ├── FromNow.tsx │ │ │ ├── ButtonIcon.tsx │ │ │ ├── Tooltip.module.scss │ │ │ ├── PrivateRoute.tsx │ │ │ ├── ErrorIcon.tsx │ │ │ ├── Toasts.scss │ │ │ └── LoadMore.tsx │ │ ├── types │ │ │ ├── page.ts │ │ │ └── react-state.ts │ │ ├── utils │ │ │ ├── route-up.ts │ │ │ ├── enum-to-array.ts │ │ │ ├── os.ts │ │ │ └── random-string.ts │ │ ├── keyboard │ │ │ ├── shortcuts-keys.ts │ │ │ └── use-shortcut.ts │ │ ├── sentry │ │ │ └── SentryProvider.module.scss │ │ └── hooks │ │ │ └── use-mounted-state.ts │ ├── utils │ │ ├── dedup-slashes.ts │ │ ├── query-params.ts │ │ ├── extract-exrror-message.ts │ │ ├── format-duration.ts │ │ ├── text-search.ts │ │ ├── debounce-time.ts │ │ ├── suffix-time.ts │ │ └── suffix-big-number.ts │ ├── styles │ │ ├── fonts.scss │ │ └── animations.scss │ ├── setupTests.ts │ ├── websockets │ │ ├── listen.ts │ │ ├── emit-now-and-on-reconnect.ts │ │ ├── listen-many.ts │ │ └── SocketProvider.tsx │ ├── setupProxy.js │ ├── react-app-env.d.ts │ ├── posthog │ │ └── PosthogWarning.module.scss │ ├── Hello.tsx │ └── App.module.scss ├── public │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── robots.txt │ ├── apple-touch-icon.png │ ├── mstile-150x150.png │ ├── assets │ │ ├── fonts │ │ │ ├── ocraext.ttf │ │ │ ├── Graphik-Bold.ttf │ │ │ ├── Graphik-Medium.ttf │ │ │ ├── Graphik-Regular.ttf │ │ │ ├── Graphik-Semibold.ttf │ │ │ └── OxygenMono-Regular.ttf │ │ ├── get-slack-webhook-url.mp4 │ │ └── ads.js │ ├── android-chrome-192x192.png │ ├── android-chrome-256x256.png │ ├── browserconfig.xml │ └── site.webmanifest ├── scripts │ └── build-info.js └── tsconfig.json ├── scripts ├── publish.sh ├── build.sh ├── setup-git.sh ├── build-info.js └── rebase-git-branch.sh ├── caddy └── config.json ├── docker ├── caddy-config.json └── entrypoint.sh ├── .dockerignore ├── upload.sh ├── .editorconfig ├── ci └── setup-git.sh ├── .github └── pull_request_template.md ├── .gitignore ├── SECURITY.md ├── docker-compose-dev.yml ├── docker-compose.yml └── .releaserc.js /server/migrations/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/components/orgs/OrgView.module.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/components/user/UserView.module.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | docker push meli/server 2 | -------------------------------------------------------------------------------- /server/.eslintignore: -------------------------------------------------------------------------------- 1 | *.spec.ts 2 | *.spec.js 3 | -------------------------------------------------------------------------------- /ui/src/components/sites/SiteView.module.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/components/teams/TeamView.module.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/components/sites/settings/SelectMainBranch.module.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/Hello.module.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | transform: scale(2); 3 | } 4 | -------------------------------------------------------------------------------- /server/src/commons/force-chalk-colors.ts: -------------------------------------------------------------------------------- 1 | process.env.FORCE_COLOR = '1'; 2 | -------------------------------------------------------------------------------- /server/src/auth/passport/auth-methods.ts: -------------------------------------------------------------------------------- 1 | export const authMethods: string[] = []; 2 | -------------------------------------------------------------------------------- /server/src/utils/uuid.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid'; 2 | 3 | export const uuid = v4; 4 | -------------------------------------------------------------------------------- /ui/src/providers/BlurProvider.module.scss: -------------------------------------------------------------------------------- 1 | .blur { 2 | filter: blur(8px); 3 | } 4 | -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getmeli/meli/HEAD/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/src/commons/components/Pagination.module.scss: -------------------------------------------------------------------------------- 1 | .size-select { 2 | width: 110px; 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/components/Logo.module.scss: -------------------------------------------------------------------------------- 1 | @import '../styles/variables'; 2 | 3 | .logo { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/components/user/api-tokens/ApiScopes.module.scss: -------------------------------------------------------------------------------- 1 | .method { 2 | width: 80px; 3 | } 4 | -------------------------------------------------------------------------------- /ui/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getmeli/meli/HEAD/ui/public/favicon-16x16.png -------------------------------------------------------------------------------- /ui/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getmeli/meli/HEAD/ui/public/favicon-32x32.png -------------------------------------------------------------------------------- /ui/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /server/src/utils/id.ts: -------------------------------------------------------------------------------- 1 | import { string } from 'joi'; 2 | 3 | export const $id = string().required(); 4 | -------------------------------------------------------------------------------- /ui/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getmeli/meli/HEAD/ui/public/apple-touch-icon.png -------------------------------------------------------------------------------- /ui/public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getmeli/meli/HEAD/ui/public/mstile-150x150.png -------------------------------------------------------------------------------- /ui/src/commons/types/page.ts: -------------------------------------------------------------------------------- 1 | export interface Page { 2 | items: T[]; 3 | count: number; 4 | } 5 | -------------------------------------------------------------------------------- /ui/src/components/sites/tokens/AddToken.module.scss: -------------------------------------------------------------------------------- 1 | .form { 2 | width: 400px; 3 | max-width: 100%; 4 | } 5 | -------------------------------------------------------------------------------- /caddy/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "admin": { 3 | "disabled": false, 4 | "listen": "0.0.0.0:2019" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /server/src/entities/sites/password.ts: -------------------------------------------------------------------------------- 1 | export interface Password { 2 | hash: string; 3 | salt: string; 4 | } 5 | -------------------------------------------------------------------------------- /ui/public/assets/fonts/ocraext.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getmeli/meli/HEAD/ui/public/assets/fonts/ocraext.ttf -------------------------------------------------------------------------------- /ui/src/components/sites/SiteCard.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/variables'; 2 | 3 | .container { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /docker/caddy-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "admin": { 3 | "disabled": false, 4 | "listen": "0.0.0.0:2019" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /server/src/commons/enum-to-array.ts: -------------------------------------------------------------------------------- 1 | export function enumToArray(e): string[] { 2 | return Object.values(e); 3 | } 4 | -------------------------------------------------------------------------------- /ui/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getmeli/meli/HEAD/ui/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /ui/public/android-chrome-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getmeli/meli/HEAD/ui/public/android-chrome-256x256.png -------------------------------------------------------------------------------- /ui/src/commons/components/DocsLink.scss: -------------------------------------------------------------------------------- 1 | .docs-link { 2 | font-size: .75rem; 3 | text-transform: uppercase; 4 | } 5 | -------------------------------------------------------------------------------- /ui/src/components/SubHeader.module.scss: -------------------------------------------------------------------------------- 1 | @import "../styles/variables"; 2 | 3 | .header { 4 | margin: 1rem 0; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/components/orgs/staff/invites/AddInvite.module.scss: -------------------------------------------------------------------------------- 1 | .form { 2 | width: 400px; 3 | max-width: 100%; 4 | } 5 | -------------------------------------------------------------------------------- /ui/src/components/sites/branches/header.ts: -------------------------------------------------------------------------------- 1 | export interface Header { 2 | name: string; 3 | value: string; 4 | } 5 | -------------------------------------------------------------------------------- /server/src/commons/utils/array-set.ts: -------------------------------------------------------------------------------- 1 | export function arraySet(arr: string[]) { 2 | return Array.from(new Set(arr)); 3 | } 4 | -------------------------------------------------------------------------------- /ui/public/assets/fonts/Graphik-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getmeli/meli/HEAD/ui/public/assets/fonts/Graphik-Bold.ttf -------------------------------------------------------------------------------- /ui/public/assets/fonts/Graphik-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getmeli/meli/HEAD/ui/public/assets/fonts/Graphik-Medium.ttf -------------------------------------------------------------------------------- /ui/public/assets/fonts/Graphik-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getmeli/meli/HEAD/ui/public/assets/fonts/Graphik-Regular.ttf -------------------------------------------------------------------------------- /ui/public/assets/get-slack-webhook-url.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getmeli/meli/HEAD/ui/public/assets/get-slack-webhook-url.mp4 -------------------------------------------------------------------------------- /ui/src/commons/utils/route-up.ts: -------------------------------------------------------------------------------- 1 | export function routeUp(url: string) { 2 | return url.split('/').slice(0, -1).join('/'); 3 | } 4 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | npm i 2 | npm run build 3 | rm -rf node_modules 4 | npm ci --production 5 | docker build -t meli/server . 6 | npm i 7 | -------------------------------------------------------------------------------- /ui/public/assets/fonts/Graphik-Semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getmeli/meli/HEAD/ui/public/assets/fonts/Graphik-Semibold.ttf -------------------------------------------------------------------------------- /ui/public/assets/fonts/OxygenMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getmeli/meli/HEAD/ui/public/assets/fonts/OxygenMono-Regular.ttf -------------------------------------------------------------------------------- /ui/src/components/hooks/HookTypeIcon.module.scss: -------------------------------------------------------------------------------- 1 | .icon { 2 | height: 1.15em; 3 | display: inline-block; 4 | width: auto; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/commons/components/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function NotFound() { 4 | return <>Not found; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/commons/utils/enum-to-array.ts: -------------------------------------------------------------------------------- 1 | export function enumToArray(e): string[] { 2 | return Array.from(new Set(Object.keys(e))); 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/providers/history.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history'; 2 | 3 | export const routerHistory = createBrowserHistory(); 4 | -------------------------------------------------------------------------------- /ui/src/components/UserHome.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function UserHome() { 4 | return ( 5 | <>Hello 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /server/src/commons/types/duration.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * process.hrtime format: [seconds, microseconds] 3 | */ 4 | export type Duration = [number, number]; 5 | -------------------------------------------------------------------------------- /server/src/commons/multer/types.ts: -------------------------------------------------------------------------------- 1 | import { Options as MulterOptions } from 'multer'; 2 | 3 | export type MulterLimitOptions = MulterOptions['limits']; 4 | -------------------------------------------------------------------------------- /server/src/commons/utils/set-replacer.ts: -------------------------------------------------------------------------------- 1 | export function setReplacer(key, value) { 2 | return value instanceof Set ? Array.from(value) : value; 3 | } 4 | -------------------------------------------------------------------------------- /server/src/commons/validators/to-url.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'url'; 2 | 3 | export function toUrl(str: string): URL { 4 | return new URL(str); 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/components/auth/methods/SignInWithGitHub.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/variables'; 2 | 3 | .github { 4 | background: $github-bg; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/components/auth/methods/SignInWithGitlab.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/variables'; 2 | 3 | .gitlab { 4 | background: $gitlab-bg; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/components/sites/tokens/token.ts: -------------------------------------------------------------------------------- 1 | export interface Token { 2 | _id: string; 3 | name: string; 4 | value: string; 5 | createdAt: Date; 6 | } 7 | -------------------------------------------------------------------------------- /ui/src/utils/dedup-slashes.ts: -------------------------------------------------------------------------------- 1 | export function dedupSlashes(str: string): string { 2 | return str ? str.replace(/(([^:]|^)\/)\/+/g, '$1') : str; 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/commons/types/react-state.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from 'react'; 2 | 3 | export type ReactState = [T, Dispatch>]; 4 | -------------------------------------------------------------------------------- /ui/src/components/sidebar/SideBar.module.scss: -------------------------------------------------------------------------------- 1 | @import 'src/styles/variables'; 2 | 3 | .container { 4 | flex-shrink: 0; 5 | background: $gray-100; 6 | } 7 | -------------------------------------------------------------------------------- /ui/src/components/sites/SiteList.module.scss: -------------------------------------------------------------------------------- 1 | .loader { 2 | position: absolute; 3 | right: 15px; 4 | top: 50%; 5 | transform: translateY(-50%); 6 | } 7 | -------------------------------------------------------------------------------- /ui/src/components/teams/TeamList.module.scss: -------------------------------------------------------------------------------- 1 | .loader { 2 | position: absolute; 3 | right: 15px; 4 | top: 50%; 5 | transform: translateY(-50%); 6 | } 7 | -------------------------------------------------------------------------------- /server/src/utils/arrays-utils.ts: -------------------------------------------------------------------------------- 1 | export function unique(element: T, index: number, array: T[]): boolean { 2 | return array.indexOf(element) === index; 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/components/sites/releases/Search.module.scss: -------------------------------------------------------------------------------- 1 | .loader { 2 | position: absolute; 3 | right: 15px; 4 | top: 50%; 5 | transform: translateY(-50%); 6 | } 7 | -------------------------------------------------------------------------------- /server/tests/utils/matchers.ts: -------------------------------------------------------------------------------- 1 | export function jsonDate() { 2 | return expect.stringMatching(/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z/); 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/components/teams/members/team-member.ts: -------------------------------------------------------------------------------- 1 | export interface TeamMember { 2 | memberId: string; 3 | name: string; 4 | email: string; 5 | admin: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !server/package.json 3 | !server/package-lock.json 4 | !server/migrate-mongo-config.js 5 | !server/migrations 6 | !server/build 7 | !ui/build 8 | !docker 9 | -------------------------------------------------------------------------------- /server/src/commons/utils/month-diff.ts: -------------------------------------------------------------------------------- 1 | export function monthDiff(d1: Date, d2: Date): number { 2 | return Math.abs((d2.getTime() - d1.getTime()) / 86400000 / 30.5); 3 | } 4 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -e 3 | 4 | caddy start --config /etc/caddy/config.json 5 | 6 | echo "MELI_API_URL=$MELI_URL" > /app/ui/env.txt 7 | 8 | exec "$@" 9 | -------------------------------------------------------------------------------- /server/src/utils/generate-token-value.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from 'crypto'; 2 | 3 | export function generateTokenValue() { 4 | return randomBytes(32).toString('hex'); 5 | } 6 | -------------------------------------------------------------------------------- /server/src/caddy/definitions/apps.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Caddy { 2 | interface Apps { 3 | '@id'?: string; 4 | http?: Http; 5 | pki?: Pki; 6 | tls?: Tls; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /server/src/commons/errors/invalid-environment-error.ts: -------------------------------------------------------------------------------- 1 | export class InvalidEnvironmentError extends Error { 2 | constructor() { 3 | super('Invalid environment'); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /server/src/system/handlers/system-info.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | export function systemInfo(req: Request, res: Response) { 4 | res.json(BUILD_INFO); 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/commons/components/Bubble.module.scss: -------------------------------------------------------------------------------- 1 | @import "src/styles/variables"; 2 | 3 | .bubble { 4 | width: 20px; 5 | height: 20px; 6 | border-radius: 50%; 7 | display: block; 8 | } 9 | -------------------------------------------------------------------------------- /ui/src/components/orgs/org.ts: -------------------------------------------------------------------------------- 1 | export interface Org { 2 | _id: string; 3 | name: string; 4 | color: string; 5 | logo?: string; 6 | createdAt: Date; 7 | updatedAt: Date; 8 | } 9 | -------------------------------------------------------------------------------- /ui/src/components/orgs/staff/members/org-member.ts: -------------------------------------------------------------------------------- 1 | export interface OrgMember { 2 | _id: string; 3 | name: string; 4 | email: string; 5 | admin: boolean; 6 | owner: boolean; 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/commons/components/CopyToClipboard.module.scss: -------------------------------------------------------------------------------- 1 | @import "src/styles/mixins"; 2 | 3 | .container { 4 | cursor: pointer; 5 | } 6 | 7 | .blur { 8 | @include blur(8px); 9 | } 10 | -------------------------------------------------------------------------------- /ui/src/providers/axios.ts: -------------------------------------------------------------------------------- 1 | import axiosLib from 'axios'; 2 | 3 | export const axios = axiosLib.create({ 4 | withCredentials: true, 5 | }); 6 | 7 | export const { CancelToken } = axiosLib; 8 | -------------------------------------------------------------------------------- /server/src/auth/passport/providers/github/types/github-email.ts: -------------------------------------------------------------------------------- 1 | export interface GithubEmail { 2 | email: string; 3 | verified: boolean; 4 | primary: boolean; 5 | visibility: string; 6 | } 7 | -------------------------------------------------------------------------------- /server/src/commons/express/handlers/no-content.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | export function noContent(req: Request, res: Response) { 4 | res.status(204).send(); 5 | } 6 | -------------------------------------------------------------------------------- /server/src/commons/types/constructor.ts: -------------------------------------------------------------------------------- 1 | export interface Constructor extends Function { 2 | prototype: T; 3 | name: string; 4 | 5 | new(...args: A): T; 6 | } 7 | -------------------------------------------------------------------------------- /server/src/emails/templates/form-submission.hbs: -------------------------------------------------------------------------------- 1 | Dear, 2 | 3 | A new submission for form "{{formName}}" was made: 4 | 5 | {{{formData}}} 6 | 7 | Keep up the great work, 8 | Your beloved Meli team. 9 | -------------------------------------------------------------------------------- /server/src/utils/slugify.ts: -------------------------------------------------------------------------------- 1 | import slug from 'slugify'; 2 | 3 | export function slugify(str: string): string { 4 | return slug(str, { 5 | replacement: '-', 6 | lower: true, 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /ui/public/assets/ads.js: -------------------------------------------------------------------------------- 1 | // https://www.detectadblock.com/ 2 | var e = document.createElement('div'); 3 | e.id = 'adsjs-wlegKJyqQhLm'; 4 | e.style.display = 'none'; 5 | document.body.appendChild(e); 6 | -------------------------------------------------------------------------------- /ui/src/components/orgs/staff/invites/invite.ts: -------------------------------------------------------------------------------- 1 | export interface Invite { 2 | _id: string; 3 | email: string; 4 | expiresAt: Date; 5 | memberOptions: { 6 | admin: boolean; 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /server/src/emails/templates/invite.hbs: -------------------------------------------------------------------------------- 1 | Hello, 2 | 3 | You have been invited to join {{org}} in Meli. 4 | 5 | Click here to join: {{{url}}} 6 | 7 | Keep up the great work, 8 | Your beloved Meli team. 9 | -------------------------------------------------------------------------------- /server/src/upload.ts: -------------------------------------------------------------------------------- 1 | import multer from 'multer'; 2 | import { env } from './env/env'; 3 | 4 | export const upload = multer({ 5 | dest: env.MELI_TMP_DIRECTORY, 6 | limits: env.MELI_MULTER_LIMITS, 7 | }); 8 | -------------------------------------------------------------------------------- /server/src/utils/basic-auth.ts: -------------------------------------------------------------------------------- 1 | export function basicAuth(user: string, password: string): string { 2 | const data = `${user}:${password}`; 3 | return `Basic ${Buffer.from(data).toString('base64')}`; 4 | } 5 | -------------------------------------------------------------------------------- /server/src/auth/utils/get-user.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { User } from '../../entities/users/user'; 3 | 4 | export function getUser(req: Request): User { 5 | return req.user as User; 6 | } 7 | -------------------------------------------------------------------------------- /server/src/commons/axios/axios.ts: -------------------------------------------------------------------------------- 1 | import axiosModule from 'axios'; 2 | import { ensureStackTrace } from './ensure-stack-trace'; 3 | 4 | export const axios = axiosModule.create(); 5 | 6 | ensureStackTrace(axios); 7 | -------------------------------------------------------------------------------- /ui/src/components/auth/user-org.ts: -------------------------------------------------------------------------------- 1 | import { Org } from '../orgs/org'; 2 | 3 | export interface UserOrg { 4 | org: Org; 5 | member: { 6 | userId: string; 7 | admin: boolean; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /server/src/entities/orgs/invite.ts: -------------------------------------------------------------------------------- 1 | export interface Invite { 2 | _id: string; 3 | token: string; 4 | email: string; 5 | expiresAt: Date; 6 | memberOptions: { 7 | admin: boolean; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ui/src/commons/components/dropdown/DropdownSeparator.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/variables'; 2 | 3 | .separator { 4 | width: 100%; 5 | height: 2px; 6 | background: $_glitter; 7 | opacity: 0.5; 8 | } 9 | -------------------------------------------------------------------------------- /ui/src/components/sites/branches/redirects/branch-redirects-form-data.tsx: -------------------------------------------------------------------------------- 1 | import { BranchRedirect } from '../branch-redirect'; 2 | 3 | export interface BranchRedirectsFormData { 4 | redirects: BranchRedirect[]; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/components/teams/team.ts: -------------------------------------------------------------------------------- 1 | export interface Team { 2 | _id: string; 3 | orgId: string; 4 | name: string; 5 | color: string; 6 | logo?: string; 7 | createdAt: Date; 8 | updatedAt: Date; 9 | } 10 | -------------------------------------------------------------------------------- /server/src/storage/get-file-path.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { env } from '../env/env'; 3 | 4 | export function getFilePath(id: string): string { 5 | return path.resolve(`${env.MELI_STORAGE_DIR}/${id}`); 6 | } 7 | -------------------------------------------------------------------------------- /ui/src/utils/query-params.ts: -------------------------------------------------------------------------------- 1 | import qs from 'qs'; 2 | 3 | export function queryParams(): { [key: string]: string } { 4 | return qs.parse(window.location.search, { 5 | ignoreQueryPrefix: true, 6 | }) as any; 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/components/hooks/form/configs/Slack.module.scss: -------------------------------------------------------------------------------- 1 | .modal { 2 | max-width: 1000px; 3 | width: 80%; 4 | max-height: 80%; 5 | } 6 | 7 | .video { 8 | width: 100%; 9 | height: auto; 10 | object-fit: fill; 11 | } 12 | -------------------------------------------------------------------------------- /ui/src/styles/fonts.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@200;400;600;900&display=swap'); 2 | @import url('https://fonts.googleapis.com/css2?family=Source+Serif+Pro:wght@900&display=swap'); 3 | -------------------------------------------------------------------------------- /server/src/commons/errors/not-found-error.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from './http-error'; 2 | 3 | export class NotFoundError extends HttpError { 4 | constructor(message?: string) { 5 | super(404, undefined, message || 'Not found'); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/commons/components/modals/CloseModal.module.scss: -------------------------------------------------------------------------------- 1 | @import 'src/styles/variables'; 2 | @import 'src/styles/mixins'; 3 | 4 | .container { 5 | color: $dark; 6 | cursor: pointer; 7 | display: flex; 8 | align-items: center; 9 | } 10 | -------------------------------------------------------------------------------- /upload.sh: -------------------------------------------------------------------------------- 1 | npx @getmeli/cli upload \ 2 | --branch main \ 3 | --url http://localhost:8080 \ 4 | --site 30e4d0ea-1b63-4a83-ac37-30bfb85f495a \ 5 | --token 974bc47c3d90842ecb011e07f95126131c7b23d95d2daf6d7454651676a838ba \ 6 | ./tmp 7 | -------------------------------------------------------------------------------- /server/src/commons/errors/forbidden-error.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from './http-error'; 2 | 3 | export class ForbiddenError extends HttpError { 4 | constructor(message?: string) { 5 | super(403, undefined, message || 'Forbidden'); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/commons/components/dropdown/DropdownSeparator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './DropdownSeparator.module.scss'; 3 | 4 | export default function DropdownSeparator() { 5 | return
; 6 | } 7 | -------------------------------------------------------------------------------- /server/src/commons/errors/unauthorized-error.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from './http-error'; 2 | 3 | export class UnauthorizedError extends HttpError { 4 | constructor(message?: string) { 5 | super(401, undefined, message || 'Unauthorized'); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/components/auth/methods/SignInWithGoogle.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/variables'; 2 | 3 | .google { 4 | background: $google-bg; 5 | } 6 | 7 | .icon { 8 | color: $google-color; 9 | display: block; 10 | width: 100%; 11 | } 12 | -------------------------------------------------------------------------------- /ui/src/components/sites/branches/branch.ts: -------------------------------------------------------------------------------- 1 | import { Header } from './header'; 2 | 3 | export interface Branch { 4 | _id: string; 5 | name: string; 6 | release?: string; 7 | hasPassword?: string; 8 | url: string; 9 | headers: Header[]; 10 | } 11 | -------------------------------------------------------------------------------- /ui/src/utils/extract-exrror-message.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios'; 2 | 3 | export function extractExrrorMessage(err: any) { 4 | if (err.isAxiosError) { 5 | return (err as AxiosError).response.data.message; 6 | } 7 | return err.message; 8 | } 9 | -------------------------------------------------------------------------------- /server/src/auth/passport/providers/gitea/types/gitea-oauth-token-grant.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | export interface GiteaOauthTokenGrant { 4 | access_token: string; 5 | token_type: string; 6 | expires_in: number; 7 | refresh_token: string; 8 | } 9 | -------------------------------------------------------------------------------- /server/src/utils/basic-auth.spec.ts: -------------------------------------------------------------------------------- 1 | import { basicAuth } from './basic-auth'; 2 | 3 | describe('basicAuth', () => { 4 | it('should return basic auth header', async () => { 5 | expect(basicAuth('user', 'pass')).toEqual('Basic dXNlcjpwYXNz'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /ui/src/commons/components/FullPageCentered.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function FullPageCentered({ children }: { children: any }) { 4 | return
{children}
; 5 | } 6 | -------------------------------------------------------------------------------- /server/src/auth/passport/providers/gitlab/types/gitlab-oauth-token-grant.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | export interface GitlabOauthTokenGrant { 4 | access_token: string; 5 | token_type: string; 6 | expires_in: number; 7 | refresh_token: string; 8 | } 9 | -------------------------------------------------------------------------------- /server/src/commons/errors/app-error.ts: -------------------------------------------------------------------------------- 1 | export class AppError extends Error { 2 | constructor( 3 | message: string, 4 | public readonly jsonResponse?: any, 5 | public readonly statusCode: number = 500, 6 | ) { 7 | super(message); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/src/commons/errors/http-error.ts: -------------------------------------------------------------------------------- 1 | export class HttpError extends Error { 2 | constructor( 3 | public readonly statusCode: number, 4 | public readonly jsonResponse: number, 5 | message?: string, 6 | ) { 7 | super(message); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ui/src/components/auth/methods/SignInWithGitea.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/variables'; 2 | 3 | .gitea { 4 | background: $gitea-bg; 5 | } 6 | 7 | .icon { 8 | display: block; 9 | width: 100%; 10 | position: relative; 11 | bottom: -2px; 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/components/invites/user-invite.ts: -------------------------------------------------------------------------------- 1 | export interface UserInvite { 2 | _id: string; 3 | org: { 4 | name: string; 5 | color: string; 6 | logo?: string; 7 | }; 8 | expiresAt: Date; 9 | memberOptions: { 10 | admin: boolean; 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/commons/components/CenteredLoader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Loader } from './Loader'; 3 | 4 | export function CenteredLoader(props) { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /server/src/auth/serialize-user.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../entities/users/user'; 2 | 3 | export async function serializeUser(user: User) { 4 | return { 5 | _id: user._id, 6 | authType: user.authProvider, 7 | name: user.name, 8 | email: user.email, 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /server/src/caddy/definitions/storage.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Caddy { 2 | type Storage = Storages.FileSystem; 3 | 4 | namespace Storages { 5 | interface FileSystem { 6 | '@id'?: string; 7 | module: 'file_system'; 8 | root?: string; 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /server/src/entities/sites/header.ts: -------------------------------------------------------------------------------- 1 | import { object, string } from 'joi'; 2 | 3 | export interface Header { 4 | name: string; 5 | value: string; 6 | } 7 | 8 | export const $header = object({ 9 | name: string().required(), 10 | value: string().optional().empty(''), 11 | }); 12 | -------------------------------------------------------------------------------- /ui/src/components/hooks/deliveries/hook-delivery.ts: -------------------------------------------------------------------------------- 1 | import { HookType } from '../hook'; 2 | 3 | export interface HookDelivery { 4 | _id: string; 5 | type: HookType; 6 | hookId: string; 7 | date: Date; 8 | data: string; 9 | success: string; 10 | error: string; 11 | } 12 | -------------------------------------------------------------------------------- /ui/src/components/user/api-tokens/api-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { HttpMethod } from './HttpMethodBadge'; 2 | import { ApiScope } from './api-scope'; 3 | 4 | export interface ApiEndpoint { 5 | method: HttpMethod; 6 | path: string; 7 | auth: boolean; 8 | apiScope: ApiScope; 9 | } 10 | -------------------------------------------------------------------------------- /ui/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /ui/src/websockets/listen.ts: -------------------------------------------------------------------------------- 1 | export function listen(socket: SocketIOClient.Socket, event: string, listener: any): () => void | undefined { 2 | if (socket) { 3 | socket.on(event, listener); 4 | return () => { 5 | socket.removeListener(event, listener); 6 | }; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ui/src/commons/components/Toasts.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ToastContainer } from 'react-toastify'; 3 | import './Toasts.scss'; 4 | 5 | export function Toasts() { 6 | return ( 7 | <> 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /ui/src/components/invites/UserInviteView.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables"; 2 | 3 | .org { 4 | background: $gray-100; 5 | display: flex; 6 | align-items: center; 7 | padding: 1.5rem; 8 | margin: 2rem 0; 9 | font-size: 1.5rem; 10 | border-radius: $border-radius; 11 | } 12 | -------------------------------------------------------------------------------- /server/src/commons/validators/is-certificate.ts: -------------------------------------------------------------------------------- 1 | export function isCertificate(value: string): string { 2 | if (!value.includes('-----BEGIN CERTIFICATE-----') 3 | || !value.includes('-----END CERTIFICATE-----')) { 4 | throw new Error('Invalid PEM certificate'); 5 | } 6 | 7 | return value; 8 | } 9 | -------------------------------------------------------------------------------- /ui/src/commons/keyboard/shortcuts-keys.ts: -------------------------------------------------------------------------------- 1 | // Keep shortcut keys is this file to avoid collisions 2 | // Either Ctrl or Cmd must be pressed for these shortcuts to work 3 | // Avoid: C, F?, N, Q, R, T, V, W, A, X 4 | 5 | export const SEARCH_SHORTCUT_KEY = 'K'; 6 | export const ADD_TEAM_SHORTCUT_KEY = 'P'; 7 | -------------------------------------------------------------------------------- /ui/src/components/orgs/staff/Staff.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Invites } from './invites/Invites'; 3 | import { Members } from './members/Members'; 4 | 5 | export function Staff() { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /server/src/caddy/definitions/apps/http/route.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Caddy { 2 | namespace Http { 3 | interface Route { 4 | '@id'?: string; 5 | group?: string; 6 | match?: Route.Matcher[]; 7 | handle?: Route.Handler[]; 8 | terminal?: boolean; 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /server/src/commons/errors/bad-request-error.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from './http-error'; 2 | 3 | export class BadRequestError extends HttpError { 4 | constructor( 5 | message?: string, 6 | jsonResponse?: any, 7 | ) { 8 | super(400, jsonResponse, message || 'Bad request'); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /server/src/commons/validators/is-url.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'url'; 2 | 3 | export function isUrl(value: string) { 4 | try { 5 | // eslint-disable-next-line no-new 6 | new URL(value); 7 | } catch (e) { 8 | throw new Error('value is not a valid URL'); 9 | } 10 | return value; 11 | } 12 | -------------------------------------------------------------------------------- /server/src/hooks/handlers/mattermost/get-mattermost-message.ts: -------------------------------------------------------------------------------- 1 | export function getMattermostMessage(text: string) { 2 | return { 3 | username: 'meli', 4 | icon_url: 'https://raw.githubusercontent.com/gomeli/meli-brand/master/logo/meli-logo.svg', 5 | text, 6 | channel: undefined, 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /server/src/system/handlers/system-env.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { env } from '../../env/env'; 3 | 4 | export function systemEnv(req: Request, res: Response) { 5 | res.json({ 6 | MELI_URL: env.MELI_URL, 7 | MELI_HTTPS_AUTO: env.MELI_HTTPS_AUTO, 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /server/src/commons/utils/ensure-empty-directory.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | 3 | export async function ensureEmptyDirectory(path: string): Promise { 4 | await fs.rmdir(path, { 5 | recursive: true, 6 | }); 7 | await fs.mkdir(path, { 8 | recursive: true, 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /server/src/entities/orgs/serialize-invite.ts: -------------------------------------------------------------------------------- 1 | import { Invite } from './invite'; 2 | 3 | export function serializeInvite(invite: Invite) { 4 | return { 5 | _id: invite._id, 6 | email: invite.email, 7 | expiresAt: invite.expiresAt, 8 | memberOptions: invite.memberOptions, 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /ui/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #ffffff 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /ui/src/commons/components/FullPageLoader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Loader } from './Loader'; 3 | 4 | export function FullPageLoader() { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /server/src/auth/handlers/get-auth-methods.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { authMethods } from '../passport/auth-methods'; 3 | 4 | export function handler(req: Request, res: Response) { 5 | res.json(authMethods); 6 | } 7 | 8 | export const getAuthMethods = [ 9 | handler, 10 | ]; 11 | -------------------------------------------------------------------------------- /server/src/commons/validators/is-rsa-private-key.ts: -------------------------------------------------------------------------------- 1 | export function isRsaPrivateKey(value: string): string { 2 | if (!value.includes('-----BEGIN RSA PRIVATE KEY-----') 3 | || !value.includes('-----END RSA PRIVATE KEY-----')) { 4 | throw new Error('Invalid RSA private key'); 5 | } 6 | 7 | return value; 8 | } 9 | -------------------------------------------------------------------------------- /server/src/entities/sites/get-branch-domain.ts: -------------------------------------------------------------------------------- 1 | import { Branch } from './branch'; 2 | import { Site } from './site'; 3 | import { getSiteMainDomain } from './get-site-main-domain'; 4 | 5 | export function getBranchDomain(site: Site, branch: Branch) { 6 | return `${branch.slug}.${getSiteMainDomain(site, true)}`; 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/components/sites/get-team-sites.ts: -------------------------------------------------------------------------------- 1 | import { axios } from '../../providers/axios'; 2 | import { Site } from './site'; 3 | 4 | export function getTeamSites( 5 | teamId: string, 6 | ): Promise { 7 | return axios 8 | .get(`/api/v1/teams/${teamId}/sites`) 9 | .then(res => res.data); 10 | } 11 | -------------------------------------------------------------------------------- /ui/src/components/user/api-tokens/api-token.ts: -------------------------------------------------------------------------------- 1 | import { ApiScope } from './api-scope'; 2 | 3 | export interface ApiToken { 4 | _id: string; 5 | name: string; 6 | value: string; 7 | scopes: ApiScope[]; 8 | expiresAt: Date; 9 | activatesAt: Date; 10 | createdAt: Date; 11 | updatedAt: Date; 12 | } 13 | -------------------------------------------------------------------------------- /server/src/entities/api/serialize-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { ApiEndpoint } from './api-endpoint'; 2 | 3 | export function serializeEndpoint(endpoint: ApiEndpoint) { 4 | return { 5 | method: endpoint.method, 6 | path: endpoint.path, 7 | auth: endpoint.auth, 8 | apiScope: endpoint.apiScope, 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /server/src/entities/sites/serialize-site-token.ts: -------------------------------------------------------------------------------- 1 | import { SiteToken } from './site'; 2 | 3 | export function serializeSiteToken(token: SiteToken) { 4 | return !token ? undefined : { 5 | _id: token._id, 6 | name: token.name, 7 | createdAt: token.createdAt, 8 | value: token.value, 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /server/src/auth/guards/is-owner.ts: -------------------------------------------------------------------------------- 1 | import { Orgs } from '../../entities/orgs/org'; 2 | 3 | export async function isOwner(userId: string, orgId: string) { 4 | const count = await Orgs().countDocuments({ 5 | _id: orgId, 6 | ownerId: userId, 7 | }, { 8 | limit: 1, 9 | }); 10 | return count === 1; 11 | } 12 | -------------------------------------------------------------------------------- /server/src/entities/members/member.ts: -------------------------------------------------------------------------------- 1 | import { AppDb } from '../../db/db'; 2 | 3 | export interface Member { 4 | _id: string; 5 | orgId: string; 6 | userId: string; 7 | admin: boolean; 8 | name: string; 9 | email: string; 10 | } 11 | 12 | export const Members = () => AppDb.db.collection('members'); 13 | -------------------------------------------------------------------------------- /server/src/entities/orgs/serialize-org.ts: -------------------------------------------------------------------------------- 1 | import { Org } from './org'; 2 | import { getLogoUrl } from '../../utils/get-logo-url'; 3 | 4 | export function serializeOrg(org: Org) { 5 | return { 6 | _id: org._id, 7 | name: org.name, 8 | color: org.color, 9 | logo: getLogoUrl('orgs', org), 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /server/src/commons/validators/is-cron-expression.ts: -------------------------------------------------------------------------------- 1 | import cron from 'cron-parser'; 2 | 3 | export function isCronExpression(expression: string) { 4 | try { 5 | cron.parseExpression(expression); 6 | } catch (e) { 7 | throw new Error('value is an invalid cron expression'); 8 | } 9 | return expression; 10 | } 11 | -------------------------------------------------------------------------------- /server/src/entities/orgs/guards/is-org-member.ts: -------------------------------------------------------------------------------- 1 | import { Members } from '../../members/member'; 2 | 3 | export async function isOrgMember(userId: string, orgId: string) { 4 | const count = await Members().countDocuments({ 5 | userId, 6 | orgId, 7 | }, { 8 | limit: 1, 9 | }); 10 | return count === 1; 11 | } 12 | -------------------------------------------------------------------------------- /ui/src/commons/components/KeyboardShortcut.module.scss: -------------------------------------------------------------------------------- 1 | @import 'src/styles/variables'; 2 | @import 'src/styles/mixins'; 3 | 4 | .icon { 5 | margin-right: 5px; 6 | } 7 | 8 | .shortcut { 9 | font-size: .8rem; 10 | display: flex; 11 | align-items: center; 12 | text-transform: uppercase; 13 | font-weight: bold; 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/commons/components/modals/CardModal.module.scss: -------------------------------------------------------------------------------- 1 | @import 'src/styles/variables'; 2 | @import 'src/styles/mixins'; 3 | 4 | 5 | .card { 6 | margin: 0; 7 | background: $white; 8 | border-radius: $border-radius; 9 | height: 100%; 10 | } 11 | 12 | .content { 13 | max-height: 80vh; 14 | overflow: auto; 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/components/commons/Logo.module.scss: -------------------------------------------------------------------------------- 1 | @import "src/styles/variables"; 2 | 3 | .dropzone { 4 | &:focus { 5 | outline: 0; 6 | } 7 | 8 | border: 2px dashed transparent; 9 | 10 | &.active { 11 | border: 2px dashed $secondary; 12 | } 13 | } 14 | 15 | .logo { 16 | width: 50px; 17 | height: 50px; 18 | } 19 | -------------------------------------------------------------------------------- /ui/src/components/sites/search/SearchModal.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/variables'; 2 | @import '../../../styles/mixins'; 3 | 4 | .loader { 5 | position: absolute; 6 | right: 15px; 7 | top: 50%; 8 | transform: translateY(-50%); 9 | } 10 | 11 | .search { 12 | width: 80%; 13 | height: 80vh !important; 14 | } 15 | -------------------------------------------------------------------------------- /server/src/caddy/utils/get-reverse-proxy-dial.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'url'; 2 | 3 | export function getReverseProxyDial(host: string) { 4 | const url = new URL(host); 5 | let { port } = url; 6 | if (port === '') { 7 | port = url.protocol === 'https:' ? '443' : '80'; 8 | } 9 | return `${url.hostname}:${port}`; 10 | } 11 | -------------------------------------------------------------------------------- /server/src/system/routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { systemInfo } from './handlers/system-info'; 3 | import { systemEnv } from './handlers/system-env'; 4 | 5 | const router = Router(); 6 | 7 | router.get('/system/info', systemInfo); 8 | router.get('/system/env', systemEnv); 9 | 10 | export default router; 11 | -------------------------------------------------------------------------------- /ui/src/components/orgs/settings/OrgSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { OrgGeneralSettings } from './OrgGeneralSettings'; 3 | import { OrgLogo } from './OrgLogo'; 4 | 5 | export function OrgSettings() { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/components/orgs/settings/OrgLogo.module.scss: -------------------------------------------------------------------------------- 1 | @import "src/styles/variables"; 2 | 3 | .dropzone { 4 | &:focus { 5 | outline: 0; 6 | } 7 | 8 | border: 2px dashed transparent; 9 | 10 | &.active { 11 | border: 2px dashed $secondary; 12 | } 13 | } 14 | 15 | .logo { 16 | width: 50px; 17 | height: 50px; 18 | } 19 | -------------------------------------------------------------------------------- /server/src/auth/guards/is-admin.ts: -------------------------------------------------------------------------------- 1 | import { Members } from '../../entities/members/member'; 2 | 3 | export async function isAdmin(userId: string, orgId: string) { 4 | const count = await Members().countDocuments({ 5 | userId, 6 | orgId, 7 | admin: true, 8 | }, { 9 | limit: 1, 10 | }); 11 | return count === 1; 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/commons/utils/os.ts: -------------------------------------------------------------------------------- 1 | export function isMac(): boolean { 2 | const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); 3 | const isIOS = /(iPhone|iPod|iPad)/i.test(navigator.platform); 4 | return isMacLike || isIOS; 5 | } 6 | 7 | export function isWindows(): boolean { 8 | return /Win/i.test(navigator.platform); 9 | } 10 | -------------------------------------------------------------------------------- /server/src/commons/utils/base64.ts: -------------------------------------------------------------------------------- 1 | export function base64Encode(raw: string, encoding: BufferEncoding = 'utf-8'): string { 2 | return Buffer.from(raw, encoding).toString('base64'); 3 | } 4 | 5 | export function base64Decode(encoded: string, encoding: BufferEncoding = 'utf-8'): string { 6 | return Buffer.from(encoded, 'base64').toString(encoding); 7 | } 8 | -------------------------------------------------------------------------------- /server/src/commons/validators/is-moment-timezone.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment-timezone'; 2 | 3 | const momentTimezones: Set = new Set(moment.tz.names()); 4 | 5 | export function isMomentTimezone(tz: string) { 6 | if (!momentTimezones.has(tz)) { 7 | throw new Error('value is not a valid moment timezone'); 8 | } 9 | return tz; 10 | } 11 | -------------------------------------------------------------------------------- /ui/src/components/hooks/hook.ts: -------------------------------------------------------------------------------- 1 | export enum HookType { 2 | email = 'email', 3 | mattermost = 'mattermost', 4 | slack = 'slack', 5 | web = 'web', 6 | } 7 | 8 | export interface Hook { 9 | _id: string; 10 | name: string; 11 | type: HookType; 12 | createdAt: Date; 13 | updatedAt: Date; 14 | config: any; 15 | events: string[]; 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/components/auth/IsOwner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useCurrentOrg } from '../../providers/OrgProvider'; 3 | 4 | export function IsOwner({ children }: { 5 | children; 6 | }) { 7 | const { currentOrg } = useCurrentOrg(); 8 | return currentOrg && currentOrg.isOwner ? ( 9 | children 10 | ) : ( 11 | <> 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/orgs/staff/invites/Invites.module.scss: -------------------------------------------------------------------------------- 1 | @import "src/styles/variables"; 2 | 3 | .loader { 4 | position: absolute; 5 | right: 15px; 6 | top: 50%; 7 | transform: translateY(-50%); 8 | } 9 | 10 | .add { 11 | text-transform: uppercase; 12 | font-weight: bold; 13 | cursor: pointer; 14 | text-align: center !important; 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/components/orgs/staff/members/Members.module.scss: -------------------------------------------------------------------------------- 1 | @import "src/styles/variables"; 2 | 3 | .loader { 4 | position: absolute; 5 | right: 15px; 6 | top: 50%; 7 | transform: translateY(-50%); 8 | } 9 | 10 | .add { 11 | text-transform: uppercase; 12 | font-weight: bold; 13 | cursor: pointer; 14 | text-align: center !important; 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/components/sites/branches/BranchList.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .loader { 4 | position: absolute; 5 | right: 15px; 6 | top: 50%; 7 | transform: translateY(-50%); 8 | } 9 | 10 | .add { 11 | text-transform: uppercase; 12 | font-weight: bold; 13 | cursor: pointer; 14 | text-align: center !important; 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/components/teams/members/Members.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .loader { 4 | position: absolute; 5 | right: 15px; 6 | top: 50%; 7 | transform: translateY(-50%); 8 | } 9 | 10 | .add { 11 | text-transform: uppercase; 12 | font-weight: bold; 13 | cursor: pointer; 14 | text-align: center !important; 15 | } 16 | -------------------------------------------------------------------------------- /server/src/auth/guards/is-admin-or-owner.ts: -------------------------------------------------------------------------------- 1 | import { isOwner } from './is-owner'; 2 | import { isAdmin } from './is-admin'; 3 | 4 | export async function isAdminOrOwner(userId: string, orgId: string): Promise { 5 | const admin = await isAdmin(userId, orgId); 6 | if (admin) { 7 | return true; 8 | } 9 | return isOwner(userId, orgId); 10 | } 11 | -------------------------------------------------------------------------------- /server/src/auth/passport.ts: -------------------------------------------------------------------------------- 1 | import { authMethods } from './passport/auth-methods'; 2 | import './passport/github'; 3 | import './passport/gitlab'; 4 | import './passport/gitea'; 5 | import './passport/google'; 6 | import './passport/in-memory'; 7 | 8 | if (authMethods.length === 0) { 9 | throw new Error('No auth methods enabled, please configure one'); 10 | } 11 | -------------------------------------------------------------------------------- /server/src/entities/sites/yaml-config/site-config.ts: -------------------------------------------------------------------------------- 1 | import { $formMapEntry, Form } from '../../forms/form'; 2 | import { object } from 'joi'; 3 | 4 | export interface SiteConfig { 5 | forms: { 6 | [name: string]: Form 7 | }; 8 | } 9 | 10 | export const $meliConfig = object({ 11 | forms: object().optional().pattern(/\w+/, $formMapEntry), 12 | }); 13 | -------------------------------------------------------------------------------- /server/src/prometheus/metrics/up.ts: -------------------------------------------------------------------------------- 1 | import { Prometheus } from '@promster/express'; 2 | import { env } from '../../env/env'; 3 | 4 | const gauge = new Prometheus.Gauge({ 5 | name: `${env.MELI_PROMETHEUS_METRICS_PREFIX}up`, 6 | help: 'Whether the server is up or down', 7 | }); 8 | 9 | export async function up(): Promise { 10 | gauge.set(1); 11 | } 12 | -------------------------------------------------------------------------------- /ui/src/setupProxy.js: -------------------------------------------------------------------------------- 1 | const { createProxyMiddleware } = require('http-proxy-middleware'); 2 | 3 | module.exports = function (app) { 4 | app.use( 5 | '/api', 6 | createProxyMiddleware({ 7 | target: 'http://localhost:3001', 8 | changeOrigin: true, 9 | pathRewrite: { 10 | '^/api': '/', 11 | }, 12 | }), 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /server/src/auth/passport/providers/gitea/types/gitea-user.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | export interface GiteaUser { 4 | id: number; 5 | login: string; 6 | full_name: string; 7 | email: string; 8 | avatar_url: string; 9 | language: string; 10 | is_admin: boolean; 11 | last_login: Date; 12 | created: Date; 13 | username: string; 14 | } 15 | -------------------------------------------------------------------------------- /server/src/emails/templates/README.md: -------------------------------------------------------------------------------- 1 | # Email templates 2 | 3 | Place in this folder Handlebar email templates. 4 | 5 | For example, create a `my-template.hbs` file: 6 | 7 | ```hbs 8 | Hello {{name}} 9 | ``` 10 | 11 | Then in the code, use it as: 12 | 13 | ```ts 14 | sendEmail(['test@test.com'], 'Hey !', 'my-template', { 15 | name: 'Steeve', 16 | }); 17 | ``` 18 | -------------------------------------------------------------------------------- /ui/src/components/auth/IsAdmin.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useCurrentOrg } from '../../providers/OrgProvider'; 3 | 4 | export function IsAdmin({ children }: { 5 | children; 6 | }) { 7 | const { currentOrg } = useCurrentOrg(); 8 | return currentOrg && currentOrg.isAdminOrOwner ? ( 9 | children 10 | ) : ( 11 | <> 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /server/src/emails/methods/send-invite.ts: -------------------------------------------------------------------------------- 1 | import { sendEmail } from '../send-email'; 2 | 3 | interface TemplateVars { 4 | org: string; 5 | url: string; 6 | } 7 | 8 | export function sendInvite(to: string, vars: TemplateVars): Promise { 9 | return sendEmail( 10 | [to], 11 | `Join ${vars.org} on Meli`, 12 | 'invite', 13 | vars, 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /server/src/entities/releases/release.ts: -------------------------------------------------------------------------------- 1 | import { AppDb } from '../../db/db'; 2 | import { Form } from '../forms/form'; 3 | 4 | export interface Release { 5 | _id: string; 6 | siteId: string; 7 | name: string; 8 | date: Date; 9 | branches: string[]; 10 | forms?: Form[]; 11 | } 12 | 13 | export const Releases = () => AppDb.db.collection('releases'); 14 | -------------------------------------------------------------------------------- /ui/src/components/hooks/HookList.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables"; 2 | 3 | .loader { 4 | position: absolute; 5 | right: 15px; 6 | top: 50%; 7 | transform: translateY(-50%); 8 | } 9 | 10 | .add { 11 | text-transform: uppercase; 12 | font-weight: bold; 13 | text-align: center !important; 14 | //border: 2px solid $gray-200 !important; 15 | } 16 | -------------------------------------------------------------------------------- /server/src/auth/passport/providers/gitea/types/gitea-org.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | export interface GiteaOrg { 4 | id: number; 5 | username: string; 6 | full_name: string; 7 | avatar_url: string; 8 | description: string; 9 | website: string; 10 | location: string; 11 | visibility: string; 12 | repo_admin_change_team_access: boolean; 13 | } 14 | -------------------------------------------------------------------------------- /server/src/posthog/posthog.ts: -------------------------------------------------------------------------------- 1 | import PostHog from 'posthog-node'; 2 | import { env } from '../env/env'; 3 | 4 | export const postHog = new PostHog( 5 | '-BcCDFlG6nIchkTWROH5C3iplPWRjdEwrb6wpQKKwDg', 6 | { 7 | host: 'https://posthog.meli.sh', 8 | enable: env.MELI_POSTHOG_ENABLED, 9 | }, 10 | ); 11 | 12 | export const postHogId = { 13 | id: undefined, 14 | }; 15 | -------------------------------------------------------------------------------- /server/src/auth/handlers/sign-out.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { authCookieName, getCookieOptions } from '../auth'; 3 | 4 | export function handler(req: Request, res: Response) { 5 | res 6 | .cookie(authCookieName, '', getCookieOptions(0)) 7 | .status(204) 8 | .send(); 9 | } 10 | 11 | export const signOut = [ 12 | handler, 13 | ]; 14 | -------------------------------------------------------------------------------- /server/src/hooks/serialize-hook-delivery.ts: -------------------------------------------------------------------------------- 1 | import { HookDelivery } from './hook-delivery'; 2 | 3 | export function serializeHookDelivery(delivery: HookDelivery) { 4 | return { 5 | _id: delivery._id, 6 | hookId: delivery.hookId, 7 | date: delivery.date, 8 | data: delivery.data, 9 | success: delivery.success, 10 | error: delivery.error, 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/components/SubHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import styles from './SubHeader.module.scss'; 4 | 5 | export function SubHeader({ children, className }: { children?: any; className: string }) { 6 | return ( 7 |
8 | {children} 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /ui/src/components/sites/tokens/TokenList.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .loader { 4 | position: absolute; 5 | right: 15px; 6 | top: 50%; 7 | transform: translateY(-50%); 8 | } 9 | 10 | .add { 11 | text-transform: uppercase; 12 | font-weight: bold; 13 | text-align: center !important; 14 | //border: 2px solid $gray-200 !important; 15 | } 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | indent_style = space 11 | indent_size = 2 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | 15 | [*.md] 16 | indent_size = 4 17 | -------------------------------------------------------------------------------- /ui/src/components/user/api-tokens/ApiTokenList.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .loader { 4 | position: absolute; 5 | right: 15px; 6 | top: 50%; 7 | transform: translateY(-50%); 8 | } 9 | 10 | .add { 11 | text-transform: uppercase; 12 | font-weight: bold; 13 | text-align: center !important; 14 | //border: 2px solid $gray-200 !important; 15 | } 16 | -------------------------------------------------------------------------------- /server/src/entities/sites/get-site-url.ts: -------------------------------------------------------------------------------- 1 | import { Site } from './site'; 2 | import { env } from '../../env/env'; 3 | import { URL } from 'url'; 4 | import { getSiteMainDomain } from './get-site-main-domain'; 5 | 6 | const sitesUrl = new URL(env.MELI_SITES_URL); 7 | 8 | export function getSiteUrl(site: Site): string { 9 | return `${sitesUrl.protocol}//${getSiteMainDomain(site, true)}`; 10 | } 11 | -------------------------------------------------------------------------------- /server/src/entities/teams/serialize-team-member.ts: -------------------------------------------------------------------------------- 1 | import { Members } from '../members/member'; 2 | 3 | export async function serializeTeamMember(memberId: string) { 4 | const member = await Members().findOne({ 5 | _id: memberId, 6 | }); 7 | return { 8 | memberId: member._id, 9 | name: member.name, 10 | email: member.email, 11 | admin: member.admin, 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/commons/utils/random-string.ts: -------------------------------------------------------------------------------- 1 | export function randomString(length) { 2 | let result = ''; 3 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 4 | const charactersLength = characters.length; 5 | for (let i = 0; i < length; i++) { 6 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 7 | } 8 | return result; 9 | } 10 | -------------------------------------------------------------------------------- /server/src/caddy/definitions/admin.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Caddy { 2 | interface Admin { 3 | '@id'?: string; 4 | disabled?: boolean; 5 | listen?: string; 6 | enforce_origin?: boolean; 7 | origins?: string[]; 8 | config?: Admin.Config; 9 | } 10 | 11 | namespace Admin { 12 | interface Config { 13 | '@id'?: string; 14 | persist?: boolean 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /server/src/hooks/hook-delivery.ts: -------------------------------------------------------------------------------- 1 | import { AppDb } from '../db/db'; 2 | import { HookType } from './hook'; 3 | 4 | export interface HookDelivery { 5 | _id: string; 6 | hookId: string; 7 | type: HookType; 8 | date: Date; 9 | data?: T; 10 | success: boolean; 11 | error?: string; 12 | } 13 | 14 | export const HookDeliveries = () => AppDb.db.collection('hook-deliveries'); 15 | -------------------------------------------------------------------------------- /ui/src/commons/components/AlertError.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Alert } from './Alert'; 3 | 4 | export function AlertError({ 5 | error, children, className, 6 | }: { 7 | error?: any; 8 | children?: any; 9 | className?: string; 10 | }) { 11 | return ( 12 | 13 | {children || error.toString()} 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/components/sites/branches/get-branches.ts: -------------------------------------------------------------------------------- 1 | import { axios } from '../../../providers/axios'; 2 | import { Env } from '../../../providers/EnvProvider'; 3 | import { Branch } from './branch'; 4 | 5 | export function getBranches( 6 | env: Env, 7 | siteId: string, 8 | ): Promise { 9 | return axios 10 | .get(`/api/v1/sites/${siteId}/branches`) 11 | .then(res => res.data); 12 | } 13 | -------------------------------------------------------------------------------- /server/migrate-mongo-config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | // we actually don't use this 3 | mongodb: { 4 | url: "mongodb://localhost:27017", 5 | databaseName: "migrate-test", 6 | options: { 7 | useNewUrlParser: true, 8 | useUnifiedTopology: true, 9 | } 10 | }, 11 | migrationsDir: "migrations", 12 | changelogCollectionName: "changelog" 13 | }; 14 | 15 | module.exports = config; 16 | -------------------------------------------------------------------------------- /ui/src/commons/components/Hint.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables"; 2 | 3 | .hint { 4 | width: 16px; 5 | height: 16px; 6 | background: $_blueBell;; 7 | display: inline-block; 8 | text-align: center; 9 | line-height: 16px; 10 | color: $white; 11 | border-radius: 50%; 12 | font-size: .5rem; 13 | 14 | &:hover { 15 | background: #1B0449; 16 | opacity: 0.85; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ui/src/components/icons/FormIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import { faCode } from '@fortawesome/free-solid-svg-icons'; 3 | import React from 'react'; 4 | 5 | export function FormIcon({ className }: { className? }) { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/icons/HookIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import { faBell } from '@fortawesome/free-regular-svg-icons'; 3 | import React from 'react'; 4 | 5 | export function HookIcon({ className }: { className? }) { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/icons/OrgIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import React from 'react'; 3 | import { faVihara } from '@fortawesome/free-solid-svg-icons'; 4 | 5 | export function OrgIcon({ className }: { className? }) { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/icons/TokenIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import { faKey } from '@fortawesome/free-solid-svg-icons'; 3 | import React from 'react'; 4 | 5 | export function TokenIcon({ className }: { className? }) { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /ci/setup-git.sh: -------------------------------------------------------------------------------- 1 | [[ "$1" == "" ]] && echo 'missing deploy key ($1)' && exit 1; 2 | DEPLOY_KEY="$1" 3 | 4 | # install ssh and add ssh key for semantic-release 5 | which ssh-agent || apk add openssh 6 | eval $(ssh-agent -s) 7 | echo "$DEPLOY_KEY" | tr -d '\r' | ssh-add - 8 | mkdir -p ~/.ssh 9 | chmod 700 ~/.ssh 10 | # setup git 11 | git config user.name "semantic-release" 12 | git config user.email "release@meli.sh" 13 | -------------------------------------------------------------------------------- /server/src/auth/handlers/redirect-to-ui.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { env } from '../../env/env'; 3 | import { Logger } from '../../commons/logger/logger'; 4 | 5 | const logger = new Logger('meli.api:redirectToUi'); 6 | 7 | export function redirectToUi(req: Request, res: Response): void { 8 | logger.debug('Redirecting to', env.MELI_URL); 9 | res.redirect(env.MELI_URL); 10 | } 11 | -------------------------------------------------------------------------------- /ui/src/components/icons/HeaderIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import { faWrench } from '@fortawesome/free-solid-svg-icons'; 3 | import React from 'react'; 4 | 5 | export function HeaderIcon({ className }: { className? }) { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/icons/ReleaseIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import { faList } from '@fortawesome/free-solid-svg-icons'; 3 | import React from 'react'; 4 | 5 | export function ReleaseIcon({ className }: { className? }) { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/icons/SecurityIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import React from 'react'; 3 | import { faLock } from '@fortawesome/free-solid-svg-icons'; 4 | 5 | export function SecurityIcon({ className }: { className? }) { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/icons/SiteIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import { faRocket } from '@fortawesome/free-solid-svg-icons'; 3 | import React from 'react'; 4 | 5 | export function SiteIcon({ className }: { className? }) { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/sites/settings/SiteLogo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSite } from '../SiteView'; 3 | import { Logo } from '../../commons/Logo'; 4 | 5 | export function SiteLogo() { 6 | const { site, setSite } = useSite(); 7 | 8 | return ( 9 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /scripts/setup-git.sh: -------------------------------------------------------------------------------- 1 | [[ "$1" == "" ]] && echo 'missing deploy key ($1)' && exit 1; 2 | DEPLOY_KEY="$1" 3 | 4 | # install ssh and add ssh key for semantic-release 5 | which ssh-agent || apk add openssh 6 | eval $(ssh-agent -s) 7 | echo "$DEPLOY_KEY" | tr -d '\r' | ssh-add - 8 | mkdir -p ~/.ssh 9 | chmod 700 ~/.ssh 10 | # setup git 11 | git config user.name "semantic-release" 12 | git config user.email "release@meli.sh" 13 | -------------------------------------------------------------------------------- /server/tests/nock.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock'; 2 | import { appendFileSync } from 'fs'; 3 | 4 | console.log('Starting nock recordings'); 5 | 6 | const fileName = './requests.json'; 7 | 8 | nock.recorder.rec({ 9 | output_objects: true, 10 | enable_reqheaders_recording: true, 11 | use_separator: false, 12 | logging: content => { 13 | appendFileSync(fileName, `${JSON.stringify(content)}\n`); 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /ui/src/components/icons/SettingsIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import { faWrench } from '@fortawesome/free-solid-svg-icons'; 3 | import React from 'react'; 4 | 5 | export function SettingsIcon({ className }: { className? }) { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /server/src/auth/utils/get-user.spec.ts: -------------------------------------------------------------------------------- 1 | import { getUser } from './get-user'; 2 | 3 | describe('getUser', () => { 4 | 5 | afterEach(() => jest.restoreAllMocks()); 6 | 7 | it('should get user from socket', async () => { 8 | const fakeUser: any = { _id: 'userId' }; 9 | 10 | const user = getUser({ 11 | user: fakeUser, 12 | }); 13 | 14 | expect(user).toEqual(fakeUser); 15 | }); 16 | 17 | }); 18 | -------------------------------------------------------------------------------- /ui/src/components/icons/BranchIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import React from 'react'; 3 | import { faCodeBranch } from '@fortawesome/free-solid-svg-icons'; 4 | 5 | export function BranchIcon({ className }: { className? }) { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/icons/InviteIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import { faPaperPlane } from '@fortawesome/free-regular-svg-icons'; 3 | import React from 'react'; 4 | 5 | export function InviteIcon({ className }: { className? }) { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/icons/TeamIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import { faSpaceShuttle } from '@fortawesome/free-solid-svg-icons'; 3 | import React from 'react'; 4 | 5 | export function TeamIcon({ className }: { className? }) { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/icons/UserIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import { faUserAstronaut } from '@fortawesome/free-solid-svg-icons'; 3 | import React from 'react'; 4 | 5 | export function UserIcon({ className }: { className? }) { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /server/src/entities/teams/serialize-team.ts: -------------------------------------------------------------------------------- 1 | import { Team } from './team'; 2 | import { getLogoUrl } from '../../utils/get-logo-url'; 3 | 4 | export function serializeTeam(team: Team) { 5 | return { 6 | _id: team._id, 7 | orgId: team.orgId, 8 | createdAt: team.createdAt, 9 | updatedAt: team.updatedAt, 10 | name: team.name, 11 | color: team.color, 12 | logo: getLogoUrl('teams', team), 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/components/icons/RedirectIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import { faDirections } from '@fortawesome/free-solid-svg-icons'; 3 | import React from 'react'; 4 | 5 | export function RedirectIcon({ className }: { className? }) { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/icons/OrgMemberIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import { faUserAstronaut } from '@fortawesome/free-solid-svg-icons'; 3 | import React from 'react'; 4 | 5 | export function OrgMemberIcon({ className }: { className? }) { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /server/src/auth/passport/providers/github/types/github-org.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | export interface GithubOrg { 4 | login: string; 5 | id: number; 6 | node_id: string; 7 | url: string; 8 | repos_url: string; 9 | events_url: string; 10 | hooks_url: string; 11 | issues_url: string; 12 | members_url: string; 13 | public_members_url: string; 14 | avatar_url: string; 15 | description: string; 16 | } 17 | -------------------------------------------------------------------------------- /server/src/entities/orgs/serialize-user-org.ts: -------------------------------------------------------------------------------- 1 | import { Org } from './org'; 2 | import { Member } from '../members/member'; 3 | import { serializeOrg } from './serialize-org'; 4 | import { serializeMember } from '../members/serialize-member'; 5 | 6 | export async function serializeUserOrg(org: Org, member: Member) { 7 | return { 8 | org: serializeOrg(org), 9 | member: await serializeMember(member, org.ownerId), 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /ui/src/commons/components/forms/InputError.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import get from 'lodash/get'; 3 | 4 | export function InputError({ 5 | error, className, path, 6 | }: { 7 | error: any; 8 | path: string; 9 | className?: string; 10 | }) { 11 | const err = get(error, path); 12 | return err ? ( 13 | 14 | {err.message} 15 | 16 | ) : null; 17 | } 18 | -------------------------------------------------------------------------------- /ui/src/components/icons/HookDeliveryIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import { faPaperPlane } from '@fortawesome/free-regular-svg-icons'; 3 | import React from 'react'; 4 | 5 | export function HookDeliveryIcon({ className }: { className? }) { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/icons/TeamMemberIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import { faUserAstronaut } from '@fortawesome/free-solid-svg-icons'; 3 | import React from 'react'; 4 | 5 | export function TeamMemberIcon({ className }: { className? }) { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /server/src/entities/sites/get-branch-url.ts: -------------------------------------------------------------------------------- 1 | import { Branch } from './branch'; 2 | import { env } from '../../env/env'; 3 | import { getBranchDomain } from './get-branch-domain'; 4 | import { Site } from './site'; 5 | import { URL } from 'url'; 6 | 7 | const sitesUrl = new URL(env.MELI_SITES_URL); 8 | 9 | export function getBranchUrl(site: Site, branch: Branch) { 10 | return `${sitesUrl.protocol}//${getBranchDomain(site, branch)}`; 11 | } 12 | -------------------------------------------------------------------------------- /server/src/entities/teams/team.ts: -------------------------------------------------------------------------------- 1 | import { AppDb } from '../../db/db'; 2 | import { StoredFile } from '../../storage/store-file'; 3 | 4 | export interface Team { 5 | _id: string; 6 | orgId: string; 7 | createdAt: Date; 8 | updatedAt: Date; 9 | name: string; 10 | color: string; 11 | logo?: StoredFile; 12 | members: string[]; 13 | hooks: string[]; 14 | } 15 | 16 | export const Teams = () => AppDb.db.collection('teams'); 17 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "build" 4 | ], 5 | "compilerOptions": { 6 | "incremental": false, 7 | "target": "es2018", 8 | "module": "commonjs", 9 | "lib": [ 10 | "esnext" 11 | ], 12 | "outDir": "../build", 13 | "strict": false, 14 | "sourceMap": true, 15 | "esModuleInterop": true, 16 | "experimentalDecorators": true, 17 | "emitDecoratorMetadata": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ui/src/websockets/emit-now-and-on-reconnect.ts: -------------------------------------------------------------------------------- 1 | export function emitNowAndOnReconnect( 2 | socket: SocketIOClient.Socket, 3 | emit: () => any, 4 | ): (() => void) | undefined { 5 | if (socket) { 6 | emit(); 7 | const event = 'connect'; 8 | const listener = () => { 9 | emit(); 10 | }; 11 | socket.on(event, listener); 12 | return () => { 13 | socket.removeListener(event, listener); 14 | }; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /server/src/entities/releases/serialize-release.ts: -------------------------------------------------------------------------------- 1 | import { Release } from './release'; 2 | import { serializeForm } from '../forms/serialize-form'; 3 | 4 | export function serializeRelease(release: Release) { 5 | return { 6 | _id: release._id, 7 | date: release.date, 8 | name: release.name, 9 | siteId: release.siteId, 10 | branches: release.branches || [], 11 | forms: release.forms?.map(serializeForm) || [], 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/hooks/form/HookForm.module.scss: -------------------------------------------------------------------------------- 1 | @import "src/styles/variables"; 2 | 3 | .remove { 4 | font-size: 1.5rem; 5 | cursor: pointer; 6 | display: flex; 7 | justify-content: flex-end; 8 | } 9 | 10 | .container { 11 | margin-bottom: .25rem; 12 | 13 | &:last-child { 14 | margin-bottom: 0; 15 | } 16 | 17 | &:hover { 18 | .remove { 19 | visibility: visible; 20 | opacity: 1; 21 | } 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /scripts/build-info.js: -------------------------------------------------------------------------------- 1 | // eval $(node build-info.js) 2 | 3 | const join = require('path').join; 4 | const execSync = require('child_process').execSync; 5 | 6 | const packageJson = require(join(process.cwd(), './package.json')); 7 | 8 | console.log(` 9 | export REACT_APP_VERSION=${packageJson.version}; 10 | export REACT_APP_BUILD_DATE=${new Date().toISOString()}; 11 | export REACT_APP_COMMIT_HASH=${execSync('git rev-parse HEAD').toString().trim()}; 12 | `); 13 | -------------------------------------------------------------------------------- /ui/src/utils/format-duration.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | export function formatDuration(seconds, microseconds): string { 4 | // some random moment in time 5 | const start = moment('2000-01-01T00:00:00'); 6 | const end = start 7 | .clone() 8 | .add(seconds, 'seconds') 9 | .add(microseconds / 1000000, 'milliseconds'); 10 | const diff = end.diff(start); 11 | return moment 12 | .utc(diff) 13 | .format('HH:mm:ss'); 14 | } 15 | -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | # urls 2 | MELI_URL=http://localhost:8080 3 | MELI_URL_INTERNAL=http://host.docker.internal:3001 4 | MELI_UI_URL_INTERNAL=http://host.docker.internal:3000 5 | MELI_SITES_URL=http://loopback.sh 6 | # db 7 | MELI_MONGO_URI=mongodb://localhost:27017/meli 8 | # auth 9 | MELI_JWT_SECRET=secret 10 | # directories 11 | MELI_SITES_DIR=./data/sites 12 | # github 13 | MELI_USER=user 14 | MELI_PASSWORD=password 15 | 16 | DEBUG=meli.api:handleError,meli* 17 | -------------------------------------------------------------------------------- /server/src/entities/api/serialize-api-token.ts: -------------------------------------------------------------------------------- 1 | import { ApiToken } from './api-token'; 2 | 3 | export function serializeApiToken(token: ApiToken) { 4 | return !token ? undefined : { 5 | _id: token._id, 6 | name: token.name, 7 | value: token.value, 8 | scopes: token.scopes || [], 9 | expiresAt: token.expiresAt, 10 | activatesAt: token.activatesAt, 11 | createdAt: token.createdAt, 12 | updatedAt: token.updatedAt, 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /server/src/entities/members/serialize-member.ts: -------------------------------------------------------------------------------- 1 | import { Users } from '../users/user'; 2 | import { Member } from './member'; 3 | 4 | export async function serializeMember(member: Member, ownerId: string) { 5 | const user = await Users().findOne({ 6 | _id: member.userId, 7 | }); 8 | return { 9 | _id: member._id, 10 | name: user.name, 11 | email: user.email, 12 | admin: member.admin, 13 | owner: user._id === ownerId, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /server/src/posthog/send-heartbeat.ts: -------------------------------------------------------------------------------- 1 | import { postHog, postHogId } from './posthog'; 2 | import { Logger } from '../commons/logger/logger'; 3 | 4 | const logger = new Logger('app.posthog:sendHeartbeat'); 5 | 6 | export function sendHeartbeat() { 7 | logger.debug('sending heartbeat'); 8 | postHog.capture({ 9 | event: 'heartbeat', 10 | distinctId: postHogId.id, 11 | properties: { 12 | version: BUILD_INFO.version, 13 | }, 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /ui/scripts/build-info.js: -------------------------------------------------------------------------------- 1 | // eval $(node build-info.js) 2 | 3 | const join = require('path').join; 4 | const execSync = require('child_process').execSync; 5 | 6 | const packageJson = require(join(process.cwd(), './package.json')); 7 | 8 | console.log(` 9 | export REACT_APP_VERSION=${packageJson.version}; 10 | export REACT_APP_BUILD_DATE=${new Date().toISOString()}; 11 | export REACT_APP_COMMIT_HASH=${execSync('git rev-parse HEAD').toString().trim()}; 12 | `); 13 | -------------------------------------------------------------------------------- /ui/src/commons/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import React from 'react'; 3 | import { faSpinner } from '@fortawesome/free-solid-svg-icons'; 4 | 5 | export function Loader({ className, ...props }: { className?: string; [key: string]: string }) { 6 | return ( 7 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/commons/components/ProgressBar.module.scss: -------------------------------------------------------------------------------- 1 | .progress { 2 | background: #EEECF9; 3 | border-radius: 100px; 4 | height: 10px; 5 | position: relative; 6 | transition: width .2s linear; 7 | } 8 | 9 | .bar { 10 | height: 100%; 11 | border-radius: 100px; 12 | overflow: hidden; 13 | } 14 | 15 | .gradient { 16 | background: linear-gradient(89.33deg, #661AFF 0%, #FF7F66 100%); 17 | width: 100%; 18 | height: 100%; 19 | position: relative; 20 | } 21 | -------------------------------------------------------------------------------- /server/src/entities/orgs/guards/max-org-guard.ts: -------------------------------------------------------------------------------- 1 | import { guard } from '../../../commons/express/guard'; 2 | import { Orgs } from '../org'; 3 | import { env } from '../../../env/env'; 4 | 5 | export const maxOrgsGuard = guard( 6 | async () => { 7 | if (env.MELI_MAX_ORGS === 0) { 8 | return true; 9 | } 10 | const count = await Orgs().countDocuments({}, { limit: 1 }); 11 | return count < env.MELI_MAX_ORGS; 12 | }, 13 | 'Cannot create more orgs', 14 | ); 15 | -------------------------------------------------------------------------------- /server/src/entities/orgs/org.ts: -------------------------------------------------------------------------------- 1 | import { AppDb } from '../../db/db'; 2 | import { Invite } from './invite'; 3 | import { StoredFile } from '../../storage/store-file'; 4 | 5 | export interface Org { 6 | _id: string; 7 | color: string; 8 | logo?: StoredFile; 9 | createdAt: Date; 10 | updatedAt: Date; 11 | ownerId: string; 12 | name: string; 13 | invites: Invite[]; 14 | hooks: string[]; 15 | } 16 | 17 | export const Orgs = () => AppDb.db.collection('orgs'); 18 | -------------------------------------------------------------------------------- /server/src/entities/users/user.ts: -------------------------------------------------------------------------------- 1 | import { AppDb } from '../../db/db'; 2 | import { ApiToken } from '../api/api-token'; 3 | 4 | export interface User { 5 | _id: string; 6 | createdAt: Date; 7 | updatedAt: Date; 8 | authProvider: string; 9 | externalUserId: any; 10 | name: string; 11 | email: string; 12 | tokens?: ApiToken[]; 13 | invalidateTokensAt?: number; 14 | hooks: string[]; 15 | } 16 | 17 | export const Users = () => AppDb.db.collection('users'); 18 | -------------------------------------------------------------------------------- /server/src/hooks/handlers/slack/get-slack-message.ts: -------------------------------------------------------------------------------- 1 | export function getSlackMessage(title: string, message: string) { 2 | return { 3 | blocks: [ 4 | { 5 | type: 'section', 6 | text: { 7 | type: 'mrkdwn', 8 | text: title, 9 | }, 10 | }, 11 | { 12 | type: 'section', 13 | text: { 14 | type: 'mrkdwn', 15 | text: message, 16 | }, 17 | }, 18 | ], 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /ui/src/commons/components/ExternalLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function ExternalLink({ 4 | href, className, children, onClick, 5 | }: { 6 | href: string; 7 | className?; 8 | children; 9 | onClick?; 10 | }) { 11 | return ( 12 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /server/src/hooks/serialize-hook.ts: -------------------------------------------------------------------------------- 1 | import { Hook } from './hook'; 2 | 3 | export function serializeHook(hook: Hook) { 4 | return { 5 | _id: hook._id, 6 | type: hook.type, 7 | name: hook.name, 8 | createdAt: hook.createdAt, 9 | updatedAt: hook.updatedAt, 10 | // TODO custom config serializer per type ? 11 | // TODO might not want to leak these secrets into webhooks and events ? 12 | config: hook.config, 13 | events: hook.events, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /server/src/entities/sites/get-site-main-domain.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'url'; 2 | import { env } from '../../env/env'; 3 | import { Site } from './site'; 4 | 5 | const sitesUrl = new URL(env.MELI_SITES_URL); 6 | 7 | export function getSiteMainDomain(site: Site, withPort = false): string { 8 | return `${ 9 | site.name 10 | }.${ 11 | sitesUrl.hostname 12 | }${ 13 | withPort && sitesUrl.port !== '80' && sitesUrl.port !== '443' ? `:${sitesUrl.port}` : '' 14 | }`; 15 | } 16 | -------------------------------------------------------------------------------- /server/src/entities/sites/guards/is-site-token-valid.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { Sites } from '../site'; 3 | 4 | export async function isSiteTokenValid(siteId: string, req: Request): Promise { 5 | const token = req.headers['x-meli-token'] as string; 6 | 7 | if (!token) { 8 | return false; 9 | } 10 | 11 | const site = await Sites().findOne({ 12 | _id: siteId, 13 | }); 14 | 15 | return site.tokens.some(({ value }) => value === token); 16 | } 17 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 3 | /* 4 | * Order matters: 5 | * 1. sourcemap support for proper stacktraces 6 | * 2. loading .env 7 | * 3. force chalk 8 | */ 9 | 10 | require('source-map-support/register'); 11 | require('dotenv/config'); 12 | require('./commons/force-chalk-colors'); 13 | 14 | const { createServer } = require('./createServer'); 15 | 16 | // eslint-disable-next-line no-console 17 | createServer().catch(console.error); 18 | -------------------------------------------------------------------------------- /ui/src/commons/components/EmptyList.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/variables'; 2 | 3 | .container { 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | color: transparentize($dark, 0.15); 8 | padding: 3rem 0; 9 | background-size: cover; 10 | } 11 | 12 | .title { 13 | font-size: 1.2rem; 14 | margin-top: 0.75rem; 15 | } 16 | 17 | .subtitle { 18 | } 19 | 20 | .action { 21 | margin-top: 1.5rem; 22 | } 23 | 24 | .icon { 25 | font-size: 3rem; 26 | } 27 | -------------------------------------------------------------------------------- /server/src/auth/utils/get-user-from-socket.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io'; 2 | import { verifyToken } from './verify-token'; 3 | import { authCookieName } from '../auth'; 4 | import cookie from 'cookie'; 5 | import { User } from '../../entities/users/user'; 6 | 7 | export function getUserFromSocket(socket: Socket): Promise { 8 | const cookies = cookie.parse(socket.handshake.headers.cookie || ''); 9 | const token = cookies[authCookieName]; 10 | return verifyToken(token); 11 | } 12 | -------------------------------------------------------------------------------- /server/src/commons/express/guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NextFunction, Request, Response, 3 | } from 'express'; 4 | import { ForbiddenError } from '../errors/forbidden-error'; 5 | 6 | export function guard( 7 | check: (req: Request) => Promise, 8 | message: string, 9 | ) { 10 | return (req: Request, res: Response, next: NextFunction) => { 11 | check(req) 12 | .then(isAllowed => next(isAllowed ? undefined : new ForbiddenError(message))) 13 | .catch(next); 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /server/src/entities/sites/serialize-branch.ts: -------------------------------------------------------------------------------- 1 | import { Branch } from './branch'; 2 | import { Site } from './site'; 3 | import { getBranchUrl } from './get-branch-url'; 4 | 5 | export function serializeBranch(site: Site, branch: Branch) { 6 | return !branch ? undefined : { 7 | _id: branch._id, 8 | name: branch.name, 9 | release: branch.release, 10 | hasPassword: !!branch.password, 11 | url: getBranchUrl(site, branch), 12 | headers: branch.headers || [], 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/websockets/listen-many.ts: -------------------------------------------------------------------------------- 1 | export function listenMany( 2 | socket: SocketIOClient.Socket, 3 | listeners: { 4 | event: string; 5 | listener: any; 6 | }[], 7 | ): () => void | undefined { 8 | if (socket) { 9 | listeners.forEach(({ event, listener }) => { 10 | socket.on(event, listener); 11 | }); 12 | return () => { 13 | listeners.forEach(({ event, listener }) => { 14 | socket.removeListener(event, listener); 15 | }); 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ui/src/components/sites/tokens/Tokens.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Route, Switch, useRouteMatch, 4 | } from 'react-router-dom'; 5 | import { TokenList } from './TokenList'; 6 | import { TokenView } from './TokenView'; 7 | 8 | export function Tokens() { 9 | const { path } = useRouteMatch(); 10 | return ( 11 | 12 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare const process: Process; 4 | 5 | declare interface Process { 6 | env: { 7 | NODE_ENV: string; 8 | REACT_APP_ENTERPRISE: boolean; 9 | // build info 10 | REACT_APP_VERSION: string; 11 | REACT_APP_BUILD_DATE: string; 12 | REACT_APP_COMMIT_HASH: string; 13 | // sentry 14 | REACT_APP_SENTRY_RELEASE: string; 15 | REACT_APP_SENTRY_DSN: string; 16 | }; 17 | } 18 | 19 | declare module '*.md'; 20 | -------------------------------------------------------------------------------- /server/src/auth/auth.ts: -------------------------------------------------------------------------------- 1 | import { env } from '../env/env'; 2 | 3 | export const authCookieName = 'auth'; 4 | 5 | export function getCookieOptions(maxAge = env.MELI_JWT_TOKEN_EXPIRATION) { 6 | return { 7 | httpOnly: true, 8 | path: '/', 9 | expires: new Date(new Date().getTime() + maxAge), 10 | maxAge, 11 | secure: env.MELI_COOKIE_SECURE, 12 | sameSite: env.MELI_COOKIE_SAMESITE, 13 | }; 14 | } 15 | 16 | export interface JwtToken { 17 | userId: string; 18 | issuedAt: number; 19 | } 20 | -------------------------------------------------------------------------------- /server/src/caddy/config/fallback.ts: -------------------------------------------------------------------------------- 1 | export const fallback = { 2 | /* 3 | * By default, caddy returns 200 with an empty response when no route matches. 4 | * This is a bit confusing, so we're changing it to a 523, used by Cloudflare to 5 | * indicate that the destination is unreachable. 6 | */ 7 | handle: [ 8 | { 9 | handler: 'static_response', 10 | status_code: '523', 11 | body: 'Requested URL not served on this server', 12 | }, 13 | ], 14 | terminal: true, 15 | }; 16 | -------------------------------------------------------------------------------- /ui/src/commons/sentry/SentryProvider.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables"; 2 | @import "../../styles/mixins"; 3 | 4 | .modal { 5 | text-align: center; 6 | } 7 | 8 | .buttons { 9 | 10 | button { 11 | margin-left: 1rem; 12 | } 13 | 14 | @include media-breakpoint-down(md) { 15 | display: flex; 16 | flex-direction: column; 17 | 18 | button { 19 | 20 | &:not(:first-child) { 21 | margin-top: 15px; 22 | } 23 | 24 | margin-left: 0; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ui/src/components/sites/settings/DomainForm.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .remove { 4 | font-size: 1.5rem; 5 | cursor: pointer; 6 | display: flex; 7 | justify-content: flex-end; 8 | } 9 | 10 | .notification { 11 | margin-bottom: .25rem; 12 | 13 | &:last-child { 14 | margin-bottom: 0; 15 | } 16 | 17 | &:hover { 18 | .remove { 19 | visibility: visible; 20 | opacity: 1; 21 | } 22 | } 23 | } 24 | 25 | textarea { 26 | min-height: 200px; 27 | } 28 | 29 | -------------------------------------------------------------------------------- /ui/src/components/sites/branches/Branches.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Route, Switch, useRouteMatch, 4 | } from 'react-router-dom'; 5 | import { BranchView } from './BranchView'; 6 | import { BranchList } from './BranchList'; 7 | 8 | export function Branches() { 9 | const { path } = useRouteMatch(); 10 | return ( 11 | 12 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/components/sites/settings/SecuritySettings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSite } from '../SiteView'; 3 | import { SitePassword } from './SitePassword'; 4 | 5 | export function SecuritySettings() { 6 | const { site, setSite } = useSite(); 7 | 8 | return ( 9 |
10 |
11 | 15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /server/src/prometheus/metrics/user-count.ts: -------------------------------------------------------------------------------- 1 | import { Prometheus } from '@promster/express'; 2 | import { env } from '../../env/env'; 3 | import { Users } from '../../entities/users/user'; 4 | 5 | const counter = new Prometheus.Gauge({ 6 | name: `${env.MELI_PROMETHEUS_METRICS_PREFIX}user_count`, 7 | help: 'Total number of users', 8 | }); 9 | 10 | export function userCount(): Promise { 11 | return Users() 12 | .estimatedDocumentCount({}) 13 | .then(res => { 14 | counter.set(res); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /ui/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-256x256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /server/src/events/emit-event.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../commons/logger/logger'; 2 | import { hookEventHandler } from '../hooks/hook-event-handler'; 3 | import { handleSocketEvent } from '../socket/handle-socket-event'; 4 | import { EventData } from './event-data'; 5 | 6 | const logger = new Logger('meli.api:emitEvent'); 7 | 8 | export function emitEvent(type: T, data: EventData[T]): void { 9 | logger.debug(type, data); 10 | hookEventHandler(type, data); 11 | handleSocketEvent(type, data); 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/components/auth/SignIn.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/variables'; 2 | @import '../../styles/mixins'; 3 | 4 | .container { 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | height: 100vh; 9 | width: 100%; 10 | } 11 | 12 | .title { 13 | font-size: 3rem; 14 | text-align: center; 15 | font-family: 'Source Serif Pro', serif; 16 | margin-bottom: 0; 17 | } 18 | 19 | .grid { 20 | width: 300px; 21 | 22 | @include media-breakpoint-down(xs) { 23 | width: 100%; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /server/src/entities/sites/branch.ts: -------------------------------------------------------------------------------- 1 | import { string } from 'joi'; 2 | import { STRING_MAX_LENGTH } from '../../constants'; 3 | import { Redirect } from './redirect'; 4 | import { Password } from './password'; 5 | import { Header } from './header'; 6 | 7 | export interface Branch { 8 | _id: string; 9 | name: string; 10 | slug: string; 11 | release?: string; 12 | password?: Password; 13 | redirects?: Redirect[]; 14 | headers?: Header[]; 15 | } 16 | 17 | export const $branchName = string().required().max(STRING_MAX_LENGTH); 18 | -------------------------------------------------------------------------------- /server/src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import { ApiToken } from './entities/api/api-token'; 4 | 5 | export interface BuildInfo { 6 | version: string; 7 | buildDate: Date; 8 | commitHash: string; 9 | } 10 | 11 | declare global { 12 | const BUILD_INFO: BuildInfo; 13 | const SENTRY_RELEASE: string; 14 | const SENTRY_DSN: string; 15 | } 16 | 17 | declare module '*.hbs'; 18 | 19 | declare global { 20 | namespace Express { 21 | interface Request { 22 | apiToken?: ApiToken; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ui/src/posthog/PosthogWarning.module.scss: -------------------------------------------------------------------------------- 1 | @import "src/styles/variables"; 2 | 3 | .container { 4 | padding: 3rem; 5 | background: linear-gradient(180deg, #200c39, #220f3f); 6 | color: $light; 7 | } 8 | 9 | .logos { 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | } 14 | 15 | .message { 16 | margin-top: 3rem; 17 | } 18 | 19 | .posthogLogo { 20 | height: 35px; 21 | } 22 | 23 | .plus { 24 | margin: 0 2rem; 25 | font-size: 2rem; 26 | } 27 | 28 | .meliLogo { 29 | height: 35px; 30 | } 31 | -------------------------------------------------------------------------------- /server/src/caddy/config/get-error-routes.ts: -------------------------------------------------------------------------------- 1 | import Server = Caddy.Http.Server; 2 | 3 | export function getErrorRoutes(): Server['errors'] { 4 | return { 5 | routes: [ 6 | { 7 | match: [{ 8 | expression: '{http.error.status_code} == 404', 9 | }], 10 | handle: [ 11 | { 12 | handler: 'static_response', 13 | status_code: '404', 14 | body: 'Not found', 15 | }, 16 | ], 17 | terminal: true, 18 | }, 19 | ], 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /server/src/entities/sites/guards/can-admin-site.ts: -------------------------------------------------------------------------------- 1 | import { canReadTeam } from '../../teams/guards/can-read-team'; 2 | import { Sites } from '../site'; 3 | import { NotFoundError } from '../../../commons/errors/not-found-error'; 4 | 5 | export async function canAdminSite(siteId: string, userId: string): Promise { 6 | const site = await Sites().findOne({ 7 | _id: siteId, 8 | }); 9 | 10 | if (!site) { 11 | throw new NotFoundError('Site not found'); 12 | } 13 | 14 | return canReadTeam(site.teamId, userId); 15 | } 16 | -------------------------------------------------------------------------------- /server/tests/utils/spyon-verifytoken.ts: -------------------------------------------------------------------------------- 1 | import * as _verifyToken from '../../src/auth/utils/verify-token'; 2 | import { User } from '../../src/entities/users/user'; 3 | 4 | export const AUTHENTICATED_USER_ID = 'authenticatedUserId'; 5 | 6 | export function spyOnVerifyToken(user: Partial = {}) { 7 | return jest.spyOn(_verifyToken, 'verifyToken').mockReturnValue(Promise.resolve({ 8 | _id: AUTHENTICATED_USER_ID, 9 | name: 'Authenticated User', 10 | email: 'authenticated@test.tst', 11 | ...user, 12 | } as User)); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/commons/components/Gauge.module.scss: -------------------------------------------------------------------------------- 1 | .gauge { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | position: relative; 6 | 7 | .value { 8 | font-size: 28px; 9 | line-height: 130%; 10 | color: inherit; 11 | } 12 | 13 | :global { 14 | .CircularProgressbar { 15 | position: absolute; 16 | width: 100%; 17 | height: 100%; 18 | } 19 | 20 | .CircularProgressbar .CircularProgressbar-path { 21 | stroke: url(#svg-gauge-gradient); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | .idea/* 4 | !.idea/runConfigurations 5 | 6 | # deps 7 | node_modules 8 | /.pnp 9 | .pnp.js 10 | 11 | # testing 12 | coverage 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | dist 29 | build 30 | tmp 31 | tmp.* 32 | data 33 | *.iml 34 | *.env 35 | *.tgz 36 | -------------------------------------------------------------------------------- /server/src/entities/invites/serialize-invite.ts: -------------------------------------------------------------------------------- 1 | import { Org } from '../orgs/org'; 2 | import { Invite } from '../orgs/invite'; 3 | import { getLogoUrl } from '../../utils/get-logo-url'; 4 | 5 | export function serializeUserInvite(org: Org, invite: Invite) { 6 | return { 7 | _id: invite._id, 8 | org: { 9 | name: org.name, 10 | color: org.color, 11 | logo: getLogoUrl('orgs', org, { invite: invite.token }), 12 | }, 13 | expiresAt: invite.expiresAt, 14 | memberOptions: invite.memberOptions, 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /server/src/entities/sites/get-redirect-url.ts: -------------------------------------------------------------------------------- 1 | import { Site } from './site'; 2 | import { getSiteUrl } from './get-site-url'; 3 | import { Branch } from './branch'; 4 | import { Redirect } from './redirect'; 5 | import { getBranchUrl } from './get-branch-url'; 6 | 7 | export function getRedirectUrl(site: Site, branch: Branch, rediret: Redirect): string { 8 | const isMainBranch = site.mainBranch === branch._id; 9 | const branchUrl = isMainBranch ? getSiteUrl(site) : getBranchUrl(site, branch); 10 | return `${branchUrl}${rediret.path}`; 11 | } 12 | -------------------------------------------------------------------------------- /ui/src/components/teams/members/add/AddMember.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../styles/variables"; 2 | 3 | .label { 4 | visibility: hidden; 5 | opacity: 0; 6 | transition: all $transition-duration $transition-effect; 7 | 8 | position: absolute; 9 | right: 15px; 10 | top: 50%; 11 | transform: translateY(-50%); 12 | } 13 | 14 | .listItem { 15 | &:hover { 16 | .label { 17 | visibility: visible; 18 | opacity: 1; 19 | } 20 | } 21 | } 22 | 23 | .modal { 24 | width: 80%; 25 | height: 80vh !important; 26 | } 27 | -------------------------------------------------------------------------------- /server/tests/utils/spyon-isadmin.ts: -------------------------------------------------------------------------------- 1 | import * as _isAdmin from '../../src/auth/guards/is-admin'; 2 | 3 | export function spyOnIsAdmin(value: boolean, requiredUserId?: string, requiredOrgId?: string) { 4 | return jest.spyOn(_isAdmin, 'isAdmin').mockImplementation(async (userId, orgId) => { 5 | if (requiredUserId !== undefined && userId !== requiredUserId) { 6 | return false; 7 | } 8 | 9 | if (requiredOrgId !== undefined && orgId !== requiredOrgId) { 10 | return false; 11 | } 12 | 13 | return value; 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /server/tests/utils/spyon-isowner.ts: -------------------------------------------------------------------------------- 1 | import * as _isOwner from '../../src/auth/guards/is-owner'; 2 | 3 | export function spyOnIsOwner(value: boolean, requiredUserId?: string, requiredOrgId?: string) { 4 | return jest.spyOn(_isOwner, 'isOwner').mockImplementation(async (userId, orgId) => { 5 | if (requiredUserId !== undefined && userId !== requiredUserId) { 6 | return false; 7 | } 8 | 9 | if (requiredOrgId !== undefined && orgId !== requiredOrgId) { 10 | return false; 11 | } 12 | 13 | return value; 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/commons/components/Currency.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function Currency({ 4 | value, currency, symbolOnly, 5 | }: { 6 | value?: number; 7 | currency: string; 8 | symbolOnly?: boolean; 9 | }) { 10 | const text = !symbolOnly 11 | ? new Intl.NumberFormat('en-US', { 12 | style: 'currency', currency, 13 | }).format(value) 14 | : new Intl.NumberFormat('en-US', { 15 | style: 'currency', currency, 16 | }) 17 | .format(0) 18 | .replace(/[0-9.,]/g, ''); 19 | return <>{text}; 20 | } 21 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react" 17 | }, 18 | "include": ["src"] 19 | } 20 | -------------------------------------------------------------------------------- /server/src/entities/users/handlers/get-user-handler.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { serializeUser } from '../../../auth/serialize-user'; 3 | import { wrapAsyncMiddleware } from '../../../commons/utils/wrap-async-middleware'; 4 | import { getUser } from '../../../auth/utils/get-user'; 5 | 6 | async function handler(req: Request, res: Response) { 7 | const user = getUser(req); 8 | res.json(user ? await serializeUser(user) : null); 9 | } 10 | 11 | export const getUserHandler = [ 12 | wrapAsyncMiddleware(handler), 13 | ]; 14 | -------------------------------------------------------------------------------- /ui/src/components/auth/UserInfo.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/variables'; 2 | 3 | .container { 4 | display: flex; 5 | align-items: center; 6 | cursor: pointer; 7 | } 8 | 9 | .bubble { 10 | width: 40px; 11 | height: 40px; 12 | } 13 | 14 | .user { 15 | position: relative; 16 | } 17 | 18 | //.gitlab { 19 | // background: $gitlab-bg; 20 | //} 21 | // 22 | //.gitea { 23 | // background: $gitea-bg; 24 | //} 25 | // 26 | //.github { 27 | // background: $github-bg; 28 | //} 29 | // 30 | //.google { 31 | // background: $google-bg; 32 | //} 33 | -------------------------------------------------------------------------------- /ui/src/components/hooks/HookProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext } from 'react'; 2 | 3 | interface HookContext { 4 | context: string; 5 | } 6 | 7 | const Context = createContext(undefined); 8 | 9 | export const useHookContext = () => useContext(Context); 10 | 11 | export function HookProvider({ context, ...props }: { 12 | context: string; 13 | [prop: string]: any; 14 | }) { 15 | return ( 16 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /ui/src/components/sidebar/SideBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import styles from './SideBar.module.scss'; 4 | import { Teams } from './Teams'; 5 | 6 | export function SideBar({ className }: { className? }) { 7 | return ( 8 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /ui/src/components/sites/tokens/get-tokens.ts: -------------------------------------------------------------------------------- 1 | import { axios } from '../../../providers/axios'; 2 | import { Env } from '../../../providers/EnvProvider'; 3 | import { Page } from '../../../commons/types/page'; 4 | import { Token } from './token'; 5 | 6 | export function getTokens( 7 | env: Env, 8 | siteId: string, 9 | search?: string, 10 | ): Promise> { 11 | return axios 12 | .get(`/api/v1/sites/${siteId}/tokens`, { 13 | params: { 14 | search: search || undefined, 15 | }, 16 | }) 17 | .then(res => res.data); 18 | } 19 | -------------------------------------------------------------------------------- /server/src/entities/releases/guards/can-admin-release.ts: -------------------------------------------------------------------------------- 1 | import { Releases } from '../release'; 2 | import { canAdminSite } from '../../sites/guards/can-admin-site'; 3 | import { NotFoundError } from '../../../commons/errors/not-found-error'; 4 | 5 | export async function canAdminRelease(releaseId: string, userId: string): Promise { 6 | const release = await Releases().findOne({ 7 | _id: releaseId, 8 | }); 9 | 10 | if (!release) { 11 | throw new NotFoundError('Release not found'); 12 | } 13 | 14 | return canAdminSite(release.siteId, userId); 15 | } 16 | -------------------------------------------------------------------------------- /server/src/storage/delete-file.ts: -------------------------------------------------------------------------------- 1 | import { getFilePath } from './get-file-path'; 2 | import { promises } from 'fs'; 3 | 4 | async function fileExists(path: string): Promise { 5 | try { 6 | await promises.stat(path); 7 | return true; 8 | } catch (e) { 9 | return false; 10 | } 11 | } 12 | 13 | export async function deleteFile(id: string): Promise { 14 | const filePath = getFilePath(id); 15 | 16 | const exists = await fileExists(filePath); 17 | if (!exists) { 18 | return; 19 | } 20 | 21 | return promises.unlink(filePath); 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/components/sites/branches/branch-redirect.ts: -------------------------------------------------------------------------------- 1 | export enum RedirectType { 2 | file = 'file', 3 | reverse_proxy = 'reverse_proxy', 4 | } 5 | 6 | export interface FileRedirectConfig { 7 | content: string; 8 | } 9 | 10 | export interface ReverseProxyRedirectConfig { 11 | url: string; 12 | stripPathPrefix: string; 13 | } 14 | 15 | export interface BranchRedirect { 16 | _id: string; 17 | type: RedirectType; 18 | // https://caddyserver.com/docs/json/apps/http/servers/routes/match/path/ 19 | path: string; 20 | config: T; 21 | url: string; 22 | } 23 | -------------------------------------------------------------------------------- /server/src/entities/forms/serialize-form.ts: -------------------------------------------------------------------------------- 1 | import { EmailForm, Form } from './form'; 2 | 3 | function serializeEmailForm(form: EmailForm) { 4 | return { 5 | recipient: form.recipient, 6 | }; 7 | } 8 | 9 | function customFields(form: Form) { 10 | switch (form.type) { 11 | case 'email': 12 | return serializeEmailForm(form); 13 | default: { 14 | return {}; 15 | } 16 | } 17 | } 18 | 19 | export function serializeForm(form: Form) { 20 | return { 21 | type: form.type, 22 | name: form.name, 23 | ...customFields(form), 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /server/src/db/db.ts: -------------------------------------------------------------------------------- 1 | import { Db, MongoClient } from 'mongodb'; 2 | import { env } from '../env/env'; 3 | import { buildMongoUri } from './build-mongo-uri'; 4 | 5 | const url = env.MELI_MONGO_URI || buildMongoUri(); 6 | 7 | const client = new MongoClient(url, { 8 | useNewUrlParser: true, 9 | useUnifiedTopology: true, 10 | }); 11 | 12 | export class AppDb { 13 | static client: MongoClient; 14 | 15 | static db: Db; 16 | 17 | static async init(): Promise { 18 | await client.connect(); 19 | AppDb.db = client.db(); 20 | AppDb.client = client; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/components/teams/members/get-members.ts: -------------------------------------------------------------------------------- 1 | import { axios } from '../../../providers/axios'; 2 | import { Env } from '../../../providers/EnvProvider'; 3 | import { Page } from '../../../commons/types/page'; 4 | import { TeamMember } from './team-member'; 5 | 6 | export function getMembers( 7 | env: Env, 8 | teamId: string, 9 | search?: string, 10 | ): Promise> { 11 | return axios 12 | .get(`/api/v1/teams/${teamId}/members`, { 13 | params: { 14 | search: search || undefined, 15 | }, 16 | }) 17 | .then(res => res.data); 18 | } 19 | -------------------------------------------------------------------------------- /server/tests/utils/spyon-isadminorowner.ts: -------------------------------------------------------------------------------- 1 | import * as _isAdminOrOwner from '../../src/auth/guards/is-admin-or-owner'; 2 | 3 | export function spyOnIsAdminOrOwner(value: boolean, requiredUserId?: string, requiredOrgId?: string) { 4 | return jest.spyOn(_isAdminOrOwner, 'isAdminOrOwner').mockImplementation(async (userId, orgId) => { 5 | if (requiredUserId !== undefined && userId !== requiredUserId) { 6 | return false; 7 | } 8 | 9 | if (requiredOrgId !== undefined && orgId !== requiredOrgId) { 10 | return false; 11 | } 12 | 13 | return value; 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/commons/components/EmptyList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './EmptyList.module.scss'; 3 | 4 | export function EmptyList({ 5 | title, icon, subTitle, children, 6 | }: { 7 | title: string; 8 | subTitle?: string; 9 | icon: any; 10 | children?: any; 11 | }) { 12 | return ( 13 |
14 |
{icon}
15 |

{title}

16 | {subTitle &&
{subTitle}
} 17 | {children || <>} 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /ui/src/commons/components/modals/CloseModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import styles from './CloseModal.module.scss'; 4 | import { KeyboardShortcut } from '../KeyboardShortcut'; 5 | 6 | export function CloseModal({ onClick, className }: { onClick; className? }) { 7 | return ( 8 |
9 |
Press
10 | 11 | esc 12 | 13 |
to close
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/components/hooks/Hooks.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Route, Switch, useRouteMatch, 4 | } from 'react-router-dom'; 5 | import { HookList } from './HookList'; 6 | import { AddHook } from './AddHook'; 7 | import { HookView } from './HookView'; 8 | 9 | export function Hooks() { 10 | const { path } = useRouteMatch(); 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /ui/src/components/teams/settings/TeamSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TeamGeneralSettings } from './TeamGeneralSettings'; 3 | import { Logo } from '../../commons/Logo'; 4 | import { useTeam } from '../TeamView'; 5 | 6 | function TeamLogo() { 7 | const { team, setTeam } = useTeam(); 8 | return ( 9 | 14 | ); 15 | } 16 | 17 | export function TeamSettings() { 18 | return ( 19 | <> 20 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /server/src/constants.ts: -------------------------------------------------------------------------------- 1 | import { AsyncValidationOptions } from 'joi'; 2 | 3 | export const APP_NAME = 'Meli'; 4 | export const APP_URL = 'https://meli.sh'; 5 | 6 | export const JOI_OPTIONS: AsyncValidationOptions = { 7 | abortEarly: true, 8 | stripUnknown: true, 9 | convert: true, 10 | }; 11 | export const STRING_MAX_LENGTH = 1000; 12 | export const LONG_STRING_MAX_LENGTH = 5000; 13 | export const ARRAY_MAX = 1000; 14 | export const STRIPE_SIGNATURE_HEADER = 'stripe-signature'; 15 | export const SUBDOMAIN_PATTERN = /^[a-z0-9]?[a-z0-9-]*[a-z0-9]{1}$/; 16 | export const COLOR_PATTERN = /^#[a-z0-9]{6}$/; 17 | -------------------------------------------------------------------------------- /ui/src/commons/components/ButtonIcon.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/variables'; 2 | 3 | .container { 4 | cursor: pointer; 5 | background: $gray-100; 6 | color: $dark; 7 | height: 30px; 8 | width: 30px; 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | border-radius: 50%; 13 | 14 | transition: all $transition-duration $transition-effect; 15 | 16 | &:hover { 17 | font-weight: bold; 18 | background: $gray-200; 19 | text-shadow: $_boxShadow; 20 | } 21 | 22 | &.disabled { 23 | cursor: default; 24 | color: $text-muted; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ui/src/commons/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import ReactTooltip from 'react-tooltip'; 2 | import React from 'react'; 3 | import styles from './Tooltip.module.scss'; 4 | 5 | export function Tooltip({ 6 | children, id, ...props 7 | }: { children: any; id: string; [key: string]: any }) { 8 | return ( 9 | <> 10 | 11 | {children} 12 | 13 | 14 | ); 15 | } 16 | 17 | export function tooltipToggle(id: string) { 18 | return { 19 | 'data-tip': 'tip', 20 | 'data-for': id, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/utils/text-search.ts: -------------------------------------------------------------------------------- 1 | function cleanString(s: string): string { 2 | if (!s) { 3 | return ''; 4 | } 5 | return s 6 | .toLowerCase() 7 | .trim() 8 | .replace(/[&/\\#,+()$~%. '":*?<>{}\-_]/g, ''); 9 | } 10 | 11 | export function textSearch(needle: string, haystack: string): boolean { 12 | if (!needle || !haystack) { 13 | return true; 14 | } 15 | const cleanHaystack = cleanString(haystack); 16 | 17 | return needle.split(' ') 18 | .some(term => { 19 | const cleanTerm = cleanString(term); 20 | return cleanHaystack.indexOf(cleanTerm) !== -1; 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Guidelines for this Project 2 | 3 | We take security seriously and do our best to keep our code secure. However, if you have found or think you may have found a security vulnerability, you should open a security advisory [here](./security/advisories/new) or contact us at info@charie-bravo.be. 4 | 5 | Examples of things you should **not** do: 6 | 7 | - open a normal issue 8 | - disclose sensitive information publicly 9 | - use, distribute or disclose information exploited from a security vulnerability 10 | - do any harm to systems or persons impacted by the security vulnerability 11 | - ... 12 | -------------------------------------------------------------------------------- /ui/src/commons/components/Bubble.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import styles from './Bubble.module.scss'; 4 | 5 | export function Bubble({ color, className, src }: { 6 | color?: string; 7 | src?: string; 8 | className?: any; 9 | }) { 10 | return src ? ( 11 | bubble 16 | ) : ( 17 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /ui/src/commons/components/dropdown/DropdownLink.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/variables'; 2 | 3 | .link { 4 | font-weight: 500; 5 | font-size: 14px; 6 | line-height: 130%; 7 | display: flex; 8 | align-items: center; 9 | padding: 0.6rem 1rem; 10 | color: $dark; 11 | 12 | &.disabled { 13 | color: $text-muted; 14 | } 15 | 16 | &:not(.disabled) { 17 | &:hover { 18 | text-decoration: none; 19 | color: $primary; 20 | cursor: pointer; 21 | background: $gray-100; 22 | } 23 | } 24 | } 25 | 26 | .icon { 27 | //width: 30px; 28 | margin-right: 8px; 29 | } 30 | -------------------------------------------------------------------------------- /ui/src/utils/debounce-time.ts: -------------------------------------------------------------------------------- 1 | export function debounceTime( 2 | cb: (val: any) => Promise | T, 3 | time: number, 4 | ): (val: any) => Promise { 5 | let timeout; 6 | return val => { 7 | if (timeout) { 8 | clearTimeout(timeout); 9 | } 10 | return new Promise((resolve, reject) => { 11 | timeout = setTimeout( 12 | async () => { 13 | try { 14 | const res = await cb(val); 15 | resolve(res); 16 | } catch (e) { 17 | reject(e); 18 | } 19 | }, 20 | time, 21 | ); 22 | }); 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /server/src/entities/api/handlers/endpoints/list-api-endpoints.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { wrapAsyncMiddleware } from '../../../../commons/utils/wrap-async-middleware'; 3 | import { apiEndpoints } from '../../api-endpoint'; 4 | import { serializeEndpoint } from '../../serialize-endpoint'; 5 | 6 | const validators = []; 7 | 8 | async function handler(req: Request, res: Response): Promise { 9 | const json = apiEndpoints.map(serializeEndpoint); 10 | res.json(json); 11 | } 12 | 13 | export const listApiEndpoints = [ 14 | ...validators, 15 | wrapAsyncMiddleware(handler), 16 | ]; 17 | -------------------------------------------------------------------------------- /ui/src/components/sites/releases/release.ts: -------------------------------------------------------------------------------- 1 | export enum FormType { 2 | db = 'db', 3 | email = 'email', 4 | } 5 | 6 | export interface FormBase { 7 | name: string; 8 | } 9 | 10 | export interface DbForm extends FormBase { 11 | type: FormType.db; 12 | } 13 | 14 | export interface EmailForm extends FormBase { 15 | type: FormType.email; 16 | recipient: string; 17 | } 18 | 19 | export type Form = 20 | | DbForm 21 | | EmailForm; 22 | 23 | export interface Release { 24 | _id: string; 25 | name: string; 26 | date: Date; 27 | siteId: string; 28 | branches?: string[]; 29 | forms?: Form[]; 30 | } 31 | -------------------------------------------------------------------------------- /server/src/commons/express-joi/params.ts: -------------------------------------------------------------------------------- 1 | import { AnySchema } from 'joi'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | import { JOI_OPTIONS } from '../../constants'; 4 | import { BadRequestError } from '../errors/bad-request-error'; 5 | 6 | export function params($schema: AnySchema) { 7 | return (req: Request, res: Response, next: NextFunction) => { 8 | $schema 9 | .validateAsync(req.params, JOI_OPTIONS) 10 | .then(() => { 11 | next(); 12 | }) 13 | .catch(err => { 14 | next(new BadRequestError('Invalid params', err.details)); 15 | }); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /server/src/commons/utils/wrap-async-middleware.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NextFunction, Request, Response, 3 | } from 'express'; 4 | 5 | export function wrapAsyncMiddleware( 6 | fn: (req?: Request, res?: Response, next?: NextFunction) => void | Promise, 7 | ): (req?: Request, res?: Response, next?: NextFunction) => void | Promise { 8 | return async (req?: Request, res?: Response, next?: NextFunction) => { 9 | try { 10 | const result = fn(req, res, next); 11 | if (result) { 12 | (result as Promise).catch(next); 13 | } 14 | } catch (e) { 15 | next(e); 16 | } 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /server/src/auth/guards/api-guard.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import { ApiScope } from '../../entities/api/api-scope'; 3 | import { ForbiddenError } from '../../commons/errors/forbidden-error'; 4 | 5 | export function apiGuard(...scopes: ApiScope[]) { 6 | return (req: Request, res: Response, next: NextFunction) => { 7 | const { apiToken } = req as any; 8 | if (apiToken && !scopes.some(scope => apiToken.scopes.includes(scope))) { 9 | next(new ForbiddenError(`Api token is missing one of scopes [${scopes.join(',')}]`)); 10 | } else { 11 | next(); 12 | } 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /server/src/utils/get-logo-url.ts: -------------------------------------------------------------------------------- 1 | import { env } from '../env/env'; 2 | import { StoredFile } from '../storage/store-file'; 3 | import qs from 'qs'; 4 | 5 | interface EntityWithLogo { 6 | _id: string; 7 | logo?: StoredFile; 8 | } 9 | 10 | export function getLogoUrl(context: string, entity: EntityWithLogo, extraQueryParams?: { [key: string]: string }): string | undefined { 11 | const query = qs.stringify({ 12 | ...extraQueryParams, 13 | // force cache refresh in frontend 14 | id: entity.logo?.id, 15 | }); 16 | return entity.logo ? `${env.MELI_URL}/api/v1/${context}/${entity._id}/logo?${query}` : undefined; 17 | } 18 | -------------------------------------------------------------------------------- /ui/src/components/orgs/staff/members/get-members.ts: -------------------------------------------------------------------------------- 1 | import { axios } from '../../../../providers/axios'; 2 | import { Page } from '../../../../commons/types/page'; 3 | import { OrgMember } from './org-member'; 4 | 5 | export interface OrgMembersSearchQuery { 6 | search: string; 7 | page: number; 8 | size: number; 9 | } 10 | 11 | export function getMembers( 12 | orgId: string, 13 | query?: OrgMembersSearchQuery, 14 | ): Promise> { 15 | return axios 16 | .get(`/api/v1/orgs/${orgId}/members`, { 17 | params: { 18 | ...query, 19 | }, 20 | }) 21 | .then(res => res.data); 22 | } 23 | -------------------------------------------------------------------------------- /server/src/entities/teams/guards/can-read-team-guard.ts: -------------------------------------------------------------------------------- 1 | import { guard } from '../../../commons/express/guard'; 2 | import { params } from '../../../commons/express-joi/params'; 3 | import { object } from 'joi'; 4 | import { getUser } from '../../../auth/utils/get-user'; 5 | import { canReadTeam } from './can-read-team'; 6 | import { $id } from '../../../utils/id'; 7 | 8 | export const canReadTeamGuard = [ 9 | params(object({ 10 | teamId: $id, 11 | })), 12 | guard(req => { 13 | const user = getUser(req); 14 | const { teamId } = req.params; 15 | return canReadTeam(teamId, user._id); 16 | }, 'Cannot get team'), 17 | ]; 18 | -------------------------------------------------------------------------------- /ui/src/commons/hooks/use-mounted-state.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useCallback, useEffect, useRef, useState, 3 | } from 'react'; 4 | import { ReactState } from '../types/react-state'; 5 | 6 | export function useMountedState(initialValue?: T): ReactState { 7 | const mounted = useRef(true); 8 | const [loading, _setLoading] = useState(initialValue); 9 | 10 | useEffect(() => () => { 11 | mounted.current = false; 12 | }, []); 13 | 14 | const setLoading = useCallback((val: T) => { 15 | if (mounted && mounted.current) { 16 | _setLoading(val); 17 | } 18 | }, [mounted]); 19 | 20 | return [loading, setLoading]; 21 | } 22 | -------------------------------------------------------------------------------- /server/src/db/migrate/roll-forward.ts: -------------------------------------------------------------------------------- 1 | import { Db, MongoClient } from 'mongodb'; 2 | import chalk from 'chalk'; 3 | import { up } from 'migrate-mongo'; 4 | import { Logger } from '../../commons/logger/logger'; 5 | 6 | const logger = new Logger('meli.api:migrate.rollForward'); 7 | 8 | export async function rollForward(db: Db, client: MongoClient): Promise { 9 | logger.debug('Rolling forward'); 10 | const migrated: any[] = await up(db, client); 11 | if (migrated?.length > 0) { 12 | migrated.forEach(fileName => logger.info(`Migrated ${chalk.bold(fileName)}`)); 13 | } else { 14 | logger.info('Nothing to migrate'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /server/src/entities/sites/guards/can-admin-site-guard.ts: -------------------------------------------------------------------------------- 1 | import { guard } from '../../../commons/express/guard'; 2 | import { params } from '../../../commons/express-joi/params'; 3 | import { object } from 'joi'; 4 | import { getUser } from '../../../auth/utils/get-user'; 5 | import { canAdminSite } from './can-admin-site'; 6 | import { $id } from '../../../utils/id'; 7 | 8 | export const canAdminSiteGuard = [ 9 | params(object({ 10 | siteId: $id, 11 | })), 12 | guard(req => { 13 | const user = getUser(req); 14 | const { siteId } = req.params; 15 | return canAdminSite(siteId, user._id); 16 | }, 'Cannot delete site'), 17 | ]; 18 | -------------------------------------------------------------------------------- /ui/src/commons/components/LoadMore.module.scss: -------------------------------------------------------------------------------- 1 | @import 'src/styles/variables'; 2 | 3 | .button { 4 | border: 2px solid $text-muted; 5 | border-radius: $list-group-border-radius; 6 | background: $list-group-bg; 7 | padding: $list-group-item-padding-y $list-group-item-padding-x; 8 | display: block; 9 | width: 100%; 10 | 11 | &:active, 12 | &:focus { 13 | outline: none; 14 | } 15 | 16 | transition: all $transition-duration $transition-effect; 17 | 18 | &:hover { 19 | border-color: $primary; 20 | } 21 | 22 | &.disabled { 23 | &:hover { 24 | border-color: $text-muted !important; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /server/src/caddy/definitions/config.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Caddy { 2 | 3 | type TODO = unknown; 4 | type NON_STANDARD = any; 5 | type EMPTY_OBJECT = { [key: string]: never }; 6 | type UNDOCUMENTED = any; 7 | 8 | type Duration = number | string; 9 | 10 | interface Root { 11 | '@id'?: string; 12 | admin?: Admin; 13 | logging?: Logging; 14 | storage?: Storage; 15 | apps?: Apps; 16 | } 17 | 18 | interface HttpServerTlsConnectionPolicy { 19 | '@id'?: string; 20 | match?: TlsHandshakeMatch; 21 | } 22 | 23 | interface TlsHandshakeMatch { 24 | '@id'?: string; 25 | sni?: string[]; 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /ui/src/components/auth/Orgs.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/variables'; 2 | @import '../../styles/mixins'; 3 | 4 | .container { 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | } 9 | 10 | .title { 11 | font-size: 3rem; 12 | text-align: center; 13 | font-family: 'Source Serif Pro', serif; 14 | margin-bottom: 0; 15 | } 16 | 17 | .grid { 18 | width: 300px; 19 | 20 | @include media-breakpoint-down(xs) { 21 | width: 100%; 22 | } 23 | } 24 | 25 | .add { 26 | text-transform: uppercase; 27 | font-weight: bold; 28 | text-align: center !important; 29 | //border: 2px solid $gray-200 !important; 30 | } 31 | -------------------------------------------------------------------------------- /ui/src/commons/components/AdBlockWarning.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Alert } from './Alert'; 3 | 4 | export function adBlockerEnabled(): boolean { 5 | return !document.getElementById('adsjs-wlegKJyqQhLm'); 6 | } 7 | 8 | export function AdBlockerWarning({ className }: { className?: string }) { 9 | const [hasAdBlocker] = useState(adBlockerEnabled()); 10 | return hasAdBlocker ? ( 11 | 15 | You seem to have an ad blocker enabled. You may experience issues with Stripe payments. 16 | 17 | ) : ( 18 | <> 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /ui/src/components/Home.module.scss: -------------------------------------------------------------------------------- 1 | @import '../styles/variables'; 2 | 3 | .container { 4 | padding-top: 100px; 5 | display: flex; 6 | flex-direction: column; 7 | align-items: center; 8 | } 9 | 10 | .logo { 11 | width: 100px; 12 | position: relative; 13 | } 14 | 15 | .inspire { 16 | max-width: 350px; 17 | text-align: center; 18 | } 19 | 20 | .quoteContainer { 21 | margin-top: 100px; 22 | position: relative; 23 | font-size: .8em; 24 | } 25 | 26 | .quoteIcon { 27 | position: absolute; 28 | font-size: 10rem; 29 | color: transparentize($dark, .98); 30 | left: 50%; 31 | top: 50%; 32 | transform: translate(-50%, -50%); 33 | } 34 | -------------------------------------------------------------------------------- /server/src/commons/express-joi/query.ts: -------------------------------------------------------------------------------- 1 | import { AnySchema } from 'joi'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | import { JOI_OPTIONS } from '../../constants'; 4 | import { BadRequestError } from '../errors/bad-request-error'; 5 | 6 | export function query($schema: AnySchema) { 7 | return (req: Request, res: Response, next: NextFunction) => { 8 | $schema 9 | .validateAsync(req.query, JOI_OPTIONS) 10 | .then(value => { 11 | req.query = value; 12 | next(undefined); 13 | }) 14 | .catch(err => { 15 | next(new BadRequestError('Invalid query', err.details)); 16 | }); 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /server/src/commons/utils/exec-async.ts: -------------------------------------------------------------------------------- 1 | import { exec, ExecOptions } from 'child_process'; 2 | 3 | export class ExecError extends Error { 4 | constructor( 5 | private readonly error: any, 6 | private readonly stdout: string, 7 | private readonly stderr: string, 8 | ) { 9 | super(); 10 | } 11 | } 12 | 13 | export function execAsync(cmd: string, options?: ExecOptions): Promise { 14 | return new Promise((resolve, reject) => { 15 | exec(cmd, options, (error, stdout, stderr) => { 16 | if (error) { 17 | reject(new ExecError(error, stdout, stderr)); 18 | } 19 | resolve(stdout); 20 | }); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /server/src/entities/users/handlers/invalidate-tokens.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { wrapAsyncMiddleware } from '../../../commons/utils/wrap-async-middleware'; 3 | import { getUser } from '../../../auth/utils/get-user'; 4 | import { Users } from '../user'; 5 | 6 | async function handler(req: Request, res: Response) { 7 | const user = getUser(req); 8 | 9 | await Users().updateOne({ 10 | _id: user._id, 11 | }, { 12 | $set: { 13 | invalidateTokensAt: Date.now(), 14 | }, 15 | }); 16 | 17 | res.status(204).send(); 18 | } 19 | 20 | export const invalidateTokens = [ 21 | wrapAsyncMiddleware(handler), 22 | ]; 23 | -------------------------------------------------------------------------------- /server/src/db/migrate/roll-forward.spec.ts: -------------------------------------------------------------------------------- 1 | import { Db, MongoClient } from 'mongodb'; 2 | import { up } from 'migrate-mongo'; 3 | import { rollForward } from './roll-forward'; 4 | 5 | jest.mock('migrate-mongo'); 6 | 7 | describe('rollforward', () => { 8 | let db: Db; 9 | let client: MongoClient; 10 | 11 | beforeEach(() => { 12 | db = {} as any; 13 | client = {} as any; 14 | }); 15 | 16 | afterEach(() => { 17 | jest.clearAllMocks(); 18 | jest.restoreAllMocks(); 19 | }); 20 | 21 | it('should run migration', async () => { 22 | await rollForward(db, client); 23 | 24 | expect(up).toHaveBeenCalledWith(db, client); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /server/src/commons/express-joi/body.ts: -------------------------------------------------------------------------------- 1 | import { AnySchema } from 'joi'; 2 | import { 3 | NextFunction, Request, Response, 4 | } from 'express'; 5 | import { BadRequestError } from '../errors/bad-request-error'; 6 | import { JOI_OPTIONS } from '../../constants'; 7 | 8 | export function body($schema: AnySchema) { 9 | return (req: Request, res: Response, next: NextFunction) => { 10 | $schema 11 | .validateAsync(req.body, JOI_OPTIONS) 12 | .then(value => { 13 | req.body = value; 14 | next(undefined); 15 | }) 16 | .catch(err => { 17 | next(new BadRequestError('Invalid body', err.details)); 18 | }); 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /ui/src/utils/suffix-time.ts: -------------------------------------------------------------------------------- 1 | import { round } from 'lodash'; 2 | 3 | interface SuffixTime { 4 | value: number; 5 | suffix: string; 6 | } 7 | 8 | export function suffixTime(ms: number, precision = 1): SuffixTime { 9 | if (ms >= 3600000) { 10 | return { 11 | value: round(ms / 3600000, precision), suffix: 'h', 12 | }; 13 | } 14 | if (ms >= 60000) { 15 | return { 16 | value: round(ms / 60000, precision), suffix: 'min', 17 | }; 18 | } 19 | if (ms >= 1000) { 20 | return { 21 | value: round(ms / 1000, precision), suffix: 's', 22 | }; 23 | } 24 | return { 25 | value: round(ms, precision), suffix: 'ms', 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /server/src/db/migrate/roll-backwards.spec.ts: -------------------------------------------------------------------------------- 1 | import { Db, MongoClient } from 'mongodb'; 2 | import { down } from 'migrate-mongo'; 3 | import { rollBackwards } from './roll-backwards'; 4 | 5 | jest.mock('migrate-mongo'); 6 | 7 | describe('rollBackwards', () => { 8 | let db: Db; 9 | let client: MongoClient; 10 | 11 | beforeEach(() => { 12 | db = {} as any; 13 | client = {} as any; 14 | }); 15 | 16 | afterEach(() => { 17 | jest.clearAllMocks(); 18 | jest.restoreAllMocks(); 19 | }); 20 | 21 | it('should run migration', async () => { 22 | await rollBackwards(db, client); 23 | expect(down).toHaveBeenCalledWith(db, client); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /server/src/entities/orgs/guards/is-org-member-guard.ts: -------------------------------------------------------------------------------- 1 | import { getUser } from '../../../auth/utils/get-user'; 2 | import { guard } from '../../../commons/express/guard'; 3 | import { isOrgMember } from './is-org-member'; 4 | import { params } from '../../../commons/express-joi/params'; 5 | import { object } from 'joi'; 6 | import { $id } from '../../../utils/id'; 7 | 8 | export const isOrgMemberGuard = [ 9 | params(object({ 10 | orgId: $id, 11 | })), 12 | guard( 13 | req => { 14 | const { _id } = getUser(req); 15 | const { orgId } = req.params; 16 | return isOrgMember(_id, orgId); 17 | }, 18 | 'Not allowed to read org', 19 | ), 20 | ]; 21 | -------------------------------------------------------------------------------- /server/src/entities/sites/serialize-redirect.ts: -------------------------------------------------------------------------------- 1 | import { Branch } from './branch'; 2 | import { Site } from './site'; 3 | import { getSiteUrl } from './get-site-url'; 4 | import { getBranchUrl } from './get-branch-url'; 5 | import { Redirect } from './redirect'; 6 | 7 | export function serializeRedirect(site: Site, branch: Branch, redirect: Redirect) { 8 | const isMainBranch = site.mainBranch === branch._id; 9 | const url = isMainBranch ? getSiteUrl(site) : getBranchUrl(site, branch); 10 | return { 11 | _id: redirect._id, 12 | type: redirect.type, 13 | path: redirect.path, 14 | config: redirect.config, 15 | url: `${url}${redirect.path}`, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /server/src/hooks/handlers/web/handle-web-hook.ts: -------------------------------------------------------------------------------- 1 | import { Hook } from '../../hook'; 2 | import { EventType } from '../../../events/event-type'; 3 | import { deliverWebHook } from './deliver-web-hook'; 4 | import { getPayload } from './get-payload'; 5 | 6 | export function handleWebHook(hook: Hook, eventType: EventType, data: any) { 7 | const payloadFn = getPayload[eventType]; 8 | const payload = payloadFn ? payloadFn(data) : { 9 | eventType, 10 | data: { 11 | message: 'No custom handler set for this event, sending empty data to prevent leaking sensistive information', 12 | }, 13 | }; 14 | return deliverWebHook(hook.config, eventType, payload); 15 | } 16 | -------------------------------------------------------------------------------- /server/src/db/migrate/roll-backwards.ts: -------------------------------------------------------------------------------- 1 | import { Db, MongoClient } from 'mongodb'; 2 | import chalk from 'chalk'; 3 | import { down } from 'migrate-mongo'; 4 | import { Logger } from '../../commons/logger/logger'; 5 | 6 | const logger = new Logger('meli.api:migrate.rollBackwards'); 7 | 8 | export async function rollBackwards(db: Db, client: MongoClient): Promise { 9 | logger.debug('Rolling back'); 10 | const migratedDown = await down(db, client); 11 | if (migratedDown?.length > 0) { 12 | migratedDown.forEach(fileName => logger.info(`Rolled back migration: ${chalk.bold(fileName)}`)); 13 | } else { 14 | logger.info('No migration to roll back'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/commons/components/KeyboardShortcut.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 4 | import { faKeyboard } from '@fortawesome/free-regular-svg-icons'; 5 | import styles from './KeyboardShortcut.module.scss'; 6 | 7 | export function KeyboardShortcut({ 8 | children, className, icon = true, 9 | }: { children; className?; icon?: boolean }) { 10 | return ( 11 |
12 | {icon && ( 13 | 14 | )} 15 | {children} 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /server/src/entities/orgs/guards/can-write-org-guard.ts: -------------------------------------------------------------------------------- 1 | import { getUser } from '../../../auth/utils/get-user'; 2 | import { guard } from '../../../commons/express/guard'; 3 | import { params } from '../../../commons/express-joi/params'; 4 | import { object } from 'joi'; 5 | import { $id } from '../../../utils/id'; 6 | import { isOwner } from '../../../auth/guards/is-owner'; 7 | 8 | export const canWriteOrgGuard = [ 9 | params(object({ 10 | orgId: $id, 11 | })), 12 | guard( 13 | req => { 14 | const user = getUser(req); 15 | const { orgId } = req.params; 16 | return isOwner(user._id, orgId); 17 | }, 18 | 'Not allowed to update org', 19 | ), 20 | ]; 21 | -------------------------------------------------------------------------------- /ui/src/commons/components/DocsLink.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import React from 'react'; 3 | import './DocsLink.scss'; 4 | import { faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'; 5 | import classNames from 'classnames'; 6 | 7 | export function DocsLink({ href, className }: { 8 | href: string; 9 | className?: string; 10 | }) { 11 | return ( 12 | 18 | docs 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/components/sites/SiteCard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import styles from './SiteCard.module.scss'; 4 | import { Site } from './site'; 5 | import { Bubble } from '../../commons/components/Bubble'; 6 | 7 | export function SiteCard({ site, className }: { 8 | site: Site; 9 | className?; 10 | }) { 11 | return ( 12 |
13 |
14 | 15 | {site.name} 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /ui/src/components/sites/releases/get-releases.ts: -------------------------------------------------------------------------------- 1 | import { axios } from '../../../providers/axios'; 2 | import { Page } from '../../../commons/types/page'; 3 | import { Release } from './release'; 4 | 5 | export interface ReleaseSearchQuery { 6 | search: string; 7 | page: number; 8 | size: number; 9 | branch?: string; 10 | } 11 | 12 | export function getReleases( 13 | siteId: string, 14 | query?: ReleaseSearchQuery, 15 | ): Promise> { 16 | return axios 17 | .get(`/api/v1/sites/${siteId}/releases`, { 18 | params: { 19 | ...query, 20 | search: query.search || undefined, 21 | }, 22 | }) 23 | .then(res => res.data); 24 | } 25 | -------------------------------------------------------------------------------- /ui/src/Hello.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import classNames from 'classnames'; 3 | import styles from './Hello.module.scss'; 4 | 5 | export function Hello({ label, hello }: { 6 | label: string; 7 | hello: () => void; 8 | }) { 9 | const [counter, setCounter] = useState(0); 10 | 11 | const onLick = () => { 12 | setCounter(counter + 1); 13 | hello(); 14 | }; 15 | 16 | return ( 17 | <> 18 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /server/src/auth/guards/is-admin-or-owner-guard.ts: -------------------------------------------------------------------------------- 1 | import { object } from 'joi'; 2 | import { isAdminOrOwner } from './is-admin-or-owner'; 3 | import { guard } from '../../commons/express/guard'; 4 | import { getUser } from '../utils/get-user'; 5 | import { params } from '../../commons/express-joi/params'; 6 | import { $id } from '../../utils/id'; 7 | 8 | export const isAdminOrOwnerGuard = [ 9 | params(object({ 10 | orgId: $id, 11 | })), 12 | guard(async req => { 13 | const user = getUser(req); 14 | const { orgId } = req.params; 15 | if (!user) { 16 | return false; 17 | } 18 | return isAdminOrOwner(user._id, orgId); 19 | }, 'Cannot admin team members'), 20 | ]; 21 | -------------------------------------------------------------------------------- /server/src/commons/axios/axios-error.ts: -------------------------------------------------------------------------------- 1 | export class AxiosError extends Error { 2 | constructor(message: string, public error?: any) { 3 | super(message); 4 | } 5 | 6 | toJSON(): any { 7 | if (!this.error) { 8 | return undefined; 9 | } 10 | return { 11 | errorObject: this.error?.toJSON(), 12 | response: { 13 | status: this.error.response?.status, 14 | statusText: this.error.response?.statusText, 15 | headers: this.error.response?.headers, 16 | data: this.error.response?.data, 17 | }, 18 | }; 19 | } 20 | 21 | toString(): string { 22 | return `${this.message} ${JSON.stringify(this.toJSON())}`; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /server/src/entities/releases/guards/can-admin-release-guard.ts: -------------------------------------------------------------------------------- 1 | import { object } from 'joi'; 2 | import { getUser } from '../../../auth/utils/get-user'; 3 | import { params } from '../../../commons/express-joi/params'; 4 | import { canAdminRelease } from './can-admin-release'; 5 | import { guard } from '../../../commons/express/guard'; 6 | import { $id } from '../../../utils/id'; 7 | 8 | export const canAdminReleaseGuard = [ 9 | params(object({ 10 | releaseId: $id, 11 | })), 12 | guard(req => { 13 | const user = getUser(req); 14 | const { releaseId } = req.params; 15 | return canAdminRelease(releaseId, user._id); 16 | }, 'You are not allowed to access this release'), 17 | ]; 18 | -------------------------------------------------------------------------------- /server/src/entities/sites/handlers/hooks/list-site-events.ts: -------------------------------------------------------------------------------- 1 | import { wrapAsyncMiddleware } from '../../../../commons/utils/wrap-async-middleware'; 2 | import { params } from '../../../../commons/express-joi/params'; 3 | import { Request, Response } from 'express'; 4 | import { object } from 'joi'; 5 | import { $id } from '../../../../utils/id'; 6 | import { siteEvents } from './site-hook'; 7 | 8 | const validators = [ 9 | params(object({ 10 | siteId: $id, 11 | })), 12 | ]; 13 | 14 | async function handler(req: Request, res: Response): Promise { 15 | res.json(siteEvents); 16 | } 17 | 18 | export const listSiteEvents = [ 19 | ...validators, 20 | wrapAsyncMiddleware(handler), 21 | ]; 22 | -------------------------------------------------------------------------------- /ui/src/commons/components/FromNow.tsx: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import React, { useState } from 'react'; 3 | import { uniqueId } from 'lodash'; 4 | import { Tooltip, tooltipToggle } from './Tooltip'; 5 | 6 | export function FromNow({ 7 | date, className, label = 'Created', 8 | }: { 9 | label?: string; 10 | date: Date; 11 | className?; 12 | }) { 13 | const [uid] = useState(uniqueId()); 14 | return ( 15 | <> 16 | 20 | {label} 21 | {' '} 22 | {moment(date).fromNow()} 23 | 24 | {date} 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /server/src/db/migrate/migrate.ts: -------------------------------------------------------------------------------- 1 | import { Db, MongoClient } from 'mongodb'; 2 | import { rollBackwards } from './roll-backwards'; 3 | import { rollForward } from './roll-forward'; 4 | import { Logger } from '../../commons/logger/logger'; 5 | import { env } from '../../env/env'; 6 | 7 | const logger = new Logger('meli.api:migrate.migrate'); 8 | 9 | // https://github.com/seppevs/migrate-mongo#api-usage 10 | export async function migrate(client: MongoClient, db: Db): Promise { 11 | if (env.MELI_MIGRATE_ROLLBACK) { 12 | await rollBackwards(db, client); 13 | logger.debug('Exiting after rollback'); 14 | process.exit(0); 15 | } else { 16 | await rollForward(db, client); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ui/src/styles/animations.scss: -------------------------------------------------------------------------------- 1 | .fade-down-appear { 2 | } 3 | 4 | .fade-down-enter { 5 | opacity: 0; 6 | transform: translateY(-25%); 7 | transition: opacity $transition-duration, transform $transition-duration; 8 | } 9 | 10 | .fade-down-enter.fade-down-enter-active { 11 | opacity: 1; 12 | transform: translateY(0%); 13 | } 14 | 15 | .fade-down-enter.fade-down-enter-done { 16 | } 17 | 18 | .fade-down-exit { 19 | opacity: 1; 20 | transform: translateY(0%); 21 | } 22 | 23 | .fade-down-exit.fade-down-exit-active { 24 | opacity: 0; 25 | transform: translateY(25%); 26 | transition: opacity $transition-duration, transform $transition-duration; 27 | } 28 | 29 | .fade-down-exit.fade-down-exit-done { 30 | } 31 | -------------------------------------------------------------------------------- /docker-compose-dev.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | 5 | mongo: 6 | image: mongo:4.2-bionic 7 | volumes: 8 | - ./data/mongo:/data/db 9 | ports: 10 | - 127.0.0.1:27017:27017 11 | 12 | mailhog: 13 | image: mailhog/mailhog 14 | ports: 15 | - 127.0.0.1:8025:8025 16 | - 127.0.0.1:1025:1025 17 | 18 | caddy: 19 | image: caddy 20 | command: [ 'caddy', 'run', '--config', '/etc/caddy/config.json', '--resume' ] 21 | ports: 22 | - 127.0.0.1:8080:80 23 | - 127.0.0.1:2019:2019 24 | volumes: 25 | - ./data/sites:/sites 26 | - ./caddy/config.json:/etc/caddy/config.json 27 | - ./data/caddy/data:/data 28 | - ./data/caddy/config:/config 29 | -------------------------------------------------------------------------------- /server/src/caddy/definitions/apps/pki.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Caddy { 2 | interface Pki { 3 | '@id'?: string; 4 | certificate_authorities?: { [id: string]: Pki.CertificateAuthority }; 5 | } 6 | 7 | namespace Pki { 8 | interface CertificateAuthority { 9 | '@id'?: string; 10 | name?: string; 11 | root_common_name?: string; 12 | intermediate_common_name?: string; 13 | install_trust?: boolean; 14 | root?: Certificate; 15 | intermediate?: Certificate; 16 | storage?: Storage; 17 | } 18 | 19 | interface Certificate { 20 | '@id'?: string; 21 | certificate?: string; 22 | private_key?: string; 23 | format?: string; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ui/src/commons/components/ButtonIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import styles from './ButtonIcon.module.scss'; 4 | import { Loader } from './Loader'; 5 | 6 | export function ButtonIcon({ 7 | children, className, onClick, loading, ...props 8 | }: { 9 | children: any; 10 | className?: string; 11 | onClick?: (ev) => void; 12 | loading?: boolean; 13 | [props: string]: any; 14 | }) { 15 | return ( 16 |
21 | {loading ? ( 22 | 23 | ) : ( 24 | <>{children} 25 | )} 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /ui/src/utils/suffix-big-number.ts: -------------------------------------------------------------------------------- 1 | import { round } from 'lodash'; 2 | 3 | export interface SuffixBigNumber { 4 | value: number; 5 | suffix: string; 6 | } 7 | 8 | export function suffixBigNumber(value: number, precision = 1): SuffixBigNumber { 9 | if (value >= 1000000000) { 10 | return { 11 | value: round(value / 1000000000, precision), suffix: 'B', 12 | }; 13 | } 14 | if (value >= 1000000) { 15 | return { 16 | value: round(value / 1000000, precision), suffix: 'M', 17 | }; 18 | } 19 | if (value >= 1000) { 20 | return { 21 | value: round(value / 1000, precision), suffix: 'k', 22 | }; 23 | } 24 | return { 25 | value: round(value, precision), suffix: '', 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /ui/src/App.module.scss: -------------------------------------------------------------------------------- 1 | $sidebar-width: 300px; 2 | .app { 3 | display: flex; 4 | position: relative; 5 | } 6 | 7 | $header-height: 70px; 8 | 9 | .header { 10 | position: absolute; 11 | top: 0; 12 | left: 0; 13 | width: $sidebar-width; 14 | height: $header-height; 15 | display: flex; 16 | align-items: center; 17 | } 18 | 19 | .sidebar { 20 | width: $sidebar-width; 21 | min-height: 100vh; 22 | padding-top: $header-height; 23 | } 24 | 25 | .main { 26 | min-height: 100vh; 27 | //padding-top: 30px; 28 | padding-bottom: 100px; 29 | flex-direction: column; 30 | display: flex; 31 | flex-grow: 1; 32 | } 33 | 34 | .banner { 35 | position: absolute; 36 | top: 0; 37 | left: 0; 38 | z-index: 10; 39 | } 40 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | 5 | meli: 6 | image: tmp 7 | ports: 8 | - 127.0.0.1:8000:80 9 | environment: 10 | # no trailing slash ! 11 | MELI_URL: http://localhost:8000 12 | MELI_MONGO_URI: mongodb://mongo:27017/meli 13 | MELI_JWT_SECRET: changeMe 14 | MELI_USER: user 15 | MELI_PASSWORD: changeMe 16 | MELI_HTTPS_AUTO: "false" 17 | volumes: 18 | - ./tmp/sites:/sites 19 | - ./tmp/files:/files 20 | - ./tmp/caddy/data:/data 21 | - ./tmp/caddy/config:/config 22 | depends_on: 23 | - mongo 24 | 25 | mongo: 26 | image: mongo:4.2-bionic 27 | restart: unless-stopped 28 | volumes: 29 | - ./tmp/mongo:/data/db 30 | -------------------------------------------------------------------------------- /server/src/socket/handle-socket-event.ts: -------------------------------------------------------------------------------- 1 | import { EventData } from '../events/event-data'; 2 | import { Logger } from '../commons/logger/logger'; 3 | import { messageBuilders } from './message-builders'; 4 | import { Io } from './create-io-server'; 5 | 6 | const logger = new Logger('meli.api:handleSocketEvent'); 7 | 8 | export function handleSocketEvent(eventType: T, data: EventData[T]): void { 9 | const messageBuilder = messageBuilders[eventType]; 10 | 11 | if (!messageBuilder) { 12 | logger.debug('no message builder for', eventType); 13 | return; 14 | } 15 | 16 | const { room, data: serializedData } = messageBuilder(data); 17 | 18 | Io.server.to(room).emit(eventType, serializedData); 19 | } 20 | -------------------------------------------------------------------------------- /ui/src/commons/components/Tooltip.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/variables'; 2 | 3 | .tooltip { 4 | background: #1b0449; 5 | opacity: 0.85; 6 | box-shadow: $_boxShadow; 7 | border-radius: 5px; 8 | color: #b0abba; 9 | padding: 10px 16px !important; 10 | 11 | text-transform: none !important; 12 | text-align: left; 13 | line-height: $line-height-base; 14 | 15 | &.place-top { 16 | &:after { 17 | display: none; 18 | } 19 | } 20 | 21 | &.place-left { 22 | &:after { 23 | display: none; 24 | } 25 | } 26 | 27 | &.place-bottom { 28 | &:after { 29 | display: none; 30 | } 31 | } 32 | 33 | &.place-right { 34 | &:after { 35 | display: none; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ui/src/commons/components/PrivateRoute.tsx: -------------------------------------------------------------------------------- 1 | import { Redirect, Route } from 'react-router-dom'; 2 | import React from 'react'; 3 | 4 | export function PrivateRoute({ 5 | component: Component, authed, redirectTo, ...rest 6 | }: { 7 | component: any; 8 | authed: any; 9 | redirectTo: string; 10 | [prop: string]: any; 11 | }) { 12 | return ( 13 | ( 16 | authed ? ( 17 | 18 | ) : ( 19 | 26 | ) 27 | )} 28 | /> 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /ui/src/components/auth/methods/SignInButton.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React from 'react'; 3 | import styles from './SignInButton.module.scss'; 4 | 5 | export function SignInButton({ 6 | onClick, label, icon, className, 7 | }: { 8 | onClick?: any; 9 | label: any; 10 | icon: any; 11 | className?: any; 12 | }) { 13 | return ( 14 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /ui/src/components/sidebar/Teams.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables"; 2 | 3 | .linkHeader { 4 | color: $dark; 5 | font-size: .8rem; 6 | font-weight: bold; 7 | margin-bottom: 10px; 8 | display: flex; 9 | justify-content: space-between; 10 | align-items: center; 11 | position: relative; 12 | } 13 | 14 | .siteIcon { 15 | width: 20px; 16 | height: 20px; 17 | border-radius: 50%; 18 | display: block; 19 | } 20 | 21 | .active { 22 | &:before { 23 | $size: 8px; 24 | content: ''; 25 | width: $size; 26 | height: $size; 27 | border-radius: 50%; 28 | background: $success; 29 | position: absolute; 30 | top: 50%; 31 | left: -$size - 3px; 32 | transform: translateY(-50%); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ui/src/components/Footer.module.scss: -------------------------------------------------------------------------------- 1 | @import '../styles/variables'; 2 | 3 | .footer { 4 | position: absolute; 5 | bottom: 0; 6 | left: 0; 7 | width: 100%; 8 | display: flex; 9 | justify-content: space-between; 10 | padding: 0.25rem 1rem; 11 | margin: 0; 12 | z-index: 10; 13 | } 14 | 15 | .link { 16 | font-style: normal; 17 | font-weight: 500; 18 | font-size: 14px; 19 | line-height: 130%; 20 | //color: $_rhythm; 21 | margin-right: 1rem; 22 | 23 | &:last-child { 24 | margin-right: 0; 25 | } 26 | } 27 | 28 | .separator { 29 | $size: 5px; 30 | width: $size; 31 | height: $size; 32 | border-radius: 50%; 33 | background: $_blueBell; 34 | display: inline-block; 35 | margin: 0 0.5rem; 36 | opacity: 0.5; 37 | } 38 | -------------------------------------------------------------------------------- /ui/src/components/auth/methods/SignInWithGitea.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './SignInWithGitea.module.scss'; 3 | import giteaLogo from '../../../assets/images/git-servers/gitea.svg'; 4 | import { SignInButton } from './SignInButton'; 5 | 6 | export function SignInWithGitea({ className }: { 7 | className?: any; 8 | }) { 9 | return ( 10 | 14 | 21 | )} 22 | label="Gitea" 23 | className={styles.gitea} 24 | /> 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /ui/src/commons/components/ErrorIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; 4 | import { uniqueId } from 'lodash'; 5 | import { Tooltip, tooltipToggle } from './Tooltip'; 6 | 7 | export function ErrorIcon({ error, className }: { 8 | error: any; 9 | className?: string; 10 | }) { 11 | const [id] = useState(uniqueId()); 12 | return ( 13 | <> 14 | 19 | 20 | {error.toString()} 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /ui/src/commons/keyboard/use-shortcut.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export function onShortcutKey(letter: string, callback: () => any): (event: KeyboardEvent) => void { 4 | const listener = (ev: KeyboardEvent) => { 5 | if ((ev.ctrlKey || ev.metaKey) && ev.key.toUpperCase() === letter) { 6 | ev.preventDefault(); 7 | callback(); 8 | } 9 | }; 10 | document.addEventListener('keydown', listener); 11 | 12 | return listener; 13 | } 14 | 15 | export function useShortcut(letter: string, callback: () => any) { 16 | useEffect(() => { 17 | const listener = onShortcutKey(letter, callback); 18 | return () => { 19 | document.removeEventListener('keydown', listener); 20 | }; 21 | }, [letter, callback]); 22 | } 23 | -------------------------------------------------------------------------------- /server/src/db/build-mongo-uri.ts: -------------------------------------------------------------------------------- 1 | import { env } from '../env/env'; 2 | import { InvalidEnvironmentError } from '../commons/errors/invalid-environment-error'; 3 | 4 | // https://docs.mongodb.com/manual/reference/connection-string/ 5 | // mongodb://[username:password@]host1[:port1][,...hostN[:portN]][/[defaultauthdb][?options]] 6 | export function buildMongoUri(): string { 7 | if (!env.MELI_MONGO_HOST || !env.MELI_MONGO_PORT || !env.MELI_MONGO_DB) { 8 | throw new InvalidEnvironmentError(); 9 | } 10 | 11 | return `mongodb://${ 12 | !env.MELI_MONGO_USER ? '' : ( 13 | `${env.MELI_MONGO_USER}${env.MELI_MONGO_PASSWORD ? `:${env.MELI_MONGO_PASSWORD}` : ''}@` 14 | ) 15 | }${env.MELI_MONGO_HOST}:${env.MELI_MONGO_PORT}/${env.MELI_MONGO_DB}`; 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/components/auth/methods/SignInWithGithub.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './SignInWithGitHub.module.scss'; 3 | import githubLogo from '../../../assets/images/git-servers/github.svg'; 4 | import { SignInButton } from './SignInButton'; 5 | 6 | export function SignInWithGithub({ className }: { 7 | className?: any; 8 | }) { 9 | return ( 10 | 14 | 21 | )} 22 | label="Github" 23 | className={styles.github} 24 | /> 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /ui/src/components/auth/methods/SignInWithGitlab.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './SignInWithGitlab.module.scss'; 3 | import gitlabLogo from '../../../assets/images/git-servers/gitlab.svg'; 4 | import { SignInButton } from './SignInButton'; 5 | 6 | export function SignInWithGitlab({ className }: { 7 | className?: any; 8 | }) { 9 | return ( 10 | 14 | 21 | )} 22 | label="Gitlab" 23 | className={styles.gitlab} 24 | /> 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /server/src/auth/guards/auth-guard.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import { getUser } from '../utils/get-user'; 3 | import { UnauthorizedError } from '../../commons/errors/unauthorized-error'; 4 | 5 | export function authGuard(req: Request, res: Response, next: NextFunction) { 6 | /* 7 | * TODO should support multiple auth methods sources: 8 | * currently, authorizeReq + authorizeApiReq set a user => authGuard checks for the user 9 | * apiGuard => checks for scopes 10 | * we should have a authenticateSiteRequest => and a guard which checks 11 | */ 12 | const user = getUser(req); 13 | if (user) { 14 | next(undefined); 15 | } else { 16 | next(new UnauthorizedError('You are not authenticated')); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /server/src/auth/utils/get-user-from-socket.spec.ts: -------------------------------------------------------------------------------- 1 | import { getUserFromSocket } from './get-user-from-socket'; 2 | import * as _verifyToken from './verify-token'; 3 | 4 | describe('getUserFromSocket', () => { 5 | 6 | afterEach(() => jest.restoreAllMocks()); 7 | 8 | it('should get user from socket', async () => { 9 | const fakeUser: any = { _id: 'userId' }; 10 | const verifyToken = jest.spyOn(_verifyToken, 'verifyToken').mockReturnValue(fakeUser); 11 | 12 | const user = await getUserFromSocket({ 13 | handshake: { 14 | headers: { 15 | cookie: 'auth=token', 16 | }, 17 | }, 18 | }); 19 | 20 | expect(user).toEqual(fakeUser); 21 | expect(verifyToken).toHaveBeenCalledWith('token'); 22 | }); 23 | 24 | }); 25 | -------------------------------------------------------------------------------- /ui/src/commons/components/Toasts.scss: -------------------------------------------------------------------------------- 1 | @import '~react-toastify/dist/ReactToastify.css'; 2 | @import '../../styles/variables'; 3 | 4 | .Toastify__toast { 5 | box-shadow: $_boxShadow !important; 6 | border-radius: $border-radius; 7 | padding: 15px 30px; 8 | } 9 | 10 | .Toastify__toast--dark { 11 | background: $_blackGradient; 12 | color: $light; 13 | } 14 | 15 | .Toastify__toast--default { 16 | background: $_silverGradient; 17 | color: $dark; 18 | } 19 | 20 | .Toastify__toast--info { 21 | background: $_blueGradient; 22 | } 23 | 24 | .Toastify__toast--success { 25 | background: $_greenGradient; 26 | } 27 | 28 | .Toastify__toast--warning { 29 | background: $_orangeGradient; 30 | } 31 | 32 | .Toastify__toast--error { 33 | background: $_redGradient; 34 | } 35 | -------------------------------------------------------------------------------- /server/tests/build-mock.ts: -------------------------------------------------------------------------------- 1 | import { Constructor } from '../src/commons/types/constructor'; 2 | 3 | export function buildMock(constructor: Constructor, stub?: Partial): T { 4 | const mock = {} as Partial; 5 | 6 | for (let prototype = constructor.prototype; prototype; prototype = Object.getPrototypeOf(prototype)) { 7 | const descriptors = Object.getOwnPropertyDescriptors(prototype); 8 | for (const key in descriptors) { 9 | const descriptor = descriptors[key]; 10 | if (typeof descriptor.value === 'function') { 11 | mock[key] = stub?.[key] ?? jest.fn(); 12 | } 13 | } 14 | } 15 | 16 | // add extra props 17 | for (const key of Object.keys(stub || {})) { 18 | mock[key] = stub[key]; 19 | } 20 | 21 | return mock as T; 22 | } 23 | -------------------------------------------------------------------------------- /.releaserc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: [ 3 | { name: 'latest' }, 4 | { 5 | name: 'beta', 6 | prerelease: true, 7 | }, 8 | { 9 | name: 'next', 10 | prerelease: true, 11 | }, 12 | ], 13 | plugins: [ 14 | '@semantic-release/commit-analyzer', 15 | '@semantic-release/release-notes-generator', 16 | '@semantic-release/changelog', 17 | 'semantic-release-license', 18 | '@semantic-release/github', 19 | [ 20 | '@semantic-release/git', 21 | { assets: ['CHANGELOG.md', 'package.json', 'LICENSE'] }, 22 | ], 23 | [ 24 | '@semantic-release/exec', 25 | { generateNotesCmd: 'echo -n "${nextRelease.version}" > VERSION && echo -n "${nextRelease.channel}" > RELEASE_CHANNEL' }, 26 | ], 27 | ], 28 | }; 29 | -------------------------------------------------------------------------------- /ui/src/commons/components/LoadMore.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import styles from './LoadMore.module.scss'; 4 | import { Loader } from './Loader'; 5 | 6 | export function LoadMore({ 7 | onClick, className, disabled, loading, 8 | }: { 9 | onClick: (e: any) => void; 10 | disabled?: boolean; 11 | loading?: boolean; 12 | className?; 13 | }) { 14 | return ( 15 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /server/src/auth/passport/providers/gitlab/types/gitlab-group.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | export interface GitlabGroup { 4 | id: number; 5 | web_url: string; 6 | name: string; 7 | path: string; 8 | description: string; 9 | visibility: string; 10 | share_with_group_lock: boolean; 11 | require_two_factor_authentication: boolean; 12 | two_factor_grace_period: number; 13 | project_creation_level: string; 14 | auto_devops_enabled?: any; 15 | subgroup_creation_level: string; 16 | emails_disabled?: any; 17 | mentions_disabled?: any; 18 | lfs_enabled: boolean; 19 | default_branch_protection: number; 20 | avatar_url?: any; 21 | request_access_enabled: boolean; 22 | full_name: string; 23 | full_path: string; 24 | created_at: Date; 25 | parent_id?: any; 26 | } 27 | -------------------------------------------------------------------------------- /server/src/entities/api/handlers/tokens/list-api-tokens.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { wrapAsyncMiddleware } from '../../../../commons/utils/wrap-async-middleware'; 3 | import { serializeApiToken } from '../../serialize-api-token'; 4 | import { getUser } from '../../../../auth/utils/get-user'; 5 | import { ApiTokens } from '../../api-token'; 6 | 7 | const validators = []; 8 | 9 | async function handler(req: Request, res: Response): Promise { 10 | const { _id } = getUser(req); 11 | 12 | const apiTokens = await ApiTokens() 13 | .find({ 14 | userId: _id, 15 | }) 16 | .toArray(); 17 | 18 | res.json(apiTokens.map(serializeApiToken)); 19 | } 20 | 21 | export const listApiTokens = [ 22 | ...validators, 23 | wrapAsyncMiddleware(handler), 24 | ]; 25 | -------------------------------------------------------------------------------- /server/src/storage/store-file.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { env } from '../env/env'; 3 | import { promises } from 'fs'; 4 | import { uuid } from '../utils/uuid'; 5 | import { getFilePath } from './get-file-path'; 6 | 7 | export interface StoredFile { 8 | id: string; 9 | type: string; 10 | name: string; 11 | size: number; 12 | } 13 | 14 | export async function storeFile(file: Express.Multer.File): Promise { 15 | const fileId = uuid(); 16 | const filePath = getFilePath(fileId); 17 | await promises.mkdir(env.MELI_STORAGE_DIR, { recursive: true }); 18 | await promises.copyFile(path.join(file.destination, file.filename), filePath); 19 | return { 20 | id: fileId, 21 | type: file.mimetype, 22 | name: file.originalname, 23 | size: file.size, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /ui/src/components/sites/branches/settings/BranchSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import { BranchPassword } from '../BranchPassword'; 4 | import { useBranch } from '../BranchView'; 5 | import { BranchGeneralSettings } from './BranchGeneralSettings'; 6 | 7 | export function BranchSettings() { 8 | const { siteId } = useParams(); 9 | const { branch, setBranch } = useBranch(); 10 | return ( 11 | <> 12 |
13 |
14 | 19 |
20 |
21 | 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /server/src/caddy/config/api-route.ts: -------------------------------------------------------------------------------- 1 | import { env } from '../../env/env'; 2 | import { getReverseProxyDial } from '../utils/get-reverse-proxy-dial'; 3 | import { URL } from 'url'; 4 | import Route = Caddy.Http.Route; 5 | 6 | const melihost = new URL(env.MELI_URL); 7 | 8 | export const apiRoute: Route = { 9 | group: 'api', 10 | match: [{ 11 | host: [melihost.hostname], 12 | path: [ 13 | '/api/*', 14 | '/auth/*', 15 | '/system/*', 16 | '/socket.io/*', 17 | ], 18 | }], 19 | handle: [ 20 | // https://caddyserver.com/docs/json/apps/http/servers/routes/handle/reverse_proxy/ 21 | { 22 | handler: 'reverse_proxy', 23 | upstreams: [{ 24 | dial: getReverseProxyDial(env.MELI_URL_INTERNAL), 25 | }], 26 | }, 27 | ], 28 | terminal: true, 29 | }; 30 | -------------------------------------------------------------------------------- /ui/src/components/orgs/settings/OrgLogo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useOrg } from '../OrgView'; 3 | import { Org } from '../org'; 4 | import { useCurrentOrg } from '../../../providers/OrgProvider'; 5 | import { Logo } from '../../commons/Logo'; 6 | 7 | export function OrgLogo() { 8 | const { org, setOrg } = useOrg(); 9 | const { currentOrg, setCurrentOrg } = useCurrentOrg(); 10 | 11 | const updateOrg = (value: Org) => { 12 | setOrg(value); 13 | if (currentOrg && currentOrg.org?._id === org._id) { 14 | setCurrentOrg({ 15 | ...currentOrg, 16 | org: value, 17 | }); 18 | } 19 | }; 20 | 21 | return ( 22 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /ui/src/components/auth/methods/SignInWithGoogle.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import { faGoogle } from '@fortawesome/free-brands-svg-icons'; 3 | import React from 'react'; 4 | import styles from './SignInWithGoogle.module.scss'; 5 | import { SignInButton } from './SignInButton'; 6 | 7 | export function SignInWithGoogle({ className }: { 8 | className?: any; 9 | }) { 10 | return ( 11 | 15 | 18 | 19 |
20 | )} 21 | label="Google" 22 | className={styles.google} 23 | /> 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /ui/src/components/sidebar/Sites.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables"; 2 | 3 | .linkHeader { 4 | text-transform: uppercase; 5 | color: $dark; 6 | display: block; 7 | font-size: .8rem; 8 | font-weight: bold; 9 | margin-bottom: 10px; 10 | } 11 | 12 | .site { 13 | color: inherit; 14 | height: 30px; 15 | display: flex; 16 | align-items: center; 17 | border-radius: $border-radius; 18 | padding: 0 .5rem; 19 | font-weight: 600; 20 | 21 | transition: all $transition-duration $transition-effect; 22 | 23 | &:hover { 24 | background: $gray-200; 25 | text-decoration: none; 26 | color: inherit; 27 | } 28 | } 29 | 30 | .siteIcon { 31 | width: 20px; 32 | height: 20px; 33 | border-radius: 50%; 34 | display: block; 35 | } 36 | 37 | .active { 38 | background: $gray-200; 39 | } 40 | -------------------------------------------------------------------------------- /ui/src/websockets/SocketProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useEffect, useRef, useState } from 'react'; 2 | import openSocket from 'socket.io-client'; 3 | 4 | export const SocketContext = createContext(undefined); 5 | 6 | export const useSocket = () => useContext(SocketContext); 7 | 8 | export function SocketProvider(props) { 9 | 10 | const socketRef = useRef(); 11 | const [socket, setSocket] = useState(); 12 | 13 | useEffect(() => { 14 | if (socketRef.current) { 15 | socketRef.current.close(); 16 | } 17 | const sock: SocketIOClient.Socket = openSocket('/'); 18 | socketRef.current = sock; 19 | setSocket(sock); 20 | }, []); 21 | 22 | return ; 23 | } 24 | -------------------------------------------------------------------------------- /server/src/commons/axios/ensure-stack-trace.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from 'axios'; 2 | import { AxiosError } from './axios-error'; 3 | 4 | // https://github.com/axios/axios/issues/2387#issuecomment-652242713 5 | export function ensureStackTrace(instance: AxiosInstance): AxiosInstance { 6 | instance.interceptors.request.use(config => { 7 | (config as any).errorContext = new Error('Thrown at:'); 8 | return config; 9 | }); 10 | instance.interceptors.response.use(undefined, async error => { 11 | const err = error.isAxiosError ? new AxiosError(error.message, error) : error; 12 | const originalStackTrace = error.config?.errorContext?.stack; 13 | if (originalStackTrace) { 14 | error.stack = `${error.stack}\n${originalStackTrace}`; 15 | } 16 | throw err; 17 | }); 18 | return instance; 19 | } 20 | -------------------------------------------------------------------------------- /server/src/entities/members/routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { updateMember } from './handlers/update-member'; 3 | import { deleteMember } from './handlers/delete-member'; 4 | import { apiEndpoint } from '../api/api-endpoint'; 5 | import { ApiScope } from '../api/api-scope'; 6 | 7 | const router = Router(); 8 | 9 | // members 10 | apiEndpoint({ 11 | name: 'update member', 12 | method: 'put', 13 | path: '/api/v1/members/:memberId', 14 | handler: updateMember, 15 | auth: true, 16 | apiScope: ApiScope.member_update, 17 | router, 18 | }); 19 | apiEndpoint({ 20 | name: 'delete member', 21 | method: 'delete', 22 | path: '/api/v1/members/:memberId', 23 | handler: deleteMember, 24 | auth: true, 25 | apiScope: ApiScope.member_delete, 26 | router, 27 | }); 28 | 29 | export default router; 30 | -------------------------------------------------------------------------------- /server/src/entities/users/routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { getUserHandler } from './handlers/get-user-handler'; 3 | import { apiEndpoint } from '../api/api-endpoint'; 4 | import { ApiScope } from '../api/api-scope'; 5 | import { invalidateTokens } from './handlers/invalidate-tokens'; 6 | 7 | const router = Router(); 8 | 9 | apiEndpoint({ 10 | name: 'get user route', 11 | method: 'get', 12 | path: '/api/v1/user', 13 | handler: getUserHandler, 14 | auth: false, 15 | apiScope: ApiScope.user_read, 16 | router, 17 | }); 18 | apiEndpoint({ 19 | name: 'disconnect user from all devices', 20 | method: 'put', 21 | path: '/api/v1/user/disconnect', 22 | handler: invalidateTokens, 23 | auth: true, 24 | apiScope: ApiScope.user_disconnect, 25 | router, 26 | }); 27 | 28 | export default router; 29 | -------------------------------------------------------------------------------- /server/src/entities/sites/serialize-site.ts: -------------------------------------------------------------------------------- 1 | import { Site } from './site'; 2 | import { getSiteUrl } from './get-site-url'; 3 | import { getLogoUrl } from '../../utils/get-logo-url'; 4 | import { serializeBranch } from './serialize-branch'; 5 | 6 | export function serializeSite(site: Site): any { 7 | return { 8 | _id: site._id, 9 | teamId: site.teamId, 10 | color: site.color, 11 | logo: getLogoUrl('sites', site), 12 | createdAt: site.createdAt, 13 | updatedAt: site.updatedAt, 14 | name: site.name, 15 | mainBranch: site.mainBranch, 16 | domains: site.domains || [], 17 | branches: site.branches?.map(branch => serializeBranch(site, branch)) || [], 18 | url: getSiteUrl(site), 19 | spa: site.spa, 20 | hasPassword: !!site.password, 21 | headers: site.headers || [], 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /server/src/entities/members/guards/can-admin-member-guard.ts: -------------------------------------------------------------------------------- 1 | import { params } from '../../../commons/express-joi/params'; 2 | import { object } from 'joi'; 3 | import { $id } from '../../../utils/id'; 4 | import { Members } from '../member'; 5 | import { getUser } from '../../../auth/utils/get-user'; 6 | import { guard } from '../../../commons/express/guard'; 7 | import { isAdminOrOwner } from '../../../auth/guards/is-admin-or-owner'; 8 | 9 | export const canAdminMemberGuard = [ 10 | params(object({ 11 | memberId: $id, 12 | })), 13 | guard( 14 | async req => { 15 | const { memberId } = req.params; 16 | const user = getUser(req); 17 | const { orgId } = await Members().findOne({ _id: memberId }); 18 | return isAdminOrOwner(user._id, orgId); 19 | }, 20 | 'Cannot admin member', 21 | ), 22 | ]; 23 | -------------------------------------------------------------------------------- /server/src/entities/sites/hash-password.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes, scrypt } from 'crypto'; 2 | import { Password } from './password'; 3 | 4 | export const scryptOptions = { 5 | saltLength: 16, 6 | keyLength: 64, 7 | N: 16384, 8 | r: 8, 9 | p: 1, 10 | }; 11 | 12 | export async function hashPassword(plain: string): Promise { 13 | return new Promise((resolve, reject) => { 14 | const salt = randomBytes(scryptOptions.saltLength).toString('hex'); 15 | 16 | scrypt(plain, salt, scryptOptions.keyLength, { 17 | N: scryptOptions.N, 18 | r: scryptOptions.r, 19 | p: scryptOptions.p, 20 | }, (err, derivedKey) => { 21 | if (err) { 22 | reject(err); 23 | } 24 | resolve({ 25 | hash: derivedKey.toString('hex'), 26 | salt, 27 | }); 28 | }); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /server/src/entities/teams/guards/can-admin-team-guard.ts: -------------------------------------------------------------------------------- 1 | import { params } from '../../../commons/express-joi/params'; 2 | import { object } from 'joi'; 3 | import { $id } from '../../../utils/id'; 4 | import { guard } from '../../../commons/express/guard'; 5 | import { Teams } from '../team'; 6 | import { isAdminOrOwner } from '../../../auth/guards/is-admin-or-owner'; 7 | import { getUser } from '../../../auth/utils/get-user'; 8 | 9 | export const canAdminTeamGuard = [ 10 | params(object({ 11 | teamId: $id, 12 | })), 13 | guard( 14 | async req => { 15 | const { teamId } = req.params; 16 | const { orgId } = await Teams().findOne({ 17 | _id: (teamId), 18 | }); 19 | const user = getUser(req); 20 | return isAdminOrOwner(user._id, orgId); 21 | }, 22 | 'Cannot admin ord', 23 | ), 24 | ]; 25 | -------------------------------------------------------------------------------- /server/src/entities/sites/guards/branch-exists-guard.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import { object, string } from 'joi'; 3 | import { NotFoundError } from '../../../commons/errors/not-found-error'; 4 | import { params } from '../../../commons/express-joi/params'; 5 | import { Sites } from '../site'; 6 | 7 | export const branchExistsGuard = [ 8 | params(object({ 9 | branchId: string().required(), 10 | })), 11 | (req: Request, res: Response, next: NextFunction) => { 12 | const { branchId } = req.params; 13 | Sites() 14 | .countDocuments({ 15 | 'branches._id': branchId, 16 | }, { 17 | limit: 1, 18 | }) 19 | .then(count => { 20 | next(count !== 0 ? undefined : new NotFoundError('Branch not found')); 21 | }) 22 | .catch(next); 23 | }, 24 | ]; 25 | -------------------------------------------------------------------------------- /server/src/entities/forms/form.ts: -------------------------------------------------------------------------------- 1 | import { alternatives, object, string } from 'joi'; 2 | import { STRING_MAX_LENGTH } from '../../constants'; 3 | 4 | export interface FormBase { 5 | name: string; 6 | } 7 | 8 | export interface EmailForm extends FormBase { 9 | type: 'email'; 10 | recipient: string; 11 | } 12 | 13 | export type Form = 14 | | EmailForm; 15 | 16 | const formBase = { 17 | name: string().required().pattern(/[a-zA-Z_]+/).max(STRING_MAX_LENGTH), 18 | }; 19 | 20 | const $emailForm = { 21 | type: string().required().valid('email'), 22 | recipient: string().optional().email().max(STRING_MAX_LENGTH), 23 | }; 24 | 25 | export const $formMapEntry = alternatives( 26 | object($emailForm), 27 | ); 28 | 29 | export const $formArrayItem = alternatives( 30 | object({ 31 | ...formBase, 32 | ...$emailForm, 33 | }), 34 | ); 35 | -------------------------------------------------------------------------------- /server/src/entities/orgs/guards/org-exists-guard.ts: -------------------------------------------------------------------------------- 1 | import { params } from '../../../commons/express-joi/params'; 2 | import { object } from 'joi'; 3 | import { 4 | NextFunction, Request, Response, 5 | } from 'express'; 6 | import { NotFoundError } from '../../../commons/errors/not-found-error'; 7 | import { $id } from '../../../utils/id'; 8 | import { Orgs } from '../org'; 9 | 10 | export const orgExistsGuard = [ 11 | params(object({ 12 | orgId: $id, 13 | })), 14 | (req: Request, res: Response, next: NextFunction) => { 15 | const { orgId } = req.params; 16 | Orgs() 17 | .countDocuments({ 18 | _id: orgId, 19 | }, { 20 | limit: 1, 21 | }) 22 | .then(count => { 23 | next(count !== 0 ? undefined : new NotFoundError('Org not found')); 24 | }) 25 | .catch(next); 26 | }, 27 | ]; 28 | -------------------------------------------------------------------------------- /server/src/entities/sites/handlers/hooks/site-hook.ts: -------------------------------------------------------------------------------- 1 | import { EventType } from '../../../../events/event-type'; 2 | import { $hook, $hookEvent, $hookEvents } from '../../../../hooks/hook'; 3 | 4 | export const siteEvents = [ 5 | EventType.site_updated, 6 | EventType.site_deleted, 7 | EventType.site_token_added, 8 | EventType.site_token_deleted, 9 | EventType.site_release_created, 10 | EventType.site_release_renamed, 11 | EventType.site_release_deleted, 12 | EventType.site_branch_added, 13 | EventType.site_branch_updated, 14 | EventType.site_branch_deleted, 15 | EventType.site_branch_release_set, 16 | EventType.site_branch_password_set, 17 | EventType.site_branch_password_removed, 18 | ]; 19 | 20 | export const $siteHhook = $hook.keys({ 21 | events: $hookEvents.items( 22 | $hookEvent.valid(...siteEvents), 23 | ), 24 | }); 25 | -------------------------------------------------------------------------------- /server/src/entities/sites/guards/site-exists-guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NextFunction, Request, Response, 3 | } from 'express'; 4 | import { object } from 'joi'; 5 | import { NotFoundError } from '../../../commons/errors/not-found-error'; 6 | import { params } from '../../../commons/express-joi/params'; 7 | import { Sites } from '../site'; 8 | import { $id } from '../../../utils/id'; 9 | 10 | export const siteExistsGuard = [ 11 | params(object({ 12 | siteId: $id, 13 | })), 14 | (req: Request, res: Response, next: NextFunction) => { 15 | const { siteId } = req.params; 16 | Sites() 17 | .countDocuments({ 18 | _id: siteId, 19 | }, { 20 | limit: 1, 21 | }) 22 | .then(count => { 23 | next(count !== 0 ? undefined : new NotFoundError('Site not found')); 24 | }) 25 | .catch(next); 26 | }, 27 | ]; 28 | -------------------------------------------------------------------------------- /server/src/entities/teams/guards/team-exists-guard.ts: -------------------------------------------------------------------------------- 1 | import { params } from '../../../commons/express-joi/params'; 2 | import { object } from 'joi'; 3 | import { 4 | NextFunction, Request, Response, 5 | } from 'express'; 6 | import { NotFoundError } from '../../../commons/errors/not-found-error'; 7 | import { $id } from '../../../utils/id'; 8 | import { Teams } from '../team'; 9 | 10 | export const teamExistsGuard = [ 11 | params(object({ 12 | teamId: $id, 13 | })), 14 | (req: Request, res: Response, next: NextFunction) => { 15 | const { teamId } = req.params; 16 | Teams() 17 | .countDocuments({ 18 | _id: teamId, 19 | }, { 20 | limit: 1, 21 | }) 22 | .then(count => { 23 | next(count !== 0 ? undefined : new NotFoundError('Team not found')); 24 | }) 25 | .catch(next); 26 | }, 27 | ]; 28 | -------------------------------------------------------------------------------- /server/src/entities/users/guards/user-exists-guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NextFunction, Request, Response, 3 | } from 'express'; 4 | import { object } from 'joi'; 5 | import { NotFoundError } from '../../../commons/errors/not-found-error'; 6 | import { params } from '../../../commons/express-joi/params'; 7 | import { Users } from '../user'; 8 | import { $id } from '../../../utils/id'; 9 | 10 | export const userExistsGuard = [ 11 | params(object({ 12 | userId: $id, 13 | })), 14 | (req: Request, res: Response, next: NextFunction) => { 15 | const { userId } = req.params; 16 | Users() 17 | .countDocuments({ 18 | _id: userId, 19 | }, { 20 | limit: 1, 21 | }) 22 | .then(count => { 23 | next(count !== 0 ? undefined : new NotFoundError('User not found')); 24 | }) 25 | .catch(next); 26 | }, 27 | ]; 28 | -------------------------------------------------------------------------------- /server/src/hooks/handlers/slack/handle-slack-hook.ts: -------------------------------------------------------------------------------- 1 | import { sendSlackMessage } from './send-slack-message'; 2 | import { Hook } from '../../hook'; 3 | import { EventType } from '../../../events/event-type'; 4 | import { HookDeliveryResult } from '../get-hook-handler'; 5 | import { getMessageForEvent } from './get-message-for-event'; 6 | import { getSlackMessage } from './get-slack-message'; 7 | 8 | export function handleSlackHook(hook: Hook, eventType: EventType, data: any): Promise { 9 | const getMessageFn = getMessageForEvent[eventType]; 10 | const message = getMessageFn ? getMessageFn(data) : getSlackMessage( 11 | `Meli event: ${eventType}`, 12 | 'No custom handler set for this event, sending empty data to prevent leaking sensistive information', 13 | ); 14 | return sendSlackMessage(hook.config, eventType, message); 15 | } 16 | -------------------------------------------------------------------------------- /server/src/entities/forms/submit-email-form.ts: -------------------------------------------------------------------------------- 1 | import { EmailForm } from './form'; 2 | import { AppError } from '../../commons/errors/app-error'; 3 | import { sendEmail } from '../../emails/send-email'; 4 | 5 | export function submitEmailForm(form: EmailForm, formData: any, files: Express.Multer.File[]): Promise { 6 | if (!form.recipient) { 7 | throw new AppError('Recipient not defined'); 8 | } 9 | return sendEmail( 10 | [form.recipient], 11 | `Form submission - ${form.name}`, 12 | 'form-submission', 13 | { 14 | formName: form.name, 15 | formData: JSON.stringify(formData, null, 2), 16 | }, 17 | files.map(upload => ({ 18 | filename: `${upload.fieldname}_${upload.originalname}`, 19 | path: upload.path, 20 | encoding: upload.encoding, 21 | contentType: upload.mimetype, 22 | })), 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /ui/src/commons/components/dropdown/DropDown.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/variables'; 2 | 3 | .dropdown { 4 | opacity: 1 !important; 5 | padding: 0 !important; 6 | border: none !important; 7 | 8 | background: #ffffff !important; 9 | box-shadow: $_boxShadow !important; 10 | border-radius: 5px; 11 | color: $dark !important; 12 | } 13 | 14 | :global { 15 | .__react_component_tooltip { 16 | &.place-top { 17 | &:after { 18 | display: none !important; 19 | } 20 | } 21 | 22 | &.place-left { 23 | &:after { 24 | display: none !important; 25 | } 26 | } 27 | 28 | &.place-bottom { 29 | &:after { 30 | display: none !important; 31 | } 32 | } 33 | 34 | &.place-right { 35 | &:after { 36 | display: none !important; 37 | } 38 | } 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /server/src/entities/members/guards/member-exists-guard.ts: -------------------------------------------------------------------------------- 1 | import { params } from '../../../commons/express-joi/params'; 2 | import { object } from 'joi'; 3 | import { NextFunction, Request, Response } from 'express'; 4 | import { NotFoundError } from '../../../commons/errors/not-found-error'; 5 | import { $id } from '../../../utils/id'; 6 | import { Members } from '../member'; 7 | 8 | export const memberExistsGuard = [ 9 | params(object({ 10 | memberId: $id, 11 | })), 12 | (req: Request, res: Response, next: NextFunction) => { 13 | const { memberId } = req.params; 14 | Members() 15 | .countDocuments({ 16 | _id: memberId, 17 | }, { 18 | limit: 1, 19 | }) 20 | .then(count => { 21 | next(count !== 0 ? undefined : new NotFoundError('Org member not found')); 22 | }) 23 | .catch(next); 24 | }, 25 | ]; 26 | -------------------------------------------------------------------------------- /server/src/entities/teams/guards/can-read-team.ts: -------------------------------------------------------------------------------- 1 | import { Teams } from '../team'; 2 | import { AppError } from '../../../commons/errors/app-error'; 3 | import { isAdminOrOwner } from '../../../auth/guards/is-admin-or-owner'; 4 | import { Members } from '../../members/member'; 5 | 6 | export async function canReadTeam(teamId: string, userId: string): Promise { 7 | const team = await Teams().findOne({ 8 | _id: (teamId), 9 | }); 10 | 11 | if (!team) { 12 | throw new AppError('Team not found'); 13 | } 14 | 15 | const { orgId, members } = team; 16 | 17 | if (await isAdminOrOwner(userId, orgId)) { 18 | return true; 19 | } 20 | 21 | const member = await Members().findOne({ userId, 22 | orgId }); 23 | 24 | if (!member) { 25 | throw new Error('Not an org member'); 26 | } 27 | 28 | return members.includes(member._id); 29 | } 30 | -------------------------------------------------------------------------------- /server/src/posthog/init-posthog.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid'; 2 | import { postHogId } from './posthog'; 3 | import { sendHeartbeat } from './send-heartbeat'; 4 | import { AppDb } from '../db/db'; 5 | 6 | export interface AppInfo { 7 | _id: string; 8 | value: string; 9 | } 10 | 11 | export const AppInfos = () => AppDb.db.collection('app-info'); 12 | 13 | const appInfoKey = 'install_id'; 14 | 15 | export async function initPosthog() { 16 | // id 17 | const appInfo = await AppInfos().findOne({ _id: appInfoKey }); 18 | if (appInfo) { 19 | postHogId.id = appInfo.value; 20 | } else { 21 | postHogId.id = uuid(); 22 | await AppInfos().insertOne({ 23 | _id: appInfoKey, 24 | value: postHogId.id, 25 | }); 26 | } 27 | 28 | // heartbeat 29 | sendHeartbeat(); 30 | setInterval(sendHeartbeat, 86400000); // every day 31 | } 32 | -------------------------------------------------------------------------------- /scripts/rebase-git-branch.sh: -------------------------------------------------------------------------------- 1 | [[ "$1" == "" ]] && echo 'missing base branch ($1)' && exit 1; 2 | BASE_BRANCH="$1" 3 | 4 | [[ "$2" == "" ]] && echo 'missing head branch ($2)' && exit 1; 5 | HEAD_BRANCH="$2" 6 | 7 | # save head to come back to it after we're done rebasing 8 | HEAD=$(git rev-parse HEAD | tr -d '\n') 9 | 10 | # reduce log verbosity 11 | git config advice.detachedHead false 12 | 13 | # cleanup workspace 14 | git reset --hard 15 | 16 | # update everything 17 | git fetch 18 | 19 | # move to head branch and update 20 | git checkout $HEAD_BRANCH 21 | git pull --rebase 22 | 23 | # move to base branch 24 | git checkout $BASE_BRANCH 25 | git branch -u origin/$BASE_BRANCH 26 | git pull --rebase 27 | #git push --set-upstream origin $BASE_BRANCH 28 | git merge --ff -m "chore: realign $BASE_BRANCH on $HEAD_BRANCH [ci skip]" $HEAD_BRANCH 29 | git push 30 | git checkout $HEAD 31 | -------------------------------------------------------------------------------- /server/src/hooks/handlers/mattermost/handle-mattermost-hook.ts: -------------------------------------------------------------------------------- 1 | import { Hook } from '../../hook'; 2 | import { EventType } from '../../../events/event-type'; 3 | import { sendMattermostMessage } from './send-mattermost-message'; 4 | import { HookDeliveryResult } from '../get-hook-handler'; 5 | import { getMessageForEvent } from '../slack/get-message-for-event'; 6 | import { getMattermostMessage } from './get-mattermost-message'; 7 | 8 | export function handleMattermostHook(hook: Hook, eventType: EventType, data: any): Promise { 9 | const getMessageFn = getMessageForEvent[eventType]; 10 | const message = getMessageFn 11 | ? getMessageFn(data) 12 | : getMattermostMessage('No custom handler set for this event, sending empty data to prevent leaking sensistive information'); 13 | return sendMattermostMessage(hook.config, eventType, message); 14 | } 15 | -------------------------------------------------------------------------------- /server/src/entities/api/guards/api-token-exists-guard.ts: -------------------------------------------------------------------------------- 1 | import { params } from '../../../commons/express-joi/params'; 2 | import { object } from 'joi'; 3 | import { NextFunction, Request, Response } from 'express'; 4 | import { NotFoundError } from '../../../commons/errors/not-found-error'; 5 | import { $id } from '../../../utils/id'; 6 | import { ApiTokens } from '../api-token'; 7 | 8 | export const apiTokenExistsGuard = [ 9 | params(object({ 10 | apiTokenId: $id, 11 | })), 12 | (req: Request, res: Response, next: NextFunction) => { 13 | const { apiTokenId } = req.params; 14 | ApiTokens() 15 | .countDocuments({ 16 | _id: apiTokenId, 17 | }, { 18 | limit: 1, 19 | }) 20 | .then(count => { 21 | next(count !== 0 ? undefined : new NotFoundError('Api token not found')); 22 | }) 23 | .catch(next); 24 | }, 25 | ]; 26 | -------------------------------------------------------------------------------- /server/src/entities/sites/handlers/tokens/list-tokens.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { siteExistsGuard } from '../../guards/site-exists-guard'; 3 | import { wrapAsyncMiddleware } from '../../../../commons/utils/wrap-async-middleware'; 4 | import { Sites } from '../../site'; 5 | import { serializeSiteToken } from '../../serialize-site-token'; 6 | import { canAdminSiteGuard } from '../../guards/can-admin-site-guard'; 7 | 8 | const validators = []; 9 | 10 | async function handler(req: Request, res: Response): Promise { 11 | const { siteId } = req.params; 12 | 13 | const site = await Sites().findOne({ 14 | _id: siteId, 15 | }); 16 | 17 | res.json(site.tokens.map(serializeSiteToken)); 18 | } 19 | 20 | export const listTokens = [ 21 | ...siteExistsGuard, 22 | ...canAdminSiteGuard, 23 | ...validators, 24 | wrapAsyncMiddleware(handler), 25 | ]; 26 | -------------------------------------------------------------------------------- /server/src/utils/get-pagination.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { query } from '../commons/express-joi/query'; 3 | import { number, object } from 'joi'; 4 | 5 | export const pageValidators = [ 6 | query(object({ 7 | size: number().optional().default(10).min(0) 8 | .max(100), 9 | page: number().optional().default(0).min(0), 10 | })), 11 | ]; 12 | 13 | export function getPagination(req: Request): { size: number, offset: number } { 14 | const size: number = req.query.size ? req.query.size as any as number : 10; 15 | return { 16 | size, 17 | offset: req.query.page ? req.query.page as any as number * size : 0, 18 | }; 19 | } 20 | 21 | export interface Page { 22 | items: T[]; 23 | count: number; 24 | } 25 | 26 | export function pageResponse(items: any[], count: number): Page { 27 | return { 28 | items, 29 | count, 30 | }; 31 | } 32 | --------------------------------------------------------------------------------