diff --git a/.gitignore b/.gitignore index e941da68..90286207 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.DS_Store .bundle vendor/bundle -.DS_Store +*.swp +*.swo diff --git a/.rspec b/.rspec deleted file mode 100644 index 660778bd..00000000 --- a/.rspec +++ /dev/null @@ -1 +0,0 @@ ---colour --format documentation diff --git a/Gemfile b/Gemfile index 1f478929..53fdd760 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,9 @@ source 'https://rubygems.org' ruby '2.0.0' -gem 'rspec', '~> 2.14.1' +gem 'sinatra' +gem 'sinatra-contrib' gem 'pry-byebug' +gem 'thin' +gem 'pg' +gem 'rake' diff --git a/Gemfile.lock b/Gemfile.lock index a885a8c7..93d8cc90 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,15 +1,19 @@ GEM remote: https://rubygems.org/ specs: + backports (3.6.4) byebug (3.5.1) columnize (~> 0.8) debugger-linecache (~> 1.2) slop (~> 3.6) coderay (1.1.0) - columnize (0.8.9) + columnize (0.9.0) + daemons (1.1.9) debugger-linecache (1.2.0) - diff-lcs (1.2.5) + eventmachine (1.0.3) method_source (0.8.2) + multi_json (1.10.1) + pg (0.17.1) pry (0.10.1) coderay (~> 1.1.0) method_source (~> 0.8.1) @@ -17,19 +21,37 @@ GEM pry-byebug (2.0.0) byebug (~> 3.4) pry (~> 0.10) - rspec (2.14.1) - rspec-core (~> 2.14.0) - rspec-expectations (~> 2.14.0) - rspec-mocks (~> 2.14.0) - rspec-core (2.14.7) - rspec-expectations (2.14.5) - diff-lcs (>= 1.1.3, < 2.0) - rspec-mocks (2.14.5) + rack (1.5.2) + rack-protection (1.5.3) + rack + rack-test (0.6.2) + rack (>= 1.0) + rake (10.3.2) + sinatra (1.4.5) + rack (~> 1.4) + rack-protection (~> 1.4) + tilt (~> 1.3, >= 1.3.4) + sinatra-contrib (1.4.2) + backports (>= 2.0) + multi_json + rack-protection + rack-test + sinatra (~> 1.4.0) + tilt (~> 1.3) slop (3.6.0) + thin (1.6.3) + daemons (~> 1.0, >= 1.0.9) + eventmachine (~> 1.0) + rack (~> 1.0) + tilt (1.4.1) PLATFORMS ruby DEPENDENCIES + pg pry-byebug - rspec (~> 2.14.1) + rake + sinatra + sinatra-contrib + thin diff --git a/README.md b/README.md new file mode 100644 index 00000000..8e5038ac --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Chatitude + +A start on a chat server built using Sinatra and server sent events. The SSE necessitated the use of an evented web server so WEBrick was swapped out for Thin. + +## Notes + +The *client* closes connections after around 40 seconds and the only remedy I could find is to require the client to send a PING message periodically and have the server respond with a PONG message. The server response is what will actually keep the connection open. + +Chat users are identified by their ID which is stored in the session. Users have to sign up and have an account (username & password) to begin chatting. + +This line in the `get '/chat'` endpoint adds a method called `user` to the instance of Sinatra::Helpers::Stream so that they can be identified for keeping connections open and later for private messages. +``` +def out.user; @current_user; end +``` diff --git a/Rakefile b/Rakefile new file mode 100644 index 00000000..f82d6eb1 --- /dev/null +++ b/Rakefile @@ -0,0 +1,45 @@ +task :environment do + require './lib/chatitude' +end + +task :console => :environment do + require 'irb' + ARGV.clear + IRB.start +end + +namespace :db do + task :create do + `createdb chatitude` + puts 'Database \'chatitude\' created!' + end + + task :drop do + `dropdb chatitude` + puts 'Database \'chatitude\' dropped!' + end + + task :create_tables => :environment do + db = Chatitude.create_db_connection 'chatitude' + Chatitude.create_tables db + puts 'Created tables.' + end + + task :drop_tables => :environment do + db = Chatitude.create_db_connection 'chatitude' + Chatitude.drop_tables db + puts 'Dropped tables.' + end + + task :clear => :environment do + db = Chatitude.create_db_connection 'chatitude' + Chatitude.clear_tables db + puts 'Cleared tables.' + end + + task :seed => :environment do + db = Chatitude.create_db_connection 'chatitude' + Chatitude.seed_dummy_users db + puts 'Tables seeded.' + end +end diff --git a/config.ru b/config.ru new file mode 100644 index 00000000..ad41ffe7 --- /dev/null +++ b/config.ru @@ -0,0 +1,3 @@ +require './server' + +run Chatitude::Server diff --git a/lib/chatitude.rb b/lib/chatitude.rb new file mode 100644 index 00000000..7b5779d2 --- /dev/null +++ b/lib/chatitude.rb @@ -0,0 +1,44 @@ +require 'pg' +require_relative 'chatitude/repos/users_repo.rb' + +module Chatitude + def self.create_db_connection dbname + PG.connect(host: 'localhost', dbname: dbname) + end + + def self.clear db + db.exec <<-SQL + DELETE FROM users; + DELETE FROM sessions; + SQL + end + + def self.create_tables db + db.exec <<-SQL + CREATE TABLE IF NOT EXISTS users( + id SERIAL PRIMARY KEY, + username VARCHAR, + password VARCHAR + ); + CREATE TABLE IF NOT EXISTS sessions( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users (id), + token TEXT UNIQUE + ); + SQL + end + + def self.drop_tables db + db.exec <<-SQL + DROP TABLE IF EXISTS users; + DROP TABLE IF EXISTS sessions; + SQL + end + + def self.seed_dummy_users db + db.exec <<-SQL + INSERT INTO users (username, password) + VALUES ('nick','nick'), ('kate','kate'); + SQL + end +end diff --git a/lib/chatitude/repos/users_repo.rb b/lib/chatitude/repos/users_repo.rb new file mode 100644 index 00000000..8102ca0e --- /dev/null +++ b/lib/chatitude/repos/users_repo.rb @@ -0,0 +1,49 @@ +require 'securerandom' + +module Chatitude + class UsersRepo + def self.find db, user_id + sql = %q[SELECT * FROM users WHERE id = $1] + result = db.exec(sql, [user_id]) + result.first + end + + def self.find_by_name db, username + sql = %q[SELECT * FROM users WHERE username = $1] + result = db.exec(sql, [username]) + result.first + end + + def self.find_by_token db, token + sql = %q[ + SELECT + u.id + , u.username + , u.password + FROM sessions s + JOIN users u + ON s.user_id = u.id + WHERE s.token = $1 + ] + result = db.exec(sql, [token]) + result.first + end + + def self.save db, user_data + sql = %q[INSERT INTO users (username, password) VALUES ($1, $2) RETURNING *] + result = db.exec(sql, [user_data[:username], user_data[:password]]) + result.first + end + + def self.sign_in db, id + token = SecureRandom.hex(16) + sql = %q[INSERT INTO sessions (user_id, token) VALUES ($1, $2)] + result = db.exec(sql, [id, token]) + token + end + + def self.sign_out db, token + db.exec("DELETE FROM sessions WHERE token = $1", [token]) + end + end +end diff --git a/public/index.html b/public/index.html new file mode 100644 index 00000000..ab55abe5 --- /dev/null +++ b/public/index.html @@ -0,0 +1 @@ +

