├── config.ru ├── mails ├── invite.mote └── reset.mote ├── bin ├── mk ├── mt ├── gs └── dep ├── views ├── users │ └── index.mote ├── guests │ ├── index.mote │ ├── update.mote │ ├── signup.mote │ ├── reset.mote │ └── login.mote └── layout.mote ├── lib └── env.rb ├── env.example ├── models └── user.rb ├── .gems ├── test ├── helper.rb └── all.rb ├── filters ├── invite.rb └── signup.rb ├── routes ├── users.rb └── guests.rb ├── services ├── mailer.rb └── gatekeeper.rb ├── makefile ├── decks └── frontend.rb ├── LICENSE ├── public └── css │ └── index.css ├── app.rb ├── README.md └── doc └── help /config.ru: -------------------------------------------------------------------------------- 1 | require "./app" 2 | 3 | run(App) 4 | -------------------------------------------------------------------------------- /mails/invite.mote: -------------------------------------------------------------------------------- 1 | Visit {{ url }} to activate your account. 2 | -------------------------------------------------------------------------------- /mails/reset.mote: -------------------------------------------------------------------------------- 1 | Visit {{ url }} to reset your password. 2 | -------------------------------------------------------------------------------- /bin/mk: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -f .env ]; then 4 | env `cat .env` \ 5 | make $* 6 | else 7 | make $* 8 | fi 9 | -------------------------------------------------------------------------------- /views/users/index.mote: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Hello World

6 | 7 |

You are logged in as {{ user.email }}

8 | -------------------------------------------------------------------------------- /views/guests/index.mote: -------------------------------------------------------------------------------- 1 | 5 | 6 |

Hello World

7 | 8 |

Sample content

9 | -------------------------------------------------------------------------------- /lib/env.rb: -------------------------------------------------------------------------------- 1 | # Access an environment variable or raise a runtime error 2 | # if it can't be found. 3 | $env = ->(name) { 4 | ENV.fetch(name) do 5 | raise(sprintf("Missing ENV[\"%s\"]", name)) 6 | end 7 | } 8 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | RACK_SESSION_SECRET=? 2 | REDIS_URL=redis://localhost:6379 3 | REDIS_TEST_URL=redis://localhost:6380 4 | HOST=http://localhost:9393 5 | MALONE_URL=smtp://localhost:2525 6 | NOBI_SECRET=? 7 | NOBI_EXPIRE=7200 8 | -------------------------------------------------------------------------------- /models/user.rb: -------------------------------------------------------------------------------- 1 | class User < Ohm::Model 2 | include Shield::Model 3 | 4 | attribute :email 5 | attribute :crypted_password 6 | 7 | unique :email 8 | 9 | def self.fetch(email) 10 | with(:email, email) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /.gems: -------------------------------------------------------------------------------- 1 | shield -v 2.1.1 2 | mote -v 1.3.1 3 | ohm -v 3.1.1 4 | shotgun -v 0.9.2 5 | cutest -v 1.2.3 6 | hache -v 3.0.0 7 | scrivener -v 1.1.1 8 | malone -v 1.2.1 9 | rack-test -v 1.1.0 10 | nobi -v 0.0.1 11 | syro -v 3.2.1 12 | puma -v 5.4.0 13 | tas -v 0.0.1 14 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require_relative "../app" 2 | require "rack/test" 3 | require "malone/test" 4 | 5 | class Driver 6 | include Rack::Test::Methods 7 | 8 | def initialize(app) 9 | @app = app 10 | end 11 | 12 | def app 13 | @app 14 | end 15 | end 16 | 17 | -------------------------------------------------------------------------------- /filters/invite.rb: -------------------------------------------------------------------------------- 1 | class Invite < Scrivener 2 | attr_accessor :email 3 | 4 | def self.[](email) 5 | new(email: email) 6 | end 7 | 8 | def validate 9 | if assert_present :email 10 | assert User.fetch(email).nil?, [:email, :not_unique] 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /filters/signup.rb: -------------------------------------------------------------------------------- 1 | class Signup < Scrivener 2 | attr_accessor :email 3 | attr_accessor :password 4 | 5 | def validate 6 | if assert_present :email 7 | assert User.fetch(email).nil?, [:email, :not_unique] 8 | assert_present :password 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /routes/users.rb: -------------------------------------------------------------------------------- 1 | Users = Syro.new(Frontend) do 2 | page[:title] = "Welcome" 3 | 4 | @user = authenticated(User) 5 | 6 | on "logout" do 7 | get do 8 | logout(User) 9 | 10 | res.redirect "/" 11 | end 12 | end 13 | 14 | get do 15 | render("views/users/index.mote", user: @user) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /views/guests/update.mote: -------------------------------------------------------------------------------- 1 | 5 | 6 |

