├── spec ├── static │ ├── index.html │ ├── cats │ │ ├── .dog │ │ ├── garfield.svg │ │ ├── small.txt │ │ └── big.txt │ └── .dogs │ │ └── ragoon.txt ├── spec_helper.cr ├── ssl_spec.cr ├── keys │ ├── openssl.crt │ └── openssl.key ├── init_handler_spec.cr ├── helpers_spec.cr ├── static_file_handler_spec.cr └── cli_spec.cr ├── .gitignore ├── shard.lock ├── src ├── sabo-tabby │ ├── ecr │ │ ├── css │ │ │ ├── reset.css │ │ │ ├── directory_listing │ │ │ │ ├── default.css │ │ │ │ ├── flat.css │ │ │ │ ├── gradient.css │ │ │ │ └── material.css │ │ │ └── error_page │ │ │ │ ├── tqila.css │ │ │ │ ├── default.css │ │ │ │ ├── boring.css │ │ │ │ └── gradient.css │ │ ├── directory_listing.ecr │ │ └── error_page.ecr │ ├── ssl.cr │ ├── helpers │ │ ├── utils.cr │ │ └── helpers.cr │ ├── init_handler.cr │ ├── error_handler.cr │ ├── config.cr │ ├── static_file_handler.cr │ ├── log_handler.cr │ └── cli.cr ├── licenses.cr └── sabo-tabby.cr ├── .editorconfig ├── shard.yml ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── Makefile ├── LICENSE ├── sabo.tabby.yaml ├── logo.svg ├── CODE_OF_CONDUCT.md └── README.md /spec/static/index.html: -------------------------------------------------------------------------------- 1 | test -------------------------------------------------------------------------------- /spec/static/cats/.dog: -------------------------------------------------------------------------------- 1 | i'm hidding -------------------------------------------------------------------------------- /spec/static/cats/garfield.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/static/cats/small.txt: -------------------------------------------------------------------------------- 1 | hello 2 | world -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | 7 | benchmarks/ 8 | website/ 9 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | shards: 3 | crustache: 4 | git: https://github.com/makenowjust/crustache.git 5 | version: 2.4.4 6 | 7 | -------------------------------------------------------------------------------- /src/sabo-tabby/ecr/css/reset.css: -------------------------------------------------------------------------------- 1 | *,*::before,*::after{box-sizing:border-box;}a,a:visited,a:hover,a:active{color:inherit;}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin:0;} -------------------------------------------------------------------------------- /spec/static/.dogs/ragoon.txt: -------------------------------------------------------------------------------- 1 | ___ 2 | __/_ `. .-"""-. 3 | woof \_,` | \-' / )`-') 4 | "") `"` \ ((`"` 5 | ___Y , .'7 /| 6 | (_,___/...-` (_/_/ -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: sabo-tabby 2 | version: 1.1.0 3 | 4 | authors: 5 | - Evangelos Paterakis 6 | 7 | dependencies: 8 | crustache: 9 | github: MakeNowJust/crustache 10 | 11 | targets: 12 | sabo-tabby: 13 | main: src/sabo-tabby.cr 14 | 15 | crystal: 1.5.0 16 | 17 | license: BSD-2-Clause 18 | -------------------------------------------------------------------------------- /src/sabo-tabby/ecr/css/directory_listing/default.css: -------------------------------------------------------------------------------- 1 | :root{--bg:#222;--color-text:white;--color-border:rgba(255,255,255,.3);--theme:#f0db78;--item-border-width:.3rem;--item-border-radius:.7rem;}@media (prefers-color-scheme:light){:root{--bg:#fff;--color-text:black;--color-border:rgba(0,0,0,.3);--theme:#a56262}}h1{text-align:center;} -------------------------------------------------------------------------------- /src/sabo-tabby/ecr/css/error_page/tqila.css: -------------------------------------------------------------------------------- 1 | :root{--bg:#f05b72;--color-text:black;}h1,h2,.btn{text-align:center;}h1{font-size:clamp(1.8rem,40vw,15rem);}h2{font-size:clamp(1rem,20vw,4rem);}.btn{text-decoration:underline;font-size:clamp(1rem,20vw,4rem);font-weight:bold;}.btn:hover{text-decoration:dashed;text-decoration-line:underline;} -------------------------------------------------------------------------------- /src/sabo-tabby/ssl.cr: -------------------------------------------------------------------------------- 1 | module Sabo::Tabby 2 | class SSL 3 | getter context 4 | 5 | def initialize 6 | @context = OpenSSL::SSL::Context::Server.new 7 | end 8 | 9 | def key_file=(key_file : String) 10 | @context.private_key = key_file 11 | end 12 | 13 | def cert_file=(cert_file : String) 14 | @context.certificate_chain = cert_file 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /src/sabo-tabby/ecr/css/directory_listing/flat.css: -------------------------------------------------------------------------------- 1 | :root{--bg:#2b579a;--color-text:white;--color-border:white;--theme:white;--item-border-width:.1rem;--item-border-radius:0;}@media (prefers-color-scheme:light){:root{--bg:#0078d4}}h1{text-align:left;}a,a:visited{color:black!important;}.item{background-color:white;color:black;}.item:hover{color:black!important;box-shadow:0 6px 14px 0 rgba(0,0,0,.53),0 1px 3px 0 rgba(0,0,0,.50);} -------------------------------------------------------------------------------- /src/sabo-tabby/helpers/utils.cr: -------------------------------------------------------------------------------- 1 | module Sabo::Tabby 2 | module Utils 3 | ZIP_TYPES = {".htm", ".html", ".txt", ".css", ".js", ".svg", ".json", ".xml", ".otf", ".ttf", ".woff", ".woff2"} 4 | 5 | # Returns whether a file should be compressed 6 | def self.zip_types(path : String | Path) # https://github.com/h5bp/server-configs-nginx/blob/main/nginx.conf 7 | ZIP_TYPES.includes? File.extname(path) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /src/sabo-tabby/ecr/css/directory_listing/gradient.css: -------------------------------------------------------------------------------- 1 | :root{--bg:linear-gradient(to top,#0c3483 0%,#a2b6df 100%,#6b8cce 100%,#a2b6df 100%);--color-text:white;--color-border:rgba(255,255,255,.3);--theme:#FFD2C8;--item-border-width:.1rem;--item-border-radius:.1rem;}@media (prefers-color-scheme:light){:root{--bg:radial-gradient(circle farthest-corner at 10% 20%,rgba(253,101,133,1) 0%,rgba(255,211,165,1) 90%);--color-text:black;--color-border:rgba(0,0,0,.3);--theme:#4f476c}}h1{text-align:left;} -------------------------------------------------------------------------------- /src/sabo-tabby/ecr/css/error_page/default.css: -------------------------------------------------------------------------------- 1 | :root{--bg:#222;--color-text:white;--theme:#f0db78;}@media (prefers-color-scheme:light){:root{--bg:#fff;--color-text:black;--theme:#a56262}}svg{fill:var(--color-text)}h1,h2,.btn{text-align:center;}h1{font-size:clamp(1.8rem,40vw,15rem);color:var(--theme);}h2{font-size:clamp(1rem,20vw,4rem);}.btn{text-decoration:underline;font-size:clamp(1rem,20vw,4rem);font-weight:bold;}.btn:hover{text-decoration:dashed;text-decoration-line:underline;color:var(--theme);} -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/sabo-tabby" 3 | 4 | COLORS_ENABLED = STDOUT.tty? && STDERR.tty? && ENV["TERM"]? != "dumb" 5 | 6 | # Disable colorize only for the block and then reset to original value. 7 | def disable_colorize(&block) 8 | Colorize.enabled = false 9 | result = yield 10 | Colorize.enabled = COLORS_ENABLED 11 | 12 | result 13 | end 14 | 15 | # Override abort so it doesn't exit. 16 | def abort(message = nil, status = 1) 17 | message 18 | end 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Specs & Lint 2 | on: [push, pull_request] 3 | jobs: 4 | ci: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Download source 8 | uses: actions/checkout@v2 9 | - name: Install Crystal 10 | uses: crystal-lang/install-crystal@v1 11 | - name: Install shards 12 | run: shards install 13 | - name: Run tests 14 | run: make test_all 15 | - name: Build 16 | run: make debug 17 | - name: Check formatting 18 | run: crystal tool format; git diff --exit-code -------------------------------------------------------------------------------- /src/sabo-tabby/init_handler.cr: -------------------------------------------------------------------------------- 1 | module Sabo::Tabby 2 | # Initializes the context with default values, such as 3 | # *Content-Type* or *Server* headers. 4 | class InitHandler 5 | include HTTP::Handler 6 | 7 | INSTANCE = new 8 | 9 | def call(context : HTTP::Server::Context) 10 | context.response.headers.add "Server", Sabo::Tabby::SERVER_HEADER if Sabo::Tabby.config.server_header 11 | context.response.content_type = "text/html; charset=UTF-8" unless context.response.headers.has_key?("Content-Type") 12 | call_next context 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all install uninstall test build debug static test_all test_mt 2 | PREFIX ?= /usr 3 | 4 | all: build 5 | 6 | debug: 7 | shards build 8 | 9 | build: 10 | shards build --production --no-debug --release -Dpreview_mt 11 | 12 | static: 13 | shards build --production --no-debug --release -Dpreview_mt --static 14 | 15 | test_all: test test_mt 16 | 17 | test: 18 | crystal spec --order random 19 | 20 | test_mt: 21 | crystal spec --order random -Dpreview_mt 22 | 23 | install: 24 | install -D -m 0755 bin/sabo-tabby $(PREFIX)/bin/sabo-tabby 25 | 26 | uninstall: 27 | rm -f $(PREFIX)/bin/sabo-tabby 28 | -------------------------------------------------------------------------------- /src/sabo-tabby/ecr/css/error_page/boring.css: -------------------------------------------------------------------------------- 1 | :root{--bg:#2b579a;--color-text:white;}@media (prefers-color-scheme:light){:root{--bg:#0078d4}}svg{fill:var(--color-text)}h1,h2,.btn{text-align:left;position:relative;color:var(--color-text);}h1,h2{padding-left:2rem;}body{display:block!important}h1{font-size:clamp(1.8rem,40vw,15rem);}h2{font-size:clamp(1rem,20vw,4rem);}.btn{font-size:clamp(1rem,20vw,4rem);font-weight:bold;padding:0 1rem;background-color:white;margin:1rem;margin-left:2rem;border-radius:.1rem;color:black!important;text-decoration:none;text-shadow:none!important;}.btn:hover{box-shadow:0 6px 14px 0 rgba(0,0,0,.53),0 1px 3px 0 rgba(0,0,0,.50);} -------------------------------------------------------------------------------- /src/sabo-tabby/ecr/css/directory_listing/material.css: -------------------------------------------------------------------------------- 1 | :root{--bg:#1B5E20;--color-text:white;--color-border:#9eaf9e;--theme:#4CAF50;--item-border-width:.1rem;--item-border-radius:2rem;--item-background-color:var(--bg);}@media (prefers-color-scheme:light){:root{--bg:#C8E6C9;--color-text:black}}h1{text-align:center;}a,a:visited{color:white!important;}.item{background-color:var(--theme);color:white!important;box-shadow:0 .5rem .5rem 0 rgba(0,0,0,.14),0 .25rem 1.25rem 0 rgba(0,0,0,.12),0 .75rem .25rem -.5rem rgba(0,0,0,.2);border-color:var(--theme)!important;}.item:hover{color:var(--color-text)!important;background-color:var(--item-background-color);box-shadow:none;border-color:var(--color-border)!important;} -------------------------------------------------------------------------------- /src/sabo-tabby/ecr/css/error_page/gradient.css: -------------------------------------------------------------------------------- 1 | :root{--bg:radial-gradient(circle farthest-corner at 10.2% 55.8%,rgba(252,37,103,1) 0%,rgba(250,38,151,1) 46.2%,rgba(186,8,181,1) 90.1%);--color-text:white;--btn-color:black;}@media (prefers-color-scheme:light){:root{--bg:radial-gradient(circle farthest-corner at 10% 20%,rgba(161,255,206,1) 0%,rgba(250,255,209,1) 90%);--color-text:black;--btn-color:white}}svg{fill:var(--color-text)}h1,h2,.btn{text-align:center;}h1{font-size:clamp(5rem,50vw,25rem);}h2{font-size:clamp(1rem,20vw,4rem);}.btn{font-size:clamp(1rem,20vw,4rem);font-weight:bold;padding:1rem 2rem;background-color:var(--color-text);margin:1rem;border-radius:100px;color:var(--btn-color)!important;text-decoration:none;}.btn:hover{box-shadow:0 6px 14px 0 rgba(0,0,0,.53),0 1px 3px 0 rgba(0,0,0,.50);} -------------------------------------------------------------------------------- /spec/ssl_spec.cr: -------------------------------------------------------------------------------- 1 | {% skip_file if flag?(:without_openssl) %} 2 | 3 | require "./spec_helper" 4 | 5 | describe "Sabo::Tabby::SSL" do 6 | it "should create a OpenSSL::SSL::Context::Server instance" do 7 | ssl = Sabo::Tabby::SSL.new 8 | keys = Path[__DIR__, "keys"] 9 | 10 | ssl.key_file = (keys / "openssl.key").to_s 11 | ssl.cert_file = (keys / "openssl.crt").to_s 12 | 13 | ssl.context.should be_a(OpenSSL::SSL::Context::Server) 14 | end 15 | 16 | it "should raise an OpenSSL::Error if either key or cert can't be found" do 17 | ssl = Sabo::Tabby::SSL.new 18 | 19 | expect_raises(OpenSSL::Error) do 20 | ssl.key_file = "unknown.key" 21 | end 22 | 23 | expect_raises(OpenSSL::Error) do 24 | ssl.cert_file = "unknown.crt" 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/keys/openssl.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDTzCCAjegAwIBAgIUAJnI9fYTd3Puio4Vl55ILbazW+wwDQYJKoZIhvcNAQEL 3 | BQAwNzELMAkGA1UEBhMCR1IxEzARBgNVBAgMClNvbWUtU3RhdGUxEzARBgNVBAoM 4 | ClNhYm8gVGFiYnkwHhcNMjIwNzE0MTIwMjIxWhcNMjIwODEzMTIwMjIxWjA3MQsw 5 | CQYDVQQGEwJHUjETMBEGA1UECAwKU29tZS1TdGF0ZTETMBEGA1UECgwKU2FibyBU 6 | YWJieTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK+Yi1SHaWgh9q0P 7 | Mv/rtLtwst+XniC3QYmBko3rZsh21yFN/D56arH0RhfnrTW3iWtpruEBHGVUGdrt 8 | ntN5YpYEwztFCM0qazevByl4IXcgtpgQAF9Whx85pXHPN6uxDS3YsTEIBqVmT5a7 9 | H1d15QIWjAlD1uNHz3gpbzCTYT1c4CcURWmluP3ak5D0nH6SB0P+hHBhSPmv2ima 10 | 3qE/CkBdY8dd3hJsbB82IbEvjk8I2Npsia6djq9YS3zhXi5RDFWCTtmn3AmHNf9S 11 | VfsD2rYv40wAUtUkXce0OvjaFR6Iu7FoquNfdmK9cnLpCsxYdiSOioNLEwMLvgo/ 12 | d+qHeN0CAwEAAaNTMFEwHQYDVR0OBBYEFLzvsYlngk7FkR6RjoPVHRJ7j0njMB8G 13 | A1UdIwQYMBaAFLzvsYlngk7FkR6RjoPVHRJ7j0njMA8GA1UdEwEB/wQFMAMBAf8w 14 | DQYJKoZIhvcNAQELBQADggEBAGtAvyhOR8iwcoi1QaBPdgqv1bWXjNsusOTc5VAJ 15 | XeJVpxAXnp+/eUxVtb/oinLKAkpu+1DpRsTq0C8E4MyNTGS1l9K8WdvxnxgNNlv6 16 | na/EZu+6U+gn/SRigHLE9S2XlMTkUeYIez4S24qBuwNZZg+jm4obmNbLSw/RlKNW 17 | yMUDv6q1MqGbm9ZR1q4jcyf0OywRZL15eomQ8MqdY5mcVzR9LXkehpjEMOuqFxrH 18 | 9XWNAtE2LDMU2a9I/ayHZJS0zAZxnrDEMxVhaX381xuRfcOK3G+SjUNj+NjItk2w 19 | Oqgsl2qE8KHZTy72ABYK11uo5i17xwlTQVUQqHCb7d2tZJU= 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022, Evangelos "GeopJr" Paterakis 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: workflow_dispatch 3 | jobs: 4 | build_linux_static: 5 | runs-on: ubuntu-latest 6 | container: 7 | image: crystallang/crystal:latest-alpine 8 | steps: 9 | - name: Download source 10 | uses: actions/checkout@v2 11 | - name: Retrieve version 12 | run: | 13 | echo "::set-output name=VERSION::$(shards version)" 14 | id: version 15 | - name: Build 16 | run: make static 17 | - name: Make binary executable 18 | run: chmod +x bin/sabo-tabby 19 | - name: Upload artifact 20 | uses: actions/upload-artifact@v3 21 | with: 22 | name: sabo-tabby-${{ steps.version.outputs.VERSION }}-linux-x86_64-static 23 | path: bin/sabo-tabby 24 | 25 | release: 26 | runs-on: ubuntu-latest 27 | needs: build_linux_static 28 | steps: 29 | - uses: actions/checkout@v2 30 | - uses: actions/download-artifact@v2 31 | with: 32 | path: ./GH_ARTIFACTS 33 | - name: Make all binaries executable 34 | run: chmod +x GH_ARTIFACTS/**/* 35 | - name: Create zips 36 | run: cd GH_ARTIFACTS && find . -maxdepth 1 -mindepth 1 -type d -execdir zip -jr '{}.zip' '{}' \; && cd .. 37 | - uses: softprops/action-gh-release@v1 38 | with: 39 | draft: true 40 | files: | 41 | GH_ARTIFACTS/*.zip 42 | -------------------------------------------------------------------------------- /spec/init_handler_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe "Sabo::Tabby::InitHandler" do 4 | it "should initialize context with Content-Type: text/html; charset=UTF-8" do 5 | request = HTTP::Request.new("GET", "/") 6 | io = IO::Memory.new 7 | response = HTTP::Server::Response.new(io) 8 | context = HTTP::Server::Context.new(request, response) 9 | Sabo::Tabby::InitHandler::INSTANCE.next = ->(_context : HTTP::Server::Context) {} 10 | Sabo::Tabby::InitHandler::INSTANCE.call(context) 11 | context.response.headers["Content-Type"].should eq "text/html; charset=UTF-8" 12 | end 13 | 14 | it "should initialize context with Server: sabo-tabby/VERSION" do 15 | Sabo::Tabby.config.server_header = true 16 | 17 | request = HTTP::Request.new("GET", "/") 18 | io = IO::Memory.new 19 | response = HTTP::Server::Response.new(io) 20 | context = HTTP::Server::Context.new(request, response) 21 | Sabo::Tabby::InitHandler::INSTANCE.call(context) 22 | context.response.headers["Server"].should eq "sabo-tabby/#{Sabo::Tabby::VERSION}" 23 | end 24 | 25 | it "shouldn't initialize context with Server: sabo-tabby/VERSION if it's disabled" do 26 | Sabo::Tabby.config.server_header = false 27 | 28 | request = HTTP::Request.new("GET", "/") 29 | io = IO::Memory.new 30 | response = HTTP::Server::Response.new(io) 31 | context = HTTP::Server::Context.new(request, response) 32 | Sabo::Tabby::InitHandler::INSTANCE.call(context) 33 | context.response.headers["Server"]?.should be_nil 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/keys/openssl.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCvmItUh2loIfat 3 | DzL/67S7cLLfl54gt0GJgZKN62bIdtchTfw+emqx9EYX5601t4lraa7hARxlVBna 4 | 7Z7TeWKWBMM7RQjNKms3rwcpeCF3ILaYEABfVocfOaVxzzersQ0t2LExCAalZk+W 5 | ux9XdeUCFowJQ9bjR894KW8wk2E9XOAnFEVppbj92pOQ9Jx+kgdD/oRwYUj5r9op 6 | mt6hPwpAXWPHXd4SbGwfNiGxL45PCNjabImunY6vWEt84V4uUQxVgk7Zp9wJhzX/ 7 | UlX7A9q2L+NMAFLVJF3HtDr42hUeiLuxaKrjX3ZivXJy6QrMWHYkjoqDSxMDC74K 8 | P3fqh3jdAgMBAAECggEALddAAHg9X08Gi9Vc9gy9qPVZ7R8yy82rkU8/SEd9FLZZ 9 | oTsvr8vbkMt5hQIZaN2aRW1YlyroE9fpzAqenQyiXRGvOgjirFT3mpjZ4MyYx/XF 10 | VVtQDZOUQOWWuGhlWzAkhaR9VZSHz6QvAOWm3/lKKMpkEoMgVUNI+VCv01mWWCM4 11 | tPYAHOB/baQLtcqkxm/YLuYklz3UXeOuBsgP6gHsoNSOyIMVO55WB0zKCiTNrmxr 12 | tu67mkkhQ8R2wIA94byPDzaMg1mT3EyqoruK9QWeGa6+XqkGjB7FKlrWcV0cioal 13 | vCvQ+pSn3NtvKYSZ/f7zxd1BUkKjtffVHlsD677WTQKBgQDpktBn0FsfMA500Lme 14 | 5rlDzfVwEaBdLxTMmPMGJ/U0z3TorVmqbsvJEo/0BDnKFk6UBShLjpz7hWkuxgDP 15 | y4vzP5wHxInusU9KGNroyR7M2Aq9Ej0tZyNehxaMgJM3q5VVqggX0SLP2N371XnV 16 | F8pdeSXaqyuTPgJbAouNHi30NwKBgQDAdKckrKEYvzdmFNca2Cm0FUP+/1Q4uEaD 17 | kgfpcM6PW8rvWOR4IPaLSzYw7G9XsUCLMr975DrB3VArK0YHXG8b/ePT7fzTecHh 18 | WG+mhjO2VJQkqdfliTd1LEvaP4tSWDG+t2/wazJdSZ4qPsC4TrBGafIl7HF3WfgW 19 | pEmSYtmZiwKBgQCviKoemrMgSRlUKNiW1oY8srfVNSnzXcKf0AIziiv5OD9/7WcJ 20 | cqKrxctxcwuLGCCNlSKnRdIsJCLcB+nsP+g5MoCsRcPzIkWYRf2eHCeNgn7vgJmB 21 | WZV3IMaNaMM6fzSHYHUckQs8cJrC+7fHsU1f2f/cye7BhUR36P2/XALlGQKBgDHp 22 | Dr+wFUc5r8BFf1Ny473UFgI3bTwYbhEI+gxMOQVspMBVqUqOIeIV60PczTNMwJRY 23 | 4NfcZHCKWJQZcNvP2PDFcyQu//ZICUPLj4j8HMUYQiMP+PGKGFvG1RUQja0ZOi1f 24 | nSQYaMNQqDgEdi5WGdjo+Odk3jg5mOEmUf4orI3pAoGAKfmC6iWuDCJasXPYrerm 25 | wIRmElj1VWD6mG0fZpu34IQVY6eLhj0VWQkyVf8y9xVB0VEtk4f8l08vqV76BBFk 26 | uxxDaZp1qURiYU4bAxrt+SRmK/2oYFk7OOvsd4fTmYu6rwaNOSDUxy3uQG57dmIB 27 | OwAoFV69145IB6h3aZv80JM= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /src/sabo-tabby/ecr/directory_listing.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Directory listing for <%= HTML.escape request_path %> 10 | 16 | 17 | 18 | 19 |

Directory listing for <%= HTML.escape request_path %> 20 |

21 |
22 |
23 | <% encoded_request_path=URI.encode_path(request_path) %> 24 | <% each_entry do |entry, file| %> 25 | 26 | <%= file ? "📄" : "📁" %><%= HTML.escape entry %><%= file ? "" : "/" %> 27 | 28 | <% end %> 29 |
30 | 31 | -------------------------------------------------------------------------------- /sabo.tabby.yaml: -------------------------------------------------------------------------------- 1 | # Basic 2 | # Host to bind 3 | host: 0.0.0.0 4 | # Port to listen for connections 5 | port: 1312 6 | # Folder of static files to server 7 | # relative to this file 8 | public_folder: ./ 9 | # Whether to server hidden folders 10 | # and files 11 | serve_hidden: false 12 | 13 | # SSL 14 | ssl: 15 | # Location of your SSL key 16 | # relative to this file 17 | key: ./spec/keys/openssl.key 18 | ## Location of your SSL crt 19 | ## relative to this file 20 | cert: ./spec/keys/openssl.crt 21 | 22 | # Theming 23 | theme: 24 | # Either one of the preincluded 25 | # themes (see `-h`) for the 26 | # error page 27 | # or path to a .mst/.mustache/.html 28 | # file relative to this file 29 | error: Default 30 | # Either one of the preincluded 31 | # themes (see `-h`) for the 32 | # dir listing page 33 | # or path to a .mst/.mustache/.html 34 | # file relative to this file 35 | dir: Default 36 | # One of the preincluded logging 37 | # formats (see `-h`) 38 | logging: Default 39 | 40 | # Logging 41 | # Whether to enable logging of 42 | # requests 43 | logging: true 44 | # Whether to enable emojis in logs 45 | emoji: true 46 | # Whether to enable colors in logs 47 | # (already disabled in non-tty) 48 | colors: true 49 | 50 | # Options 51 | # Whether to set the 'Server' 52 | # header to the name and version 53 | # of the server 54 | server_header: true 55 | # Whether to enable gzip 56 | gzip: true 57 | # Directory options 58 | dir: 59 | # Whether to serve index.html of 60 | # folders if available 61 | # e.g. GET /some_dir/ => 62 | # serve /some_dir/index.html 63 | index: true 64 | # Whether to enable directory 65 | # listing 66 | listing: true 67 | # Whether to enable custom HTML 68 | # error pages 69 | # If disable, server will respond 70 | # with text/plain 71 | # #{status_code}: #{message} 72 | custom_error_page: true -------------------------------------------------------------------------------- /src/sabo-tabby/error_handler.cr: -------------------------------------------------------------------------------- 1 | module Sabo::Tabby 2 | create_theme_index "error_page", ["tqila", "gradient", "boring"] 3 | 4 | class HTTP::Server::Response 5 | record ErrorPage, status_code : Int32, message : String, theme : String do 6 | getter logo : String = Sabo::Tabby::LOGO 7 | 8 | def css : Tuple 9 | {Sabo::Tabby::RESET_CSS, Sabo::Tabby::ERROR_PAGE_INDEX[theme.downcase]} 10 | end 11 | 12 | ECR.def_to_s "#{__DIR__}/ecr/error_page.ecr" 13 | end 14 | 15 | private def error_page(status_code : Int32, message : String) 16 | theme = Sabo::Tabby.config.theme["error_page"] 17 | # If it's a Mustache file, create a model and render it, else render the ecr theme. 18 | if theme.is_a?(Crustache::Syntax::Template) 19 | model = { 20 | "status_code" => status_code, 21 | "message" => message, 22 | } 23 | 24 | Crustache.render theme, model 25 | else 26 | ErrorPage.new(status_code, message, theme.to_s).to_s 27 | end 28 | end 29 | 30 | def respond_with_status(status : HTTP::Status, message : String? = nil) 31 | check_headers 32 | reset 33 | @status = status 34 | @status_message = message ||= @status.description 35 | self.headers.add "Server", Sabo::Tabby::SERVER_HEADER if Sabo::Tabby.config.server_header 36 | 37 | # If it exists, use file in the public dir matching error_code.html (eg 404.html). 38 | static_error_page = Path[Sabo::Tabby.config.public_folder, "#{status_code}.html"] 39 | if File.exists?(static_error_page) 40 | self.content_type = "text/html; charset=UTF-8" 41 | IO.copy File.open(static_error_page), self 42 | 43 | # If HTML pages are enabled, call `error_page` else return a basic text/plain one. 44 | elsif Sabo::Tabby.config.error_page 45 | self.content_type = "text/html; charset=UTF-8" 46 | self << error_page(@status.code, @status_message.to_s) << '\n' 47 | else 48 | self.content_type = "text/plain; charset=UTF-8" 49 | self << @status.code << ' ' << message << '\n' 50 | end 51 | close 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/helpers_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe "::log" do 4 | message = "No gods, no masters" 5 | io = IO::Memory.new 6 | 7 | before_each do 8 | io = IO::Memory.new 9 | Sabo::Tabby.config.logger = Sabo::Tabby::LogHandler.new(io) 10 | end 11 | 12 | it "should use Sabo::Tabby::Config.logger to log a message" do 13 | log message 14 | 15 | result = io.to_s.split(' ') 16 | emoji = result.shift 17 | 18 | result.join(' ').should eq("#{message}\n") 19 | Sabo::Tabby::EMOJIS[:base].should contain(emoji) 20 | end 21 | 22 | it "should use Sabo::Tabby::Config.logger to log a without emojis set explicitly" do 23 | log message, emoji: false 24 | 25 | result = io.to_s.split(' ') 26 | 27 | result.join(' ').should eq("#{message}\n") 28 | end 29 | 30 | it "should use Sabo::Tabby::Config.logger to log a without emojis set on config" do 31 | Sabo::Tabby.config.emoji = false 32 | log message 33 | 34 | result = io.to_s.split(' ') 35 | 36 | result.join(' ').should eq("#{message}\n") 37 | Sabo::Tabby.config.emoji = true 38 | end 39 | 40 | it "should use Sabo::Tabby::Config.logger to log a message but with a newline at the start" do 41 | log message, newline: true 42 | 43 | result = io.to_s.split(' ') 44 | newline, emoji = result.shift 45 | result[0] = "#{newline}#{result[0]}" 46 | 47 | result.join(' ').should eq("\n#{message}\n") 48 | Sabo::Tabby::EMOJIS[:base].should contain(emoji.to_s) 49 | end 50 | end 51 | 52 | describe "::abort_log" do 53 | message = "No gods, no masters" 54 | 55 | it "should return a formatted abort error message" do 56 | abort_message = disable_colorize do 57 | abort_log(message).to_s 58 | end 59 | 60 | abort_message.should eq("[ERROR][#{Sabo::Tabby::APP_NAME}]: #{message}") 61 | end 62 | end 63 | 64 | describe Sabo::Tabby::Utils do 65 | it "should return whether a file should be compressed" do 66 | gzip_file = Path["cat", "meow#{Sabo::Tabby::Utils::ZIP_TYPES.sample}"] 67 | non_gzip_file = Path["cat", "meow.cr"] 68 | 69 | Sabo::Tabby::Utils.zip_types(gzip_file).should be_true 70 | Sabo::Tabby::Utils.zip_types(non_gzip_file).should be_false 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /src/licenses.cr: -------------------------------------------------------------------------------- 1 | require "colorize" 2 | Colorize.enabled = true 3 | 4 | APP_NAME = {{read_file("#{__DIR__}/../shard.yml").split("name: ")[1].split("\n")[0]}} 5 | LICENSE_FILES = {"LICENSE", "LICENSE.md", "UNLICENSE"} 6 | OLD_LICENSE = <<-KEMAL_LICENSE 7 | #{"Kemal".capitalize.colorize.mode(:underline).mode(:bold)} 8 | Copyright (c) 2016 Serdar Doğruyol 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE 27 | 28 | KEMAL_LICENSE 29 | 30 | licenses = [] of String 31 | root = Path[__DIR__, ".."] 32 | lib_folder = root / "lib" 33 | 34 | def get_license_content(parent : Path) : String? 35 | license = nil 36 | LICENSE_FILES.each do |license_file| 37 | license_path = parent / license_file 38 | if File.exists?(license_path) 39 | license = File.read(license_path) 40 | break 41 | end 42 | end 43 | license 44 | end 45 | 46 | unless (license = get_license_content(root)).nil? 47 | licenses << license 48 | end 49 | 50 | licenses << OLD_LICENSE 51 | 52 | Dir.each_child(lib_folder) do |shard| 53 | path = lib_folder / shard 54 | next if File.file?(path) 55 | 56 | unless (license = get_license_content(path)).nil? 57 | licenses << "#{shard.capitalize.colorize.mode(:underline).mode(:bold)}\n#{license}" 58 | end 59 | end 60 | 61 | licenses.unshift("#{APP_NAME.capitalize.colorize.mode(:underline).mode(:bold)}") 62 | 63 | puts licenses.join('\n') 64 | -------------------------------------------------------------------------------- /src/sabo-tabby/ecr/error_page.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= HTML.escape message %> 9 | 15 | 16 | 17 |

<%= status_code %>

18 |

<%= HTML.escape message %>

19 | Go Home 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /spec/static/cats/big.txt: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse posuere cursus consectetur. Donec mauris lorem, sodales a eros a, ultricies convallis ante. Quisque elementum lacus purus, sagittis mollis justo dignissim ac. Suspendisse potenti. Cras non mauris accumsan mi porttitor congue. Quisque posuere aliquam tellus sit amet ultrices. Sed at tortor sed libero fringilla luctus vitae quis magna. In maximus congue felis, et porta tortor egestas sed. Phasellus orci eros, finibus sed ipsum eget, euismod bibendum nisl. Etiam ultrices facilisis diam in gravida. Praesent lobortis leo vitae aliquet volutpat. Praesent vel blandit risus. In suscipit eget nunc at ultrices. Proin dapibus feugiat diam ut tincidunt. Donec lectus diam, ornare ut consequat nec, gravida sit amet metus. 2 | 3 | Nunc a viverra urna, quis ullamcorper augue. Morbi posuere auctor nibh, tempor luctus massa mollis laoreet. Pellentesque sagittis leo eu felis interdum finibus. Pellentesque porttitor lobortis arcu, eu mollis dui iaculis nec. Vestibulum sit amet sodales erat. Nullam quis mi massa. Suspendisse sit amet elit auctor, feugiat ipsum a, placerat metus. Vestibulum quis felis a lectus blandit aliquam. Nam consectetur iaculis nulla. Mauris sit amet condimentum erat, in vestibulum dui. Nullam nec mattis tortor, non viverra nunc. Proin eget congue augue. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed ut hendrerit nulla. Etiam cursus sagittis metus, et feugiat ligula molestie sit amet. Aliquam laoreet auctor sagittis. 4 | 5 | Aliquam tempor urna non consectetur tincidunt. Maecenas porttitor augue diam, ac lobortis nulla suscipit eget. Ut quis lacus facilisis, euismod lacus non, ullamcorper urna. Cras pretium fringilla pharetra. Praesent sed nunc at elit vulputate elementum. Suspendisse ac molestie nunc, sit amet consectetur nunc. Cras placerat ligula tortor, non bibendum massa tempus ut. Etiam eros erat, gravida id felis eget, congue suscipit ipsum. Sed condimentum erat at facilisis dictum. Cras venenatis vitae turpis vitae sagittis. Proin id posuere est, non ornare sem. Donec vitae sollicitudin dolor, a pulvinar ex. Integer porta velit lectus, et imperdiet enim commodo a. 6 | 7 | Donec sit amet ipsum tempus, tincidunt neque eget, luctus massa. Praesent vel nulla pretium, bibendum enim a, pulvinar enim. Vestibulum non libero eu est dignissim cursus. Nullam commodo tellus imperdiet feugiat placerat. Sed sed dolor ut nibh blandit maximus ac eget neque. Ut sit amet augue maximus, lacinia eros non, faucibus eros. Suspendisse ac bibendum libero, eu lobortis nulla. Mauris arcu nulla, tempus eu varius eu, bibendum at nibh. Donec id libero consequat, volutpat ex vitae, molestie velit. Aliquam aliquam sem ac arcu pellentesque, placerat bibendum enim dapibus. Duis consectetur ligula non placerat euismod. 8 | 9 | Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Proin commodo ullamcorper venenatis. Cras ac lorem sit amet augue varius convallis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris dolor nisi, efficitur id aliquet ut, ultricies sed elit. Proin ultricies turpis dolor, in auctor velit aliquet nec. Praesent vehicula aliquam viverra. Suspendisse potenti. Donec aliquet iaculis ultricies. Proin dignissim vitae nisl at rutrum. -------------------------------------------------------------------------------- /src/sabo-tabby/config.cr: -------------------------------------------------------------------------------- 1 | module Sabo::Tabby 2 | # The CSS reset used in all ecr templates. 3 | RESET_CSS = {{read_file("#{__DIR__}/ecr/css/reset.css")}} 4 | 5 | # Macro that expands an array of *styles* into an enum based on *name* and loads their CSS files in memory under a `NamedTuple` based on *name*. 6 | macro create_theme_index(name, styles) 7 | {% styles.unshift("default") %} 8 | 9 | # Themes for {{name.id}} 10 | enum {{"Config::#{name.camelcase.id}Theme".id}} 11 | {% for style in styles %} 12 | {{style.capitalize.id}} 13 | {% end %} 14 | end 15 | 16 | # CSS files for {{name.id}} 17 | {{"#{name.upcase.id}_INDEX".id}} = { 18 | {% for style in styles %} 19 | {{style.downcase.id}}: {{read_file("#{__DIR__}/ecr/css/#{name.downcase.id}/#{style.downcase.id}.css")}}, 20 | {% end %} 21 | } 22 | end 23 | 24 | # Stores all the configuration options for a Sabo::Tabby application. 25 | # 26 | # It's a singleton and you can access it like: 27 | # ``` 28 | # Sabo::Tabby.config 29 | # ``` 30 | class Config 31 | INSTANCE = Config.new 32 | 33 | property host_binding : String = "0.0.0.0" 34 | property port : Int32 = 1312 35 | property public_folder : String = "." 36 | # Whether to server hidden files and folders. 37 | property serve_hidden : Bool = false 38 | 39 | # Hash of themes. 40 | getter theme : Hash(String, ErrorPageTheme | DirectoryListingTheme | Crustache::Syntax::Template) = Hash(String, ErrorPageTheme | DirectoryListingTheme | Crustache::Syntax::Template).new 41 | property logger_style : LoggerStyle = :Default 42 | 43 | property logging : Bool = true 44 | property emoji : Bool = true 45 | 46 | property server_header : Bool = true 47 | property gzip : Bool = true 48 | # Whether to redirect / to /index.html. 49 | property dir_index : Bool = true 50 | # Whether to list directory files. 51 | property dir_listing : Bool = true 52 | # Whether to show an HTML error page or a basic text/plain one. 53 | property error_page : Bool = true 54 | 55 | getter handlers : Array(HTTP::Handler) = Array(HTTP::Handler).new 56 | getter logger : Sabo::Tabby::LogHandler = Sabo::Tabby::LogHandler.new 57 | property server : HTTP::Server? 58 | property running : Bool = true 59 | 60 | {% if flag?(:without_openssl) %} 61 | property ssl : Bool? = false 62 | {% else %} 63 | property ssl : OpenSSL::SSL::Context::Server? 64 | {% end %} 65 | 66 | # Only if running in spec. 67 | {% if @top_level.has_constant? "Spec" %} 68 | setter logger 69 | {% end %} 70 | 71 | def initialize 72 | setup_themes 73 | end 74 | 75 | def scheme : String 76 | ssl ? "https" : "http" 77 | end 78 | 79 | def setup 80 | setup_handlers 81 | end 82 | 83 | private def setup_handlers 84 | @handlers << Sabo::Tabby::InitHandler::INSTANCE 85 | @handlers << logger if logging 86 | @handlers << Sabo::Tabby::StaticFileHandler.new(public_folder) 87 | end 88 | 89 | private def setup_themes 90 | @theme["error_page"] = ErrorPageTheme::Default 91 | @theme["dir_listing"] = DirectoryListingTheme::Default 92 | end 93 | end 94 | 95 | def self.config 96 | yield Config::INSTANCE 97 | end 98 | 99 | def self.config 100 | Config::INSTANCE 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/sabo-tabby.cr: -------------------------------------------------------------------------------- 1 | require "http" 2 | require "json" 3 | require "uri" 4 | require "colorize" 5 | require "crustache" 6 | 7 | require "./sabo-tabby/*" 8 | require "./sabo-tabby/helpers/*" 9 | 10 | module Sabo::Tabby 11 | # The app name, shown in logs and CLI messages. 12 | APP_NAME = "sabo-tabby" 13 | # The app version, read from its `shard.yml`. 14 | VERSION = {{read_file("#{__DIR__}/../shard.yml").split("version: ")[1].split("\n")[0]}} 15 | # The app logo, URI encoded for usage in ecr. 16 | LOGO = URI.encode_path({{read_file("#{__DIR__}/../logo.svg")}}) 17 | # The `Server` header. 18 | SERVER_HEADER = "#{Sabo::Tabby::APP_NAME}/#{Sabo::Tabby::VERSION}" 19 | # Licenses of sabo-tabby, kemal and shards 20 | LICENSE = {{run("./licenses.cr").stringify}} 21 | # Emojis used in logs. 22 | EMOJIS = { 23 | base: { 24 | "🐈", 25 | "🐱", 26 | }, 27 | happy: { 28 | "😺", 29 | "😽", 30 | "😻", 31 | "😸", 32 | }, 33 | sad: { 34 | "😿", 35 | "🙀", 36 | "😾", 37 | }, 38 | sleepy: { 39 | "😴", 40 | "😪", 41 | "🥱", 42 | "💤", 43 | "🛏️", 44 | }, 45 | } 46 | 47 | # Only colorize on tty. 48 | Colorize.on_tty_only! 49 | 50 | # The command to run a `Sabo::Tabby` application. 51 | # 52 | # To use custom command line arguments, set args to nil 53 | def self.run(args : Array(String)? = ARGV) : Nil 54 | Sabo::Tabby::CLI.new args 55 | config = Sabo::Tabby.config 56 | config.setup 57 | setup_trap_signal 58 | server = config.server ||= HTTP::Server.new(config.handlers) 59 | config.running = true 60 | 61 | # Abort if block called `Sabo::Tabby#stop` 62 | return unless config.running 63 | 64 | unless server.each_address { |_| break true } 65 | begin 66 | {% if flag?(:without_openssl) %} 67 | server.bind_tcp(config.host_binding, config.port) 68 | {% else %} 69 | if ssl = config.ssl 70 | server.bind_tls(config.host_binding, config.port, ssl) 71 | else 72 | server.bind_tcp(config.host_binding, config.port) 73 | end 74 | {% end %} 75 | rescue ex 76 | unless (msg = ex.message).nil? 77 | abort abort_log(msg) 78 | else 79 | raise ex 80 | end 81 | end 82 | end 83 | 84 | display_startup_message(config, server) 85 | 86 | server.listen 87 | end 88 | 89 | # Logs a startup message. 90 | def self.display_startup_message(config : Sabo::Tabby::Config, server : HTTP::Server) : Nil 91 | addresses = server.addresses.join ", " { |address| "#{config.scheme}://#{address}" } 92 | log "[#{APP_NAME}] is ready to lead at #{addresses.colorize.mode(:bold)}".colorize(:light_magenta), ignore_pipe: true 93 | end 94 | 95 | # Stops the server. 96 | def self.stop : Nil 97 | abort "#{Sabo::Tabby.config.emoji ? EMOJIS[:sad].sample : nil} [#{APP_NAME}] has already gone to bed. #{Sabo::Tabby.config.emoji ? EMOJIS[:sleepy].sample : nil}".colorize(:red) unless config.running 98 | if server = config.server 99 | server.close unless server.closed? 100 | config.running = false 101 | else 102 | abort abort_log("Sabo::Tabby.config.server is not set. Please use Sabo::Tabby.run to set the server.") 103 | end 104 | end 105 | 106 | private def self.setup_trap_signal : Nil 107 | Signal::INT.trap do 108 | log( 109 | "[#{APP_NAME}] is going to bed... #{Sabo::Tabby.config.emoji ? EMOJIS[:sleepy].sample : nil}".colorize(:light_magenta), 110 | newline: true, 111 | ignore_pipe: true 112 | ) 113 | Sabo::Tabby.stop 114 | exit 115 | end 116 | end 117 | end 118 | 119 | # Ignore if running in spec. 120 | {% unless @top_level.has_constant? "Spec" %} 121 | Sabo::Tabby.run 122 | {% end %} 123 | -------------------------------------------------------------------------------- /src/sabo-tabby/static_file_handler.cr: -------------------------------------------------------------------------------- 1 | module Sabo::Tabby 2 | create_theme_index "directory_listing", ["gradient", "flat", "material"] 3 | 4 | class StaticFileHandler < HTTP::StaticFileHandler 5 | record DirectoryListing, request_path : String, path : String, children : Array(String), theme : String do 6 | getter logo : String = Sabo::Tabby::LOGO 7 | 8 | def each_entry 9 | children.each do |entry| 10 | next if !Sabo::Tabby.config.serve_hidden && entry.starts_with?('.') 11 | yield entry, File.file?(Path[path, entry]) 12 | end 13 | end 14 | 15 | def css : Tuple 16 | {Sabo::Tabby::RESET_CSS, Sabo::Tabby::DIRECTORY_LISTING_INDEX[theme.downcase]} 17 | end 18 | 19 | ECR.def_to_s "#{__DIR__}/ecr/directory_listing.ecr" 20 | end 21 | 22 | private def directory_listing(io, request_path, path) 23 | theme = Sabo::Tabby.config.theme["dir_listing"] 24 | # Sort dir items alphabetically. 25 | sorted_files = Dir.children(path).sort 26 | request_path_string = request_path.to_s 27 | # If it's a Mustache file, create a model and render it, else render the ecr theme. 28 | if theme.is_a?(Crustache::Syntax::Template) 29 | entries = [] of Hash(String, String | Bool) 30 | request_path_encoded = URI.encode_path(request_path_string) 31 | 32 | sorted_files.each do |entry| 33 | next if !Sabo::Tabby.config.serve_hidden && entry.starts_with?('.') 34 | entries << {"path" => entry, "file" => File.file?(Path[path, entry]), "href" => "#{request_path_encoded}#{URI.encode_path(entry)}"} 35 | end 36 | 37 | model = { 38 | "request_path" => request_path_string, 39 | "entries" => entries, 40 | } 41 | 42 | Crustache.render theme, model, Crustache::HashFileSystem.new, io 43 | else 44 | DirectoryListing.new(request_path_string, path.to_s, sorted_files, theme.to_s).to_s(io) 45 | end 46 | end 47 | 48 | def call(context : HTTP::Server::Context) 49 | # Only accept "GET" & "HEAD" requests. 50 | unless {"GET", "HEAD"}.includes?(context.request.method) 51 | context.response.status_code = 405 52 | context.response.headers.add("Allow", "GET, HEAD") 53 | return 54 | end 55 | 56 | original_path = context.request.path.not_nil! 57 | request_path = URI.decode(original_path) 58 | 59 | # If `Sabo::Tabby::Config#serve_hidden` is false and the request path includes "./" (hidden file or folder), 404. 60 | if !Sabo::Tabby.config.serve_hidden && request_path.includes?("/.") 61 | call_next(context) 62 | return 63 | end 64 | 65 | # File path cannot contains '\0' (NUL) because all filesystem I know 66 | # don't accept '\0' character as file name. 67 | if request_path.includes? '\0' 68 | context.response.status_code = 400 69 | return 70 | end 71 | 72 | expanded_path = File.expand_path(request_path, "/") 73 | is_dir_path = if original_path.ends_with?('/') && !expanded_path.ends_with? '/' 74 | expanded_path = expanded_path + '/' 75 | true 76 | else 77 | expanded_path.ends_with? '/' 78 | end 79 | 80 | file_path = File.join(@public_dir, expanded_path) 81 | is_dir = Dir.exists?(file_path) 82 | 83 | if request_path != expanded_path 84 | redirect_to context, expanded_path 85 | elsif is_dir && !is_dir_path 86 | redirect_to context, expanded_path + '/' 87 | end 88 | 89 | if is_dir 90 | if Sabo::Tabby.config.dir_index && File.exists?(File.join(file_path, "index.html")) 91 | file_path = File.join(@public_dir, expanded_path, "index.html") 92 | 93 | last_modified = modification_time(file_path) 94 | add_cache_headers(context.response.headers, last_modified) 95 | 96 | if cache_request?(context, last_modified) 97 | context.response.status_code = 304 98 | return 99 | end 100 | send_file(context, file_path) 101 | elsif Sabo::Tabby.config.dir_listing 102 | context.response.content_type = "text/html; charset=UTF-8" 103 | directory_listing(context.response, request_path, file_path) 104 | else 105 | call_next(context) 106 | end 107 | elsif File.exists?(file_path) 108 | last_modified = modification_time(file_path) 109 | add_cache_headers(context.response.headers, last_modified) 110 | 111 | if cache_request?(context, last_modified) 112 | context.response.status_code = 304 113 | return 114 | end 115 | send_file(context, file_path) 116 | else 117 | call_next(context) 118 | end 119 | end 120 | 121 | private def modification_time(file_path) 122 | File.info(file_path).modification_time 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /src/sabo-tabby/log_handler.cr: -------------------------------------------------------------------------------- 1 | module Sabo::Tabby 2 | enum Config::LoggerStyle 3 | Default 4 | Extended 5 | NCSA 6 | NCSA_Extended 7 | Kemal 8 | end 9 | 10 | # Uses `STDOUT` by default and handles the logging of request/response process time. 11 | class LogHandler 12 | include HTTP::Handler 13 | 14 | TTY = STDOUT.tty? 15 | 16 | macro generate_loggers(io) 17 | # Colorize with green if successful else red. 18 | Colorize.with.fore(success ? :green : :red).surround({{io}}) do 19 | case Sabo::Tabby.config.logger_style 20 | when Sabo::Tabby::Config::LoggerStyle::NCSA, Sabo::Tabby::Config::LoggerStyle::NCSA_Extended 21 | ncsa(context, io: {{io}}) 22 | when Sabo::Tabby::Config::LoggerStyle::Kemal 23 | kemal(context, elapsed_text(elapsed_time), io: {{io}}) 24 | else 25 | sabo(context, elapsed_text(elapsed_time), success, io: {{io}}) 26 | end 27 | end 28 | end 29 | 30 | def initialize(@io : IO = STDOUT) 31 | end 32 | 33 | def call(context : HTTP::Server::Context) 34 | elapsed_time = Time.measure { call_next(context) } 35 | success = context.response.status_code < 400 36 | 37 | # In multithreaded mode, STDOUT is not "safe" so we need to 38 | # create a string instead of appending directly to it. 39 | # 40 | # https://github.com/crystal-lang/crystal/issues/8140 41 | {% if flag?(:preview_mt) %} 42 | print(String.build do |io| 43 | generate_loggers(io) 44 | end) 45 | {% else %} 46 | generate_loggers(@io) 47 | 48 | @io.flush if TTY 49 | {% end %} 50 | 51 | context 52 | end 53 | 54 | def write(message : String, ignore_pipe : Bool = false) 55 | return @io if ignore_pipe && !@io.tty? 56 | @io << message 57 | @io.flush 58 | @io 59 | end 60 | 61 | private def kemal(context : HTTP::Server::Context, elapsed_text : String, io : IO = @io) 62 | io << Time.utc 63 | io << ' ' 64 | io << context.response.status_code 65 | io << ' ' 66 | io << context.request.method 67 | io << ' ' 68 | io << context.request.resource 69 | io << ' ' 70 | io << elapsed_text 71 | io << '\n' 72 | end 73 | 74 | private def sabo(context : HTTP::Server::Context, elapsed_text : String, success : Bool, emoji : Bool = Sabo::Tabby.config.emoji, extended : Bool = Sabo::Tabby.config.logger_style == Sabo::Tabby::Config::LoggerStyle::Extended, io : IO = @io) 75 | req = context.request 76 | res = context.response 77 | 78 | if emoji 79 | io << EMOJIS[success ? :happy : :sad].sample 80 | io << ' ' 81 | end 82 | io << '[' 83 | io << Time.utc 84 | io << "] [" 85 | unless (address = req.remote_address).nil? 86 | if address.is_a?(Socket::IPAddress) 87 | io << address.address 88 | elsif address.is_a?(Socket::UNIXAddress) 89 | io << address.path 90 | else 91 | io << "0.0.0.0" 92 | end 93 | else 94 | io << "0.0.0.0" 95 | end 96 | io << "] [" 97 | io << context.response.status_code 98 | io << "] [" 99 | io << context.request.method 100 | io << "] [" 101 | io << context.request.resource 102 | io << "] " 103 | if extended 104 | io << "[" 105 | io << res.headers.fetch("Content-Length", "compressed") 106 | io << "] [" 107 | io << req.headers.fetch("Referer", "-") 108 | io << "] [" 109 | io << req.headers.fetch("User-Agent", "-") 110 | io << "] " 111 | end 112 | io << "[" 113 | io << elapsed_text 114 | io << ']' 115 | io << '\n' 116 | end 117 | 118 | # https://en.wikipedia.org/wiki/Common_Log_Format 119 | private def ncsa(context : HTTP::Server::Context, extended : Bool = Sabo::Tabby.config.logger_style == Sabo::Tabby::Config::LoggerStyle::NCSA_Extended, io : IO = @io) 120 | req = context.request 121 | res = context.response 122 | 123 | unless (address = req.remote_address).nil? 124 | if address.is_a?(Socket::IPAddress) 125 | io << address.address 126 | elsif address.is_a?(Socket::UNIXAddress) 127 | io << address.path 128 | else 129 | io << "0.0.0.0" 130 | end 131 | else 132 | io << "0.0.0.0" 133 | end 134 | io << " - - [" 135 | io << Time.local.to_s("%d/%b/%Y:%H:%M:%S %z") 136 | io << "] \"" 137 | io << req.method 138 | io << ' ' 139 | io << req.resource 140 | io << ' ' 141 | io << req.version 142 | io << "\" " 143 | io << res.status_code 144 | io << ' ' 145 | io << res.headers.fetch("Content-Length", "0") 146 | if extended 147 | io << " \"" 148 | io << req.headers.fetch("Referer", "-") 149 | io << "\" \"" 150 | io << req.headers.fetch("User-Agent", "-") 151 | io << '"' 152 | end 153 | io << '\n' 154 | end 155 | 156 | private def elapsed_text(elapsed) 157 | millis = elapsed.total_milliseconds 158 | return "#{millis.round(2)}ms" if millis >= 1 159 | 160 | "#{(millis * 1000).round(2)}µs" 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /src/sabo-tabby/helpers/helpers.cr: -------------------------------------------------------------------------------- 1 | require "compress/deflate" 2 | require "compress/gzip" 3 | require "mime" 4 | 5 | # Logs the output via `logger`. 6 | # This is the built-in `Sabo::Tabby::LogHandler` by default which uses STDOUT. 7 | # 8 | # If *newline* is `true` then the log will start with a newline. 9 | # 10 | # If *ignore_pipe* is `true` then it won't be logged if it's not a TTY. 11 | # 12 | # If *emoji* is `false` then it won't prefix the log with a `Sabo::Tabby::EMOJIS[:base]`. 13 | def log(message : String | Colorize::Object(String), newline : Bool = false, ignore_pipe : Bool = false, emoji : Bool = Sabo::Tabby.config.emoji) 14 | Sabo::Tabby.config.logger.write( 15 | String.build do |io| 16 | io << '\n' if newline 17 | if emoji 18 | io << Sabo::Tabby::EMOJIS[:base].sample 19 | io << ' ' 20 | end 21 | io << message 22 | io << '\n' 23 | end, 24 | ignore_pipe 25 | ) 26 | end 27 | 28 | # Returns an error formatted string 29 | def abort_log(message : String) : Colorize::Object(String) 30 | "[ERROR][#{Sabo::Tabby::APP_NAME}]: #{message}".colorize(:red) 31 | end 32 | 33 | # Send a file with given path and base the mime-type on the file extension 34 | # or default `application/octet-stream` mime_type. 35 | # 36 | # ``` 37 | # send_file env, "./path/to/file" 38 | # ``` 39 | # 40 | # Optionally you can override the mime_type 41 | # 42 | # ``` 43 | # send_file env, "./path/to/file", "image/jpeg" 44 | # ``` 45 | # 46 | # Also you can set the filename and the disposition 47 | # 48 | # ``` 49 | # send_file env, "./path/to/file", filename: "image.jpg", disposition: "attachment" 50 | # ``` 51 | def send_file(env : HTTP::Server::Context, path : String, mime_type : String? = nil, *, filename : String? = nil, disposition : String? = nil) 52 | file_path = File.expand_path(path, Dir.current) 53 | mime_type ||= MIME.from_filename(file_path, "application/octet-stream") 54 | mime_type = "#{mime_type}; charset=UTF-8" if mime_type.downcase.starts_with?("text") 55 | env.response.content_type = mime_type 56 | env.response.headers["Accept-Ranges"] = "bytes" 57 | env.response.headers["X-Content-Type-Options"] = "nosniff" 58 | minsize = 860 # http://webmasters.stackexchange.com/questions/31750/what-is-recommended-minimum-object-size-for-gzip-performance-benefits 59 | request_headers = env.request.headers 60 | filesize = File.size(file_path) 61 | attachment(env, filename, disposition) 62 | 63 | File.open(file_path) do |file| 64 | if env.request.method == "GET" && env.request.headers.has_key?("Range") 65 | next multipart(file, env) 66 | end 67 | 68 | condition = Sabo::Tabby.config.gzip && filesize > minsize && Sabo::Tabby::Utils.zip_types(file_path) 69 | if condition && request_headers.includes_word?("Accept-Encoding", "gzip") 70 | env.response.headers["Content-Encoding"] = "gzip" 71 | Compress::Gzip::Writer.open(env.response) do |deflate| 72 | IO.copy(file, deflate) 73 | end 74 | elsif condition && request_headers.includes_word?("Accept-Encoding", "deflate") 75 | env.response.headers["Content-Encoding"] = "deflate" 76 | Compress::Deflate::Writer.open(env.response) do |deflate| 77 | IO.copy(file, deflate) 78 | end 79 | else 80 | env.response.content_length = filesize 81 | IO.copy(file, env.response) 82 | end 83 | end 84 | return 85 | end 86 | 87 | # Send a file with given data and default `application/octet-stream` mime_type. 88 | # 89 | # ``` 90 | # send_file env, data_slice 91 | # ``` 92 | # 93 | # Optionally you can override the mime_type 94 | # 95 | # ``` 96 | # send_file env, data_slice, "image/jpeg" 97 | # ``` 98 | # 99 | # Also you can set the filename and the disposition 100 | # 101 | # ``` 102 | # send_file env, data_slice, filename: "image.jpg", disposition: "attachment" 103 | # ``` 104 | def send_file(env : HTTP::Server::Context, data : Slice(UInt8), mime_type : String? = nil, *, filename : String? = nil, disposition : String? = nil) 105 | mime_type ||= "application/octet-stream" 106 | env.response.content_type = mime_type 107 | env.response.content_length = data.bytesize 108 | attachment(env, filename, disposition) 109 | env.response.write data 110 | end 111 | 112 | private def multipart(file, env : HTTP::Server::Context) 113 | # See https://httpwg.org/specs/rfc7233.html 114 | fileb = file.size 115 | startb = endb = 0_i64 116 | 117 | if match = env.request.headers["Range"].match /bytes=(\d{1,})-(\d{0,})/ 118 | startb = match[1].to_i64 { 0_i64 } if match.size >= 2 119 | endb = match[2].to_i64 { 0_i64 } if match.size >= 3 120 | end 121 | 122 | endb = fileb - 1 if endb == 0 123 | 124 | if startb < endb < fileb 125 | content_length = 1_i64 + endb - startb 126 | env.response.status_code = 206 127 | env.response.content_length = content_length 128 | env.response.headers["Accept-Ranges"] = "bytes" 129 | env.response.headers["Content-Range"] = "bytes #{startb}-#{endb}/#{fileb}" # MUST 130 | 131 | file.seek(startb) 132 | IO.copy(file, env.response, content_length) 133 | else 134 | env.response.content_length = fileb 135 | env.response.status_code = 200 # Range not satisfable, see 4.4 Note 136 | IO.copy(file, env.response) 137 | end 138 | end 139 | 140 | # Set the Content-Disposition to "attachment" with the specified filename, 141 | # instructing the user agents to prompt to save. 142 | private def attachment(env : HTTP::Server::Context, filename : String? = nil, disposition : String? = nil) 143 | disposition = "attachment" if disposition.nil? && filename 144 | if disposition && filename 145 | env.response.headers["Content-Disposition"] = "#{disposition}; filename=\"#{File.basename(filename)}\"" 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | coc@geopjr.dev. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 126 | at [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /spec/static_file_handler_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | private def handle_request(request, decompress = true) : HTTP::Client::Response 4 | io = IO::Memory.new 5 | response = HTTP::Server::Response.new(io) 6 | context = HTTP::Server::Context.new(request, response) 7 | handler = Sabo::Tabby::StaticFileHandler.new "#{__DIR__}/static" 8 | handler.call context 9 | response.close 10 | io.rewind 11 | HTTP::Client::Response.from_io(io, decompress: decompress) 12 | end 13 | 14 | describe Sabo::Tabby::StaticFileHandler do 15 | file = File.open "#{__DIR__}/static/cats/small.txt" 16 | file_size = file.size 17 | 18 | it "should serve a file with content type and etag" do 19 | response = handle_request HTTP::Request.new("GET", "/cats/small.txt") 20 | response.status_code.should eq(200) 21 | response.headers["Content-Type"].should eq "text/plain; charset=UTF-8" 22 | response.headers["Etag"].should contain "W/\"" 23 | response.body.should eq(File.read("#{__DIR__}/static/cats/small.txt")) 24 | end 25 | 26 | it "should serve an image file with content type" do 27 | response = handle_request HTTP::Request.new("GET", "/cats/garfield.svg") 28 | response.status_code.should eq(200) 29 | response.headers["Content-Type"].downcase.should match(/^image\/.+(? etag} 50 | response = handle_request HTTP::Request.new("GET", "/cats/small.txt", headers) 51 | response.headers["Content-Type"]?.should be_nil 52 | response.status_code.should eq(304) 53 | response.body.should eq "" 54 | end 55 | 56 | it "should not list directory's entries" do 57 | Sabo::Tabby.config.dir_listing = false 58 | 59 | response = handle_request HTTP::Request.new("GET", "/cats/") 60 | response.status_code.should eq(404) 61 | end 62 | 63 | it "should list directory's entries" do 64 | Sabo::Tabby.config.dir_listing = true 65 | 66 | response = handle_request HTTP::Request.new("GET", "/cats/") 67 | response.status_code.should eq(200) 68 | response.body.should match(/big.txt/) 69 | end 70 | 71 | it "should gzip a file if config is true, headers accept gzip and file is > 880 bytes" do 72 | Sabo::Tabby.config.gzip = true 73 | 74 | headers = HTTP::Headers{"Accept-Encoding" => "gzip, deflate, sdch, br"} 75 | response = handle_request HTTP::Request.new("GET", "/cats/big.txt", headers), decompress: false 76 | response.status_code.should eq(200) 77 | response.headers["Content-Encoding"].should eq "gzip" 78 | end 79 | 80 | it "should not gzip a file if config is true, headers accept gzip and file is < 880 bytes" do 81 | Sabo::Tabby.config.gzip = true 82 | 83 | headers = HTTP::Headers{"Accept-Encoding" => "gzip, deflate, sdch, br"} 84 | response = handle_request HTTP::Request.new("GET", "/cats/small.txt", headers), decompress: false 85 | response.status_code.should eq(200) 86 | response.headers["Content-Encoding"]?.should be_nil 87 | end 88 | 89 | it "should not gzip a file if config is false, headers accept gzip and file is > 880 bytes" do 90 | Sabo::Tabby.config.gzip = false 91 | 92 | headers = HTTP::Headers{"Accept-Encoding" => "gzip, deflate, sdch, br"} 93 | response = handle_request HTTP::Request.new("GET", "/cats/big.txt", headers), decompress: false 94 | response.status_code.should eq(200) 95 | response.headers["Content-Encoding"]?.should be_nil 96 | end 97 | 98 | it "should serve custom static error pages" do 99 | page_content = "🫖" 100 | 101 | # Create custom static error page 102 | current_public_folder = Sabo::Tabby.config.public_folder 103 | folder = Path[Dir.tempdir, Random::Secure.hex(5)] 104 | Dir.mkdir(folder) 105 | Sabo::Tabby.config.public_folder = folder.to_s 106 | 107 | custom_page_path = Path[folder, "404.html"] 108 | File.write(custom_page_path, page_content) 109 | 110 | response = handle_request HTTP::Request.new("GET", "/I_DO_NOT_EXIST") 111 | response.status_code.should eq(404) 112 | response.body.should eq(page_content) 113 | 114 | # Cleanup 115 | Sabo::Tabby.config.public_folder = current_public_folder 116 | File.delete(custom_page_path) 117 | Dir.delete(folder) 118 | end 119 | 120 | it "should not serve a not found file" do 121 | response = handle_request HTTP::Request.new("GET", "/not_found_file.txt") 122 | response.status_code.should eq(404) 123 | end 124 | 125 | it "should not serve a not found directory" do 126 | response = handle_request HTTP::Request.new("GET", "/not_found_dir/") 127 | response.status_code.should eq(404) 128 | end 129 | 130 | it "should not serve a file as directory" do 131 | response = handle_request HTTP::Request.new("GET", "/cats/small.txt/") 132 | response.status_code.should eq(404) 133 | end 134 | 135 | it "should not serve hidden files and folders" do 136 | Sabo::Tabby.config.serve_hidden = false 137 | 138 | response = handle_request HTTP::Request.new("GET", "/cats/.dog") 139 | response.status_code.should eq(404) 140 | 141 | response = handle_request HTTP::Request.new("GET", "/.dogs/") 142 | response.status_code.should eq(404) 143 | 144 | response = handle_request HTTP::Request.new("GET", "/.dogs/ragoon.txt") 145 | response.status_code.should eq(404) 146 | end 147 | 148 | it "should serve hidden files or folders" do 149 | Sabo::Tabby.config.serve_hidden = true 150 | Sabo::Tabby.config.dir_listing = true 151 | 152 | response = handle_request HTTP::Request.new("GET", "/cats/.dog") 153 | response.status_code.should eq(200) 154 | 155 | response = handle_request HTTP::Request.new("GET", "/.dogs/") 156 | response.status_code.should eq(200) 157 | 158 | response = handle_request HTTP::Request.new("GET", "/.dogs/ragoon.txt") 159 | response.status_code.should eq(200) 160 | end 161 | 162 | it "should handle only GET and HEAD methods" do 163 | %w(GET HEAD).each do |method| 164 | response = handle_request HTTP::Request.new(method, "/cats/small.txt") 165 | response.status_code.should eq(200) 166 | end 167 | 168 | %w(POST PUT DELETE).each do |method| 169 | response = handle_request HTTP::Request.new(method, "/cats/small.txt") 170 | response.status_code.should eq(405) 171 | response.headers["Allow"].should eq("GET, HEAD") 172 | end 173 | end 174 | 175 | it "should send part of files when requested (RFC7233)" do 176 | %w(POST PUT DELETE HEAD).each do |method| 177 | headers = HTTP::Headers{"Range" => "0-100"} 178 | response = handle_request HTTP::Request.new(method, "/cats/small.txt", headers) 179 | response.status_code.should_not eq(206) 180 | response.headers.has_key?("Content-Range").should eq(false) 181 | end 182 | 183 | %w(GET).each do |method| 184 | headers = HTTP::Headers{"Range" => "0-100"} 185 | response = handle_request HTTP::Request.new(method, "/cats/small.txt", headers) 186 | response.status_code.should eq(206 || 200) 187 | if response.status_code == 206 188 | response.headers.has_key?("Content-Range").should eq true 189 | match = response.headers["Content-Range"].match(/bytes (\d+)-(\d+)\/(\d+)/) 190 | match.should_not be_nil 191 | if match 192 | start_range = match[1].to_i { 0 } 193 | end_range = match[2].to_i { 0 } 194 | range_size = match[3].to_i { 0 } 195 | 196 | range_size.should eq file_size 197 | (end_range < file_size).should eq true 198 | (start_range < end_range).should eq true 199 | end 200 | end 201 | end 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /spec/cli_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Sabo::Tabby::CLI do 4 | r = Random.new 5 | 6 | after_each do 7 | Colorize.enabled = COLORS_ENABLED 8 | end 9 | 10 | describe "config file" do 11 | tempfile = File.tempfile("sabo.tabby.yaml") do |file| 12 | config = <<-YAML 13 | host: #{r.rand(255)}.#{r.rand(255)}.#{r.rand(255)}.#{r.rand(255)} 14 | port: #{r.rand(9999)} 15 | public_folder: ./ 16 | serve_hidden: #{r.next_bool} 17 | 18 | logging: #{r.next_bool} 19 | emoji: #{r.next_bool} 20 | colors: #{r.next_bool} 21 | 22 | server_header: #{r.next_bool} 23 | gzip: #{r.next_bool} 24 | custom_error_page: #{r.next_bool} 25 | 26 | ssl: 27 | key: #{r.hex} 28 | cert: #{r.hex} 29 | 30 | directory: 31 | index: #{r.next_bool} 32 | listing: #{r.next_bool} 33 | 34 | theme: 35 | error: #{Sabo::Tabby::Config::ErrorPageTheme.names.sample} 36 | dir: #{Sabo::Tabby::Config::DirectoryListingTheme.names.sample} 37 | logger: #{Sabo::Tabby::Config::LoggerStyle.names.sample} 38 | YAML 39 | 40 | file.print(config) 41 | end 42 | 43 | before_each do 44 | Colorize.enabled = COLORS_ENABLED 45 | end 46 | 47 | after_all do 48 | tempfile.delete 49 | end 50 | 51 | it "should be parsed correctly" do 52 | config_raw = File.read(tempfile.path) 53 | config = Sabo::Tabby::CLI::Config.from_yaml(config_raw) 54 | config_yaml = YAML.parse(config_raw) 55 | 56 | Colorize.enabled = COLORS_ENABLED 57 | YAML.parse(config.to_yaml).should eq(config_yaml) 58 | end 59 | 60 | it "should set Sabo::Tabby::Config" do 61 | cli = Sabo::Tabby::CLI.new Array(String).new 62 | cli.clear_config 63 | cli.configure_config(Path[tempfile.path]) 64 | config = cli.config 65 | 66 | config_should_be = File.open(tempfile.path) do |file| 67 | Sabo::Tabby::CLI::Config.from_yaml(file) 68 | end 69 | 70 | Colorize.enabled = COLORS_ENABLED 71 | config.host_binding.should eq(config_should_be.host) 72 | config.port.should eq(config_should_be.port) 73 | # its actually the file's path as the path is relative to the config 74 | config.public_folder.should eq(Path[tempfile.path].expand.parent.to_s.rchop('/') + '/') 75 | config.serve_hidden.should eq(config_should_be.serve_hidden) 76 | 77 | config.logging.should eq(config_should_be.logging) 78 | config.emoji.should eq(config_should_be.emoji) 79 | 80 | config.server_header.should eq(config_should_be.server_header) 81 | config.gzip.should eq(config_should_be.gzip) 82 | config.error_page.should eq(config_should_be.custom_error_page) 83 | 84 | config.dir_index.should eq(config_should_be.directory.try &.index) 85 | config.dir_listing.should eq(config_should_be.directory.try &.listing) 86 | 87 | config.theme["error_page"].to_s.should eq(config_should_be.theme.try &.error) 88 | config.theme["dir_listing"].to_s.should eq(config_should_be.theme.try &.dir) 89 | config.logger_style.to_s.should eq(config_should_be.theme.try &.logger) 90 | end 91 | end 92 | 93 | describe "logger style" do 94 | cli = Sabo::Tabby::CLI.new Array(String).new 95 | 96 | it "should return the Sabo::Tabby::Config::LoggerStyle from string" do 97 | style = Sabo::Tabby::Config::LoggerStyle.from_value(1) # not default 98 | 99 | cli.configure_logger_style(style.to_s).should eq(style) 100 | end 101 | 102 | it "should return the default if style is not part of Sabo::Tabby::Config::LoggerStyle" do 103 | style = r.hex 104 | 105 | cli.configure_logger_style(style).should eq(Sabo::Tabby::Config::LoggerStyle::Default) 106 | end 107 | end 108 | 109 | describe "public folder" do 110 | cli = Sabo::Tabby::CLI.new Array(String).new 111 | 112 | it "should set the Sabo::Tabby.config.public_folder if it exists" do 113 | cli.clear_config 114 | config = cli.config 115 | current_public_folder = config.public_folder 116 | 117 | folder = Path[Dir.tempdir, r.hex(5)] 118 | Dir.mkdir(folder) 119 | cli.configure_public_folder(folder.to_s) 120 | 121 | config.public_folder.should eq(folder.to_s) 122 | config.public_folder = current_public_folder 123 | end 124 | 125 | it "should abort if public_folder does not exist" do 126 | folder = Path[Dir.tempdir, r.hex(5)] 127 | 128 | abort_message = disable_colorize { abort_log("\"#{folder}\" doesn't exist.") } 129 | result = disable_colorize { cli.configure_public_folder(folder.to_s) } 130 | 131 | result.should eq(abort_message) 132 | end 133 | end 134 | 135 | describe "page themes" do 136 | cli = Sabo::Tabby::CLI.new Array(String).new 137 | page_themes = { 138 | Sabo::Tabby::Config::ErrorPageTheme, 139 | Sabo::Tabby::Config::DirectoryListingTheme, 140 | } 141 | 142 | it "should return the Sabo::Tabby::Config::*Theme from string" do 143 | page_themes.each do |theme_enum_class| 144 | style = theme_enum_class.from_value(1) 145 | 146 | cli.configure_theme(theme_enum_class, style.to_s).should eq(style) 147 | end 148 | end 149 | 150 | it "should return the default if style not in Sabo::Tabby::Config::*Theme" do 151 | page_themes.each do |theme_enum_class| 152 | style = r.hex 153 | 154 | cli.configure_theme(theme_enum_class, style.to_s).should eq(theme_enum_class.from_value(0)) 155 | end 156 | end 157 | 158 | it "should return a Crustache instance if a path to a moustache file is provided" do 159 | mst = File.tempfile("template.mst") do |file| 160 | config = <<-MST 161 | {{test}} 162 | MST 163 | 164 | file.print(config) 165 | end 166 | 167 | page_themes.each do |theme_enum_class| 168 | model = {"test" => r.hex} 169 | style = cli.configure_theme(theme_enum_class, mst.path, Path[Dir.tempdir]) 170 | 171 | style.is_a?(Crustache::Syntax::Template).should be_true 172 | Crustache.render(style.as(Crustache::Syntax::Template), model).should eq(model["test"]) 173 | end 174 | mst.delete 175 | end 176 | end 177 | 178 | describe "cli args" do 179 | results = { 180 | bind: "#{r.rand(255)}.#{r.rand(255)}.#{r.rand(255)}.#{r.rand(255)}", 181 | port: r.rand(9999), 182 | public_folder: Dir.tempdir, 183 | serve_hidden: r.next_bool, 184 | 185 | ssl: r.next_bool, 186 | ssl_key_file: r.hex, 187 | ssl_cert_file: r.hex, 188 | 189 | error_page_theme: Sabo::Tabby::Config::ErrorPageTheme.names.sample, 190 | dir_listing_theme: Sabo::Tabby::Config::DirectoryListingTheme.names.sample, 191 | logger_style: Sabo::Tabby::Config::LoggerStyle.names.sample, 192 | 193 | no_logging: r.next_bool, 194 | no_emoji: r.next_bool, 195 | no_colors: r.next_bool, 196 | 197 | no_server_header: r.next_bool, 198 | no_gzip: r.next_bool, 199 | no_dir_index: r.next_bool, 200 | no_dir_listing: r.next_bool, 201 | no_error_page: r.next_bool, 202 | } 203 | args = { 204 | "--bind=#{results[:bind]}", 205 | "--port=#{results[:port]}", 206 | "--public-folder=#{results[:public_folder]}", 207 | "#{results[:serve_hidden] ? "--serve-hidden" : nil}", 208 | 209 | "#{results[:ssl] ? "--ssl" : nil}", 210 | "--ssl-key-file=#{results[:ssl_key_file]}", 211 | "--ssl-cert-file=#{results[:ssl_cert_file]}", 212 | 213 | "--error-page-theme=#{results[:error_page_theme]}", 214 | "--dir-listing-theme=#{results[:dir_listing_theme]}", 215 | "--logger-style=#{results[:logger_style]}", 216 | 217 | "#{results[:no_logging] ? "--no-logging" : nil}", 218 | "#{results[:no_emoji] ? "--no-emoji" : nil}", 219 | "#{results[:no_colors] ? "--no-colors" : nil}", 220 | 221 | "#{results[:no_server_header] ? "--no-server-header" : nil}", 222 | "#{results[:no_gzip] ? "--no-gzip" : nil}", 223 | "#{results[:no_dir_index] ? "--no-dir-index" : nil}", 224 | "#{results[:no_dir_listing] ? "--no-dir-listing" : nil}", 225 | "#{results[:no_error_page] ? "--no-error-page" : nil}", 226 | } 227 | 228 | it "should set config" do 229 | cli = Sabo::Tabby::CLI.new Array(String).new 230 | cli.clear_config 231 | cli.parse(args.to_a) 232 | config = cli.config 233 | 234 | Colorize.enabled = COLORS_ENABLED 235 | config.host_binding.should eq(results[:bind]) 236 | config.port.should eq(results[:port]) 237 | config.public_folder.should eq(Dir.tempdir) 238 | config.serve_hidden.should eq(results[:serve_hidden]) 239 | 240 | config.theme["error_page"].to_s.should eq(results[:error_page_theme]) 241 | config.theme["dir_listing"].to_s.should eq(results[:dir_listing_theme]) 242 | config.logger_style.to_s.should eq(results[:logger_style]) 243 | 244 | config.logging.should eq(!results[:no_logging]) 245 | config.emoji.should eq(!results[:no_emoji]) 246 | 247 | config.server_header.should eq(!results[:no_server_header]) 248 | config.gzip.should eq(!results[:no_gzip]) 249 | config.dir_index.should eq(!results[:no_dir_index]) 250 | config.dir_listing.should eq(!results[:no_dir_listing]) 251 | config.error_page.should eq(!results[:no_error_page]) 252 | end 253 | end 254 | end 255 | -------------------------------------------------------------------------------- /src/sabo-tabby/cli.cr: -------------------------------------------------------------------------------- 1 | require "option_parser" 2 | require "yaml" 3 | 4 | module Sabo::Tabby 5 | # Handles all the initialization from the command line & config files. 6 | class CLI 7 | TEMPLATE_EXTENSIONS = {".mst", ".html", ".mustache"} 8 | CONFIG_EXTENSIONS = {".yaml", ".yml"} 9 | CONFIG_NAMES = {"sabo-tabby", "sabotabby", "sabo.tabby"} 10 | 11 | # A config file. 12 | class Config 13 | include YAML::Serializable 14 | 15 | getter host : String? 16 | getter port : Int32? 17 | getter public_folder : String? 18 | getter serve_hidden : Bool? 19 | 20 | getter ssl : ConfigSSL? 21 | getter theme : ConfigTheme? 22 | 23 | getter logging : Bool? 24 | getter emoji : Bool? 25 | getter colors : Bool? 26 | 27 | getter server_header : Bool? 28 | getter gzip : Bool? 29 | getter directory : ConfigDirectory? 30 | getter custom_error_page : Bool? 31 | end 32 | 33 | # The directory options part of the config. 34 | class ConfigDirectory 35 | include YAML::Serializable 36 | 37 | getter index : Bool? 38 | getter listing : Bool? 39 | end 40 | 41 | # The SSL part of the config. 42 | class ConfigSSL 43 | include YAML::Serializable 44 | 45 | getter key : String 46 | getter cert : String 47 | end 48 | 49 | # The theming options part of the config. 50 | class ConfigTheme 51 | include YAML::Serializable 52 | 53 | getter error : String? 54 | getter dir : String? 55 | getter logger : String? 56 | end 57 | 58 | # Creates a `Sabo::Tabby::CLI` with the specified CLI *args*. 59 | def initialize(args : Array(String)?) 60 | @ssl_enabled = false 61 | @key_file = "" 62 | @cert_file = "" 63 | @config = Sabo::Tabby.config 64 | 65 | # Ignore if running in spec. 66 | {% unless @top_level.has_constant? "Spec" %} 67 | # If *args* were provided, call `OptionParser`, else try to load config from current dir. 68 | if args.size > 0 69 | parse args 70 | else 71 | Dir.each_child(".") do |item| 72 | ext = File.extname(item) 73 | next unless CONFIG_EXTENSIONS.includes?(ext.downcase) && CONFIG_NAMES.includes?(File.basename(item, ext).downcase) 74 | configure_config(Path[item]) 75 | end 76 | end 77 | {% end %} 78 | 79 | configure_ssl 80 | end 81 | 82 | # Parses args from CLI. 83 | private def parse(args : Array(String)) 84 | OptionParser.parse args do |opts| 85 | opts.banner = <<-BANNER 86 | #{APP_NAME.colorize(:light_magenta)} #{('v' + VERSION).colorize(:light_magenta)} 87 | 88 | #{"Usage:".colorize(:light_magenta)} #{APP_NAME} [arguments] 89 | 90 | #{"Examples:".colorize(:light_magenta)} 91 | #{APP_NAME} 92 | #{APP_NAME} -f ./my_site/ 93 | #{APP_NAME} -b 0.0.0.0 -p 8080 -e flat -d ./dir_listing.mst -l ncsa 94 | #{APP_NAME} -c ./config.yaml 95 | 96 | #{"Arguments:".colorize(:light_magenta)} 97 | BANNER 98 | opts.separator(" Basic".colorize(:light_magenta)) 99 | opts.on("-b HOST", "--bind HOST", "Host to bind [default: #{@config.host_binding}]") do |host_binding| 100 | @config.host_binding = host_binding 101 | end 102 | opts.on("-p PORT", "--port PORT", "Port to listen for connections [default: #{@config.port}]") do |opt_port| 103 | @config.port = opt_port.to_i 104 | end 105 | opts.on("-f DIR", "--public-folder DIR", "Set which folder to server [default: ./]") do |folder| 106 | configure_public_folder(folder) 107 | end 108 | opts.on("-c FILE", "--config FILE", "Load config from file") do |config| 109 | path = Path[config] 110 | abort abort_log("\"#{path}\" doesn't exist or is not a YAML file.") unless File.exists?(path) && CONFIG_EXTENSIONS.includes?(File.extname(path).downcase) 111 | configure_config(path) 112 | end 113 | opts.on("--serve-hidden", "Enable serving hidden folders and files") do 114 | @config.serve_hidden = true 115 | end 116 | opts.on("--licenses", "Shows the licenses of the app and its dependencies") do 117 | puts LICENSE 118 | exit 0 119 | end 120 | opts.on("-h", "--help", "Shows this help") do 121 | puts opts 122 | exit 0 123 | end 124 | 125 | opts.separator 126 | opts.separator(" SSL".colorize(:light_magenta)) 127 | opts.on("-s", "--ssl", "Enables SSL") do 128 | @ssl_enabled = true 129 | end 130 | opts.on("--ssl-key-file FILE", "SSL key file") do |key_file| 131 | @key_file = key_file 132 | end 133 | opts.on("--ssl-cert-file FILE", "SSL certificate file") do |cert_file| 134 | @cert_file = cert_file 135 | end 136 | 137 | opts.separator 138 | opts.separator(" Theming".colorize(:light_magenta)) 139 | opts.on("-e THEME", "--error-page-theme THEME", "Either error page theme or path to custom mustache file [available: #{Sabo::Tabby::Config::ErrorPageTheme.names.sort.join(", ")}] [default: #{Sabo::Tabby::Config::ErrorPageTheme.from_value(0)}]") do |theme| 140 | @config.theme["error_page"] = configure_theme(Sabo::Tabby::Config::ErrorPageTheme, theme) 141 | end 142 | opts.on("-d THEME", "--dir-listing-theme THEME", "Either dir listing theme or path to custom mustache file [available: #{Sabo::Tabby::Config::DirectoryListingTheme.names.sort.join(", ")}] [default: #{Sabo::Tabby::Config::DirectoryListingTheme.from_value(0)}]") do |theme| 143 | @config.theme["dir_listing"] = configure_theme(Sabo::Tabby::Config::DirectoryListingTheme, theme) 144 | end 145 | opts.on("-l STYLE", "--logger-style STYLE", "Log style [available: #{Sabo::Tabby::Config::LoggerStyle.names.sort.join(", ")}] [default: #{Sabo::Tabby::Config::LoggerStyle.from_value(0)}]") do |style| 146 | @config.logger_style = configure_logger_style(style) 147 | end 148 | 149 | opts.separator 150 | opts.separator(" Logging".colorize(:light_magenta)) 151 | opts.on("--no-logging", "Disable logging") do 152 | @config.logging = false 153 | end 154 | opts.on("--no-emoji", "Disable emojis in log") do 155 | @config.emoji = false 156 | end 157 | opts.on("--no-colors", "Disable colored output (already disabled in non-tty)") do 158 | Colorize.enabled = false 159 | end 160 | 161 | opts.separator 162 | opts.separator(" Options".colorize(:light_magenta)) 163 | opts.on("--no-server-header", "Disable the 'Server' header") do 164 | @config.server_header = false 165 | end 166 | opts.on("--no-gzip", "Disable gzip") do 167 | @config.gzip = false 168 | end 169 | opts.on("--no-dir-index", "Disable serving /index.html on /") do 170 | @config.dir_index = false 171 | end 172 | opts.on("--no-dir-listing", "Disable directory listing") do 173 | @config.dir_listing = false 174 | end 175 | opts.on("--no-error-page", "Disable custom error page") do 176 | @config.error_page = false 177 | end 178 | 179 | opts.invalid_option do |flag| 180 | message = "#{flag} is not a valid option." 181 | 182 | # Only if running in spec. 183 | {% if @top_level.has_constant? "Spec" %} 184 | next raise message 185 | {% end %} 186 | 187 | STDERR.puts abort_log(message) 188 | STDERR.puts opts 189 | exit(1) 190 | end 191 | end 192 | end 193 | 194 | # Configues `Sabo::Tabby::Config` from config file. 195 | private def configure_config(config_path : Path) 196 | config = File.open(config_path) do |file| 197 | Config.from_yaml(file) 198 | end 199 | 200 | unless (config_host = config.host).nil? 201 | @config.host_binding = config_host 202 | end 203 | unless (config_port = config.port).nil? 204 | @config.port = config_port 205 | end 206 | unless (config_public_folder = config.public_folder).nil? 207 | configure_public_folder(config_public_folder, config_path.parent) 208 | end 209 | unless (config_serve_hidden = config.serve_hidden).nil? 210 | @config.serve_hidden = config_serve_hidden 211 | end 212 | 213 | unless (config_ssl = config.ssl).nil? 214 | unless config_ssl.key == "" || config_ssl.cert == "" 215 | @ssl_enabled = true 216 | @key_file = Path[config_ssl.key].expand(config_path.parent).to_s 217 | @cert_file = Path[config_ssl.cert].expand(config_path.parent).to_s 218 | end 219 | end 220 | unless (config_theme = config.theme).nil? 221 | unless (config_theme_error = config_theme.error).nil? 222 | @config.theme["error_page"] = configure_theme(Sabo::Tabby::Config::ErrorPageTheme, config_theme_error, config_path.parent) 223 | end 224 | 225 | unless (config_theme_dir = config_theme.dir).nil? 226 | @config.theme["dir_listing"] = configure_theme(Sabo::Tabby::Config::DirectoryListingTheme, config_theme_dir, config_path.parent) 227 | end 228 | 229 | unless (config_logger_style = config_theme.logger).nil? 230 | @config.logger_style = configure_logger_style(config_logger_style) 231 | end 232 | end 233 | 234 | unless (config_logging = config.logging).nil? 235 | @config.logging = config_logging 236 | end 237 | unless (config_emoji = config.emoji).nil? 238 | @config.emoji = config_emoji 239 | end 240 | unless (config_colors = config.colors).nil? 241 | Colorize.enabled = config_colors 242 | end 243 | 244 | unless (config_server_header = config.server_header).nil? 245 | @config.server_header = config_server_header 246 | end 247 | unless (config_gzip = config.gzip).nil? 248 | @config.gzip = config_gzip 249 | end 250 | unless (config_dir = config.directory).nil? 251 | unless (config_dir_listing = config_dir.listing).nil? 252 | @config.dir_listing = config_dir_listing 253 | end 254 | unless (config_dir_index = config_dir.index).nil? 255 | @config.dir_index = config_dir_index 256 | end 257 | end 258 | unless (config_custom_error_page = config.custom_error_page).nil? 259 | @config.error_page = config_custom_error_page 260 | end 261 | end 262 | 263 | # Returns the `Sabo::Tabby::Config::LoggerStyle` based on the *style* provided. 264 | # 265 | # If *style* not in `Sabo::Tabby::Config::LoggerStyle`, it returns `Sabo::Tabby::Config::LoggerStyle::Default`. 266 | private def configure_logger_style(style : String) : Sabo::Tabby::Config::LoggerStyle 267 | result = Sabo::Tabby::Config::LoggerStyle::Default 268 | unless (logger_style = Sabo::Tabby::Config::LoggerStyle.parse?(style)).nil? 269 | result = logger_style 270 | end 271 | result 272 | end 273 | 274 | # Configures the `Sabo::Tabby::Config#public_folder`. 275 | # 276 | # *parent* is being used in case the folder is relative to the config file that might not be in the current dir. 277 | private def configure_public_folder(folder : String, parent : Path = Path[Dir.current]) 278 | path = Path[folder].expand(parent) 279 | 280 | if Dir.exists?(path) 281 | @config.public_folder = path.to_s 282 | else 283 | abort abort_log("\"#{path}\" doesn't exist.") 284 | end 285 | end 286 | 287 | # Returns either the *themes*'s item or a `Crustache::Syntax::Template` instance based on *theme*. 288 | # 289 | # If *theme* is part of the *themes* enum, it returns it. 290 | # 291 | # Else if it's a file path (relative to *parent*, in case it's relative to the config file that might not be in the current dir) and it's a mustache file, it parses and returns it. 292 | # 293 | # Else it returns the *theme*'s first item. 294 | private def configure_theme(themes : Enum.class, theme : String, parent : Path = Path[Dir.current]) 295 | result = themes.parse?(theme) 296 | if result.nil? 297 | path = Path[theme].expand(parent) 298 | result = themes.from_value(0) 299 | 300 | if File.exists?(path) && TEMPLATE_EXTENSIONS.includes?(File.extname(path).downcase) 301 | result = File.open(path) do |file| 302 | Crustache.parse(file) 303 | end 304 | end 305 | end 306 | result 307 | end 308 | 309 | # Configures SSL 310 | private def configure_ssl 311 | {% unless flag?(:without_openssl) %} 312 | if @ssl_enabled 313 | abort abort_log("SSL Key \"#{@key_file}\" doesn't exist.") unless @key_file && File.exists?(@key_file) 314 | abort abort_log("SSL Certificate \"#{@key_file}\" doesn't exist.") unless @cert_file && File.exists?(@cert_file) 315 | ssl = Sabo::Tabby::SSL.new 316 | ssl.key_file = @key_file.not_nil! 317 | ssl.cert_file = @cert_file.not_nil! 318 | Sabo::Tabby.config.ssl = ssl.context 319 | end 320 | {% end %} 321 | end 322 | 323 | # Only if running in spec. 324 | {% if @top_level.has_constant? "Spec" %} 325 | def clear_config 326 | @config = Sabo::Tabby::Config.new 327 | end 328 | 329 | def config : Sabo::Tabby::Config 330 | @config 331 | end 332 | 333 | def parse(args : Array(String)) 334 | previous_def 335 | end 336 | 337 | def configure_config(config_path : Path) 338 | previous_def 339 | end 340 | 341 | def configure_logger_style(style : String) 342 | previous_def 343 | end 344 | 345 | def configure_public_folder(folder : String, parent : Path = Path[Dir.current]) 346 | previous_def 347 | end 348 | 349 | def configure_theme(themes : Enum.class, theme : String, parent : Path = Path[Dir.current]) 350 | previous_def 351 | end 352 | 353 | def configure_ssl 354 | previous_def 355 | end 356 | {% end %} 357 | end 358 | end 359 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | sabo-tabby, read more https://archive.iww.org/history/icons/black_cat/, the only difference is that the background is a trans flag 3 |

4 |

sabo-tabby

5 |

Extremely Fast Static File Server

6 |

7 |
8 | Code Of Conduct 9 | BSD-2-Clause 10 | ci action status 11 |

12 | 13 | 14 | # 15 | 16 | ## What is sabo-tabby? 17 | 18 | I tend to mirror static files and sites to Tor and other networks but don't want to bother with setting up services or writing a server using a web framework every time. 19 | 20 | At the same time, I want the extreme speed and safety of Crystal and a fully customizable experience from error & directory listing pages to logging format. 21 | 22 | This is what sabo-tabby is - an extremely fast & customizable static file server. 23 | 24 | sabo-tabby is a fork of [Kemal](https://github.com/kemalcr/kemal) but with all the framework parts stripped away. 25 | 26 | # 27 | 28 | ## Benchmarks 29 | 30 | ![Benchmarks, read https://github.com/GeopJr/sabo-tabby/blob/benchmarks/README.md for full benchmark results](https://raw.githubusercontent.com/GeopJr/sabo-tabby/benchmarks/benchmarks.svg) 31 | 32 | > Benchmarks were done using the `wrk` tool. Please don't take them too seriously, their only use is to show that it is indeed very fast. The frameworks it competes against offer a wide variety of functions and features. All benchmarks are in the `benchmarks` branch. 33 | 34 | # 35 | 36 | ## Installation 37 | 38 | ### Pre-built 39 | 40 | You can download one of the statically-linked pre-built binaries from the [releases page](https://github.com/GeopJr/sabo-tabby/releases/latest). 41 | 42 | They are built & published by our lovely [actions](https://github.com/GeopJr/sabo-tabby/actions/workflows/release.yml). 43 | 44 | ### Building 45 | 46 | #### Dependencies 47 | 48 | - `crystal` - `1.5.0` 49 | 50 | #### Makefile 51 | 52 | - `$ make` (or `$ make static` on Alpine Linux for a static build) 53 | - `# make install # to install it` 54 | 55 | #### Manually 56 | 57 | `$ shards build --production --no-debug --release -Dpreview_mt` 58 | 59 | # 60 | 61 | ## Usage 62 | 63 | ``` 64 | sabo-tabby v1.1.0 65 | 66 | Usage: sabo-tabby [arguments] 67 | 68 | Examples: 69 | sabo-tabby 70 | sabo-tabby -f ./my_site/ 71 | sabo-tabby -b 0.0.0.0 -p 8080 -e flat -d ./dir_listing.mst -l ncsa 72 | sabo-tabby -c ./config.yaml 73 | 74 | Arguments: 75 | Basic 76 | -b HOST, --bind HOST Host to bind [default: 0.0.0.0] 77 | -p PORT, --port PORT Port to listen for connections [default: 1312] 78 | -f DIR, --public-folder DIR Set which folder to server [default: ./] 79 | -c FILE, --config FILE Load config from file 80 | --serve-hidden Enable serving hidden folders and files 81 | --licenses Shows the licenses of the app and its dependencies 82 | -h, --help Shows this help 83 | 84 | SSL 85 | -s, --ssl Enables SSL 86 | --ssl-key-file FILE SSL key file 87 | --ssl-cert-file FILE SSL certificate file 88 | 89 | Theming 90 | -e THEME, --error-page-theme THEME 91 | Either error page theme or path to custom mustache file [available: Boring, Default, Gradient, Tqila] [default: Default] 92 | -d THEME, --dir-listing-theme THEME 93 | Either dir listing theme or path to custom mustache file [available: Default, Flat, Gradient, Material] [default: Default] 94 | -l STYLE, --logger-style STYLE Log style [available: Default, Extended, Kemal, NCSA, NCSA_Extended] [default: Default] 95 | 96 | Logging 97 | --no-logging Disable logging 98 | --no-emoji Disable emojis in log 99 | --no-colors Disable colored output (already disabled in non-tty) 100 | 101 | Options 102 | --no-server-header Disable the 'Server' header 103 | --no-gzip Disable gzip 104 | --no-dir-index Disable serving /index.html on / 105 | --no-dir-listing Disable directory listing 106 | --no-error-page Disable custom error page 107 | ``` 108 | 109 | # 110 | 111 | ## Config 112 | 113 | You can load your config from a file. 114 | 115 | If no arguments are provided when running sabo-tabby, it will automatically try to load it from one of the following: `./sabo-tabby.yml`, `./sabotabby.yml`, `./sabo.tabby.yaml`. 116 | 117 | If the config file is in a different path or with a different name, you can point at it with the `-c` option. 118 | 119 | Read [./sabo.tabby.yaml](./sabo.tabby.yaml) for an example config. 120 | 121 | # 122 | 123 | ## Themes 124 | 125 |
126 | Error Page 127 | 128 | | Theme | Screenshot | 129 | | :---: | :---: | 130 | | `Default` (light) | ![404 page, white background, centered column, at the top the status code 404 is shown with in red color, below it the message 'not found' in black color and below it a button with the label 'Go home' underlined in black color, at the bottom right corner a silhouette of a black cat directly looking at the user](https://i.imgur.com/RUqVtGd.jpg) | 131 | | `Default` (dark) | ![404 page, dark grey background, centered column, at the top the status code 404 is shown in pastel yellow color, below it the message 'not found' in white color and below it a button with the label 'Go home' underlined in white color, at the bottom right corner a silhouette of a white cat directly looking at the user](https://i.imgur.com/A6ySelO.jpg) | 132 | | `Boring` (light) | ![404 page, light blue background, left column, at the top the status code 404 is shown in white color, below it the message 'not found' in white color and below it a button with the label 'Go home' in black color inside a square white background, at the bottom right corner a silhouette of a white cat directly looking at the user](https://i.imgur.com/9OaujHt.jpg) | 133 | | `Boring` (dark) | ![404 page, dark blue background, left column, at the top the status code 404 is shown in white color, below it the message 'not found' in white color and below it a button with the label 'Go home' in black color inside a square white background, at the bottom right corner a silhouette of a white cat directly looking at the user](https://i.imgur.com/SSbLSPz.jpg) | 134 | | `Gradient` (light) | ![404 page, gradient background of light green top left to white bottom right, center column, at the top the status code 404 is shown in black color, below it the message 'not found' in black color and below it a button with the label 'Go home' in white color inside a pill shaped black background, at the bottom right corner a silhouette of a black cat directly looking at the user](https://i.imgur.com/IjemCw2.jpg) | 135 | | `Gradient` (dark) | ![404 page, gradient background of magenta top left to purple bottom right, center column, at the top the status code 404 is shown in white color, below it the message 'not found' in white color and below it a button with the label 'Go home' in black color inside a a pill shaped white background, at the bottom right corner a silhouette of a white cat directly looking at the user](https://i.imgur.com/GauBCAw.jpg) | 136 | | `TQILA` | ![404 page, pastel pink background, center column, at the top the status code 404 is shown in black color, below it the message 'not found' in black color and below it a button with the label 'Go home' underlined in black color, at the bottom right corner a silhouette of a black cat directly looking at the user](https://i.imgur.com/oHqGItc.jpg) | 137 |
138 | 139 | # 140 | 141 |
142 | Directory Listing 143 | 144 | | Theme | Screenshot | 145 | | :---: | :---: | 146 | | `Default` (light) | ![directory listing page, white background, at the top theres a centered header with the label 'Directory listing for /' in black color, underneath it theres a horizontal line in pastel red color, below it theres a grid of 3 columns of cards, cards have a grey border and rounded edges, inside each card theres a label in black color with the name of the file or folder it represents, all files are named in the format of cake_recipe_{1-12} and folders are named in the format of stuff{1-7}, next to the folder label theres a folder emoji and next to the file one a file emoji, when a card gets hovered, the border and the label change color to a pastel red one](https://i.imgur.com/ddB0SC0.jpg) | 147 | | `Default` (dark) | ![directory listing page, dark grey background, at the top theres a centered header with the label 'Directory listing for /' in white color, underneath it theres a horizontal line in pastel yellow color, below it theres a grid of 3 columns of cards, cards have a grey border and rounded edges, inside each card theres a label in white color with the name of the file or folder it represents, all files are named in the format of cake_recipe_{1-12} and folders are named in the format of stuff{1-7}, next to the folder label theres a folder emoji and next to the file one a file emoji, when a card gets hovered, the border and the label change color to a pastel yellow one](https://i.imgur.com/Ps3yMhS.jpg) | 148 | | `Flat` (light) | ![directory listing page, light blue background, at the top theres a left header with the label 'Directory listing for /' in white color, underneath it theres a horizontal line in white color, below it theres a grid of 3 columns of cards, cards have a white background and sharp edges, inside each card theres a label in black color with the name of the file or folder it represents, all files are named in the format of cake_recipe_{1-12} and folders are named in the format of stuff{1-7}, next to the folder label theres a folder emoji and next to the file one a file emoji, when a card gets hovered it gets a shadow below it giving the impression of floating](https://i.imgur.com/isOtZNy.jpg) | 149 | | `Flat` (dark) | ![directory listing page, dark blue background, at the top theres a left header with the label 'Directory listing for /' in white color, underneath it theres a horizontal line in white color, below it theres a grid of 3 columns of cards, cards have a white background and sharp edges, inside each card theres a label in black color with the name of the file or folder it represents, all files are named in the format of cake_recipe_{1-12} and folders are named in the format of stuff{1-7}, next to the folder label theres a folder emoji and next to the file one a file emoji, when a card gets hovered it gets a shadow below it giving the impression of floating](https://i.imgur.com/uSTdOW1.jpg) | 150 | | `Gradient` (light) | ![directory listing page, gradient background of top left orange-purple to bottom right yellow, at the top theres a left header with the label 'Directory listing for /' in black color, underneath it theres a horizontal line in washed dark blue color, below it theres a grid of 3 columns of cards, cards have a grey border and sharp edges, inside each card theres a label in black color with the name of the file or folder it represents, all files are named in the format of cake_recipe_{1-12} and folders are named in the format of stuff{1-7}, next to the folder label theres a folder emoji and next to the file one a file emoji, when a card gets hovered, the border and the label change color to a washed dark blue one](https://i.imgur.com/Bg8MX7r.jpg) | 151 | | `Gradient` (dark) | ![directory listing page, gradient background of top light blue to bottom dark blue, at the top theres a left header with the label 'Directory listing for /' in white color, underneath it theres a horizontal line in light orange color, below it theres a grid of 3 columns of cards, cards have a grey border and sharp edges, inside each card theres a label in white color with the name of the file or folder it represents, all files are named in the format of cake_recipe_{1-12} and folders are named in the format of stuff{1-7}, next to the folder label theres a folder emoji and next to the file one a file emoji, when a card gets hovered, the border and the label change color to a light orange one](https://i.imgur.com/AMNRaCO.jpg) | 152 | | `Material` (light) | ![directory listing page, light green background, at the top theres a centered header with the label 'Directory listing for /' in black color, underneath it theres a horizontal line in darker green color, below it theres a grid of 3 columns of cards, cards have a darker green background, are pill shaped and have a shadow underneath them giving them a floating effect, inside each card theres a label in white color with the name of the file or folder it represents, all files are named in the format of cake_recipe_{1-12} and folders are named in the format of stuff{1-7}, next to the folder label theres a folder emoji and next to the file one a file emoji, when a card gets hovered, the shadow gets removed, the label changes color to black, the background changes color to the same as the page background and it gets a border with a darker green color](https://i.imgur.com/OcCsXY7.jpg) | 153 | | `Material` (dark) | ![directory listing page, dark green background, at the top theres a centered header with the label 'Directory listing for /' in white color, underneath it theres a horizontal line in lighter green color, below it theres a grid of 3 columns of cards, cards have a lighter green background, are pill shaped and have a shadow underneath them giving them a floating effect, inside each card theres a label in white color with the name of the file or folder it represents, all files are named in the format of cake_recipe_{1-12} and folders are named in the format of stuff{1-7}, next to the folder label theres a folder emoji and next to the file one a file emoji, when a card gets hovered, the shadow gets removed, the background changes color to the same as the page background and it gets a border with a lighter green color](https://i.imgur.com/BT5aNES.jpg) | 154 |
155 | 156 | # 157 | 158 | ### Loading custom themes using Crustache 159 | 160 | You can create custom themes using Mustache templates. 161 | 162 | To set a Mustache file as the theme of error or directory listing pages, just pass it's path to `-e` or `-d` respectfully(?) e.g. `-e ./error.mustache -d ./pages/dir.html`. 163 | 164 | A model is passed to each one that contains data about the page: 165 | 166 | #### Error 167 | 168 | ##### Model 169 | 170 | ```cr 171 | # The error code 172 | status_code : Int32 173 | 174 | # The error message 175 | message : String 176 | ``` 177 | 178 | ##### Example 179 | 180 | ```html 181 |

Oops!

182 |

{{status_code}}

183 |

{{message}}

184 | ``` 185 | 186 | #### Directory Listing 187 | 188 | ##### Model 189 | 190 | ```cr 191 | # The directory that is being listed 192 | request_path : String 193 | 194 | # A hash with the items "path" for file name, "file" for whether it is a file or not and "href" for the URI encoded file path 195 | entries : Hash(String, String | Bool) 196 | ``` 197 | 198 | ##### Example 199 | 200 | ```html 201 | Dir: 202 | {{request_path}} 203 |
204 | Items: 205 |
206 | {{#entries}} 207 | 208 | {{path}} 209 | {{file}} 210 | 211 |
212 | {{/entries}} 213 | ``` 214 | 215 | # 216 | 217 | ## Logging styles 218 | 219 | - Default 220 | 221 | ``` 222 | [2022-07-16 16:37:43 UTC] [127.0.0.1] [200] [GET] [/] [228.33µs] 223 | ``` 224 | 225 | - Extended 226 | 227 | ``` 228 | [2022-07-16 16:37:43 UTC] [127.0.0.1] [200] [GET] [/] [/] [compressed] [-] [Mozilla/5.0 (X11; Linux x86_64; rv:103.0) Gecko/20100101 Firefox/103.0] [228.33µs] 229 | ``` 230 | 231 | - Kemal 232 | 233 | ``` 234 | 2022-07-16 16:37:43 UTC 200 GET / 228.33µs 235 | ``` 236 | 237 | - [NCSA](https://en.wikipedia.org/wiki/Common_Log_Format) 238 | 239 | ``` 240 | 127.0.0.1 - - [16/Jul/2022:19:37:43 +0300] "GET / HTTP/1.1" 200 0 241 | ``` 242 | 243 | - [NCSA](https://en.wikipedia.org/wiki/Common_Log_Format)_Extended 244 | 245 | ``` 246 | 127.0.0.1 - - [16/Jul/2022:19:37:43 +0300] "GET / HTTP/1.1" 200 0 "-" "Mozilla/5.0 (X11; Linux x86_64; rv:103.0) Gecko/20100101 Firefox/103.0" 247 | ``` 248 | # 249 | 250 | ## Development 251 | 252 | ### Themes 253 | 254 | - Themes should not depend on external assets like fonts, images and styles (unless they can be embedded). 255 | - Themes should pass performance and accessibility on [Lighthouse](https://github.com/GoogleChrome/lighthouse). 256 | - Themes should be responsive. 257 | - Theme styles should be minified before pushing. 258 | 259 | ### CLI 260 | 261 | - Flag names should be descriptive and easy to remember. 262 | - Config file should support all flags. 263 | - If a flag support paths as input, they should be relative to the config if read from config. 264 | 265 | ### App 266 | 267 | - Performance is the main focus. 268 | - Avoid unnecessary variables. 269 | - Follow https://crystal-lang.org/reference/1.5/guides/performance.html. 270 | 271 | ## Contributing 272 | 273 | 1. Read the [Code of Conduct](./CODE_OF_CONDUCT.md) 274 | 2. Fork it () 275 | 3. Create your feature branch (`git checkout -b my-new-feature`) 276 | 4. Commit your changes (`git commit -am 'Add some feature'`) 277 | 5. Push to the branch (`git push origin my-new-feature`) 278 | 6. Create a new Pull Request 279 | 280 | # 281 | 282 | ## Sponsors 283 | 284 | 285 |

286 | 287 | [![GeopJr Sponsors](https://cdn.jsdelivr.net/gh/GeopJr/GeopJr@main/sponsors.svg)](https://github.com/sponsors/GeopJr) 288 | 289 |

290 | --------------------------------------------------------------------------------