hi

diff --git a/server.rb b/server.rb new file mode 100644 index 00000000..3dc6ff8c --- /dev/null +++ b/server.rb @@ -0,0 +1,154 @@ +require 'sinatra' +require 'sinatra/reloader' +require 'json' +require 'pry-byebug' + +require './lib/chatitude' + +class Chatitude::Server < Sinatra::Application + + # use thin instead of webrick + configure do + enable :sessions + set server: 'thin' + end + + # helpers for connecting to db and prerparing json responses + helpers do + def db + Chatitude.create_db_connection 'chatitude' + end + + def timestamp + Time.now.to_i + end + + def respond sender, message + { + :sender => sender, + :message => message, + :timestamp => timestamp + }.to_json + end + + def parse_message input + msg_pieces = input.split + data = {sender: @current_user['username']} + if msg_pieces.first == '/pm' + data.merge({ + :recipient => msg_pieces[1], + :message => msg_pieces[2..-1].join(' ') + }) + else + data.merge({ + :recipient => :all, + :message => input + }) + end + end + end + + # run this before every endpoint to get the current user + before do + # this condition assign the current user if someone is logged in + if params[:apiToken] + @current_user = Chatitude::UsersRepo.find_by_token db, params[:apiToken] + end + + # the next few lines are to allow cross domain requests + headers["Access-Control-Allow-Origin"] = "*" + headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" + headers["Access-Control-Allow-Headers"] = "Origin, X-Requested-With, Content-Type, Accept" + end + + # Array used to store event streams + # connections = [] + + messages, message_count = [], 0 + + ############ MAIN ROUTES ############### + + get '/' do + send_file 'public/index.html' + end + + post '/signup' do + errors = [] + if !params[:password] || params[:password] == '' + errors << 'blank_password' + end + if !params[:username] || params[:username] == '' + errors << 'blank_username' + end + if Chatitude::UsersRepo.find_by_name(db, params[:username]) + errors << 'username_taken' + end + + if errors.count == 0 + user_data = {username: params[:username], password: params[:password]} + user = Chatitude::UsersRepo.save db, user_data + session[:user_id] = user['id'] + status 200 + '{}' + else + status 400 + { errors: errors }.to_json + end + end + + post '/signin' do + user = Chatitude::UsersRepo.find_by_name db, params[:username] + + if user && user['password'] == params[:password] + token = Chatitude::UsersRepo.sign_in db, user['id'] + { apiToken: token }.to_json + else + status 401 + end + end + + delete '/signout' do + Chatitude::UsersRepo.sign_out db, params[:apiToken] + status 200 + '{}' + end + + ########################################## + # event stream stuff. + + get '/chats' do + if params[:since] + messages.select { |m| params[:since].to_f < m[:time] } + else + messages.last 10 + end.to_json + end + + post '/chats' do + if @current_user + + msg = params[:message] + if msg.nil? || msg == '' + status 400 + return { errors: ["blank_message"] }.to_json + elsif messages.find {|m| m[:message] == msg } + status 400 + return { errors: ["message_already_exists"] }.to_json + end + + message_count += 1 + messages << { + user: @current_user['username'], + message: msg, + time: timestamp, + id: message_count + } + status 200 + '{}' + else + status 403 + { errors: "invalid_api_key" }.to_json + end + end + +end diff --git a/spec/.gitkeep b/spec/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/views/index.erb b/views/index.erb new file mode 100644 index 00000000..a950ce7e --- /dev/null +++ b/views/index.erb @@ -0,0 +1,64 @@ + + + + + + + + + + + +

+ + + + + + + + + + + +