Update your password

7 | 8 | % if app.session[:alert] 9 |

10 | {{ app.session.delete(:alert) }} 11 |

12 | % end 13 | 14 |
15 | 19 | 20 | 21 |
22 | -------------------------------------------------------------------------------- /services/mailer.rb: -------------------------------------------------------------------------------- 1 | module Mailer 2 | MissingSubject = Class.new(ArgumentError) 3 | MissingText = Class.new(ArgumentError) 4 | 5 | def self.deliver(message) 6 | raise MissingSubject if message[:subject].nil? 7 | raise MissingText if message[:text].nil? 8 | 9 | defaults = { 10 | to: "info@example.com", 11 | bcc: "info@example.com", 12 | from: "info@example.com" 13 | } 14 | 15 | Malone.deliver(defaults.merge(message)) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /views/guests/signup.mote: -------------------------------------------------------------------------------- 1 | 5 | 6 |

Signup

7 | 8 | % if app.session[:alert] 9 |

10 | {{ app.session.delete(:alert) }} 11 |

12 | % end 13 | 14 |
15 | 20 | 21 | 22 |
23 | -------------------------------------------------------------------------------- /views/guests/reset.mote: -------------------------------------------------------------------------------- 1 | 5 | 6 |

Login

7 | 8 | % if app.session[:alert] 9 |

10 | {{ app.session.delete(:alert) }} 11 |

