├── .dockerignore ├── .editorconfig ├── .github ├── CODEOWNERS └── workflows │ ├── build.yml │ ├── playwright.yml │ └── test.yml ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc ├── LICENSE ├── README.md ├── apps ├── backend │ ├── .dockerignore │ ├── .editorconfig │ ├── .env.empty │ ├── .eslintignore │ ├── .eslintrc │ ├── .gitignore │ ├── .lintstagedrc │ ├── Dockerfile │ ├── _site │ │ └── README │ │ │ └── index.html │ ├── config │ │ ├── admin.js │ │ ├── api.js │ │ ├── env │ │ │ ├── development │ │ │ │ ├── database.js │ │ │ │ └── plugins.js │ │ │ ├── production │ │ │ │ ├── database.js │ │ │ │ └── plugins.js │ │ │ └── test │ │ │ │ ├── database.js │ │ │ │ └── plugins.js │ │ ├── middlewares.js │ │ ├── server.js │ │ └── sync │ │ │ ├── admin-role.strapi-super-admin.json │ │ │ ├── core-store.core_admin_auth.json │ │ │ ├── core-store.plugin_content_manager_configuration_content_types##admin##api-token-permission.json │ │ │ ├── core-store.plugin_content_manager_configuration_content_types##admin##api-token.json │ │ │ ├── core-store.plugin_content_manager_configuration_content_types##admin##permission.json │ │ │ ├── core-store.plugin_content_manager_configuration_content_types##admin##role.json │ │ │ ├── core-store.plugin_content_manager_configuration_content_types##admin##transfer-token-permission.json │ │ │ ├── core-store.plugin_content_manager_configuration_content_types##admin##transfer-token.json │ │ │ ├── core-store.plugin_content_manager_configuration_content_types##admin##user.json │ │ │ ├── core-store.plugin_content_manager_configuration_content_types##api##post.post.json │ │ │ ├── core-store.plugin_content_manager_configuration_content_types##api##tag.tag.json │ │ │ ├── core-store.plugin_content_manager_configuration_content_types##plugin##i18n.locale.json │ │ │ ├── core-store.plugin_content_manager_configuration_content_types##plugin##upload.file.json │ │ │ ├── core-store.plugin_content_manager_configuration_content_types##plugin##upload.folder.json │ │ │ ├── core-store.plugin_content_manager_configuration_content_types##plugin##users-permissions.permission.json │ │ │ ├── core-store.plugin_content_manager_configuration_content_types##plugin##users-permissions.role.json │ │ │ ├── core-store.plugin_content_manager_configuration_content_types##plugin##users-permissions.user.json │ │ │ ├── core-store.plugin_documentation_config.json │ │ │ ├── core-store.plugin_i18n_default_locale.json │ │ │ ├── core-store.plugin_upload_settings.json │ │ │ ├── core-store.plugin_upload_view_configuration.json │ │ │ ├── core-store.plugin_users-permissions_advanced.json │ │ │ ├── core-store.plugin_users-permissions_email.json │ │ │ ├── i18n-locale.en.json │ │ │ ├── user-role.authenticated.json │ │ │ ├── user-role.contributor.json │ │ │ └── user-role.public.json │ ├── database │ │ └── migrations │ │ │ └── .gitkeep │ ├── docker-compose.yml │ ├── favicon.png │ ├── jest.config.js │ ├── package.json │ ├── public │ │ ├── robots.txt │ │ └── uploads │ │ │ └── .gitkeep │ ├── sample.env │ ├── scripts │ │ └── seed.js │ ├── src │ │ ├── admin │ │ │ ├── app.example.js │ │ │ └── webpack.config.example.js │ │ ├── api │ │ │ ├── helpers │ │ │ │ └── services │ │ │ │ │ └── helpers.js │ │ │ ├── post │ │ │ │ ├── content-types │ │ │ │ │ └── post │ │ │ │ │ │ ├── lifecycles.js │ │ │ │ │ │ └── schema.json │ │ │ │ ├── controllers │ │ │ │ │ └── post.js │ │ │ │ ├── policies │ │ │ │ │ ├── is-own-post-slug-id.js │ │ │ │ │ └── is-own-post.js │ │ │ │ ├── routes │ │ │ │ │ ├── 0-post.js │ │ │ │ │ └── post.js │ │ │ │ └── services │ │ │ │ │ └── post.js │ │ │ └── tag │ │ │ │ ├── content-types │ │ │ │ └── tag │ │ │ │ │ └── schema.json │ │ │ │ ├── controllers │ │ │ │ └── tag.js │ │ │ │ ├── routes │ │ │ │ └── tag.js │ │ │ │ └── services │ │ │ │ └── tag.js │ │ ├── extensions │ │ │ └── users-permissions │ │ │ │ ├── content-types │ │ │ │ ├── role │ │ │ │ │ └── schema.json │ │ │ │ └── user │ │ │ │ │ └── schema.json │ │ │ │ └── strapi-server.js │ │ ├── index.js │ │ └── seed │ │ │ └── index.js │ └── tests │ │ ├── app.test.js │ │ ├── auth │ │ └── index.js │ │ ├── helpers │ │ ├── data.helper.js │ │ ├── fixtures.js │ │ ├── helpers.js │ │ └── strapi.js │ │ ├── post │ │ └── index.js │ │ └── user │ │ └── index.js ├── cron │ ├── .gitignore │ ├── package.json │ ├── sample.env │ ├── src │ │ ├── index.ts │ │ └── jobs │ │ │ └── check-and-publish.ts │ └── tsconfig.json └── frontend │ ├── .dockerignore │ ├── .editorconfig │ ├── .eslintrc.json │ ├── .gitignore │ ├── .lintstagedrc │ ├── jsconfig.json │ ├── next.config.js │ ├── package.json │ ├── public │ └── favicon.ico │ ├── sample.env │ └── src │ ├── components │ ├── editor-drawer.jsx │ ├── nav-menu.jsx │ ├── pagination.jsx │ ├── post-form.jsx │ ├── schedule-menu.jsx │ ├── search-component.jsx │ ├── tags-list.jsx │ ├── tip-tap-extensions │ │ └── heading-extension.jsx │ ├── tiptap.jsx │ └── users-list.jsx │ ├── lib │ ├── current-user.js │ ├── editor-config.js │ ├── posts.js │ ├── roles.js │ ├── tags.js │ ├── theme.js │ └── users.js │ ├── middleware.js │ ├── pages │ ├── _app.js │ ├── _document.js │ ├── api │ │ └── auth │ │ │ └── [...nextauth].js │ ├── index.js │ ├── posts │ │ ├── [postId].js │ │ ├── index.js │ │ └── preview │ │ │ └── [postId].js │ ├── profile.js │ ├── signin │ │ └── index.js │ ├── tags │ │ ├── [id].js │ │ ├── index.js │ │ └── new.js │ └── users │ │ ├── [id].js │ │ └── index.js │ └── styles │ └── globals.css ├── docker ├── backend │ └── Dockerfile ├── cron │ └── Dockerfile └── frontend │ └── Dockerfile ├── docs └── README.md ├── e2e ├── .eslintrc.js ├── auth.setup.ts ├── contributor │ └── posts.spec.ts ├── editor-drawer.spec.ts ├── editor.spec.ts ├── fixtures │ └── feature-image.png ├── helpers │ ├── constants.ts │ ├── post.ts │ └── user.ts ├── pages │ └── users.ts ├── posts-preview.spec.ts ├── posts.spec.ts ├── tag.spec.ts ├── tsconfig.eslint.json ├── tsconfig.json └── users.spec.ts ├── package-lock.json ├── package.json ├── playwright.config.ts ├── renovate.json ├── tools └── docker-compose.yml └── turbo.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .tmp/ 2 | .next/ 3 | .cache/ 4 | .git/ 5 | build/ 6 | node_modules/ 7 | .env* 8 | data/ 9 | docker/ -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = true 13 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------- 2 | # CODEOWNERS - For automated review request for 3 | # high impact files. 4 | # 5 | # Important: The order in this file cascades. 6 | # 7 | # https://help.github.com/articles/about-codeowners 8 | # ------------------------------------------------- 9 | 10 | # ------------------------------------------------- 11 | # All files are owned by dev team 12 | # ------------------------------------------------- 13 | 14 | * @freecodecamp/dev-team 15 | 16 | # --- Owned by none (negate rule above) --- 17 | 18 | package.json 19 | package-lock.json 20 | 21 | # ------------------------------------------------- 22 | # All files in the root are owned by dev team 23 | # ------------------------------------------------- 24 | 25 | /* @freecodecamp/dev-team 26 | 27 | # --- Owned by none (negate rule above) --- 28 | 29 | /package.json 30 | /package-lock.json 31 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | name: Build 9 | runs-on: ubuntu-22.04 10 | environment: staging # Hardcoded for now. TODO: use a matrix? 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | node-version: [20.x] 15 | site_tlds: ["dev"] 16 | apps: ["backend", "frontend", "cron"] 17 | 18 | steps: 19 | - name: Checkout source code 20 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 21 | 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | 27 | - name: Create a tagname 28 | id: tagname 29 | run: | 30 | echo "tagname=$(git rev-parse --short HEAD)-$(date +%Y%m%d)-$(date +%H%M)" >> $GITHUB_ENV 31 | 32 | - name: Build & Tag Images 33 | # We pass NEXT_PUBLIC_STRAPI_BACKEND_URL as a build arg to all apps, 34 | # even though only the frontend needs it. The other builds will ignore 35 | # it. 36 | run: | 37 | docker build . \ 38 | --tag registry.digitalocean.com/${{ secrets.DOCR_NAME }}/${{ matrix.site_tlds }}/publish-${{ matrix.apps }}:$tagname \ 39 | --tag registry.digitalocean.com/${{ secrets.DOCR_NAME }}/${{ matrix.site_tlds }}/publish-${{ matrix.apps }}:latest \ 40 | --build-arg NEXT_PUBLIC_STRAPI_BACKEND_URL=${{ secrets.NEXT_PUBLIC_STRAPI_BACKEND_URL }} \ 41 | --file docker/${{ matrix.apps }}/Dockerfile 42 | 43 | - name: Install doctl 44 | uses: digitalocean/action-doctl@v2 45 | with: 46 | token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 47 | 48 | - name: Log in to DigitalOcean Container Registry with short-lived credentials 49 | run: doctl registry login --expiry-seconds 1200 50 | 51 | - name: Push image to DigitalOcean Container Registry 52 | run: | 53 | docker push registry.digitalocean.com/${{ secrets.DOCR_NAME }}/${{ matrix.site_tlds }}/publish-${{ matrix.apps }}:$tagname 54 | docker push registry.digitalocean.com/${{ secrets.DOCR_NAME }}/${{ matrix.site_tlds }}/publish-${{ matrix.apps }}:latest 55 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: main 5 | pull_request: 6 | branches: main 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-22.04 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | browsers: [chromium, firefox, webkit] 15 | services: 16 | postgres: 17 | image: postgres:14 18 | ports: 19 | - 5432:5432 20 | env: 21 | POSTGRES_USER: strapi 22 | POSTGRES_PASSWORD: password 23 | POSTGRES_DB: strapi 24 | mailhog: 25 | image: mailhog/mailhog 26 | ports: 27 | - 1025:1025 28 | steps: 29 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 30 | - uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3 31 | with: 32 | node-version: 18 33 | cache: npm 34 | - name: Install dependencies 35 | run: npm ci 36 | - name: Seed database 37 | run: npm run seed 38 | - name: Build apps 39 | run: npm run build 40 | - name: Install Playwright Browsers 41 | run: npx playwright install --with-deps 42 | - name: Run Playwright tests 43 | run: npx playwright test --project=${{ matrix.browsers }} 44 | - uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3 45 | if: always() 46 | with: 47 | name: playwright-report 48 | path: playwright-report/ 49 | retention-days: 30 50 | - uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3 51 | if: always() 52 | with: 53 | name: traces 54 | path: test-results/ 55 | retention-days: 30 56 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Lint and Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 14 | - name: Set up Node.js 15 | uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2 16 | with: 17 | node-version: "20.x" 18 | cache: "npm" 19 | - name: Install dependencies 20 | run: npm ci 21 | - name: Run lint 22 | run: npm run lint 23 | 24 | test: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout code 28 | uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 29 | - name: Set up Node.js 30 | uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2 31 | with: 32 | # TODO: update to 20 when the tests run on it reliably. 33 | node-version: "18.x" 34 | cache: "npm" 35 | - name: Install dependencies 36 | run: npm ci 37 | - name: Init 38 | run: npm run turbo init 39 | - name: Test 40 | run: npm test 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # VS Code settings 107 | .vscode 108 | 109 | # MacOS 110 | .DS_Store 111 | 112 | # Turborepo 113 | .turbo 114 | 115 | # Playwright 116 | /test-results/ 117 | /playwright-report/ 118 | /blob-report/ 119 | /playwright/.cache/ 120 | /playwright/.auth 121 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | 6 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*": "prettier --ignore-unknown --write", 3 | "e2e/**/*.{js,jsx,ts,tsx}": "eslint" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, freeCodeCamp.org 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > Content backend platform for /news 2 | -------------------------------------------------------------------------------- /apps/backend/.dockerignore: -------------------------------------------------------------------------------- 1 | .tmp/ 2 | .cache/ 3 | .git/ 4 | build/ 5 | node_modules/ 6 | .env* 7 | data/ 8 | -------------------------------------------------------------------------------- /apps/backend/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [{package.json,*.yml}] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /apps/backend/.env.empty: -------------------------------------------------------------------------------- 1 | # keep this file empty 2 | -------------------------------------------------------------------------------- /apps/backend/.eslintignore: -------------------------------------------------------------------------------- 1 | .cache 2 | build 3 | **/node_modules/** 4 | -------------------------------------------------------------------------------- /apps/backend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "prettier"], 3 | "plugins": ["jest"], 4 | "env": { 5 | "commonjs": true, 6 | "es6": true, 7 | "node": true, 8 | "browser": false, 9 | "jest/globals": true 10 | }, 11 | "parserOptions": { 12 | "ecmaVersion": "latest", 13 | "sourceType": "module" 14 | }, 15 | "globals": { 16 | "strapi": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/backend/.gitignore: -------------------------------------------------------------------------------- 1 | ############################ 2 | # OS X 3 | ############################ 4 | 5 | .DS_Store 6 | .AppleDouble 7 | .LSOverride 8 | Icon 9 | .Spotlight-V100 10 | .Trashes 11 | ._* 12 | 13 | 14 | ############################ 15 | # Linux 16 | ############################ 17 | 18 | *~ 19 | 20 | 21 | ############################ 22 | # Windows 23 | ############################ 24 | 25 | Thumbs.db 26 | ehthumbs.db 27 | Desktop.ini 28 | $RECYCLE.BIN/ 29 | *.cab 30 | *.msi 31 | *.msm 32 | *.msp 33 | 34 | 35 | ############################ 36 | # Packages 37 | ############################ 38 | 39 | *.7z 40 | *.csv 41 | *.dat 42 | *.dmg 43 | *.gz 44 | *.iso 45 | *.jar 46 | *.rar 47 | *.tar 48 | *.zip 49 | *.com 50 | *.class 51 | *.dll 52 | *.exe 53 | *.o 54 | *.seed 55 | *.so 56 | *.swo 57 | *.swp 58 | *.swn 59 | *.swm 60 | *.out 61 | *.pid 62 | 63 | 64 | ############################ 65 | # Logs and databases 66 | ############################ 67 | 68 | .tmp 69 | *.log 70 | *.sql 71 | *.sqlite 72 | *.sqlite3 73 | 74 | 75 | ############################ 76 | # Misc. 77 | ############################ 78 | 79 | *# 80 | ssl 81 | .idea 82 | nbproject 83 | public/uploads/* 84 | !public/uploads/.gitkeep 85 | 86 | ############################ 87 | # Node.js 88 | ############################ 89 | 90 | lib-cov 91 | lcov.info 92 | pids 93 | logs 94 | results 95 | node_modules 96 | .node_history 97 | 98 | ############################ 99 | # Tests 100 | ############################ 101 | 102 | testApp 103 | coverage 104 | 105 | ############################ 106 | # Strapi 107 | ############################ 108 | 109 | .env* 110 | !.env.empty 111 | !.env.example 112 | license.txt 113 | exports 114 | *.cache 115 | dist 116 | build 117 | .strapi-updater.json 118 | # Auto-generated files on startup 119 | types/generated/* 120 | .strapi/client/* 121 | 122 | ############################ 123 | # Documentation plugin 124 | ############################ 125 | src/extensions/documentation 126 | src/api/*/documentation 127 | 128 | ############################ 129 | # Docker 130 | ############################ 131 | data 132 | 133 | ############################ 134 | # VS Code 135 | ############################ 136 | 137 | /.vscode 138 | -------------------------------------------------------------------------------- /apps/backend/.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "**/*.{js,jsx,ts,tsx}": "eslint", 3 | "*": "prettier --ignore-unknown --write" 4 | } 5 | -------------------------------------------------------------------------------- /apps/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | # Installing libvips-dev for sharp Compatibility 3 | RUN apk update && apk add --no-cache build-base gcc autoconf automake zlib-dev libpng-dev nasm bash vips-dev 4 | ARG NODE_ENV=development 5 | ENV NODE_ENV=${NODE_ENV} 6 | 7 | WORKDIR /opt/ 8 | COPY package.json package-lock.json ./ 9 | RUN npm config set fetch-retry-maxtimeout 600000 -g && npm install 10 | ENV PATH /opt/node_modules/.bin:$PATH 11 | 12 | WORKDIR /opt/app 13 | COPY . . 14 | RUN chown -R node:node /opt/app 15 | USER node 16 | RUN ["npm", "run", "build"] 17 | EXPOSE 1337 18 | CMD ["npm", "run", "develop"] 19 | -------------------------------------------------------------------------------- /apps/backend/_site/README/index.html: -------------------------------------------------------------------------------- 1 |

🚀 Getting started with Strapi

2 |

3 | Strapi comes with a full featured 4 | Command Line Interface 8 | (CLI) which lets you scaffold and manage your project in seconds. 9 |

10 |

develop

11 |

12 | Start your Strapi application with autoReload enabled. 13 | Learn more 17 |

18 |
npm run develop
 19 | # or
 20 | yarn develop
 21 | 
22 |

start

23 |

24 | Start your Strapi application with autoReload disabled. 25 | Learn more 29 |

30 |
npm run start
 31 | # or
 32 | yarn start
 33 | 
34 |

build

35 |

36 | Build your admin panel. 37 | Learn more 41 |

42 |
npm run build
 43 | # or
 44 | yarn build
 45 | 
46 |

⚙️ Deployment

47 |

48 | Strapi gives you many possible deployment options for your project. Find the 49 | one that suits you on the 50 | deployment section of the documentation. 54 |

55 |

📚 Learn more

56 | 78 |

79 | Feel free to check out the 80 | Strapi GitHub repository. Your 81 | feedback and contributions are welcome! 82 |

83 |

✨ Community

84 | 99 |
100 |

101 | 🤫 Psst! Strapi is hiring. 102 |

