How to make state consistency while using Action Cable - ruby-on-rails

Let's assume we use something like current_user which is an instance of a ServiceClass which holds user model, session params and other info.
The thing is that variable is being set during connection with websocket and being reused for all AC calls on different subscriptions.
Then, at some point user decides to update his username, we make a call to current_user.update(new_username) and it works okay.
But other AC subscriptions under that user still use old user model. I suppose as each subscription works under their own thread, thus updating user model under one thread will not update them under other threads. What is the best approach for such case?
class ServiceClass
def initialize(session,...)
#session = session
#user = current_user
end
def update!(username)
#user.username = username
#user.save!
end
...
end
module ApplicationCable
class Channel < ActionCable::Channel::Base
def current_user
#current_user ||= ServiceClass.new(session, user)
end
end
end

What you want to do is to broadcast to current_user and for current_user to be subscribed to his / her stream.
In your controller:
ActionCable.server.broadcast "#{current_user}"
In your channel (eg. app/channels/user_channel.rb)
class UserChannel < ApplicationCable::Channel
def subscribed
stream_from "#{current_user}"
end
end

Related

How to access current user in a mailer file before action

I have a before action in a user mailer file, which is supposed to stop mailers sending if a column on user is set to true or false. However current user is currently unavailable. I understand why, but was wondering if there was a way to do this.
I want to avoid adding the check_if_users_can_receive_mailers at the top of each mailer method.
before_action :check_if_users_can_receive_mailers
#methods that send mailers
private
def check_if_users_can_receive_mailers
current_user.send_mailers?
end
You have to make the current user available as a attribute or class variable. The most straight forward method is something like this:
class MailerBase < ActionMailer::Base
before_action :check_if_users_can_receive_mailers
attr_accessor :user
def initialize(user)
#user = user
end
private
def check_if_users_can_receive_mailers
user.send_mailers?
end
end
class SomeMailerClass < MailerBase
end
In Rails only your controller and views are request aware. Mailers and models and other classes in your application are not and they cannot get the current user since they can't access the session nor the method current_user which is a helper method mixed into your controller (and the view context).
If your mailers need to know about the current user the most logical approach is to pass that information into the mailer:
class UserMailer < ApplicationMailer
def intialize(user)
#user = user
end
end
However a mailer should only have one job - to send emails and it shouldn't be questioning if it should do the job or not. Determining if you should send an email to the user should be done elsewhere. You can place this logic in the controller or even better in a service object:
# app/notifiers/user_notifier.rb
class UserNotifier
def initialize(user, event:)
#user = user
#event = event
end
def notify
if #user.wants_email?
spam_user!
end
send_in_app_notification
end
def self.notify(user, event:)
new(user, event:)
end
private
def spam_user!
# ...
end
def send_in_app_notification
# ...
end
end
class ThingsController
def create
#thing = Thing.new
if #thing.save
UserNotifier.notify(current_user, event: :thing_created)
redirect_to #thing
else
render :new
end
end
end

Rails Action Cable: How can I access instance variables within ApplicationCable::Channel Class?

I would like to create chatrooms per product page so that uses can chat about the product while they are isolated from other products' discussions.
For this purpose; I was planning to use #product instance varialbe while defining the subscriptions however it seems; instance variables are not accessible within Action Cable
"app/channels/product_channel.rb"
class ProductChannel < ApplicationCable::Channel
def subscribed
stream_from "room_channel_product_#{#product.id}"
end
def unsubscribed
end
end
How can I access instance variables within channel module??
I think you can't access the instance variable while defining the subscriptions. But you can pass product_id as a parameter, then you subscribe to the ProductChannel.
https://guides.rubyonrails.org/action_cable_overview.html#subscriber
App.cable.subscriptions.create { channel: "ProductChannel", product_id: your_product_id }
And on your channel, you can access to "product_id" like:
def subscribed
stream_from "product_channel_#{params[:product_id]}"
end
Declare the variable in your subscribed method. Think of the subscribed method as an initializer in the context of your channel.
An example exists within the ActionCable codebase itself. In your case, this can be achieved like so
class ProductChannel < ApplicationCable::Channel
def subscribed
#product = Product.find(params[:product_id])
stream_from "room_channel_product_#{#product.id}"
end
def unsubscribed
end
def foo
#product.do_stuff
end
end

