├── .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 | salt icon 3 |

4 | 5 |

6 | A Human Friendly Interface for HTTP webservers written in Crystal. 7 |

8 | 9 |

10 | Project Status 11 | Langugea 12 | License 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 | 131 | 132 | 133 | 134 | 135 | <% end %> 136 |
<%= file[0] %><%= file[1] %><%= file[3] %><%= file[4] %>
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 | 63 | 70 | 71 | 72 | 73 | 74 | 75 |
Crystal 64 | <% if first = exception.backtrace.first %> 65 | <%= first %> 66 | <% else %> 67 | unknown location 68 | <% end %> 69 |
Web<%= env.method %> <%= "#{env.host_with_port}#{env.path}" %>
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 | 106 | 107 | 108 | 109 | 110 | <% env.query_params.each { |key, value| %> 111 | 112 | 113 | 114 | 115 | <% } %> 116 | 117 |
VariableValue
<%= key %>
<%= value.to_s %>
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 | 129 | 130 | 131 | 132 | 133 | <% env.params.each { |key, value| %> 134 | 135 | 136 | 137 | 138 | <% } %> 139 | 140 |
VariableValue
<%= key %>
<%= value.to_s %>
141 | <% else %> 142 |

No POST data.

143 | <% end %> 144 | <% end %> 145 | 146 | 147 | <% if !env.cookies.empty? %> 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | <% env.cookies.each { |cookie| %> 157 | 158 | 159 | 160 | 161 | <% } %> 162 | 163 |
VariableValue
<%= cookie.name %>
<%= cookie.value %>
164 | <% else %> 165 |

No Cookies data.

166 | <% end %> 167 | 168 |

Headers

169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | <% env.headers.each { |key, value| %> 178 | 179 | 180 | 181 | 182 | <% } %> 183 | 184 |
VariableValue
<%= key %>
<%= value.join("; ") %>
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 | --------------------------------------------------------------------------------