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(