├── .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 |
57 |
58 | Resource center - Strapi
59 | resource center.
60 |
61 |
62 | Strapi documentation - Official Strapi
63 | documentation.
64 |
65 |
66 | Strapi tutorials - List of
67 | tutorials made by the core team and the community.
68 |
69 |
70 | Strapi blog - Official Strapi blog
71 | containing articles made by the Strapi team and the community.
72 |
73 |
74 | Changelog - Find out about the
75 | Strapi product updates, new features and general improvements.
76 |
77 |
78 |
79 | Feel free to check out the
80 | Strapi GitHub repository . Your
81 | feedback and contributions are welcome!
82 |
83 | ✨ Community
84 |
85 |
86 | Discord - Come chat with the Strapi
87 | community including the core team.
88 |
89 |
90 | Forum - Place to discuss, ask
91 | questions and find answers, show your Strapi project and get feedback or
92 | just talk with other Community members.
93 |
94 |
95 | Awesome Strapi - A
96 | curated list of awesome things related to Strapi.
97 |
98 |
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\nBut don’t worry! You can use the following link to reset your password:
\n<%= URL %>?code=<%= TOKEN %>
\n\nThanks.
"
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\nYou have to confirm your email address. Please click on the link below.
\n\n<%= URL %>?confirmation=<%= CODE %>
\n\nThanks.
"
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 |
20 | router.replace({
21 | pathname: `/${endpoint}`,
22 | query: { page: page - 1, ...queryParams },
23 | })
24 | }
25 | >
26 | {"<"}
27 |
28 |
35 |
36 | {pageCount > 0 ? page : "0"} of {pageCount}
37 |
38 |
39 |
43 | router.replace({
44 | pathname: `/${endpoint}`,
45 | query: { page: page + 1, ...queryParams },
46 | })
47 | }
48 | >
49 | {">"}
50 |
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 |
5 | {allTagsData.data.map((tag) => {
6 | return (
7 |
8 | {tag.attributes.name}
9 |
10 | );
11 | })}
12 |
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 |
6 | {allUsersData.map((user) => {
7 | console.log(user);
8 | return (
9 |
10 | {user.username}
11 |
12 | email: {user.email}
13 |
14 | );
15 | })}
16 |
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 |
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 | signIn(undefined, { callbackUrl: "/posts" })}
49 | w="100%"
50 | colorScheme="blue"
51 | >
52 | Sign In
53 |
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 |
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 |
17 | View Posts
18 |
19 | >
20 | );
21 | }
22 | return (
23 | <>
24 | signIn()}>Sign in
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 |
--------------------------------------------------------------------------------