103 | -------------------------------------------------------------------------------- /apps/backend/config/admin.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ env }) => ({ 2 | auth: { 3 | secret: env("ADMIN_JWT_SECRET"), 4 | }, 5 | apiToken: { 6 | salt: env("API_TOKEN_SALT"), 7 | }, 8 | transfer: { 9 | token: { 10 | salt: env("TRANSFER_TOKEN_SALT"), 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /apps/backend/config/api.js: -------------------------------------------------------------------------------- 1 | // NOTE: Disabled for pagination 2 | // module.exports = { 3 | // rest: { 4 | // defaultLimit: 25, 5 | // maxLimit: 100, 6 | // withCount: true, 7 | // }, 8 | // }; 9 | -------------------------------------------------------------------------------- /apps/backend/config/env/development/database.js: -------------------------------------------------------------------------------- 1 | // To use PostgreSQL for development, this file has to be in /config/env / 2 | // development directory. If database.js is present under /config directory, 3 | // values from that file affect the test environment setup and result in an 4 | // error. 5 | 6 | module.exports = ({ env }) => ({ 7 | connection: { 8 | client: "postgres", 9 | connection: { 10 | connectionString: env("DATABASE_URL"), 11 | host: env("DATABASE_HOST", "localhost"), 12 | port: env.int("DATABASE_PORT", 5432), 13 | database: env("DATABASE_NAME", "strapi"), 14 | user: env("DATABASE_USERNAME", "strapi"), 15 | password: env("DATABASE_PASSWORD", "strapi"), 16 | ssl: env.bool("DATABASE_SSL", false) && { 17 | ca: env("DATABASE_SSL_CA", undefined), 18 | rejectUnauthorized: env.bool("DATABASE_SSL_REJECT_UNAUTHORIZED", false), 19 | }, 20 | }, 21 | acquireConnectionTimeout: env.int("DATABASE_CONNECTION_TIMEOUT", 60000), 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /apps/backend/config/env/development/plugins.js: -------------------------------------------------------------------------------- 1 | // Default config taken from: 2 | // https://github.com/strapi/strapi/blob/main/packages/plugins/documentation/server/config/default-plugin-config.js 3 | 4 | module.exports = ({ env }) => ({ 5 | documentation: { 6 | enabled: true, 7 | config: { 8 | openapi: "3.0.0", 9 | info: { 10 | version: "1.0.0", 11 | title: "fCC Publication API", 12 | description: "", 13 | termsOfService: null, 14 | contact: null, 15 | license: null, 16 | }, 17 | "x-strapi-config": { 18 | path: "/documentation", 19 | plugins: null, 20 | mutateDocumentation: null, 21 | }, 22 | servers: [], 23 | externalDocs: { 24 | description: "Find out more", 25 | url: "https://docs.strapi.io/developer-docs/latest/getting-started/introduction.html", 26 | }, 27 | security: [{ bearerAuth: [] }], 28 | }, 29 | }, 30 | email: { 31 | config: { 32 | provider: "nodemailer", 33 | providerOptions: { 34 | host: env("NODEMAILER_HOST", "localhost"), 35 | secure: false, 36 | port: 1025, 37 | auth: { 38 | user: "test", 39 | pass: "test", 40 | }, 41 | tls: { 42 | rejectUnauthorized: false, 43 | }, 44 | }, 45 | }, 46 | }, 47 | // When running e2e tests we use NODE_ENV=development and do not want to be 48 | // rate limited. 49 | "users-permissions": { 50 | config: { 51 | ratelimit: { 52 | enabled: false, 53 | }, 54 | }, 55 | }, 56 | }); 57 | -------------------------------------------------------------------------------- /apps/backend/config/env/production/database.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ env }) => ({ 2 | connection: { 3 | client: "postgres", 4 | connection: { 5 | connectionString: env("DATABASE_URL"), 6 | host: env("DATABASE_HOST"), 7 | port: env.int("DATABASE_PORT"), 8 | database: env("DATABASE_NAME"), 9 | user: env("DATABASE_USERNAME"), 10 | password: env("DATABASE_PASSWORD"), 11 | ssl: env.bool("DATABASE_SSL", true) && { 12 | ca: env("DATABASE_SSL_CA"), 13 | rejectUnauthorized: env.bool("DATABASE_SSL_REJECT_UNAUTHORIZED", true), 14 | }, 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /apps/backend/config/env/production/plugins.js: -------------------------------------------------------------------------------- 1 | // Default config taken from: 2 | // https://github.com/strapi/strapi/blob/main/packages/plugins/documentation/server/config/default-plugin-config.js 3 | 4 | module.exports = ({ env }) => ({ 5 | email: { 6 | config: { 7 | provider: "nodemailer", 8 | providerOptions: { 9 | host: env("AWS_SES_HOST"), 10 | secure: true, 11 | port: 465, 12 | auth: { 13 | user: env("AWS_SES_KEY"), 14 | pass: env("AWS_SES_SECRET"), 15 | }, 16 | }, 17 | settings: { 18 | defaultFrom: "team@freecodecamp.org", 19 | }, 20 | }, 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /apps/backend/config/env/test/database.js: -------------------------------------------------------------------------------- 1 | module.exports = () => ({ 2 | connection: { 3 | client: "sqlite", 4 | connection: { 5 | filename: ".tmp/test.db", 6 | }, 7 | useNullAsDefault: true, 8 | debug: false, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /apps/backend/config/env/test/plugins.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | documentation: { 3 | enabled: false, 4 | }, 5 | "config-sync": { 6 | enabled: true, 7 | config: { 8 | syncDir: "config/sync/", 9 | minify: false, 10 | soft: false, 11 | importOnBootstrap: true, // import permission config on running tests 12 | customTypes: [], 13 | excludedTypes: [], 14 | excludedConfig: [], 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /apps/backend/config/middlewares.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | "strapi::errors", 3 | "strapi::security", 4 | "strapi::cors", 5 | "strapi::poweredBy", 6 | "strapi::logger", 7 | "strapi::query", 8 | "strapi::session", 9 | "strapi::favicon", 10 | "strapi::body", 11 | "strapi::public", 12 | ]; 13 | -------------------------------------------------------------------------------- /apps/backend/config/server.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ env }) => ({ 2 | host: env("HOST", "0.0.0.0"), 3 | port: env.int("PORT", 1337), 4 | app: { 5 | keys: env.array("APP_KEYS"), 6 | }, 7 | webhooks: { 8 | populateRelations: env.bool("WEBHOOKS_POPULATE_RELATIONS", false), 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /apps/backend/config/sync/core-store.core_admin_auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "core_admin_auth", 3 | "value": { 4 | "providers": { 5 | "autoRegister": false, 6 | "defaultRole": null, 7 | "ssoLockedRoles": null 8 | } 9 | }, 10 | "type": "object", 11 | "environment": null, 12 | "tag": null 13 | } 14 | -------------------------------------------------------------------------------- /apps/backend/config/sync/core-store.plugin_content_manager_configuration_content_types##admin##api-token-permission.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "plugin_content_manager_configuration_content_types::admin::api-token-permission", 3 | "value": { 4 | "uid": "admin::api-token-permission", 5 | "settings": { 6 | "bulkable": true, 7 | "filterable": true, 8 | "searchable": true, 9 | "pageSize": 10, 10 | "mainField": "action", 11 | "defaultSortBy": "action", 12 | "defaultSortOrder": "ASC" 13 | }, 14 | "metadatas": { 15 | "id": { 16 | "edit": {}, 17 | "list": { 18 | "label": "id", 19 | "searchable": true, 20 | "sortable": true 21 | } 22 | }, 23 | "action": { 24 | "edit": { 25 | "label": "action", 26 | "description": "", 27 | "placeholder": "", 28 | "visible": true, 29 | "editable": true 30 | }, 31 | "list": { 32 | "label": "action", 33 | "searchable": true, 34 | "sortable": true 35 | } 36 | }, 37 | "token": { 38 | "edit": { 39 | "label": "token", 40 | "description": "", 41 | "placeholder": "", 42 | "visible": true, 43 | "editable": true, 44 | "mainField": "name" 45 | }, 46 | "list": { 47 | "label": "token", 48 | "searchable": true, 49 | "sortable": true 50 | } 51 | }, 52 | "createdAt": { 53 | "edit": { 54 | "label": "createdAt", 55 | "description": "", 56 | "placeholder": "", 57 | "visible": false, 58 | "editable": true 59 | }, 60 | "list": { 61 | "label": "createdAt", 62 | "searchable": true, 63 | "sortable": true 64 | } 65 | }, 66 | "updatedAt": { 67 | "edit": { 68 | "label": "updatedAt", 69 | "description": "", 70 | "placeholder": "", 71 | "visible": false, 72 | "editable": true 73 | }, 74 | "list": { 75 | "label": "updatedAt", 76 | "searchable": true, 77 | "sortable": true 78 | } 79 | }, 80 | "createdBy": { 81 | "edit": { 82 | "label": "createdBy", 83 | "description": "", 84 | "placeholder": "", 85 | "visible": false, 86 | "editable": true, 87 | "mainField": "firstname" 88 | }, 89 | "list": { 90 | "label": "createdBy", 91 | "searchable": true, 92 | "sortable": true 93 | } 94 | }, 95 | "updatedBy": { 96 | "edit": { 97 | "label": "updatedBy", 98 | "description": "", 99 | "placeholder": "", 100 | "visible": false, 101 | "editable": true, 102 | "mainField": "firstname" 103 | }, 104 | "list": { 105 | "label": "updatedBy", 106 | "searchable": true, 107 | "sortable": true 108 | } 109 | } 110 | }, 111 | "layouts": { 112 | "list": ["id", "action", "token", "createdAt"], 113 | "edit": [ 114 | [ 115 | { 116 | "name": "action", 117 | "size": 6 118 | }, 119 | { 120 | "name": "token", 121 | "size": 6 122 | } 123 | ] 124 | ] 125 | } 126 | }, 127 | "type": "object", 128 | "environment": null, 129 | "tag": null 130 | } 131 | -------------------------------------------------------------------------------- /apps/backend/config/sync/core-store.plugin_content_manager_configuration_content_types##admin##permission.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "plugin_content_manager_configuration_content_types::admin::permission", 3 | "value": { 4 | "uid": "admin::permission", 5 | "settings": { 6 | "bulkable": true, 7 | "filterable": true, 8 | "searchable": true, 9 | "pageSize": 10, 10 | "mainField": "action", 11 | "defaultSortBy": "action", 12 | "defaultSortOrder": "ASC" 13 | }, 14 | "metadatas": { 15 | "id": { 16 | "edit": {}, 17 | "list": { 18 | "label": "id", 19 | "searchable": true, 20 | "sortable": true 21 | } 22 | }, 23 | "action": { 24 | "edit": { 25 | "label": "action", 26 | "description": "", 27 | "placeholder": "", 28 | "visible": true, 29 | "editable": true 30 | }, 31 | "list": { 32 | "label": "action", 33 | "searchable": true, 34 | "sortable": true 35 | } 36 | }, 37 | "actionParameters": { 38 | "edit": { 39 | "label": "actionParameters", 40 | "description": "", 41 | "placeholder": "", 42 | "visible": true, 43 | "editable": true 44 | }, 45 | "list": { 46 | "label": "actionParameters", 47 | "searchable": false, 48 | "sortable": false 49 | } 50 | }, 51 | "subject": { 52 | "edit": { 53 | "label": "subject", 54 | "description": "", 55 | "placeholder": "", 56 | "visible": true, 57 | "editable": true 58 | }, 59 | "list": { 60 | "label": "subject", 61 | "searchable": true, 62 | "sortable": true 63 | } 64 | }, 65 | "properties": { 66 | "edit": { 67 | "label": "properties", 68 | "description": "", 69 | "placeholder": "", 70 | "visible": true, 71 | "editable": true 72 | }, 73 | "list": { 74 | "label": "properties", 75 | "searchable": false, 76 | "sortable": false 77 | } 78 | }, 79 | "conditions": { 80 | "edit": { 81 | "label": "conditions", 82 | "description": "", 83 | "placeholder": "", 84 | "visible": true, 85 | "editable": true 86 | }, 87 | "list": { 88 | "label": "conditions", 89 | "searchable": false, 90 | "sortable": false 91 | } 92 | }, 93 | "role": { 94 | "edit": { 95 | "label": "role", 96 | "description": "", 97 | "placeholder": "", 98 | "visible": true, 99 | "editable": true, 100 | "mainField": "name" 101 | }, 102 | "list": { 103 | "label": "role", 104 | "searchable": true, 105 | "sortable": true 106 | } 107 | }, 108 | "createdAt": { 109 | "edit": { 110 | "label": "createdAt", 111 | "description": "", 112 | "placeholder": "", 113 | "visible": false, 114 | "editable": true 115 | }, 116 | "list": { 117 | "label": "createdAt", 118 | "searchable": true, 119 | "sortable": true 120 | } 121 | }, 122 | "updatedAt": { 123 | "edit": { 124 | "label": "updatedAt", 125 | "description": "", 126 | "placeholder": "", 127 | "visible": false, 128 | "editable": true 129 | }, 130 | "list": { 131 | "label": "updatedAt", 132 | "searchable": true, 133 | "sortable": true 134 | } 135 | }, 136 | "createdBy": { 137 | "edit": { 138 | "label": "createdBy", 139 | "description": "", 140 | "placeholder": "", 141 | "visible": false, 142 | "editable": true, 143 | "mainField": "firstname" 144 | }, 145 | "list": { 146 | "label": "createdBy", 147 | "searchable": true, 148 | "sortable": true 149 | } 150 | }, 151 | "updatedBy": { 152 | "edit": { 153 | "label": "updatedBy", 154 | "description": "", 155 | "placeholder": "", 156 | "visible": false, 157 | "editable": true, 158 | "mainField": "firstname" 159 | }, 160 | "list": { 161 | "label": "updatedBy", 162 | "searchable": true, 163 | "sortable": true 164 | } 165 | } 166 | }, 167 | "layouts": { 168 | "list": ["id", "action", "subject", "role"], 169 | "edit": [ 170 | [ 171 | { 172 | "name": "action", 173 | "size": 6 174 | }, 175 | { 176 | "name": "subject", 177 | "size": 6 178 | } 179 | ], 180 | [ 181 | { 182 | "name": "properties", 183 | "size": 12 184 | } 185 | ], 186 | [ 187 | { 188 | "name": "conditions", 189 | "size": 12 190 | } 191 | ], 192 | [ 193 | { 194 | "name": "role", 195 | "size": 6 196 | } 197 | ], 198 | [ 199 | { 200 | "name": "actionParameters", 201 | "size": 12 202 | } 203 | ] 204 | ] 205 | } 206 | }, 207 | "type": "object", 208 | "environment": null, 209 | "tag": null 210 | } 211 | -------------------------------------------------------------------------------- /apps/backend/config/sync/core-store.plugin_content_manager_configuration_content_types##admin##role.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "plugin_content_manager_configuration_content_types::admin::role", 3 | "value": { 4 | "uid": "admin::role", 5 | "settings": { 6 | "bulkable": true, 7 | "filterable": true, 8 | "searchable": true, 9 | "pageSize": 10, 10 | "mainField": "name", 11 | "defaultSortBy": "name", 12 | "defaultSortOrder": "ASC" 13 | }, 14 | "metadatas": { 15 | "id": { 16 | "edit": {}, 17 | "list": { 18 | "label": "id", 19 | "searchable": true, 20 | "sortable": true 21 | } 22 | }, 23 | "name": { 24 | "edit": { 25 | "label": "name", 26 | "description": "", 27 | "placeholder": "", 28 | "visible": true, 29 | "editable": true 30 | }, 31 | "list": { 32 | "label": "name", 33 | "searchable": true, 34 | "sortable": true 35 | } 36 | }, 37 | "code": { 38 | "edit": { 39 | "label": "code", 40 | "description": "", 41 | "placeholder": "", 42 | "visible": true, 43 | "editable": true 44 | }, 45 | "list": { 46 | "label": "code", 47 | "searchable": true, 48 | "sortable": true 49 | } 50 | }, 51 | "description": { 52 | "edit": { 53 | "label": "description", 54 | "description": "", 55 | "placeholder": "", 56 | "visible": true, 57 | "editable": true 58 | }, 59 | "list": { 60 | "label": "description", 61 | "searchable": true, 62 | "sortable": true 63 | } 64 | }, 65 | "users": { 66 | "edit": { 67 | "label": "users", 68 | "description": "", 69 | "placeholder": "", 70 | "visible": true, 71 | "editable": true, 72 | "mainField": "firstname" 73 | }, 74 | "list": { 75 | "label": "users", 76 | "searchable": false, 77 | "sortable": false 78 | } 79 | }, 80 | "permissions": { 81 | "edit": { 82 | "label": "permissions", 83 | "description": "", 84 | "placeholder": "", 85 | "visible": true, 86 | "editable": true, 87 | "mainField": "action" 88 | }, 89 | "list": { 90 | "label": "permissions", 91 | "searchable": false, 92 | "sortable": false 93 | } 94 | }, 95 | "createdAt": { 96 | "edit": { 97 | "label": "createdAt", 98 | "description": "", 99 | "placeholder": "", 100 | "visible": false, 101 | "editable": true 102 | }, 103 | "list": { 104 | "label": "createdAt", 105 | "searchable": true, 106 | "sortable": true 107 | } 108 | }, 109 | "updatedAt": { 110 | "edit": { 111 | "label": "updatedAt", 112 | "description": "", 113 | "placeholder": "", 114 | "visible": false, 115 | "editable": true 116 | }, 117 | "list": { 118 | "label": "updatedAt", 119 | "searchable": true, 120 | "sortable": true 121 | } 122 | }, 123 | "createdBy": { 124 | "edit": { 125 | "label": "createdBy", 126 | "description": "", 127 | "placeholder": "", 128 | "visible": false, 129 | "editable": true, 130 | "mainField": "firstname" 131 | }, 132 | "list": { 133 | "label": "createdBy", 134 | "searchable": true, 135 | "sortable": true 136 | } 137 | }, 138 | "updatedBy": { 139 | "edit": { 140 | "label": "updatedBy", 141 | "description": "", 142 | "placeholder": "", 143 | "visible": false, 144 | "editable": true, 145 | "mainField": "firstname" 146 | }, 147 | "list": { 148 | "label": "updatedBy", 149 | "searchable": true, 150 | "sortable": true 151 | } 152 | } 153 | }, 154 | "layouts": { 155 | "list": ["id", "name", "code", "description"], 156 | "edit": [ 157 | [ 158 | { 159 | "name": "name", 160 | "size": 6 161 | }, 162 | { 163 | "name": "code", 164 | "size": 6 165 | } 166 | ], 167 | [ 168 | { 169 | "name": "description", 170 | "size": 6 171 | }, 172 | { 173 | "name": "users", 174 | "size": 6 175 | } 176 | ], 177 | [ 178 | { 179 | "name": "permissions", 180 | "size": 6 181 | } 182 | ] 183 | ] 184 | } 185 | }, 186 | "type": "object", 187 | "environment": null, 188 | "tag": null 189 | } 190 | -------------------------------------------------------------------------------- /apps/backend/config/sync/core-store.plugin_content_manager_configuration_content_types##admin##transfer-token-permission.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "plugin_content_manager_configuration_content_types::admin::transfer-token-permission", 3 | "value": { 4 | "uid": "admin::transfer-token-permission", 5 | "settings": { 6 | "bulkable": true, 7 | "filterable": true, 8 | "searchable": true, 9 | "pageSize": 10, 10 | "mainField": "action", 11 | "defaultSortBy": "action", 12 | "defaultSortOrder": "ASC" 13 | }, 14 | "metadatas": { 15 | "id": { 16 | "edit": {}, 17 | "list": { 18 | "label": "id", 19 | "searchable": true, 20 | "sortable": true 21 | } 22 | }, 23 | "action": { 24 | "edit": { 25 | "label": "action", 26 | "description": "", 27 | "placeholder": "", 28 | "visible": true, 29 | "editable": true 30 | }, 31 | "list": { 32 | "label": "action", 33 | "searchable": true, 34 | "sortable": true 35 | } 36 | }, 37 | "token": { 38 | "edit": { 39 | "label": "token", 40 | "description": "", 41 | "placeholder": "", 42 | "visible": true, 43 | "editable": true, 44 | "mainField": "name" 45 | }, 46 | "list": { 47 | "label": "token", 48 | "searchable": true, 49 | "sortable": true 50 | } 51 | }, 52 | "createdAt": { 53 | "edit": { 54 | "label": "createdAt", 55 | "description": "", 56 | "placeholder": "", 57 | "visible": false, 58 | "editable": true 59 | }, 60 | "list": { 61 | "label": "createdAt", 62 | "searchable": true, 63 | "sortable": true 64 | } 65 | }, 66 | "updatedAt": { 67 | "edit": { 68 | "label": "updatedAt", 69 | "description": "", 70 | "placeholder": "", 71 | "visible": false, 72 | "editable": true 73 | }, 74 | "list": { 75 | "label": "updatedAt", 76 | "searchable": true, 77 | "sortable": true 78 | } 79 | }, 80 | "createdBy": { 81 | "edit": { 82 | "label": "createdBy", 83 | "description": "", 84 | "placeholder": "", 85 | "visible": false, 86 | "editable": true, 87 | "mainField": "firstname" 88 | }, 89 | "list": { 90 | "label": "createdBy", 91 | "searchable": true, 92 | "sortable": true 93 | } 94 | }, 95 | "updatedBy": { 96 | "edit": { 97 | "label": "updatedBy", 98 | "description": "", 99 | "placeholder": "", 100 | "visible": false, 101 | "editable": true, 102 | "mainField": "firstname" 103 | }, 104 | "list": { 105 | "label": "updatedBy", 106 | "searchable": true, 107 | "sortable": true 108 | } 109 | } 110 | }, 111 | "layouts": { 112 | "list": ["id", "action", "token", "createdAt"], 113 | "edit": [ 114 | [ 115 | { 116 | "name": "action", 117 | "size": 6 118 | }, 119 | { 120 | "name": "token", 121 | "size": 6 122 | } 123 | ] 124 | ] 125 | } 126 | }, 127 | "type": "object", 128 | "environment": null, 129 | "tag": null 130 | } 131 | -------------------------------------------------------------------------------- /apps/backend/config/sync/core-store.plugin_content_manager_configuration_content_types##api##tag.tag.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "plugin_content_manager_configuration_content_types::api::tag.tag", 3 | "value": { 4 | "uid": "api::tag.tag", 5 | "settings": { 6 | "bulkable": true, 7 | "filterable": true, 8 | "searchable": true, 9 | "pageSize": 10, 10 | "mainField": "name", 11 | "defaultSortBy": "name", 12 | "defaultSortOrder": "ASC" 13 | }, 14 | "metadatas": { 15 | "id": { 16 | "edit": {}, 17 | "list": { 18 | "label": "id", 19 | "searchable": true, 20 | "sortable": true 21 | } 22 | }, 23 | "name": { 24 | "edit": { 25 | "label": "name", 26 | "description": "", 27 | "placeholder": "", 28 | "visible": true, 29 | "editable": true 30 | }, 31 | "list": { 32 | "label": "name", 33 | "searchable": true, 34 | "sortable": true 35 | } 36 | }, 37 | "slug": { 38 | "edit": { 39 | "label": "slug", 40 | "description": "", 41 | "placeholder": "", 42 | "visible": true, 43 | "editable": true 44 | }, 45 | "list": { 46 | "label": "slug", 47 | "searchable": true, 48 | "sortable": true 49 | } 50 | }, 51 | "posts": { 52 | "edit": { 53 | "label": "posts", 54 | "description": "", 55 | "placeholder": "", 56 | "visible": true, 57 | "editable": true, 58 | "mainField": "title" 59 | }, 60 | "list": { 61 | "label": "posts", 62 | "searchable": false, 63 | "sortable": false 64 | } 65 | }, 66 | "visibility": { 67 | "edit": { 68 | "label": "visibility", 69 | "description": "", 70 | "placeholder": "", 71 | "visible": true, 72 | "editable": true 73 | }, 74 | "list": { 75 | "label": "visibility", 76 | "searchable": true, 77 | "sortable": true 78 | } 79 | }, 80 | "ghost_id": { 81 | "edit": { 82 | "label": "ghost_id", 83 | "description": "", 84 | "placeholder": "", 85 | "visible": true, 86 | "editable": true 87 | }, 88 | "list": { 89 | "label": "ghost_id", 90 | "searchable": true, 91 | "sortable": true 92 | } 93 | }, 94 | "createdAt": { 95 | "edit": { 96 | "label": "createdAt", 97 | "description": "", 98 | "placeholder": "", 99 | "visible": false, 100 | "editable": true 101 | }, 102 | "list": { 103 | "label": "createdAt", 104 | "searchable": true, 105 | "sortable": true 106 | } 107 | }, 108 | "updatedAt": { 109 | "edit": { 110 | "label": "updatedAt", 111 | "description": "", 112 | "placeholder": "", 113 | "visible": false, 114 | "editable": true 115 | }, 116 | "list": { 117 | "label": "updatedAt", 118 | "searchable": true, 119 | "sortable": true 120 | } 121 | }, 122 | "createdBy": { 123 | "edit": { 124 | "label": "createdBy", 125 | "description": "", 126 | "placeholder": "", 127 | "visible": false, 128 | "editable": true, 129 | "mainField": "firstname" 130 | }, 131 | "list": { 132 | "label": "createdBy", 133 | "searchable": true, 134 | "sortable": true 135 | } 136 | }, 137 | "updatedBy": { 138 | "edit": { 139 | "label": "updatedBy", 140 | "description": "", 141 | "placeholder": "", 142 | "visible": false, 143 | "editable": true, 144 | "mainField": "firstname" 145 | }, 146 | "list": { 147 | "label": "updatedBy", 148 | "searchable": true, 149 | "sortable": true 150 | } 151 | } 152 | }, 153 | "layouts": { 154 | "list": ["id", "name", "createdAt", "updatedAt"], 155 | "edit": [ 156 | [ 157 | { 158 | "name": "name", 159 | "size": 6 160 | } 161 | ], 162 | [ 163 | { 164 | "name": "slug", 165 | "size": 6 166 | } 167 | ], 168 | [ 169 | { 170 | "name": "visibility", 171 | "size": 6 172 | }, 173 | { 174 | "name": "posts", 175 | "size": 6 176 | } 177 | ], 178 | [ 179 | { 180 | "name": "ghost_id", 181 | "size": 6 182 | } 183 | ] 184 | ] 185 | } 186 | }, 187 | "type": "object", 188 | "environment": null, 189 | "tag": null 190 | } 191 | -------------------------------------------------------------------------------- /apps/backend/config/sync/core-store.plugin_content_manager_configuration_content_types##plugin##i18n.locale.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "plugin_content_manager_configuration_content_types::plugin::i18n.locale", 3 | "value": { 4 | "uid": "plugin::i18n.locale", 5 | "settings": { 6 | "bulkable": true, 7 | "filterable": true, 8 | "searchable": true, 9 | "pageSize": 10, 10 | "mainField": "name", 11 | "defaultSortBy": "name", 12 | "defaultSortOrder": "ASC" 13 | }, 14 | "metadatas": { 15 | "id": { 16 | "edit": {}, 17 | "list": { 18 | "label": "id", 19 | "searchable": true, 20 | "sortable": true 21 | } 22 | }, 23 | "name": { 24 | "edit": { 25 | "label": "name", 26 | "description": "", 27 | "placeholder": "", 28 | "visible": true, 29 | "editable": true 30 | }, 31 | "list": { 32 | "label": "name", 33 | "searchable": true, 34 | "sortable": true 35 | } 36 | }, 37 | "code": { 38 | "edit": { 39 | "label": "code", 40 | "description": "", 41 | "placeholder": "", 42 | "visible": true, 43 | "editable": true 44 | }, 45 | "list": { 46 | "label": "code", 47 | "searchable": true, 48 | "sortable": true 49 | } 50 | }, 51 | "createdAt": { 52 | "edit": { 53 | "label": "createdAt", 54 | "description": "", 55 | "placeholder": "", 56 | "visible": false, 57 | "editable": true 58 | }, 59 | "list": { 60 | "label": "createdAt", 61 | "searchable": true, 62 | "sortable": true 63 | } 64 | }, 65 | "updatedAt": { 66 | "edit": { 67 | "label": "updatedAt", 68 | "description": "", 69 | "placeholder": "", 70 | "visible": false, 71 | "editable": true 72 | }, 73 | "list": { 74 | "label": "updatedAt", 75 | "searchable": true, 76 | "sortable": true 77 | } 78 | }, 79 | "createdBy": { 80 | "edit": { 81 | "label": "createdBy", 82 | "description": "", 83 | "placeholder": "", 84 | "visible": false, 85 | "editable": true, 86 | "mainField": "firstname" 87 | }, 88 | "list": { 89 | "label": "createdBy", 90 | "searchable": true, 91 | "sortable": true 92 | } 93 | }, 94 | "updatedBy": { 95 | "edit": { 96 | "label": "updatedBy", 97 | "description": "", 98 | "placeholder": "", 99 | "visible": false, 100 | "editable": true, 101 | "mainField": "firstname" 102 | }, 103 | "list": { 104 | "label": "updatedBy", 105 | "searchable": true, 106 | "sortable": true 107 | } 108 | } 109 | }, 110 | "layouts": { 111 | "list": ["id", "name", "code", "createdAt"], 112 | "edit": [ 113 | [ 114 | { 115 | "name": "name", 116 | "size": 6 117 | }, 118 | { 119 | "name": "code", 120 | "size": 6 121 | } 122 | ] 123 | ] 124 | } 125 | }, 126 | "type": "object", 127 | "environment": null, 128 | "tag": null 129 | } 130 | -------------------------------------------------------------------------------- /apps/backend/config/sync/core-store.plugin_content_manager_configuration_content_types##plugin##upload.folder.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "plugin_content_manager_configuration_content_types::plugin::upload.folder", 3 | "value": { 4 | "uid": "plugin::upload.folder", 5 | "settings": { 6 | "bulkable": true, 7 | "filterable": true, 8 | "searchable": true, 9 | "pageSize": 10, 10 | "mainField": "name", 11 | "defaultSortBy": "name", 12 | "defaultSortOrder": "ASC" 13 | }, 14 | "metadatas": { 15 | "id": { 16 | "edit": {}, 17 | "list": { 18 | "label": "id", 19 | "searchable": true, 20 | "sortable": true 21 | } 22 | }, 23 | "name": { 24 | "edit": { 25 | "label": "name", 26 | "description": "", 27 | "placeholder": "", 28 | "visible": true, 29 | "editable": true 30 | }, 31 | "list": { 32 | "label": "name", 33 | "searchable": true, 34 | "sortable": true 35 | } 36 | }, 37 | "pathId": { 38 | "edit": { 39 | "label": "pathId", 40 | "description": "", 41 | "placeholder": "", 42 | "visible": true, 43 | "editable": true 44 | }, 45 | "list": { 46 | "label": "pathId", 47 | "searchable": true, 48 | "sortable": true 49 | } 50 | }, 51 | "parent": { 52 | "edit": { 53 | "label": "parent", 54 | "description": "", 55 | "placeholder": "", 56 | "visible": true, 57 | "editable": true, 58 | "mainField": "name" 59 | }, 60 | "list": { 61 | "label": "parent", 62 | "searchable": true, 63 | "sortable": true 64 | } 65 | }, 66 | "children": { 67 | "edit": { 68 | "label": "children", 69 | "description": "", 70 | "placeholder": "", 71 | "visible": true, 72 | "editable": true, 73 | "mainField": "name" 74 | }, 75 | "list": { 76 | "label": "children", 77 | "searchable": false, 78 | "sortable": false 79 | } 80 | }, 81 | "files": { 82 | "edit": { 83 | "label": "files", 84 | "description": "", 85 | "placeholder": "", 86 | "visible": true, 87 | "editable": true, 88 | "mainField": "name" 89 | }, 90 | "list": { 91 | "label": "files", 92 | "searchable": false, 93 | "sortable": false 94 | } 95 | }, 96 | "path": { 97 | "edit": { 98 | "label": "path", 99 | "description": "", 100 | "placeholder": "", 101 | "visible": true, 102 | "editable": true 103 | }, 104 | "list": { 105 | "label": "path", 106 | "searchable": true, 107 | "sortable": true 108 | } 109 | }, 110 | "createdAt": { 111 | "edit": { 112 | "label": "createdAt", 113 | "description": "", 114 | "placeholder": "", 115 | "visible": false, 116 | "editable": true 117 | }, 118 | "list": { 119 | "label": "createdAt", 120 | "searchable": true, 121 | "sortable": true 122 | } 123 | }, 124 | "updatedAt": { 125 | "edit": { 126 | "label": "updatedAt", 127 | "description": "", 128 | "placeholder": "", 129 | "visible": false, 130 | "editable": true 131 | }, 132 | "list": { 133 | "label": "updatedAt", 134 | "searchable": true, 135 | "sortable": true 136 | } 137 | }, 138 | "createdBy": { 139 | "edit": { 140 | "label": "createdBy", 141 | "description": "", 142 | "placeholder": "", 143 | "visible": false, 144 | "editable": true, 145 | "mainField": "firstname" 146 | }, 147 | "list": { 148 | "label": "createdBy", 149 | "searchable": true, 150 | "sortable": true 151 | } 152 | }, 153 | "updatedBy": { 154 | "edit": { 155 | "label": "updatedBy", 156 | "description": "", 157 | "placeholder": "", 158 | "visible": false, 159 | "editable": true, 160 | "mainField": "firstname" 161 | }, 162 | "list": { 163 | "label": "updatedBy", 164 | "searchable": true, 165 | "sortable": true 166 | } 167 | } 168 | }, 169 | "layouts": { 170 | "list": ["id", "name", "pathId", "parent"], 171 | "edit": [ 172 | [ 173 | { 174 | "name": "name", 175 | "size": 6 176 | }, 177 | { 178 | "name": "pathId", 179 | "size": 4 180 | } 181 | ], 182 | [ 183 | { 184 | "name": "parent", 185 | "size": 6 186 | }, 187 | { 188 | "name": "children", 189 | "size": 6 190 | } 191 | ], 192 | [ 193 | { 194 | "name": "files", 195 | "size": 6 196 | }, 197 | { 198 | "name": "path", 199 | "size": 6 200 | } 201 | ] 202 | ] 203 | } 204 | }, 205 | "type": "object", 206 | "environment": null, 207 | "tag": null 208 | } 209 | -------------------------------------------------------------------------------- /apps/backend/config/sync/core-store.plugin_content_manager_configuration_content_types##plugin##users-permissions.permission.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "plugin_content_manager_configuration_content_types::plugin::users-permissions.permission", 3 | "value": { 4 | "uid": "plugin::users-permissions.permission", 5 | "settings": { 6 | "bulkable": true, 7 | "filterable": true, 8 | "searchable": true, 9 | "pageSize": 10, 10 | "mainField": "action", 11 | "defaultSortBy": "action", 12 | "defaultSortOrder": "ASC" 13 | }, 14 | "metadatas": { 15 | "id": { 16 | "edit": {}, 17 | "list": { 18 | "label": "id", 19 | "searchable": true, 20 | "sortable": true 21 | } 22 | }, 23 | "action": { 24 | "edit": { 25 | "label": "action", 26 | "description": "", 27 | "placeholder": "", 28 | "visible": true, 29 | "editable": true 30 | }, 31 | "list": { 32 | "label": "action", 33 | "searchable": true, 34 | "sortable": true 35 | } 36 | }, 37 | "role": { 38 | "edit": { 39 | "label": "role", 40 | "description": "", 41 | "placeholder": "", 42 | "visible": true, 43 | "editable": true, 44 | "mainField": "name" 45 | }, 46 | "list": { 47 | "label": "role", 48 | "searchable": true, 49 | "sortable": true 50 | } 51 | }, 52 | "createdAt": { 53 | "edit": { 54 | "label": "createdAt", 55 | "description": "", 56 | "placeholder": "", 57 | "visible": false, 58 | "editable": true 59 | }, 60 | "list": { 61 | "label": "createdAt", 62 | "searchable": true, 63 | "sortable": true 64 | } 65 | }, 66 | "updatedAt": { 67 | "edit": { 68 | "label": "updatedAt", 69 | "description": "", 70 | "placeholder": "", 71 | "visible": false, 72 | "editable": true 73 | }, 74 | "list": { 75 | "label": "updatedAt", 76 | "searchable": true, 77 | "sortable": true 78 | } 79 | }, 80 | "createdBy": { 81 | "edit": { 82 | "label": "createdBy", 83 | "description": "", 84 | "placeholder": "", 85 | "visible": false, 86 | "editable": true, 87 | "mainField": "firstname" 88 | }, 89 | "list": { 90 | "label": "createdBy", 91 | "searchable": true, 92 | "sortable": true 93 | } 94 | }, 95 | "updatedBy": { 96 | "edit": { 97 | "label": "updatedBy", 98 | "description": "", 99 | "placeholder": "", 100 | "visible": false, 101 | "editable": true, 102 | "mainField": "firstname" 103 | }, 104 | "list": { 105 | "label": "updatedBy", 106 | "searchable": true, 107 | "sortable": true 108 | } 109 | } 110 | }, 111 | "layouts": { 112 | "list": ["id", "action", "role", "createdAt"], 113 | "edit": [ 114 | [ 115 | { 116 | "name": "action", 117 | "size": 6 118 | }, 119 | { 120 | "name": "role", 121 | "size": 6 122 | } 123 | ] 124 | ] 125 | } 126 | }, 127 | "type": "object", 128 | "environment": null, 129 | "tag": null 130 | } 131 | -------------------------------------------------------------------------------- /apps/backend/config/sync/core-store.plugin_content_manager_configuration_content_types##plugin##users-permissions.role.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "plugin_content_manager_configuration_content_types::plugin::users-permissions.role", 3 | "value": { 4 | "uid": "plugin::users-permissions.role", 5 | "settings": { 6 | "bulkable": true, 7 | "filterable": true, 8 | "searchable": true, 9 | "pageSize": 10, 10 | "mainField": "name", 11 | "defaultSortBy": "name", 12 | "defaultSortOrder": "ASC" 13 | }, 14 | "metadatas": { 15 | "id": { 16 | "edit": {}, 17 | "list": { 18 | "label": "id", 19 | "searchable": true, 20 | "sortable": true 21 | } 22 | }, 23 | "name": { 24 | "edit": { 25 | "label": "name", 26 | "description": "", 27 | "placeholder": "", 28 | "visible": true, 29 | "editable": true 30 | }, 31 | "list": { 32 | "label": "name", 33 | "searchable": true, 34 | "sortable": true 35 | } 36 | }, 37 | "description": { 38 | "edit": { 39 | "label": "description", 40 | "description": "", 41 | "placeholder": "", 42 | "visible": true, 43 | "editable": true 44 | }, 45 | "list": { 46 | "label": "description", 47 | "searchable": true, 48 | "sortable": true 49 | } 50 | }, 51 | "type": { 52 | "edit": { 53 | "label": "type", 54 | "description": "", 55 | "placeholder": "", 56 | "visible": true, 57 | "editable": true 58 | }, 59 | "list": { 60 | "label": "type", 61 | "searchable": true, 62 | "sortable": true 63 | } 64 | }, 65 | "permissions": { 66 | "edit": { 67 | "label": "permissions", 68 | "description": "", 69 | "placeholder": "", 70 | "visible": true, 71 | "editable": true, 72 | "mainField": "action" 73 | }, 74 | "list": { 75 | "label": "permissions", 76 | "searchable": false, 77 | "sortable": false 78 | } 79 | }, 80 | "users": { 81 | "edit": { 82 | "label": "users", 83 | "description": "", 84 | "placeholder": "", 85 | "visible": true, 86 | "editable": true, 87 | "mainField": "username" 88 | }, 89 | "list": { 90 | "label": "users", 91 | "searchable": false, 92 | "sortable": false 93 | } 94 | }, 95 | "createdAt": { 96 | "edit": { 97 | "label": "createdAt", 98 | "description": "", 99 | "placeholder": "", 100 | "visible": false, 101 | "editable": true 102 | }, 103 | "list": { 104 | "label": "createdAt", 105 | "searchable": true, 106 | "sortable": true 107 | } 108 | }, 109 | "updatedAt": { 110 | "edit": { 111 | "label": "updatedAt", 112 | "description": "", 113 | "placeholder": "", 114 | "visible": false, 115 | "editable": true 116 | }, 117 | "list": { 118 | "label": "updatedAt", 119 | "searchable": true, 120 | "sortable": true 121 | } 122 | }, 123 | "createdBy": { 124 | "edit": { 125 | "label": "createdBy", 126 | "description": "", 127 | "placeholder": "", 128 | "visible": false, 129 | "editable": true, 130 | "mainField": "firstname" 131 | }, 132 | "list": { 133 | "label": "createdBy", 134 | "searchable": true, 135 | "sortable": true 136 | } 137 | }, 138 | "updatedBy": { 139 | "edit": { 140 | "label": "updatedBy", 141 | "description": "", 142 | "placeholder": "", 143 | "visible": false, 144 | "editable": true, 145 | "mainField": "firstname" 146 | }, 147 | "list": { 148 | "label": "updatedBy", 149 | "searchable": true, 150 | "sortable": true 151 | } 152 | } 153 | }, 154 | "layouts": { 155 | "list": ["id", "name", "description", "type"], 156 | "edit": [ 157 | [ 158 | { 159 | "name": "name", 160 | "size": 6 161 | }, 162 | { 163 | "name": "description", 164 | "size": 6 165 | } 166 | ], 167 | [ 168 | { 169 | "name": "type", 170 | "size": 6 171 | }, 172 | { 173 | "name": "permissions", 174 | "size": 6 175 | } 176 | ], 177 | [ 178 | { 179 | "name": "users", 180 | "size": 6 181 | } 182 | ] 183 | ] 184 | } 185 | }, 186 | "type": "object", 187 | "environment": null, 188 | "tag": null 189 | } 190 | -------------------------------------------------------------------------------- /apps/backend/config/sync/core-store.plugin_documentation_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "plugin_documentation_config", 3 | "value": { 4 | "restrictedAccess": false 5 | }, 6 | "type": "object", 7 | "environment": null, 8 | "tag": null 9 | } 10 | -------------------------------------------------------------------------------- /apps/backend/config/sync/core-store.plugin_i18n_default_locale.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "plugin_i18n_default_locale", 3 | "value": "en", 4 | "type": "string", 5 | "environment": null, 6 | "tag": null 7 | } 8 | -------------------------------------------------------------------------------- /apps/backend/config/sync/core-store.plugin_upload_settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "plugin_upload_settings", 3 | "value": { 4 | "sizeOptimization": true, 5 | "responsiveDimensions": true, 6 | "autoOrientation": false 7 | }, 8 | "type": "object", 9 | "environment": null, 10 | "tag": null 11 | } 12 | -------------------------------------------------------------------------------- /apps/backend/config/sync/core-store.plugin_upload_view_configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "plugin_upload_view_configuration", 3 | "value": { 4 | "pageSize": 10, 5 | "sort": "createdAt:DESC" 6 | }, 7 | "type": "object", 8 | "environment": null, 9 | "tag": null 10 | } 11 | -------------------------------------------------------------------------------- /apps/backend/config/sync/core-store.plugin_users-permissions_advanced.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "plugin_users-permissions_advanced", 3 | "value": { 4 | "unique_email": true, 5 | "allow_register": false, 6 | "email_confirmation": false, 7 | "email_reset_password": null, 8 | "email_confirmation_redirection": "", 9 | "default_role": "contributor" 10 | }, 11 | "type": "object", 12 | "environment": null, 13 | "tag": null 14 | } 15 | -------------------------------------------------------------------------------- /apps/backend/config/sync/core-store.plugin_users-permissions_email.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "plugin_users-permissions_email", 3 | "value": { 4 | "reset_password": { 5 | "display": "Email.template.reset_password", 6 | "icon": "sync", 7 | "options": { 8 | "from": { 9 | "name": "Administration Panel", 10 | "email": "no-reply@strapi.io" 11 | }, 12 | "response_email": "", 13 | "object": "Reset password", 14 | "message": "

We heard that you lost your password. Sorry about that!

\n\n

But don’t worry! You can use the following link to reset your password:

\n

<%= URL %>?code=<%= TOKEN %>

\n\n

Thanks.

" 15 | } 16 | }, 17 | "email_confirmation": { 18 | "display": "Email.template.email_confirmation", 19 | "icon": "check-square", 20 | "options": { 21 | "from": { 22 | "name": "Administration Panel", 23 | "email": "no-reply@strapi.io" 24 | }, 25 | "response_email": "", 26 | "object": "Account confirmation", 27 | "message": "

Thank you for registering!

\n\n

You have to confirm your email address. Please click on the link below.

\n\n

<%= URL %>?confirmation=<%= CODE %>

\n\n

Thanks.

" 28 | } 29 | } 30 | }, 31 | "type": "object", 32 | "environment": null, 33 | "tag": null 34 | } 35 | -------------------------------------------------------------------------------- /apps/backend/config/sync/i18n-locale.en.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "English (en)", 3 | "code": "en" 4 | } 5 | -------------------------------------------------------------------------------- /apps/backend/config/sync/user-role.authenticated.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Editor", 3 | "description": "Editors", 4 | "type": "authenticated", 5 | "permissions": [ 6 | { 7 | "action": "api::post.post.create" 8 | }, 9 | { 10 | "action": "api::post.post.delete" 11 | }, 12 | { 13 | "action": "api::post.post.find" 14 | }, 15 | { 16 | "action": "api::post.post.findOne" 17 | }, 18 | { 19 | "action": "api::post.post.findOneBySlugId" 20 | }, 21 | { 22 | "action": "api::post.post.publish" 23 | }, 24 | { 25 | "action": "api::post.post.schedule" 26 | }, 27 | { 28 | "action": "api::post.post.unpublish" 29 | }, 30 | { 31 | "action": "api::post.post.update" 32 | }, 33 | { 34 | "action": "api::tag.tag.create" 35 | }, 36 | { 37 | "action": "api::tag.tag.delete" 38 | }, 39 | { 40 | "action": "api::tag.tag.find" 41 | }, 42 | { 43 | "action": "api::tag.tag.findOne" 44 | }, 45 | { 46 | "action": "api::tag.tag.update" 47 | }, 48 | { 49 | "action": "plugin::upload.content-api.destroy" 50 | }, 51 | { 52 | "action": "plugin::upload.content-api.find" 53 | }, 54 | { 55 | "action": "plugin::upload.content-api.findOne" 56 | }, 57 | { 58 | "action": "plugin::upload.content-api.upload" 59 | }, 60 | { 61 | "action": "plugin::users-permissions.auth.acceptInvitation" 62 | }, 63 | { 64 | "action": "plugin::users-permissions.auth.changePassword" 65 | }, 66 | { 67 | "action": "plugin::users-permissions.auth.invitation" 68 | }, 69 | { 70 | "action": "plugin::users-permissions.role.find" 71 | }, 72 | { 73 | "action": "plugin::users-permissions.user.create" 74 | }, 75 | { 76 | "action": "plugin::users-permissions.user.destroy" 77 | }, 78 | { 79 | "action": "plugin::users-permissions.user.find" 80 | }, 81 | { 82 | "action": "plugin::users-permissions.user.findOne" 83 | }, 84 | { 85 | "action": "plugin::users-permissions.user.me" 86 | }, 87 | { 88 | "action": "plugin::users-permissions.user.update" 89 | }, 90 | { 91 | "action": "plugin::users-permissions.user.updateMe" 92 | } 93 | ] 94 | } 95 | -------------------------------------------------------------------------------- /apps/backend/config/sync/user-role.contributor.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Contributor", 3 | "description": "Authors and Translators", 4 | "type": "contributor", 5 | "permissions": [ 6 | { 7 | "action": "api::post.post.create" 8 | }, 9 | { 10 | "action": "api::post.post.delete" 11 | }, 12 | { 13 | "action": "api::post.post.find" 14 | }, 15 | { 16 | "action": "api::post.post.findOne" 17 | }, 18 | { 19 | "action": "api::post.post.findOneBySlugId" 20 | }, 21 | { 22 | "action": "api::post.post.update" 23 | }, 24 | { 25 | "action": "api::tag.tag.find" 26 | }, 27 | { 28 | "action": "api::tag.tag.findOne" 29 | }, 30 | { 31 | "action": "plugin::upload.content-api.destroy" 32 | }, 33 | { 34 | "action": "plugin::upload.content-api.find" 35 | }, 36 | { 37 | "action": "plugin::upload.content-api.findOne" 38 | }, 39 | { 40 | "action": "plugin::upload.content-api.upload" 41 | }, 42 | { 43 | "action": "plugin::users-permissions.auth.acceptInvitation" 44 | }, 45 | { 46 | "action": "plugin::users-permissions.role.find" 47 | }, 48 | { 49 | "action": "plugin::users-permissions.user.find" 50 | }, 51 | { 52 | "action": "plugin::users-permissions.user.findOne" 53 | }, 54 | { 55 | "action": "plugin::users-permissions.user.me" 56 | }, 57 | { 58 | "action": "plugin::users-permissions.user.updateMe" 59 | } 60 | ] 61 | } 62 | -------------------------------------------------------------------------------- /apps/backend/config/sync/user-role.public.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Public", 3 | "description": "Default role given to unauthenticated user (unauthenticated API calls).", 4 | "type": "public", 5 | "permissions": [ 6 | { 7 | "action": "plugin::users-permissions.auth.callback" 8 | }, 9 | { 10 | "action": "plugin::users-permissions.auth.connect" 11 | }, 12 | { 13 | "action": "plugin::users-permissions.auth.emailConfirmation" 14 | }, 15 | { 16 | "action": "plugin::users-permissions.auth.forgotPassword" 17 | }, 18 | { 19 | "action": "plugin::users-permissions.auth.register" 20 | }, 21 | { 22 | "action": "plugin::users-permissions.auth.resetPassword" 23 | }, 24 | { 25 | "action": "plugin::users-permissions.auth.sendEmailConfirmation" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /apps/backend/database/migrations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/publish/3b34d0108e7b87932b43ed5969ad028097d56447/apps/backend/database/migrations/.gitkeep -------------------------------------------------------------------------------- /apps/backend/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | strapi: 4 | container_name: strapi-development 5 | build: . 6 | image: strapi:latest 7 | restart: unless-stopped 8 | env_file: .env 9 | environment: 10 | DATABASE_CLIENT: ${DATABASE_CLIENT} 11 | DATABASE_HOST: strapiDB 12 | DATABASE_PORT: ${DATABASE_PORT} 13 | DATABASE_NAME: ${DATABASE_NAME} 14 | DATABASE_USERNAME: ${DATABASE_USERNAME} 15 | DATABASE_PASSWORD: ${DATABASE_PASSWORD} 16 | JWT_SECRET: ${JWT_SECRET} 17 | ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET} 18 | APP_KEYS: ${APP_KEYS} 19 | NODE_ENV: ${NODE_ENV} 20 | DASHBOARD_URL: ${DASHBOARD_URL} 21 | NODEMAILER_HOST: mailhog 22 | volumes: 23 | - ./config:/opt/app/config 24 | - ./src:/opt/app/src 25 | - ./package.json:/opt/package.json 26 | - ./package-lock.json:/opt/package-lock.json 27 | - ./.env:/opt/app/.env 28 | - ./public/uploads:/opt/app/public/uploads 29 | ports: 30 | - "1337:1337" 31 | networks: 32 | - strapi 33 | depends_on: 34 | - strapiDB 35 | - mailhog 36 | 37 | strapiDB: 38 | container_name: strapiDB 39 | platform: linux/amd64 #for platform error on Apple M1 chips 40 | restart: unless-stopped 41 | env_file: .env 42 | image: postgres:14-alpine 43 | environment: 44 | POSTGRES_USER: ${DATABASE_USERNAME} 45 | POSTGRES_PASSWORD: ${DATABASE_PASSWORD} 46 | POSTGRES_DB: ${DATABASE_NAME} 47 | volumes: 48 | - strapi-data:/var/lib/postgresql/data/ #using a volume 49 | # - ./data:/var/lib/postgresql/data/ # if you want to use a bind folder 50 | 51 | ports: 52 | - "5432:5432" 53 | networks: 54 | - strapi 55 | 56 | mailhog: 57 | image: mailhog/mailhog 58 | container_name: "mailhog" 59 | platform: linux/amd64 60 | logging: 61 | driver: none 62 | ports: 63 | - "1025:1025" 64 | - "8025:8025" 65 | networks: 66 | - strapi 67 | 68 | volumes: 69 | strapi-data: 70 | 71 | networks: 72 | strapi: 73 | name: Strapi 74 | driver: bridge 75 | -------------------------------------------------------------------------------- /apps/backend/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/publish/3b34d0108e7b87932b43ed5969ad028097d56447/apps/backend/favicon.png -------------------------------------------------------------------------------- /apps/backend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testPathIgnorePatterns: ["/node_modules/", ".tmp", ".cache"], 3 | testEnvironment: "node", 4 | testTimeout: 15000, 5 | }; 6 | -------------------------------------------------------------------------------- /apps/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "private": true, 4 | "version": "0.1.0", 5 | "description": "A Strapi application", 6 | "scripts": { 7 | "develop": "strapi develop", 8 | "start": "strapi start", 9 | "build": "strapi build", 10 | "eslint": "eslint .", 11 | "strapi": "strapi", 12 | "seed": "config-sync import --yes && node ./scripts/seed.js", 13 | "cs": "config-sync", 14 | "init": "shx cp -n sample.env .env", 15 | "test": "jest --forceExit", 16 | "test-verbose": "jest --forceExit --detectOpenHandles --verbose" 17 | }, 18 | "dependencies": { 19 | "@strapi/plugin-documentation": "^4.11.5", 20 | "@strapi/plugin-i18n": "4.15.5", 21 | "@strapi/plugin-users-permissions": "4.15.5", 22 | "@strapi/provider-email-nodemailer": "^4.12.4", 23 | "@strapi/strapi": "4.15.5", 24 | "nanoid": "^3.3.6", 25 | "pg": "^8.11.3", 26 | "react": "^18.2.0", 27 | "react-dom": "^18.2.0", 28 | "react-router-dom": "^5.3.4", 29 | "strapi-plugin-config-sync": "^1.1.2", 30 | "styled-components": "5.3.11" 31 | }, 32 | "author": { 33 | "name": "A Strapi developer" 34 | }, 35 | "strapi": { 36 | "uuid": "1efcc92e-6a66-407d-94c3-476c26671efd" 37 | }, 38 | "license": "MIT", 39 | "devDependencies": { 40 | "@faker-js/faker": "^8.2.0", 41 | "better-sqlite3": "8.7.0", 42 | "eslint": "^8.49.0", 43 | "eslint-config-prettier": "^9.0.0", 44 | "eslint-plugin-jest": "^27.2.3", 45 | "jest": "^29.6.4", 46 | "prettier": "^3.0.3", 47 | "shx": "^0.3.4", 48 | "supertest": "^6.3.3" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /apps/backend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # To prevent search engines from seeing the site altogether, uncomment the next two lines: 2 | # User-Agent: * 3 | # Disallow: / 4 | -------------------------------------------------------------------------------- /apps/backend/public/uploads/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/publish/3b34d0108e7b87932b43ed5969ad028097d56447/apps/backend/public/uploads/.gitkeep -------------------------------------------------------------------------------- /apps/backend/sample.env: -------------------------------------------------------------------------------- 1 | # Optional in production 2 | HOST=localhost 3 | PORT=1337 4 | 5 | # Secret keys 6 | # What each value is: https://docs.strapi.io/dev-docs/installation/docker#development-dockerfile 7 | # How to generate them: https://docs.strapi.io/dev-docs/migration/v4/migration-guide-4.0.6-to-4.1.8#setting-secrets-for-non-development-environments 8 | # In short, generate each value with `openssl rand -base64 32` command 9 | APP_KEYS="toBeModified1,toBeModified2" 10 | API_TOKEN_SALT=tobemodified 11 | ADMIN_JWT_SECRET=tobemodified 12 | TRANSFER_TOKEN_SALT=tobemodified 13 | JWT_SECRET=tobemodified 14 | 15 | # Database (Postgres) 16 | DATABASE_HOST=localhost 17 | DATABASE_PORT=5432 18 | DATABASE_NAME=strapi 19 | DATABASE_USERNAME=strapi 20 | DATABASE_PASSWORD=password 21 | DATABASE_CLIENT=postgres 22 | # true in production: 23 | DATABASE_SSL= 24 | # true in production: 25 | DATABASE_SSL_REJECT_UNAUTHORIZED= 26 | # CA certificate value, e.g. ${db.CA_CERT} in DigitalOcean App Platform 27 | DATABASE_SSL_CA= 28 | 29 | # Environment 30 | NODE_ENV=development 31 | 32 | # URL of the frontend app: 33 | DASHBOARD_URL=http://localhost:3000 34 | 35 | # Email 36 | # Required in production: 37 | AWS_SES_KEY= 38 | AWS_SES_SECRET= 39 | AWS_SES_HOST= 40 | 41 | # Data migration 42 | DATA_MIGRATION=false 43 | -------------------------------------------------------------------------------- /apps/backend/scripts/seed.js: -------------------------------------------------------------------------------- 1 | const strapi = require("@strapi/strapi"); 2 | const { generateSeedData } = require("../src/seed"); 3 | 4 | const seed = async () => { 5 | const app = await strapi().load(); 6 | if (process.env.NODE_ENV !== "development") { 7 | console.log("Seeding the database is only allowed in development mode."); 8 | process.exit(1); 9 | } 10 | console.log("Seeding database..."); 11 | // SEEDING_DATA is set to prevent the sending of emails during seeding. 12 | global.SEEDING_DATA = "true"; 13 | await generateSeedData(app); 14 | console.log("Seeding database complete!"); 15 | process.exit(0); 16 | }; 17 | 18 | seed(); 19 | -------------------------------------------------------------------------------- /apps/backend/src/admin/app.example.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | locales: [ 3 | // 'ar', 4 | // 'fr', 5 | // 'cs', 6 | // 'de', 7 | // 'dk', 8 | // 'es', 9 | // 'he', 10 | // 'id', 11 | // 'it', 12 | // 'ja', 13 | // 'ko', 14 | // 'ms', 15 | // 'nl', 16 | // 'no', 17 | // 'pl', 18 | // 'pt-BR', 19 | // 'pt', 20 | // 'ru', 21 | // 'sk', 22 | // 'sv', 23 | // 'th', 24 | // 'tr', 25 | // 'uk', 26 | // 'vi', 27 | // 'zh-Hans', 28 | // 'zh', 29 | ], 30 | }; 31 | 32 | const bootstrap = (app) => { 33 | console.log(app); 34 | }; 35 | 36 | export default { 37 | config, 38 | bootstrap, 39 | }; 40 | -------------------------------------------------------------------------------- /apps/backend/src/admin/webpack.config.example.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* eslint-disable no-unused-vars */ 4 | module.exports = (config, webpack) => { 5 | // Note: we provide webpack above so you should not `require` it 6 | // Perform customizations to webpack config 7 | // Important: return the modified config 8 | return config; 9 | }; 10 | -------------------------------------------------------------------------------- /apps/backend/src/api/helpers/services/helpers.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * helpers service 5 | */ 6 | 7 | module.exports = () => ({ 8 | isEditor(ctx) { 9 | if (process.env.DATA_MIGRATION === "true") { 10 | return true; 11 | } 12 | return ctx.state?.user?.role?.name === "Editor"; 13 | }, 14 | isAPIToken(ctx) { 15 | // Checks if the current request is using an API Token instead of users-permissions login 16 | return ctx.state?.auth?.strategy?.name === "api-token"; 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /apps/backend/src/api/post/content-types/post/lifecycles.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | beforeCreate(event) { 3 | const { 4 | params: { data }, 5 | } = event; 6 | if (data.publishedAt) { 7 | strapi 8 | .service("api::post.post") 9 | .validatePublishedAt(new Date(data.publishedAt)); 10 | } 11 | // auto generate slug_id 12 | data.slug_id = strapi.service("api::post.post").generateSlugId(); 13 | }, 14 | beforeUpdate(event) { 15 | const { 16 | params: { data }, 17 | } = event; 18 | if (data.publishedAt) { 19 | strapi 20 | .service("api::post.post") 21 | .validatePublishedAt(new Date(data.publishedAt)); 22 | } 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /apps/backend/src/api/post/content-types/post/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "collectionType", 3 | "collectionName": "posts", 4 | "info": { 5 | "singularName": "post", 6 | "pluralName": "posts", 7 | "displayName": "post", 8 | "description": "" 9 | }, 10 | "options": { 11 | "draftAndPublish": true 12 | }, 13 | "pluginOptions": { 14 | "i18n": { 15 | "localized": true 16 | } 17 | }, 18 | "attributes": { 19 | "title": { 20 | "type": "string", 21 | "required": true, 22 | "maxLength": 200, 23 | "pluginOptions": { 24 | "i18n": { 25 | "localized": true 26 | } 27 | } 28 | }, 29 | "slug": { 30 | "pluginOptions": { 31 | "i18n": { 32 | "localized": true 33 | } 34 | }, 35 | "type": "string", 36 | "unique": true, 37 | "maxLength": 200 38 | }, 39 | "feature_image": { 40 | "type": "media", 41 | "multiple": false, 42 | "required": false, 43 | "allowedTypes": ["images"], 44 | "pluginOptions": { 45 | "i18n": { 46 | "localized": false 47 | } 48 | } 49 | }, 50 | "body": { 51 | "pluginOptions": { 52 | "i18n": { 53 | "localized": true 54 | } 55 | }, 56 | "type": "richtext" 57 | }, 58 | "excerpt": { 59 | "pluginOptions": { 60 | "i18n": { 61 | "localized": true 62 | } 63 | }, 64 | "type": "text" 65 | }, 66 | "tags": { 67 | "type": "relation", 68 | "relation": "manyToMany", 69 | "target": "api::tag.tag", 70 | "inversedBy": "posts" 71 | }, 72 | "author": { 73 | "type": "relation", 74 | "relation": "manyToOne", 75 | "target": "plugin::users-permissions.user", 76 | "inversedBy": "posts" 77 | }, 78 | "scheduled_at": { 79 | "pluginOptions": { 80 | "i18n": { 81 | "localized": true 82 | } 83 | }, 84 | "type": "datetime" 85 | }, 86 | "ghost_id": { 87 | "pluginOptions": { 88 | "i18n": { 89 | "localized": true 90 | } 91 | }, 92 | "type": "string" 93 | }, 94 | "codeinjection_head": { 95 | "pluginOptions": { 96 | "i18n": { 97 | "localized": true 98 | } 99 | }, 100 | "type": "text" 101 | }, 102 | "codeinjection_foot": { 103 | "pluginOptions": { 104 | "i18n": { 105 | "localized": true 106 | } 107 | }, 108 | "type": "text" 109 | }, 110 | "slug_id": { 111 | "pluginOptions": { 112 | "i18n": { 113 | "localized": true 114 | } 115 | }, 116 | "type": "string", 117 | "maxLength": 8, 118 | "unique": true 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /apps/backend/src/api/post/controllers/post.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const { ValidationError } = require("@strapi/utils").errors; 3 | 4 | /** 5 | * post controller 6 | */ 7 | 8 | const { createCoreController } = require("@strapi/strapi").factories; 9 | 10 | module.exports = createCoreController("api::post.post", ({ strapi }) => { 11 | const helpers = strapi.service("api::helpers.helpers"); 12 | 13 | return { 14 | async find(ctx) { 15 | if (helpers.isEditor(ctx) || helpers.isAPIToken(ctx)) { 16 | // allow access to all posts 17 | return await super.find(ctx); 18 | } else { 19 | // return only current user's posts 20 | const filters = ctx.query.filters || {}; 21 | if (filters.author) { 22 | delete filters.author; 23 | } 24 | filters.author = [ctx.state.user.id]; 25 | ctx.query.filters = filters; 26 | // call the default core action with modified ctx 27 | return await super.find(ctx); 28 | } 29 | }, 30 | async findOneBySlugId(ctx) { 31 | try { 32 | // find id from slug_id 33 | const postId = await strapi 34 | .service("api::post.post") 35 | .findIdBySlugId(ctx.request.params.slug_id); 36 | 37 | ctx.request.params.id = postId; 38 | 39 | // pass it onto default findOne controller 40 | return await super.findOne(ctx); 41 | } catch (err) { 42 | console.error(err); 43 | ctx.body = err; 44 | } 45 | }, 46 | async create(ctx) { 47 | if (!helpers.isEditor(ctx)) { 48 | // don't allow publishing or scheduling posts 49 | delete ctx.request.body.data.publishedAt; 50 | delete ctx.request.body.data.scheduled_at; 51 | 52 | // don't allow code injection 53 | delete ctx.request.body.data.codeinjection_head; 54 | delete ctx.request.body.data.codeinjection_foot; 55 | 56 | // force set author to current user 57 | delete ctx.request.body.data.author; 58 | ctx.request.body.data.author = [ctx.state.user.id]; 59 | } 60 | 61 | // call the default core action with modified data 62 | try { 63 | return await super.create(ctx); 64 | } catch (err) { 65 | console.error(err); 66 | // TODO: DRY out error handling. 67 | const isValidationError = err instanceof ValidationError; 68 | if (isValidationError) { 69 | ctx.throw(400, err); 70 | } else { 71 | ctx.throw(err); 72 | } 73 | } 74 | }, 75 | async update(ctx) { 76 | if (!helpers.isEditor(ctx)) { 77 | // don't allow publishing or scheduling posts 78 | delete ctx.request.body.data.publishedAt; 79 | delete ctx.request.body.data.scheduled_at; 80 | 81 | // don't allow code injection 82 | delete ctx.request.body.data.codeinjection_head; 83 | delete ctx.request.body.data.codeinjection_foot; 84 | 85 | // don't allow changing author 86 | delete ctx.request.body.data.author; 87 | } 88 | 89 | // prevent updating the slug ID 90 | delete ctx.request.body.data.slug_id; 91 | 92 | // call the default core action with modified data 93 | try { 94 | return await super.update(ctx); 95 | } catch (err) { 96 | console.error(err); 97 | // TODO: DRY out error handling. 98 | const isValidationError = err instanceof ValidationError; 99 | if (isValidationError) { 100 | ctx.throw(400, err); 101 | } else { 102 | ctx.throw(err); 103 | } 104 | } 105 | }, 106 | async schedule(ctx) { 107 | try { 108 | const response = await strapi 109 | .service("api::post.post") 110 | .schedule(ctx.request.params.id, ctx.request.body); 111 | 112 | // this.transformResponse in controller transforms the response object 113 | // into { data: { id: 1, attributes: {...} } } format 114 | // to comply with other default endpoints 115 | ctx.body = this.transformResponse(response); 116 | } catch (err) { 117 | console.error(err); 118 | ctx.body = err; 119 | } 120 | }, 121 | async publish(ctx) { 122 | try { 123 | const response = await strapi 124 | .service("api::post.post") 125 | .publish(ctx.request.params.id); 126 | ctx.body = this.transformResponse(response); 127 | } catch (err) { 128 | console.error(err); 129 | ctx.body = err; 130 | } 131 | }, 132 | async unpublish(ctx) { 133 | try { 134 | const response = await strapi 135 | .service("api::post.post") 136 | .unpublish(ctx.request.params.id); 137 | ctx.body = this.transformResponse(response); 138 | } catch (err) { 139 | console.error(err); 140 | ctx.body = err; 141 | } 142 | }, 143 | async checkAndPublish(ctx) { 144 | try { 145 | const draftPostToPublish = await strapi.entityService.findMany( 146 | "api::post.post", 147 | { 148 | filters: { 149 | publishedAt: { 150 | $null: true, 151 | }, 152 | scheduled_at: { 153 | $lt: new Date(), 154 | }, 155 | }, 156 | }, 157 | ); 158 | 159 | await Promise.all( 160 | draftPostToPublish.map((post) => { 161 | return strapi.service("api::post.post").publish(post.id); 162 | }), 163 | ); 164 | 165 | const response = { 166 | count: draftPostToPublish.length, 167 | data: draftPostToPublish.map((post) => post.title), 168 | }; 169 | 170 | ctx.body = this.transformResponse(response); 171 | } catch (err) { 172 | console.error(err); 173 | ctx.body = err; 174 | } 175 | }, 176 | }; 177 | }); 178 | -------------------------------------------------------------------------------- /apps/backend/src/api/post/policies/is-own-post-slug-id.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * `is-own-post-slug-id` policy 5 | */ 6 | 7 | module.exports = async (policyContext, config, { strapi }) => { 8 | const helpers = strapi.service("api::helpers.helpers"); 9 | 10 | // Editors can access any posts 11 | if (helpers.isEditor(policyContext)) { 12 | return true; 13 | } 14 | 15 | // Contributors can only access their own posts 16 | try { 17 | // find author id from slug_id 18 | const posts = await strapi.entityService.findMany("api::post.post", { 19 | filters: { slug_id: policyContext.params.slug_id }, 20 | fields: ["id"], 21 | populate: ["author"], 22 | }); 23 | 24 | if (posts[0].author.id !== policyContext.state.user.id) { 25 | return false; 26 | } 27 | } catch (err) { 28 | strapi.log.error("Error in is-own-post-slug-id policy."); 29 | strapi.log.error(err); 30 | return false; 31 | } 32 | 33 | return true; 34 | }; 35 | -------------------------------------------------------------------------------- /apps/backend/src/api/post/policies/is-own-post.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * `is-own-post` policy 5 | */ 6 | 7 | module.exports = async (policyContext, config, { strapi }) => { 8 | const helpers = strapi.service("api::helpers.helpers"); 9 | 10 | // Editors can access any posts 11 | if (helpers.isEditor(policyContext)) { 12 | return true; 13 | } 14 | 15 | // Contributors can only access their own posts 16 | try { 17 | const post = await strapi.entityService.findOne( 18 | "api::post.post", 19 | policyContext.params.id, 20 | { 21 | populate: ["author"], 22 | }, 23 | ); 24 | if (post.author.id !== policyContext.state.user.id) { 25 | return false; 26 | } 27 | } catch (err) { 28 | strapi.log.error("Error in is-own-post policy."); 29 | strapi.log.error(err); 30 | return false; 31 | } 32 | 33 | return true; 34 | }; 35 | -------------------------------------------------------------------------------- /apps/backend/src/api/post/routes/0-post.js: -------------------------------------------------------------------------------- 1 | // Routes files are loaded in alphabetical order. 2 | // Using the filename starting with "0-" to load custom routes before core routes. 3 | 4 | // Custom routes 5 | module.exports = { 6 | routes: [ 7 | { 8 | method: "GET", 9 | path: "/posts/slug_id/:slug_id", 10 | handler: "post.findOneBySlugId", 11 | config: { 12 | policies: ["is-own-post-slug-id"], 13 | middlewares: [], 14 | }, 15 | }, 16 | { 17 | method: "PATCH", 18 | path: "/posts/:id/schedule", 19 | handler: "post.schedule", 20 | config: { 21 | policies: [], 22 | middlewares: [], 23 | }, 24 | }, 25 | { 26 | method: "PATCH", 27 | path: "/posts/:id/publish", 28 | handler: "post.publish", 29 | config: { 30 | policies: [], 31 | middlewares: [], 32 | }, 33 | }, 34 | { 35 | method: "PATCH", 36 | path: "/posts/:id/unpublish", 37 | handler: "post.unpublish", 38 | config: { 39 | policies: [], 40 | middlewares: [], 41 | }, 42 | }, 43 | { 44 | method: "GET", 45 | path: "/posts/check-and-publish", 46 | handler: "post.checkAndPublish", 47 | config: { 48 | policies: [], 49 | middlewares: [], 50 | }, 51 | }, 52 | ], 53 | }; 54 | -------------------------------------------------------------------------------- /apps/backend/src/api/post/routes/post.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * post router 5 | */ 6 | 7 | const { createCoreRouter } = require("@strapi/strapi").factories; 8 | 9 | // Core routes 10 | module.exports = createCoreRouter("api::post.post", { 11 | config: { 12 | findOne: { 13 | policies: ["is-own-post"], 14 | }, 15 | update: { 16 | policies: ["is-own-post"], 17 | }, 18 | delete: { 19 | policies: ["is-own-post"], 20 | }, 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /apps/backend/src/api/post/services/post.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { ValidationError } = require("@strapi/utils").errors; 4 | const { customAlphabet } = require("nanoid"); 5 | 6 | /** 7 | * post service 8 | */ 9 | 10 | const { createCoreService } = require("@strapi/strapi").factories; 11 | 12 | module.exports = createCoreService("api::post.post", ({ strapi }) => ({ 13 | // finds id from slug_id 14 | // returns null if not found 15 | async findIdBySlugId(slug_id) { 16 | // Have to use findMany instead of fineOne to search by slug_id 17 | const postIds = await strapi.entityService.findMany("api::post.post", { 18 | filters: { slug_id: slug_id }, 19 | fields: ["id"], 20 | }); 21 | return postIds.length > 0 ? postIds[0].id : null; 22 | }, 23 | 24 | async create(reqBody = {}) { 25 | if (process.env.DATA_MIGRATION === "true") { 26 | reqBody.data.createdAt = reqBody.data.created_at; 27 | reqBody.data.updatedAt = reqBody.data.updated_at; 28 | delete reqBody.data.created_at; 29 | delete reqBody.data.updated_at; 30 | } 31 | return strapi.entityService.create("api::post.post", reqBody); 32 | }, 33 | 34 | async update(postId, reqBody = {}) { 35 | return strapi.entityService.update("api::post.post", postId, reqBody); 36 | }, 37 | 38 | async schedule(postId, reqBody = {}) { 39 | // Extract the scheduled_at field from the reqBody object 40 | const { scheduled_at } = reqBody.data; 41 | // update only the scheduled_at field 42 | return strapi.entityService.update("api::post.post", postId, { 43 | data: { scheduled_at }, 44 | }); 45 | }, 46 | 47 | async publish(postId) { 48 | // update only the publishedAt field 49 | return strapi.entityService.update("api::post.post", postId, { 50 | data: { publishedAt: new Date() }, 51 | }); 52 | }, 53 | 54 | async unpublish(postId) { 55 | // update only the publishedAt field 56 | return strapi.entityService.update("api::post.post", postId, { 57 | data: { publishedAt: null }, 58 | }); 59 | }, 60 | 61 | validatePublishedAt(publishedAt) { 62 | if (publishedAt > new Date()) { 63 | throw new ValidationError("publishedAt must be a past date"); 64 | } 65 | return true; 66 | }, 67 | 68 | generateSlugId() { 69 | // generate random 8 characters ID 70 | const characterSet = "0123456789abcdefghijklmnopqrstuvwxyz"; 71 | const nanoid = customAlphabet(characterSet, 8); 72 | return nanoid(); 73 | }, 74 | })); 75 | -------------------------------------------------------------------------------- /apps/backend/src/api/tag/content-types/tag/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "collectionType", 3 | "collectionName": "tags", 4 | "info": { 5 | "singularName": "tag", 6 | "pluralName": "tags", 7 | "displayName": "tag", 8 | "description": "" 9 | }, 10 | "options": { 11 | "draftAndPublish": false 12 | }, 13 | "pluginOptions": {}, 14 | "attributes": { 15 | "name": { 16 | "type": "string" 17 | }, 18 | "slug": { 19 | "type": "string", 20 | "unique": true 21 | }, 22 | "posts": { 23 | "type": "relation", 24 | "relation": "manyToMany", 25 | "target": "api::post.post", 26 | "mappedBy": "tags" 27 | }, 28 | "visibility": { 29 | "type": "enumeration", 30 | "enum": ["public", "internal"], 31 | "default": "public", 32 | "required": true 33 | }, 34 | "ghost_id": { 35 | "type": "string" 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/backend/src/api/tag/controllers/tag.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * tag controller 5 | */ 6 | 7 | const { createCoreController } = require("@strapi/strapi").factories; 8 | 9 | module.exports = createCoreController("api::tag.tag"); 10 | -------------------------------------------------------------------------------- /apps/backend/src/api/tag/routes/tag.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * tag router 5 | */ 6 | 7 | const { createCoreRouter } = require("@strapi/strapi").factories; 8 | 9 | module.exports = createCoreRouter("api::tag.tag"); 10 | -------------------------------------------------------------------------------- /apps/backend/src/api/tag/services/tag.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * tag service 5 | */ 6 | 7 | const { createCoreService } = require("@strapi/strapi").factories; 8 | 9 | module.exports = createCoreService("api::tag.tag"); 10 | -------------------------------------------------------------------------------- /apps/backend/src/extensions/users-permissions/content-types/role/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "collectionType", 3 | "collectionName": "up_roles", 4 | "info": { 5 | "name": "role", 6 | "description": "", 7 | "singularName": "role", 8 | "pluralName": "roles", 9 | "displayName": "Role" 10 | }, 11 | "pluginOptions": { 12 | "content-manager": { 13 | "visible": false 14 | }, 15 | "content-type-builder": { 16 | "visible": false 17 | } 18 | }, 19 | "attributes": { 20 | "name": { 21 | "type": "string", 22 | "minLength": 3, 23 | "required": true, 24 | "configurable": false 25 | }, 26 | "description": { 27 | "type": "string", 28 | "configurable": false 29 | }, 30 | "type": { 31 | "type": "string", 32 | "unique": true, 33 | "configurable": false 34 | }, 35 | "permissions": { 36 | "type": "relation", 37 | "relation": "oneToMany", 38 | "target": "plugin::users-permissions.permission", 39 | "mappedBy": "role", 40 | "configurable": false 41 | }, 42 | "users": { 43 | "type": "relation", 44 | "relation": "oneToMany", 45 | "target": "plugin::users-permissions.user", 46 | "mappedBy": "role", 47 | "configurable": false 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /apps/backend/src/extensions/users-permissions/content-types/user/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "collectionType", 3 | "collectionName": "up_users", 4 | "info": { 5 | "name": "user", 6 | "description": "", 7 | "singularName": "user", 8 | "pluralName": "users", 9 | "displayName": "User" 10 | }, 11 | "options": { 12 | "draftAndPublish": false 13 | }, 14 | "attributes": { 15 | "username": { 16 | "type": "string", 17 | "minLength": 3, 18 | "unique": true, 19 | "configurable": false, 20 | "required": true 21 | }, 22 | "email": { 23 | "type": "email", 24 | "minLength": 6, 25 | "unique": true, 26 | "configurable": false, 27 | "required": true 28 | }, 29 | "provider": { 30 | "type": "string", 31 | "configurable": false 32 | }, 33 | "password": { 34 | "type": "password", 35 | "minLength": 6, 36 | "configurable": false, 37 | "private": true, 38 | "searchable": false 39 | }, 40 | "resetPasswordToken": { 41 | "type": "string", 42 | "configurable": false, 43 | "private": true, 44 | "searchable": false 45 | }, 46 | "confirmationToken": { 47 | "type": "string", 48 | "configurable": false, 49 | "private": true, 50 | "searchable": false 51 | }, 52 | "confirmed": { 53 | "type": "boolean", 54 | "default": false, 55 | "configurable": false 56 | }, 57 | "blocked": { 58 | "type": "boolean", 59 | "default": false, 60 | "configurable": false 61 | }, 62 | "role": { 63 | "type": "relation", 64 | "relation": "manyToOne", 65 | "target": "plugin::users-permissions.role", 66 | "inversedBy": "users", 67 | "configurable": false 68 | }, 69 | "slug": { 70 | "type": "string", 71 | "unique": true, 72 | "required": false, 73 | "minLength": 1, 74 | "maxLength": 100 75 | }, 76 | "name": { 77 | "type": "string", 78 | "required": false, 79 | "unique": false, 80 | "maxLength": 100 81 | }, 82 | "profile_image": { 83 | "type": "media", 84 | "multiple": false, 85 | "required": false, 86 | "allowedTypes": ["images"] 87 | }, 88 | "bio": { 89 | "type": "text", 90 | "maxLength": 300 91 | }, 92 | "website": { 93 | "type": "string", 94 | "maxLength": 200 95 | }, 96 | "location": { 97 | "type": "string", 98 | "maxLength": 100 99 | }, 100 | "facebook": { 101 | "type": "string", 102 | "maxLength": 200 103 | }, 104 | "twitter": { 105 | "type": "string", 106 | "maxLength": 200 107 | }, 108 | "last_seen": { 109 | "type": "datetime" 110 | }, 111 | "posts": { 112 | "type": "relation", 113 | "relation": "oneToMany", 114 | "target": "api::post.post", 115 | "mappedBy": "author" 116 | }, 117 | "ghost_id": { 118 | "type": "string" 119 | }, 120 | "status": { 121 | "type": "enumeration", 122 | "enum": ["new", "invited", "active"], 123 | "required": true, 124 | "default": "new" 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /apps/backend/src/extensions/users-permissions/strapi-server.js: -------------------------------------------------------------------------------- 1 | const DASHBOARD_URL = process.env.DASHBOARD_URL ?? "http://localhost:3000"; 2 | 3 | module.exports = (plugin) => { 4 | plugin.controllers.user.updateMe = async (ctx) => { 5 | if (!ctx.state.user || !ctx.state.user.id) { 6 | return (ctx.response.status = 401); 7 | } 8 | 9 | await strapi 10 | .query("plugin::users-permissions.user") 11 | .update({ 12 | where: { id: ctx.state.user.id }, 13 | data: ctx.request.body, 14 | }) 15 | .then(() => { 16 | ctx.response.status = 200; 17 | ctx.response.body = { 18 | status: "success", 19 | }; 20 | }) 21 | .catch((err) => { 22 | ctx.response.status = 400; 23 | ctx.response.body = { 24 | status: "error", 25 | message: err.message, 26 | }; 27 | }); 28 | }; 29 | 30 | // TODO: find out if unshift is necessary or push will work. 31 | plugin.routes["content-api"].routes.unshift({ 32 | method: "PUT", 33 | path: "/users/me", 34 | handler: "user.updateMe", 35 | config: { 36 | prefix: "", 37 | policies: [], 38 | }, 39 | }); 40 | 41 | plugin.controllers.auth.invitation = async (ctx) => { 42 | if (!ctx.state.user || !ctx.state.user.id) { 43 | return (ctx.response.status = 401); 44 | } 45 | 46 | const { email } = await strapi 47 | .query("plugin::users-permissions.user") 48 | .update({ 49 | where: { id: ctx.request.params.id }, 50 | data: { 51 | provider: "auth0", 52 | password: null, 53 | status: "invited", 54 | }, 55 | }); 56 | 57 | await strapi.plugins.email.services.email.send({ 58 | to: email, 59 | from: "support@freecodecamp.org", 60 | subject: "Invitation Link", 61 | text: `Here is your invitation link: ${DASHBOARD_URL}/api/auth/signin`, 62 | }); 63 | 64 | ctx.response.status = 200; 65 | ctx.response.body = { 66 | status: "success", 67 | }; 68 | }; 69 | plugin.routes["content-api"].routes.unshift({ 70 | method: "PUT", 71 | path: "/auth/invitation/:id", 72 | handler: "auth.invitation", 73 | config: { 74 | prefix: "", 75 | policies: [], 76 | }, 77 | }); 78 | 79 | plugin.controllers.auth.acceptInvitation = async (ctx) => { 80 | if (!ctx.state.user || !ctx.state.user.id) { 81 | return (ctx.response.status = 401); 82 | } 83 | 84 | await strapi.query("plugin::users-permissions.user").update({ 85 | where: { id: ctx.state.user.id }, 86 | data: { 87 | status: "active", 88 | }, 89 | }); 90 | 91 | ctx.response.status = 200; 92 | ctx.response.body = { 93 | status: "success", 94 | }; 95 | }; 96 | 97 | plugin.routes["content-api"].routes.unshift({ 98 | method: "PUT", 99 | path: "/auth/accept-invitation", 100 | handler: "auth.acceptInvitation", 101 | config: { 102 | prefix: "", 103 | policies: [], 104 | }, 105 | }); 106 | return plugin; 107 | }; 108 | -------------------------------------------------------------------------------- /apps/backend/src/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | /** 5 | * An asynchronous register function that runs before 6 | * your application is initialized. 7 | * 8 | * This gives you an opportunity to extend code. 9 | */ 10 | register(/*{ strapi }*/) {}, 11 | 12 | /** 13 | * An asynchronous bootstrap function that runs before 14 | * your application gets started. 15 | * 16 | * This gives you an opportunity to set up your data model, 17 | * run jobs, or perform some special logic. 18 | */ 19 | bootstrap(/*{ strapi }*/) {}, 20 | }; 21 | -------------------------------------------------------------------------------- /apps/backend/tests/app.test.js: -------------------------------------------------------------------------------- 1 | const { setupStrapi, cleanupStrapi } = require("./helpers/strapi"); 2 | const { 3 | createTestUsers, 4 | createTestTags, 5 | createTestPosts, 6 | } = require("./helpers/data.helper"); 7 | const { setupFixtures } = require("./helpers/fixtures"); 8 | 9 | beforeAll(async () => { 10 | // Create a Strapi instance in the testing environment 11 | await setupStrapi(); 12 | 13 | // Get fixtures 14 | const fixtures = await setupFixtures(); 15 | 16 | // Seed test database 17 | const users = await createTestUsers(fixtures.testUsers); 18 | const tags = await createTestTags(fixtures.testTags); 19 | await createTestPosts(fixtures.getTestPostsData, users, tags); 20 | }); 21 | 22 | afterAll(cleanupStrapi); 23 | 24 | it("strapi is defined", () => { 25 | expect(strapi).toBeDefined(); 26 | }); 27 | 28 | require("./user"); 29 | require("./auth"); 30 | require("./post"); 31 | -------------------------------------------------------------------------------- /apps/backend/tests/helpers/data.helper.js: -------------------------------------------------------------------------------- 1 | // helper functions to seed the test database 2 | 3 | const createTestUsers = async (testUsers) => { 4 | const editor = await strapi.entityService.create( 5 | "plugin::users-permissions.user", 6 | { data: testUsers.editor }, 7 | ); 8 | const contributor = await strapi.entityService.create( 9 | "plugin::users-permissions.user", 10 | { data: testUsers.contributor }, 11 | ); 12 | 13 | return { 14 | editor, 15 | contributor, 16 | }; 17 | }; 18 | 19 | const createTestTags = async (testTags) => { 20 | const html = await strapi.entityService.create("api::tag.tag", { 21 | data: testTags.html, 22 | }); 23 | const css = await strapi.entityService.create("api::tag.tag", { 24 | data: testTags.css, 25 | }); 26 | 27 | return { 28 | html, 29 | css, 30 | }; 31 | }; 32 | 33 | const createTestPosts = async (generateTestPosts, users, tags) => { 34 | const testPosts = await generateTestPosts(users, tags); 35 | 36 | for (const data of testPosts) { 37 | await strapi.entityService.create("api::post.post", { 38 | data, 39 | }); 40 | } 41 | }; 42 | 43 | module.exports = { 44 | createTestUsers, 45 | createTestTags, 46 | createTestPosts, 47 | }; 48 | -------------------------------------------------------------------------------- /apps/backend/tests/helpers/fixtures.js: -------------------------------------------------------------------------------- 1 | const { getRoleId } = require("./helpers"); 2 | 3 | const setupFixtures = async () => { 4 | const contributor = await getRoleId("Contributor"); 5 | const editor = await getRoleId("Editor"); 6 | 7 | const fixtures = { 8 | testUsers: { 9 | contributor: { 10 | name: "Contributor User", 11 | slug: "contributor-user", 12 | username: "contributor-user", 13 | email: "contributor@example.com", 14 | password: "contributor", 15 | status: "active", 16 | confirmed: true, 17 | role: { 18 | connect: [contributor], 19 | }, 20 | }, 21 | editor: { 22 | name: "Editor User", 23 | slug: "editor-user", 24 | username: "editor-user", 25 | email: "editor@example.com", 26 | password: "editor", 27 | status: "active", 28 | confirmed: true, 29 | role: { 30 | connect: [editor], 31 | }, 32 | }, 33 | }, 34 | testTags: { 35 | html: { 36 | name: "HTML", 37 | slug: "html", 38 | }, 39 | css: { 40 | name: "CSS", 41 | slug: "css", 42 | }, 43 | }, 44 | getTestPostsData({ editor, contributor }, { html, css }) { 45 | return [ 46 | { 47 | title: "Test Title", 48 | body: "

test body

", 49 | slug: "test-slug", 50 | publishedAt: new Date("2023-08-30T00:00:00.000Z"), 51 | code_injection_head: 52 | '', 53 | author: { 54 | connect: [contributor.id], 55 | }, 56 | tags: { 57 | connect: [html.id, css.id], 58 | }, 59 | }, 60 | { 61 | title: "test title 2", 62 | body: "

test body 2

", 63 | slug: "test-slug-2", 64 | publishedAt: new Date("2023-08-30T04:09:32.928Z"), 65 | author: { connect: [contributor.id] }, 66 | }, 67 | { 68 | title: "Draft Post", 69 | body: "

draft post

", 70 | slug: "draft-post", 71 | publishedAt: null, 72 | scheduled_at: null, 73 | author: { 74 | connect: [contributor.id], 75 | }, 76 | tags: { 77 | connect: [html.id, css.id], 78 | }, 79 | }, 80 | { 81 | title: "Published Post", 82 | body: "

published post

", 83 | slug: "published-post", 84 | publishedAt: null, 85 | scheduled_at: null, 86 | author: { 87 | connect: [contributor.id], 88 | }, 89 | tags: { 90 | connect: [html.id, css.id], 91 | }, 92 | }, 93 | { 94 | title: "Editor's draft post", 95 | body: "

This is a post by editor user.

", 96 | slug: "editors-draft-post", 97 | publishedAt: null, 98 | author: { 99 | connect: [editor.id], 100 | }, 101 | tags: { 102 | connect: [html.id, css.id], 103 | }, 104 | }, 105 | ]; 106 | }, 107 | }; 108 | 109 | return fixtures; 110 | }; 111 | 112 | module.exports = { 113 | setupFixtures, 114 | }; 115 | -------------------------------------------------------------------------------- /apps/backend/tests/helpers/helpers.js: -------------------------------------------------------------------------------- 1 | // helper functions 2 | const getUser = async (username) => { 3 | try { 4 | return await strapi.db.query("plugin::users-permissions.user").findOne({ 5 | where: { username: username }, 6 | }); 7 | } catch (e) { 8 | console.error(e); 9 | throw new Error(`Failed to get User for ${username}`); 10 | } 11 | }; 12 | 13 | const getUserByRole = async (roleId) => 14 | await strapi.db 15 | .query("plugin::users-permissions.user") 16 | .findOne({ where: { role: roleId }, populate: ["role"] }); 17 | 18 | const deleteUser = async (username) => { 19 | try { 20 | return await strapi.db.query("plugin::users-permissions.user").delete({ 21 | where: { username }, 22 | }); 23 | } catch (e) { 24 | console.error(e); 25 | throw new Error(`Failed to delete User for ${username}`); 26 | } 27 | }; 28 | 29 | const getPost = async (slug) => { 30 | try { 31 | return await strapi.db.query("api::post.post").findOne({ 32 | where: { slug: slug }, 33 | }); 34 | } catch (e) { 35 | console.error(e); 36 | throw new Error(`Failed to get Post for ${slug}`); 37 | } 38 | }; 39 | 40 | const getUserJWT = async (username) => { 41 | try { 42 | const user = await getUser(username); 43 | return await strapi.plugins["users-permissions"].services.jwt.issue({ 44 | id: user.id, 45 | }); 46 | } catch (e) { 47 | console.error(e); 48 | throw new Error(`Failed to get JWT for ${username}`); 49 | } 50 | }; 51 | 52 | const getRoleId = async (roleName) => { 53 | try { 54 | const role = await strapi.db 55 | .query("plugin::users-permissions.role") 56 | .findOne({ 57 | where: { name: roleName }, 58 | }); 59 | return role.id; 60 | } catch (e) { 61 | console.error(e); 62 | throw new Error(`Failed to get Role ID for ${roleName}`); 63 | } 64 | }; 65 | 66 | const getAllRoles = async () => 67 | await strapi.db.query("plugin::users-permissions.role").findMany({ 68 | where: { $not: { type: "public" } }, 69 | }); 70 | 71 | module.exports = { 72 | deleteUser, 73 | getUser, 74 | getPost, 75 | getUserJWT, 76 | getRoleId, 77 | getUserByRole, 78 | getAllRoles, 79 | }; 80 | -------------------------------------------------------------------------------- /apps/backend/tests/helpers/strapi.js: -------------------------------------------------------------------------------- 1 | const Strapi = require("@strapi/strapi"); 2 | const fs = require("fs"); 3 | 4 | async function setupStrapi() { 5 | if (typeof strapi === "undefined") { 6 | await Strapi().load(); 7 | await strapi.server.mount(); 8 | } 9 | } 10 | 11 | async function cleanupStrapi() { 12 | const dbSettings = strapi.config.get("database.connection"); 13 | 14 | //close server to release the db-file 15 | await strapi.server.httpServer.close(); 16 | 17 | // close the connection to the database before deletion 18 | await strapi.db.connection.destroy(); 19 | 20 | //delete test database after all tests have completed 21 | if (dbSettings && dbSettings.connection && dbSettings.connection.filename) { 22 | const tmpDbFile = dbSettings.connection.filename; 23 | if (fs.existsSync(tmpDbFile)) { 24 | fs.unlinkSync(tmpDbFile); 25 | } 26 | } 27 | } 28 | 29 | module.exports = { setupStrapi, cleanupStrapi }; 30 | -------------------------------------------------------------------------------- /apps/backend/tests/user/index.js: -------------------------------------------------------------------------------- 1 | const request = require("supertest"); 2 | const { 3 | deleteUser, 4 | getUserByRole, 5 | getAllRoles, 6 | } = require("../helpers/helpers"); 7 | 8 | // user mock data 9 | const mockUserData = { 10 | username: "tester", 11 | email: "tester@strapi.com", 12 | provider: "local", 13 | password: "1234abc", 14 | confirmed: true, 15 | blocked: null, 16 | }; 17 | 18 | describe("user", () => { 19 | // Example test taken from https://docs.strapi.io/dev-docs/testing 20 | // This test should pass if the test environment is set up properly 21 | afterAll(async () => { 22 | await deleteUser(mockUserData.username); 23 | }); 24 | it("should login user and return jwt token", async () => { 25 | /** Creates a new user and save it to the database */ 26 | await strapi.plugins["users-permissions"].services.user.add({ 27 | ...mockUserData, 28 | }); 29 | 30 | await request(strapi.server.httpServer) // app server is an instance of Class: http.Server 31 | .post("/api/auth/local") 32 | .set("accept", "application/json") 33 | .set("Content-Type", "application/json") 34 | .send({ 35 | identifier: mockUserData.email, 36 | password: mockUserData.password, 37 | }) 38 | .expect("Content-Type", /json/) 39 | .expect(200) 40 | .then((data) => { 41 | expect(data.body.jwt).toBeDefined(); 42 | }); 43 | }); 44 | 45 | // This just ensures that the test environment is set up with all types of 46 | // user. 47 | it("should have a user for each of the roles", async () => { 48 | const roles = await getAllRoles(); 49 | for (const role of roles) { 50 | const user = await getUserByRole(role.id); 51 | expect(user).toMatchObject({ 52 | role: { 53 | name: role.name, 54 | }, 55 | }); 56 | } 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /apps/cron/.gitignore: -------------------------------------------------------------------------------- 1 | # Production build 2 | prod/ 3 | -------------------------------------------------------------------------------- /apps/cron/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cron", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "dev": "nodemon src/index.ts # don't create a develop script unless you want turbo develop to start it", 9 | "init": "shx cp -n sample.env .env", 10 | "start": "NODE_ENV=production node prod/index.js", 11 | "type-check": "tsc --noEmit" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "@types/node": "^20.8.8", 18 | "nodemon": "^3.0.1", 19 | "shx": "^0.3.4", 20 | "ts-node": "^10.9.1", 21 | "typescript": "^5.2.2" 22 | }, 23 | "dependencies": { 24 | "bree": "^9.1.3", 25 | "dotenv": "^14.2.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/cron/sample.env: -------------------------------------------------------------------------------- 1 | STRAPI_URL=http://localhost:1337 2 | STRAPI_ACCESS_TOKEN= 3 | -------------------------------------------------------------------------------- /apps/cron/src/index.ts: -------------------------------------------------------------------------------- 1 | import Bree from "bree"; 2 | import path from "path"; 3 | 4 | const bree = new Bree({ 5 | root: path.join(__dirname, "jobs"), 6 | defaultExtension: process.env.NODE_ENV === "production" ? "js" : "ts", 7 | jobs: [ 8 | { 9 | name: "check-and-publish", 10 | interval: "5m", 11 | timeout: 0, 12 | }, 13 | ], 14 | }); 15 | 16 | (async () => { 17 | await bree.start(); 18 | })(); 19 | -------------------------------------------------------------------------------- /apps/cron/src/jobs/check-and-publish.ts: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | 3 | (async () => { 4 | const res = await fetch( 5 | new URL("/api/posts/check-and-publish", process.env.STRAPI_URL), 6 | { 7 | method: "GET", 8 | headers: { 9 | "Content-Type": "application/json", 10 | Authorization: `Bearer ${process.env.STRAPI_ACCESS_TOKEN}`, 11 | }, 12 | }, 13 | ); 14 | 15 | const resJson = await res.json(); 16 | 17 | if (resJson.error) { 18 | console.error(resJson); 19 | throw new Error(resJson.error); 20 | } 21 | 22 | const data = resJson.data.attributes; 23 | const count = data.count; 24 | const postTitles = data.data; 25 | 26 | console.log(`Found ${count} posts to publish.`); 27 | })(); 28 | -------------------------------------------------------------------------------- /apps/frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | README.md 6 | .next 7 | docker 8 | .git 9 | .env* 10 | -------------------------------------------------------------------------------- /apps/frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [{package.json,*.yml}] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /apps/frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"], 3 | "plugins": ["unused-imports"], 4 | "rules": { 5 | "no-unused-vars": "off", 6 | "unused-imports/no-unused-imports": "error", 7 | "unused-imports/no-unused-vars": [ 8 | "error", 9 | { 10 | "vars": "all", 11 | "varsIgnorePattern": "^_", 12 | "args": "after-used", 13 | "argsIgnorePattern": "^_" 14 | } 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | # VS Code 38 | /.vscode 39 | -------------------------------------------------------------------------------- /apps/frontend/.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "**/*.{js,jsx,ts,tsx}": "eslint", 3 | "*": "prettier --ignore-unknown --write" 4 | } 5 | -------------------------------------------------------------------------------- /apps/frontend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/frontend/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | output: "standalone", 5 | }; 6 | 7 | module.exports = nextConfig; 8 | -------------------------------------------------------------------------------- /apps/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "develop": "npm run dev", 9 | "eslint": "next lint --max-warnings 0", 10 | "init": "shx cp -n sample.env .env", 11 | "start": "next start" 12 | }, 13 | "dependencies": { 14 | "@chakra-ui/icons": "^2.1.1", 15 | "@chakra-ui/next-js": "^2.1.5", 16 | "@chakra-ui/react": "^2.8.0", 17 | "@choc-ui/chakra-autocomplete": "^5.2.8", 18 | "@emotion/react": "^11.11.1", 19 | "@emotion/styled": "^11.11.0", 20 | "@floating-ui/react": "^0.26.9", 21 | "@fortawesome/fontawesome-svg-core": "^6.4.2", 22 | "@fortawesome/free-regular-svg-icons": "^6.4.2", 23 | "@fortawesome/free-solid-svg-icons": "^6.4.2", 24 | "@fortawesome/react-fontawesome": "^0.2.0", 25 | "@nikolovlazar/chakra-ui-prose": "^1.2.1", 26 | "@tiptap/extension-character-count": "^2.1.11", 27 | "@tiptap/extension-code-block-lowlight": "^2.2.0", 28 | "@tiptap/extension-image": "^2.0.4", 29 | "@tiptap/extension-link": "^2.1.8", 30 | "@tiptap/extension-list-item": "^2.0.3", 31 | "@tiptap/extension-placeholder": "^2.0.4", 32 | "@tiptap/extension-text-style": "^2.0.3", 33 | "@tiptap/extension-youtube": "^2.1.8", 34 | "@tiptap/pm": "^2.0.3", 35 | "@tiptap/react": "^2.0.3", 36 | "@tiptap/starter-kit": "^2.0.3", 37 | "date-fns": "^2.30.0", 38 | "formik": "^2.4.4", 39 | "framer-motion": "^10.13.1", 40 | "lowlight": "2.9.0", 41 | "next": "13.5.6", 42 | "next-auth": "^4.22.1", 43 | "qs": "^6.11.2", 44 | "react": "18.2.0", 45 | "react-dom": "18.2.0", 46 | "react-icons": "^4.10.1", 47 | "react-infinite-scroll-component": "^6.1.0", 48 | "slugify": "^1.6.6", 49 | "tiptap-markdown": "^0.8.2", 50 | "use-debounce": "^10.0.0", 51 | "uuid": "^9.0.1", 52 | "yup": "^1.3.2" 53 | }, 54 | "devDependencies": { 55 | "eslint": "^8.46.0", 56 | "eslint-config-next": "^13.4.13", 57 | "eslint-config-prettier": "^8.10.0", 58 | "eslint-plugin-unused-imports": "^3.0.0", 59 | "prettier": "^3.0.1", 60 | "shx": "^0.3.4" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /apps/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/publish/3b34d0108e7b87932b43ed5969ad028097d56447/apps/frontend/public/favicon.ico -------------------------------------------------------------------------------- /apps/frontend/sample.env: -------------------------------------------------------------------------------- 1 | # NextAuth 2 | NEXT_PUBLIC_STRAPI_BACKEND_URL=http://localhost:1337 3 | NEXTAUTH_URL=http://localhost:3000 4 | # generate random secret with `openssl rand -base64 32` 5 | # See https://next-auth.js.org/configuration/options#secret for details 6 | NEXTAUTH_SECRET=tobemodified 7 | 8 | # Development authentication 9 | # DO NOT USE IN PRODUCTION (it's just to simplify development and testing) 10 | EMAIL_PASSWORD_AUTHENTICATION=true 11 | 12 | # Auth0 13 | AUTH0_CLIENT_ID= 14 | AUTH0_CLIENT_SECRET= 15 | AUTH0_DOMAIN= 16 | -------------------------------------------------------------------------------- /apps/frontend/src/components/pagination.jsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Text } from "@chakra-ui/react"; 2 | import React from "react"; 3 | import { useRouter } from "next/router"; 4 | 5 | const Pagination = ({ pagination, endpoint, queryParams }) => { 6 | const router = useRouter(); 7 | const { 8 | pagination: { page, pageCount }, 9 | } = pagination; 10 | 11 | // remove page from query params 12 | delete queryParams.page; 13 | 14 | return ( 15 | <> 16 | 28 | 35 | 36 | {pageCount > 0 ? page : "0"} of {pageCount} 37 | 38 | 39 | 51 | 52 | ); 53 | }; 54 | 55 | export default Pagination; 56 | -------------------------------------------------------------------------------- /apps/frontend/src/components/search-component.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | IconButton, 4 | Modal, 5 | ModalContent, 6 | ModalOverlay, 7 | Input, 8 | useDisclosure, 9 | InputGroup, 10 | InputLeftElement, 11 | Text, 12 | Card, 13 | CardBody, 14 | } from "@chakra-ui/react"; 15 | import { SearchIcon } from "@chakra-ui/icons"; 16 | import { getAllPosts } from "@/lib/posts"; 17 | import { isEditor } from "@/lib/current-user"; 18 | import NextLink from "next/link"; 19 | 20 | const PostSearch = ({ user }) => { 21 | const [posts, setPosts] = useState([]); 22 | 23 | const { isOpen, onOpen, onClose } = useDisclosure(); 24 | 25 | const search = async (query) => { 26 | const result = await getAllPosts(user.jwt, { 27 | publicationState: "preview", 28 | fields: ["title", "id"], 29 | populate: ["author"], 30 | filters: { 31 | title: { 32 | $containsi: query, 33 | }, 34 | // only show posts of that user if they are not an editor 35 | ...(isEditor(user) ? {} : { author: { id: { $eq: user.id } } }), 36 | }, 37 | pagination: { 38 | pageSize: 5, 39 | }, 40 | }); 41 | 42 | setPosts(result.data); 43 | }; 44 | 45 | return ( 46 | <> 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | search(query.target.value)} 62 | size="lg" 63 | /> 64 | 65 | {posts.map((post) => ( 66 | 67 | 68 | {post.attributes.title} 69 | 70 | 71 | ))} 72 | 73 | 74 | 75 | ); 76 | }; 77 | 78 | export default PostSearch; 79 | -------------------------------------------------------------------------------- /apps/frontend/src/components/tags-list.jsx: -------------------------------------------------------------------------------- 1 | export default function TagsList({ allTagsData }) { 2 | return ( 3 | <> 4 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /apps/frontend/src/components/tip-tap-extensions/heading-extension.jsx: -------------------------------------------------------------------------------- 1 | import BaseHeading from "@tiptap/extension-heading"; 2 | import slugify from "slugify"; 3 | 4 | export const Heading = BaseHeading.configure({ 5 | levels: [1, 2, 3, 4, 5, 6], 6 | }).extend({ 7 | addAttributes() { 8 | return { 9 | ...this.parent?.(), 10 | id: { 11 | default: null, 12 | parseHTML: (element) => ({ 13 | id: slugify(element.textContent, { 14 | lower: true, 15 | }), 16 | }), 17 | renderHTML: (attributes) => ({ 18 | id: attributes.id, 19 | }), 20 | }, 21 | }; 22 | }, 23 | 24 | onSelectionUpdate({ editor }) { 25 | const { $from } = editor.state.selection; 26 | 27 | // This line gets the node at the depth of 28 | // the start of the selection. If the selection 29 | // starts in a heading, this will be the heading node. 30 | 31 | const node = $from.node($from.depth); 32 | 33 | if (node.type.name === "heading") { 34 | editor.commands.updateAttributes("heading", { 35 | id: slugify(node.textContent, { 36 | lower: true, 37 | }), 38 | }); 39 | } 40 | }, 41 | 42 | renderHTML({ node }) { 43 | return [ 44 | "h" + node.attrs.level, 45 | { 46 | id: slugify(node.textContent, { 47 | lower: true, 48 | }), 49 | }, 50 | 0, 51 | ]; 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /apps/frontend/src/components/users-list.jsx: -------------------------------------------------------------------------------- 1 | export default function UsersList({ allUsersData }) { 2 | console.log(allUsersData); 3 | return ( 4 | <> 5 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/frontend/src/lib/current-user.js: -------------------------------------------------------------------------------- 1 | export function isEditor(user) { 2 | return user?.role === "Editor"; 3 | } 4 | -------------------------------------------------------------------------------- /apps/frontend/src/lib/editor-config.js: -------------------------------------------------------------------------------- 1 | import StarterKit from "@tiptap/starter-kit"; 2 | import Youtube from "@tiptap/extension-youtube"; 3 | import { Markdown } from "tiptap-markdown"; 4 | import { lowlight } from "lowlight"; 5 | import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; 6 | import CharacterCount from "@tiptap/extension-character-count"; 7 | import Image from "@tiptap/extension-image"; 8 | import Placeholder from "@tiptap/extension-placeholder"; 9 | import Link from "@tiptap/extension-link"; 10 | import { Heading } from "@/components/tip-tap-extensions/heading-extension"; 11 | 12 | export const extensions = [ 13 | StarterKit.configure({ 14 | bulletList: { 15 | keepMarks: true, 16 | keepAttributes: false, 17 | }, 18 | orderedList: { 19 | keepMarks: true, 20 | keepAttributes: false, 21 | }, 22 | heading: false, 23 | codeBlock: false, 24 | }), 25 | Placeholder.configure({ 26 | // Use a placeholder: 27 | placeholder: "Write something …", 28 | }), 29 | Image.configure({ 30 | inline: true, 31 | HTMLAttributes: { 32 | class: "add-image-form", 33 | }, 34 | }), 35 | Youtube.configure({ 36 | width: 480, 37 | height: 320, 38 | }), 39 | Link.configure({ 40 | protocols: ["http", "https", "mailto", "tel"], 41 | autolink: false, 42 | openOnClick: false, 43 | }), 44 | Markdown.configure({ 45 | transformPastedText: true, 46 | }), 47 | CodeBlockLowlight.configure({ 48 | defaultLanguage: "javascript", 49 | lowlight, 50 | }), 51 | CharacterCount.configure({}), 52 | Heading, 53 | ]; 54 | -------------------------------------------------------------------------------- /apps/frontend/src/lib/posts.js: -------------------------------------------------------------------------------- 1 | import qs from "qs"; 2 | 3 | // Post API calls 4 | 5 | const base = process.env.NEXT_PUBLIC_STRAPI_BACKEND_URL; 6 | 7 | export async function getAllPosts(token, queryParams) { 8 | const url = new URL("/api/posts", base); 9 | url.search = qs.stringify(queryParams, { 10 | encodeValuesOnly: true, 11 | }); 12 | 13 | const options = { 14 | method: "GET", 15 | headers: { 16 | "Content-Type": "application/json", 17 | Authorization: `Bearer ${token}`, 18 | }, 19 | }; 20 | 21 | try { 22 | const res = await fetch(url, options); 23 | 24 | if (!res.ok) { 25 | console.error("getAllPosts responded with error. Status: ", res.status); 26 | throw new Error( 27 | `getAllPosts responded with error. Status: ${res.status}`, 28 | ); 29 | } 30 | 31 | return res.json(); 32 | } catch (error) { 33 | console.error("getAllPosts Failed. Error: ", error); 34 | throw new Error(`getAllPosts Failed. Error: ${error}`); 35 | } 36 | } 37 | 38 | export async function getUserPosts(token, queryParams) { 39 | const url = new URL("/api/posts", base); 40 | url.search = qs.stringify(queryParams, { 41 | encodeValuesOnly: true, 42 | }); 43 | 44 | const options = { 45 | method: "GET", 46 | headers: { 47 | "Content-Type": "application/json", 48 | Authorization: `Bearer ${token}`, 49 | }, 50 | }; 51 | 52 | try { 53 | const res = await fetch(url, options); 54 | 55 | if (!res.ok) { 56 | console.error("getUserPosts responded with error. Status: ", res.status); 57 | throw new Error( 58 | `getUserPosts responded with error. Status: ${res.status}`, 59 | ); 60 | } 61 | 62 | return res.json(); 63 | } catch (error) { 64 | console.error("getUserPosts Failed. Error: ", error); 65 | throw new Error(`getUserPosts Failed. Error: ${error}`); 66 | } 67 | } 68 | 69 | export async function getPost(postId, token) { 70 | const url = new URL(`/api/posts/${postId}`, base); 71 | url.search = "populate=*"; 72 | 73 | const options = { 74 | method: "GET", 75 | headers: { 76 | "Content-Type": "application/json", 77 | Authorization: `Bearer ${token}`, 78 | }, 79 | }; 80 | 81 | try { 82 | const res = await fetch(url, options); 83 | 84 | if (!res.ok) { 85 | console.error( 86 | `getPost responded with error. postId: ${postId}, Status: ${res.status}`, 87 | ); 88 | throw new Error( 89 | `getPost responded with error. postId: ${postId}, Status: ${res.status}`, 90 | ); 91 | } 92 | 93 | return res.json(); 94 | } catch (error) { 95 | console.error(`getPost Failed. postId: ${postId}, Error: `, error); 96 | throw new Error(`getPost Failed. postId: ${postId}, Error: ${error}`); 97 | } 98 | } 99 | 100 | export async function createPost(data, token) { 101 | const url = new URL("/api/posts", base); 102 | 103 | const options = { 104 | method: "POST", 105 | headers: { 106 | "Content-Type": "application/json", 107 | accept: "application/json", 108 | Authorization: `Bearer ${token}`, 109 | }, 110 | body: JSON.stringify(data), 111 | }; 112 | const res = await fetch(url, options); 113 | if (!res.ok) { 114 | throw new Error("createPost Failed"); 115 | } 116 | 117 | return res.json(); 118 | } 119 | 120 | export async function updatePost(postId, data, token) { 121 | const url = new URL(`/api/posts/${postId}?populate=feature_image`, base); 122 | 123 | const options = { 124 | method: "PUT", 125 | headers: { 126 | "Content-Type": "application/json", 127 | Authorization: `Bearer ${token}`, 128 | }, 129 | body: JSON.stringify(data), 130 | }; 131 | 132 | const res = await fetch(url, options); 133 | 134 | if (!res.ok) { 135 | throw new Error("updatePost Failed"); 136 | } 137 | 138 | return res.json(); 139 | } 140 | -------------------------------------------------------------------------------- /apps/frontend/src/lib/roles.js: -------------------------------------------------------------------------------- 1 | const base = process.env.NEXT_PUBLIC_STRAPI_BACKEND_URL; 2 | 3 | export async function getRoles(token) { 4 | const url = new URL("/api/users-permissions/roles", base); 5 | 6 | const options = { 7 | method: "GET", 8 | headers: { 9 | "Content-Type": "application/json", 10 | Authorization: `Bearer ${token}`, 11 | }, 12 | }; 13 | 14 | try { 15 | const res = await fetch(url, options); 16 | 17 | return res.json(); 18 | } catch (error) { 19 | console.error("getRoles responded with error. Status: ", res?.body); 20 | throw new Error(`getRoles responded with error. Status: ${res?.body}`); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /apps/frontend/src/lib/tags.js: -------------------------------------------------------------------------------- 1 | import qs from "qs"; 2 | 3 | // Tag API calls 4 | const base = process.env.NEXT_PUBLIC_STRAPI_BACKEND_URL; 5 | 6 | export async function getTags(token, queryParams) { 7 | const url = new URL("/api/tags", base); 8 | url.search = qs.stringify(queryParams, { 9 | encodeValuesOnly: true, 10 | }); 11 | 12 | const options = { 13 | method: "GET", 14 | headers: { 15 | "Content-Type": "application/json", 16 | Authorization: `Bearer ${token}`, 17 | }, 18 | }; 19 | 20 | try { 21 | const res = await fetch(url, options); 22 | 23 | return res.json(); 24 | } catch (error) { 25 | console.error("getTags responded with error. Status: ", res.body); 26 | throw new Error(`getTags responded with error. Status: ${res.body}`); 27 | } 28 | } 29 | 30 | export async function createTag(token, data) { 31 | const url = new URL("/api/tags", base); 32 | 33 | const options = { 34 | method: "POST", 35 | headers: { 36 | "Content-Type": "application/json", 37 | Authorization: `Bearer ${token}`, 38 | }, 39 | body: JSON.stringify(data), 40 | }; 41 | 42 | try { 43 | const res = await fetch(url, options); 44 | 45 | if (!res.ok) { 46 | console.error("createTag responded with error. Status: ", res.status); 47 | throw new Error(`createTag responded with error. Status: ${res.status}`); 48 | } 49 | 50 | return res.json(); 51 | } catch (error) { 52 | console.error("createTag Failed. Error: ", error); 53 | throw new Error(`createTag Failed. Error: ${error}`); 54 | } 55 | } 56 | 57 | export async function getTag(token, tagId) { 58 | const url = new URL(`/api/tags/${tagId}`, base); 59 | url.search = qs.stringify( 60 | { 61 | populate: "*", 62 | }, 63 | { 64 | encodeValuesOnly: true, 65 | }, 66 | ); 67 | 68 | const options = { 69 | method: "GET", 70 | headers: { 71 | "Content-Type": "application/json", 72 | Authorization: `Bearer ${token}`, 73 | }, 74 | }; 75 | 76 | try { 77 | const res = await fetch(url, options); 78 | 79 | return res.json(); 80 | } catch (error) { 81 | console.error("getTag responded with error. Status: ", res.body); 82 | throw new Error(`getTag responded with error. Status: ${res.body}`); 83 | } 84 | } 85 | 86 | export async function updateTag(token, tagId, data) { 87 | const url = new URL(`/api/tags/${tagId}`, base); 88 | url.search = qs.stringify( 89 | { 90 | populate: "*", 91 | }, 92 | { 93 | encodeValuesOnly: true, 94 | }, 95 | ); 96 | 97 | const options = { 98 | method: "PUT", 99 | headers: { 100 | "Content-Type": "application/json", 101 | Authorization: `Bearer ${token}`, 102 | }, 103 | body: JSON.stringify(data), 104 | }; 105 | 106 | try { 107 | const res = await fetch(url, options); 108 | 109 | return res.json(); 110 | } catch (error) { 111 | console.error("updateTag responded with error. Status: ", res.body); 112 | throw new Error(`updateTag responded with error. Status: ${res.body}`); 113 | } 114 | } 115 | 116 | export async function deleteTag(token, tagId) { 117 | const url = new URL(`/api/tags/${tagId}`, base); 118 | 119 | const options = { 120 | method: "DELETE", 121 | headers: { 122 | "Content-Type": "application/json", 123 | Authorization: `Bearer ${token}`, 124 | }, 125 | }; 126 | 127 | try { 128 | const res = await fetch(url, options); 129 | 130 | return res.json(); 131 | } catch (error) { 132 | console.error("deleteTag responded with error. Status: ", res.body); 133 | throw new Error(`deleteTag responded with error. Status: ${res.body}`); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /apps/frontend/src/lib/theme.js: -------------------------------------------------------------------------------- 1 | import { extendTheme } from "@chakra-ui/react"; 2 | import { withProse } from "@nikolovlazar/chakra-ui-prose"; 3 | 4 | const config = { 5 | initialColorMode: "system", 6 | useSystemColorMode: false, 7 | components: { 8 | Table: { 9 | variants: { 10 | simple: { 11 | th: { 12 | borderColor: "gray.200", 13 | }, 14 | td: { 15 | borderColor: "gray.200", 16 | }, 17 | }, 18 | }, 19 | }, 20 | }, 21 | }; 22 | 23 | const theme = extendTheme( 24 | config, 25 | withProse({ 26 | baseStyle: { 27 | h1: { 28 | mt: 0, 29 | mb: "2rem", 30 | }, 31 | h2: { 32 | mt: 0, 33 | mb: "1.5rem", 34 | }, 35 | h3: { 36 | mt: 0, 37 | mb: "1rem", 38 | }, 39 | h4: { 40 | mt: 0, 41 | mb: "0.5rem", 42 | }, 43 | h5: { 44 | margin: 0, 45 | }, 46 | h6: { 47 | margin: 0, 48 | }, 49 | }, 50 | }), 51 | ); 52 | 53 | export default theme; 54 | -------------------------------------------------------------------------------- /apps/frontend/src/lib/users.js: -------------------------------------------------------------------------------- 1 | import qs from "qs"; 2 | 3 | // Users & Permissions API calls 4 | const base = process.env.NEXT_PUBLIC_STRAPI_BACKEND_URL; 5 | export async function getMe(token, queryParams) { 6 | const url = new URL("/api/users/me", base); 7 | url.search = qs.stringify(queryParams, { 8 | encodeValuesOnly: true, 9 | }); 10 | 11 | const options = { 12 | method: "GET", 13 | headers: { 14 | "Content-Type": "application/json", 15 | Authorization: `Bearer ${token}`, 16 | }, 17 | }; 18 | 19 | const res = await fetch(url, options); 20 | if (!res.ok) { 21 | throw new Error("getMe Failed"); 22 | } 23 | return res.json(); 24 | } 25 | 26 | export async function updateMe(token, data) { 27 | const url = new URL("/api/users/me", base); 28 | 29 | const options = { 30 | method: "PUT", 31 | headers: { 32 | "Content-Type": "application/json", 33 | Authorization: `Bearer ${token}`, 34 | }, 35 | body: JSON.stringify(data), 36 | }; 37 | 38 | const res = await fetch(url, options); 39 | 40 | if (!res.ok) { 41 | throw new Error("updateUsers Failed"); 42 | } 43 | return res.json(); 44 | } 45 | 46 | export async function getUsers(token, queryParams) { 47 | const url = new URL("/api/users", base); 48 | url.search = qs.stringify(queryParams, { 49 | encodeValuesOnly: true, 50 | }); 51 | 52 | const options = { 53 | method: "GET", 54 | headers: { 55 | "Content-Type": "application/json", 56 | Authorization: `Bearer ${token}`, 57 | }, 58 | }; 59 | 60 | const res = await fetch(url, options); 61 | 62 | if (!res.ok) { 63 | throw new Error("getUsers Failed"); 64 | } 65 | return res.json(); 66 | } 67 | 68 | export async function userExists(token, email) { 69 | const url = new URL("/api/users", base); 70 | url.search = qs.stringify( 71 | { 72 | filters: { 73 | email: { 74 | $eqi: email, 75 | }, 76 | }, 77 | }, 78 | { 79 | encodeValuesOnly: true, 80 | }, 81 | ); 82 | 83 | const options = { 84 | method: "GET", 85 | headers: { 86 | "Content-Type": "application/json", 87 | Authorization: `Bearer ${token}`, 88 | }, 89 | }; 90 | 91 | const res = await fetch(url, options); 92 | 93 | if (!res.ok) { 94 | throw new Error("userExists Failed"); 95 | } 96 | 97 | const data = await res.json(); 98 | return data.length > 0 ? data[0].status : false; 99 | } 100 | 101 | export async function createUser(token, data) { 102 | const url = new URL(`/api/users`, base); 103 | 104 | const options = { 105 | method: "POST", 106 | headers: { 107 | "Content-Type": "application/json", 108 | Authorization: `Bearer ${token}`, 109 | }, 110 | body: JSON.stringify(data), 111 | }; 112 | 113 | const res = await fetch(url, options); 114 | 115 | if (!res.ok) { 116 | throw new Error("createUser Failed"); 117 | } 118 | return res.json(); 119 | } 120 | 121 | export async function getUser(token, userId) { 122 | const url = new URL(`/api/users/${userId}`, base); 123 | url.search = qs.stringify( 124 | { 125 | populate: ["role"], 126 | }, 127 | { 128 | encodeValuesOnly: true, 129 | }, 130 | ); 131 | 132 | const options = { 133 | method: "GET", 134 | headers: { 135 | "Content-Type": "application/json", 136 | Authorization: `Bearer ${token}`, 137 | }, 138 | }; 139 | 140 | const res = await fetch(url, options); 141 | 142 | if (!res.ok) { 143 | throw new Error("getUsers Failed"); 144 | } 145 | return res.json(); 146 | } 147 | 148 | export async function updateUser(token, userId, data) { 149 | const url = new URL(`/api/users/${userId}`, base); 150 | 151 | const options = { 152 | method: "PUT", 153 | headers: { 154 | "Content-Type": "application/json", 155 | Authorization: `Bearer ${token}`, 156 | }, 157 | body: JSON.stringify(data), 158 | }; 159 | 160 | const res = await fetch(url, options); 161 | 162 | if (!res.ok) { 163 | throw new Error("updateUsers Failed"); 164 | } 165 | return res.json(); 166 | } 167 | 168 | export async function deleteUser(token, userId) { 169 | const url = new URL(`/api/users/${userId}`, base); 170 | 171 | const options = { 172 | method: "DELETE", 173 | headers: { 174 | "Content-Type": "application/json", 175 | Authorization: `Bearer ${token}`, 176 | }, 177 | }; 178 | 179 | const res = await fetch(url, options); 180 | 181 | if (!res.ok) { 182 | throw new Error("deleteUsers Failed"); 183 | } 184 | return res.json(); 185 | } 186 | 187 | export async function inviteUser(token, userId) { 188 | const url = new URL(`/api/auth/invitation/${userId}`, base); 189 | 190 | const options = { 191 | method: "PUT", 192 | headers: { 193 | "Content-Type": "application/json", 194 | Authorization: `Bearer ${token}`, 195 | }, 196 | }; 197 | 198 | const res = await fetch(url, options); 199 | 200 | if (!res.ok) { 201 | throw new Error("inviteUser Failed"); 202 | } 203 | return res.json(); 204 | } 205 | -------------------------------------------------------------------------------- /apps/frontend/src/middleware.js: -------------------------------------------------------------------------------- 1 | import { withAuth } from "next-auth/middleware"; 2 | import { NextResponse, URLPattern } from "next/server"; 3 | 4 | const PATTERNS = [ 5 | [ 6 | new URLPattern({ pathname: "/users/:id" }), 7 | ({ pathname }) => pathname.groups, 8 | ], 9 | ]; 10 | 11 | const params = (url) => { 12 | const input = url.split("?")[0]; 13 | let result = {}; 14 | 15 | for (const [pattern, handler] of PATTERNS) { 16 | const patternResult = pattern.exec(input); 17 | if (patternResult !== null && "pathname" in patternResult) { 18 | result = handler(patternResult); 19 | break; 20 | } 21 | } 22 | return result; 23 | }; 24 | 25 | export default withAuth((req) => { 26 | if (req.nextUrl.pathname.startsWith("/users")) { 27 | const { id } = params(req.url); 28 | if (req.nextauth.token.userRole !== "Editor") { 29 | if (id !== undefined) { 30 | if (req.nextauth.token.id != id) { 31 | return NextResponse.redirect(new URL("/posts", req.url)); 32 | } 33 | } else { 34 | return NextResponse.redirect(new URL("/posts", req.url)); 35 | } 36 | } 37 | } 38 | if (req.nextUrl.pathname.startsWith("/tags")) { 39 | if (req.nextauth.token.userRole !== "Editor") { 40 | return NextResponse.redirect(new URL("/posts", req.url)); 41 | } 42 | } 43 | }); 44 | 45 | export const config = { 46 | matcher: ["/posts/:path*", "/tags/:path*", "/users/:path*"], 47 | }; 48 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/_app.js: -------------------------------------------------------------------------------- 1 | import { ChakraProvider } from "@chakra-ui/react"; 2 | import { config } from "@fortawesome/fontawesome-svg-core"; 3 | import { SessionProvider } from "next-auth/react"; 4 | 5 | import theme from "@/lib/theme"; 6 | 7 | import "@/styles/globals.css"; 8 | import "@fortawesome/fontawesome-svg-core/styles.css"; 9 | 10 | // Tell Font Awesome to skip adding the CSS automatically 11 | // since it's already imported above 12 | config.autoAddCss = false; 13 | 14 | export default function App({ 15 | Component, 16 | pageProps: { session, ...pageProps }, 17 | }) { 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/_document.js: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | import { ColorModeScript } from "@chakra-ui/react"; 3 | import theme from "../lib/theme"; 4 | 5 | // Custom document component 6 | // https://nextjs.org/docs/pages/building-your-application/routing/custom-document 7 | export default function Document() { 8 | return ( 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/api/auth/[...nextauth].js: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import Auth0Provider from "next-auth/providers/auth0"; 3 | import CredentialsProvider from "next-auth/providers/credentials"; 4 | 5 | export const authOptions = { 6 | providers: [ 7 | process.env.EMAIL_PASSWORD_AUTHENTICATION === "true" && 8 | CredentialsProvider({ 9 | name: "email", 10 | credentials: { 11 | identifier: { 12 | label: "Email", 13 | type: "email", 14 | placeholder: "foo@bar.com", 15 | required: true, 16 | }, 17 | password: { label: "Password", type: "password", required: true }, 18 | }, 19 | async authorize(credentials) { 20 | const { identifier, password } = credentials; 21 | const url = new URL( 22 | "api/auth/local", 23 | process.env.NEXT_PUBLIC_STRAPI_BACKEND_URL, 24 | ); 25 | const res = await fetch(url, { 26 | method: "POST", 27 | body: JSON.stringify({ identifier, password }), 28 | headers: { "Content-Type": "application/json" }, 29 | }); 30 | const data = await res.json(); 31 | 32 | if (res.ok && data.jwt) { 33 | const user = { ...data.user, jwt: data.jwt }; 34 | return user; 35 | } 36 | return null; 37 | }, 38 | }), 39 | Auth0Provider({ 40 | clientId: process.env.AUTH0_CLIENT_ID, 41 | clientSecret: process.env.AUTH0_CLIENT_SECRET, 42 | issuer: `https://${process.env.AUTH0_DOMAIN}`, 43 | }), 44 | ], 45 | 46 | // Details: https://next-auth.js.org/configuration/callbacks 47 | callbacks: { 48 | async signIn({ user, account }) { 49 | // For auth0 we reuqest the callback to get strapi jwt token. If user exists 50 | // token is returned otherwise the request will fail with a 400 error which 51 | // we use for rejecting sign in attempt from non-invited users. 52 | if (account.provider === "auth0") { 53 | const url = new URL( 54 | `/api/auth/${account.provider}/callback`, 55 | process.env.NEXT_PUBLIC_STRAPI_BACKEND_URL, 56 | ); 57 | url.search = `access_token=${account.access_token}`; 58 | const res = await fetch(url); 59 | const data = await res.json(); 60 | // Note: If the email is already registered on Strapi app without using Auth0 61 | // then it will fail to get JWT token 62 | // https://github.com/strapi/strapi/issues/12907 63 | if (res.ok) { 64 | const { jwt } = data; 65 | // Storing the token in the user object so that it can then be passed to the 66 | // session token in the jwt callback 67 | user.jwt = jwt; 68 | return true; 69 | } else { 70 | return false; 71 | } 72 | } 73 | 74 | // We return true by default as for credentials login only invited users will pass 75 | // the authorization step and land here. 76 | return true; 77 | }, 78 | // This callback is called whenever a JSON Web Token is created (i.e. at sign in) 79 | // or updated(i.e whenever a session is accessed in the client). 80 | async jwt({ token, user }) { 81 | if (user) { 82 | token.jwt = user.jwt; 83 | 84 | // Set user status to actice 85 | const acceptInvitationUrl = new URL( 86 | "/api/auth/accept-invitation", 87 | process.env.NEXT_PUBLIC_STRAPI_BACKEND_URL, 88 | ); 89 | await fetch(acceptInvitationUrl, { 90 | method: "PUT", 91 | headers: { 92 | Authorization: `Bearer ${token.jwt}`, 93 | }, 94 | }); 95 | 96 | // Fetch user role data from /api/users/me?populate=role 97 | const usersUrl = new URL( 98 | "/api/users/me", 99 | process.env.NEXT_PUBLIC_STRAPI_BACKEND_URL, 100 | ); 101 | usersUrl.search = "populate=*"; 102 | const res2 = await fetch(usersUrl, { 103 | headers: { 104 | Authorization: `Bearer ${token.jwt}`, 105 | }, 106 | }); 107 | 108 | if (res2.ok) { 109 | const userData = await res2.json(); 110 | // Add the role name to session JWT 111 | token.name = userData?.name || null; 112 | token.userRole = userData?.role?.name || null; 113 | token.id = userData?.id || null; 114 | if (userData.profile_image !== null) { 115 | token.image = 116 | process.env.NEXT_PUBLIC_STRAPI_BACKEND_URL + 117 | userData.profile_image.url; 118 | } 119 | } 120 | } 121 | 122 | // The returned value will be encrypted, and it is stored in a cookie. 123 | // We can access it through the session callback. 124 | return token; 125 | }, 126 | 127 | // The session callback is called whenever a session is checked. 128 | async session({ session, token }) { 129 | // Decrypt the token in the cookie and return needed values 130 | delete session.user.image; 131 | session.user.jwt = token.jwt; // JWT token to access the Strapi API 132 | session.user.role = token.userRole; 133 | session.user.id = token.id; 134 | if ("image" in token) { 135 | session.user.image = token.image; 136 | } 137 | return session; 138 | }, 139 | }, 140 | 141 | session: { 142 | // The default is `"jwt"`, an encrypted JWT (JWE) stored in the session cookie. 143 | // If you use an `adapter` however, we default it to `"database"` instead. 144 | // You can still force a JWT session by explicitly defining `"jwt"`. 145 | strategy: "jwt", 146 | }, 147 | 148 | // Not providing any secret or NEXTAUTH_SECRET will throw an error in production. 149 | secret: process.env.NEXTAUTH_SECRET, 150 | }; 151 | 152 | const auth = (req, res) => NextAuth(req, res, authOptions); 153 | 154 | export default auth; 155 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import { Button, Box, Flex, Img } from "@chakra-ui/react"; 2 | import { signIn } from "next-auth/react"; 3 | import { useColorModeValue } from "@chakra-ui/react"; 4 | 5 | export default function IndexPage() { 6 | const bg = useColorModeValue("gray.200", "gray.700"); 7 | const container = useColorModeValue("white", "gray.800"); 8 | 9 | return ( 10 | <> 11 | 18 | 27 | 28 | 37 | 44 | freeCodeCamp Editorial 45 | 46 | 47 | 54 | 55 | 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/posts/[postId].js: -------------------------------------------------------------------------------- 1 | import PostForm from "@/components/post-form"; 2 | import { getPost } from "@/lib/posts"; 3 | import { getTags } from "@/lib/tags"; 4 | import { getUsers } from "@/lib/users"; 5 | import { authOptions } from "@/pages/api/auth/[...nextauth]"; 6 | import { getServerSession } from "next-auth/next"; 7 | 8 | export async function getServerSideProps(context) { 9 | const session = await getServerSession(context.req, context.res, authOptions); 10 | const { postId } = context.params; 11 | const { data: tags } = await getTags(session.user.jwt, { 12 | fields: ["id", "name", "slug"], 13 | pagination: { 14 | limit: -1, 15 | }, 16 | }); 17 | 18 | const { data: post } = await getPost(postId, session.user.jwt); 19 | const authors = await getUsers(session.user.jwt, { 20 | fields: ["id", "name", "slug"], 21 | }); 22 | 23 | return { 24 | props: { tags, post, authors, user: session.user }, 25 | }; 26 | } 27 | 28 | export default function EditPostPage({ tags, post, user, authors }) { 29 | return ( 30 | <> 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/posts/preview/[postId].js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { getServerSession } from "next-auth/next"; 3 | import { authOptions } from "@/pages/api/auth/[...nextauth]"; 4 | import { getPost } from "@/lib/posts"; 5 | import { Prose } from "@nikolovlazar/chakra-ui-prose"; 6 | import { EditorContent, useEditor } from "@tiptap/react"; 7 | import { Text, Box, Image, useToast } from "@chakra-ui/react"; 8 | 9 | import { extensions } from "@/lib/editor-config"; 10 | 11 | export async function getServerSideProps(context) { 12 | const session = await getServerSession(context.req, context.res, authOptions); 13 | const apiBase = process.env.NEXT_PUBLIC_STRAPI_BACKEND_URL; 14 | 15 | const { params } = context; 16 | const { postId } = params; 17 | 18 | try { 19 | const data = await getPost(postId, session.user.jwt); 20 | return { 21 | props: { 22 | post: data.data.attributes, 23 | postId: postId, 24 | baseUrl: apiBase, 25 | }, 26 | }; 27 | } catch (error) { 28 | console.error("Error fetching article:", error); 29 | return { 30 | notFound: true, 31 | }; 32 | } 33 | } 34 | 35 | export default function PreviewArticlePage({ post, baseUrl }) { 36 | const toast = useToast(); 37 | const toastIdRef = useRef(); 38 | 39 | const editor = useEditor({ 40 | extensions, 41 | content: post?.body, 42 | editable: false, 43 | editorProps: { 44 | attributes: { 45 | class: "preview", 46 | }, 47 | }, 48 | onCreate: () => { 49 | // Prevent from creating a new toast every time the editor is created 50 | if (toastIdRef.current) { 51 | toast.close(toastIdRef.current); 52 | } 53 | toastIdRef.current = toast({ 54 | title: `Preview Mode`, 55 | description: `This is just a preview of the formatting of the content for readability. The page may look different when published on the publication.`, 56 | isClosable: true, 57 | status: "info", 58 | position: "bottom-right", 59 | duration: null, 60 | }); 61 | }, 62 | }); 63 | 64 | return ( 65 | <> 66 | 67 | 68 | {post?.title} 69 | 70 | 71 | Written by {post.author.data.attributes.name} 72 | 73 | 87 | {post.feature_image.data ? ( 88 | Post Image 97 | ) : ( 98 | 99 | 100 | No image provided 101 | 102 | 103 | )} 104 | 105 | 106 | 107 | 108 | 109 | 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/profile.js: -------------------------------------------------------------------------------- 1 | import { getMe } from "../lib/users"; 2 | import NavMenu from "@/components/nav-menu"; 3 | // Get session in getServerSideProps 4 | import { authOptions } from "@/pages/api/auth/[...nextauth]"; 5 | import { getServerSession } from "next-auth/next"; 6 | import { Flex } from "@chakra-ui/react"; 7 | 8 | export async function getServerSideProps(context) { 9 | const session = await getServerSession(context.req, context.res, authOptions); 10 | const userData = await getMe(session.user.jwt); 11 | return { 12 | props: { 13 | userData, 14 | user: session.user, 15 | }, 16 | }; 17 | } 18 | 19 | export default function Profile({ userData, user }) { 20 | return ( 21 | 22 | 23 | 24 |
25 |
    26 |
  • 27 | Username: {userData.username} 28 |
  • 29 |
  • 30 | Email: {userData.email} 31 |
  • 32 |
  • 33 | name: {userData.name} 34 |
  • 35 |
  • 36 | bio: {userData.bio} 37 |
  • 38 |
  • 39 | website: {userData.website} 40 |
  • 41 |
  • 42 | location: {userData.location} 43 |
  • 44 |
  • 45 | facebook: {userData.facebook} 46 |
  • 47 |
  • 48 | twitter: {userData.twitter} 49 |
  • 50 |
  • 51 | role: {userData.role.name} 52 |
  • 53 |
  • 54 | profile_image:{" "} 55 | {userData.profile_image?.formats.thumbnail.url} 56 |
  • 57 |
58 |
59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/signin/index.js: -------------------------------------------------------------------------------- 1 | import { Button } from "@chakra-ui/react"; 2 | import { signIn, useSession } from "next-auth/react"; 3 | import NextLink from "next/link"; 4 | 5 | export default function IndexPage() { 6 | const { data: session, status } = useSession(); 7 | 8 | const isLoading = status === "loading"; 9 | if (isLoading) return "Loading..."; 10 | 11 | // This is a temporary implementation 12 | if (session) { 13 | return ( 14 | <> 15 |

Signed in as {session.user.email}

16 | 19 | 20 | ); 21 | } 22 | return ( 23 | <> 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /apps/frontend/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | /* CSS for @tiptap/extension-placeholder */ 2 | .ProseMirror p.is-editor-empty:first-child::before { 3 | color: #adb5bd; 4 | content: attr(data-placeholder); 5 | float: left; 6 | height: 0; 7 | pointer-events: none; 8 | } 9 | 10 | /* CSS for underlining links */ 11 | .ProseMirror a { 12 | text-decoration: underline !important; 13 | text-decoration-color: #3eb0ef !important; 14 | } 15 | 16 | .ProseMirror a:hover { 17 | text-decoration: underline !important; 18 | text-decoration-color: #3eb0ef !important; 19 | } 20 | 21 | .ProseMirror { 22 | min-height: 75vh; 23 | max-height: 75vh; 24 | overflow: scroll; 25 | padding: 1rem; 26 | min-width: 100%; 27 | } 28 | 29 | .preview { 30 | min-height: 0; 31 | max-height: unset; 32 | overflow: unset; 33 | padding: 1rem; 34 | min-width: 100%; 35 | } 36 | 37 | .ProseMirror:focus { 38 | outline: none; 39 | } 40 | 41 | .vl { 42 | border-left: 1px solid silver; 43 | height: 2rem; 44 | margin: 0 0.5rem; 45 | } 46 | 47 | .add-image-form { 48 | width: 40%; 49 | height: 40%; 50 | } 51 | 52 | .InputLeft { 53 | top: 3px !important; 54 | left: 3px !important; 55 | } 56 | 57 | /* Basic editor styles */ 58 | .tiptap { 59 | > * + * { 60 | margin-top: 0.75em; 61 | } 62 | 63 | pre { 64 | background: #0d0d0d; 65 | border-radius: 0.5rem; 66 | color: #fff; 67 | font-family: "JetBrainsMono", monospace; 68 | padding: 0.75rem 1rem; 69 | 70 | code { 71 | background: none; 72 | color: inherit; 73 | font-size: 0.8rem; 74 | padding: 0; 75 | } 76 | 77 | .hljs-comment, 78 | .hljs-quote { 79 | color: #616161; 80 | } 81 | 82 | .hljs-variable, 83 | .hljs-template-variable, 84 | .hljs-attribute, 85 | .hljs-tag, 86 | .hljs-name, 87 | .hljs-regexp, 88 | .hljs-link, 89 | .hljs-name, 90 | .hljs-selector-id, 91 | .hljs-selector-class { 92 | color: #f98181; 93 | } 94 | 95 | .hljs-number, 96 | .hljs-meta, 97 | .hljs-built_in, 98 | .hljs-builtin-name, 99 | .hljs-literal, 100 | .hljs-type, 101 | .hljs-params { 102 | color: #fbbc88; 103 | } 104 | 105 | .hljs-string, 106 | .hljs-symbol, 107 | .hljs-bullet { 108 | color: #b9f18d; 109 | } 110 | 111 | .hljs-title, 112 | .hljs-section { 113 | color: #faf594; 114 | } 115 | 116 | .hljs-keyword, 117 | .hljs-selector-tag { 118 | color: #70cff8; 119 | } 120 | 121 | .hljs-emphasis { 122 | font-style: italic; 123 | } 124 | 125 | .hljs-strong { 126 | font-weight: 700; 127 | } 128 | } 129 | } 130 | 131 | .dark-border pre { 132 | border: 2px solid #adb5bd; 133 | } 134 | -------------------------------------------------------------------------------- /docker/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Creating multi-stage build for production 2 | FROM node:18-alpine AS base 3 | WORKDIR /app 4 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 5 | RUN apk update 6 | RUN apk add --no-cache libc6-compat 7 | 8 | FROM base as prune 9 | COPY . . 10 | # Install the version of turbo that is specified in package.json 11 | RUN apk add --no-cache jq 12 | RUN npm i -g turbo@$(jq -r .devDependencies.turbo package.json) 13 | # Since prune --docker provides a truncated package-lock, we cannot use npm ci. 14 | RUN turbo prune backend --docker 15 | 16 | FROM base AS install 17 | COPY --from=prune /app/out/json/ . 18 | # Workaround for the fact the prepare script will try to run husky (which doesn't exist in production) 19 | RUN npm pkg delete scripts.prepare 20 | RUN npm install --omit=dev 21 | 22 | FROM base as build 23 | # See: https://docs.strapi.io/dev-docs/installation/docker#production-environments 24 | RUN apk add --no-cache build-base gcc autoconf automake zlib-dev libpng-dev vips-dev > /dev/null 2>&1 25 | # First install dependencies (as they change less often) 26 | COPY --from=prune /app/out/json/ . 27 | RUN npm install 28 | COPY --from=prune /app/out/full . 29 | # Force use of installed turbo 30 | RUN npx -no turbo run build --filter=backend... 31 | # Remove all node_modules folders so we can copy the entire output of this 32 | # stage. The depedencies are copied from the install stage. 33 | RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' + 34 | 35 | # Creating final production image 36 | FROM base AS runner 37 | # See: https://docs.strapi.io/dev-docs/installation/docker#production-environments 38 | RUN apk add --no-cache vips-dev 39 | # Run the app in production mode 40 | ENV NODE_ENV=production 41 | COPY --from=install --chown=node:node /app . 42 | COPY --from=build --chown=node:node /app . 43 | USER node 44 | EXPOSE 1337 45 | WORKDIR /app/apps/backend 46 | CMD ["npm", "run", "start"] 47 | -------------------------------------------------------------------------------- /docker/cron/Dockerfile: -------------------------------------------------------------------------------- 1 | # Creating multi-stage build for production 2 | FROM node:18-alpine AS base 3 | WORKDIR /app 4 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 5 | RUN apk update 6 | RUN apk add --no-cache libc6-compat 7 | 8 | FROM base as prune 9 | COPY . . 10 | # Install the version of turbo that is specified in package.json 11 | RUN apk add --no-cache jq 12 | RUN npm i -g turbo@$(jq -r .devDependencies.turbo package.json) 13 | # Since prune --docker provides a truncated package-lock, we cannot use npm ci. 14 | RUN turbo prune cron --docker 15 | 16 | FROM base AS install 17 | COPY --from=prune /app/out/json/ . 18 | # Workaround for the fact the prepare script will try to run husky (which doesn't exist in production) 19 | RUN npm pkg delete scripts.prepare 20 | RUN npm install --omit=dev 21 | 22 | FROM base as build 23 | # First install dependencies (as they change less often) 24 | COPY --from=prune /app/out/json/ . 25 | RUN npm install 26 | COPY --from=prune /app/out/full . 27 | # Force use of installed turbo 28 | RUN npx -no turbo run build --filter=cron... 29 | # Remove all node_modules folders so we can copy the entire output of this 30 | # stage. The depedencies are copied from the install stage. 31 | RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' + 32 | 33 | # Creating final production image 34 | FROM base AS runner 35 | # Run the app in production mode 36 | ENV NODE_ENV=production 37 | COPY --from=install --chown=node:node /app . 38 | COPY --from=build --chown=node:node /app . 39 | USER node 40 | EXPOSE 1337 41 | WORKDIR /app/apps/cron 42 | CMD ["npm", "run", "start"] 43 | -------------------------------------------------------------------------------- /docker/frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Creating multi-stage build for production 2 | FROM node:18-alpine AS base 3 | WORKDIR /app 4 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 5 | RUN apk update 6 | RUN apk add --no-cache libc6-compat 7 | # Next.js collects completely anonymous telemetry data about general usage. 8 | # Learn more here: https://nextjs.org/telemetry 9 | # Uncomment the following line in case you want to disable telemetry during the build. 10 | ENV NEXT_TELEMETRY_DISABLED 1 11 | 12 | FROM base as prune 13 | COPY . . 14 | # Install the version of turbo that is specified in package.json 15 | RUN apk add --no-cache jq 16 | RUN npm i -g turbo@$(jq -r .devDependencies.turbo package.json) 17 | # Since prune --docker provides a truncated package-lock, we cannot use npm ci. 18 | RUN turbo prune frontend --docker 19 | 20 | FROM base as build 21 | ARG NEXT_PUBLIC_STRAPI_BACKEND_URL=http://localhost:1337 22 | ENV NEXT_PUBLIC_STRAPI_BACKEND_URL=$NEXT_PUBLIC_STRAPI_BACKEND_URL 23 | # First install dependencies (as they change less often) 24 | COPY --from=prune /app/out/json/ . 25 | RUN npm install 26 | COPY --from=prune /app/out/full . 27 | # Force use of installed turbo 28 | RUN npx -no turbo run build --filter=frontend... 29 | 30 | # Creating final production image 31 | FROM base AS runner 32 | # Run the app in production mode 33 | ENV NODE_ENV=production 34 | # Automatically leverage output traces to reduce image size 35 | # https://nextjs.org/docs/advanced-features/output-file-tracing 36 | COPY --from=build --chown=node:node /app/apps/frontend/.next/standalone ./ 37 | COPY --from=build /app/apps/frontend/public ./apps/frontend/public 38 | COPY --from=build --chown=node:node /app/apps/frontend/.next/static ./apps/frontend/.next/static 39 | USER node 40 | EXPOSE 3000 41 | ENV PORT 3000 42 | ENV HOSTNAME "0.0.0.0" 43 | WORKDIR /app/apps/frontend 44 | CMD ["node", "server.js"] 45 | -------------------------------------------------------------------------------- /e2e/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | extends: [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:@typescript-eslint/recommended-type-checked", 7 | ], 8 | parser: "@typescript-eslint/parser", 9 | parserOptions: { 10 | tsconfigRootDir: __dirname, 11 | project: ["./tsconfig.json", "./tsconfig.eslint.json"], 12 | }, 13 | plugins: ["@typescript-eslint"], 14 | root: true, 15 | }; 16 | -------------------------------------------------------------------------------- /e2e/auth.setup.ts: -------------------------------------------------------------------------------- 1 | import { test as setup } from "@playwright/test"; 2 | 3 | import { signIn } from "./helpers/user"; 4 | import { 5 | EDITOR_CREDENTIALS, 6 | CONTRIBUTOR_CREDENTIALS, 7 | } from "./helpers/constants"; 8 | 9 | const editorFile = "playwright/.auth/editor.json"; 10 | const contributorFile = "playwright/.auth/contributor.json"; 11 | 12 | setup("authenticate as editor", async ({ page }) => { 13 | await signIn(page, EDITOR_CREDENTIALS); 14 | await page.context().storageState({ path: editorFile }); 15 | }); 16 | 17 | setup("authenticate as contributor", async ({ page }) => { 18 | await signIn(page, CONTRIBUTOR_CREDENTIALS); 19 | await page.context().storageState({ path: contributorFile }); 20 | }); 21 | -------------------------------------------------------------------------------- /e2e/contributor/posts.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test.describe("contributor", () => { 4 | test("can only view draft posts", async ({ page }) => { 5 | await page.goto("/posts"); 6 | 7 | // the filter "All posts" should be hidden 8 | await expect(page.getByRole("button", { name: "All posts" })).toBeHidden(); 9 | 10 | // check that only draft posts are in the list 11 | const publishedCount = await page 12 | .locator("data-testid=published-badge") 13 | .count(); 14 | expect(publishedCount).toBe(0); 15 | const draftCount = await page.locator("data-testid=draft-badge").count(); 16 | expect(draftCount).toBeGreaterThanOrEqual(1); 17 | }); 18 | 19 | test("can only view their own posts", async ({ page }) => { 20 | await page.goto("/posts"); 21 | 22 | // check that other user's posts are not in the list 23 | const ownPostsCount = await page.getByText("By contributor-user").count(); 24 | const otherPostsCount = await page.getByText("By editor-user").count(); 25 | expect(ownPostsCount).toBeGreaterThanOrEqual(1); 26 | expect(otherPostsCount).toBe(0); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /e2e/editor-drawer.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import path from "path"; 3 | 4 | import { 5 | deletePost, 6 | getPostIdInURL, 7 | createPostWithFeatureImage, 8 | } from "./helpers/post"; 9 | 10 | test.describe("feature image", () => { 11 | const postIdsToDelete: string[] = []; 12 | 13 | test.afterAll(async ({ request }) => { 14 | // delete all posts created in the tests 15 | for (const postId of postIdsToDelete) { 16 | await deletePost(request, postId); 17 | } 18 | }); 19 | 20 | test("it should be possible to save without a feature image", async ({ 21 | page, 22 | }) => { 23 | // Open a new post 24 | await page.goto("/posts"); 25 | await page.getByRole("button", { name: "New post" }).click(); 26 | await page.waitForURL(/.*\/posts\/\d+/); 27 | // Store the post id to delete after the test 28 | const postId = getPostIdInURL(page); 29 | if (postId) { 30 | // Store the post id to delete after the test 31 | postIdsToDelete.push(postId); 32 | } 33 | 34 | // Wait for the editor to load 35 | await page.getByTestId("editor").waitFor(); 36 | 37 | // Save the new post without adding a feature image 38 | await page.keyboard.down("Control"); 39 | await page.keyboard.press("s"); 40 | 41 | // Check that the post was saved successfully 42 | const saveNotificationTitle = page.locator("#toast-1-title"); 43 | const saveNotificationDescription = page.locator("#toast-1-description"); 44 | await expect(saveNotificationTitle).toBeVisible(); 45 | await expect(saveNotificationTitle).toHaveText("Post has been updated."); 46 | 47 | await expect(saveNotificationDescription).toBeVisible(); 48 | await expect(saveNotificationDescription).toHaveText( 49 | "The post has been updated.", 50 | ); 51 | }); 52 | 53 | test("it should be possible to save with a feature image", async ({ 54 | page, 55 | }) => { 56 | // Open a new post 57 | await page.goto("/posts"); 58 | await page.getByRole("button", { name: "New post" }).click(); 59 | await page.waitForURL(/.*\/posts\/\d+/); 60 | // Store the post id to delete after the test 61 | const postId = getPostIdInURL(page); // Extract the new post id from the URL 62 | if (postId) { 63 | // Store the post id to delete after the test 64 | postIdsToDelete.push(postId); 65 | } 66 | 67 | // Prepare promises before clicking the button 68 | const fileChooserPromise = page.waitForEvent("filechooser"); 69 | const waitForUploadPromise = page.waitForResponse("**/api/upload"); 70 | 71 | // Open the drawer 72 | const drawerButton = page.getByTestId("open-post-drawer"); 73 | await drawerButton.click(); 74 | 75 | // Select a feature image 76 | const fileChooserButton = page.locator('text="Select Image"'); 77 | await fileChooserButton.click(); 78 | const fileChooser = await fileChooserPromise; 79 | await fileChooser.setFiles( 80 | path.join(__dirname, "/fixtures/feature-image.png"), 81 | ); 82 | 83 | // Wait for feature image upload to complete before saving the post 84 | await waitForUploadPromise; 85 | 86 | // Prepare a promise before pressing the save shortcut 87 | const waitForSavePromise = page.waitForResponse( 88 | `**/api/posts/${postId}?populate=feature_image`, 89 | ); 90 | 91 | // Save the new post with a feature image 92 | await page.keyboard.down("Control"); 93 | await page.keyboard.press("s"); 94 | 95 | // Wait for the request to complete 96 | await waitForSavePromise; 97 | 98 | // Check that the post was saved successfully 99 | const saveNotificationTitle = page.locator("#toast-1-title"); 100 | await expect(saveNotificationTitle).toBeVisible(); 101 | await expect(saveNotificationTitle).toHaveText("Post has been updated."); 102 | }); 103 | 104 | test("the saved image should be visible in the drawer and can be deleted", async ({ 105 | page, 106 | request, 107 | }) => { 108 | // Prepare existing post that has a feature image 109 | const postId = await createPostWithFeatureImage(page, request); 110 | if (postId) { 111 | // Store the post id to delete after the test 112 | postIdsToDelete.push(postId); 113 | } 114 | 115 | // Open the post 116 | await page.goto(`/posts/${postId}`); 117 | 118 | // Check that saved feature image is visible in the drawer 119 | await page.getByTestId("open-post-drawer").click(); 120 | await expect(page.getByTestId("feature-image")).toBeVisible(); 121 | 122 | // Check that it's possible to delete the feature image 123 | const deleteImageButton = page.getByTestId("delete-feature-image"); 124 | await deleteImageButton.click(); 125 | 126 | // Prepare a promise before pressing the save shortcut 127 | const nextPromise = page.waitForResponse( 128 | `**/api/posts/${postId}?populate=feature_image`, 129 | ); 130 | await page.keyboard.down("Control"); 131 | await page.keyboard.press("s"); 132 | 133 | // Wait for the request to complete 134 | await nextPromise; 135 | 136 | // Wait for the save notification to appear 137 | const saveNotificationTitle = page.locator("#toast-1-title"); 138 | await expect(saveNotificationTitle).toBeVisible(); 139 | await expect(saveNotificationTitle).toHaveText("Post has been updated."); 140 | 141 | // Reopen the post 142 | await page.goto(`/posts/${postId}`); 143 | await page.getByTestId("open-post-drawer").click(); 144 | // Wait for the drawer to open 145 | await page.locator('text="Select Image"').waitFor(); 146 | 147 | // Check that deleted image has dissapeared 148 | await expect(page.getByTestId("feature-image")).not.toBeVisible(); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /e2e/editor.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto("/posts/2"); 5 | }); 6 | 7 | test("it should be possible to type in the editor", async ({ page }) => { 8 | const editor = page.getByTestId("editor"); 9 | const wordCount = page.getByTestId("word-count"); 10 | 11 | await editor.press("Control+A"); 12 | await editor.press("Backspace"); 13 | await expect(wordCount).toHaveText("0 words"); 14 | 15 | await editor.pressSequentially("Hello World"); 16 | await expect(wordCount).toHaveText("2 words"); 17 | }); 18 | 19 | test("it should have eleven buttons in the toolbar", async ({ page }) => { 20 | const buttons = page.locator("#toolbar > button"); 21 | await expect(buttons).toHaveCount(10); 22 | 23 | const addImageButton = page.locator("#toolbar > label > button"); 24 | await expect(addImageButton).toHaveCount(1); 25 | }); 26 | 27 | test("it should be possible to edit the title", async ({ page }) => { 28 | await page.getByTestId("post-title").click(); 29 | 30 | const titleField = page.getByTestId("post-title-field"); 31 | await titleField.fill("New Title"); 32 | await page.keyboard.press("Enter"); 33 | 34 | const newTitle = await page.getByTestId("post-title").innerText(); 35 | expect(newTitle).toBe("New Title"); 36 | }); 37 | 38 | test("it should have bubble menu when text is selected with 3 buttons", async ({ 39 | page, 40 | }) => { 41 | const bubbleMenu = page.locator("#bubble-menu"); 42 | await expect(bubbleMenu).not.toBeVisible(); 43 | 44 | await page.getByTestId("editor").fill("Hello World"); 45 | await page.getByTestId("editor").selectText(); 46 | 47 | await expect(bubbleMenu).toBeVisible(); 48 | await expect(bubbleMenu).toHaveCount(1); 49 | 50 | const buttons = page.locator("#bubble-menu > button"); 51 | await expect(buttons).toHaveCount(3); 52 | }); 53 | -------------------------------------------------------------------------------- /e2e/fixtures/feature-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/publish/3b34d0108e7b87932b43ed5969ad028097d56447/e2e/fixtures/feature-image.png -------------------------------------------------------------------------------- /e2e/helpers/constants.ts: -------------------------------------------------------------------------------- 1 | export const API_URL = "http://localhost:1337"; 2 | 3 | export const EDITOR_CREDENTIALS = { 4 | identifier: "editor@user.com", 5 | password: "editor", 6 | }; 7 | 8 | export const INVITEE_CREDENTIALS = { 9 | identifier: "invited@user.com", 10 | password: "invited", 11 | }; 12 | 13 | export const CONTRIBUTOR_CREDENTIALS = { 14 | identifier: "contributor@user.com", 15 | password: "contributor", 16 | }; 17 | -------------------------------------------------------------------------------- /e2e/helpers/post.ts: -------------------------------------------------------------------------------- 1 | import type { Page, APIRequestContext } from "@playwright/test"; 2 | import path from "path"; 3 | import fs from "fs"; 4 | import FormData from "form-data"; 5 | import fetch from "node-fetch"; 6 | 7 | import { API_URL, EDITOR_CREDENTIALS } from "./constants"; 8 | import { getBearerToken } from "./user"; 9 | 10 | export async function deletePost(request: APIRequestContext, postId: string) { 11 | const jwt = await getBearerToken(request, EDITOR_CREDENTIALS); 12 | await request.delete(`${API_URL}/api/posts/${postId}`, { 13 | headers: { 14 | Authorization: `Bearer ${jwt}`, 15 | }, 16 | }); 17 | } 18 | 19 | export function getPostIdInURL(page: Page) { 20 | const pageUrl = page.url(); 21 | const match = pageUrl.match(/.*\/posts\/(\d+)/); 22 | const postId = match ? match[1] : null; // Extract the new post id from the URL 23 | return postId; 24 | } 25 | 26 | export async function createPostWithFeatureImage( 27 | page: Page, 28 | request: APIRequestContext, 29 | ) { 30 | // Create a new post via API 31 | const jwt = await getBearerToken(request, EDITOR_CREDENTIALS); 32 | const timestamp = Date.now(); 33 | const createPostRes = await request.post(`${API_URL}/api/posts`, { 34 | headers: { 35 | Authorization: `Bearer ${jwt}`, 36 | }, 37 | data: { 38 | data: { 39 | title: `Test Post ${timestamp}`, 40 | slug: `test-post-${timestamp}`, // Make sure the slug is unique 41 | body: "Test post body", 42 | author: [1], 43 | }, 44 | }, 45 | }); 46 | const postId = ((await createPostRes.json()) as { data: { id: string } }).data 47 | .id; 48 | 49 | // Attach a feature image to the post 50 | const formData = new FormData(); 51 | const image = fs.createReadStream( 52 | path.join(__dirname, "..", "fixtures", "feature-image.png"), 53 | ); 54 | formData.append("files", image); 55 | formData.append("refId", postId); 56 | formData.append("ref", "api::post.post"); 57 | formData.append("field", "feature_image"); 58 | // Using fetch here since Playwright's request context didn't work 59 | await fetch(new URL("api/upload", API_URL), { 60 | method: "POST", 61 | headers: { 62 | Accept: "application/json", 63 | Authorization: `Bearer ${jwt}`, 64 | }, 65 | body: formData, 66 | }); 67 | 68 | return postId; 69 | } 70 | -------------------------------------------------------------------------------- /e2e/helpers/user.ts: -------------------------------------------------------------------------------- 1 | import type { Page, APIRequestContext } from "@playwright/test"; 2 | import { expect } from "@playwright/test"; 3 | 4 | import { API_URL, EDITOR_CREDENTIALS } from "./constants"; 5 | 6 | export async function getBearerToken( 7 | request: APIRequestContext, 8 | data: { identifier: string; password: string }, 9 | ) { 10 | const editorRes = await request.post(API_URL + "/api/auth/local", { 11 | data, 12 | }); 13 | expect(editorRes.status()).toBe(200); 14 | return ((await editorRes.json()) as { jwt: string }).jwt; 15 | } 16 | 17 | // This type is not exhaustive - it only contains the types currently used by the tests. 18 | 19 | type User = { 20 | id: string; 21 | }; 22 | 23 | function validateUsers(users: unknown[]): asserts users is User[] { 24 | users.forEach(validateUser); 25 | } 26 | 27 | function validateUser(user: unknown): asserts user is User { 28 | expect(user).toHaveProperty("id"); 29 | } 30 | 31 | async function getUsersHelper( 32 | request: APIRequestContext, 33 | data: { identifier: string; jwt: string }, 34 | ): Promise { 35 | const usersRes = await request.get( 36 | `${API_URL}/api/users?filters[email][$eq]=${data.identifier}`, 37 | { 38 | headers: { 39 | Authorization: `Bearer ${data.jwt}`, 40 | }, 41 | }, 42 | ); 43 | 44 | const users = (await usersRes.json()) as unknown[]; 45 | validateUsers(users); 46 | return users; 47 | } 48 | 49 | export async function deleteUser( 50 | request: APIRequestContext, 51 | data: { identifier: string }, 52 | ) { 53 | const jwt = await getBearerToken(request, EDITOR_CREDENTIALS); 54 | const user = await getUsersHelper(request, { ...data, jwt }); 55 | if (user.length) { 56 | await request.delete(`${API_URL}/api/users/${user[0].id}`, { 57 | headers: { 58 | Authorization: `Bearer ${jwt}`, 59 | }, 60 | }); 61 | } 62 | } 63 | 64 | export async function signIn( 65 | page: Page, 66 | credentials: { identifier: string; password: string }, 67 | ) { 68 | await page.goto("/api/auth/signin?callbackUrl=%2Fposts"); 69 | 70 | const emailField = page.getByLabel("Email"); 71 | const passwordField = page.getByLabel("Password"); 72 | const signinButton = page.getByRole("button", { name: "Sign in with email" }); 73 | 74 | // Sign in via the UI 75 | await emailField.click(); 76 | await emailField.fill(credentials.identifier); 77 | await emailField.press("Tab"); 78 | await expect(passwordField).toBeFocused(); 79 | await passwordField.fill(credentials.password); 80 | await passwordField.press("Tab"); 81 | await expect(signinButton).toBeFocused(); 82 | await signinButton.click(); 83 | 84 | // Wait until the page receives the cookies. 85 | await page.waitForURL("**/posts"); 86 | } 87 | 88 | async function getUserByEmail( 89 | request: APIRequestContext, 90 | data: { identifier: string }, 91 | ) { 92 | const jwt = await getBearerToken(request, EDITOR_CREDENTIALS); 93 | const users = await getUsersHelper(request, { ...data, jwt }); 94 | // There should only be one user with this username, so we should assert that. 95 | expect(users).toHaveLength(1); 96 | return users[0]; 97 | } 98 | 99 | // By default an invited user has the Auth0 provider. To allow the user to sign 100 | // in in testing, we need to change the provider to local and set a password. 101 | export async function useCredentialsForAuth( 102 | request: APIRequestContext, 103 | data: { identifier: string; password: string }, 104 | ) { 105 | const { password } = data; 106 | const jwt = await getBearerToken(request, EDITOR_CREDENTIALS); 107 | const user = await getUserByEmail(request, data); 108 | const updateUserUrl = `${API_URL}/api/users/${user.id}`; 109 | const userRes = await request.put(updateUserUrl, { 110 | headers: { 111 | Authorization: `Bearer ${jwt}`, 112 | }, 113 | data: { provider: "local", password }, 114 | }); 115 | expect(userRes.status()).toBe(200); 116 | const updatedUser = (await userRes.json()) as unknown; 117 | validateUser(updatedUser); 118 | return updatedUser; 119 | } 120 | -------------------------------------------------------------------------------- /e2e/pages/users.ts: -------------------------------------------------------------------------------- 1 | import type { Page, Locator } from "@playwright/test"; 2 | 3 | export class UsersPage { 4 | private readonly activeUsers: Locator; 5 | private readonly invitedUsers: Locator; 6 | 7 | constructor(public readonly page: Page) { 8 | this.activeUsers = page.locator('[data-testid="active-user"]'); 9 | this.invitedUsers = page.locator('[data-testid="invited-user"]'); 10 | } 11 | 12 | getInvitedUser(email: string) { 13 | return this.invitedUsers.filter({ hasText: email }); 14 | } 15 | 16 | getActiveUser(email: string) { 17 | return this.activeUsers.filter({ hasText: email }); 18 | } 19 | 20 | async inviteUser(email: string) { 21 | await this.page.getByRole("button", { name: "Invite user" }).click(); 22 | await this.page.getByLabel("Email*").click(); 23 | await this.page.getByLabel("Email*").fill(email); 24 | await this.page.getByRole("button", { name: "Send invitation" }).click(); 25 | } 26 | 27 | async revokeUser(email: string) { 28 | const revokeButton = this.getInvitedUser(email).getByRole("button", { 29 | name: "Revoke", 30 | }); 31 | await revokeButton.click(); 32 | } 33 | 34 | async goto() { 35 | await this.page.goto("/users"); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /e2e/posts-preview.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | import { deletePost, createPostWithFeatureImage } from "./helpers/post"; 4 | 5 | test.describe("feature image", () => { 6 | const postIdsToDelete: string[] = []; 7 | 8 | test.afterAll(async ({ request }) => { 9 | // delete all posts created in the tests 10 | for (const postId of postIdsToDelete) { 11 | await deletePost(request, postId); 12 | } 13 | }); 14 | 15 | test("feature image should be visible in preview", async ({ 16 | page, 17 | request, 18 | }) => { 19 | // Prepare existing post that has a feature image 20 | const postId = await createPostWithFeatureImage(page, request); 21 | postIdsToDelete.push(postId); 22 | 23 | // Open a post 24 | await page.goto(`/posts/${postId}`); 25 | 26 | // Open the drawer 27 | const drawerButton = page.getByTestId("open-post-drawer"); 28 | await drawerButton.click(); 29 | 30 | // Open the preview 31 | const [newPage] = await Promise.all([ 32 | page.waitForEvent("popup"), 33 | page.getByRole("button", { name: "Preview" }).click(), 34 | ]); 35 | 36 | // Check that saved feature image is visible 37 | await expect(newPage.getByTestId("feature-image-preview")).toBeVisible(); 38 | }); 39 | 40 | test("preview should open without feature image", async ({ page }) => { 41 | // Open a post 42 | await page.goto("/posts/1"); 43 | 44 | // Open the drawer 45 | const drawerButton = page.getByTestId("open-post-drawer"); 46 | await drawerButton.click(); 47 | 48 | // Open the preview 49 | const [newPage] = await Promise.all([ 50 | page.waitForEvent("popup"), 51 | page.getByRole("button", { name: "Preview" }).click(), 52 | ]); 53 | 54 | // Check that the preview was opened successfully 55 | await expect(newPage.locator('text="No image provided"')).toBeVisible(); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /e2e/posts.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test("has a button to create a new post", async ({ page }) => { 4 | await page.goto("/posts"); 5 | 6 | await page.getByRole("button", { name: "New post" }).click(); 7 | 8 | await expect(page).toHaveURL(/.*\/posts\/\d+/); 9 | await expect(page.getByRole("link", { name: "Posts" })).toBeVisible(); 10 | }); 11 | 12 | test("an editor can view published and drafted posts in the list of posts", async ({ 13 | page, 14 | }) => { 15 | await page.goto("/posts"); 16 | 17 | // The filter on posts page should be "All posts" by default 18 | await expect(page.getByRole("button", { name: "All posts" })).toBeVisible(); 19 | 20 | // Show 50 posts to see both draft and published posts 21 | await page.getByTestId("results-per-page").dispatchEvent("click"); 22 | await page.click("text=50"); 23 | // Wait for the table to load 24 | await page.waitForSelector("table tbody tr:nth-child(6)"); 25 | 26 | // Check that both draft and published posts are in the list 27 | const publishedCount = await page 28 | .locator("data-testid=published-badge") 29 | .count(); 30 | expect(publishedCount).toBeGreaterThanOrEqual(1); 31 | const draftCount = await page.locator("data-testid=draft-badge").count(); 32 | expect(draftCount).toBeGreaterThanOrEqual(1); 33 | }); 34 | 35 | test("an editor can filter by draft posts", async ({ page }) => { 36 | await page.goto("/posts"); 37 | 38 | // Show 50 posts to see both draft and published posts 39 | await page.getByTestId("results-per-page").dispatchEvent("click"); 40 | await page.click("text=50"); 41 | 42 | // Select the "Drafts posts" filter 43 | await page.getByRole("button", { name: "All posts" }).dispatchEvent("click"); 44 | await page.click("text=Drafts posts"); 45 | 46 | // Wait for published posts to disappear 47 | await expect(page.locator("data-testid=published-badge")).toHaveCount(0); 48 | // Check that draft posts are in the list 49 | const draftCount = await page.locator("data-testid=draft-badge").count(); 50 | expect(draftCount).toBeGreaterThanOrEqual(1); 51 | }); 52 | 53 | test("an editor can filter by published posts", async ({ page }) => { 54 | await page.goto("/posts"); 55 | 56 | // Show 50 posts to see both draft and published posts 57 | await page.getByTestId("results-per-page").dispatchEvent("click"); 58 | await page.click("text=50"); 59 | 60 | // Select the "Published posts" filter 61 | await page.getByRole("button", { name: "All posts" }).dispatchEvent("click"); 62 | await page.click("text=Published posts"); 63 | 64 | // Wait for draft posts to disappear 65 | await expect(page.locator("data-testid=draft-badge")).toHaveCount(0); 66 | // Check that published posts are in the list 67 | const publishedCount = await page 68 | .locator("data-testid=published-badge") 69 | .count(); 70 | expect(publishedCount).toBeGreaterThanOrEqual(1); 71 | }); 72 | -------------------------------------------------------------------------------- /e2e/tag.spec.ts: -------------------------------------------------------------------------------- 1 | import { type Page, expect, test } from "@playwright/test"; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto("/tags"); 5 | }); 6 | 7 | // Helper function to wait for the tags page to load. This is because waitForURL 8 | // doesn't always work as expected. However, we know that the "New Tag" button 9 | // is always visible on the tags page. 10 | async function waitForTags(page: Page) { 11 | return await expect( 12 | page.getByRole("link", { name: "New Tag" }), 13 | ).toBeVisible(); 14 | } 15 | 16 | test("it should be possible to create and delete a new tag", async ({ 17 | page, 18 | }) => { 19 | // first create a new tag 20 | await page.getByRole("link", { name: "New Tag" }).click(); 21 | 22 | await page.fill("input[name=name]", "My new tag"); 23 | await page.fill("input[name=slug]", "my-new-tag"); 24 | 25 | await page.getByRole("button", { name: "Save" }).click(); 26 | await waitForTags(page); 27 | 28 | // now delete it 29 | await page.getByText("My new tag").click(); 30 | 31 | await page.getByRole("button", { name: "Delete tag" }).click(); 32 | 33 | await page.getByRole("button", { name: "Delete", exact: true }).click(); 34 | await expect(page.getByText("Are you sure")).toBeHidden(); 35 | await expect(page.getByText("My new tag")).toBeHidden(); 36 | }); 37 | 38 | test("it should be possible to edit a tag", async ({ page }) => { 39 | await page.getByText("HTML", { exact: true }).click(); 40 | 41 | await page.fill("input[name=name]", "HTML - edited"); 42 | await page.getByRole("button", { name: "Save" }).click(); 43 | await waitForTags(page); 44 | await expect(page.getByText("HTML - edited")).toBeVisible(); 45 | 46 | // undo the edit 47 | await page.getByText("HTML - edited").click(); 48 | await page.fill("input[name=name]", "HTML"); 49 | await page.getByRole("button", { name: "Save" }).click(); 50 | }); 51 | 52 | test("it should be possible to create a new internal tag", async ({ page }) => { 53 | await page.getByRole("link", { name: "New Tag" }).click(); 54 | 55 | await page.fill("input[name=name]", "My new tag"); 56 | await page.fill("input[name=slug]", "my-new-tag"); 57 | 58 | await page.check("text=Internal Tag"); 59 | 60 | await page.getByRole("button", { name: "Save" }).click(); 61 | await page.getByRole("radiogroup").getByText("internal tags").click(); 62 | 63 | // TODO: delete via api call and just check that it's visible 64 | await page.getByText("My new tag").click(); 65 | await page.getByRole("button", { name: "Delete tag" }).click(); 66 | await page.getByRole("button", { name: "Delete", exact: true }).click(); 67 | await expect(page.getByText("Are you sure")).toBeHidden(); 68 | await expect(page.getByText("My new tag")).toBeHidden(); 69 | }); 70 | 71 | test("it should handle empty name fields correctly", async ({ page }) => { 72 | await page.getByRole("link", { name: "New Tag" }).click(); 73 | 74 | await page.getByRole("button", { name: "Save" }).click(); 75 | 76 | await page.getByText("You must specify a name for the tag.").isVisible(); 77 | }); 78 | 79 | // TODO: add handling for empty slug fields 80 | 81 | // TODO: add handling for duplicate slug fields 82 | -------------------------------------------------------------------------------- /e2e/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [".eslintrc.js"], 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "noEmit": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | // Why is this here and not the root? Because if it's in the root, it will be 2 | // picked up by Strapi (it looks up the tree for tsconfig.json files). We could 3 | // avoid that by calling it something else, but then VSCode would not discover 4 | // it. 5 | { 6 | "include": ["**/*.ts"], 7 | "compilerOptions": { 8 | "esModuleInterop": true, 9 | "noEmit": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "skipLibCheck": true // Since we aren't using pnpm, there are conflicts in the types. 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /e2e/users.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | import { UsersPage } from "./pages/users"; 4 | import { useCredentialsForAuth, signIn, deleteUser } from "./helpers/user"; 5 | 6 | const NEW_USER_CREDENTIALS = { 7 | identifier: "new@user.com", 8 | password: "password", 9 | }; 10 | 11 | test.describe("inviting a user", () => { 12 | let usersPage: UsersPage; 13 | 14 | test.beforeEach(async ({ browser }) => { 15 | // To avoid using the invitee's credentials, we have to create a new context 16 | const editorContext = await browser.newContext({ 17 | storageState: "playwright/.auth/editor.json", 18 | }); 19 | usersPage = new UsersPage(await editorContext.newPage()); 20 | await usersPage.goto(); 21 | }); 22 | 23 | test.afterEach(async ({ request }) => { 24 | // Delete the user if it exists 25 | await deleteUser(request, NEW_USER_CREDENTIALS); 26 | }); 27 | 28 | test("invitations can be created and revoked", async () => { 29 | await usersPage.inviteUser(NEW_USER_CREDENTIALS.identifier); 30 | await expect( 31 | usersPage.getInvitedUser(NEW_USER_CREDENTIALS.identifier), 32 | ).toBeVisible(); 33 | 34 | await usersPage.revokeUser(NEW_USER_CREDENTIALS.identifier); 35 | await expect( 36 | usersPage.getInvitedUser(NEW_USER_CREDENTIALS.identifier), 37 | ).toBeHidden(); 38 | }); 39 | 40 | test("invited users become active by signing in", async ({ 41 | page, 42 | request, 43 | }) => { 44 | await usersPage.inviteUser(NEW_USER_CREDENTIALS.identifier); 45 | 46 | // Allow the user to sign in with email/password, not Auth0. 47 | await useCredentialsForAuth(request, NEW_USER_CREDENTIALS); 48 | 49 | await signIn(page, NEW_USER_CREDENTIALS); 50 | 51 | // After signing in, the invited user should be in the active list 52 | await usersPage.page.reload(); 53 | await expect( 54 | usersPage.getActiveUser(NEW_USER_CREDENTIALS.identifier), 55 | ).toBeVisible(); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@playwright/test": "^1.39.0", 4 | "@types/node": "^20.8.10", 5 | "@types/node-fetch": "^2.6.11", 6 | "@typescript-eslint/eslint-plugin": "^6.20.0", 7 | "@typescript-eslint/parser": "^6.20.0", 8 | "eslint": "^8.56.0", 9 | "husky": "^8.0.0", 10 | "lint-staged": "^14.0.0", 11 | "turbo": "1.10.16", 12 | "typescript": "^5.3.3" 13 | }, 14 | "scripts": { 15 | "build": "turbo build", 16 | "prepare": "husky install", 17 | "lint": "turbo lint --continue", 18 | "eslint": "eslint e2e", 19 | "seed": "turbo seed", 20 | "start": "turbo start", 21 | "develop": "turbo develop", 22 | "run-tools": "cd tools && docker compose up -d", 23 | "test": "turbo test", 24 | "turbo": "turbo", 25 | "type-check": "tsc --noEmit --project e2e/tsconfig.json", 26 | "format": "prettier --write --ignore-path=apps/backend/.gitignore --ignore-path=apps/frontend/.gitignore --ignore-path=apps/cron/.gitignore --ignore-path=.gitignore .", 27 | "prettier-check": "prettier --check --ignore-path=apps/backend/.gitignore --ignore-path=apps/frontend/.gitignore --ignore-path=apps/cron/.gitignore --ignore-path=.gitignore ." 28 | }, 29 | "workspaces": [ 30 | "apps/*" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: "./e2e", 14 | /* Run tests in a single file in series */ 15 | fullyParallel: false, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 2 : 0, 20 | /* Opt out of parallel tests on CI. */ 21 | workers: process.env.CI ? 1 : undefined, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: "html", 24 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 25 | use: { 26 | /* Base URL to use in actions like `await page.goto('/')`. */ 27 | baseURL: "http://localhost:3000", 28 | 29 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 30 | trace: "on-first-retry", 31 | }, 32 | 33 | /* Configure projects for major browsers */ 34 | projects: [ 35 | { name: "setup", testMatch: /.*\.setup\.ts/ }, 36 | { 37 | name: "chromium", 38 | testIgnore: "e2e/contributor/**", 39 | use: { 40 | ...devices["Desktop Chrome"], 41 | storageState: "playwright/.auth/editor.json", 42 | }, 43 | dependencies: ["setup"], 44 | }, 45 | 46 | { 47 | name: "firefox", 48 | testIgnore: "e2e/contributor/**", 49 | use: { 50 | ...devices["Desktop Firefox"], 51 | storageState: "playwright/.auth/editor.json", 52 | }, 53 | dependencies: ["setup"], 54 | }, 55 | 56 | { 57 | name: "webkit", 58 | testIgnore: "e2e/contributor/**", 59 | use: { 60 | ...devices["Desktop Safari"], 61 | storageState: "playwright/.auth/editor.json", 62 | }, 63 | dependencies: ["setup"], 64 | }, 65 | 66 | { 67 | name: "chromium-contributor", 68 | testDir: "e2e/contributor", 69 | use: { 70 | ...devices["Desktop Chrome"], 71 | storageState: "playwright/.auth/contributor.json", 72 | }, 73 | dependencies: ["setup"], 74 | }, 75 | 76 | { 77 | name: "firefox-contributor", 78 | testDir: "e2e/contributor", 79 | use: { 80 | ...devices["Desktop Firefox"], 81 | storageState: "playwright/.auth/contributor.json", 82 | }, 83 | dependencies: ["setup"], 84 | }, 85 | 86 | { 87 | name: "webkit-contributor", 88 | testDir: "e2e/contributor", 89 | use: { 90 | ...devices["Desktop Safari"], 91 | storageState: "playwright/.auth/contributor.json", 92 | }, 93 | dependencies: ["setup"], 94 | }, 95 | 96 | /* Test against mobile viewports. */ 97 | // { 98 | // name: 'Mobile Chrome', 99 | // use: { ...devices['Pixel 5'] }, 100 | // }, 101 | // { 102 | // name: 'Mobile Safari', 103 | // use: { ...devices['iPhone 12'] }, 104 | // }, 105 | 106 | /* Test against branded browsers. */ 107 | // { 108 | // name: 'Microsoft Edge', 109 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 110 | // }, 111 | // { 112 | // name: 'Google Chrome', 113 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 114 | // }, 115 | ], 116 | 117 | /* Run your local dev server before starting the tests */ 118 | // Using port so that playwright will wait for either 127.0.0.1 or ::1 119 | webServer: [ 120 | { 121 | command: "npm run start -- --filter=backend", 122 | port: 1337, 123 | reuseExistingServer: !process.env.CI, 124 | }, 125 | { 126 | command: "npm run start -- --filter=frontend", 127 | port: 3000, 128 | reuseExistingServer: !process.env.CI, 129 | }, 130 | ], 131 | }); 132 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>freecodecamp/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /tools/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | strapiDB: 4 | platform: linux/amd64 #for platform error on Apple M1 chips 5 | restart: unless-stopped 6 | image: postgres:14-alpine 7 | environment: 8 | POSTGRES_USER: strapi 9 | POSTGRES_PASSWORD: password 10 | POSTGRES_DB: strapi 11 | volumes: 12 | - strapi-data:/var/lib/postgresql/data/ #using a volume 13 | # - ./data:/var/lib/postgresql/data/ # if you want to use a bind folder 14 | 15 | ports: 16 | - "5432:5432" 17 | 18 | mailhog: 19 | image: mailhog/mailhog 20 | platform: linux/amd64 21 | logging: 22 | driver: none 23 | ports: 24 | - "1025:1025" 25 | - "8025:8025" 26 | 27 | volumes: 28 | strapi-data: 29 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "outputs": [".next/**", "!.next/cache/**", "build/**"], 6 | "dotEnv": [".env.local", ".env"] 7 | }, 8 | "start": { 9 | "dependsOn": ["build"] 10 | }, 11 | "test": {}, 12 | "lint": { 13 | "dependsOn": [ 14 | "//#prettier-check", 15 | "//#eslint", 16 | "eslint", 17 | "//#type-check", 18 | "type-check" 19 | ] 20 | }, 21 | "//#prettier-check": {}, 22 | "//#type-check": {}, 23 | "type-check": {}, 24 | "//#eslint": {}, 25 | "eslint": {}, 26 | "//#format": {}, 27 | "develop": { 28 | "cache": false, 29 | "persistent": true 30 | }, 31 | "clean": { 32 | "cache": false 33 | }, 34 | "seed": { "cache": false, "dependsOn": ["init"] }, 35 | "init": { "cache": false } 36 | } 37 | } 38 | --------------------------------------------------------------------------------