📜 ⬆️ ⬇️

Writing a forum from scratch on Ruby on Rails and AngularJS

Not so long ago, I talked about the Oxymoron gem, which allows you to very easily and quickly build modern Single Page Application on AngularJS and Ruby on Rails. The article was met very positively, so it's time to write a more or less complex application to show all the possibilities of the heme.

Considering the mistakes of past articles, I registered a domain name and rented a server to deploy applications for the habr.

Full source repository
Deployed Application

Task


Write a forum with the following functionality:

')

Selected technologies


I will use PostgreSQL as a database , since I need the ability to store arrays and hashes. For the search will be used the search engine Sphinx . Image processing on the server according to the good old tradition will go through ImageMagick .
In this article, I will not use ancillary caching tools and will try to get away with only the Rails and Postgresql capabilities.

Used gems
gem 'active_model_serializers', '0.9.4' gem 'pg' gem 'slim' gem 'slim-rails' gem 'devise' gem 'gon' gem 'carrierwave' gem 'mysql2', '~> 0.3.18', :platform => :ruby gem 'thinking-sphinx', '~> 3.1.4' gem 'mini_magick' gem "oxymoron" gem 'kaminari' gem 'oj' gem 'file_validators' 



Database Description


Based on the requirements, you can draw an approximate database schema:



Create the appropriate models and migrations to them:

 rails g model Group rails g model Theme rails g model Topic rails g model Post 


Generation of the user model is done using the Devise gem:

 rails g devise:install rails g devise User 


Migration Content:

create_groups.rb
 class CreateGroups < ActiveRecord::Migration def change create_table :groups do |t| t.string :title t.timestamps null: false end end end 


create_themes.rb
 class CreateThemes < ActiveRecord::Migration def change create_table :themes do |t| t.string :title t.integer :group_id t.index :group_id t.integer :posts_count, default: 0 t.integer :topics_count, default: 0 t.json :last_post t.timestamps null: false end end end 


create_topics.rb
 class CreateTopics < ActiveRecord::Migration def change create_table :topics do |t| t.string :title t.text :content t.integer :user_id t.index :user_id t.integer :group_id t.index :group_id t.integer :theme_id t.index :theme_id t.json :last_post t.integer :posts_count, default: 0 t.timestamps null: false end end end 


create_posts.rb
 class CreatePosts < ActiveRecord::Migration def change create_table :posts do |t| t.string :title t.text :content t.integer :user_id t.index :user_id t.integer :topic_id t.index :topic_id t.integer :theme_id t.index :theme_id t.boolean :delta, default: true t.timestamps null: false end end end 


create_users.rb
 class DeviseCreateUsers < ActiveRecord::Migration def change create_table :users do |t| ## Database authenticatable t.string :email, null: false, default: "" t.string :encrypted_password, null: false, default: "" ## Recoverable t.string :reset_password_token t.datetime :reset_password_sent_at ## Rememberable t.datetime :remember_created_at ## Trackable t.integer :sign_in_count, default: 0, null: false t.datetime :current_sign_in_at t.datetime :last_sign_in_at t.string :current_sign_in_ip t.string :last_sign_in_ip ## Confirmable # t.string :confirmation_token # t.datetime :confirmed_at # t.datetime :confirmation_sent_at # t.string :unconfirmed_email # Only if using reconfirmable ## Lockable # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts # t.string :unlock_token # Only if unlock strategy is :email or :both # t.datetime :locked_at t.string :name t.boolean :banned, default: false t.integer :avatar_id t.string :avatar_url, default: "/default_avatar.png" t.integer :rating, default: 0 t.integer :votes, array: true, default: [] t.integer :posts_count, default: 0 t.integer :topics_count, default: 0 t.string :role t.timestamps null: false end add_index :users, :email, unique: true add_index :users, :reset_password_token, unique: true # add_index :users, :confirmation_token, unique: true # add_index :users, :unlock_token, unique: true end end 



To solve the issues of uploading files to the server, I always create a separate model and put an uploader on it. In this case, this is the Avatar model:

 rails g model avatar 

create_avatar.rb
 class CreateAvatars < ActiveRecord::Migration def change create_table :avatars do |t| t.string :body t.timestamps null: false end end end 



Model organization



We indicate all the necessary relationships and validations for our models:

models / group.rb
 class Group < ActiveRecord::Base has_many :themes, ->{order(:id)}, dependent: :destroy has_many :topics, through: :themes, dependent: :destroy has_many :posts, through: :topics, dependent: :destroy end 