12 | % end 13 | 14 |
15 | 20 | 21 | 22 |
23 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | GS=./bin/gs 2 | DEP=./bin/dep 3 | 4 | .PHONY: test 5 | 6 | default: help 7 | 8 | test: 9 | @$(GS) cutest -r ./test/helper.rb ./test/*.rb 10 | 11 | server: 12 | @$(GS) shotgun -o 0.0.0.0 13 | 14 | console: 15 | @$(GS) irb -r ./app 16 | 17 | gems: 18 | @$(GS) gem list 19 | 20 | smtp: 21 | ./bin/mt 2525 22 | 23 | check: .gs .env 24 | @$(GS) $(DEP) 25 | 26 | install: .gs .env 27 | @$(GS) $(DEP) install 28 | 29 | .gs: 30 | @mkdir -p .gs 31 | 32 | .env: 33 | @cp env.example .env 34 | 35 | help: 36 | @less ./doc/help 37 | -------------------------------------------------------------------------------- /decks/frontend.rb: -------------------------------------------------------------------------------- 1 | class Frontend < Syro::Deck 2 | include Shield::Helpers 3 | include Mote::Helpers 4 | 5 | def session 6 | req.session 7 | end 8 | 9 | def view 10 | @view ||= Tas.new do |params| 11 | mote(params[:src], params) 12 | end 13 | end 14 | 15 | def page 16 | @page ||= view.new.tap do |page| 17 | page[:src] = "views/layout.mote" 18 | page[:content] = view.new 19 | page[:content][:app] = self 20 | end 21 | end 22 | 23 | def render(path, params = {}) 24 | page[:content][:src] = path 25 | page[:content].update(params) 26 | 27 | res.html(page) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /views/guests/login.mote: -------------------------------------------------------------------------------- 1 | 5 | 6 |

Login

7 | 8 | % if app.session[:alert] 9 |

10 | {{ app.session.delete(:alert) }} 11 |

12 | % end 13 | 14 |
15 | 20 | 21 | 25 | 26 | 27 | 28 |
29 | 30 |

31 | Forgot password? 32 |

33 | -------------------------------------------------------------------------------- /views/layout.mote: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ title }} 5 | 6 | 7 | 11 | 12 | 13 |
14 | Demo 15 |
16 |
17 | {{ content }} 18 |
19 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /services/gatekeeper.rb: -------------------------------------------------------------------------------- 1 | module Gatekeeper 2 | extend Mote::Helpers 3 | 4 | def self.invite(email) 5 | mail = { 6 | to: email, 7 | subject: "Activate your account", 8 | text: mote("mails/invite.mote", url: url(:activate, email)), 9 | } 10 | 11 | Mailer.deliver(mail) 12 | end 13 | 14 | def self.reset(user) 15 | mail = { 16 | to: user.email, 17 | subject: "Password reset", 18 | text: mote("mails/reset.mote", url: url(:reset, user.id)), 19 | } 20 | 21 | Mailer.deliver(mail) 22 | end 23 | 24 | def self.url(action, str) 25 | sprintf("%s/%s/%s", $env["HOST"], action, encode(str)) 26 | end 27 | 28 | def self.signer 29 | Nobi::TimestampSigner.new($env["NOBI_SECRET"]) 30 | end 31 | 32 | def self.encode(str) 33 | signer.sign(str) 34 | end 35 | 36 | def self.decode(str, ttl = Float($env["NOBI_EXPIRE"])) 37 | signer.unsign(str, max_age: ttl) 38 | rescue Nobi::BadData 39 | nil 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Michel Martens 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /public/css/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Inconsolata", monospace; 3 | color: #111; 4 | background-color: #faffff; 5 | } 6 | 7 | header { 8 | position: fixed; 9 | top: 100px; 10 | left: 50px; 11 | } 12 | 13 | header a { 14 | font-size: 2em; 15 | text-decoration: none; 16 | color: #fff; 17 | background-color: #111; 18 | padding: 0.2em 0.4em; 19 | } 20 | 21 | section { 22 | display: block; 23 | margin-top: 100px; 24 | margin-left: 220px; 25 | width: 500px; 26 | } 27 | 28 | section h1 { 29 | font-weight: normal; 30 | font-size: 1.6em; 31 | margin-bottom: 1em; 32 | } 33 | 34 | section h2 { 35 | font-weight: normal; 36 | font-size: 1.2em; 37 | margin: 2em 0 1em 0; 38 | } 39 | 40 | section h2 a { 41 | text-decoration: none; 42 | color: #08109F; 43 | } 44 | 45 | section p { 46 | line-height: 1.5em; 47 | font-weight: 400; 48 | } 49 | 50 | section p a { 51 | color: #c55; 52 | } 53 | 54 | input, button { 55 | display: block; 56 | margin: 2em 0; 57 | font-size: 1em; 58 | } 59 | 60 | nav a { 61 | color: #c55; 62 | } 63 | 64 | footer { 65 | margin: 50px 0 50px 220px; 66 | width: 500px; 67 | border-top: 1px dotted black; 68 | font-size: 80%; 69 | } 70 | 71 | footer a { 72 | text-decoration: none; 73 | color: #c00; 74 | } 75 | -------------------------------------------------------------------------------- /app.rb: -------------------------------------------------------------------------------- 1 | require "syro" 2 | require "mote" 3 | require "ohm" 4 | require "shield" 5 | require "hache" 6 | require "scrivener" 7 | require "nobi" 8 | require "malone" 9 | require "tas" 10 | 11 | # Workaround a bug in Rack 2.2.2 12 | require "delegate" 13 | 14 | # Path to project components 15 | GLOB = "./{lib,decks,routes,models,filters,services}/*.rb" 16 | 17 | # Load components 18 | Dir[GLOB].each { |file| require file } 19 | 20 | # Connect to SMTP server 21 | Malone.connect(url: $env["MALONE_URL"], tls: false, domain: "example.com") 22 | 23 | # Connect to Redis 24 | Ohm.redis = Redic.new($env["REDIS_URL"]) 25 | 26 | # Main Syro application. It uses the `Frontend` deck, and you can 27 | # find it in `./decks/frontend.rb`. Refer to the Syro tutorial for 28 | # more information about Decks and other customizations. 29 | Web = Syro.new(Frontend) do 30 | 31 | # The authenticated helper is included by Shield, and in this case 32 | # it returns an instance of User (if one is authenticated), or nil 33 | # otherwise. Depending on the result, we run the routes for Users 34 | # or Guests. Those routes are defined in `./routes/users.rb` and 35 | # `./routes/guests.rb`. 36 | authenticated(User) ? 37 | run(Users) : 38 | run(Guests) 39 | end 40 | 41 | # Rack application 42 | App = Rack::Builder.new do 43 | use Rack::MethodOverride 44 | use Rack::Session::Cookie, secret: $env["RACK_SESSION_SECRET"] 45 | use Rack::Static, urls: %w[/css /fonts /img], root: "./public" 46 | 47 | run(Web) 48 | end 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Syro Demo 2 | ========= 3 | 4 | This is a demo application that showcases some very basic functionality 5 | like rendering templates, sending emails, and managing user accounts. 6 | 7 | Getting started 8 | --------------- 9 | 10 | I recommend having this command that runs make with a custom set 11 | of environment variables. I put it in `~/bin/mk`, and I use it 12 | instead of running `make` directly. 13 | 14 | ``` 15 | #!/bin/sh 16 | 17 | if [ -f .env ]; then 18 | env `cat .env` \ 19 | make $* 20 | else 21 | make $* 22 | fi 23 | ``` 24 | 25 | Once you have the `mk` command, run `mk` or `mk help` to setup the 26 | project. For your convenience, the `mk` command is installed inside 27 | `./bin`, so you can just run `./bin/mk` and it will work. 28 | 29 | Directory layout 30 | ---------------- 31 | 32 | ``` 33 | .env # Environment variables 34 | .gems # Dependencies 35 | LICENSE # Full text of the project's license 36 | README.md # Information about the project 37 | app.rb # Top level Syro application 38 | bin/ # Executable files 39 | config.ru # Rack's entry point, it loads ./app.rb 40 | decks/ # Custom decks 41 | doc/ # Documentation 42 | filters/ # Validation filters 43 | lib/ # Libraries 44 | mails/ # Templates for emails 45 | makefile # make server; make console; make tests 46 | models/ # Models 47 | public/ # Static files 48 | routes/ # Syro apps that will be mounted 49 | services/ # Service objects 50 | test/ # Test files 51 | views/ # Templates for views 52 | ``` 53 | 54 | More information 55 | ---------------- 56 | 57 | To learn more about Syro, visit the [website][syro] and check the 58 | [tutorial][tutorial]. 59 | 60 | [syro]: http://soveran.github.io/syro/ 61 | [tutorial]: http://files.soveran.com/syro/ 62 | -------------------------------------------------------------------------------- /doc/help: -------------------------------------------------------------------------------- 1 | usage: mk [install|check|gems|server|test|console|smtp|help] 2 | 3 | ## Commands 4 | 5 | install: 6 | Initialize your project. It creates the `.gs` directory, 7 | where the dependencies will be installed, and also the 8 | `.env` file, where environment variables will be defined. 9 | 10 | check: 11 | Check if all the dependencies are installed. 12 | 13 | gems: 14 | List installed gems. 15 | 16 | server: 17 | Run the application on localhost, port 9393. 18 | 19 | test: 20 | Run tests. 21 | 22 | console: 23 | Start an interactive Ruby session with the application 24 | available for direct access. 25 | 26 | smtp: 27 | Start a fake SMTP server that displays every message received. 28 | 29 | help: 30 | Display this help text. 31 | 32 | ## Examples 33 | 34 | Initialize the project: 35 | 36 | $ mk install 37 | 38 | Make sure Redis is running on port 6380, then run the tests: 39 | 40 | $ mk test 41 | 42 | The application in development mode requires Redis to be running 43 | on port 6379. You can change that value by editing the .env file. 44 | 45 | It also requires a valid SMTP server. If you want to run a fake 46 | local SMTP server, open a new terminal window and run: 47 | 48 | mk smtp 49 | 50 | Interact with your application via IRB: 51 | 52 | $ mk console 53 | 54 | Once in IRB, you have access to your application. This is an 55 | example of an interactive session: 56 | 57 | >> Web.call("PATH_INFO" => "/", "REQUEST_METHOD" => "GET") 58 | => [200, {"Content-Length"=>"201", "Content-Type"=>"text/html"}, ...] 59 | 60 | >> User.all.to_a 61 | >> [] 62 | 63 | Finally, you can start the server: 64 | 65 | $ mk server 66 | 67 | You can visit `localhost:9393` to access your application. It will 68 | try to send emails using the configuration defined in `.env` under 69 | the `MALONE_URL` environment variable. While testing, you don't 70 | need to worry about the emails sent, but in development it is useful 71 | to see those messages. 72 | -------------------------------------------------------------------------------- /bin/mt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # Copyright (c) 2013 Michel Martens 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | # 23 | # https://github.com/soveran/mt 24 | 25 | require 'socket' 26 | 27 | port = ARGV.fetch(0, 25) 28 | 29 | begin 30 | server = TCPServer.open(port) 31 | puts "Listening on port #{port}" 32 | rescue Errno::EACCES 33 | puts "Couldn't bind to port #{port}." 34 | exit 1 35 | end 36 | 37 | Signal.trap("INT") { exit 0 } 38 | 39 | loop do 40 | client = server.accept 41 | client.puts "220 OK" 42 | 43 | str = client.gets 44 | 45 | while str != "DATA\r\n" 46 | client.puts "250 OK" 47 | str = client.gets 48 | end 49 | 50 | client.puts "354 OK" 51 | 52 | res = [] 53 | str = client.gets 54 | 55 | while str != ".\r\n" 56 | res.push(str) 57 | str = client.gets 58 | end 59 | 60 | puts '---' 61 | puts res.join 62 | 63 | client.puts "250 OK" 64 | client.gets 65 | client.puts "221 OK" 66 | end 67 | -------------------------------------------------------------------------------- /bin/gs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # Copyright (c) 2012 Michel Martens 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | # 23 | # https://github.com/soveran/gs 24 | 25 | help = < 0 66 | exec env, *ARGV 67 | else 68 | exec env, ENV["SHELL"] || ENV["COMSPEC"] 69 | end 70 | else 71 | puts "Directory .gs not found. Try `gs help`." 72 | exit 1 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /routes/guests.rb: -------------------------------------------------------------------------------- 1 | Guests = Syro.new(Frontend) do 2 | page[:title] = "Welcome" 3 | 4 | get do 5 | render("views/guests/index.mote") 6 | end 7 | 8 | on "login" do 9 | page[:title] = "Login" 10 | 11 | get do 12 | render("views/guests/login.mote") 13 | end 14 | 15 | post do 16 | on login(User, req[:email], req[:password]) != nil do 17 | remember 18 | 19 | res.redirect "/" 20 | end 21 | 22 | session[:alert] = "Invalid login" 23 | 24 | render("views/guests/login.mote") 25 | end 26 | end 27 | 28 | on "signup" do 29 | @invite = Invite.new(req[:invite] || {}) 30 | 31 | page[:title] = "Signup" 32 | 33 | get do 34 | render("views/guests/signup.mote", invite: @invite) 35 | end 36 | 37 | post do 38 | on @invite.valid? do 39 | Gatekeeper.invite(@invite.email) 40 | 41 | session[:alert] = "Check your email" 42 | 43 | res.redirect "/login" 44 | end 45 | 46 | default do 47 | session[:alert] = "Invalid signup" 48 | 49 | render("views/guests/signup.mote", invite: @invite) 50 | end 51 | end 52 | end 53 | 54 | on "activate" do 55 | on :token do 56 | @invite = Invite[Gatekeeper.decode(inbox[:token])] 57 | 58 | on @invite.valid? do 59 | get do 60 | render("views/guests/update.mote") 61 | end 62 | 63 | post do 64 | on req[:password] != nil do 65 | @signup = Signup.new(email: @invite.email, password: req[:password]) 66 | 67 | on @signup.valid? do 68 | @user = User.create(@signup.attributes) 69 | 70 | authenticate(@user) 71 | 72 | session[:alert] = "Password updated" 73 | 74 | res.redirect "/" 75 | end 76 | 77 | default do 78 | res.write "invalid" 79 | res.write @signup.errors 80 | end 81 | end 82 | 83 | default do 84 | session[:alert] = "Invalid password" 85 | 86 | render("views/guests/update.mote") 87 | end 88 | end 89 | end 90 | 91 | default do 92 | session[:alert] = "Invalid or expired URL" 93 | 94 | res.redirect "/reset" 95 | end 96 | end 97 | end 98 | 99 | on "reset" do 100 | get do 101 | render("views/guests/reset.mote") 102 | end 103 | 104 | post do 105 | @user = User.fetch(req[:email]) 106 | 107 | on @user != nil do 108 | Gatekeeper.reset(@user) 109 | 110 | session[:alert] = "Check your email" 111 | 112 | res.redirect "/login" 113 | end 114 | 115 | default do 116 | session[:alert] = "Invalid email" 117 | 118 | render("views/guests/reset.mote") 119 | end 120 | end 121 | 122 | on :token do 123 | @user = User[Gatekeeper.decode(inbox[:token])] 124 | 125 | on @user != nil do 126 | get do 127 | render("views/guests/update.mote") 128 | end 129 | 130 | post do 131 | on req[:password] != nil do 132 | @user.update(password: req[:password]) 133 | 134 | authenticate(@user) 135 | 136 | session[:alert] = "Password updated" 137 | 138 | res.redirect "/" 139 | end 140 | 141 | default do 142 | session[:alert] = "Invalid password" 143 | 144 | render("views/guests/update.mote") 145 | end 146 | end 147 | end 148 | 149 | default do 150 | session[:alert] = "Invalid or expired URL" 151 | 152 | res.redirect "/reset" 153 | end 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /test/all.rb: -------------------------------------------------------------------------------- 1 | prepare do 2 | Ohm.redis = Redic.new($env["REDIS_TEST_URL"]) 3 | Ohm.redis.call("FLUSHDB") 4 | end 5 | 6 | setup do 7 | Driver.new(App) 8 | end 9 | 10 | test do |driver| 11 | 12 | # Homepage features 13 | 14 | driver.get("/") 15 | 16 | assert_equal 200, driver.last_response.status 17 | 18 | expected = %Q(Welcome) 19 | 20 | assert driver.last_response.body[expected] 21 | 22 | expected = %Q(Login) 23 | 24 | assert driver.last_response.body[expected] 25 | 26 | expected = %Q(Signup) 27 | 28 | assert driver.last_response.body[expected] 29 | 30 | # Signup process 31 | 32 | driver.get("/signup") 33 | 34 | assert_equal 200, driver.last_response.status 35 | 36 | expected = %Q(Signup) 37 | 38 | assert driver.last_response.body[expected] 39 | 40 | expected = %Q(
) 41 | 42 | assert driver.last_response.body[expected] 43 | 44 | driver.post("/signup") 45 | 46 | assert_equal 200, driver.last_response.status 47 | 48 | expected = %Q(Invalid signup) 49 | 50 | assert driver.last_response.body[expected] 51 | 52 | invite = { 53 | "email" => "foo@example.com", 54 | } 55 | 56 | driver.post("/signup", "invite" => invite) 57 | 58 | assert_equal 302, driver.last_response.status 59 | 60 | driver.follow_redirect! 61 | 62 | expected = %Q(Check your email) 63 | 64 | assert driver.last_response.body[expected] 65 | 66 | regex = /\/activate\/\S+/ 67 | 68 | url = Malone.deliveries.last.text[regex] 69 | 70 | assert url != nil 71 | 72 | driver.get(url) 73 | 74 | assert_equal 200, driver.last_response.status 75 | 76 | expected = %Q(

Update your password

) 77 | 78 | assert driver.last_response.body[expected] 79 | 80 | expected = %Q() 81 | 82 | assert driver.last_response.body[expected] 83 | 84 | user = { 85 | "password" => "bar", 86 | } 87 | 88 | driver.post(url, user) 89 | 90 | assert_equal 302, driver.last_response.status 91 | 92 | driver.follow_redirect! 93 | 94 | assert_equal 200, driver.last_response.status 95 | 96 | expected = %Q(Logout) 97 | 98 | driver.get("/logout") 99 | 100 | assert_equal 302, driver.last_response.status 101 | 102 | driver.follow_redirect! 103 | 104 | expected = %Q(Welcome) 105 | 106 | assert driver.last_response.body[expected] 107 | 108 | # Login process 109 | 110 | driver.get("/login") 111 | 112 | assert_equal 200, driver.last_response.status 113 | 114 | expected = %Q(Login) 115 | 116 | assert driver.last_response.body[expected] 117 | 118 | expected = %Q() 119 | 120 | assert driver.last_response.body[expected] 121 | 122 | driver.post("/login") 123 | 124 | assert_equal 200, driver.last_response.status 125 | 126 | expected = %Q(Invalid login) 127 | 128 | assert driver.last_response.body[expected] 129 | 130 | user = { 131 | "email" => "foo@example.com", 132 | "password" => "bar", 133 | } 134 | 135 | driver.post("/login", user) 136 | 137 | assert_equal 302, driver.last_response.status 138 | 139 | driver.follow_redirect! 140 | 141 | expected = %Q(Logout) 142 | 143 | assert driver.last_response.body[expected] 144 | assert driver.last_response.body[user["email"]] 145 | 146 | driver.get("/logout") 147 | 148 | # Password recovery process 149 | 150 | driver.get("/login") 151 | 152 | assert_equal 200, driver.last_response.status 153 | 154 | expected = %Q(Forgot password?) 155 | 156 | assert driver.last_response.body[expected] 157 | 158 | driver.get("/reset") 159 | 160 | expected = %Q() 161 | 162 | assert driver.last_response.body[expected] 163 | 164 | driver.post("/reset") 165 | 166 | assert_equal 200, driver.last_response.status 167 | 168 | expected = %Q(Invalid email) 169 | 170 | assert driver.last_response.body[expected] 171 | 172 | user = { 173 | "email" => "foo@example.com", 174 | } 175 | 176 | driver.post("/reset", user) 177 | 178 | regex = /\/reset\/\S+/ 179 | 180 | url = Malone.deliveries.last.text[regex] 181 | 182 | assert url != nil 183 | 184 | driver.get("/reset/42.bad-token") 185 | 186 | assert_equal 302, driver.last_response.status 187 | 188 | driver.follow_redirect! 189 | 190 | assert_equal 200, driver.last_response.status 191 | 192 | expected = %Q(Invalid or expired URL) 193 | 194 | assert driver.last_response.body[expected] 195 | 196 | driver.get(url) 197 | 198 | assert_equal 200, driver.last_response.status 199 | 200 | expected = %Q(

Update your password

) 201 | 202 | assert driver.last_response.body[expected] 203 | 204 | expected = %Q() 205 | 206 | assert driver.last_response.body[expected] 207 | 208 | user = { 209 | "password" => "baz", 210 | } 211 | 212 | driver.post(url, user) 213 | 214 | assert_equal 302, driver.last_response.status 215 | 216 | driver.follow_redirect! 217 | 218 | assert_equal 200, driver.last_response.status 219 | 220 | expected = %Q(Logout) 221 | 222 | assert driver.last_response.body[expected] 223 | end 224 | -------------------------------------------------------------------------------- /bin/dep: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # Copyright (c) 2012 Cyril David 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | # 23 | # https://github.com/cyx/dep 24 | 25 | require "fileutils" 26 | 27 | def die 28 | abort("error: dep --help for more info") 29 | end 30 | 31 | module Dep 32 | class List 33 | attr :path 34 | 35 | def initialize(path) 36 | @path = path 37 | end 38 | 39 | def add(lib) 40 | remove(lib) 41 | libraries.push(lib) 42 | end 43 | 44 | def remove(lib) 45 | libraries.delete_if { |e| e.name == lib.name } 46 | end 47 | 48 | def libraries 49 | @libraries ||= File.readlines(path).map { |line| Lib[line] } 50 | end 51 | 52 | def missing_libraries 53 | libraries.reject(&:available?) 54 | end 55 | 56 | def save 57 | File.open(path, "w") do |file| 58 | libraries.each do |lib| 59 | file.puts lib.to_s 60 | end 61 | end 62 | end 63 | end 64 | 65 | class Lib < Struct.new(:name, :version) 66 | def self.[](line) 67 | if line.strip =~ /^(\S+) -v (\S+)$/ 68 | return new($1, $2) 69 | else 70 | abort("Invalid requirement found: #{line}") 71 | end 72 | end 73 | 74 | def available? 75 | Gem::Specification.find_by_name(name, version) 76 | rescue Gem::LoadError 77 | return false 78 | end 79 | 80 | def to_s 81 | "#{name} -v #{version}" 82 | end 83 | 84 | def csv 85 | "#{name}:#{version}" 86 | end 87 | 88 | def ==(other) 89 | to_s == other.to_s 90 | end 91 | end 92 | 93 | class CLI 94 | attr_accessor :prerelease, :list, :file 95 | 96 | def add(name) 97 | dependency = Gem::Dependency.new(name) 98 | fetcher = Gem::SpecFetcher.fetcher 99 | 100 | if fetcher.respond_to?(:spec_for_dependency) 101 | dependency.prerelease = @prerelease 102 | res, _ = fetcher.spec_for_dependency(dependency) 103 | else 104 | res = fetcher.fetch(dependency, false, true, @prerelease) 105 | end 106 | 107 | abort("Unable to find #{name}") if res.empty? 108 | 109 | spec = res[-1][0] 110 | lib = Dep::Lib.new(spec.name, spec.version) 111 | 112 | @list.add(lib) 113 | @list.save 114 | 115 | puts "dep: added #{lib}" 116 | end 117 | 118 | def rm(name) 119 | @list.remove(Dep::Lib.new(name)) 120 | @list.save 121 | 122 | puts "dep: removed #{name}" 123 | end 124 | 125 | def check 126 | if @list.missing_libraries.empty? 127 | puts "dep: all cool" 128 | else 129 | puts "dep: the following libraries are missing" 130 | 131 | @list.missing_libraries.each do |lib| 132 | puts " %s" % lib 133 | end 134 | 135 | exit(1) 136 | end 137 | end 138 | 139 | def install 140 | if @list.missing_libraries.empty? 141 | puts "dep: nothing to install" 142 | exit 143 | end 144 | 145 | run "gem install #{@list.missing_libraries.map(&:csv).join(" ")}" 146 | end 147 | 148 | def run(cmd) 149 | puts " #{cmd}" 150 | `#{cmd}` 151 | end 152 | end 153 | end 154 | 155 | module Kernel 156 | private 157 | def on(flag, &block) 158 | if index = ARGV.index(flag) 159 | _ = ARGV.delete_at(index) 160 | 161 | case block.arity 162 | when 1 then block.call(ARGV.delete_at(index)) 163 | when 0 then block.call 164 | else 165 | die 166 | end 167 | end 168 | end 169 | end 170 | 171 | # So originally, this was just $0 == __FILE__, but 172 | # since rubygems wraps the actual bin file in a loader 173 | # script, we have to instead rely on a different condition. 174 | if File.basename($0) == "dep" 175 | 176 | cli = Dep::CLI.new 177 | 178 | cli.file = File.join(Dir.pwd, ".gems") 179 | cli.prerelease = false 180 | 181 | on("-f") do |file| 182 | cli.file = file 183 | end 184 | 185 | on("--pre") do 186 | cli.prerelease = true 187 | end 188 | 189 | on("--help") do 190 | 191 | # We can't use DATA.read because rubygems does a wrapper script. 192 | help = File.read(__FILE__).split(/^__END__/)[1] 193 | 194 | IO.popen("less", "w") { |f| f.write(help) } 195 | exit 196 | end 197 | 198 | cli.list = Dep::List.new(cli.file) 199 | 200 | FileUtils.touch(cli.list.path) unless File.exist?(cli.list.path) 201 | 202 | case ARGV[0] 203 | when "add" 204 | cli.add(ARGV[1]) 205 | when "rm" 206 | cli.rm(ARGV[1]) 207 | when "install", "i" 208 | cli.install 209 | when nil 210 | cli.check 211 | else 212 | die 213 | end 214 | 215 | end 216 | 217 | __END__ 218 | DEP(1) 219 | 220 | NAME 221 | dep -- Basic dependency tracking 222 | 223 | SYNOPSIS 224 | dep 225 | dep add libname [--pre] 226 | dep rm libname 227 | dep install 228 | 229 | DESCRIPTION 230 | dep 231 | Checks that all dependencies are met. 232 | 233 | dep add [gemname] 234 | Fetches the latest version of `gemname` 235 | and automatically adds it to your .gems file. 236 | 237 | rm 238 | Removes the corresponding entry in your .gems file. 239 | 240 | install 241 | Installs all the missing dependencies for you. An important 242 | point here is that it simply does a `gem install` for each 243 | dependency you have. Dep assumes that you use some form of 244 | sandboxing like gs, rbenv-gemset or RVM gemsets. 245 | 246 | 247 | INSTALLATION 248 | $ wget -qO- http://amakawa.org/sh/install.sh | sh 249 | 250 | # or 251 | 252 | $ gem install dep 253 | 254 | HISTORY 255 | dep is actually more of a workflow than a tool. If you think about 256 | package managers and the problem of dependencies, you can summarize 257 | what you absolutely need from them in just two points: 258 | 259 | 1. When you build an application which relies on 3rd party libraries, 260 | it's best to explicitly declare the version numbers of these 261 | libraries. 262 | 263 | 2. You can either bundle the specific library version together with 264 | your application, or you can have a list of versions. 265 | 266 | The first approach is handled by vendoring the library. The second 267 | approach typically is done using Bundler. But why do you need such 268 | a complicated tool when all you need is simply listing version numbers? 269 | 270 | We dissected what we were doing and eventually reached the following 271 | workflow: 272 | 273 | 1. We maintain a .gems file for every application which lists the 274 | libraries and the version numbers. 275 | 2. We omit dependencies of dependencies in that file, the reason being 276 | is that that should already be handled by the package manager 277 | (typically rubygems). 278 | 3. Whenever we add a new library, we add the latest version. 279 | 4. When we pull the latest changes, we want to be able to rapidly 280 | check if the dependencies we have is up to date and matches what 281 | we just pulled. 282 | 283 | So after doing this workflow manually for a while, we decided to 284 | build the simplest tool to aid us with our workflow. 285 | 286 | The first point is handled implicitly by dep. You can also specify 287 | a different file by doing dep -f. 288 | 289 | The second point is more of an implementation detail. We thought about 290 | doing dependencies, but then, why re-implement something that's already 291 | done for you by rubygems? 292 | 293 | The third point (and also the one which is most inconvenient), is 294 | handled by dep add. 295 | 296 | The manual workflow for that would be: 297 | 298 | gem search -r "^ohm$" [--pre] # check and remember the version number 299 | echo "ohm -v X.x.x" >> .gems 300 | 301 | If you try doing that repeatedly, it will quickly become cumbersome. 302 | 303 | The fourth and final point is handled by typing dep check or simply dep. 304 | Practically speaking it's just: 305 | 306 | git pull 307 | dep 308 | 309 | And that's it. The dep command typically happens in 0.2 seconds which 310 | is something we LOVE. 311 | --------------------------------------------------------------------------------