├── .gitignore
├── README.md
├── client
├── .dockerignore
├── Dockerfile
├── README.md
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ ├── index.html
│ ├── logo192.png
│ ├── logo512.png
│ ├── manifest.json
│ └── robots.txt
└── src
│ ├── App.css
│ ├── App.js
│ ├── App.test.js
│ ├── index.css
│ ├── index.js
│ ├── logo.svg
│ ├── reportWebVitals.js
│ └── setupTests.js
├── data
└── nginx
│ └── app.conf
├── docker-compose.yml
├── init-letsencrypt.sh
└── server
├── .babelrc
├── .dockerignore
├── Dockerfile
├── index.js
├── package-lock.json
└── package.json
/.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 | # production
12 | build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | # certbot
26 | certbot
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-node-letsencrypt-nginx-boilerplate
2 |
3 | A full-stack https application boilerplate made with Docker, LetsEncrypt, Nginx, React, and Express
4 |
5 |
6 | ## Prerequisites
7 | 1. Docker
8 | 2. Docker Compose
9 | 3. Domain name with DNS records pointing at target machine
10 |
11 | ## Installation
12 | 1. Clone project
13 | ``` bash
14 | git clone https://github.com/munikeraragon/react-express-letsencrypt-nginx-boilerplate.git
15 | ```
16 | 2. cd into folder
17 | ``` bash
18 | cd react-express-letsencrypt-nginx-boilerplate
19 | ```
20 |
21 | 3. Edit init-letsecncrypt.sh by replacing "example.com" and "www.example.com" with your domain name
22 |
23 | 4. Edit ./data/nginx/app.conf by replacing "example.com" with your domain name
24 |
25 | 5. Edit ./server/index.js by replacing "example.com" with your domain name
26 |
27 | 3. Generate Letsencrypt certificates
28 |
29 | ``` bash
30 | sudo ./init-letsencrypt.sh
31 | ```
32 |
33 | 4. Create "nodecert" group to allow Express server to read letsencrypt certifictes
34 |
35 | ``` bash
36 | # Create group "nodecert" with gid=1024
37 | sudo groupadd nodecert -g 1024
38 |
39 | # Add root to the group "nodecert"
40 | sudo usermod -a -G nodecert root
41 |
42 | # make group "nodecert" owner of files recursively
43 | sudo chgrp -R nodecert data/certbot/conf/live
44 | sudo chgrp -R nodecert data/certbot/conf/archive
45 |
46 | # change permission of files to be accesible by group "nodecert"
47 | sudo chmod -R 750 data/certbot/conf/live
48 | sudo chmod -R 750 data/certbot/conf/archive
49 | ```
50 |
51 | 5. Build docker-compose services
52 | ``` bash
53 | docker-compose build
54 | ```
55 |
56 | ## Usage
57 | 1. Run docker-compose services
58 | ``` bash
59 | docker-compose up
60 | ```
61 |
62 | 2. Access React client at https://your_domain_name.com and Express server at https://your_domain_name.com:5000
63 |
64 |
65 |
66 | ## Learn how it was made
67 | Article: https://www.codegrow.org
--------------------------------------------------------------------------------
/client/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | build
4 | .dockerignore
5 | **/.git
6 | **/.DS_Store
7 | **/node_modules
--------------------------------------------------------------------------------
/client/Dockerfile:
--------------------------------------------------------------------------------
1 | # pull the base image
2 | FROM node:16
3 |
4 | # set the working direction
5 | WORKDIR /usr/src/app/
6 |
7 | # add `/app/node_modules/.bin` to $PATH
8 | ENV PATH /app/node_modules/.bin:$PATH
9 |
10 | # install app dependencies
11 | COPY package.json ./
12 | COPY package-lock.json ./
13 | RUN npm install
14 |
15 | EXPOSE 3000
16 |
17 | # start app
18 | CMD ["npm", "start"]
19 |
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `npm start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.\
15 | You will also see any lint errors in the console.
16 |
17 | ### `npm test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `npm run build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `npm run eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
35 |
36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
39 |
40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
48 | ### Code Splitting
49 |
50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
51 |
52 | ### Analyzing the Bundle Size
53 |
54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
55 |
56 | ### Making a Progressive Web App
57 |
58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
59 |
60 | ### Advanced Configuration
61 |
62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
63 |
64 | ### Deployment
65 |
66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
67 |
68 | ### `npm run build` fails to minify
69 |
70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
71 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@testing-library/jest-dom": "^5.12.0",
7 | "@testing-library/react": "^11.2.7",
8 | "@testing-library/user-event": "^12.8.3",
9 | "react": "^17.0.2",
10 | "react-dom": "^17.0.2",
11 | "react-scripts": "4.0.3",
12 | "web-vitals": "^1.1.2"
13 | },
14 | "scripts": {
15 | "start": "react-scripts start",
16 | "build": "react-scripts build",
17 | "test": "react-scripts test",
18 | "eject": "react-scripts eject"
19 | },
20 | "eslintConfig": {
21 | "extends": [
22 | "react-app",
23 | "react-app/jest"
24 | ]
25 | },
26 | "browserslist": {
27 | "production": [
28 | ">0.2%",
29 | "not dead",
30 | "not op_mini all"
31 | ],
32 | "development": [
33 | "last 1 chrome version",
34 | "last 1 firefox version",
35 | "last 1 safari version"
36 | ]
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/munikeraragon/react-express-letsencrypt-nginx-boilerplate/1387b25f12d3fae7e075c60fc751e9496d9bd27b/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/client/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/munikeraragon/react-express-letsencrypt-nginx-boilerplate/1387b25f12d3fae7e075c60fc751e9496d9bd27b/client/public/logo192.png
--------------------------------------------------------------------------------
/client/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/munikeraragon/react-express-letsencrypt-nginx-boilerplate/1387b25f12d3fae7e075c60fc751e9496d9bd27b/client/public/logo512.png
--------------------------------------------------------------------------------
/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/client/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/client/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-logo {
6 | height: 40vmin;
7 | pointer-events: none;
8 | }
9 |
10 | @media (prefers-reduced-motion: no-preference) {
11 | .App-logo {
12 | animation: App-logo-spin infinite 20s linear;
13 | }
14 | }
15 |
16 | .App-header {
17 | background-color: #282c34;
18 | min-height: 100vh;
19 | display: flex;
20 | flex-direction: column;
21 | align-items: center;
22 | justify-content: center;
23 | font-size: calc(10px + 2vmin);
24 | color: white;
25 | }
26 |
27 | .App-link {
28 | color: #61dafb;
29 | }
30 |
31 | @keyframes App-logo-spin {
32 | from {
33 | transform: rotate(0deg);
34 | }
35 | to {
36 | transform: rotate(360deg);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/client/src/App.js:
--------------------------------------------------------------------------------
1 | import logo from './logo.svg';
2 | import './App.css';
3 |
4 | function App() {
5 | return (
6 |
22 | );
23 | }
24 |
25 | export default App;
26 |
--------------------------------------------------------------------------------
/client/src/App.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import App from './App';
3 |
4 | test('renders learn react link', () => {
5 | render();
6 | const linkElement = screen.getByText(/learn react/i);
7 | expect(linkElement).toBeInTheDocument();
8 | });
9 |
--------------------------------------------------------------------------------
/client/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12 | monospace;
13 | }
14 |
--------------------------------------------------------------------------------
/client/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 | import reportWebVitals from './reportWebVitals';
6 |
7 | ReactDOM.render(
8 |
9 |
10 | ,
11 | document.getElementById('root')
12 | );
13 |
14 | // If you want to start measuring performance in your app, pass a function
15 | // to log results (for example: reportWebVitals(console.log))
16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
17 | reportWebVitals();
18 |
--------------------------------------------------------------------------------
/client/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = onPerfEntry => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/client/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/data/nginx/app.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name example.com;
4 | server_tokens off;
5 |
6 | location /.well-known/acme-challenge/ {
7 | root /var/www/certbot;
8 | }
9 |
10 | location / {
11 | return 301 https://$host$request_uri;
12 | }
13 | }
14 |
15 | server {
16 | listen 443 ssl;
17 | server_name example.com;
18 | server_tokens off;
19 |
20 | ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
21 | ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
22 | include /etc/letsencrypt/options-ssl-nginx.conf;
23 | ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
24 |
25 | location / {
26 | proxy_pass http://client:3000;
27 | proxy_set_header Host $http_host;
28 | proxy_set_header X-Real-IP $remote_addr;
29 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
30 | }
31 | }
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.1"
2 |
3 | services:
4 | nginx:
5 | image: nginx:1.15-alpine
6 | restart: unless-stopped
7 | volumes:
8 | - ./data/nginx:/etc/nginx/conf.d
9 | - ./data/certbot/conf:/etc/letsencrypt
10 | - ./data/certbot/www:/var/www/certbot
11 | ports:
12 | - "80:80"
13 | - "443:443"
14 | command: '/bin/sh -c ''while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g "daemon off;"'''
15 |
16 | certbot:
17 | image: certbot/certbot
18 | restart: unless-stopped
19 | volumes:
20 | - ./data/certbot/conf:/etc/letsencrypt
21 | - ./data/certbot/www:/var/www/certbot
22 | entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
23 |
24 | client:
25 | build:
26 | context: ./client
27 | ports:
28 | - 3000:3000
29 | container_name: client
30 | restart: always
31 | volumes:
32 | - ./client:/usr/src/app
33 | - /usr/src/app/node_modules
34 |
35 | server:
36 | build:
37 | context: ./server
38 | ports:
39 | - "5000:5000"
40 | restart: always
41 | container_name: server
42 | volumes:
43 | - ./server:/usr/src/app
44 | - /usr/src/app/node_modules
45 | - ./data/certbot/conf:/etc/letsencrypt
--------------------------------------------------------------------------------
/init-letsencrypt.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | if ! [ -x "$(command -v docker-compose)" ]; then
4 | echo 'Error: docker-compose is not installed.' >&2
5 | exit 1
6 | fi
7 |
8 | domains=(example.com www.example.com)
9 | rsa_key_size=4096
10 | data_path="./data/certbot"
11 | email="" # Adding a valid address is strongly recommended
12 | staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits
13 |
14 | if [ -d "$data_path" ]; then
15 | read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision
16 | if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
17 | exit
18 | fi
19 | fi
20 |
21 |
22 | if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then
23 | echo "### Downloading recommended TLS parameters ..."
24 | mkdir -p "$data_path/conf"
25 | curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf"
26 | curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem"
27 | echo
28 | fi
29 |
30 | echo "### Creating dummy certificate for $domains ..."
31 | path="/etc/letsencrypt/live/$domains"
32 | mkdir -p "$data_path/conf/live/$domains"
33 | docker-compose run --rm --entrypoint "\
34 | openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
35 | -keyout '$path/privkey.pem' \
36 | -out '$path/fullchain.pem' \
37 | -subj '/CN=localhost'" certbot
38 | echo
39 |
40 |
41 | echo "### Starting nginx ..."
42 | docker-compose up --force-recreate -d nginx
43 | echo
44 |
45 | echo "### Deleting dummy certificate for $domains ..."
46 | docker-compose run --rm --entrypoint "\
47 | rm -Rf /etc/letsencrypt/live/$domains && \
48 | rm -Rf /etc/letsencrypt/archive/$domains && \
49 | rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot
50 | echo
51 |
52 |
53 | echo "### Requesting Let's Encrypt certificate for $domains ..."
54 | #Join $domains to -d args
55 | domain_args=""
56 | for domain in "${domains[@]}"; do
57 | domain_args="$domain_args -d $domain"
58 | done
59 |
60 | # Select appropriate email arg
61 | case "$email" in
62 | "") email_arg="--register-unsafely-without-email" ;;
63 | *) email_arg="--email $email" ;;
64 | esac
65 |
66 | # Enable staging mode if needed
67 | if [ $staging != "0" ]; then staging_arg="--staging"; fi
68 |
69 | docker-compose run --rm --entrypoint "\
70 | certbot certonly --webroot -w /var/www/certbot \
71 | $staging_arg \
72 | $email_arg \
73 | $domain_args \
74 | --rsa-key-size $rsa_key_size \
75 | --agree-tos \
76 | --force-renewal" certbot
77 | echo
78 |
79 | echo "### Reloading nginx ..."
80 | docker-compose exec nginx nginx -s reload
--------------------------------------------------------------------------------
/server/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env"
4 | ]
5 | }
--------------------------------------------------------------------------------
/server/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | build
4 | .dockerignore
5 | **/.git
6 | **/.DS_Store
7 | **/node_modules
--------------------------------------------------------------------------------
/server/Dockerfile:
--------------------------------------------------------------------------------
1 | # pull the base image
2 | FROM node:16
3 |
4 | # set the working direction
5 | WORKDIR /usr/src/app/
6 |
7 | # add `/app/node_modules/.bin` to $PATH
8 | ENV PATH /usr/src/app/node_modules/.bin:$PATH
9 |
10 | # install app dependencies
11 | COPY package.json ./
12 | COPY package-lock.json ./
13 | RUN npm install
14 |
15 | # add myuser to nodecert group so it can gain read
16 | # permissions to letsencrypt certificates
17 | RUN addgroup --gid 1024 nodecert
18 | RUN adduser --disabled-password --gecos "" --force-badname --ingroup nodecert nodeuser
19 | USER nodeuser
20 |
21 | EXPOSE 5000
22 |
23 | # start app
24 | CMD ["npm", "start"]t
25 |
26 |
--------------------------------------------------------------------------------
/server/index.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import https from 'https';
3 | import fs from 'fs';
4 |
5 | const app = express()
6 | const port = 5000
7 |
8 | var credentials = {
9 | key: fs.readFileSync(
10 | '/etc/letsencrypt/live/example.com/privkey.pem',
11 | 'utf8'
12 | ),
13 | cert: fs.readFileSync(
14 | '/etc/letsencrypt/live/example.com/cert.pem',
15 | 'utf8'
16 | ),
17 | ca: fs.readFileSync(
18 | '/etc/letsencrypt/live/example.com/fullchain.pem',
19 | 'utf8'
20 | ),
21 | };
22 |
23 | app.get('/', (req, res) => {
24 | res.send('https server')
25 | })
26 |
27 | https.createServer(credentials, app).listen(port, () => {
28 | console.log(`https server listening at http://localhost:${port}`);
29 | });
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "nodemon --exec babel-node index.js",
8 | "test": "echo \"Error: no test specified\" && exit 1"
9 | },
10 | "keywords": [],
11 | "author": "",
12 | "license": "ISC",
13 | "dependencies": {
14 | "express": "^4.17.1"
15 | },
16 | "devDependencies": {
17 | "@babel/cli": "^7.14.3",
18 | "@babel/core": "^7.14.3",
19 | "@babel/node": "^7.14.2",
20 | "@babel/preset-env": "^7.14.2",
21 | "nodemon": "^2.0.7"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------