models / theme.rb
 class Theme < ActiveRecord::Base has_many :topics, dependent: :destroy has_many :posts, dependent: :destroy belongs_to :group end 


models / topic.rb
 class Topic < ActiveRecord::Base has_many :posts, dependent: :destroy belongs_to :theme, :counter_cache => true belongs_to :user, :counter_cache => true validates_presence_of :theme, :title, :content end 


models / post.rb
 class Post < ActiveRecord::Base belongs_to :topic, :counter_cache => true belongs_to :theme, :counter_cache => true belongs_to :user, :counter_cache => true validates :content, presence: true, length: { in: 2..300 } end 


models / user.rb
 class User < ActiveRecord::Base devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable belongs_to :avatar has_many :posts has_many :topics validates :name, :uniqueness => {:case_sensitive => false}, presence: true, length: { in: 2..10 } end 


models / avatar.rb
 class Avatar < ActiveRecord::Base belongs_to :user end 



For the Topic and Theme models, you must install the last post you created in the last_post field. This is best done in callback after_create Post model:

models / post.rb
 class Post < ActiveRecord::Base belongs_to :topic, :counter_cache => true belongs_to :theme, :counter_cache => true belongs_to :user, :counter_cache => true validates :content, presence: true, length: { in: 2..300 } after_create :set_last_post private def set_last_post last_post = self.as_json(include: [:topic, :user]) topic.update(last_post: last_post) theme.update(last_post: last_post) end end 



and after creating the topic, you need to create the first post in it containing the title and content of the topic:

models / topic.rb
 class Topic < ActiveRecord::Base has_many :posts, dependent: :destroy belongs_to :theme, :counter_cache => true belongs_to :user validates_presence_of :theme, :title, :content after_create :create_post private def create_post self.posts.create self.attributes.slice("title", "content", "user_id", "theme_id") end end 



We proceed to the model Avatar. First of all, we will generate an uploader, which will be used to process the loaded avatars. I use carrierwave:

 rails g uploader Avatar 

Let us indicate to our uploader that he should compress all downloaded images to the thumb version (150x150 pixels), and he will do this via MiniMagick (for ImageMagick wrapper):

uploaders / avatar_uploader.rb
 class AvatarUploader < CarrierWave::Uploader::Base include CarrierWave::MiniMagick storage :file def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end version :thumb do process :resize_to_fill => [150, 150] end def extension_white_list %w(jpg jpeg gif png) end end 



Now we connect the AvatarUploader to the Avatar model and indicate that the size of the uploaded file should be no more than 2 MB:

models / avatar.rb
 class Avatar < ActiveRecord::Base mount_uploader :body, AvatarUploader belongs_to :user validates :body, file_size: { less_than: 2.megabytes }, file_content_type: { allow: ['image/jpg', 'image/jpeg', 'image/png', 'image/gif'] } end 



Upload files to server


Oxymoron has a fileupload directive. In order to send a file to the server, you must define it on the input [type = "file"] tag

 input type="file" fileupload="'/uploads/avatar'" ng-model="result_from_server" percent-completed="percent" 

In response, an array is expected. If necessary, you can specify the attribute multiple. If multiple is not specified, then the first element of the array will be put into the result_from_server variable; otherwise, the entire array.
Generate UploadsController, responsible for uploading files to the server:

 rails g controller uploads 

Create an avatar method that will control the logic of avatar loading:

 class UploadsController < ApplicationController before_action :authenticate_user! def avatar avatar = Avatar.new(body: params[:attachments].first) if avatar.save avatar_url = avatar.body.thumb.url current_user.update(avatar_id: avatar.id, avatar_url: avatar_url) render json: Oj.dump([avatar_url]) else render json: {msg: avatar.errors.full_messages.join(", ")} end end end 


Search by post


For full-text search, I use Sphinx and gem thinking_sphinx . The first step is to create a config file for thinking_sphinx, which will be translated to sphinx.conf. So, we need a styling search with the ability to search by an asterisk (autocomplete) and a minimum query of 3 characters. We describe this in thinking_sphinx.yml:

config / thinking_sphinx.yml
 development: &generic mem_limit: 256M enable_star: 1 expand_keywords: 1 index_exact_words: 1 min_infix_len: 3 min_word_len: 3 morphology: stem_enru charset_type: utf-8 max_matches: 100000 per_page: 100000 utf8: true mysql41: 9421 charset_table: "0..9, A..Z->a..z, _, a..z, \ U+410..U+42F->U+430..U+44F, U+430..U+44F, U+401->U+0435, U+451->U+0435" staging: <<: *generic mysql41: 9419 production: <<: *generic mysql41: 9450 test: <<: *generic mysql41: 9418 quiet_deltas: true 


