From d2135860bea0e17d4f9af375d37955e62bff2c29 Mon Sep 17 00:00:00 2001 From: "manhng132@gmail.com" Date: Mon, 7 Nov 2022 19:10:18 +0700 Subject: [PATCH] deloy --- Dockerfile | 38 ++ Dockerfile.prod | 51 ++ Gemfile | 6 +- storage/.keep => LICENSE | 0 LICENSE.md | 26 +- README.md | 520 ++++++++++++++++++ app.js | 8 + .../api/account_activations_controller.rb | 18 +- app/controllers/api/api_controller.rb | 111 +--- app/controllers/api/concerns/.keep | 0 .../api/concerns/errors_handler.rb | 60 ++ .../api/concerns/responses_handler.rb | 74 +++ .../api/password_resets_controller.rb | 106 ++-- app/controllers/api/sessions_controller.rb | 59 +- app/controllers/api/users_controller.rb | 102 ++-- app/controllers/concerns/errors_handler.rb | 60 ++ app/controllers/concerns/responses_handler.rb | 74 +++ app/controllers/errors_controller.rb | 5 + app/controllers/health_check_controller.rb | 5 + app/models/application_record.rb | 15 +- .../concerns/refresh_token_updatable.rb | 12 + app/models/concerns/type_validatable.rb | 30 + app/models/concerns/user_jwt_claims.rb | 11 + app/models/user.rb | 19 + app/services/jwt/user/decode_token_service.rb | 41 ++ app/services/jwt/user/encode_token_service.rb | 47 ++ app/services/service.rb | 8 + .../account_activations/update.json.jbuilder | 14 +- app/views/api/auths/create.json.jbuilder | 16 + app/views/api/auths/index.json.jbuilder | 1 + app/views/api/auths/refresh.json.jbuilder | 12 + app/views/api/sessions/create.json.jbuilder | 28 +- app/views/api/sessions/index.json.jbuilder | 10 +- app/views/api/sessions/refresh.json.jbuilder | 12 + app/views/api/users/create.json.jbuilder | 4 + app/views/api/users/index.json.jbuilder | 12 +- app/views/api/users/show.json.jbuilder | 26 +- app/views/api/users/update.json.jbuilder | 4 + config/database.yml | 18 +- config/master.key.example | 1 + config/puma.rb | 2 +- db/migrate/20220408200125_create_users.rb | 5 + db/schema.rb | 4 + docker-compose.production.yml | 56 ++ docker-compose.yml | 56 ++ entrypoint.sh | 11 + heroku.yml | 10 + rails-app.sh | 296 ++++++++++ wsl.conf | 6 + 49 files changed, 1754 insertions(+), 356 deletions(-) create mode 100644 Dockerfile create mode 100644 Dockerfile.prod rename storage/.keep => LICENSE (100%) create mode 100644 app.js create mode 100644 app/controllers/api/concerns/.keep create mode 100644 app/controllers/api/concerns/errors_handler.rb create mode 100644 app/controllers/api/concerns/responses_handler.rb create mode 100644 app/controllers/concerns/errors_handler.rb create mode 100644 app/controllers/concerns/responses_handler.rb create mode 100644 app/controllers/errors_controller.rb create mode 100644 app/controllers/health_check_controller.rb create mode 100644 app/models/concerns/refresh_token_updatable.rb create mode 100644 app/models/concerns/type_validatable.rb create mode 100644 app/models/concerns/user_jwt_claims.rb create mode 100644 app/services/jwt/user/decode_token_service.rb create mode 100644 app/services/jwt/user/encode_token_service.rb create mode 100644 app/services/service.rb create mode 100644 app/views/api/auths/create.json.jbuilder create mode 100644 app/views/api/auths/index.json.jbuilder create mode 100644 app/views/api/auths/refresh.json.jbuilder create mode 100644 app/views/api/sessions/refresh.json.jbuilder create mode 100644 app/views/api/users/create.json.jbuilder create mode 100644 app/views/api/users/update.json.jbuilder create mode 100644 config/master.key.example create mode 100644 docker-compose.production.yml create mode 100644 docker-compose.yml create mode 100755 entrypoint.sh create mode 100644 heroku.yml create mode 100644 rails-app.sh create mode 100644 wsl.conf diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000000..6e52f09f9d6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM ruby:3.1.2 + +# Install node & yarn +RUN curl -sL https://deb.nodesource.com/setup_12.x | bash - +RUN apt-get install -y nodejs +RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - +RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list +RUN apt-get update && apt-get install -y yarn + +# Install base deps or additional (e.g. tesseract) +ARG INSTALL_DEPENDENCIES +RUN apt-get update -qq \ + && apt-get install -y --no-install-recommends ${INSTALL_DEPENDENCIES} \ + build-essential libpq-dev git \ + && apt-get clean autoclean \ + && apt-get autoremove -y \ + && rm -rf \ + /var/lib/apt \ + /var/lib/dpkg \ + /var/lib/cache \ + /var/lib/log + +# Install deps with bundler +RUN mkdir /app +WORKDIR /app +COPY Gemfile* /app/ +ARG BUNDLE_INSTALL_ARGS +RUN gem install bundler:2.3.25 +RUN bundle config set without 'development test' +RUN bundle install ${BUNDLE_INSTALL_ARGS} \ + && rm -rf /usr/local/bundle/cache/* \ + && find /usr/local/bundle/gems/ -name "*.c" -delete \ + && find /usr/local/bundle/gems/ -name "*.o" -delete +COPY . /app/ + +# Compile assets +ARG RAILS_ENV=development +RUN if [ "$RAILS_ENV" = "production" ]; then SECRET_KEY_BASE=$(rake secret) bundle exec rake assets:precompile; fi diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 00000000000..2579a99012b --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,51 @@ +FROM ruby:3.1.2 + +RUN apt-get update -yq \ + && apt-get upgrade -yq \ + #ESSENTIALS + && apt-get install -y -qq --no-install-recommends build-essential curl git-core vim passwd unzip cron gcc wget netcat \ + # RAILS PACKAGES NEEDED + && apt-get update \ + && apt-get install -y --no-install-recommends imagemagick postgresql-client \ + # INSTALL NODE + && curl -sL https://deb.nodesource.com/setup_16.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + # INSTALL YARN + && npm install -g yarn + +# Clean cache and temp files, fix permissions +RUN apt-get clean -qy \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir /app +WORKDIR /app + +COPY package.json yarn.lock +RUN yarn install + +# install specific version of bundler +RUN gem install bundler -v 2.2.32 + +ENV BUNDLE_GEMFILE=/app/Gemfile \ + BUNDLE_JOBS=20 \ + BUNDLE_PATH=/bundle \ + BUNDLE_BIN=/bundle/bin \ + GEM_HOME=/bundle +ENV PATH="${BUNDLE_BIN}:${PATH}" + +COPY Gemfile Gemfile.lock ./ +RUN bundle install --binstubs --without development test + +COPY . . + +ENV RAILS_ENV production +ENV RAILS_SERVE_STATIC_FILES 1 +ENV RAILS_LOG_TO_STDOUT 1 + +RUN SECRET_KEY_BASE=skb DB_ADAPTER=nulldb bundle exec rails assets:precompile + +RUN chmod +x entrypoint.sh +ENTRYPOINT ["./entrypoint.sh"] + +EXPOSE 3000 +CMD bundle exec puma -C config/puma.rb diff --git a/Gemfile b/Gemfile index d83ab3cc69a..87474fdf7f9 100644 --- a/Gemfile +++ b/Gemfile @@ -37,7 +37,7 @@ gem "bootsnap", require: false group :development, :test do # Use pg as the database for Active Record - gem "pg" + # gem "pg" # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri mingw x64_mingw ] end @@ -58,7 +58,7 @@ group :test do end group :production do - gem "pg" + # gem "pg" gem "aws-sdk-s3", require: false end @@ -81,7 +81,7 @@ gem "redis" # gem 'bootstrap-will_paginate' # gem 'bootstrap-sass' # gem 'sassc-rails' -# gem 'pg' +gem 'pg' # gem 'mini_magick' gem 'kaminari' gem 'kaminari-bootstrap' diff --git a/storage/.keep b/LICENSE similarity index 100% rename from storage/.keep rename to LICENSE diff --git a/LICENSE.md b/LICENSE.md index df2ad8dd143..b6e12abd4cf 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,9 +1,6 @@ -All source code in the [Ruby on Rails Tutorial](https://www.railstutorial.org/) is available jointly under the MIT License and the Beerware License. (This means you can use one or the other or both.) +MIT License -``` -The MIT License - -Copyright (c) 2022 Michael Hartl +Copyright (c) 2022 Manh Nguyen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -12,22 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` - -``` -THE BEERWARE LICENSE (Revision 42) - -Michael Hartl wrote this code. As long as you retain this notice you can do -whatever you want with this stuff. If we meet some day, and you think this -stuff is worth it, you can buy me a beer in return. -``` +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index a37db899aa0..c466e9c3ebb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,523 @@ +# RESTful API Rails Server Boilerplate + +[![Build Status](https://travis-ci.org/hagopj13/node-express-boilerplate.svg?branch=master)](https://travis-ci.org/hagopj13/node-express-boilerplate) +[![Coverage Status](https://coveralls.io/repos/github/hagopj13/node-express-boilerplate/badge.svg?branch=master)](https://coveralls.io/github/hagopj13/node-express-boilerplate?branch=master) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) + +A boilerplate/starter project for quickly building RESTful APIs using Rails, and Postgres. + +By running a single command, you will get a production-ready Node.js app installed and fully configured on your machine. The app comes with many built-in features, such as authentication using JWT, request validation, unit and integration tests, continuous integration, docker support, API documentation, pagination, etc. For more details, check the features list below. + +## Quick Start + +To create a project, simply run: + +```bash +rails new sample_app --database=postgresql --api +``` + +Or + +```bash +rails new --database=postgresql --api +``` + +## Manual Installation + +If you would still prefer to do the installation manually, follow these steps: + +Clone the repo: + +```bash +git clone --depth 1 https://github.com/nickeryno/sample_app.git +cd sample_app +npx rimraf ./.git +``` + +Install the dependencies: + +```bash +https://github.com/Hygieia/Hygieia/issues/3145 +docker build -t nickeryno-sample_app . +docker-compose build +docker network create --driver bridge sample_app +docker-compose run --rm web db:create && db:migrate +docker-compose up +``` + +Set the environment variables: + +```bash +docker-compose exec web bash +rails db:create && rails db:migrate && rails db:seed +docker exec -it sample_app.api bash +cp .env.example .env +cp config/master.key.example config/master.key + +# open .env and modify the environment variables (if needed) +``` + +## Information Technology + +- Ruby `3.1.2` +- Rails `7.0.4` +- Postgresql `13` + +## Table of Contents + +- [Features](#features) +- [Commands](#commands) +- [Environment Variables](#environment-variables) +- [Project Structure](#project-structure) +- [API Documentation](#api-documentation) +- [Error Handling](#error-handling) +- [Validation](#validation) +- [Authentication](#authentication) +- [Authorization](#authorization) +- [Logging](#logging) +- [Custom Mongoose Plugins](#custom-mongoose-plugins) +- [Linting](#linting) +- [Contributing](#contributing) + +## Features + +- **NoSQL database**: [MongoDB](https://www.mongodb.com) object data modeling using [Mongoose](https://mongoosejs.com) +- **Authentication and authorization**: using [passport](http://www.passportjs.org) +- **Validation**: request data validation using [Joi](https://github.com/hapijs/joi) +- **Logging**: using [winston](https://github.com/winstonjs/winston) and [morgan](https://github.com/expressjs/morgan) +- **Testing**: unit and integration tests using [Jest](https://jestjs.io) +- **Error handling**: centralized error handling mechanism +- **API documentation**: with [swagger-jsdoc](https://github.com/Surnet/swagger-jsdoc) and [swagger-ui-express](https://github.com/scottie1984/swagger-ui-express) +- **Process management**: advanced production process management using [PM2](https://pm2.keymetrics.io) +- **Dependency management**: with [Yarn](https://yarnpkg.com) +- **Environment variables**: using [dotenv](https://github.com/motdotla/dotenv) and [cross-env](https://github.com/kentcdodds/cross-env#readme) +- **Security**: set security HTTP headers using [helmet](https://helmetjs.github.io) +- **Santizing**: sanitize request data against xss and query injection +- **CORS**: Cross-Origin Resource-Sharing enabled using [cors](https://github.com/expressjs/cors) +- **Compression**: gzip compression with [compression](https://github.com/expressjs/compression) +- **CI**: continuous integration with [Travis CI](https://travis-ci.org) +- **Docker support** +- **Code coverage**: using [coveralls](https://coveralls.io) +- **Code quality**: with [Codacy](https://www.codacy.com) +- **Git hooks**: with [husky](https://github.com/typicode/husky) and [lint-staged](https://github.com/okonet/lint-staged) +- **Linting**: with [ESLint](https://eslint.org) and [Prettier](https://prettier.io) +- **Editor config**: consistent editor configuration using [EditorConfig](https://editorconfig.org) + +## Commands + +Running locally: + +```bash +yarn dev +``` + +Running in production: + +```bash +yarn start +``` + +Testing: + +```bash +# run all tests +yarn test + +# run all tests in watch mode +yarn test:watch + +# run test coverage +yarn coverage +``` + +Docker: + +```bash +# run docker container in development mode +yarn docker:dev + +# run docker container in production mode +yarn docker:prod + +# run all tests in a docker container +yarn docker:test +``` + +Linting: + +```bash +# run Rubocop +gem install rubocop +bundle exec rubocop -a + +# fix ESLint errors +yarn lint:fix + +# run prettier +yarn prettier + +# fix prettier errors +yarn prettier:fix +``` + +## Environment Variables + +The environment variables can be found and modified in the `.env` file. They come with these default values: + +```bash +# Port number +PORT=3000 + +# URL of the Mongo DB +MONGODB_URL=mongodb://127.0.0.1:27017/node-boilerplate + +# JWT +# JWT secret key +JWT_SECRET=thisisasamplesecret +# Number of minutes after which an access token expires +JWT_ACCESS_EXPIRATION_MINUTES=30 +# Number of days after which a refresh token expires +JWT_REFRESH_EXPIRATION_DAYS=30 + +# SMTP configuration options for the email service +# For testing, you can use a fake SMTP service like Ethereal: https://ethereal.email/create +SMTP_HOST=email-server +SMTP_PORT=587 +SMTP_USERNAME=email-server-username +SMTP_PASSWORD=email-server-password +EMAIL_FROM=support@yourapp.com +``` + +## Project Structure + +``` +src\ + |--config\ # Environment variables and configuration related things + |--controllers\ # Route controllers (controller layer) + |--docs\ # Swagger files + |--middlewares\ # Custom express middlewares + |--models\ # Mongoose models (data layer) + |--routes\ # Routes + |--services\ # Business logic (service layer) + |--utils\ # Utility classes and functions + |--validations\ # Request data validation schemas + |--app.js # Express app + |--index.js # App entry point +``` + +## API Documentation + +To view the list of available APIs and their specifications, run the server and go to `http://localhost:3000/rails/info/routes` in your browser. This documentation page is automatically generated using the [swagger](https://swagger.io/) definitions written as comments in the route files. +https://editor.swagger.io/ coppy from swagger.yaml + +### API Endpoints + +List of available routes: + +**Auth routes**:\ +`POST /v1/auth/register` - register\ +`POST /v1/auth/login` - login\ +`POST /v1/auth/logout` - logout\ +`POST /v1/auth/refresh-tokens` - refresh auth tokens\ +`POST /v1/auth/forgot-password` - send reset password email\ +`POST /v1/auth/reset-password` - reset password\ +`POST /v1/auth/send-verification-email` - send verification email\ +`POST /v1/auth/verify-email` - verify email + +**User routes**:\ +`POST /v1/users` - create a user\ +`GET /v1/users` - get all users\ +`GET /v1/users/:userId` - get user\ +`PATCH /v1/users/:userId` - update user\ +`DELETE /v1/users/:userId` - delete user + +## Error Handling + +The app has a centralized error handling mechanism. + +Controllers should try to catch the errors and forward them to the error handling middleware (by calling `next(error)`). For convenience, you can also wrap the controller inside the catchAsync utility wrapper, which forwards the error. + +```javascript +const catchAsync = require('../utils/catchAsync'); + +const controller = catchAsync(async (req, res) => { + // this error will be forwarded to the error handling middleware + throw new Error('Something wrong happened'); +}); +``` + +The error handling middleware sends an error response, which has the following format: + +```json +{ + "code": 404, + "message": "Not found" +} +``` + +When running in development mode, the error response also contains the error stack. + +The app has a utility ApiError class to which you can attach a response code and a message, and then throw it from anywhere (catchAsync will catch it). + +For example, if you are trying to get a user from the DB who is not found, and you want to send a 404 error, the code should look something like: + +```javascript +const httpStatus = require('http-status'); +const ApiError = require('../utils/ApiError'); +const User = require('../models/User'); + +const getUser = async (userId) => { + const user = await User.findById(userId); + if (!user) { + throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); + } +}; +``` + +## Validation + +Request data is validated using [Joi](https://joi.dev/). Check the [documentation](https://joi.dev/api/) for more details on how to write Joi validation schemas. + +The validation schemas are defined in the `src/validations` directory and are used in the routes by providing them as parameters to the `validate` middleware. + +```javascript +const express = require('express'); +const validate = require('../../middlewares/validate'); +const userValidation = require('../../validations/user.validation'); +const userController = require('../../controllers/user.controller'); + +const router = express.Router(); + +router.post('/users', validate(userValidation.createUser), userController.createUser); +``` + +## Authentication + +To require authentication for certain routes, you can use the `auth` middleware. + +```javascript +const express = require('express'); +const auth = require('../../middlewares/auth'); +const userController = require('../../controllers/user.controller'); + +const router = express.Router(); + +router.post('/users', auth(), userController.createUser); +``` + +These routes require a valid JWT access token in the Authorization request header using the Bearer schema. If the request does not contain a valid access token, an Unauthorized (401) error is thrown. + +**Generating Access Tokens**: + +An access token can be generated by making a successful call to the register (`POST /v1/auth/register`) or login (`POST /v1/auth/login`) endpoints. The response of these endpoints also contains refresh tokens (explained below). + +An access token is valid for 30 minutes. You can modify this expiration time by changing the `JWT_ACCESS_EXPIRATION_MINUTES` environment variable in the .env file. + +**Refreshing Access Tokens**: + +After the access token expires, a new access token can be generated, by making a call to the refresh token endpoint (`POST /v1/auth/refresh-tokens`) and sending along a valid refresh token in the request body. This call returns a new access token and a new refresh token. + +A refresh token is valid for 30 days. You can modify this expiration time by changing the `JWT_REFRESH_EXPIRATION_DAYS` environment variable in the .env file. + +## Authorization + +The `auth` middleware can also be used to require certain rights/permissions to access a route. + +```javascript +const express = require('express'); +const auth = require('../../middlewares/auth'); +const userController = require('../../controllers/user.controller'); + +const router = express.Router(); + +router.post('/users', auth('manageUsers'), userController.createUser); +``` + +In the example above, an authenticated user can access this route only if that user has the `manageUsers` permission. + +The permissions are role-based. You can view the permissions/rights of each role in the `src/config/roles.js` file. + +If the user making the request does not have the required permissions to access this route, a Forbidden (403) error is thrown. + +## Logging + +Import the logger from `src/config/logger.js`. It is using the [Winston](https://github.com/winstonjs/winston) logging library. + +Logging should be done according to the following severity levels (ascending order from most important to least important): + +```javascript +const logger = require('/config/logger'); + +logger.error('message'); // level 0 +logger.warn('message'); // level 1 +logger.info('message'); // level 2 +logger.http('message'); // level 3 +logger.verbose('message'); // level 4 +logger.debug('message'); // level 5 +``` + +In development mode, log messages of all severity levels will be printed to the console. + +In production mode, only `info`, `warn`, and `error` logs will be printed to the console.\ +It is up to the server (or process manager) to actually read them from the console and store them in log files.\ +This app uses pm2 in production mode, which is already configured to store the logs in log files. + +Note: API request information (request url, response code, timestamp, etc.) are also automatically logged (using [morgan](https://github.com/expressjs/morgan)). + +## Custom Mongoose Plugins + +The app also contains 2 custom mongoose plugins that you can attach to any mongoose model schema. You can find the plugins in `src/models/plugins`. + +```javascript +const mongoose = require('mongoose'); +const { toJSON, paginate } = require('./plugins'); + +const userSchema = mongoose.Schema( + { + /* schema definition here */ + }, + { timestamps: true } +); + +userSchema.plugin(toJSON); +userSchema.plugin(paginate); + +const User = mongoose.model('User', userSchema); +``` + +### toJSON + +The toJSON plugin applies the following changes in the toJSON transform call: + +- removes \_\_v, createdAt, updatedAt, and any schema path that has private: true +- replaces \_id with id + +### paginate + +The paginate plugin adds the `paginate` static method to the mongoose schema. + +Adding this plugin to the `User` model schema will allow you to do the following: + +```javascript +const queryUsers = async (filter, options) => { + const users = await User.paginate(filter, options); + return users; +}; +``` + +The `filter` param is a regular mongo filter. + +The `options` param can have the following (optional) fields: + +```javascript +const options = { + sortBy: 'name:desc', // sort order + limit: 5, // maximum results per page + page: 2, // page number +}; +``` + +The plugin also supports sorting by multiple criteria (separated by a comma): `sortBy: name:desc,role:asc` + +The `paginate` method returns a Promise, which fulfills with an object having the following properties: + +```json +{ + "results": [], + "page": 2, + "limit": 5, + "totalPages": 10, + "totalResults": 48 +} +``` + +## Linting + +Linting is done using [ESLint](https://eslint.org/) and [Prettier](https://prettier.io). + +In this app, ESLint is configured to follow the [Airbnb JavaScript style guide](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb-base) with some modifications. It also extends [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) to turn off all rules that are unnecessary or might conflict with Prettier. + +To modify the ESLint configuration, update the `.eslintrc.json` file. To modify the Prettier configuration, update the `.prettierrc.json` file. + +To prevent a certain file or directory from being linted, add it to `.eslintignore` and `.prettierignore`. + +To maintain a consistent coding style across different IDEs, the project contains `.editorconfig` + +## Contributing + +Contributions are more than welcome! Please check out the [contributing guide](CONTRIBUTING.md). + +## Inspirations + +- [danielfsousa/express-rest-es2017-boilerplate](https://github.com/danielfsousa/express-rest-es2017-boilerplate) +- [madhums/node-express-mongoose](https://github.com/madhums/node-express-mongoose) +- [kunalkapadia/express-mongoose-es6-rest-api](https://github.com/kunalkapadia/express-mongoose-es6-rest-api) + +## License + +[MIT](LICENSE) + +node-boilerplate> db.users.find().limit(1) +[ + { + _id: ObjectId("624a52224f2baf43ba0e2f7c"), + role: 'user', ----> only can edit by MongoDB Compass to role: 'admin' + isEmailVerified: true, + name: 'Example User', + email: 'example@railstutorial.org', + password: '$2a$08$FRw.nF76cCtONtbbf2zfdOonuuOXp.O1baAJdWABvGH.8nnqWt0SG', + createdAt: ISODate("2022-04-04T02:04:18.837Z"), + updatedAt: ISODate("2022-04-05T06:31:32.527Z"), + __v: 0 + } +] + +mongosh +show databases +use node-boilerplate +db +db.tokens.insertOne() +db.tokens.insertMany() +node-boilerplate> db.tokens +node-boilerplate.tokens +show collections +let name = "shaul" +name +node-boilerplate.tokens.find() +help +exit +cls +https://github.com/iamshaunjp/complete-mongodb +https://www.youtube.com/watch?v=bJSj1a84I20&list=PL4cUxeGkcC9h77dJ-QJlwGlZlTd4ecZOA&index=4 +https://www.mongodb.com/docs/manual/tutorial/install-mongodb-on-ubuntu/ +https://www.mongodb.com/docs/compass/current/install/ + +use MongoDB Compass set role to admin and isEmailVerified to true + +# https://myaccount.google.com/lesssecureapps +# https://accounts.google.com/DisplayUnlockCaptcha +# https://support.google.com/mail/answer/185833?hl=en + +heroku git:clone -a warm-forest-46962 + 609 git add . + 610 git commit --amend + 611 sudo heroku container:push web --app sleepy-dawn-64450 + 612 sudo heroku container:release web --app sleepy-dawn-64450 + 613 sudo $ heroku addons:create heroku-postgresql:hobby-dev --app sleepy-dawn-64450 + 614 sudo heroku addons:create heroku-postgresql:hobby-dev --app sleepy-dawn-64450 +sudo heroku config:set PORT=3000 REDIS_URL=redis://localhost:6379/1 RAILS_MAX_THREADS=5 DB_HOST=db DB_USERNAME=postgres DB_PASSWORD=password RAILS_ENV=production PIDFILE=tmp/pids/server.pid --app sleepy-dawn-64450 + 617 sudo heroku run rails db:migrate --app sleepy-dawn-64450 + 618 sudo heroku run rails db:migrate --app sleepy-dawn-64450 + 619 sudo heroku run rails db:seed --app sleepy-dawn-64450 + 620 sudo heroku logs --tail --app sleepy-dawn-64450 + 621 history +https://gist.github.com/masutaka/d05c3908c3bef80788b8ee5b0ef7b3ba +https://qiita.com/fukazawashun/items/412bcac29cabc36da6fa#%EF%BC%94%E3%83%9E%E3%83%8B%E3%83%95%E3%82%A7%E3%82%B9%E3%83%88 +https://viblo.asia/p/deploy-mot-ung-dung-rails-don-gian-voi-docker-Eb85o4e4K2G +https://stackoverflow.com/questions/9202324/execjs-could-not-find-a-javascript-runtime-but-execjs-and-therubyracer-are-in +https://semaphoreci.com/community/tutorials/dockerizing-a-ruby-on-rails-application + + # README 2022 Rails Tutorial + REST API + GraphQL diff --git a/app.js b/app.js new file mode 100644 index 00000000000..684f4fc7338 --- /dev/null +++ b/app.js @@ -0,0 +1,8 @@ +{ + "name": "sample_app", + "description": "An example app.json for heroku-docker", + "image": "ruby:3.1.2", + "addons": [ + "heroku-postgresql" + ] +} \ No newline at end of file diff --git a/app/controllers/api/account_activations_controller.rb b/app/controllers/api/account_activations_controller.rb index f21f6324d1b..a2c8d7820ac 100644 --- a/app/controllers/api/account_activations_controller.rb +++ b/app/controllers/api/account_activations_controller.rb @@ -1,19 +1,11 @@ class Api::AccountActivationsController < Api::ApiController + before_action :authenticate!, except: %i[update] def update @user = User.find_by(email: params[:email]) - if @user && !@user.activated? && @user.authenticated?(:activation, params[:id]) - @user.activate - # log_in @user - # remember(@user) - payload = {user_id: @user.id} - @token = encode_token(payload) - # render json: { flash: ["success", "Account activated!"] } - # redirect_to @user - # redirect_to "https://sample-app-nextjs.vercel.app/users/#{@user.id}" - else - # render json: { flash: ["danger", "Invalid activation link"] } - # redirect_to "https://sample-app-nextjs.vercel.app" - end + response401_with_error(error_message(:invalid_activation_link)) unless @user && !@user.activated? && @user.authenticated?(:activation, params[:id]) + + @user.generate_tokens! if @user.activate + response200 end end diff --git a/app/controllers/api/api_controller.rb b/app/controllers/api/api_controller.rb index cfd1086a02f..d45afc941f5 100644 --- a/app/controllers/api/api_controller.rb +++ b/app/controllers/api/api_controller.rb @@ -1,101 +1,30 @@ class Api::ApiController < ActionController::Base + include ResponsesHandler + include ErrorsHandler + skip_before_action :verify_authenticity_token - # include ApiSessionsHelper + before_action :authenticate! private - def auth_header - request.headers['Authorization'] - end - # Logs in the given user. - def log_in(user) - session[:user_id] = user.id - end + def pager(records) + [records.size, records.pager(params)] + end - # Remembers a user in a persistent session. - def remember(user) - user.remember - # cookies.permanent.encrypted[:user_id] = user.id - # cookies.permanent[:remember_token] = user.remember_token - end + def error_message(error_message_key, options = nil) + options.nil? ? I18n.t(error_message_key, scope: %i[errors messages]) : I18n.t(error_message_key, scope: %i[errors messages], **options) + end - # Returns the user corresponding to the remember token cookie. - def current_user - decoded_hash = decoded_token - if !decoded_hash.empty? - puts decoded_hash.class - user_id = decoded_hash[0]['user_id'] - else - nil - end + def authenticate! + response401_with_error(error_message(:not_logged_in)) unless logged_in? + end - if (auth_header.split(' ')[2] === "null") - @current_user ||= User.find_by(id: user_id) - elsif user_id - user = User.find_by(id: user_id) - if user && user.authenticated?(:remember, auth_header.split(' ')[2]) - log_in user - @current_user = user - end - end - end + def logged_in? + !!current_user + end - # Returns true if the given user is the current user. - def current_user?(user) - user == current_user - end - - # Returns true if the user is logged in, false otherwise. - def logged_in? - !current_user.nil? - end - - # Forgets a persistent session. - def forget(user) - user.forget - # cookies.delete(:user_id) - # cookies.delete(:remember_token) - end - - # Logs out the current user. - def log_out - forget(current_user) - session.delete(:user_id) - @current_user = nil - end - - # Redirects to stored location (or to the default). - # def redirect_back_or(default) - # redirect_to(session[:forwarding_url] || default) - # session.delete(:forwarding_url) - # end - - # Stores the URL trying to be accessed. - # def store_location - # session[:forwarding_url] = request.original_url if request.get? - # end - - def encode_token(payload) - JWT.encode(payload, 'my_secret') - end - - def decoded_token - if auth_header - token = auth_header.split(' ')[1] - begin - JWT.decode(token, 'my_secret', true, algorithm: 'HS256') - rescue JWT::DecodeError - [] - end - end - end - - # Confirms a logged-in user. - def logged_in_user - unless logged_in? - # store_location - render json: { flash: ["danger", "Please log in."] } - # redirect_to login_url - end - end + def current_user + user_id = Jwt::User::DecodeTokenService.call(request.headers['Authorization']) + User.find_by(id: user_id) if user_id + end end diff --git a/app/controllers/api/concerns/.keep b/app/controllers/api/concerns/.keep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/app/controllers/api/concerns/errors_handler.rb b/app/controllers/api/concerns/errors_handler.rb new file mode 100644 index 00000000000..cbe37282d9b --- /dev/null +++ b/app/controllers/api/concerns/errors_handler.rb @@ -0,0 +1,60 @@ +module ErrorsHandler + extend ActiveSupport::Concern + + included do + unless Rails.env.development? + rescue_from StandardError, with: :rescue500 + rescue_from ActionController::RoutingError, with: :rescue404 + rescue_from ActiveRecord::RecordNotFound, with: :rescue404 + end + rescue_from JWT::DecodeError, with: :rescue400 + rescue_from JWT::InvalidAudError, with: :rescue401 + rescue_from JWT::InvalidIssuerError, with: :rescue401 + rescue_from JWT::InvalidSubError, with: :rescue401 + rescue_from JWT::ExpiredSignature, with: :rescue401 + end + + private + + def rescue400(exception = nil) + log_message = '400 Bad Request' + log_message << " exception: #{exception.message}" if exception + logger.warn log_message + response400 + end + + def rescue401(exception = nil) + log_message = '401 Unauthorized' + log_message << " exception: #{exception.message}" if exception + logger.warn log_message + response401 + end + + def rescue403(exception = nil) + log_message = '403 Forbidden' + log_message << " exception: #{exception.message}" if exception + logger.warn log_message + response403 + end + + def rescue404(exception = nil) + log_message = '404 Not Found' + log_message << " exception: #{exception.message}" if exception + logger.warn log_message + response404 + end + + def rescue500(exception = nil) + log_message = '500 Internal Server Error' + log_message << " exception: #{exception.message} #{exception.backtrace.inspect}" if exception + logger.error log_message + response500 + end + + def rescue503(exception = nil) + log_message = '503 Service Unavailable' + log_message << " exception: #{exception.message} #{exception.backtrace.inspect}" if exception + logger.error log_message + response503 + end +end diff --git a/app/controllers/api/concerns/responses_handler.rb b/app/controllers/api/concerns/responses_handler.rb new file mode 100644 index 00000000000..427387fb8d1 --- /dev/null +++ b/app/controllers/api/concerns/responses_handler.rb @@ -0,0 +1,74 @@ +module ResponsesHandler + private + + def response200(class_name = controller_name, method_name = action_name, option_data: {}) + base_body = { status: 200, message: "OK #{class_name.classify} #{method_name.classify}" } + render status: :ok, json: base_body.merge(option_data) + end + + def response201(class_name = controller_name, option_data: {}) + base_body = { status: 201, message: "Created #{class_name.classify}" } + render status: :created, json: base_body.merge(option_data) + end + + def response400(option_data: {}) + base_body = { status: 400, message: 'Bad Request' } + render status: :bad_request, json: base_body.merge(option_data) + end + + def response401(option_data: {}) + base_body = { status: 401, message: 'Unauthorized' } + render status: :unauthorized, json: base_body.merge(option_data) + end + + def response403(option_data: {}) + base_body = { status: 403, message: 'Forbidden' } + render status: :forbidden, json: base_body.merge(option_data) + end + + def response404(class_name = nil, option_data: {}) + class_message = class_name.present? ? "#{class_name.classify} " : '' + base_body = { status: 404, message: "#{class_message}Not Found" } + render status: :not_found, json: base_body.merge(option_data) + end + + def response422(option_data: {}) + base_body = { status: 422, message: 'Unprocessable Entity' } + render status: :unprocessable_entity, json: base_body.merge(option_data) + end + + def response426(option_data: {}) + base_body = { status: 426, message: 'Upgrade Required' } + render status: :upgrade_required, json: base_body.merge(option_data) + end + + def response500(option_data: {}) + base_body = { status: 500, message: 'Internal Server Error' } + render status: :internal_server_error, json: base_body.merge(option_data) + end + + def response503(option_data: {}) + base_body = { status: 503, message: 'Service Unavailable' } + render status: :service_unavailable, json: base_body.merge(option_data) + end + + def response401_with_error(message) + response401(option_data: { errors: message }) + end + + def response403_with_error(message) + response403(option_data: { errors: message }) + end + + def response404_with_error(message, class_name = nil) + response404(class_name, option_data: { errors: message }) + end + + def response422_with_error(messages) + response422(option_data: { errors: messages }) + end + + def response426_with_error(message) + response426(option_data: { errors: message }) + end +end diff --git a/app/controllers/api/password_resets_controller.rb b/app/controllers/api/password_resets_controller.rb index 968ceca6c36..2ae7f82fb2f 100644 --- a/app/controllers/api/password_resets_controller.rb +++ b/app/controllers/api/password_resets_controller.rb @@ -1,66 +1,50 @@ class Api::PasswordResetsController < Api::ApiController - before_action :get_user, only: [:edit, :update] - before_action :valid_user, only: [:edit, :update] - before_action :check_expiration, only: [:edit, :update] # Case (1) - - def create - @user = User.find_by(email: params[:password_reset][:email].downcase) - if @user - @user.create_reset_digest - @user.send_password_reset_email - # flash[:info] = "Email sent with password reset instructions" - render json: { flash: ["info", "Email sent with password reset instructions"] } - # redirect_to root_url - else - # flash.now[:danger] = "Email address not found" - render json: { flash: ["danger", "Email address not found"] } - # render 'new' - end + before_action :authenticate!, except: %i[create update] + before_action :set_user, only: %i[update] + before_action :valid_user, only: %i[update] + before_action :check_expiration, only: %i[update] # Case (1) + + def create + @user = User.find_by(email: params[:password_reset][:email]) + if @user + @user.create_reset_digest + @user.send_password_reset_email + response200 + else + response401_with_error(error_message(:not_found_email)) end - - def update - if params[:user][:password].empty? # Case (3) - @user.errors.add(:password, "can't be empty") - render json: { error: @user.errors.full_messages } - # render 'edit' - elsif @user.update(user_params) # Case (4) - log_in @user - # flash[:success] = "Password has been reset." - render json: { user_id: @user.id, flash: ["success", "Password has been reset."] } - # redirect_to @user - else - render json: { error: @user.errors.full_messages } - # render 'edit' # Case (2) - end + end + + def update + if params[:user][:password].empty? # Case (3) + @user.errors.add(:password, error_message(:blank)) + response422_with_error(@user.errors.messages) + elsif @user.update(user_params) # Case (4) + response200 + else # Case (2) + response422_with_error(@user.errors.messages) + end + end + + private + + def user_params + params.require(:user).permit(:password, :password_confirmation) + end + + def set_user + @user = User.find_by(email: params[:email]) + end + + def valid_user + unless @user&.activated? && + @user&.authenticated?(:reset, params[:id]) + response401_with_error(error_message(:not_valid_user)) end - - private - - def user_params - params.require(:user).permit(:password, :password_confirmation) - end - - # Before filters - - def get_user - @user = User.find_by(email: params[:email]) - end - - # Confirms a valid user. - def valid_user - unless (@user && @user.activated? && - @user.authenticated?(:reset, params[:id])) - redirect_to root_url - end - end - - # Checks expiration of reset token. - def check_expiration - if @user.password_reset_expired? - # flash[:danger] = "Password reset has expired." - render json: { flash: ["danger", "Password reset has expired."] } - # redirect_to new_password_reset_url - end - end end + + def check_expiration + response401_with_error(error_message(:password_reset_expired)) if @user.password_reset_expired? + end +end \ No newline at end of file diff --git a/app/controllers/api/sessions_controller.rb b/app/controllers/api/sessions_controller.rb index fb1b68fd835..b74ad3432b5 100644 --- a/app/controllers/api/sessions_controller.rb +++ b/app/controllers/api/sessions_controller.rb @@ -1,35 +1,50 @@ class Api::SessionsController < Api::ApiController + before_action :authenticate!, except: %i[create] + def index - if current_user - @user = current_user - # render json: { user: current_user } - else - head :ok - end + @current_user = current_user if current_user end + def create - @user = User.find_by(email: params[:session][:email].downcase) - if @user && @user.authenticate(params[:session][:password]) + @user = User.find_by(email: auth_params[:email]) + if @user&.auth?(auth_params[:password]) if @user.activated? - log_in @user - params[:session][:remember_me] == '1' ? remember(@user) : forget(@user) - # redirect_back_or user - payload = {user_id: @user.id} - @token = encode_token(payload) - # render json: { - # user: user - # } + @user.generate_tokens! else - message = "Account not activated. " - message += "Check your email for the activation link." - # render json: { flash: ["warning", message] } + response401_with_error(error_message(:not_activated)) end else - # render json: { flash: ["danger", "Invalid email/password combination"] } + response401_with_error(error_message(:invalid_email_or_password)) end end + def destroy - log_out if logged_in? - head :ok + current_user.revoke_refresh_token! + response200 + end + + def refresh + @user = User.find_by(refresh_token: refresh_params[:refresh_token]) + if @user.present? && (@user.refresh_token_expiration_at&.> Time.current) + @user.generate_tokens! + else + response401_with_error(error_message(:invalid_refresh_token)) + end + end + + def revoke + @user = User.find_by(refresh_token: refresh_params[:refresh_token]) + @user.revoke_refresh_token! if @user.present? && (@user.refresh_token_expiration_at&.> Time.current) + response200 + end + + private + + def auth_params + params.require(:auth).permit(:email, :password) + end + + def refresh_params + params.require(:auth).permit(:refresh_token) end end diff --git a/app/controllers/api/users_controller.rb b/app/controllers/api/users_controller.rb index d1731ae8401..727bc60b68b 100644 --- a/app/controllers/api/users_controller.rb +++ b/app/controllers/api/users_controller.rb @@ -1,92 +1,56 @@ class Api::UsersController < Api::ApiController - before_action :logged_in_user, only: [:index, :edit, :update, :destroy, - :following, :followers] - before_action :set_user, except: [:index, :new, :create] - before_action :correct_user, only: [:edit, :update, - :following, :followers] - before_action :admin_user, only: :destroy + before_action :authenticate!, except: %i[create] + before_action :set_user, except: %i[index create] + before_action :correct_user, only: %i[update destroy] + before_action :admin_user, only: %i[destroy] def index - @users = User.page(params[:page]) + users = User.all.order(id: :asc) + @total, @users = pager(users) end - def show - @microposts = @user.microposts.page(params[:page]) - @current_user = current_user - end def create @user = User.new(user_params) + if @user.save @user.send_activation_email - render json: { - flash: ["info", "Please check your email to activate your account."], - user: @user - # redirect_to root_url - } else - render json: { error: @user.errors.full_messages }, status: :unprocessable_entity - # res = { - # error: @user.errors.full_messages, - # status: 303 - # } - # render :json => res, :status => :unprocessable_entity - # render json: res, status: :unprocessable_entity + response422_with_error(@user.errors.messages) end end - def edit - render json: { - user: @user, - gravatar: Digest::MD5::hexdigest(@user.email.downcase) - } - end + def update if @user.update(user_params) - # format.json { head :no_content } - render json: { flash_success: ["success", "Profile updated"] }, status: :ok + @user.unactivate if @user.saved_change_to_email? && @user.send_activation_email + response200 else - render json: { error: @user.errors.full_messages }, status: :unprocessable_entity + response422_with_error(@user.errors.messages) end end + def destroy @user.destroy - render json: { flash: ["success", "User deleted"] }, status: :see_other + response200 + end + + private + + def set_user + @user = User.find(params[:id]) end - def following - @user = User.find(params[:id]) - @users = @user.following.page(params[:page]) - @xusers = @user.following - # format.json { - # render template: 'api/users/following.json.jbuilder', - # status: unprocessable_entity - # } + + def user_params + params.require(:user).permit( + :name, :email, :password, :password_confirmation + ) end - def followers - @user = User.find(params[:id]) - @users = @user.followers.page(params[:page]) - @xusers = @user.followers - # format.json { - # render template: 'api/users/followers.json.jbuilder', - # status: unprocessable_entity - # } + + def correct_user + @user = User.find(params[:id]) + response403_with_error(error_message(:not_current_user)) unless @user == current_user + end + + def admin_user + response403_with_error(error_message(:not_admin)) unless current_user.admin? end - private - def set_user - params[:id] ||= current_user.id - @user = User.find(params[:id]) - end - def user_params - params.require(:user).permit(:name, :email, :password, - :password_confirmation) - end - # Before filters - # Confirms the correct user. - def correct_user - params[:id] ||= current_user.id - @user = User.find(params[:id]) - render json: { flash: ["info", "User aren't Current User"] } unless current_user?(@user) - end - # Confirms an admin user. - def admin_user - render json: { flash: ["info", "Current User aren't Admin"] } unless current_user.admin? - end end diff --git a/app/controllers/concerns/errors_handler.rb b/app/controllers/concerns/errors_handler.rb new file mode 100644 index 00000000000..cbe37282d9b --- /dev/null +++ b/app/controllers/concerns/errors_handler.rb @@ -0,0 +1,60 @@ +module ErrorsHandler + extend ActiveSupport::Concern + + included do + unless Rails.env.development? + rescue_from StandardError, with: :rescue500 + rescue_from ActionController::RoutingError, with: :rescue404 + rescue_from ActiveRecord::RecordNotFound, with: :rescue404 + end + rescue_from JWT::DecodeError, with: :rescue400 + rescue_from JWT::InvalidAudError, with: :rescue401 + rescue_from JWT::InvalidIssuerError, with: :rescue401 + rescue_from JWT::InvalidSubError, with: :rescue401 + rescue_from JWT::ExpiredSignature, with: :rescue401 + end + + private + + def rescue400(exception = nil) + log_message = '400 Bad Request' + log_message << " exception: #{exception.message}" if exception + logger.warn log_message + response400 + end + + def rescue401(exception = nil) + log_message = '401 Unauthorized' + log_message << " exception: #{exception.message}" if exception + logger.warn log_message + response401 + end + + def rescue403(exception = nil) + log_message = '403 Forbidden' + log_message << " exception: #{exception.message}" if exception + logger.warn log_message + response403 + end + + def rescue404(exception = nil) + log_message = '404 Not Found' + log_message << " exception: #{exception.message}" if exception + logger.warn log_message + response404 + end + + def rescue500(exception = nil) + log_message = '500 Internal Server Error' + log_message << " exception: #{exception.message} #{exception.backtrace.inspect}" if exception + logger.error log_message + response500 + end + + def rescue503(exception = nil) + log_message = '503 Service Unavailable' + log_message << " exception: #{exception.message} #{exception.backtrace.inspect}" if exception + logger.error log_message + response503 + end +end diff --git a/app/controllers/concerns/responses_handler.rb b/app/controllers/concerns/responses_handler.rb new file mode 100644 index 00000000000..427387fb8d1 --- /dev/null +++ b/app/controllers/concerns/responses_handler.rb @@ -0,0 +1,74 @@ +module ResponsesHandler + private + + def response200(class_name = controller_name, method_name = action_name, option_data: {}) + base_body = { status: 200, message: "OK #{class_name.classify} #{method_name.classify}" } + render status: :ok, json: base_body.merge(option_data) + end + + def response201(class_name = controller_name, option_data: {}) + base_body = { status: 201, message: "Created #{class_name.classify}" } + render status: :created, json: base_body.merge(option_data) + end + + def response400(option_data: {}) + base_body = { status: 400, message: 'Bad Request' } + render status: :bad_request, json: base_body.merge(option_data) + end + + def response401(option_data: {}) + base_body = { status: 401, message: 'Unauthorized' } + render status: :unauthorized, json: base_body.merge(option_data) + end + + def response403(option_data: {}) + base_body = { status: 403, message: 'Forbidden' } + render status: :forbidden, json: base_body.merge(option_data) + end + + def response404(class_name = nil, option_data: {}) + class_message = class_name.present? ? "#{class_name.classify} " : '' + base_body = { status: 404, message: "#{class_message}Not Found" } + render status: :not_found, json: base_body.merge(option_data) + end + + def response422(option_data: {}) + base_body = { status: 422, message: 'Unprocessable Entity' } + render status: :unprocessable_entity, json: base_body.merge(option_data) + end + + def response426(option_data: {}) + base_body = { status: 426, message: 'Upgrade Required' } + render status: :upgrade_required, json: base_body.merge(option_data) + end + + def response500(option_data: {}) + base_body = { status: 500, message: 'Internal Server Error' } + render status: :internal_server_error, json: base_body.merge(option_data) + end + + def response503(option_data: {}) + base_body = { status: 503, message: 'Service Unavailable' } + render status: :service_unavailable, json: base_body.merge(option_data) + end + + def response401_with_error(message) + response401(option_data: { errors: message }) + end + + def response403_with_error(message) + response403(option_data: { errors: message }) + end + + def response404_with_error(message, class_name = nil) + response404(class_name, option_data: { errors: message }) + end + + def response422_with_error(messages) + response422(option_data: { errors: messages }) + end + + def response426_with_error(message) + response426(option_data: { errors: message }) + end +end diff --git a/app/controllers/errors_controller.rb b/app/controllers/errors_controller.rb new file mode 100644 index 00000000000..921b4b2e114 --- /dev/null +++ b/app/controllers/errors_controller.rb @@ -0,0 +1,5 @@ +class ErrorsController < ApplicationController + def not_found + rescue404(ActionController::RoutingError.new("No route matches #{request.request_method} #{request.path}")) + end +end diff --git a/app/controllers/health_check_controller.rb b/app/controllers/health_check_controller.rb new file mode 100644 index 00000000000..c42de6fb65f --- /dev/null +++ b/app/controllers/health_check_controller.rb @@ -0,0 +1,5 @@ +class HealthCheckController < ApplicationController + def status + head :ok + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 10a4cba84df..4bf8a42f65e 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,3 +1,14 @@ class ApplicationRecord < ActiveRecord::Base - self.abstract_class = true -end + primary_abstract_class + include TypeValidatable + + MAX_RECORD_NUMBER = 1000 + + scope :pager, lambda { |params| + relation = self + per_page = [params[:limit]&.to_i || MAX_RECORD_NUMBER, MAX_RECORD_NUMBER].min + relation = relation.limit(per_page) + relation = relation.offARRAY[params[:offset]] if params[:offset] + relation + } +end \ No newline at end of file diff --git a/app/models/concerns/refresh_token_updatable.rb b/app/models/concerns/refresh_token_updatable.rb new file mode 100644 index 00000000000..5b45ce2d037 --- /dev/null +++ b/app/models/concerns/refresh_token_updatable.rb @@ -0,0 +1,12 @@ +module RefreshTokenUpdatable + extend ActiveSupport::Concern + + def update_refresh_token!(refresh_token, refresh_token_expiration_at) + # == update!(refresh_token: refresh_token, refresh_token_expiration_at: refresh_token_expiration_at) + update!(refresh_token:, refresh_token_expiration_at:) + end + + def revoke_refresh_token! + update!(refresh_token: nil, refresh_token_expiration_at: nil) + end +end diff --git a/app/models/concerns/type_validatable.rb b/app/models/concerns/type_validatable.rb new file mode 100644 index 00000000000..4fc34f45f49 --- /dev/null +++ b/app/models/concerns/type_validatable.rb @@ -0,0 +1,30 @@ +module TypeValidatable + extend ActiveSupport::Concern + + STRING_LEN_MAX = 255 + TEXT_LEN_MAX = 2000 + INTEGER_MAX = 1_000_000_000 + STRING_VALIDATION = { length: { maximum: STRING_LEN_MAX } }.freeze + TEXT_VALIDATION = { length: { maximum: TEXT_LEN_MAX } }.freeze + VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i + + class_methods do + def validates_by_type(type:, opt:, only: nil, except: nil) + column_names = columns_by_type(type) + inclusions = Array(only) + column_names.select! { |name| inclusions.include?(name) } if inclusions.present? + exclusions = Array(except) + column_names.select! { |name| exclusions.exclude?(name) } if exclusions.present? + + validates(*column_names, **opt) + end + + private + + def columns_by_type(type) + raise 'type error' unless %i[string text integer decimal].include?(type) + + columns.filter { |col| col.type == type }.map(&:name).map(&:to_sym) + end + end +end diff --git a/app/models/concerns/user_jwt_claims.rb b/app/models/concerns/user_jwt_claims.rb new file mode 100644 index 00000000000..cd2dd1cd8c4 --- /dev/null +++ b/app/models/concerns/user_jwt_claims.rb @@ -0,0 +1,11 @@ +module UserJwtClaims + extend ActiveSupport::Concern + + ALGORITHM = 'HS256'.freeze + ISS = 'http://localhost'.freeze + SUB = 'rails-boilerplate-user'.freeze + AUD = ['http://localhost'].freeze + ACCESS_TOKEN_EXPIRATION = 1.hour + ACCESS_TOKEN_EXPIRATION_FOR_DEV = 24.hours + REFRESH_TOKEN_EXPIRATION = 30.days +end diff --git a/app/models/user.rb b/app/models/user.rb index 4a9644f76d5..9ee05e9218f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,4 +1,7 @@ class User < ApplicationRecord + include RefreshTokenUpdatable + attr_accessor :activation_token, :reset_token + has_many :microposts, dependent: :destroy has_many :active_relationships, class_name: "Relationship", foreign_key: "follower_id", @@ -27,6 +30,22 @@ class User < ApplicationRecord enum admin: { admin: true, user: false } + attribute :token, :string + attribute :token_expiration_at, :string + validates :refresh_token, uniqueness: true, allow_nil: true + validates_by_type(type: :string, except: %i[email password_digest refresh_token], opt: STRING_VALIDATION) + + def auth?(password) + authenticate(password) + end + + def generate_tokens! + access_token, access_token_expiration_at, refresh_token, refresh_token_expiration_at = Jwt::User::EncodeTokenService.call(id) + update_refresh_token!(refresh_token, refresh_token_expiration_at) + self.token = access_token + self.token_expiration_at = access_token_expiration_at + end + # Returns the hash digest of the given string. def User.digest(string) cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : diff --git a/app/services/jwt/user/decode_token_service.rb b/app/services/jwt/user/decode_token_service.rb new file mode 100644 index 00000000000..cb7d327fca0 --- /dev/null +++ b/app/services/jwt/user/decode_token_service.rb @@ -0,0 +1,41 @@ +class Jwt::User::DecodeTokenService + include Service + include UserJwtClaims + + def initialize(auth_header) + @auth_header = auth_header + end + + def call + id_from_claim + end + + private + + attr_reader :auth_header + + def id_from_claim + decoded_token[0].fetch('rails_boilerplate_user_claim').fetch('id') if decoded_token + end + + def decoded_token + return unless auth_header + + token = auth_header.split[1] # == auth_header.split(' ')[1] + JWT.decode( + token, + Rails.application.credentials[:secret_key_base], + true, + jwt_claims + ) + end + + def jwt_claims + { + iss: ISS, verify_iss: true, + sub: SUB, verify_sub: true, + aud: AUD, verify_aud: true, + algorithm: ALGORITHM + } + end +end diff --git a/app/services/jwt/user/encode_token_service.rb b/app/services/jwt/user/encode_token_service.rb new file mode 100644 index 00000000000..cb43dbfa576 --- /dev/null +++ b/app/services/jwt/user/encode_token_service.rb @@ -0,0 +1,47 @@ +class Jwt::User::EncodeTokenService + include Service + include UserJwtClaims + + # const tokenTypes = { + # ACCESS: 'access', + # REFRESH: 'refresh', + # RESET_PASSWORD: 'resetPassword', + # VERIFY_EMAIL: 'verifyEmail', + # }; + + + def initialize(user_id) + @user_claims = { + sub: user_id + } + end + + def call + [ + encode_token('access'), # access_token + access_token_expiration.from_now.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), # access_token_expiration_at + encode_token('refresh'), # refresh_token + REFRESH_TOKEN_EXPIRATION.from_now # refresh_token_expiration_at + ] + end + + private + + attr_reader :user_claims + + def encode_token(type) + payload = jwt_claims.merge(user_claims, {type: type}) + JWT.encode(payload, Rails.application.credentials.secret_key_base, ALGORITHM, { typ: 'JWT' }) # ALGORITHM = HS256 + end + + def jwt_claims + { + exp: access_token_expiration.from_now.to_i, # Expiration Time + iat: Time.current.to_i # Issued At + } + end + + def access_token_expiration + Rails.env.development? ? ACCESS_TOKEN_EXPIRATION_FOR_DEV : ACCESS_TOKEN_EXPIRATION + end +end diff --git a/app/services/service.rb b/app/services/service.rb new file mode 100644 index 00000000000..13104ed1384 --- /dev/null +++ b/app/services/service.rb @@ -0,0 +1,8 @@ +module Service + extend ActiveSupport::Concern + class_methods do + def call(*args) + new(*args).call + end + end +end diff --git a/app/views/api/account_activations/update.json.jbuilder b/app/views/api/account_activations/update.json.jbuilder index f120b68974f..cd89a40c03b 100644 --- a/app/views/api/account_activations/update.json.jbuilder +++ b/app/views/api/account_activations/update.json.jbuilder @@ -1,13 +1 @@ -if @user && @user.activated? - json.user do - json.id @user.id - json.name @user.name - json.admin @user.admin - json.email @user.email - end - json.jwt @token - json.token @user.remember_token - json.flash ["success", "Account activated!"] -else - json.flash ["danger", "Invalid activation link"] -end +json.extract! @user, :id, :name, :email, :token, :token_expiration_at, :refresh_token, :refresh_token_expiration_at diff --git a/app/views/api/auths/create.json.jbuilder b/app/views/api/auths/create.json.jbuilder new file mode 100644 index 00000000000..418f837caae --- /dev/null +++ b/app/views/api/auths/create.json.jbuilder @@ -0,0 +1,16 @@ +json.user do + json.extract! @user, :id, :email, :name + json.role @user.admin +end +json.tokens do + json.access do + json.token @user.token + json.expires @user.token_expiration_at + end +end +json.refresh do + json.access do + json.token @user.refresh_token + json.expires @user.refresh_token_expiration_at + end +end diff --git a/app/views/api/auths/index.json.jbuilder b/app/views/api/auths/index.json.jbuilder new file mode 100644 index 00000000000..d0adcbd97d7 --- /dev/null +++ b/app/views/api/auths/index.json.jbuilder @@ -0,0 +1 @@ +json.extract! @current_user, :id, :name, :email, :admin if @current_user diff --git a/app/views/api/auths/refresh.json.jbuilder b/app/views/api/auths/refresh.json.jbuilder new file mode 100644 index 00000000000..e75d8534b91 --- /dev/null +++ b/app/views/api/auths/refresh.json.jbuilder @@ -0,0 +1,12 @@ +json.tokens do + json.access do + json.token @user.token + json.expires @user.token_expiration_at + end +end +json.refresh do + json.access do + json.token @user.refresh_token + json.expires @user.refresh_token_expiration_at + end +end diff --git a/app/views/api/sessions/create.json.jbuilder b/app/views/api/sessions/create.json.jbuilder index 8c978412646..418f837caae 100644 --- a/app/views/api/sessions/create.json.jbuilder +++ b/app/views/api/sessions/create.json.jbuilder @@ -1,16 +1,16 @@ -if @user && @user.authenticate(params[:session][:password]) - if @user.activated? - json.user do - json.id @user.id - json.name @user.name - json.admin @user.admin - json.email @user.email - end - json.jwt @token - json.token @user.remember_token - else - json.flash ["warning", message] +json.user do + json.extract! @user, :id, :email, :name + json.role @user.admin +end +json.tokens do + json.access do + json.token @user.token + json.expires @user.token_expiration_at + end +end +json.refresh do + json.access do + json.token @user.refresh_token + json.expires @user.refresh_token_expiration_at end -else - json.flash ["danger", "Invalid email/password combination"] end diff --git a/app/views/api/sessions/index.json.jbuilder b/app/views/api/sessions/index.json.jbuilder index aac878bc6fd..d0adcbd97d7 100644 --- a/app/views/api/sessions/index.json.jbuilder +++ b/app/views/api/sessions/index.json.jbuilder @@ -1,9 +1 @@ -if current_user - json.user do - json.id @user.id - json.name @user.name - json.admin @user.admin - json.email @user.email - end -end - +json.extract! @current_user, :id, :name, :email, :admin if @current_user diff --git a/app/views/api/sessions/refresh.json.jbuilder b/app/views/api/sessions/refresh.json.jbuilder new file mode 100644 index 00000000000..e75d8534b91 --- /dev/null +++ b/app/views/api/sessions/refresh.json.jbuilder @@ -0,0 +1,12 @@ +json.tokens do + json.access do + json.token @user.token + json.expires @user.token_expiration_at + end +end +json.refresh do + json.access do + json.token @user.refresh_token + json.expires @user.refresh_token_expiration_at + end +end diff --git a/app/views/api/users/create.json.jbuilder b/app/views/api/users/create.json.jbuilder new file mode 100644 index 00000000000..f6fb02a2575 --- /dev/null +++ b/app/views/api/users/create.json.jbuilder @@ -0,0 +1,4 @@ +json.user do + json.extract! @user, :id, :email, :name + json.role @user.admin +end diff --git a/app/views/api/users/index.json.jbuilder b/app/views/api/users/index.json.jbuilder index b6b64aa13da..4e2e0e14e05 100644 --- a/app/views/api/users/index.json.jbuilder +++ b/app/views/api/users/index.json.jbuilder @@ -1,9 +1,3 @@ -json.users do - json.array!(@users) do |u| - json.id u.id - json.name u.name - json.gravatar_id Digest::MD5::hexdigest(u.email.downcase) - json.size 50 - end -end -json.total_count @users.total_count +json.partial! partial: 'layouts/pager', records: @users, total: @total, index: lambda { |records| + json.array! records, :id, :name, :email +} diff --git a/app/views/api/users/show.json.jbuilder b/app/views/api/users/show.json.jbuilder index 704ed8ed9d3..f6fb02a2575 100644 --- a/app/views/api/users/show.json.jbuilder +++ b/app/views/api/users/show.json.jbuilder @@ -1,26 +1,4 @@ json.user do - json.id @user.id - json.name @user.name - json.gravatar_id Digest::MD5::hexdigest(@user.email.downcase) - json.size 80 - json.following @user.following.count - json.followers @user.followers.count - json.current_user_following_user @current_user.following?(@user) + json.extract! @user, :id, :email, :name + json.role @user.admin end - -if @current_user.following?(@user) -json.id_relationships @current_user.active_relationships.find_by(followed_id: @user.id).id -else -json.id_relationships nil -end - -json.microposts do - json.array!(@microposts) do |m| - json.id m.id - json.user_id m.user_id - json.content m.content - json.image "#{request.ssl? ? 'https' : 'http'}://#{request.env['HTTP_HOST']}"+url_for(m.display_image) if m.image.attached? - json.timestamp time_ago_in_words(m.created_at) - end -end -json.total_count @microposts.total_count diff --git a/app/views/api/users/update.json.jbuilder b/app/views/api/users/update.json.jbuilder new file mode 100644 index 00000000000..f6fb02a2575 --- /dev/null +++ b/app/views/api/users/update.json.jbuilder @@ -0,0 +1,4 @@ +json.user do + json.extract! @user, :id, :email, :name + json.role @user.admin +end diff --git a/config/database.yml b/config/database.yml index 44c82914a47..4b2e9751ffe 100644 --- a/config/database.yml +++ b/config/database.yml @@ -17,19 +17,22 @@ default: &default adapter: postgresql encoding: unicode + host: postgres + username: postgres + password: password # For details on connection pooling, see Rails configuration guide # https://guides.rubyonrails.org/configuring.html#database-pooling pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> development: <<: *default - database: sample_app_development + database: rails_boilerplate_development # The specified database role being used to connect to postgres. # To create additional roles in postgres see `$ createuser --help`. # When left blank, postgres will use the default role. This is # the same name as the operating system user running Rails. - #username: sample_app + #username: rails_boilerplate # The password associated with the postgres role (username). #password: @@ -57,7 +60,7 @@ development: # Do not set this db to the same as development or production. test: <<: *default - database: sample_app_test + database: rails_boilerplate_test # As with config/credentials.yml, you never want to store sensitive information, # like your database password, in your source code. If your source code is @@ -81,6 +84,9 @@ test: # production: <<: *default - database: sample_app_production - username: sample_app - password: <%= ENV["SAMPLE_APP_DATABASE_PASSWORD"] %> + database: rails_boilerplate_production + # username: rails_boilerplate + # password: <%= ENV["RAILS_BOILERPLATE_DATABASE_PASSWORD"] %> + host: <%= ENV.fetch("DB_HOST", "db") %> + username: <%= ENV.fetch("DB_USERNAME", "postgres") %> + password: <%= ENV.fetch("DB_PASSWORD", "password") %> diff --git a/config/master.key.example b/config/master.key.example new file mode 100644 index 00000000000..63c911633f8 --- /dev/null +++ b/config/master.key.example @@ -0,0 +1 @@ +6a31ee167b28ab32ab171795d2eff778 \ No newline at end of file diff --git a/config/puma.rb b/config/puma.rb index b77fb8c5f72..bed5fd06552 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -11,7 +11,7 @@ # Specifies the `port` that Puma will listen on to receive requests; default is 3000. # -port ENV.fetch("PORT") { 3001 } +port ENV.fetch("PORT") { 3000 } # Specifies the `environment` that Puma will run in. # diff --git a/db/migrate/20220408200125_create_users.rb b/db/migrate/20220408200125_create_users.rb index a5c65cb8bf7..671cc669abd 100644 --- a/db/migrate/20220408200125_create_users.rb +++ b/db/migrate/20220408200125_create_users.rb @@ -3,8 +3,13 @@ def change create_table :users do |t| t.string :name t.string :email + t.string "refresh_token" + t.datetime "refresh_token_expiration_at" t.timestamps + + t.index %i[email], unique: true, name: 'index_admin_users_email_uniqueness' + t.index %i[refresh_token], unique: true, name: 'index_admin_users_refresh_token_uniqueness' end end end diff --git a/db/schema.rb b/db/schema.rb index 1cf6c3e4a14..e67a2094dd3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -192,6 +192,8 @@ create_table "users", force: :cascade do |t| t.string "name" t.string "email" + t.string "refresh_token" + t.datetime "refresh_token_expiration_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "password_digest" @@ -202,7 +204,9 @@ t.datetime "activated_at" t.string "reset_digest" t.datetime "reset_sent_at" + t.index ["email"], name: "index_admin_users_email_uniqueness", unique: true t.index ["email"], name: "index_users_on_email", unique: true + t.index ["refresh_token"], name: "index_admin_users_refresh_token_uniqueness", unique: true end create_table "variants", force: :cascade do |t| diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 00000000000..684e79f160b --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,56 @@ +version: "3.9" + +services: + web: + build: . + container_name: sample_app.api + ports: + - 3000:3000 + volumes: + - ./:/app + - /app/tmp + - gem-data:/usr/local/bundle + working_dir: /app + command: bundle exec puma -C config/puma.rb -e production + networks: + - sample_app-networks + depends_on: + - postgres + environment: + DATABASE_URL: postgres://postgres:password@5432:5432/rails_boilerplate_production + environment: + - POSTGRES_HOST_AUTH_METHOD=trust + stdin_open: true + tty: true + + postgres: + image: postgres:13 + container_name: sample_app.postgres + volumes: + - db-data:/var/lib/postgresql/data + networks: + - sample_app-networks + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + + redis: + image: redis + command: redis-server + container_name: sample_app.redis + volumes: + - redis-data:/var/shared/redis + networks: + - sample_app-networks + +networks: + sample_app-networks: + name: sample_app + external: true + +volumes: + db-data: + gem-data: + redis-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000000..a7e86194252 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,56 @@ +version: "3.9" + +services: + web: + build: . + container_name: sample_app.api + ports: + - 3000:3000 + volumes: + - ./:/app + - /app/tmp + - gem-data:/usr/local/bundle + working_dir: /app + command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'" + networks: + - sample_app-networks + depends_on: + - postgres + environment: + DATABASE_URL: postgres://postgres:password@5432:5432/rails_boilerplate_development + environment: + - POSTGRES_HOST_AUTH_METHOD=trust + stdin_open: true + tty: true + + postgres: + image: postgres:13 + container_name: sample_app.postgres + volumes: + - db-data:/var/lib/postgresql/data + networks: + - sample_app-networks + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + + redis: + image: redis + command: redis-server + container_name: sample_app.redis + volumes: + - redis-data:/var/shared/redis + networks: + - sample_app-networks + +networks: + sample_app-networks: + name: sample_app + external: true + +volumes: + db-data: + gem-data: + redis-data: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 00000000000..0ec28e671e5 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +# install missing gems +bundle check || bundle install --jobs 20 --retry 5 + +# Remove a potentially pre-existing server.pid for Rails. +rm -f /rails_app/tmp/pids/server.pid + +# Then exec the container's main process (what's set as CMD in the Dockerfile). +exec "$@" diff --git a/heroku.yml b/heroku.yml new file mode 100644 index 00000000000..3bfc3c712fc --- /dev/null +++ b/heroku.yml @@ -0,0 +1,10 @@ +build: + docker: + web: Dockerfile + config: + BUNDLE_INSTALL_ARGS: --jobs 10 --retry=3 + RAILS_ENV: production + # Put extra deps here + INSTALL_DEPENDENCIES: curl openssh-server python +run: + web: bundle exec puma -C config/puma.rb \ No newline at end of file diff --git a/rails-app.sh b/rails-app.sh new file mode 100644 index 00000000000..8647a1f5d92 --- /dev/null +++ b/rails-app.sh @@ -0,0 +1,296 @@ +#!/bin/bash + +# Set some global variables +BASE_NAME="base" +APP_NAME="myapp" +OP="run" +BASE_IMAGE_NAME="rails_web" +DB_PATH="tmp/db" +PARAMS="" + +ECHO="echo -e" + +# Define correct usage +usage () +{ + ${ECHO} " ${cc_blue}Usage:${cc_normal}" + ${ECHO} + ${ECHO} " ${0} [options] [op-params]" + ${ECHO} + ${ECHO} " ${cc_blue} possibilities:${cc_normal}" + ${ECHO} " setup Initial setup" + ${ECHO} " update Update gems with new Gemfile" + ${ECHO} " up Runs the app. If setup wasn't run first it will do that as well if needed." + ${ECHO} " Default operation." + ${ECHO} " run [op-params] Executes a command on the rails container with the given op-params" + ${ECHO} " down Powers down the app" + ${ECHO} " clean Remove last app" + ${ECHO} " clean-all Remove all including base image" + ${ECHO} + ${ECHO} " ${cc_blue}Options:${cc_normal}" + ${ECHO} " --app-name Defines the name of the app, container and directory for the app" + ${ECHO} " Defaults to 'myapp'" + ${ECHO} " --help This usage information" + ${ECHO} + ${ECHO} " e.g. ${0} --app-name awesome-app setup" + ${ECHO} + exit 1 +} + +# Check the parameters +while [ $# -gt 0 ]; do + case "${1}" in + --app-name) + if [ -n "${2}" ]; then + APP_NAME=${2} + shift + else + usage + fi + ;; + setup) + OP="setup" + shift + ;; + update) + OP="update" + shift + ;; + up) + OP="up" + shift + ;; + down) + OP="down" + shift + ;; + clean) + OP="clean" + shift + ;; + clean-all) + OP="clean-all" + shift + ;; + run) + OP="run" + shift + while [ $# -gt 0 ]; do + PARAMS="${PARAMS} ${1}" + shift + done + break 2 + ;; + --help) + usage + exit 0 + ;; + *) + usage + ;; + esac + shift +done + +if [ -z "${OP}" ]; then + ${ECHO} " ${cc_red}No valid operation given!${cc_normal}" + usage + exit 0 +fi + +if [ -z "${APP_NAME}" ]; then + ${ECHO} " ${cc_red}No valid app-name given! Please just leave the option off to use the default name 'myapp'. ${cc_normal}" + usage + exit 0 +fi + +test_base_image () +{ + ${ECHO} "Testing for base image \"${BASE_IMAGE_NAME}\"" + TEST=`docker image ls | grep ${BASE_IMAGE_NAME}` + if [ -n "${TEST}" ]; then + ${ECHO} "Found" + return 1 + fi + ${ECHO} "None found" + return 0 +} + +test_app_exists () +{ + ${ECHO} "Testing \"${APP_NAME}\" existence" + APP_EXISTS=`ls | grep ${APP_NAME}` + if [ -n "$APP_EXISTS" ]; then + ${ECHO} "Found" + return 1 + fi + ${ECHO} "None found" + return 0 +} + +test_db_exists () +{ + ${ECHO} "Testing \"${APP_NAME}\" DB existence" + DBP_EXISTS=`sudo ls ${APP_NAME}/${DB_PATH}/pg_hba.conf` + if [ $? == 0 ]; then + ${ECHO} "Found" + return 1 + fi + ${ECHO} "None found" + return 0 +} + +run_setup () +{ + ${ECHO} "Setting up..." + test_base_image + if [ $? == 0 ]; then + ${ECHO} "Base image \"${BASE_IMAGE_NAME}\" doesn't exist. Running setup." + BASE_PATH=${PWD} APP_NAME=${APP_NAME} docker-compose build + else + ${ECHO} "Base image \"${BASE_IMAGE_NAME}\" exists." + fi + test_app_exists + if [ $? == 0 ]; then + ${ECHO} "Creating \"${APP_NAME}\" directory." + mkdir ${APP_NAME} + cp Gemfile* ${APP_NAME}/ + chmod a+w ${APP_NAME}/Gemfile.lock + BASE_PATH=${PWD} APP_NAME=${APP_NAME} docker-compose run --rm --no-deps web rails new . --force --database=postgresql + if [ $? == 0 ]; then + ${ECHO} "Need to ensure all files of newly created app \"${APP_NAME}\" are owned by user \"${USER}\"" + sudo chown -R $USER:$USER ${APP_NAME} + cp database.yml ${APP_NAME}/config/database.yml + sed -i 's@APP_NAME@'"${APP_NAME}"'@g' ${APP_NAME}/config/database.yml + test_db_exists + if [ $? == 0 ]; then + ${ECHO} "Need to ensure database for \"${APP_NAME}\" exists and are owned by \"postgres\"" + mkdir -p ${APP_NAME}/${DB_PATH} + sudo chown -R postgres:postgres ${APP_NAME}/${DB_PATH} + fi + ${ECHO} "Fixing view files monitoring" + sed -i 's/EventedFileUpdateChecker/FileUpdateChecker/g' ${APP_NAME}/config/environments/development.rb + fi + else + ${ECHO} "${APP_NAME} directory exists." + fi + + ${ECHO} "Setup done" + ${ECHO} +} + +run_update () +{ + ${ECHO} "Updating..." + test_base_image + if [ $? == 0 ]; then + ${ECHO} "Base image \"${BASE_IMAGE_NAME}\" doesn't exist. Run setup instead." + exit 0 + else + ${ECHO} "Base image \"${BASE_IMAGE_NAME}\" exists." + test_app_exists + if [ $? == 0 ]; then + ${ECHO} "${APP_NAME} directory doesn't exist. Run setup instead." + exit 0 + else + ${ECHO} "${APP_NAME} directory exists." + docker container prune -f + docker image rm ${BASE_IMAGE_NAME}:latest + cp ${APP_NAME}/Gemfile ./ + cp ${APP_NAME}/Gemfile.lock ./ + BASE_PATH=${PWD} APP_NAME=${APP_NAME} docker-compose up --force-recreate --no-start + fi + fi + ${ECHO} "Update done" + ${ECHO} +} + +run_app () +{ + ${ECHO} "Running App \"${APP_NAME}\"" + BASE_PATH=${PWD} APP_NAME=${APP_NAME} docker-compose up + ${ECHO} "\"${APP_NAME}\" done" + ${ECHO} +} + +run_down () +{ + ${ECHO} "Downing App \"${APP_NAME}\"" + BASE_PATH=${PWD} APP_NAME=${APP_NAME} docker-compose down + ${ECHO} "\"${APP_NAME}\" done" + ${ECHO} +} + +run_exec () +{ + ${ECHO} "Executing the rails container with \"${PARAMS}\"" + test_app_exists + if [ $? == 1 ]; then + # BASE_PATH=${PWD} APP_NAME=${APP_NAME} docker-compose run --rm --service-ports web ${PARAMS} + BASE_PATH=${PWD} APP_NAME=${APP_NAME} docker-compose run --rm web ${PARAMS} + fi + + ${ECHO} "Execution done" + ${ECHO} +} + +run_clean () +{ + ${ECHO} "Cleaning \"${APP_NAME}\" " + test_app_exists + if [ $? == 1 ]; then + sudo rm -rf ${APP_NAME} + fi + ${ECHO} "Cleaning done" + ${ECHO} +} + +run_clean_all () +{ + run_clean + ${ECHO} "Cleaning all " + test_base_image + if [ $? == 1 ]; then + docker image rm ${BASE_IMAGE_NAME} + fi + ${ECHO} "Cleaning all done" + ${ECHO} +} + +fix_unix_style () +{ + FILES_TO_CHECK="Gemfile* entrypoint.sh Dockerfile *.yml" + NOT_UNIX=`file ${FILES_TO_CHECK} | grep CRLF` + if [ -n "${NOT_UNIX}" ]; then + ${ECHO} "Making sure all files have proper Linux line endings" + dos2unix ${FILES_TO_CHECK} + ${ECHO} "Done" + fi +} + +fix_unix_style + +case "${OP}" in + setup) + run_setup + ;; + update) + run_update + ;; + up) + run_setup + run_app + ;; + run) + run_exec + ;; + down) + run_down + ;; + clean) + run_clean + ;; + clean-all) + run_clean_all + ;; +esac \ No newline at end of file diff --git a/wsl.conf b/wsl.conf new file mode 100644 index 00000000000..3fbc6185eab --- /dev/null +++ b/wsl.conf @@ -0,0 +1,6 @@ +[automount] +options = "metadata" +enabled = true + +[interop] +appendWindowsPath = true \ No newline at end of file