Rails 5 set current_user from external API

I use temporarily Rails as frontend app to communicate with an API.
After the authentication, I set the user_id in a cookie.
I use the her gem to call the User from the API and save it into an instance variable.
The issue is that I do this request on every page I and would like to do it once.
It's like #current_user is reset after each page.
def current_user
#User.find -> Her model
#current_user ||= User.find(cookies.signed[:user_id]) if cookies.signed[:user_id]
end
There no clear solution because your user coming from API. You can try to do something like that:
#remember user attributes without references
session['user'] = #current_user.attributes #remember
#user = OpenStruct(session['user']) #load, allow call #user.name etc, but not #user.posts
#use class variable
class User
include Her::Model
##tmp = {}
def remember
##tmp[id] = self
#call job etc to delete user from tmp to prevent something that reminds "memory leak"
end
def self.local_find(id)
##tmp[id]
end
end
def current_user
#current_user ||= User.local_find(cookies.signed[:user_id]) ||
User.find(cookies.signed[:user_id]) if cookies.signed[:user_id]
end
The main reason not to store(remember) objects in the session(long-term variable) is that if the object structure changes, you will get an exception.

Rails action cable specific consumer

I'm struggling with action cable. In my case I have couple of users (via Devise) who can share tasks with each other.
Now when user#1 share task (via Form) with user#2 all authenticated users receive notifications.
How and where should I identify my user#2 to broadcast only to him?
Here is my code so far:
connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
logger.add_tags 'ActionCable', current_user.id
end
protected
def find_verified_user # this checks whether a user is authenticated with devise
if verified_user = env['warden'].user
verified_user
else
reject_unauthorized_connection
end
end
end
end
cable.js
(function() {
this.App || (this.App = {});
App.cable = ActionCable.createConsumer();
}).call(this);
todo_channel.rb
class TodoChannel < ApplicationCable::Channel
def subscribed
stream_from "todo_channel_#{current_user.id}"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
def notify
ActionCable.server.broadcast "todo_channel_#{current_user.id}", message: 'some message'(not implemented yet)
end
end
todo.coffee
App.todo = App.cable.subscriptions.create "TodoChannel",
connected: ->
# Called when the subscription is ready for use on the server
disconnected: ->
# Called when the subscription has been terminated by the server
received: (data) ->
console.log(data['message'])
notify: ->
#perform 'notify'
i've faced something similar before until i realized that you can actually call stream_from multiple times in the channel and that user will be subscribed to multiple different "rooms" within the same channel connection. Which means you can basically do this
class TodoChannel < ApplicationCable::Channel
def subscribed
stream_from "todo_channel_all"
stream_from "todo_channel_#{current_user.id}"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
def notify(data)
# depending on data given from the user, send it to only one specific user or everyone listening to the "todo_channel_all"
if data['message']['other_user_id']
ActionCable.server.broadcast "todo_channel_#{data['message']['other_user_id']}", message: 'some message'
else
ActionCable.server.broadcast "todo_channel_all", message: 'some message'
end
end
end
that code assuming that the user already knows the other user's id and sent it to the channel, you would probably have to wrap that with some security or something, i admit i'm not very well experienced with rails as i'm still learning.
Something else that might be beneficial to you in the future is the fact that you can also broadcast several messages/times in the same channel function. That means you can potentially support sending out your tasks to a single specific user, a list of specific users or everyone. Just iterate on the list/array/whatever of users and broadcast the task/message/notification/whatever to them each on their personal "todo_channel_#{user.id}"
I ended up with a different approach. I'll write it here in case someone will find it helpful.
Notification has an id of a user that has to be notified. So in model I have:
after_commit :broadcast_notification, on: :create
def broadcast_notification
ActionCable.server.broadcast "todo_channel_#{self.user_id}", message: 'some message'
end
As I placed broadcasting into the model my todo_channel.rb looks like this:
class TodoChannel < ApplicationCable::Channel
def subscribed
stream_from "todo_channel_#{current_user.id}"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end
Step#1: Let each user have unique session token. While subscription, each user will send session token in headers and header is accessible in connection class. Find user by using session token.
Step#2: Stream user on user id.
Step#3: While sharing task, take user id too in the request and broadcast on given user id.
This is called "private chat". If you really want to get current_user.id you could do it in 3 ways:
Some AJAX onload call from todo.coffee to server.
Render current_user.id as an attribute in Rails HTML view and then get it via jQuery inside todo.coffee (as in https://www.sitepoint.com/create-a-chat-app-with-rails-5-actioncable-and-devise/ )
Create a plain cookie while a user logging in and then check it inside todo.coffee
But you shouldn't use current_user.id because it's not secure. If you use it then someone might register in the same site and easily listen to other users' private chats by simply providing a random user_id.
Instead use the signed (e.g. Rails-encrypted) cookie as a user's unique broadcast identifier. That way if you register in the same site you would never know some other user's signed cookie - so you can't break into an alien private chat.
app/config/initializers/warden_hooks.rb
See https://rubytutorial.io/actioncable-devise-authentication/
# Be sure to restart your server when you modify this file.
Warden::Manager.after_set_user do |user,auth,opts|
scope = opts[:scope]
auth.cookies.signed["#{scope}.id"] = user.id
end
Warden::Manager.before_logout do |user, auth, opts|
scope = opts[:scope]
auth.cookies.delete("#{scope}.id")
end
todo_channel.rb
class TodoChannel < ApplicationCable::Channel
def subscribed
stream_from "todo_channel_#{params['user_signed_cookie']}"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
def notify(param_message)
ActionCable.server.broadcast "todo_channel_#{param_message['user_signed_cookie']}", message: 'some message'(not implemented yet)
end
end
todo.coffee
user_signed_cookie = document.cookie.replace(/(?:(?:^|.*;\s*)user.id\s*\=\s*([^;]*).*$)|^.*$/, "$1");
user_logged_in = if user_signed_cookie then true else false
if user_logged_in #avoid repetitive HTTP->WebSockets upgrade pointless browser attempts when no user logged in.
App.todo = App.cable.subscriptions.create {
channel:"TodoChannel"
user_signed_cookie: user_signed_cookie
},
connected: ->
# Called when the subscription is ready for use on the server
disconnected: ->
# Called when the subscription has been terminated by the server
received: (data) ->
console.log(data['message'])
notify: (name, content, _) -> #name, content - message fields
#perform 'notify', name: name, content: content, user_signed_cookie: user_signed_cookie

Pundit: Ensure current_user is user from params

Apparently, you can't access the params hash in a Pundit policy. It makes sense that they want to expose as little information to the policies as possible. But one use case I'm running into, which I would think would be quite common, is to check that the current_user is the user from the params.
So here's my new action in my controller:
class ReviewsController < ApplicationController
...
def new
#user = User.friendly.find(params[:user_id])
unless current_user.admin? || current_user.id == #user.id
flash[:alert] = 'Access denied.'
redirect_to root_url
end
#review = #user.reviews.build
end
...
end
So here, I'm saying to authorize if the user is an admin, or the current user is the same as the one in the URL. Otherwise, the user with the id of 2 could go to /users/1/reviews/new.
There doesn't seem to be any way to handle this in the policy, because I can't pass the params[:user_id] into the policy.
Is there a way to handle this authorization scheme from a Pundit policy, rather than handling auth logic in my controller?
Not sure if this question is out of date.
When pundit does the authorization in the controller, it will pass two objects. One is record and another is current_user. But you only need to provide the record when you call the authorize method, current_user will be passed automatically.
#authorize(record, query = nil) ⇒ true
In your case, when you call authorize(#user, :new?), in your policy, #user will be referenced as record, and current_user will be referenced as user.
Therefore, in your policy:
class UserPolicy < ApplicationPolicy
def new?
user.admin? || record == user
end
end
And you can check the policy in your controller:
class ReviewsController < ApplicationController
...
def new
#user = User.friendly.find(params[:user_id])
authorize(#user, :new?)
#review = #user.reviews.build
end
...
end

Resources