Now create an index for the posts. The title and content should be indexed. The result will be sorted in reverse order from the date of creation, so it must be specified in the form of a filter:

app / indices / post_index.rb
 ThinkingSphinx::Index.define :post, {delta: true} do indexes title indexes content has created_at end 


We perform sphinx-config generation and start the searchd daemon with one command:

 rake ts:rebuild 

If the rebuild is successful, then you will see in the console a message that the daemon started successfully.

Add a method to search in the Post model. Since the search method took thinking_sphinx, I used look_for:

 def self.look_for query return self if query.blank? or query.length < 3 search_ids = self.search_for_ids(query, {per_page: 1000, order: 'created_at DESC'}) self.where(id: search_ids) end 


We will generate the controller responsible for the search and define the index method, which will process the search logic:

 rails g controller search index 

We will define this method later.

Captcha reCAPTCHA


In order to keep up with the fashion, we will connect a new reCAPTCHA to our application. After registration, you will be available 2 keys: public and private. Both of these keys we put in secrets.yml. There we will store all possible api-key of our application.

config / secrets.yml
 apikeys: &generic recaptcha: public_key: your_recaptcha_public_key secret_key: your_recaptcha_secret_key # generate your_secret_key_base by `rake secret` development: <<: *generic secret_key_base: your_secret_key_base test: <<: *generic secret_key_base: your_secret_key_base production: <<: *generic secret_key_base: your_secret_key_base 


Let's write the protected method in ApplicationContoller, which verifies captcha

 protected def verify_captcha response result = RestClient.post( "https://www.google.com/recaptcha/api/siteverify", secret: Rails.application.secrets[:recaptcha]["secret_key"], response: response) JSON.parse(result)["success"] end 

This method is now available on all controllers inherited from ApplicationController.

Authorization and registration


We have a clean SPA application. We do not reload the page even at login / login. Create controllers for session management and registration based on JSON API:

controllers / auth / sessions_controller.rb
 class Auth::SessionsController < Devise::SessionsController skip_before_action :authenticate_user! after_filter :set_csrf_headers, only: [:create, :destroy] def create if verify_captcha(params[:user][:recaptcha]) self.resource = warden.authenticate(auth_options) if self.resource sign_in(resource_name, self.resource) render json: {msg: "    ", current_user: current_user.public_fields} else render json: {msg: "Email  ,   "}, status: 401 end else render json: {msg: "   "}, status: 422 end end def destroy Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name) render json: {msg: "  "} end protected def set_csrf_headers cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery? end end 


controllers / auth / registrations_controller.rb
 class Auth::RegistrationsController < Devise::RegistrationsController skip_before_action :authenticate_user! def create if verify_captcha(params[:user][:recaptcha]) build_resource(sign_up_params) resource.save unless resource.persisted? render json: { msg: resource.errors.full_messages.first, errors: resource.errors, }, status: 403 else sign_up(resource_name, resource) render json: { msg: "  !", current_user: current_user.public_fields } end else render json: {msg: "   "}, status: 422 end end private def sign_up_params params.require(:user).permit(:name, :email, :password) end end 



No comments here. Unless it is worth paying attention to set_csrf_headers . Since the page is not updated with us, we need to receive "fresh" CSRF tokens from the server in order not to be vulnerable to CSRF attacks . For ActionController, this is done automatically by Oxymoron. For all other controllers that bypass ActionController, it is necessary to set in cookies ['XSRF-TOKEN'] the current value of the CSRF token.

Now we need to block all pages that require authorization. To do this, we will have to override the authenticate_user method ! . Let's do this in ApplicationController:

 before_action :authenticate_user!, only: [:create, :update, :destroy, :new, :edit] private def authenticate_user! unless current_user if request.xhr? render json: {msg: "  "}, status: 403 else redirect_to root_path end end end 


Routing application


We will immediately describe the routes.rb file so that it will not be returned to it anymore. So, we have 5 resources: users , groups , themes , topics and posts . There are also routes / uploads / avatar and / search . In addition, we need methods on the users resource to determine the online user, get its rating and other statistics.

 Rails.application.routes.draw do root to: 'groups#index' devise_for :users, controllers: { sessions: 'auth/sessions', registrations: 'auth/registrations', } post "uploads/avatar" => "uploads#avatar" get "search" => "search#index" resources :groups resources :themes resources :topics resources :posts resources :users, only: [:index, :show] do collection do get "touch" # touch  current_user,     get "metrics" #   end member do put "rate" #   put "ban" #  put "unban" #  end end end 


