├── .editorconfig
├── .gitignore
├── LICENSE
├── README.md
├── icon.svg
├── shard.yml
├── spec
├── salt
│ └── enviroment_spec.cr
├── salt_spec.cr
└── spec_helper.cr
└── src
├── salt.cr
└── salt
├── app.cr
├── environment.cr
├── errors.cr
├── middlewares.cr
├── middlewares
├── basic_auth.cr
├── common_logger.cr
├── directory.cr
├── etag.cr
├── file.cr
├── head.cr
├── logger.cr
├── router.cr
├── runtime.cr
├── session.cr
├── session
│ ├── abstract
│ │ ├── persisted.cr
│ │ └── session_hash.cr
│ ├── cookie.cr
│ └── redis.cr
├── show_exceptions.cr
├── static.cr
└── views
│ ├── directory
│ └── layout.ecr
│ └── show_exceptions
│ └── layout.ecr
├── server.cr
└── version.cr
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*.cr]
2 | charset = utf-8
3 | end_of_line = lf
4 | insert_final_newline = true
5 | indent_style = space
6 | indent_size = 2
7 | trim_trailing_whitespace = true
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /doc/
2 | /lib/
3 | /bin/
4 | /.shards/
5 |
6 | # Libraries don't need dependency lock
7 | # Dependencies will be locked in application that uses them
8 | /shard.lock
9 | docs/
10 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017 icyleaf
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | A Human Friendly Interface for HTTP webservers written in Crystal.
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | "Salt" icon by Creative Stall from Noun Project .
17 |
18 |
19 | ## Installation
20 |
21 | Add this to your application's `shard.yml`:
22 |
23 | ```yaml
24 | dependencies:
25 | salt:
26 | github: icyleaf/salt
27 | branch: master
28 | ```
29 |
30 | ## Usage
31 |
32 | ```crystal
33 | require "salt"
34 | require "salt/middlewares/session/cookie"
35 | require "salt/middlewares/logger"
36 |
37 | class Talk < Salt::App
38 | def call(env)
39 | env.session.set("username", "icyleaf")
40 | env.logger.info("Start Talking!")
41 | {400, { "Content-Type" => "text/plain" }, ["Can I talk to salt?"]}
42 | end
43 | end
44 |
45 | class Shout < Salt::App
46 | def call(env)
47 | call_app(env)
48 |
49 | env.logger.debug("Shout class")
50 | {status_code, headers, body.map &.upcase }
51 | end
52 | end
53 |
54 | class Speaking < Salt::App
55 | def call(env)
56 | call_app(env)
57 |
58 | env.logger.debug("Speaking class")
59 | {200, headers, ["This is Slat speaking! #{env.session.get("username")}"]}
60 | end
61 | end
62 |
63 | Salt.use Salt::Session::Cookie, secret: ""
64 | Salt.use Salt::Logger, level: Logger::DEBUG, progname: "app"
65 | Salt.use Shout
66 | Salt.use Speaking
67 |
68 | Salt.run Talk.new
69 | ```
70 |
71 | ## Available middleware
72 |
73 | - [x] `ShowExceptions`
74 | - [x] `CommonLogger`
75 | - [x] `Logger`
76 | - [x] `Runtime`
77 | - [x] `Session` (Cookie/Redis)
78 | - [x] `Head`
79 | - [x] `File`
80 | - [x] `Directory`
81 | - [x] `Static`
82 | - [ ] `SendFile`
83 | - [x] `ETag`
84 | - [x] `BasicAuth`
85 | - [x] `Router` (lightweight)
86 |
87 | All these components use the same interface, which is described in detail in the Salt::App specification. These optional components can be used in any way you wish.
88 |
89 | ## How to Contribute
90 |
91 | Your contributions are always welcome! Please submit a pull request or create an issue to add a new question, bug or feature to the list.
92 |
93 | All [Contributors](https://github.com/icyleaf/salt/graphs/contributors) are on the wall.
94 |
95 | ## You may also like
96 |
97 | - [halite](https://github.com/icyleaf/halite) - HTTP Requests Client with a chainable REST API, built-in sessions and loggers.
98 | - [totem](https://github.com/icyleaf/totem) - Load and parse a configuration file or string in JSON, YAML, dotenv formats.
99 | - [markd](https://github.com/icyleaf/markd) - Yet another markdown parser built for speed, Compliant to CommonMark specification.
100 | - [poncho](https://github.com/icyleaf/poncho) - A .env parser/loader improved for performance.
101 | - [popcorn](https://github.com/icyleaf/popcorn) - Easy and Safe casting from one type to another.
102 | - [fast-crystal](https://github.com/icyleaf/fast-crystal) - 💨 Writing Fast Crystal 😍 -- Collect Common Crystal idioms.
103 |
104 | ## Resouces
105 |
106 | Heavily inspired from Ruby's rack gem.
107 |
108 | ## License
109 |
110 | [MIT License](https://github.com/icyleaf/salt/blob/master/LICENSE) © icyleaf
111 |
--------------------------------------------------------------------------------
/icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/shard.yml:
--------------------------------------------------------------------------------
1 | name: salt
2 | version: 0.4.4
3 |
4 | authors:
5 | - icyleaf
6 |
7 | dependencies:
8 | mime:
9 | github: icyleaf/mime.cr
10 | version: ~> 0.1.0
11 |
12 | development_dependencies:
13 | radix:
14 | github: luislavena/radix
15 | version: ~> 0.3.8
16 | redis:
17 | github: stefanwille/crystal-redis
18 | version: ~> 1.11.0
19 |
20 | crystal: 0.27.0
21 |
22 | license: MIT
23 |
--------------------------------------------------------------------------------
/spec/salt/enviroment_spec.cr:
--------------------------------------------------------------------------------
1 | require "../spec_helper"
2 |
3 | private def mock_enviroment(method = "GET",
4 | resource = "/",
5 | headers : HTTP::Headers? = nil,
6 | body : String? = nil)
7 | io = IO::Memory.new
8 | request = HTTP::Request.new(method, resource, headers, body)
9 | response = HTTP::Server::Response.new(io)
10 | context = HTTP::Server::Context.new(request, response)
11 | Salt::Environment.new(context)
12 | end
13 |
14 | describe Salt::Environment do
15 | describe "with initialize" do
16 | it "should gets version and headers" do
17 | env = mock_enviroment
18 | env.version.should eq "HTTP/1.1"
19 | env.headers.size.should eq 0
20 | end
21 | end
22 |
23 | describe "with uri" do
24 | it "should gets http uri" do
25 | env = mock_enviroment("GET", "/path/to?name=foo", HTTP::Headers{"Host" => "example.com"})
26 | env.url.should eq "http://example.com/path/to?name=foo"
27 | env.scheme.should eq "http"
28 | env.base_url.should eq "http://example.com"
29 | env.host.should eq "example.com"
30 | env.host_with_port.should eq "example.com"
31 | env.port.should eq 80
32 | env.path.should eq "/path/to"
33 | env.full_path.should eq "/path/to?name=foo"
34 | end
35 |
36 | it "should gets http uri without HOST" do
37 | env = mock_enviroment("GET", "/path/to?name=foo")
38 | env.url.should eq "/path/to?name=foo"
39 | env.scheme.should eq "http"
40 | env.base_url.should eq nil
41 | env.host.should eq nil
42 | env.host_with_port.should eq nil
43 | env.port.should eq 80
44 | env.path.should eq "/path/to"
45 | env.full_path.should eq "/path/to?name=foo"
46 | end
47 | end
48 |
49 | describe "with methods" do
50 | {% for name in Salt::Environment::Methods::NAMES %}
51 | it "should gets {{ name.id }} method" do
52 | env = mock_enviroment({{ name.id.stringify }})
53 | env.method.should eq {{ name.id.stringify }}
54 | env.{{ name.id.downcase }}?.should be_true
55 | end
56 | {% end %}
57 | end
58 |
59 | describe "with parameters" do
60 | it "should gets query params" do
61 | env = mock_enviroment("GET", "/path/to?name=foo#toc")
62 | env.form_data?.should be_false
63 | env.query_params["name"].should eq "foo"
64 | env.params["name"].should eq "foo"
65 | end
66 |
67 | it "should gets form urlencoded body params" do
68 | env = mock_enviroment("POST", "/path/to", HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}, "name=foo")
69 | env.form_data?.should be_false
70 | env.params["name"].should eq "foo"
71 | end
72 |
73 | it "should gets form data body params" do
74 | io = IO::Memory.new
75 | builder = HTTP::FormData::Builder.new(io)
76 | builder.field("name", "foo")
77 | builder.finish
78 |
79 | env = mock_enviroment("POST", "/path/to", HTTP::Headers{"Content-Type" => builder.content_type}, io.to_s)
80 | env.form_data?.should be_true
81 | env.params["name"].should eq "foo"
82 | end
83 |
84 | it "should gets files" do
85 | io = IO::Memory.new
86 | builder = HTTP::FormData::Builder.new(io)
87 | builder.field("name", "foo")
88 | builder.file("not_file", IO::Memory.new("hello"))
89 | builder.file("file", File.open("shard.yml"), HTTP::FormData::FileMetadata.new(filename: "shard.yml"))
90 | builder.finish
91 |
92 | env = mock_enviroment("POST", "/path/to", HTTP::Headers{"Content-Type" => builder.content_type}, io.to_s)
93 | env.form_data?.should be_true
94 | env.files["file"].filename.should eq "shard.yml"
95 | File.open(env.files["file"].tempfile.path).gets_to_end.should eq File.open("shard.yml").gets_to_end
96 | expect_raises KeyError do
97 | env.files["not_file"]
98 | end
99 | env.files["not_file"]?.should be_nil
100 |
101 | env.params["not_file"].should eq "hello"
102 | env.params["name"].should eq "foo"
103 | end
104 | end
105 | end
106 |
--------------------------------------------------------------------------------
/spec/salt_spec.cr:
--------------------------------------------------------------------------------
1 | require "./spec_helper"
2 |
3 | describe Salt do
4 | describe "#alias class" do
5 | context "Runtime" do
6 | it "should alias Salt::Middlewares::Runtime" do
7 | Salt::Middlewares::Runtime.should be_a Salt::Middlewares::Runtime.class
8 | end
9 | end
10 |
11 | context "Logger" do
12 | it "should alias Salt::Middlewares::Logger" do
13 | Salt::Middlewares::Logger.should be_a Salt::Middlewares::Logger.class
14 | end
15 | end
16 |
17 | context "CommonLogger" do
18 | it "should alias Salt::Middlewares::Runtime" do
19 | Salt::Middlewares::CommonLogger.should be_a Salt::Middlewares::CommonLogger.class
20 | end
21 | end
22 |
23 | context "ShowExceptions" do
24 | it "should alias Salt::Middlewares::Runtime" do
25 | Salt::Middlewares::ShowExceptions.should be_a Salt::Middlewares::ShowExceptions.class
26 | end
27 | end
28 |
29 | context "Session::Cookie" do
30 | it "should alias Salt::Middlewares::Runtime" do
31 | Salt::Middlewares::Session::Cookie.should be_a Salt::Middlewares::Session::Cookie.class
32 | end
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/spec/spec_helper.cr:
--------------------------------------------------------------------------------
1 | require "spec"
2 | require "../src/salt"
3 | require "../src/salt/middlewares/**"
4 |
--------------------------------------------------------------------------------
/src/salt.cr:
--------------------------------------------------------------------------------
1 | require "./salt/*"
2 |
3 | module Salt
4 | # Run http server and takes an argument that is an Salt::App that responds to #call
5 | #
6 | # ```
7 | # class App < Salt::App
8 | # def call(env)
9 | # {200, {"Content-Type" => "text/plain"}, ["hello world"]}
10 | # end
11 | # end
12 | #
13 | # Salt.run App.new
14 | # ```
15 | def self.run(app : Salt::App, **options)
16 | Salt::Server.new(**options).run(app)
17 | end
18 |
19 | # Specifies middleware to use in a stack.
20 | #
21 | # ```
22 | # class App < Salt::App
23 | # def call(env)
24 | # call_app(env)
25 | # env.session.set("user", "foobar")
26 | # {200, {"Content-Type" => "text/html"}, [Hello ", env.session.get("user")}
27 | # end
28 | # end
29 | #
30 | # Salt.use Salt::Session::Cookie, secret: ""
31 | # Sale.run App.new
32 | # ```
33 | def self.use(middleware, **options)
34 | Salt::Middlewares.use(middleware, **options)
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/src/salt/app.cr:
--------------------------------------------------------------------------------
1 | require "http/server/handler"
2 |
3 | module Salt
4 | # `Salt::App` is a abstract class and implements the `call` method.
5 | #
6 | # You can use it to create any app or middlewares, all middlewares were based on it.
7 | # Method `call` must returns as `App::Response`.
8 | #
9 | # ### Example
10 | #
11 | # #### A simple app
12 | #
13 | # ```
14 | # class App < Salt::App
15 | # def call(env)
16 | # {200, {"content-type" => "text/plain"}, ["hello world"]}
17 | # end
18 | # end
19 | #
20 | # Salt.run App.new
21 | # ```
22 | #
23 | # #### A simple middleware
24 | #
25 | # ```
26 | # class UpcaseBody < Salt::App
27 | # def call(env)
28 | # call_app env
29 | # {status_code, headers, body.map &.upcase}
30 | # end
31 | # end
32 | #
33 | # Salt.use UpcaseBody
34 | # Salt.run App.new
35 | # ```
36 | #
37 | # #### Middleware with options
38 | #
39 | # Options only accepts `Namedtuple` type, given the default value if pass as named arguments
40 | #
41 | # ```
42 | # class ServerName < Salt::App
43 | # def initialize(@app, @name : String? = nil)
44 | # end
45 | #
46 | # def call(env)
47 | # call_app env
48 | #
49 | # if name = @name
50 | # headers["Server"] = name
51 | # end
52 | #
53 | # {status_code, headers, body}
54 | # end
55 | # end
56 | #
57 | # Salt.use ServerName, name: "Salt"
58 | # Salt.run App.new
59 | # ```
60 | #
61 | # Want know more examples, check all subclassess below.
62 | abstract class App
63 | alias Response = {Int32, Hash(String, String), Array(String)}
64 |
65 | property status_code = 200
66 |
67 | property headers = {} of String => String
68 | property body = [] of String
69 |
70 | def initialize(@app : App? = nil)
71 | end
72 |
73 | abstract def call(env) : Response
74 |
75 | protected def call_app(env : Environment) : Response
76 | if app = @app
77 | response = app.call(env)
78 | @status_code = response[0].as(Int32)
79 | @headers = response[1].as(Hash(String, String))
80 | @body = response[2].as(Array(String))
81 | end
82 |
83 | {@status_code, @headers, @body}
84 | end
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/src/salt/environment.cr:
--------------------------------------------------------------------------------
1 | require "./middlewares/session/abstract/session_hash"
2 | require "uri"
3 |
4 | module Salt
5 | # `Salt::Environment` provides a convenient interface to a Salt environment.
6 | # It is stateless, the environment **env** passed to the constructor will be directly modified.
7 | class Environment
8 | @request : HTTP::Request
9 | @response : HTTP::Server::Response
10 |
11 | def initialize(@context : HTTP::Server::Context)
12 | @request = @context.request
13 | @response = @context.response
14 |
15 | parse_params
16 | end
17 |
18 | property? logger : ::Logger?
19 |
20 | # Depend on `Salt::Logger` middleware
21 | def logger
22 | unless logger?
23 | raise Exceptions::NotFoundMiddleware.new("Missing Logger middleware, use Salt::run add it.")
24 | end
25 |
26 | @logger.not_nil!
27 | end
28 |
29 | property? session : Salt::Middlewares::Session::Abstract::SessionHash?
30 |
31 | # Depend on `Salt::Session` middleware
32 | def session
33 | unless session?
34 | raise Exceptions::NotFoundMiddleware.new("Missing Session middleware, use Salt::run add it.")
35 | end
36 |
37 | @session.not_nil!
38 | end
39 |
40 | delegate version, to: @request
41 | delegate headers, to: @request
42 |
43 | module URL
44 | delegate full_path, to: @request
45 | delegate path, to: @request
46 | delegate query, to: @request
47 |
48 | def path=(value)
49 | @request.path = value
50 | end
51 |
52 | def query=(value)
53 | @request.query = value
54 | end
55 |
56 | def url : String
57 | String.build do |io|
58 | io << base_url << full_path
59 | end
60 | end
61 |
62 | def base_url : String?
63 | host_with_port ? "#{scheme}://#{host_with_port}" : nil
64 | end
65 |
66 | def full_path : String
67 | uri.full_path
68 | # query ? "#{path}?#{query.not_nil!}" : path
69 | end
70 |
71 | def scheme : String
72 | uri.scheme || "http"
73 | end
74 |
75 | def ssl? : Bool
76 | scheme == "https"
77 | end
78 |
79 | def host : String?
80 | if host = @request.host
81 | return host
82 | end
83 |
84 | uri.host
85 | end
86 |
87 | def host_with_port : String?
88 | if host_with_port = @request.host_with_port
89 | return host_with_port
90 | end
91 |
92 | return unless host
93 |
94 | String.build do |io|
95 | io << host
96 | unless [80, 443].includes?(port)
97 | io << ":" << port
98 | end
99 | end
100 | end
101 |
102 | def port : Int32
103 | uri.port || (ssl? ? 443 : 80)
104 | end
105 |
106 | @uri : URI?
107 |
108 | private def uri
109 | (@uri ||= URI.parse(@context.request.resource)).not_nil!
110 | end
111 | end
112 |
113 | module Methods
114 | NAMES = %w(GET HEAD PUT POST PATCH DELETE OPTIONS)
115 |
116 | delegate method, to: @request
117 |
118 | {% for method in NAMES %}
119 | # Checks the HTTP request method (or verb) to see if it was of type {{ method.id }}
120 | def {{ method.id.downcase }}?
121 | method == {{ method.id.stringify }}
122 | end
123 | {% end %}
124 | end
125 |
126 | module Parameters
127 | delegate query_params, to: @request
128 |
129 | @params_parsed = false
130 | @params = HTTP::Params.new
131 |
132 | def params
133 | return @params if @params_parsed && @request == @context.request
134 | parse_params
135 | end
136 |
137 | def form_data? : Bool
138 | if content_type = @request.headers["content_type"]?
139 | return content_type.starts_with?("multipart/form-data")
140 | end
141 |
142 | false
143 | end
144 |
145 | @files = {} of String => UploadFile
146 |
147 | # return files of Request body
148 | def files
149 | @files
150 | end
151 |
152 | private def parse_params
153 | @params = form_data? ? parse_multipart : parse_body
154 |
155 | # Add query params
156 | if !query_params.size.zero?
157 | query_params.each do |key, value|
158 | @params.add(key, value)
159 | end
160 | end
161 |
162 | @params_parsed = true
163 | @params
164 | end
165 |
166 | private def parse_body
167 | raws = case body = @request.body
168 | when IO
169 | body.gets_to_end
170 | when String
171 | body.to_s
172 | else
173 | ""
174 | end
175 |
176 | HTTP::Params.parse raws
177 | end
178 |
179 | private def parse_multipart : HTTP::Params
180 | params = HTTP::Params.new
181 |
182 | HTTP::FormData.parse(@request) do |part|
183 | next unless part
184 |
185 | name = part.name
186 | if filename = part.filename
187 | @files[name] = UploadFile.new(part)
188 | else
189 | params.add name, part.body.gets_to_end
190 | end
191 | end
192 |
193 | params
194 | end
195 | end
196 |
197 | module Cookies
198 | @cookies : CookiesProxy?
199 |
200 | def cookies
201 | @cookies ||= CookiesProxy.new(@context)
202 | @cookies.not_nil!
203 | end
204 |
205 | class CookiesProxy
206 | def initialize(@context : HTTP::Server::Context)
207 | end
208 |
209 | def add(name : String, value : String, path : String = "/",
210 | expires : Time? = nil, domain : String? = nil,
211 | secure : Bool = false, http_only : Bool = false,
212 | extension : String? = nil)
213 | cookie = HTTP::Cookie.new(name, value, path, expires, domain, secure, http_only, extension)
214 | add(cookie)
215 | end
216 |
217 | def add(cookie : HTTP::Cookie)
218 | @context.response.cookies << cookie
219 | end
220 |
221 | def <<(cookie : HTTP::Cookie)
222 | add(cookie)
223 | end
224 |
225 | def get(name : String)
226 | @context.request.cookies[name]
227 | end
228 |
229 | def get?(name : String)
230 | @context.request.cookies[name]?
231 | end
232 |
233 | forward_missing_to @context.request.cookies
234 | end
235 | end
236 | private struct UploadFile
237 | getter filename : String
238 | getter tempfile : ::File
239 | getter size : UInt64?
240 | getter created_at : Time?
241 | getter modifed_at : Time?
242 | getter headers : HTTP::Headers
243 |
244 | def initialize(part : HTTP::FormData::Part)
245 | @filename = part.filename.not_nil!
246 | @size = part.size
247 | @created_at = part.creation_time
248 | @modifed_at = part.modification_time
249 | @headers = part.headers
250 |
251 | @tempfile = ::File.tempfile(@filename)
252 | ::File.open(@tempfile.path, "w") do |f|
253 | IO.copy(part.body, f)
254 | end
255 | end
256 | end
257 |
258 | include URL
259 | include Methods
260 | include Parameters
261 | include Cookies
262 | end
263 | end
264 |
--------------------------------------------------------------------------------
/src/salt/errors.cr:
--------------------------------------------------------------------------------
1 | module Salt::Exceptions
2 | class Error < ::Exception; end
3 |
4 | class NotFoundMiddleware < Error; end
5 | end
6 |
--------------------------------------------------------------------------------
/src/salt/middlewares.cr:
--------------------------------------------------------------------------------
1 | require "mime"
2 |
3 | module Salt
4 | # `Salt::Middlewares` manages all about middlewares.
5 | module Middlewares
6 | @@middlewares = [] of Proc(App, App)
7 |
8 | # By default, use `Salt.use` instead this.
9 | def self.use(klass, **options)
10 | proc = ->(app : App) { klass.new(app, **options).as(App) }
11 | @@middlewares << proc
12 | end
13 |
14 | # Conversion middleweares into app
15 | def self.to_app(app : App) : App
16 | @@middlewares.reverse.reduce(app) { |a, e| e.call(a) }
17 | end
18 |
19 | # Clear all middlewares
20 | def self.clear
21 | @@middlewares.clear
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/src/salt/middlewares/basic_auth.cr:
--------------------------------------------------------------------------------
1 | require "crypto/subtle"
2 | require "base64"
3 |
4 | module Salt
5 | alias BasicAuth = Middlewares::BasicAuth
6 |
7 | module Middlewares
8 | # `Salt::BasicAuth` implements HTTP Basic Authentication, as per RFC 2617.
9 | #
10 | # Initialize with the Salt application that you want protecting,
11 | # and a block that checks if a username and password pair are valid.
12 | #
13 | # ### Rules for resouces
14 | #
15 | # #### All paths
16 | #
17 | # By defaults, it sets `[]` for all paths
18 | #
19 | # #### A list of paths/files to protect.
20 | #
21 | # `["/admin", "/config/database.yaml"]`
22 | #
23 | # ### Examples
24 | #
25 | # #### protect for all paths
26 | #
27 | # ```
28 | # Salt.use Salt::Middlewares::BasicAuth, user: "foo", password: "bar"
29 | # ```
30 | #
31 | # #### resources is a list of files/paths to protect
32 | #
33 | # ```
34 | # Salt.use Salt::Middlewares::BasicAuth, user: "foo", password: "bar", resources: ["/admin"]
35 | # ```
36 | class BasicAuth < App
37 | AUTH_STRING = "Authorization"
38 |
39 | def initialize(@app : App, @user = "", @password = "",
40 | @resources = [] of String, @authenticator : Proc(String, String?)? = nil,
41 | @realm = "Login Required", @realm_charset : String? = nil)
42 | raise "Missing user & password" if @user.empty? && password.empty?
43 | end
44 |
45 | def call(env)
46 | return call_app(env) unless resources?(env)
47 | return unauthorized unless auth_provided?(env)
48 | return bad_request unless auth_basic?(env)
49 |
50 | if username = auth_valid?(env)
51 | env.auth_user = username
52 | return call_app(env)
53 | end
54 |
55 | unauthorized
56 | end
57 |
58 | private def unauthorized
59 | body = "401 Unauthorized"
60 | {
61 | 401,
62 | {
63 | "Content-Type" => "text/plain",
64 | "Content-Lengt" => body.bytesize.to_s,
65 | "WWW-Authenticate" => realm,
66 | },
67 | [body],
68 | }
69 | end
70 |
71 | def bad_request
72 | body = "400 Bad request"
73 | {
74 | 400,
75 | {
76 | "Content-Type" => "text/plain",
77 | "Content-Lengt" => body.bytesize.to_s,
78 | },
79 | [body],
80 | }
81 | end
82 |
83 | def auth_valid?(env) : String?
84 | _, value = auth_value(env)
85 | if auth = @authenticator
86 | auth.call(value)
87 | else
88 | authorize?(value)
89 | end
90 | end
91 |
92 | def authorize?(value)
93 | user, password = Base64.decode_string(value).split(":", 2)
94 | if Crypto::Subtle.constant_time_compare(@user, user) &&
95 | Crypto::Subtle.constant_time_compare(@password, password)
96 | user
97 | end
98 | end
99 |
100 | def resources?(env)
101 | return true if @resources.empty?
102 |
103 | @resources.each do |path|
104 | return true if env.path.starts_with?(path)
105 | end
106 |
107 | false
108 | end
109 |
110 | def auth_provided?(env) : Bool
111 | env.headers.has_key?(AUTH_STRING)
112 | end
113 |
114 | def auth_basic?(env) : Bool
115 | auth_value(env).first.downcase == "basic"
116 | end
117 |
118 | def auth_value(env) : Array(String)
119 | env.headers[AUTH_STRING].split(" ", 2)
120 | end
121 |
122 | def realm : String
123 | if charset = @realm_charset
124 | %Q(Basic realm="#{@realm}", charset="#{charset}")
125 | else
126 | %Q(Basic realm="#{@realm}")
127 | end
128 | end
129 | end
130 | end
131 |
132 | class Environment
133 | property auth_user : String?
134 | end
135 | end
136 |
--------------------------------------------------------------------------------
/src/salt/middlewares/common_logger.cr:
--------------------------------------------------------------------------------
1 | require "colorize"
2 |
3 | module Salt
4 | alias CommonLogger = Middlewares::CommonLogger
5 |
6 | module Middlewares
7 | # `Salt::CommonLogger` forwards every request to the given app.
8 | #
9 | # ### Example
10 | #
11 | # #### Use built-in logger(`STDOUT`)
12 | #
13 | # ```
14 | # Salt.use Salt::CommonLogger
15 | # ```
16 | #
17 | # #### Use custom logger
18 | #
19 | # ```
20 | # Salt.use Salt::CommonLogger, io : File.open("development.log")
21 | # ```
22 | class CommonLogger < App
23 | FORMAT = %{%s |%s| %12s |%s %s%s}
24 |
25 | def initialize(@app : App, io : IO = STDERR)
26 | @logger = ::Logger.new(io)
27 | setting_logger
28 | end
29 |
30 | def call(env)
31 | began_at = Time.now
32 | response = call_app(env)
33 | log(env, status_code, headers, began_at)
34 | response
35 | end
36 |
37 | private def log(env, status_code, headers, began_at)
38 | @logger.info FORMAT % [
39 | Time.now.to_s("%Y/%m/%d - %H:%m:%S"),
40 | colorful_status_code(status_code),
41 | elapsed_from(began_at),
42 | colorful_method(env.method),
43 | env.path,
44 | env.query.to_s.empty? ? "" : "?#{env.query}",
45 | ]
46 | end
47 |
48 | private def colorful_status_code(status_code : Int32)
49 | code = " #{status_code.to_s} "
50 | case status_code
51 | when 300..399
52 | code.colorize.fore(:dark_gray).back(:white)
53 | when 400..499
54 | code.colorize.fore(:white).back(:yellow)
55 | when 500..999
56 | code.colorize.fore(:white).back(:red)
57 | else
58 | code.colorize.fore(:white).back(:green)
59 | end
60 | end
61 |
62 | private def colorful_method(method)
63 | raw = " %-10s" % method
64 |
65 | case method
66 | when "GET"
67 | raw.colorize.fore(:white).back(:blue)
68 | when "POST"
69 | raw.colorize.fore(:white).back(:green)
70 | when "PUT", "PATCH"
71 | raw.colorize.fore(:white).back(:yellow)
72 | when "DELETE"
73 | raw.colorize.fore(:white).back(:red)
74 | when "HEAD"
75 | raw.colorize.fore(:white).back(:magenta)
76 | else
77 | raw.colorize.fore(:dark_gray).back(:white)
78 | end
79 | end
80 |
81 | ELAPSED_LENGTH = 14
82 |
83 | private def elapsed_from(began_time)
84 | elapsed = Time.now - began_time
85 | millis = elapsed.total_milliseconds
86 | raw = if millis >= 1
87 | "#{millis.round(4)}ms"
88 | elsif (millis * 1000) >= 1
89 | "#{(millis * 1000).round(4)}µs"
90 | else
91 | "#{(millis * 1000 * 1000).round(4)}ns"
92 | end
93 | end
94 |
95 | private def setting_logger
96 | @logger.level = ::Logger::INFO
97 | @logger.formatter = ::Logger::Formatter.new do |severity, datetime, progname, message, io|
98 | io << message
99 | end
100 | end
101 | end
102 | end
103 | end
104 |
--------------------------------------------------------------------------------
/src/salt/middlewares/directory.cr:
--------------------------------------------------------------------------------
1 | require "./file"
2 |
3 | module Salt
4 | alias Directory = Middlewares::Directory
5 |
6 | module Middlewares
7 | # `Salt::Directory` serves entries below the **root** given, according to the
8 | # path info of the Salt request. If a directory is found, the file's contents
9 | # will be presented in an html based index. If a file is found, the env will
10 | # be passed to the specified **app**.
11 | #
12 | # If **app** is not specified, a Salt::File of the same **root** will be used.
13 | #
14 | # ### Examples
15 | #
16 | # ```
17 | # Salt.run Salt::Directory.new(root: "~/")
18 | # ```
19 | class Directory < App
20 | def initialize(app : App? = nil, root : String = ".")
21 | @root = ::File.expand_path(root)
22 | app = app || Salt::Middlewares::File.new(root: @root)
23 | end
24 |
25 | def call(env)
26 | path_info = URI.unescape(env.path)
27 | if bad_request = check_bad_request(path_info)
28 | bad_request
29 | elsif forbidden = check_forbidden(path_info)
30 | forbidden
31 | else
32 | list_path(env, path_info)
33 | end
34 | end
35 |
36 | private def list_path(env, path_info)
37 | real_path = ::File.join(@root, path_info)
38 | if ::File.readable?(real_path)
39 | if ::File.file?(real_path)
40 | @app.not_nil!.call(env)
41 | else
42 | list_directory(path_info, real_path)
43 | end
44 | else
45 | fail(404, "No such file or directory")
46 | end
47 | end
48 |
49 | private def list_directory(path_info, real_path)
50 | files = load_files(path_info)
51 |
52 | glob_path = ::File.join(real_path, "*")
53 | path = URI.escape(path_info) do |byte|
54 | URI.unreserved?(byte) || byte.chr == '/'
55 | end
56 |
57 | Dir[glob_path].sort.each do |node|
58 | next unless stat = info(node)
59 |
60 | name = ::File.basename(node)
61 | next if name.starts_with?(".")
62 |
63 | url = ::File.join(path + URI.escape(name))
64 | url += "/" if stat.directory?
65 | name += "/" if stat.directory?
66 | icon = stat.directory? ? directory_icon : file_icon
67 | size = stat.directory? ? "" : filesize_format(stat.size)
68 |
69 | files << [icon, name, url, size.to_s, stat.modification_time.to_local.to_s]
70 | end
71 |
72 | {
73 | 200,
74 | {
75 | "Content-Type" => "text/html; charset=utf-8",
76 | },
77 | [pretty_body(path_info, files)],
78 | }
79 | end
80 |
81 | private def check_bad_request(path_info)
82 | return if path_info.valid_encoding?
83 |
84 | fail(400, "Bad Request")
85 | end
86 |
87 | private def check_forbidden(path_info)
88 | return unless path_info.includes?("..")
89 |
90 | fail(403, "Forbidden")
91 | end
92 |
93 | private def fail(code : Int32, body : String, headers = {} of String => String)
94 | {
95 | code,
96 | {
97 | "Content-Type" => "text/plain; charset=utf-8",
98 | "Content-Length" => body.bytesize.to_s,
99 | "X-Cascade" => "pass",
100 | }.merge(headers),
101 | [body],
102 | }
103 | end
104 |
105 | private def load_files(path_info)
106 | if path_info == "/"
107 | [] of Array(String)
108 | else
109 | [["", "..", "../", "", ""]]
110 | end
111 | end
112 |
113 | private def directory_icon
114 | <<-HTML
115 |
116 | HTML
117 | end
118 |
119 | private def file_icon
120 | <<-HTML
121 |
122 | HTML
123 | end
124 |
125 | private def info(node)
126 | ::File.info(node)
127 | rescue Errno
128 | return nil
129 | end
130 |
131 | private def pretty_body(root, files) : String
132 | io = IO::Memory.new
133 | ECR.embed("#{__DIR__}/views/directory/layout.ecr", io)
134 | io.to_s
135 | end
136 |
137 | FILESIZE_FORMAT = {
138 | "%.1fT" => 1099511627776,
139 | "%.1fG" => 1073741824,
140 | "%.1fM" => 1048576,
141 | "%.1fK" => 1024,
142 | }
143 |
144 | private def filesize_format(int)
145 | human_size = 0.to_f
146 | FILESIZE_FORMAT.each do |format, size|
147 | return format % (int.to_f / size) if int >= size
148 | end
149 |
150 | "#{int}B"
151 | end
152 | end
153 | end
154 | end
155 |
--------------------------------------------------------------------------------
/src/salt/middlewares/etag.cr:
--------------------------------------------------------------------------------
1 | require "openssl"
2 |
3 | module Salt
4 | alias ETag = Middlewares::ETag
5 |
6 | module Middlewares
7 | # `Salt::ETag` append a weak 'ETag'/`Cache-Control` to header for all requests.
8 | class ETag < App
9 | ETAG_STRING = "ETag"
10 | CACHE_CONTROL_STRING = "Cache-Control"
11 | DEFAULT_CACHE_CONTROL = "max-age=0, private, must-revalidate"
12 |
13 | def initialize(@app : App, @no_cache_control : String? = nil, @cache_control = DEFAULT_CACHE_CONTROL)
14 | end
15 |
16 | def call(env)
17 | call_app(env)
18 |
19 | if etag_status? && !skip_caching?
20 | digest, new_body = digest_body(body)
21 | headers[ETAG_STRING] = %(W/"#{digest}") if digest
22 | end
23 |
24 | unless headers[CACHE_CONTROL_STRING]?
25 | if digest
26 | headers[CACHE_CONTROL_STRING] = @cache_control if @cache_control
27 | else
28 | headers[CACHE_CONTROL_STRING] = @no_cache_control.not_nil! if @no_cache_control
29 | end
30 | end
31 |
32 | {status_code, headers, body}
33 | end
34 |
35 | private def digest_body(body)
36 | parts = [] of String
37 | digest = nil
38 |
39 | body.each do |part|
40 | parts << part
41 | (digest ||= OpenSSL::Digest.new("SHA256")).update(part) unless part.empty?
42 | end
43 |
44 | [digest && digest.hexdigest.byte_slice(0, 32), parts]
45 | end
46 |
47 | private def etag_status?
48 | [200, 201].includes?(@status_code)
49 | end
50 |
51 | private def skip_caching?
52 | @headers[CACHE_CONTROL_STRING]?.to_s.includes?("no-cache") ||
53 | @headers.has_key?(ETAG_STRING) || @headers.has_key?("Last-Modified")
54 | end
55 | end
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/src/salt/middlewares/file.cr:
--------------------------------------------------------------------------------
1 | module Salt
2 | alias File = Middlewares::File
3 |
4 | module Middlewares
5 | # `Salt::File` serves files below the **root** directory given, according to the
6 | # path info of the Salt request.
7 | #
8 | # ### Examples
9 | #
10 | # ```
11 | # # you can access 'passwd' file as http://localhost:9898/passwd
12 | # run Salt::File.new(root: "/etc")
13 | # ```
14 | #
15 | # Handlers can detect if bodies are a Salt::File, and use mechanisms
16 | # like sendfile on the **path**.
17 | class File < App
18 | ALLOWED_VERBS = %w[GET HEAD OPTIONS]
19 | ALLOW_HEADER = ALLOWED_VERBS.join(", ")
20 |
21 | def initialize(@app : App? = nil, root : String = ".",
22 | @headers = {} of String => String, @default_mime = "text/plain")
23 | @root = ::File.expand_path(root)
24 | end
25 |
26 | def call(env)
27 | get(env)
28 | end
29 |
30 | def call(env, status_code : Int32)
31 | get(env, status_code)
32 | end
33 |
34 | private def get(env, status_code = 200)
35 | unless ALLOWED_VERBS.includes?(env.method)
36 | return fail(405, "Method Not Allowed", {"Allow" => ALLOW_HEADER})
37 | end
38 |
39 | path_info = URI.unescape(env.path)
40 | return fail(400, "Bad Request") unless path_info.valid_encoding?
41 |
42 | path = ::File.join(@root, path_info)
43 | available = begin
44 | ::File.file?(path) && ::File.readable?(path)
45 | rescue Errno
46 | false
47 | end
48 |
49 | if available
50 | serving(env, path, status_code)
51 | else
52 | fail(404, "File not found: #{path_info}")
53 | end
54 | end
55 |
56 | private def serving(env, path, status_code)
57 | if env.options?
58 | return {200, {"Allow" => ALLOW_HEADER, "Content-length" => "0"}, [] of String}
59 | end
60 |
61 | last_modified = HTTP.format_time(::File.info(path).modification_time)
62 | return {304, {} of String => String, [] of String} if env.headers["HTTP_IF_MODIFIED_SINCE"]? == last_modified
63 |
64 | headers = {
65 | "Last-Modified" => last_modified,
66 | "Content-Length" => ::File.size(path).to_s,
67 | }.merge(@headers)
68 | headers["Content-Type"] = "#{mime_type(path)}; charset=utf-8"
69 |
70 | body = String.build do |io|
71 | ::File.open(path) do |file|
72 | IO.copy(file, io)
73 | end
74 | end
75 |
76 | {status_code, headers, [body.to_s]}
77 | end
78 |
79 | private def fail(code : Int32, body : String, headers = {} of String => String)
80 | {
81 | code,
82 | {
83 | "Content-Type" => "text/plain; charset=utf-8",
84 | "Content-Length" => body.bytesize.to_s,
85 | "X-Cascade" => "pass",
86 | }.merge(headers),
87 | [body],
88 | }
89 | end
90 |
91 | private def mime_type(path : String)
92 | Mime.lookup(path, @default_mime)
93 | end
94 | end
95 | end
96 | end
97 |
--------------------------------------------------------------------------------
/src/salt/middlewares/head.cr:
--------------------------------------------------------------------------------
1 | module Salt
2 | alias Head = Middlewares::Head
3 |
4 | module Middlewares
5 | # `Salt::Head` returns an empty body for all HEAD requests.
6 | # It leaves all other requests unchanged.
7 | class Head < App
8 | def call(env)
9 | call_app(env)
10 |
11 | {status_code, headers, (env.method == "HEAD") ? [] of String : body}
12 | end
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/src/salt/middlewares/logger.cr:
--------------------------------------------------------------------------------
1 | module Salt
2 | alias Logger = Middlewares::Logger
3 |
4 | module Middlewares
5 | # Sets up env.logger to write to STDOUT stream
6 | #
7 | # ### Example
8 | #
9 | # ```
10 | # Salt.use Salt::Middlewares::Logger, io: File.open("development.log", "w"), level: Logger::ERROR
11 | # Salt.use Salt::Middlewares::Logger, level: Logger::ERROR
12 | # ```
13 | class Logger < App
14 | def initialize(@app : App, @io : IO = STDOUT, @progname = "salt",
15 | @level : ::Logger::Severity = ::Logger::INFO,
16 | @formatter : ::Logger::Formatter? = nil)
17 | end
18 |
19 | def call(env)
20 | logger = ::Logger.new(@io)
21 | logger.level = @level
22 | logger.progname = @progname
23 | logger.formatter = @formatter.not_nil! if @formatter
24 |
25 | env.logger = logger
26 | call_app(env)
27 | end
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/src/salt/middlewares/router.cr:
--------------------------------------------------------------------------------
1 | require "radix"
2 |
3 | module Salt
4 | alias Router = Middlewares::Router
5 |
6 | module Middlewares
7 | # `Salt::Router` is a lightweight HTTP Router.
8 | #
9 | # ### Example
10 | #
11 | # It Dependency [radix](https://github.com/luislavena/radix), you need add to `shard.yml` and install first.
12 | #
13 | # #### Graceful way
14 | #
15 | # ```
16 | # class Dashboard < Salt::App
17 | # def call(env)
18 | # {200, {"Content-Type" => "text/plain"}, ["dashboard"] }
19 | # end
20 | # end
21 | #
22 | # Salt.run Salt::Router.new do |r|
23 | # r.get "/dashboard", to: Dashboard.new
24 | # r.get "/users/:id" do |env|
25 | # {200, {"Content-Type" => "text/plain"}, ["hello user #{env.params["id"]}"]}
26 | # end
27 | #
28 | # r.post "/post", to: -> (env : Salt::Environment) { {200, {"Content-Type" => "text/plain"}, ["post success"] }
29 | #
30 | # r.redirect "/home", to: "/dashboard", code: 302
31 | #
32 | # r.not_found
33 | # # Or custom not found page, also it accepts Proc and Salt::App as argument.
34 | # r.not_found do |env|
35 | # {404, {"Content-Type" => "application/json"}. [{"message" => "404 not found"}.to_json]}
36 | # end
37 | # end
38 | # ```
39 | #
40 | # #### Strict way
41 | #
42 | # ```
43 | # Salt.run Salt::Router, rules: ->(r : Salt::Router::Drawer) {
44 | # r.get "/hello" do |env|
45 | # {200, {"Content-Type" => "text/plain"}, ["hello"]}
46 | # end
47 | #
48 | # # Must return nil as Proc argument.
49 | # nil
50 | # }
51 | # ```
52 | class Router < App
53 | alias Action = Environment -> Response
54 |
55 | def self.new(app : App? = nil, &rules : Drawer ->)
56 | new(app, rules: rules)
57 | end
58 |
59 | def initialize(@app : App? = nil, @rules : Proc(Drawer, Nil)? = nil)
60 | end
61 |
62 | def call(env)
63 | if response = draw(env)
64 | return response
65 | end
66 |
67 | call_app(env)
68 | end
69 |
70 | private def draw(env)
71 | return unless rules = @rules
72 |
73 | drawer = Drawer.new(env)
74 | rules.call(drawer)
75 |
76 | if response = drawer.find(env.method, env.path)
77 | response
78 | else
79 | drawer.try_not_found
80 | end
81 | end
82 |
83 | class Drawer
84 | METHODS = %w(GET POST PUT DELETE PATCH HEAD OPTIONS)
85 |
86 | @tree = Radix::Tree(Action).new # [] of Node
87 |
88 | def initialize(@env : Environment)
89 | end
90 |
91 | def find(method : String, path : String)
92 | r = @tree.find("/#{method}#{path}")
93 |
94 | if r.found?
95 | response = r.payload
96 | r.params.each do |key, value|
97 | @env.params.add(key, value)
98 | end
99 |
100 | response.call(@env)
101 | end
102 | end
103 |
104 | {% for name in METHODS %}
105 | {% method = name.id.downcase %}
106 |
107 | # Define {{ name.id }} route with block
108 | #
109 | # ```
110 | # Salt::Router.new do |draw|
111 | # draw.{{ method }} "/{{ method }}" do |env|
112 | # {200, { "Content-Type" => "text/plain"} , [] of String}
113 | # end
114 | # end
115 | # ```
116 | def {{ method }}(path : String, &block : Action)
117 | {{ method }}(path, to: block)
118 | end
119 |
120 | # Define {{ name.id }} route with `Proc(Salt::Environment, Salt::App::Response)` or `Salt::App`
121 | #
122 | # ```
123 | # Salt::Router.new do |draw|
124 | # # Proc
125 | # draw.{{ method }} "/{{ method }}", to: -> (env : Salt::Environment) {
126 | # {200, { "Content-Type" => "text/plain"} , [] of String}
127 | # }
128 | #
129 | # # Or use Salt::App
130 | # draw.{{ method }} "/{{ method }}", to: {{ method.id.capitalize }}App.new
131 | # end
132 | # ```
133 | def {{ method }}(path : String, to block : Action | Salt::App | App::Response)
134 | @tree.add(radix_path({{ name.id.upcase.stringify }}, path), action_with(block))
135 | end
136 | {% end %}
137 |
138 | # Define redirect route
139 | #
140 | # By defaults is `302`
141 | #
142 | # ```
143 | # Salt::Router.new do |draw|
144 | # draw.redirect "/source", to: "/destination", status_code: 301
145 | # # Or simple way as you like
146 | # draw.redirect "/source", "/destination", 301
147 | # end
148 | # ```
149 | def redirect(from : String, to : String, status_code = 302)
150 | method = "GET"
151 | if find(method, to)
152 | response = {status_code, {"Location" => to}, [] of String}.as(App::Response)
153 | @tree.add(radix_path(method, from), action_with(response))
154 | end
155 | end
156 |
157 | # Support a default not found page
158 | #
159 | # ```
160 | # Salt::Router.new do |draw|
161 | # # returns not found page directly
162 | # draw.not_found
163 | # end
164 | # ```
165 | def not_found
166 | not_found do |env|
167 | body = "Not found"
168 | {
169 | 404,
170 | {
171 | "Content-Type" => "text/plain",
172 | "Content-Length" => body.bytesize.to_s,
173 | },
174 | [body],
175 | }
176 | end
177 | end
178 |
179 | # Define a custom not found page with block
180 | #
181 | # ```
182 | # Salt::Router.new do |draw|
183 | # draw.not_found do |env|
184 | # {200, {"Content-Type" => "text/plain"}, [] of String}
185 | # end
186 | # end
187 | # ```
188 | def not_found(&block : Action)
189 | not_found(to: block)
190 | end
191 |
192 | # Define a custom not found page witb Proc or `Salt::App`
193 | #
194 | # ```
195 | # Salt::Router.new do |draw|
196 | # # Proc
197 | # draw.{{ method }} "/{{ method }}", to: -> (env : Salt::Environment) {
198 | # {200, { "Content-Type" => "text/plain"} , [] of String}
199 | # }
200 | #
201 | # # Or use Salt::App
202 | # draw.{{ method }} "/{{ method }}", to: NotFoundApp.new
203 | # end
204 | # ```
205 | def not_found(to block : Action | Salt::App | App::Response)
206 | @tree.add(radix_path("ANY", "/404"), action_with(block))
207 | end
208 |
209 | def try_not_found
210 | find("ANY", "/404")
211 | end
212 |
213 | private def action_with(response : Action | Salt::App | App::Response) : Action
214 | case response
215 | when Action
216 | response.as(Action)
217 | when Salt::App
218 | ->(env : Environment) { response.as(Salt::App).call(env).as(App::Response) }
219 | when App::Response
220 | ->(env : Environment) { response.as(App::Response) }
221 | else
222 | raise "Not match response. it must be Action, Salt::App or App::Response"
223 | end
224 | end
225 |
226 | private def radix_path(method, path)
227 | "/#{method.upcase}#{path}"
228 | end
229 | end
230 | end
231 |
232 | # Hacks accepts block to run `Salt::Router` middleware
233 | def self.use(klass, **options, &block : Router::Drawer ->)
234 | proc = ->(app : App) { klass.new(app, **options, &block).as(App) }
235 | @@middlewares << proc
236 | end
237 | end
238 |
239 | # Hacks accepts block to run `Salt::Router` middleware
240 | def self.use(middleware, **options, &block : Router::Drawer ->)
241 | Middlewares.use(middleware, **options, &block)
242 | end
243 |
244 | # Hacks accepts block to run `Salt::Router` middleware
245 | def self.run(app : Salt::App, **options, &block : Router::Drawer ->)
246 | Salt::Server.new(**options).run(app)
247 | end
248 | end
249 |
--------------------------------------------------------------------------------
/src/salt/middlewares/runtime.cr:
--------------------------------------------------------------------------------
1 | module Salt
2 | alias Runtime = Middlewares::Runtime
3 |
4 | module Middlewares
5 | # Sets an "X-Runtime" response header, indicating the response
6 | # time of the request, in seconds
7 | #
8 | # ### Example
9 | #
10 | # ```
11 | # Salt.use Salt::Middlewares::Runtime, name: "Salt"
12 | # ```
13 | class Runtime < App
14 | HEADER_NAME = "X-Runtime"
15 |
16 | @header_name : String
17 |
18 | def initialize(@app : App, name : String? = nil)
19 | @header_name = header_for(name)
20 | end
21 |
22 | def call(env)
23 | elapsed = elapsed do
24 | call_app(env)
25 | end
26 |
27 | unless headers.has_key?(@header_name)
28 | headers[@header_name] = elapsed
29 | end
30 |
31 | {status_code, headers, body}
32 | end
33 |
34 | private def elapsed(&block)
35 | start_time = Time.now
36 | block.call
37 | elapsed = Time.now - start_time
38 | elapsed.to_f.round(6).to_s
39 | end
40 |
41 | private def header_for(name)
42 | if name.to_s.empty?
43 | HEADER_NAME
44 | else
45 | "#{HEADER_NAME}-#{name}"
46 | end
47 | end
48 | end
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/src/salt/middlewares/session.cr:
--------------------------------------------------------------------------------
1 | module Salt
2 | alias Session = Middlewares::Session
3 | end
4 |
--------------------------------------------------------------------------------
/src/salt/middlewares/session/abstract/persisted.cr:
--------------------------------------------------------------------------------
1 | require "random/secure"
2 |
3 | module Salt::Middlewares::Session::Abstract
4 | abstract class Persisted < App
5 | SESSION_KEY = "salt.session"
6 | SESSION_ID = "session_id"
7 |
8 | DEFAULT_OPTIONS = {
9 | :key => SESSION_KEY,
10 | :path => "/",
11 | :domain => nil,
12 | :expire_after => nil,
13 | :secure => false,
14 | :http_only => true,
15 | :defer => false,
16 | :renew => false,
17 | :cookie_only => true,
18 | :session_id_bits => 128,
19 | }
20 |
21 | @options : Hash(Symbol, String | Bool | Int32 | Nil)
22 |
23 | def initialize(@app : App, **options)
24 | @options = merge_options(DEFAULT_OPTIONS, **options)
25 |
26 | @key = @options[:key].as(String)
27 | @session_id_bits = @options[:session_id_bits].as(Int32)
28 | @session_id_length = (@session_id_bits / 4).as(Int32)
29 | end
30 |
31 | def call(env)
32 | prepare_session(env)
33 | call_app(env)
34 | commit_session(env)
35 |
36 | {status_code, headers, body}
37 | end
38 |
39 | def commit_session(env)
40 | session = env.session
41 | options = session.options
42 |
43 | if options[:drop]? || options[:renew]?
44 | session_id = delete_session(env, session.id || generate_session_id)
45 | return unless session_id
46 | end
47 |
48 | session.load! unless loaded_session?(session)
49 |
50 | session_id = session.id.not_nil!
51 | session_data = session.to_h.delete_if { |_, v| v.nil? }
52 |
53 | if data = write_session(env, session_id, session_data)
54 | http_only = options[:http_only].as(Bool)
55 | secure = options[:secure].as(Bool)
56 | cookie = HTTP::Cookie.new(@key, data, expires: expires, http_only: http_only, secure: secure)
57 | set_cookie(env, cookie)
58 | else
59 | puts "Warning! #{self.class.name} failed to save session. Content dropped."
60 | end
61 | end
62 |
63 | def loaded_session?(session)
64 | !session.is_a?(SessionHash) || session.loaded?
65 | end
66 |
67 | def set_cookie(env, cookie)
68 | if env.cookies[@key]? != cookie.value || cookie.expires
69 | env.cookies.add(cookie)
70 | return true
71 | end
72 |
73 | false
74 | end
75 |
76 | def load_session(env : Environment)
77 | session_id = current_session_id(env)
78 | find_session(env, session_id)
79 | end
80 |
81 | def extract_session_id(env)
82 | session_id = env.cookies[@key]?.try(&.value)
83 | session_id ||= env.params[@key]? unless @options[:cookie_only].as(Bool)
84 | session_id
85 | end
86 |
87 | def session_exists?(env : Environment) : Bool
88 | !current_session_id(env).nil?
89 | end
90 |
91 | abstract def find_session(env : Environment, session_id : String?) : SessionStored
92 | abstract def write_session(env : Environment, session_id : String, session : Hash(String, String)) : String?
93 | abstract def delete_session(env : Environment, session_id : String) : String?
94 |
95 | private def prepare_session(env : Environment)
96 | session_was = env.session? ? env.session : nil
97 | session = SessionHash.new(self, env)
98 | session.options = @options.dup
99 | session.merge!(session_was) if session_was
100 | env.session = session
101 | end
102 |
103 | private def generate_session_id
104 | Random::Secure.hex(@session_id_length)
105 | end
106 |
107 | private def current_session_id(env : Environment) : String?
108 | env.session.id
109 | end
110 |
111 | private def merge_options(defaults, **options)
112 | options.each do |key, value|
113 | defaults[key] = value if defaults.has_key?(key)
114 | end
115 |
116 | defaults
117 | end
118 |
119 | private def expires : Time?
120 | if expire_after = @options[:expire_after]?
121 | return Time.now + expire_after.as(Int32).seconds
122 | elsif max_age = @options[:max_age]?
123 | return Time.now + max_age.as(Int32).seconds
124 | end
125 |
126 | nil
127 | end
128 | end
129 | end
130 |
--------------------------------------------------------------------------------
/src/salt/middlewares/session/abstract/session_hash.cr:
--------------------------------------------------------------------------------
1 | module Salt::Middlewares::Session::Abstract
2 | # SessionHash is responsible to lazily load the session from store.
3 | class SessionHash
4 | include Enumerable(SessionHash)
5 |
6 | property options : Hash(Symbol, Bool | Int32 | String | Nil)
7 |
8 | @exists : Bool?
9 | @id : String?
10 | @data : Hash(String, String)
11 |
12 | def initialize(@store : Persisted, @env : Environment, @loaded = false)
13 | @exists = nil
14 | @options = Hash(Symbol, Bool | Int32 | String | Nil).new
15 |
16 | @id = nil
17 | @data = Hash(String, String).new
18 | end
19 |
20 | def []=(key : String, value : String)
21 | set(key, value)
22 | end
23 |
24 | def set(key : String, value : String)
25 | load_for_write!
26 | @data[key] = value
27 | end
28 |
29 | def [](key : String)
30 | get(key)
31 | end
32 |
33 | def get(key : String)
34 | load_for_read!
35 | @data[key]
36 | end
37 |
38 | def []?(key : String)
39 | get?(key)
40 | end
41 |
42 | def get?(key : String)
43 | load_for_read!
44 | @data[key]?
45 | end
46 |
47 | def has_key?(key : String)
48 | load_for_read!
49 | @data.has_key?(key)
50 | end
51 |
52 | def fetch(key : String, default = nil)
53 | @data.fetch(key, default)
54 | end
55 |
56 | def delete(key : String)
57 | @data.delete(key)
58 | end
59 |
60 | def to_h
61 | load_for_read!
62 | @data.dup
63 | end
64 |
65 | def merge!(hash : SessionHash)
66 | load_for_write!
67 | @data.merge(stringify_keys(hash))
68 | end
69 |
70 | def each
71 | load_for_read!
72 | @data.each do |key, value|
73 | yield({key, value})
74 | end
75 | end
76 |
77 | def delete(key : String)
78 | load_for_write!
79 | @data.delete(key)
80 | end
81 |
82 | def clear
83 | load_for_write!
84 | @data.clear
85 | end
86 |
87 | def destroy
88 | clear
89 | @id = @store.delete_session(@env, id)
90 | end
91 |
92 | def id
93 | return @id if @loaded || !@id.nil?
94 | @id = @store.extract_session_id(@env)
95 | end
96 |
97 | def exists?
98 | return @exists if !@exists.nil?
99 |
100 | @exists ||= @store.session_exists?(@env)
101 | @exists.not_nil!
102 | end
103 |
104 | def loaded?
105 | @loaded
106 | end
107 |
108 | def empty?
109 | load_for_read!
110 | @data.empty?
111 | end
112 |
113 | def keys
114 | load_for_read!
115 | @data.keys
116 | end
117 |
118 | def values
119 | load_for_read!
120 | @data.values
121 | end
122 |
123 | def load!
124 | stored = @store.load_session(@env)
125 |
126 | @id = stored.session_id
127 | @data = stored.data
128 | @loaded = true
129 | end
130 |
131 | private def load_for_read!
132 | load! if !loaded? && exists?
133 | end
134 |
135 | private def load_for_write!
136 | load! unless loaded?
137 | end
138 |
139 | private def stringify_keys(session : SessionHash)
140 | data = Hash(String, String).new
141 | session.each do |key, value|
142 | data[key.to_s] = value
143 | end
144 | data
145 | end
146 | end
147 |
148 | struct SessionStored
149 | property session_id, data
150 |
151 | def initialize(@session_id : String, @data : Hash(String, String))
152 | end
153 | end
154 | end
155 |
--------------------------------------------------------------------------------
/src/salt/middlewares/session/cookie.cr:
--------------------------------------------------------------------------------
1 | require "./abstract/*"
2 | require "../session"
3 |
4 | require "openssl/hmac"
5 | require "base64"
6 | require "json"
7 | require "zlib"
8 |
9 | module Salt::Middlewares::Session
10 | # `Salt::Session::Cookie` provides simple cookie based session management.
11 | #
12 | # By default, the session is a Crystal Hash stored as base64 encoded marshalled
13 | # data set to `key` (default: `salt.session`). The object that encodes the
14 | # session data is configurable and must respond to **encode** and **decode**.
15 | # Both methods must take a string and return a string.
16 | #
17 | # When the secret key is set, cookie data is checked for data integrity.
18 | # The old secret key is also accepted and allows graceful secret rotation.
19 | #
20 | # ### Examples
21 | #
22 | # ```
23 | # use Salt::Session::Cookie, key: "salt.session",
24 | # domain: "foobar.com",
25 | # expire_after: 2592000,
26 | # secret: "change_me",
27 | # old_secret: "change_me"
28 | # ```
29 | #
30 | # All parameters are optional.
31 | class Cookie < Abstract::Persisted
32 | abstract class Base64
33 | abstract def encode(data)
34 | abstract def encode(data)
35 |
36 | protected def encode_str(data : String)
37 | ::Base64.encode(data)
38 | end
39 |
40 | protected def decode_str(data : String)
41 | ::Base64.decode_string(data)
42 | end
43 |
44 | protected def stringify_hash(data)
45 | hash = Hash(String, String).new
46 | data.as_h.each do |key, value|
47 | hash[key.to_s] = value.to_s
48 | end
49 | hash
50 | end
51 |
52 | class JSON < Base64
53 | def encode(data)
54 | encode_str(data.to_json)
55 | end
56 |
57 | def decode(data)
58 | stringify_hash(::JSON.parse(decode_str(data)))
59 | end
60 | end
61 |
62 | class ZipJSON < Base64
63 | def encode(data)
64 | io = IO::Memory.new
65 |
66 | writer = Zlib::Writer.new(io)
67 | writer.print(data.to_json)
68 | writer.close
69 |
70 | encode_str(io.to_s)
71 | end
72 |
73 | def decode(data)
74 | io = IO::Memory.new(decode_str(data))
75 |
76 | reader = Zlib::Reader.new(io)
77 | raw = String::Builder.build do |builder|
78 | IO.copy(reader, builder)
79 | end
80 | reader.close
81 |
82 | stringify_hash(::JSON.parse(raw))
83 | end
84 | end
85 | end
86 |
87 | class Identity
88 | def encode(str)
89 | str
90 | end
91 |
92 | def decode(str)
93 | str
94 | end
95 | end
96 |
97 | getter coder : Base64
98 |
99 | @secrets : Array(String)
100 | @hmac : Symbol
101 |
102 | def initialize(@app : App, **options)
103 | @secrets = compact_secrets(**options)
104 | @hmac = options.fetch(:hmac, :sha1).as(Symbol)
105 | @coder = options.fetch(:coder, Base64::ZipJSON.new).as(Base64)
106 |
107 | unless secure?(options)
108 | puts <<-MSG
109 | SECURITY WARNING: No secret option provided to Salt::Middlewares::Session::Cookie.
110 | This poses a security threat. It is strongly recommended that you
111 | provide a secret to prevent exploits that may be possible from crafted
112 | cookies. This will not be supported in future versions of Salt, and
113 | future versions will even invalidate your existing user cookies.
114 |
115 | Called from: #{caller[0]}.
116 | MSG
117 | end
118 |
119 | super(@app, **options)
120 | end
121 |
122 | def find_session(env : Environment, session_id : String?)
123 | data = unpacked_cookie_data(env)
124 | stored = persistent_session_id!(data)
125 | stored.session_id = stored.data["session_id"].as(String)
126 | stored
127 | end
128 |
129 | def write_session(env : Environment, session_id : String, session : Hash(String, String))
130 | session = session.merge({SESSION_ID => session_id})
131 | session_data = @coder.encode(session)
132 | digest = generate_hmac(@secrets.first, session_data)
133 |
134 | session_data = "#{session_data}--#{digest}" if @secrets.first
135 | if session_data.size > (4096 - @key.size)
136 | puts "Warning! Salt::Middlewares::Session::Cookie data size exceeds 4K."
137 | nil
138 | else
139 | session_data
140 | end
141 | end
142 |
143 | def delete_session(env : Environment, session_id : String)
144 | # Nothing to do here, data is in the client
145 | generate_session_id if env.session.options[:drop]?
146 | end
147 |
148 | def extract_session_id(env)
149 | unpacked_cookie_data(env)["session_id"]?
150 | end
151 |
152 | def unpacked_cookie_data(env)
153 | if (session_cookie = env.cookies[@key]?) && !session_cookie.value.empty?
154 | encrypted_data = session_cookie.value
155 | digest, session_data = encrypted_data.reverse.split("--", 2)
156 | digest = digest.reverse if digest
157 | session_data = session_data.reverse if session_data
158 |
159 | return coder.decode(session_data) if digest_match?(session_data, digest)
160 | end
161 |
162 | Hash(String, String).new
163 | end
164 |
165 | def persistent_session_id!(data : Hash(String, String), session_id : String? = nil)
166 | session_id ||= generate_session_id
167 | data[SESSION_ID] = session_id unless data.has_key?(SESSION_ID)
168 |
169 | Abstract::SessionStored.new(session_id, data)
170 | end
171 |
172 | private def generate_hmac(key : String, data : String)
173 | OpenSSL::HMAC.hexdigest(@hmac, key, data)
174 | end
175 |
176 | private def secure?(options)
177 | @secrets.size >= 1 || (options[:coder]? && options[:let_coder_handle_secure_encoding]?)
178 | end
179 |
180 | private def digest_match?(data, digest)
181 | return false unless data && digest
182 |
183 | @secrets.any? do |secret|
184 | target = generate_hmac(secret, data)
185 | return false unless digest.bytesize == target.bytesize
186 |
187 | l = digest.bytes
188 | r, i = 0, -1
189 | target.each_byte { |v| r |= v ^ l[i += 1] }
190 | r == 0
191 | end
192 | end
193 |
194 | private def compact_secrets(**options)
195 | Array(String).new.tap do |arr|
196 | if secret = options[:secret]?
197 | arr << secret
198 | end
199 |
200 | if old_secret = options[:old_secret]?
201 | arr << old_secret
202 | end
203 | end
204 | end
205 | end
206 | end
207 |
--------------------------------------------------------------------------------
/src/salt/middlewares/session/redis.cr:
--------------------------------------------------------------------------------
1 | require "./abstract/*"
2 | require "../session"
3 |
4 | require "redis"
5 | require "json"
6 |
7 | module Salt::Middlewares::Session
8 | # `Salt::Session::Redis` provides simple cookie based session management.
9 | # Session data was stored in redis. The corresponding session key is
10 | # maintained in the cookie.
11 | #
12 | # Setting :expire_after to 0 would note to the Memcache server to hang
13 | # onto the session data until it would drop it according to it's own
14 | # specifications. However, the cookie sent to the client would expire
15 | # immediately.
16 | #
17 | # **Note that** redis does drop data before it may be listed to expire. For
18 | # a full description of behaviour, please see redis's documentation.
19 | #
20 | # ### Examples
21 | #
22 | # It Dependency [crystal-redis](https://github.com/stefanwille/crystal-redis), you need add to `shard.yml` and install first.
23 | #
24 | # ```
25 | # use Salt::Session::Redis, server: "redis://localhost:6379/0",
26 | # namspace: "salt:session"
27 | # ```
28 | #
29 | # All parameters are optional.
30 | class Redis < Abstract::Persisted
31 | DEFAULT_OPTIONS = Abstract::Persisted::DEFAULT_OPTIONS.to_h.merge({
32 | :server => "redis://localhost:6379/0",
33 | :namespace => "salt:session",
34 | })
35 |
36 | def initialize(@app : App, **options)
37 | super
38 |
39 | @options = merge_options(DEFAULT_OPTIONS, **options)
40 | @pool = ::Redis.new(url: @options[:server].as(String))
41 | end
42 |
43 | def find_session(env : Environment, session_id : String?)
44 | unless session_id && (session_data = get_key(session_id))
45 | session_id = generate_session_id
46 | session_data = Hash(String, String).new
47 | unless set_key(session_id, session_data)
48 | raise "Session collision on '#{session_id.inspect}'"
49 | end
50 | end
51 |
52 | Abstract::SessionStored.new(session_id, session_data)
53 | end
54 |
55 | def write_session(env : Environment, session_id : String, session : Hash(String, String))
56 | set_key(session_id, session, expiry)
57 | session_id
58 | end
59 |
60 | def delete_session(env : Environment, session_id : String)
61 | del_key(session_id)
62 | generate_session_id unless @options[:drop]
63 | end
64 |
65 | def finalize
66 | @pool.close
67 | end
68 |
69 | private def generate_session_id
70 | loop do
71 | session_id = super
72 | break session_id if get_key(session_id).empty?
73 | end
74 | end
75 |
76 | private def expiry : Int32
77 | if expiry = @options[:expire_after]
78 | return expiry.as(Int32) + 1
79 | end
80 |
81 | 0
82 | end
83 |
84 | private def get_key(key : String) : Hash(String, String)
85 | hash = Hash(String, String).new
86 | @pool.hgetall(namespace_to(key)).each_slice(2) do |array|
87 | hash[array.first.as(String)] = array.last.as(String)
88 | end
89 | hash
90 | end
91 |
92 | private def del_key(key : String)
93 | @pool.del(namespace_to(key))
94 | end
95 |
96 | private def set_key(key : String, hash : Hash(String, _), expiry = 0)
97 | hash.each do |field, value|
98 | @pool.hset(namespace_to(key), field, value.to_s)
99 | end
100 | set_key_expire(key, expiry) if expiry > 0
101 |
102 | true
103 | end
104 |
105 | private def set_key(key : String, json : JSON::Any, expiry = 0)
106 | json.each do |field, value|
107 | @pool.hset(namespace_to(key), field, value.as_s)
108 | end
109 | set_key_expire(key, expiry) if expiry > 0
110 |
111 | true
112 | end
113 |
114 | private def set_key_expire(key : String, expiry : Int32)
115 | @pool.expire(namespace_to(key), expiry)
116 | end
117 |
118 | private def namespace_to(key : String)
119 | "#{@options[:namespace]}:#{key}"
120 | end
121 | end
122 | end
123 |
--------------------------------------------------------------------------------
/src/salt/middlewares/show_exceptions.cr:
--------------------------------------------------------------------------------
1 | require "ecr/macros"
2 |
3 | module Salt
4 | alias ShowExceptions = Middlewares::ShowExceptions
5 |
6 | module Middlewares
7 | # `Salt::ShowExceptions` catches all exceptions raised from the app it wraps.
8 | # It shows a useful backtrace with the sourcefile and
9 | # clickable context, the whole Salt environment and the request
10 | # data.
11 | #
12 | # **Be careful** when you use this on public-facing sites as it could
13 | # reveal information helpful to attackers.
14 | class ShowExceptions < App
15 | def call(env)
16 | call_app(env)
17 |
18 | [status_code, headers, body]
19 | rescue e : Exception
20 | puts dump_exception(e)
21 |
22 | body = pretty_body(env, e)
23 | {
24 | 500,
25 | {
26 | "Content-Type" => "text/html",
27 | "Content-Length" => body.bytesize.to_s,
28 | },
29 | [body],
30 | }
31 | end
32 |
33 | private def dump_exception(exception)
34 | String.build do |io|
35 | io << "#{exception.class}: #{exception.message}\n"
36 | io << exception.backtrace.map { |l| " #{l}" }.join("\n")
37 | end.to_s
38 | end
39 |
40 | private def pretty_body(env, exception) : String
41 | io = IO::Memory.new
42 | ECR.embed "#{__DIR__}/views/show_exceptions/layout.ecr", io
43 | io.to_s
44 | end
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/src/salt/middlewares/static.cr:
--------------------------------------------------------------------------------
1 | require "./file"
2 |
3 | module Salt
4 | alias Static = Middlewares::Static
5 |
6 | module Middlewares
7 | # `Salt::Static` middleware intercepts requests for static files
8 | # (javascript files, images, stylesheets, etc) based on the url prefixes or
9 | # route mappings passed in the options, and serves them using a `Salt::File`
10 | # object. This allows a Rack stack to serve both static and dynamic content.
11 | #
12 | # ### Examples
13 | #
14 | # #### Quick Start
15 | #
16 | # It will search index file with "index.html" and "index.htm"
17 | #
18 | # ```
19 | # Salt.run Salt::Static.new(root: "/var/www/html")
20 | # ```
21 | #
22 | # #### Custom index file
23 | #
24 | # ```
25 | # Salt.run Salt::Static.new(root: "/var/www/html", index: "index.txt")
26 | # ```
27 | #
28 | # #### Set 404 page
29 | #
30 | # ```
31 | # Salt.run Salt::Static.new(root: "/var/www/html", not_found: "404.html")
32 | # ```
33 | class Static < App
34 | # Default index page files
35 | INDEX_FILES = ["index.html", "index.htm"]
36 |
37 | def initialize(app : App? = nil, root : String = ".",
38 | @index : String? = nil, @not_found : String? = nil)
39 | @root = ::File.expand_path(root)
40 | @file_server = Salt::File.new(root: @root)
41 | end
42 |
43 | def call(env)
44 | if index_file = search_index_file(env.path)
45 | env.path = ::File.join(env.path, index_file)
46 | end
47 |
48 | if !::File.exists?(real_path(env.path)) && (not_found_file = @not_found)
49 | env.path = not_found_file
50 | @file_server.call(env, 404)
51 | else
52 | @file_server.call(env)
53 | end
54 | end
55 |
56 | private def search_index_file(path)
57 | # NOTE: fix crystal bug when File.join("abc", "/")
58 | # Removed if merged this PR: https://github.com/crystal-lang/crystal/pull/5915
59 | path = "" if path == "/"
60 |
61 | # Return given index file if exists
62 | if (index = @index) && ::File.exists?(real_path(path, index))
63 | return index
64 | end
65 |
66 | # Matching default index file
67 | INDEX_FILES.each do |name|
68 | file = real_path(path, name)
69 | return name if ::File.exists?(file)
70 | end
71 | end
72 |
73 | private def real_path(*files)
74 | ::File.join({@root} + files)
75 | end
76 | end
77 | end
78 | end
79 |
--------------------------------------------------------------------------------
/src/salt/middlewares/views/directory/layout.ecr:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | <%= root %>
5 |
6 |
7 |
124 |
125 |
126 | <%= root %>
127 |
128 | <% files.each do |file| %>
129 | class="parent"<% end %>>
130 | <%= file[0] %>
131 | <%= file[1] %>
132 | <%= file[3] %>
133 | <%= file[4] %>
134 |
135 | <% end %>
136 |
137 |
138 |
139 |
--------------------------------------------------------------------------------
/src/salt/middlewares/views/show_exceptions/layout.ecr:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | <%= exception.class.name %> at <%= env.path %>
7 |
54 |
55 |
56 |
57 |
58 |
<%= exception.class %> at <%= env.path %>
59 |
<%= exception.message %>
60 |
61 |
62 | Crystal
63 |
64 | <% if first = exception.backtrace.first %>
65 | <%= first %>
66 | <% else %>
67 | unknown location
68 | <% end %>
69 |
70 |
71 |
72 | Web
73 | <%= env.method %> <%= "#{env.host_with_port}#{env.path}" %>
74 |
75 |
76 |
77 |
Jump to:
78 |
86 |
87 |
88 |
89 |
Traceback (outmost first)
90 |
91 | <% exception.backtrace.each do |line| %>
92 | <%= line %>
93 | <% end %>
94 |
95 |
96 |
97 |
98 |
Request information
99 |
100 |
GET
101 | <% if !env.query_params.empty? %>
102 |
103 |
104 |
105 | Variable
106 | Value
107 |
108 |
109 |
110 | <% env.query_params.each { |key, value| %>
111 |
112 | <%= key %>
113 | <%= value.to_s %>
114 |
115 | <% } %>
116 |
117 |
118 | <% else %>
119 |
No GET data.
120 | <% end %>
121 |
122 | <% if env.method =="POST" %>
123 |
POST
124 | <% if !env.params.empty? %>
125 |
126 |
127 |
128 | Variable
129 | Value
130 |
131 |
132 |
133 | <% env.params.each { |key, value| %>
134 |
135 | <%= key %>
136 | <%= value.to_s %>
137 |
138 | <% } %>
139 |
140 |
141 | <% else %>
142 |
No POST data.
143 | <% end %>
144 | <% end %>
145 |
146 |
COOKIES
147 | <% if !env.cookies.empty? %>
148 |
149 |
150 |
151 | Variable
152 | Value
153 |
154 |
155 |
156 | <% env.cookies.each { |cookie| %>
157 |
158 | <%= cookie.name %>
159 | <%= cookie.value %>
160 |
161 | <% } %>
162 |
163 |
164 | <% else %>
165 |
No Cookies data.
166 | <% end %>
167 |
168 |
169 |
170 |
171 |
172 | Variable
173 | Value
174 |
175 |
176 |
177 | <% env.headers.each { |key, value| %>
178 |
179 | <%= key %>
180 | <%= value.join("; ") %>
181 |
182 | <% } %>
183 |
184 |
185 |
186 |
187 |
188 |
189 | You're seeing this error because you use Salt::Middlewares::ShowExceptions.
190 |
191 |
192 |
193 |
194 |
195 |
--------------------------------------------------------------------------------
/src/salt/server.cr:
--------------------------------------------------------------------------------
1 | require "http/server"
2 | require "logger"
3 |
4 | require "./middlewares/common_logger"
5 | require "./middlewares/show_exceptions"
6 |
7 | module Salt
8 | # A Salt Server.
9 | #
10 | # ### Options
11 | # - `app`: a salt application to run
12 | # - `environment`: this selects the middleware that will be wrapped around. available are:
13 | # - `development`: `Salt::CommonLogger`, `Salt::ShowExceptions`
14 | # - `deployment`: `Salt::CommonLogger`
15 | # - `none`: no extra middleware
16 | # - `host`: the host address to bind to
17 | # - `port`: the port to bind to (by default is `9898`)
18 | class Server
19 | property logger : ::Logger
20 | property options : Hash(String, String | Int32 | Bool)
21 |
22 | def initialize(**options)
23 | @options = parse_options(**options)
24 | @logger = default_logger
25 | end
26 |
27 | def run(@run_app : Salt::App)
28 | display_info
29 | run_server
30 | end
31 |
32 | def default_middlewares
33 | @middlewares ||= {
34 | "development" => [
35 | Salt::CommonLogger.as(Salt::App.class),
36 | Salt::ShowExceptions.as(Salt::App.class),
37 | ],
38 | "deployment" => [
39 | Salt::CommonLogger.as(Salt::App.class),
40 | ],
41 | }.as(Hash(String, Array(Salt::App.class)))
42 | end
43 |
44 | def run_server
45 | Signal::INT.trap { puts "\nCaught Ctrl+C and Goodbye."; exit }
46 | Signal::TERM.trap { puts "Caught kill"; exit }
47 |
48 | HTTP::Server.new(handlers: [handler]).listen(
49 | host: @options["host"].as(String),
50 | port: @options["port"].as(Int32),
51 | reuse_port: false
52 | )
53 | end
54 |
55 | private def handler
56 | Salt::Server::Handler.new(wrapped_app)
57 | end
58 |
59 | private def wrapped_app
60 | @wrapped_app ||= build_app(app).as(Salt::App)
61 | end
62 |
63 | private def app
64 | @app ||= Salt::Middlewares.to_app(run_app).as(Salt::App)
65 | end
66 |
67 | private def run_app
68 | @run_app.not_nil!
69 | end
70 |
71 | private def build_app(app : Salt::App) : Salt::App
72 | if middlewares = default_middlewares[@options["environment"]]?
73 | middlewares.each { |klass| app = klass.new(app) }
74 | end
75 |
76 | app
77 | end
78 |
79 | private def parse_options(**options)
80 | Hash(String, String | Int32 | Bool).new.tap do |obj|
81 | obj["environment"] = options.fetch(:environment, ENV["SALT_ENV"]? || "development")
82 | obj["host"] = options.fetch(:host, obj["environment"].to_s == "development" ? "localhost" : "0.0.0.0")
83 | obj["port"] = options.fetch(:port, 9898)
84 |
85 | ENV["SALT_ENV"] = obj["environment"].to_s
86 | end
87 | end
88 |
89 | private def default_logger
90 | logger = ::Logger.new(STDOUT)
91 | logger.formatter = ::Logger::Formatter.new do |severity, datetime, progname, message, io|
92 | io << "[" << Process.pid << "] " << message
93 | end
94 |
95 | logger
96 | end
97 |
98 | private def display_info
99 | @logger.info "Salt server starting ..."
100 | @logger.info "* Version #{Salt::VERSION} (Crystal #{Crystal::VERSION})"
101 | @logger.info "* Environment: #{@options["environment"]}"
102 | @logger.info "* Listening on http://#{@options["host"]}:#{@options["port"]}/"
103 | @logger.info "Use Ctrl-C to stop"
104 | end
105 |
106 | # Tranform Middlwares to HTTP::Handler
107 | class Handler
108 | include HTTP::Handler
109 |
110 | def initialize(@app : Salt::App)
111 | end
112 |
113 | def call(context)
114 | env = Salt::Environment.new(context)
115 | response = @app.call(env)
116 |
117 | status_code = response[0].as(Int32)
118 | headers = response[1].as(Hash(String, String))
119 | body = response[2].as(Array(String))
120 |
121 | context.response.status_code = status_code
122 | headers.each do |name, value|
123 | context.response.headers[name] = value
124 | end
125 |
126 | body.each do |line|
127 | context.response << line
128 | end
129 | end
130 | end
131 | end
132 | end
133 |
--------------------------------------------------------------------------------
/src/salt/version.cr:
--------------------------------------------------------------------------------
1 | module Salt
2 | VERSION = "0.4.4"
3 | end
4 |
--------------------------------------------------------------------------------