Serialization


I like the ActiveModelSerializer serialization philosophy , but I am very constrained in server capacities, especially before the Habraeffect. Therefore it was necessary to invent a mechanism for the fastest possible serialization, which is only possible within the framework of the current project. The main criterion presented by me before serialization is that it should not take more than 5-10 ms.

Everything you read further may seem alien, strange and wrong to you.

The idea is to transfer only selected fields to the client directly from the database using the join tables. At the same time, send the name of the serializer to the client, which must be applied to the answer. Angulyar allows you to intercept all requests and responses, and change them at will. Consequently, we can serialize the object on the client, while not cluttering up requests with callbacks.

The query interceptor looks like this:

javascripts / serializers / interceptor.js
 app.factory('serializerInterceptor', ['$q', function ($q) { return { response: function (response) { //     ,  if (response.data.serializer) { //        var serializer = window[response.data.serializer]; //   ,  if (serializer) { //   var collection = serializer(response.data.collection); //     ,      collection,   resource if (response.data.single) { response.data.resource = collection[0] } else { response.data.collection = collection; } } else { console.error(response.data.serializer + " is not defined") } } //      return response || $q.when(response); } }; }]) //  serializerInterceptor     http-,   Angular .config(['$httpProvider', function ($httpProvider) { $httpProvider.interceptors.push('serializerInterceptor'); }]) 


To the client, we will transfer the result of the select to the join tables. For example, we want to pass along with the post also the user who created it:

 collection = Post.joins(:user).pluck("posts.id", "posts.title", "posts.content", "users.id", "users.name") render json: { collection: collection, serializer: "ExampleSerializer" } 

The result will be a table with the corresponding columns, or in the JSON representation it is an array consisting of arrays. Let's write a serializer:

Sample serializer
 function ExampleSerializer (collection) { var result = []; collection.forEach(function(item) { id: item[0], title: item[1], content: item[2], user: { id: item[3], name: item[4] } }) return result } 


This serializer will be automatically applied to collection, and in response to any $ http request we will see the serialized result.
Now it is necessary to create a method for each model pluck_fields, which returns the fields for the select:

models / group.rb
 class Group < ActiveRecord::Base has_many :themes, ->{order(:id)}, dependent: :destroy has_many :topics, through: :themes, dependent: :destroy has_many :posts, through: :topics, dependent: :destroy def self.pluck_fields ["groups.id", "groups.title", "themes.id", "themes.title", "themes.posts_count", "themes.topics_count", "themes.last_post"] end end 


models / post.rb
 class Post < ActiveRecord::Base belongs_to :topic, :counter_cache => true belongs_to :theme, :counter_cache => true belongs_to :user, :counter_cache => true after_create :set_last_post validates :content, presence: true, length: { in: 2..300 } def self.pluck_fields ["posts.id", "posts.title", "posts.content", "users.id", "users.created_at", "users.name", "users.rating", "users.posts_count", "users.avatar_url", "topics.id", "topics.title"] end def self.look_for query return self if query.blank? or query.length < 3 search_ids = self.search_for_ids(query, {per_page: 1000000, order: 'created_at DESC'}) self.where(id: search_ids) end private def set_last_post last_post = self.as_json(include: [:topic, :user]) topic.update(last_post: last_post) theme.update(last_post: last_post) end end 


models / theme.rb
 class Theme < ActiveRecord::Base has_many :topics, dependent: :destroy has_many :posts, dependent: :destroy belongs_to :group def self.pluck_fields [:id, :title] end end 


models / topic.rb
 class Topic < ActiveRecord::Base has_many :posts, dependent: :destroy belongs_to :theme, :counter_cache => true belongs_to :user validates_presence_of :theme, :title, :content after_create do Post.create(title: title, content: content, user_id: user_id, theme_id: theme_id, topic_id: id) end def self.pluck_fields ["topics.id", "topics.title", "topics.last_post", "topics.posts_count", "users.id", "users.name", "themes.id", "themes.title"] end end 


models / user.rb
 class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable belongs_to :avatar has_many :posts validates :name, :uniqueness => {:case_sensitive => false}, presence: true, length: { in: 2..10 } def self.pluck_fields [:id, :created_at, :updated_at, :name, :avatar_url, :posts_count, :rating, :banned] end def public_fields self.attributes.slice("id", "email", "rating", "name", "created_at", "updated_at", "posts_count", "banned") end end 


We will use these methods in controllers to transfer them to the pluck method.

Controllers


In my previous article , The Single Page Application Architecture Based on AngularJS and Ruby on Rails, I gave an example of a “typical Rails controller” . Typical means that we do not need to describe the same logic each time. It is enough to write the most common controller, inherit from it and override it, or add the necessary methods. I didn’t write such a controller, and just endured all the common logic in concern.
The resulting concern looks very unusual:

controllers / concern / spa.rb
 module Spa extend ActiveSupport::Concern # @model –  (  ),     # @resource –   #           included do before_action :set_model before_action :set_resource, only: [:show, :edit, :update, :destroy] def index respond_to do |format| format.html format.json { collection = @model.where(filter_params) if params[:filter] render json: Oj.dump({ total_count: collection.count, serializer: serializer, collection: collection.page(params[:page]).per(10).pluck(*pluck_fields), page: params[:page] || 1 }) } end end def show respond_to do |format| format.html format.json { @resource = @model.where(id: params[:id]).pluck(*pluck_fields) render json: Oj.dump({ collection: @resource, serializer: serializer, single: true }) } end end def new new_params = resource_params rescue {} @resource = @model.new(new_params) authorize @resource, :create? respond_to do |format| format.html format.json { render json: Oj.dump(@resource) } end end def edit authorize @resource, :update? respond_to do |format| format.html format.json { render json: Oj.dump(@resource) } end end def create @resource = @model.new resource_params authorize @resource if @resource.save @collection = @model.where(id: @resource.id).pluck(*pluck_fields) result = { collection: @collection, serializer: serializer, single: true, }.merge(redirect_options[:update] || {}) render json: Oj.dump(result) else render json: {errors: @resource.errors, msg: @resource.errors.full_messages.join(', ')}, status: 422 end end def update authorize @resource if @resource.update(resource_params) render json: {resource: @resource, msg: "#{@model.name}  "}.merge(redirect_options[:update] || {}) else render json: {errors: @resource.errors, msg: @resource.errors.full_messages.join(', ')}, status: 422 end end def destroy authorize @resource @resource.destroy render json: {msg: "#{@model.name}  "} end private def set_resource @resource = @model.find(params[:id]) end def pluck_fields @model.pluck_fields end def redirect_options {} end def filter_params params.require(:filter).permit(filter_fields) end def serializer serializer = "#{@model.model_name}Serializer" end end end 



So, let's create on its basis PostsController:

 class PostsController < ApplicationController include Spa private #     def set_model @model = Post.joins(:user, :topic).order(:created_at) end #  ,      def filter_fields [:theme_id, :topic_id] end #  ,      def resource_params #     ,    ,      topic = Topic.find(params[:post][:topic_id]) title = params[:post][:title] params.require(:post).permit(:content, :title, :topic_id) .merge({ theme_id: topic.theme_id, user_id: current_user.id, title: title.present? ? title : "Re: #{topic.title}" }) end end 

This is all the controller code that differs from Spa. Similarly, create the rest of the controllers:

controllers / groups_controller.rb
 class GroupsController < ApplicationController include Spa private def set_model @model = Group.joins("LEFT JOIN themes ON themes.group_id = groups.id").order("groups.id") end def redirect_options { create: { redirect_to_url: root_path }, update: { redirect_to_url: root_path } } end def resource_params params.require(:group).permit(:title) end end 


controllers / themes_controller.rb
 class ThemesController < ApplicationController include Spa private def set_model @model = Theme.order(:created_at) end def redirect_options { create: { redirect_to_url: root_path }, update: { redirect_to_url: root_path } } end def resource_params params.require(:theme).permit(:title, :group_id) end end 


controllers / topics_controller.rb
 class TopicsController < ApplicationController include Spa private def set_model @model = Topic.joins(:theme, :user).order("topics.updated_at DESC") end def filter_fields [:theme_id] end def redirect_options { create: { redirect_to_url: topic_path(@resource) }, update: { redirect_to_url: topic_path(@resource) } } end def resource_params params.require(:topic).permit(:title, :content, :theme_id) .merge({ user_id: current_user.id }) end end 


controllers / users_controller.rb
 class UsersController < ApplicationController include Spa def touch current_user.touch if current_user render json: {} end def rate if current_user.votes.include?(params[:id].to_i) return render json: {msg: "     "}, status: 422 end current_user.votes.push(params[:id].to_i) current_user.save set_resource if params[:positive] @resource.increment!(:rating) else @resource.decrement!(:rating) end render json: {rating: @resource.rating} end def metrics result = current_user.attributes.slice("posts_count", "rating") if current_user render json: result || {} end def ban authorize @resource @resource.update(banned: true) render json: {msg: "  "} end def unban authorize @resource, :ban? @resource.update(banned: false) render json: {msg: "  "} end private def set_model @model = User end end 



SearchController does not use concern Spa, so we will describe it in full:

SearchController
 class SearchController < ApplicationController def index respond_to do |format| format.html format.json { collection = Post.look_for(params[:q]).joins(:user, :topic).order("created_at DESC") render json: Oj.dump({ total_count: collection.count, serializer: "PostSerializer", collection: collection.page(params[:page]).per(10).pluck(*Post.pluck_fields), page: params[:page] || 1 }) } end end end 



Differentiation of access rights


I deliberately did not use Rolify to organize user roles, because in this case it is not justified. There are no combined roles on the forum. All management goes through the role field. I use Pundit to differentiate access rights. In the description of the heme has all the information on its use.
Let's write all the policies for our application based on the requirements:

app / policies / group_policy.rb
 class GroupPolicy def initialize(user, group) @user = user @group = group end def create? @user.role == "admin" end def update? @user.role == "admin" end def destroy? @user.role == "admin" end end 


app / policies / post_policy.rb
 class PostPolicy def initialize(user, post) @user = user @post = post end def create? true end def update? ["admin", "moderator"].include?(@user.role) || @user.id == @post.user_id end def destroy? ["admin", "moderator"].include?(@user.role) || @user.id == @post.user_id end end 


app / policies / theme_policy.rb
 class ThemePolicy def initialize(user, theme) @user = user @theme = theme end def create? @user.role == "admin" end def update? @user.role == "admin" end def destroy? @user.role == "admin" end end 


app / policies / topic_policy.rb
 class TopicPolicy def initialize(user, topic) @user = user @topic = topic end def create? true end def update? @user.id == @topic.user_id || ["admin", "moderator"].include?(@user.role) end def destroy? @user.id == @topic.user_id || ["admin", "moderator"].include?(@user.role) end end 


app/policies/user_policy.rb
 class UserPolicy def initialize(user, resource) @user = user @resource = resource end def ban? ["admin", "moderator"].include? @user.role end end 



Pundit Pundit::NotAuthorizedError , , JSON API. ApplicationController :

  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized private def user_not_authorized if request.xhr? render json: {msg: "    "}, status: 403 else redirect_to root_path end end 


, , ApplicationController, Gon , :

controllers/application_controller.rb
 class ApplicationController < ActionController::Base include Pundit protect_from_forgery with: :exception rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized before_action :authenticate_user!, only: [:create, :update, :destroy, :new, :edit] #     ajax- layout proc { if request.xhr? false else set_gon "application" end } protected def verify_captcha response result = RestClient.post("https://www.google.com/recaptcha/api/siteverify", secret: Rails.application.secrets[:recaptcha]["secret_key"], response: response) JSON.parse(result)["success"] end private def set_gon gon.current_user = current_user.public_fields if current_user end def authenticate_user! unless current_user if request.xhr? render json: {msg: "  "}, status: 403 else redirect_to root_path end end end def user_not_authorized if request.xhr? render json: {msg: "    "}, status: 403 else redirect_to root_path end end end 



Client part


Oxymoron , .

AngularJS- . :

javascripts/controllers/groups_ctrl.js
 app.controller('GroupsCtrl', ['$scope', 'Group', 'action', 'Theme', function ($scope, Group, action, Theme) { var ctrl = this; action('index', function () { ctrl.groups = Group.get(); ctrl.destroy_theme = function (theme) { if (confirm(" ?")) Theme.destroy({id: theme.id}) } ctrl.destroy_group = function (group) { if (confirm(" ?")) Group.destroy({id: group.id}) } }) action('new', function () { ctrl.group = Group.new(); ctrl.save = Group.create; }) action('edit', function (params) { ctrl.group = Group.edit(params); ctrl.save = Group.update; }) }]) 


javascripts/controllers/themes_ctrl.js
 app.controller('ThemesCtrl', ['$scope', 'Theme', 'Topic', 'action', '$location', function ($scope, Theme, Topic, action, $location) { var ctrl = this; action('show', function (params) { var filter = { theme_id: params.id } ctrl.theme = Theme.get(params); ctrl.query = function (page) { Topic.get({ filter: filter, page: page }, function (res) { ctrl.topics = res; }); } ctrl.query($location.search().page || 1) ctrl.destroy = function (topic) { if (confirm(" ?")) Topic.destroy({id: topic.id}) } }) action('new', function () { Theme.new(function (res) { ctrl.theme = res; ctrl.theme.group_id = $location.search().group_id; }); ctrl.save = Theme.create; }) action('edit', function (params) { ctrl.theme = Theme.edit(params); ctrl.save = Theme.update; }) }]) 


javascripts/controllers/topics_ctrl.js
 app.controller('TopicsCtrl', ['$scope', '$location', 'Topic', 'action', 'Post', 'Theme', function ($scope, $location, Topic, action, Post, Theme) { var ctrl = this; action('show', function (params) { var filter = { topic_id: params.id } ctrl.post = { topic_id: params.id } ctrl.topic = Topic.get(params); ctrl.query = function (page, callback) { Post.get({filter: filter, page: page}, function (res) { ctrl.posts = res; if (callback) callback(); }); } ctrl.query(1) ctrl.send = function () { Post.create({post: ctrl.post}, function (res) { ctrl.post = { topic_id: params.id } ctrl.query(Math.ceil(ctrl.posts.total_count/10)) }) } }) action('new', function () { var theme_id = $location.search().theme_id; ctrl.theme = Theme.get({id: theme_id}); ctrl.topic = Topic.new({topic: {theme_id: theme_id}}); ctrl.save = Topic.create; }) action('edit', function (params) { ctrl.topic = Topic.edit(params, function (res) { ctrl.theme = Theme.get({id: res.theme_id}); }); ctrl.save = Topic.update; }) }]) 


javascripts/controllers/users_ctrl.js
 app.controller('UsersCtrl', ['$scope', 'User', 'action', function ($scope, User, action) { var ctrl = this; action('index', function () { ctrl.query = function (page) { User.get({page: page}, function (res) { ctrl.users = res; }); } ctrl.query(1) }) action('show', function (params) { ctrl.user = User.get(params); }) ctrl.ban = function (user) { User.ban({id: user.id}) user.banned = true; } ctrl.unban = function (user) { User.unban({id: user.id}) user.banned = false; } }]) 


javascripts/controllers/search_ctrl.js
 app.controller('SearchCtrl', ['$scope', '$location', '$http', function ($scope, $location, $http) { var ctrl = this; ctrl.query = function (page) { var params = { page: page || 1 } if (ctrl.q) { params.q = ctrl.q } $http.get(Routes.search_path(params)).then(function (res) { ctrl.posts = res.data; }) } $scope.$watch(function () { return $location.search().q }, function (q) { ctrl.q = q; ctrl.query() }) }]) 


javascripts/controllers/sign_ctrl.js
 app.controller('SignCtrl', ['$scope', '$http', '$interval', 'User', function ($scope, $http, $interval, User) { var ctrl = this; ctrl.title = { in: "", up: "" } ctrl.sign = { in: function () { $http.post(Routes.user_session_path(), {user: ctrl.user}) .success(function (res) { gon.current_user = res.current_user; }) }, out: function () { $http.delete(Routes.destroy_user_session_path()) .success(function () { gon.current_user = undefined; }) }, up: function () { $http.post(Routes.user_registration_path(), {user: ctrl.user}) .success(function (res) { gon.current_user = res.current_user; }) } } $scope.$watch(function () { return gon.current_user }, function (current_user) { if (current_user) { ctrl.method = 'user'; ctrl.title.user = current_user.name; } else { ctrl.method = 'in'; } }) $interval(function () { User.metrics(function (res) { angular.extend(gon.current_user, res); }) }, 10000) }]) 



, . .

app/views/components render.html.slim :

 - Dir[File.dirname(__FILE__) + '/_*.html*'].each do |partial| script type="text/ng-template" id="#{File.basename(partial).gsub('.slim', '').gsub(/^_/, '')}" = render file: partial 


, components AngularJS. . .

 = render template: "components/render" 

application/layout.html.slim
 html ng-app="app" head title  base href="/" = stylesheet_link_tag 'application' body ng-controller="MainCtrl as main" ng-class="gon.current_user.role" .layout.body .search input.form-control placeholder="" type="text" ng-model="main.search" ng-model-options="{debounce: 300}" .bredcrumbs ng-yield="bredcrumbs" .wrapper .content ui-view .sidebar = render "layouts/sidebar" = render template: "components/render" = Gon::Base.render_data script src="https://www.google.com/recaptcha/api.js?onload=vcRecaptchaApiLoaded&render=explicit" async="" defer="" = javascript_include_tag 'application' 


post:

javascripts/directives/post_directive.js
 app.directive('post', ['Post', function(Post){ return { scope: { post: "=" }, restrict: 'E', templateUrl: 'post.html', replace: true, link: function($scope, iElm, iAttrs, controller) { $scope.gon = gon; $scope.destroy = function () { if (confirm(" ?")) Post.destroy({id: $scope.post.id}) } } }; }]); 


components/_post.html.slim
 .post.clearfix .post__user .middle-ib a.post__user-avatar ui-sref="user_path(post.user)" img ng-src="{{post.user.avatar_url}}" width="75" .middle-ib .post__user-name a.link.text-red ui-sref="user_path(post.user)" ng-bind="post.user.name" rating user="post.user" .post__user-role .text-gray ng-bind="post.user.role" .post__user-metrics.text-gray .post__user-metric span.bold : |  span ng-bind="post.user.posts_count" .post__user-metric span.bold   : |  span ng-bind="post.user.created_at | date:'dd.MM.yyyy'" .post__content a.post__title ng-bind="post.title" ui-sref="topic_path(post.topic)" div ng-bind="post.content" .post__actions.only-moderator a.btn.btn-danger.btn-sm ng-click="destroy()"  



_post.html.slim post.
rating:

javascripts/directives/rating_directive.js
 app.directive('rating', ['User', function (User) { return { scope: { user: "=" }, restrict: 'E', templateUrl: 'rating.html', replace: true, link: function($scope, iElm, iAttrs, controller) { $scope.rate = function (positive) { User.rate({id: $scope.user.id, positive: positive}, function (res) { $scope.user.rating = res.rating; }) } } }; }]); 


components/_rating.html.slim
 span.rating span.text-red.rating__control ng-click="rate()" | ▼ span.rating__count ng-bind="user.rating" span.text-green.rating__control ng-click="rate(true)" | ▲ 



, . DRY .

online.js, 5 :

javascripts/online.js
 app.run(['$interval', 'User', function ($interval, User) { User.touch(); $interval(function () { User.touch(); }, 5*60*1000) }]) 



reCAPTCHA. . Angular Recaptcha , . :

 div ng-model="ctrl.user.recaptcha" vc-recaptcha="" key="'#{Rails.application.secrets[:recaptcha]["public_key"]}'" 


. ExampleSerializer :

javascripts/serializers/post_serializer.js
 function PostSerializer (collection) { var result = []; _.each(collection, function (item) { result.push({ id: item[0], title: item[1], content: item[2], user: { id: item[3], created_at: item[4], name: item[5], rating: item[6], posts_count: item[7], avatar_url: item[8] || "/default_avatar.png" }, topic: { id: item[9], title: item[10] } }) }) return result } 


javascripts/serializers/theme_serializer.js
 function ThemeSerializer (collection) { var result = []; _.each(collection, function (item) { result.push({ id: item[0], title: item[1] }) }) return result } 


javascripts/serializers/topic_serializer.js
 function TopicSerializer (collection) { var result = []; _.each(collection, function (item) { result.push({ id: item[0], title: item[1], last_post: item[2], posts_count: item[3], user: { id: item[4], name: item[5] }, theme: { id: item[6], title: item[7] } }) }) return result } 


javascripts/serializers/user_serializer.js
 function UserSerializer (collection) { var result = []; _.each(collection, function (item) { result.push({ id: item[0], created_at: item[1], updated_at: item[2], name: item[3], avatar_url: item[4], posts_count: item[5], rating: item[6], banned: item[7] }) }) return result } 


javascripts/serializers/group_serializer.js
 function GroupSerializer (collection) { var result = [], groups = _.groupBy(collection, function (el) { return el[0] }); _.each(groups, function (group) { result.push({ id: group[0][0], title: group[0][1], themes: _.map(group, function (item) { return { id: item[2], title: item[3], posts_count: item[4], topics_count: item[5], last_post: item[6] } }) }) }) return result } 




, . Gon. production-:

Spoiler header
 = cache_if Rails.env.production?, $cache_key html ng-app="app" head title  base href="/" = stylesheet_link_tag 'application' body ng-controller="MainCtrl as main" ng-class="gon.current_user.role" .layout.body .search input.form-control placeholder="" type="text" ng-model="main.search" ng-model-options="{debounce: 300}" .bredcrumbs ng-yield="bredcrumbs" .wrapper .content ui-view .sidebar = render "layouts/sidebar" = render template: "components/render" script src="https://www.google.com/recaptcha/api.js?onload=vcRecaptchaApiLoaded&render=explicit" async="" defer="" = javascript_include_tag 'application' = Gon::Base.render_data 



, / $layout_cache, cache_key:

config/initializers/layout_cache.rb
 $layout_cache = "layout_#{Time.now.to_i}" 



Total


Single Page Application , , API. . .


Source: https://habr.com/ru/post/300954/


All Articles