├── docs ├── _config.yml ├── older-versions.md ├── logger.md ├── about.md ├── credits.md ├── security-outro.md ├── readme.md ├── password-hashing.md ├── contribution.md ├── directory-structure.md ├── csrf-protection.md ├── handlers.md ├── error-handling.md ├── request-lifecycle.md ├── installation.md ├── middleware.md ├── relationships.md ├── security-intro.md ├── configuration.md ├── validator.md ├── response.md ├── database-getting-started.md ├── sessions.md ├── request.md ├── authentication.md ├── pull.md └── views.md ├── logo ├── logomark.png ├── vertical.png ├── horizontal.png └── logomark.svg ├── .joker ├── .gitignore ├── Makefile ├── Vagrantfile ├── test ├── coast │ ├── migrations │ │ └── edn_test.clj │ ├── theta_test.clj │ └── db │ │ ├── migrations_test.clj │ │ ├── transact_test.clj │ │ ├── sql_test.clj │ │ ├── queries_test.clj │ │ └── query_test.clj └── coast_test.clj ├── src ├── coast │ ├── migrations │ │ ├── sql.clj │ │ └── edn.clj │ ├── prod │ │ └── server.clj │ ├── dev │ │ └── server.clj │ ├── time2.clj │ ├── error.clj │ ├── logger.clj │ ├── db │ │ ├── delete.clj │ │ ├── insert.clj │ │ ├── errors.clj │ │ ├── update.clj │ │ ├── associations.clj │ │ ├── connection.clj │ │ ├── queries.clj │ │ ├── helpers.clj │ │ └── transact.clj │ ├── env.clj │ ├── generators.clj │ ├── validation.clj │ ├── responses.clj │ ├── components.clj │ ├── generators │ │ ├── migration.clj │ │ └── code.clj │ ├── assets.clj │ ├── time.clj │ ├── potemkin │ │ └── namespaces.clj │ ├── theta.clj │ ├── migrations.clj │ ├── utils.clj │ └── repl.clj └── coast.clj ├── provision.sh ├── LICENSE ├── deps.edn ├── coast ├── resources └── generators │ └── code.clj.txt ├── pom.xml └── README.md /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /logo/logomark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs/coast/master/logo/logomark.png -------------------------------------------------------------------------------- /logo/vertical.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs/coast/master/logo/vertical.png -------------------------------------------------------------------------------- /logo/horizontal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs/coast/master/logo/horizontal.png -------------------------------------------------------------------------------- /.joker: -------------------------------------------------------------------------------- 1 | {:known-macros [clojure.java.jdbc/with-db-connection clojure.java.jdbc/with-db-transaction] 2 | :rules {:if-without-else true 3 | :no-forms-threading false}} 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | pom.xml.asc 5 | *.jar 6 | *.class 7 | /.lein-* 8 | /.nrepl-port 9 | .hgignore 10 | .hg/ 11 | .idea/ 12 | *.iml 13 | .env 14 | .cpcache 15 | ubuntu-*console.log 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | 3 | test: 4 | COAST_ENV=test clj -A\:test 5 | 6 | repl: 7 | clj -A\:repl 8 | 9 | clean: 10 | rm -rf target 11 | 12 | pom: 13 | clj -Spom 14 | 15 | deploy: test 16 | mvn deploy 17 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | 2 | # -*- mode: ruby -*- 3 | # vi: set ft=ruby : 4 | 5 | Vagrant.configure(2) do |config| 6 | config.vm.box = 'ubuntu/bionic64' 7 | 8 | config.vm.network 'forwarded_port', guest: 9100, host: 9100 9 | config.vm.hostname = 'coast' 10 | 11 | config.vm.synced_folder '.', '/coast' 12 | 13 | config.vm.provision 'shell', path: 'provision.sh' 14 | end 15 | -------------------------------------------------------------------------------- /test/coast/migrations/edn_test.clj: -------------------------------------------------------------------------------- 1 | (ns coast.migrations.edn-test 2 | (:require [coast.migrations.edn :as migrations.edn] 3 | [clojure.test :refer :all])) 4 | 5 | (deftest migrate-test 6 | (testing "migrate with user table" 7 | (is (thrown-with-msg? Exception #"user is a reserved word in postgres try a different name for this table" 8 | (migrations.edn/migrate [{:db/ident :user/name :db/type "text"}]))))) 9 | -------------------------------------------------------------------------------- /src/coast/migrations/sql.clj: -------------------------------------------------------------------------------- 1 | (ns coast.migrations.sql 2 | (:require [clojure.string :as string] 3 | [clojure.java.io :as io] 4 | [coast.time :as time]) 5 | (:import (java.io File))) 6 | 7 | (def migration-regex #"(?s)--\s*up\s*(.+)--\s*down\s*(.+)") 8 | 9 | (defn parse [s] 10 | (when (string? s) 11 | (let [[_ up down] (re-matches migration-regex s)] 12 | {:up up 13 | :down down}))) 14 | 15 | (defn up [contents] 16 | (-> contents parse :up)) 17 | 18 | (defn down [contents] 19 | (-> contents parse :down)) 20 | -------------------------------------------------------------------------------- /src/coast/prod/server.clj: -------------------------------------------------------------------------------- 1 | (ns coast.prod.server 2 | (:require [org.httpkit.server :as httpkit] 3 | [coast.env :as env] 4 | [coast.utils :as utils])) 5 | 6 | (defn start 7 | "The prod server doesn't handle restarts with an atom, it's built for speed" 8 | ([app opts] 9 | (let [port (or (-> (or (:port opts) (env/env :port)) 10 | (utils/parse-int)) 11 | 1337)] 12 | (println "Server is listening on port" port) 13 | (httpkit/run-server app (merge opts {:port port})))) 14 | ([app] 15 | (start app nil))) 16 | -------------------------------------------------------------------------------- /docs/older-versions.md: -------------------------------------------------------------------------------- 1 | # Older Versions 2 | 3 | - [eta](https://github.com/coast-framework/coast/tree/9694e6b1e0c3e490206930196d8c97f25c1aa7c0) 4 | - [zeta](https://github.com/coast-framework/coast/tree/f652b1002e8ec0a3a350c75afa389c39d0fa6f5d) 5 | - [epsilon](https://github.com/coast-framework/coast/tree/b742c50e841c96ff4a820808dc6013a26063fc07) 6 | - [delta](https://github.com/coast-framework/coast/tree/0e9913f1c609bfb8b391300810f742390e9b6028) 7 | - [gamma](https://github.com/coast-framework/coast/tree/e2a0cacf25dd05b041d7b098e5db0a93592d3dea) 8 | - [beta](https://github.com/coast-framework/coast/tree/8a92be4a4efd5d4ed419b39ba747780f2de44fe4) 9 | - [alpha](https://github.com/coast-framework/coast/tree/4539e148bea1212c403418ec9dfbb2d68a0db3d8) 10 | - [0.6.9](https://github.com/coast-framework/coast/tree/0.6.9) 11 | -------------------------------------------------------------------------------- /docs/logger.md: -------------------------------------------------------------------------------- 1 | # Logger 2 | 3 | Coast comes with a relatively simple (i.e. not feature-packed) logger. 4 | 5 | The goal was to adhere to the 12factor app rules and not really worry about logging. 6 | 7 | All requests and responses are logged to stdout and look a little something like this: 8 | 9 | ```bash 10 | 2019-03-02 10:56:58 -0700 GET "/" :home/index 200 text/html 4ms 11 | ``` 12 | 13 | The format is more generally: 14 | 15 | - `timestamp` 16 | - `request method` 17 | - `requested url` 18 | - `handler keyword called` 19 | - `response status` 20 | - `response content type` 21 | - `response time` 22 | 23 | It's old school, but don't be afraid to use `println` if you get stuck for any reason. 24 | 25 | The logger may grow up in the future, the goal may be to copy well-known frameworks' loggers, but for now, this will do. 26 | -------------------------------------------------------------------------------- /docs/about.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | ## Motivation 4 | 5 | In my short web programming career, I've found two things 6 | that I really like: Clojure and Ruby on Rails. This is my attempt 7 | to put the two together in a tasteful, clojure-like way. 8 | 9 | ## Versioning 10 | 11 | I also wanted to say more generally why I don't seem to make backwards compatible upgrades. Well, two reasons: 12 | 13 | 1. I'm usually working on new, backwards incompatible websites. 14 | 2. This is a web framework for indie hackers. The faster you can get to validation, the better. 15 | 16 | So every new name (delta -> epsilon) for example is always a breaking change. If you're on the same name, it never has breaking changes. Keeps things pretty simple. If you aren't an [indie hacker](https://www.indiehackers.com) or if you've moved past the part where you're trying to throw stuff at the wall to see what sticks, this might not be the framework for you. 17 | -------------------------------------------------------------------------------- /src/coast/dev/server.clj: -------------------------------------------------------------------------------- 1 | (ns coast.dev.server 2 | (:require [coast.repl :as repl] 3 | [coast.env :as env] 4 | [coast.utils :as utils] 5 | [org.httpkit.server :as httpkit])) 6 | 7 | (defonce server (atom nil)) 8 | 9 | (defn start 10 | ([app] 11 | (start app nil)) 12 | ([app opts] 13 | (let [port (-> (or (:port opts) (env/env :port) 1337) 14 | (utils/parse-int))] 15 | (reset! server (httpkit/run-server app (merge opts {:port port}))) 16 | (println "Server is listening on port" port)))) 17 | 18 | (defn stop [] 19 | (when (not (nil? @server)) 20 | (@server :timeout 100) 21 | (reset! server nil) 22 | (println "Resetting dev server"))) 23 | 24 | (defn restart 25 | "Here's the magic that allows you to restart the server at will from the repl. It uses a custom version of repl/refresh that takes arguments" 26 | [app opts] 27 | (stop) 28 | (repl/refresh :after `start :after-args [app opts])) 29 | -------------------------------------------------------------------------------- /src/coast/migrations/edn.clj: -------------------------------------------------------------------------------- 1 | (ns coast.migrations.edn 2 | (:require [clojure.string :as string] 3 | [clojure.java.io :as io] 4 | [clojure.edn] 5 | [coast.db.schema :as schema] 6 | [coast.time :as time]) 7 | (:import (java.io File))) 8 | 9 | (defn migrate [content] 10 | (let [tables (schema/create-tables-if-not-exists content) 11 | cols (schema/add-columns content) 12 | idents (schema/add-idents content) 13 | rels (schema/add-rels content) 14 | constraints (schema/add-constraints content)] 15 | (->> (concat tables cols idents rels constraints) 16 | (filter some?) 17 | (string/join ";\n")))) 18 | 19 | (defn rollback [content] 20 | (let [;TODO tables (schema/drop-table content) 21 | cols (schema/drop-columns content) 22 | constraints (schema/drop-constraints content)] 23 | (->> (concat constraints cols) 24 | (filter some?) 25 | (string/join ";\n")))) 26 | -------------------------------------------------------------------------------- /src/coast/time2.clj: -------------------------------------------------------------------------------- 1 | (ns coast.time2 2 | (:import (java.time ZoneOffset ZonedDateTime ZoneId Instant) 3 | (java.time.format DateTimeFormatter))) 4 | 5 | 6 | (defn now 7 | "Return the current time (in epoch second)." 8 | [] 9 | (.getEpochSecond (Instant/now))) 10 | 11 | 12 | (defn instant 13 | "Convert epoch second `second` to `java.time.Instant`." 14 | [seconds] 15 | (Instant/ofEpochSecond seconds)) 16 | 17 | 18 | (defn datetime 19 | "Convert epoch second `second` to `java.time.ZonedDateTime`." 20 | ([seconds zone] 21 | (let [zoneId (if (string? zone) 22 | (ZoneId/of zone) 23 | ZoneOffset/UTC)] 24 | (ZonedDateTime/ofInstant 25 | (instant seconds) 26 | zoneId))) 27 | ([seconds] 28 | (datetime seconds nil))) 29 | 30 | 31 | (defn strftime 32 | "Convert datetime `d` to str with pattern `pattern`." 33 | [d pattern] 34 | (let [formatter (DateTimeFormatter/ofPattern pattern)] 35 | (.format formatter d))) 36 | -------------------------------------------------------------------------------- /docs/credits.md: -------------------------------------------------------------------------------- 1 | # Credits 2 | 3 | This framework is only possible because of the hard work of 4 | a ton of great clojure devs who graciously open sourced their 5 | projects. 6 | 7 | Here's the list of open source projects that coast uses: 8 | 9 | - [http-kit](https://github.com/http-kit/http-kit) 10 | - [hiccup](https://github.com/weavejester/hiccup) 11 | - [ring/ring-core](https://github.com/ring-clojure/ring) 12 | - [ring/ring-defaults](https://github.com/ring-clojure/ring-defaults) 13 | - [ring/ring-devel](https://github.com/ring-clojure/ring) 14 | - [org.postgresql/postgresql](https://github.com/pgjdbc/pgjdbc) 15 | - [org.clojure/java.jdbc](https://github.com/clojure/java.jdbc) 16 | - [org.clojure/tools.namespace](https://github.com/clojure/tools.namespace) 17 | - [verily](https://github.com/jkk/verily) 18 | - [potemkin](https://github.com/ztellman/potemkin) 19 | 20 | Here's the list of contributors that made coast what it is today 🎉 21 | 22 | https://github.com/coast-framework/coast/graphs/contributors 23 | https://github.com/coast-framework/template/graphs/contributors 24 | -------------------------------------------------------------------------------- /provision.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o nounset 4 | set -o errexit 5 | 6 | readonly BasePath="/coast" 7 | 8 | clojure () { 9 | echo "Installing clojure" 10 | 11 | apt-get install -y default-jre 12 | curl -O https://download.clojure.org/install/linux-install-1.10.1.561.sh 13 | chmod +x linux-install-1.10.1.561.sh 14 | sudo ./linux-install-1.10.1.561.sh 15 | } 16 | 17 | main () { 18 | echo "PROVISIONING" 19 | 20 | export DEBIAN_FRONTEND=noninteractive 21 | 22 | # Update apt cache 23 | apt-get update 24 | apt-get autoclean 25 | apt-get autoremove -y 26 | 27 | # Install some base software 28 | apt-get install -y curl vim unzip 29 | 30 | # Create bin dir for user vagrant 31 | mkdir -p /home/vagrant/bin 32 | chown vagrant:vagrant /home/vagrant/bin 33 | 34 | # Navigate to project directory on login 35 | LINE="cd ${BasePath}" 36 | FILE=/home/vagrant/.bashrc 37 | grep -q "$LINE" "$FILE" || echo "$LINE" >> "$FILE" 38 | 39 | # Add greeting 40 | echo "Hello coaster :)" > /etc/motd 41 | 42 | clojure 43 | } 44 | main 45 | -------------------------------------------------------------------------------- /src/coast/error.clj: -------------------------------------------------------------------------------- 1 | (ns coast.error 2 | "Regular exceptions leave little to be desired. raise and rescue are wrappers around ExceptionInfo") 3 | 4 | (defn raise 5 | "Raise an instance of ExceptionInfo with a map `m` and an optional message `s`." 6 | ([s m] 7 | (throw (ex-info s (assoc m ::raise true)))) 8 | ([m] 9 | (raise "Error has occurred" m))) 10 | 11 | (defmacro rescue 12 | "Evaluate the form `f` and returns a two-tuple vector of `[result error]`. 13 | Any exception thrown by form `f` will be caught and return in the `error`. 14 | Otherwise `error` will be `nil`. 15 | 16 | When the exception contains the raise keyword `:coast.error/raise`, 17 | `rescue` will not catch the error. 18 | 19 | You can customize the raise keyword by using the optional `k` argument." 20 | ([f k] 21 | `(try 22 | [~f nil] 23 | (catch clojure.lang.ExceptionInfo e# 24 | (let [ex# (ex-data e#)] 25 | (if (and (contains? ex# ::raise) 26 | (contains? ex# (or ~k ::raise))) 27 | [nil ex#] 28 | (throw e#)))))) 29 | ([f] 30 | `(rescue ~f nil))) 31 | -------------------------------------------------------------------------------- /src/coast/logger.clj: -------------------------------------------------------------------------------- 1 | (ns coast.logger 2 | (:require [clojure.string :as string] 3 | [coast.time :as time] 4 | [coast.utils :as utils]) 5 | (:import (java.time Duration))) 6 | 7 | (defn diff [start end] 8 | (let [duration (Duration/between start end)] 9 | (.toMillis duration))) 10 | 11 | (defn req-method [request] 12 | (or (-> request :params :_method) 13 | (:request-method request))) 14 | 15 | (defn log-str [request response start-time] 16 | (let [ms (diff start-time (time/now)) 17 | uri (:uri request) 18 | status (:status response) 19 | method (-> (req-method request) name string/upper-case) 20 | headers (->> (:headers response) 21 | (utils/map-vals string/lower-case)) 22 | content-type (get headers "content-type") 23 | route (:coast.router/name response) 24 | timestamp (time/fmt (time/offset) "yyyy-MM-dd HH:mm:ss xx")] 25 | (str timestamp " " method " \"" uri "\" " route " " status " " content-type " " ms "ms"))) 26 | 27 | (defn log [request response start-time] 28 | (println (log-str request response start-time))) 29 | -------------------------------------------------------------------------------- /test/coast/theta_test.clj: -------------------------------------------------------------------------------- 1 | (ns coast.theta-test 2 | (:require [coast.theta :as coast] 3 | [coast.router :as router] 4 | [coast.middleware :as middleware] 5 | [clojure.test :refer [deftest testing is]])) 6 | 7 | 8 | (deftest url-for-test 9 | (let [routes (router/routes 10 | (coast.middleware/site-routes 11 | [:get "/" ::home] 12 | [:post "/" ::home-action] 13 | [:get "/hello" ::hello] 14 | [:get "/hello/:id" ::hello-id])) 15 | _ (coast/app {:routes routes})] 16 | (testing "url-for without a map" 17 | (is (= "/" (coast/url-for ::home)))) 18 | 19 | (testing "url-for with a map with no url params" 20 | (is (= "/hello?key=value" (coast/url-for ::hello {:key "value"})))) 21 | 22 | (testing "url-for with a map with url params" 23 | (is (= "/hello/1?key=value" (coast/url-for ::hello-id {:id 1 :key "value"})))) 24 | 25 | (testing "url-for with a map, a url param and a #" 26 | (is (= "/hello/2?key=value#anchor" (coast/url-for ::hello-id {:id 2 :key "value" :# "anchor"})))))) 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sean Walker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/coast/db/migrations_test.clj: -------------------------------------------------------------------------------- 1 | (ns coast.db.migrations-test 2 | (:require [coast.db.migrations :refer [references col create-table]] 3 | [coast.db.connection] 4 | [clojure.test :refer [deftest testing is]])) 5 | 6 | (deftest ^:migrations col-test 7 | (testing "col with type and name but no options" 8 | (is (= "name text" (col :text "name" {})))) 9 | 10 | (testing "col with reference" 11 | (is (= "account integer not null references account(id) on delete cascade" 12 | (references :account))))) 13 | 14 | (deftest ^:migrations create-table-test 15 | (with-redefs [coast.db.connection/spec (fn [_] "sqlite")] 16 | (testing "create-table with a table name" 17 | (is (= '("create table customer ( id integer primary key )") 18 | (create-table "customer")))) 19 | 20 | (testing "create-table with reference" 21 | (is (= '("create table customer ( id integer primary key, account integer not null references account(id) on delete cascade )" "create index customer_account_index on customer (account)") 22 | (create-table "customer" 23 | (references :account))))))) 24 | -------------------------------------------------------------------------------- /docs/security-outro.md: -------------------------------------------------------------------------------- 1 | # XSS, Sniffing, XFrame 2 | 3 | Common security headers help ensure your web application is secure, whether you run it behind nginx or not. 4 | 5 | Coast by default attempts to protect your web app from *XSS* attacks, unwanted *iframe embeds*, and *content-type sniffing*. 6 | 7 | ### XSS 8 | Coast by default passes this to `app` which results in the header `X-XSS-Protection=1; mode=block` being sent on every response. 9 | 10 | ```clojure 11 | {:security {:xss-protection {:enable? true, :mode :block}}} 12 | ``` 13 | 14 | ### No Sniff 15 | The majority of modern browsers attempts to detect the *Content-Type* of a request by sniffing its content, meaning a file ending in *.txt* could be executed as JavaScript if it contains JavaScript code. 16 | 17 | This behavior is disabled by default with the map: 18 | 19 | ```clojure 20 | {:security {:content-type-options :nosniff}} 21 | ``` 22 | 23 | ### XFrame 24 | Coast also makes it easy for you to control the embed behavior of your website inside an iframe. 25 | 26 | Available options are `:deny`, `:same-origin` or `:allow-from [http://example.com]`: 27 | 28 | The default is `:deny` 29 | 30 | ```clojure 31 | {:security {:frame-options :deny}} 32 | ``` 33 | -------------------------------------------------------------------------------- /test/coast/db/transact_test.clj: -------------------------------------------------------------------------------- 1 | (ns coast.db.transact-test 2 | (:require [coast.db.transact :as transact] 3 | [coast.db.schema] 4 | [clojure.test :refer [deftest testing is]])) 5 | 6 | (deftest sql-vec 7 | (testing "upsert" 8 | (with-redefs [coast.db.schema/fetch (fn [] {:idents #{:member/id :member/name :member/email}})] 9 | (is (= ["insert into member(email, name)\nvalues (?, ?)\n on conflict (email,name) do update set updated_at = now()\nreturning *" "test@test.com" "test"] 10 | (transact/sql-vec {:member/name "test" 11 | :member/email "test@test.com"}))))) 12 | 13 | (testing "upsert with one rel" 14 | (with-redefs [coast.db.schema/fetch (fn [] {:idents #{:member/id :member/name :member/email :token/id} 15 | :joins {:token/member :token/member}})] 16 | (is (= ["insert into token(ident, member)\nvalues (?, ?)\n on conflict (id) do update set updated_at = now(), ident = excluded.ident, member = excluded.member\nreturning *" "something unique" "test"] 17 | (transact/sql-vec {:token/ident "something unique" 18 | :token/member [:member/name "test"]})))))) 19 | -------------------------------------------------------------------------------- /test/coast/db/sql_test.clj: -------------------------------------------------------------------------------- 1 | (ns coast.db.sql-test 2 | (:require [coast.db.sql :as sql] 3 | [clojure.test :refer [deftest testing is use-fixtures]])) 4 | 5 | 6 | (deftest select-test 7 | (testing "a select statement with one column" 8 | (is (= ["select post.id as post$id from post"] 9 | (sql/sql-vec "sqlite" {} {} '[:select post/id 10 | :from post] 11 | {})))) 12 | 13 | (testing "a select statement with an asterisk" 14 | (is (= ["select id from post"] 15 | (sql/sql-vec "sqlite" {:post [:id]} {} '[:select * 16 | :from post] 17 | {})))) 18 | 19 | (testing "upsert" 20 | (is (= ["insert into post (title, hash) values (?, ?) on conflict (hash) do update set title = excluded.title, updated_at = ?" "title" "hash"] 21 | (drop-last (sql/sql-vec 22 | "sqlite" 23 | {:post [:title :hash]} 24 | {} 25 | '[:insert post/title post/hash 26 | :values ["title" "hash"] 27 | :on-conflict "hash" 28 | :do-update-set [title "excluded.title"]] 29 | {})))))) 30 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | 3 | :deps {asset-minifier {:mvn/version "0.2.5"} 4 | com.zaxxer/HikariCP {:mvn/version "2.7.8"} 5 | org.slf4j/slf4j-nop {:mvn/version "1.7.25"} 6 | hiccup {:mvn/version "2.0.0-alpha2"} 7 | http-kit {:mvn/version "2.4.0-alpha3"} 8 | jkkramer/verily {:mvn/version "0.6.0"} 9 | org.clojure/data.json {:mvn/version "0.2.6"} 10 | org.clojure/clojure {:mvn/version "1.10.0"} 11 | org.clojure/tools.namespace {:mvn/version "0.3.0-alpha4"} 12 | org.clojure/java.jdbc {:mvn/version "0.7.8"} 13 | ring/ring-core {:mvn/version "1.7.1"} 14 | ring/ring-devel {:mvn/version "1.7.1"} 15 | ring/ring-ssl {:mvn/version "0.3.0"} 16 | ring/ring-headers {:mvn/version "0.3.0"} 17 | ring/ring-anti-forgery {:mvn/version "1.3.0"} 18 | javax.servlet/javax.servlet-api {:mvn/version "3.1.0"}} 19 | 20 | :aliases {:repl {:jvm-opts ["-Dclojure.server.repl={:port,7777,:accept,clojure.core.server/repl}"]} 21 | 22 | :test {:extra-paths ["test"] 23 | :main-opts ["-m" "cognitect.test-runner"] 24 | :extra-deps {com.cognitect/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git" 25 | :sha "5f2b5c2efb444df76fb5252102b33f542ebf7f58"}}}}} 26 | -------------------------------------------------------------------------------- /docs/readme.md: -------------------------------------------------------------------------------- 1 | ### Preface 2 | * [About](/docs/about.md) 3 | * [Credits](/docs/credits.md) 4 | * [Upgrading from eta](/docs/upgrading.md) 5 | * [Contribution Guide](/docs/contribution.md) 6 | 7 | ### Concept 8 | * [Request Lifecycle](/docs/request-lifecycle.md) 9 | 10 | ### Getting Started 11 | * [Installation](/docs/installation.md) 12 | * [Configuration](/docs/configuration.md) 13 | * [Directory Structure](/docs/directory-structure.md) 14 | 15 | ### Database 16 | * [Getting Started](/docs/database-getting-started.md) 17 | * [Queries](/docs/queries.md) 18 | * [Migrations](/docs/migrations.md) 19 | * [Relationships](/docs/relationships.md) 20 | * [Pull](/docs/pull.md) 21 | 22 | ### Basics 23 | * [Routing](/docs/routing.md) 24 | * [Middleware](/docs/middleware.md) 25 | * [Handlers](/docs/handlers.md) 26 | * [Request](/docs/request.md) 27 | * [Response](/docs/response.md) 28 | * [Views](/docs/views.md) 29 | * [Sessions](/docs/sessions.md) 30 | * [Validator](/docs/validator.md) 31 | * [Error Handling](/docs/error-handling.md) 32 | * [Logger](/docs/logger.md) 33 | 34 | ### Security 35 | * [Introduction](/docs/security-intro.md) 36 | * [Authentication](/docs/authentication.md) 37 | * [CSRF Protection](/docs/csrf-protection.md) 38 | * [Password Hashing](/docs/password-hashing.md) 39 | * [XSS, Sniffing, XFrame](/docs/security-outro.md) 40 | 41 | ### Miscellaneous 42 | * [Older Versions](/docs/older-versions.md) 43 | -------------------------------------------------------------------------------- /src/coast/db/delete.clj: -------------------------------------------------------------------------------- 1 | (ns coast.db.delete 2 | (:require [coast.utils :as utils] 3 | [clojure.string :as string])) 4 | 5 | (defn col 6 | ([table val] 7 | (when (ident? val) 8 | (let [prefix (if (nil? table) 9 | "" 10 | (str table "."))] 11 | (->> val name utils/snake 12 | (str prefix)))))) 13 | 14 | (defn same-ns? [m] 15 | (and (map? m) 16 | (= 1 (->> m keys (map namespace) (distinct) (count))))) 17 | 18 | (defn qualified-map? [m] 19 | (and (map? m) 20 | (not (empty? m)) 21 | (every? qualified-ident? (keys m)))) 22 | 23 | (defn validate-transaction [m] 24 | (cond 25 | (not (same-ns? m)) (throw (Exception. "All keys must have the same namespace")) 26 | (not (qualified-map? m)) (throw (Exception. "All keys must be qualified")) 27 | :else m)) 28 | 29 | (defn ? [m] 30 | (->> (keys m) 31 | (map (fn [_] (str "?"))) 32 | (string/join ", "))) 33 | 34 | (defn sql-vec [val] 35 | (let [v (if (sequential? val) val [val]) 36 | v (map validate-transaction v) 37 | table (-> v first keys first namespace utils/snake) 38 | sql (str "delete from " table 39 | " where " (->> v first keys first (col table)) " in " 40 | "(" (->> (map ? v) 41 | (mapcat identity) 42 | (string/join ", ")) 43 | ")" 44 | " returning *")] 45 | (vec (apply concat [sql] (map #(-> % vals) v))))) 46 | -------------------------------------------------------------------------------- /src/coast/env.clj: -------------------------------------------------------------------------------- 1 | (ns coast.env 2 | (:require [clojure.string :as string] 3 | [clojure.java.io :as io] 4 | [clojure.edn :as edn] 5 | [coast.utils :as utils])) 6 | 7 | (defn fmt 8 | "This formats .env keys that LOOK_LIKE_THIS to keys that :look-like-this" 9 | [m] 10 | (->> (map (fn [[k v]] [(-> k .toLowerCase (utils/kebab) keyword) v]) m) 11 | (into {}))) 12 | 13 | (defn dot-env 14 | "Environment variables all come from .env, specify it on prod, specify it on dev, live a happy life" 15 | [] 16 | (let [file (io/file ".env")] 17 | (if (.exists file) 18 | (->> (slurp file) 19 | (string/split-lines) 20 | (map string/trim) 21 | (filter #(not (string/blank? %))) 22 | (map #(string/split % #"=")) 23 | (map #(mapv (fn [s] (string/trim s)) %)) 24 | (into {})) 25 | {}))) 26 | 27 | 28 | (defn env-without-edn [k] 29 | (let [m (fmt (merge (dot-env) (System/getenv)))] 30 | (get m k))) 31 | 32 | 33 | (defn env-edn 34 | "Environment variables can also come from the easily-parse-able env.edn" 35 | [] 36 | (let [file (io/file "env.edn")] 37 | (if (.exists file) 38 | (->> (slurp file) 39 | (edn/read-string {:readers {'env env-without-edn}})) 40 | {}))) 41 | 42 | (defn env 43 | "This formats and merges environment variables from .env, env.edn and the OS environment" 44 | [k] 45 | (let [m (fmt (merge (dot-env) (System/getenv))) 46 | m (merge (env-edn) m)] 47 | (get m k))) 48 | -------------------------------------------------------------------------------- /src/coast/generators.clj: -------------------------------------------------------------------------------- 1 | (ns coast.generators 2 | (:require [coast.generators.code :as generators.code] 3 | [coast.generators.migration :as generators.migration] 4 | [coast.migrations :as migrations] 5 | [coast.db :as db])) 6 | 7 | 8 | (defn usage [] 9 | (println "Usage: 10 | coast new 11 | coast gen migration 12 | coast gen code 13 | coast db 14 | 15 | Examples: 16 | coast new foo 17 | coast new another-foo 18 | 19 | coast gen migration create-table-todo # Creates a new migration file 20 | coast gen sql:migration create-table-todo # Creates a new sql migration file 21 | 22 | coast gen code todo # Creates a new clj file with handler functions in src/todo.clj 23 | 24 | coast db migrate # runs all migrations found in db/migrations 25 | coast db rollback # rolls back the latest migration")) 26 | 27 | 28 | (defn gen [args] 29 | (let [[_ kind arg] args] 30 | (case kind 31 | "migration" (generators.migration/write (drop 2 args)) 32 | "code" (generators.code/write arg) 33 | (usage)))) 34 | 35 | 36 | (defn -main [& args] 37 | (let [[action] args] 38 | (case action 39 | "gen" (gen args) 40 | "db" (cond 41 | (contains? #{"migrate" "rollback"} (second args)) (migrations/-main (second args)) 42 | (contains? #{"create" "drop"} (second args)) (db/-main (second args)) 43 | :else (usage)) 44 | (usage)))) 45 | -------------------------------------------------------------------------------- /src/coast/db/insert.clj: -------------------------------------------------------------------------------- 1 | (ns coast.db.insert 2 | (:require [coast.utils :as utils] 3 | [clojure.string :as string])) 4 | 5 | (defn col [k] 6 | (if (qualified-ident? k) 7 | (str (-> k namespace utils/snake) "." (-> k name utils/snake)) 8 | (-> k name utils/snake))) 9 | 10 | (defn unqualified-col [k] 11 | (-> k name utils/snake)) 12 | 13 | (defn table [t] 14 | (str (->> (map first t) 15 | (filter qualified-ident?) 16 | (first) 17 | (namespace) 18 | (utils/snake)))) 19 | 20 | (defn insert-into [t] 21 | {:insert-into (str "insert into " (table t) " (" 22 | (->> (map first t) 23 | (map unqualified-col) 24 | (string/join ", ")) 25 | ")")}) 26 | 27 | (defn values [t] 28 | {:values (str "values " (string/join "," 29 | (map #(str "(" (->> (map (fn [_] "?") %) 30 | (string/join ",")) 31 | ")") 32 | t)))}) 33 | 34 | (defn sql-map [t] 35 | (apply merge (insert-into (first t)) 36 | (values t))) 37 | 38 | (defn tuple [m] 39 | (mapv identity m)) 40 | 41 | (defn sql-vec [arg] 42 | (let [v (if (sequential? arg) arg [arg]) 43 | tuples (mapv tuple v) 44 | {:keys [insert-into values]} (sql-map tuples)] 45 | (vec (concat [(string/join " " (filter some? [insert-into values "returning *"]))] 46 | (mapcat #(map second %) tuples))))) 47 | -------------------------------------------------------------------------------- /docs/password-hashing.md: -------------------------------------------------------------------------------- 1 | # Password Hashing 2 | 3 | * [Buddy](#user-content-buddy) 4 | * [Basic Example](#user-content-basic-example) 5 | 6 | Coast does not ship with a generic encryption mechanism. 7 | 8 | It does encrypt the session cookie, but that's internal to ring middleware. 9 | 10 | ## Buddy 11 | 12 | [Buddy](https://github.com/funcool/buddy) is a mature hashing library composed of several different, smaller libraries: 13 | 14 | - `buddy-core` 15 | - `buddy-hashers` 16 | - `buddy-sign` 17 | - `buddy-auth` 18 | 19 | Typically you will only need the `buddy-hashers` library for password hashing. 20 | 21 | Here's how to set up buddy for use with a Coast application 22 | 23 | Install the `buddy-hashers` dependency in your `deps.edn` file 24 | 25 | ```clojure 26 | ; deps.edn 27 | 28 | {; other keys not shown 29 | :deps 30 | {org.clojure/clojure {:mvn/version "1.9.0"} 31 | coast-framework/coast.theta {:mvn/version "1.0.0"} 32 | org.xerial/sqlite-jdbc {:mvn/version "3.25.2"} 33 | buddy/buddy-hashers {:mvn/version "1.3.0"}}} 34 | ``` 35 | 36 | ## Basic Example 37 | 38 | You can see the [full documentation of buddy-hashers here](https://funcool.github.io/buddy-hashers/latest/), this short guide summarizes basic usage: 39 | 40 | ```clojure 41 | (ns some-ns 42 | (:require [buddy.hashers :as hashers])) 43 | 44 | (hashers/derive "secretpassword") 45 | ;; => "bcrypt+sha512$4i9sd34m..." 46 | 47 | (hashers/check "secretpassword" "bcrypt+sha512$4i9sd34m...") 48 | ;; => true 49 | ``` 50 | 51 | Buddy uses the bcrypt + sha512 algorithm by default, although there are other algorithms available. 52 | -------------------------------------------------------------------------------- /test/coast/db/queries_test.clj: -------------------------------------------------------------------------------- 1 | (ns coast.db.queries-test 2 | (:require [coast.db.queries :as queries] 3 | [clojure.test :refer [deftest testing is]])) 4 | 5 | (deftest sql-ks-test 6 | (testing "insert" 7 | (is (= [:created_at] (queries/sql-ks "insert into (created_at) values (:created_at)")))) 8 | 9 | (testing "update" 10 | (is (= [:name :id] (queries/sql-ks "update table set name = :name where id = :id")))) 11 | 12 | (testing "delete" 13 | (is (= [:id] (queries/sql-ks "delete from table where id = :id")))) 14 | 15 | (testing "select" 16 | (is (= [:id :slug] (queries/sql-ks "select *, created_at::date from table where id = :id and slug = :slug"))))) 17 | 18 | (deftest parameterize-test 19 | (testing "insert" 20 | (is (= "insert into (id, created_at) values (?, ?)" (queries/parameterize "insert into (id, created_at) values (:id, :created_at)" {:id 1 :created_at 123})))) 21 | 22 | (testing "update" 23 | (is (= "update table set name = ? where id = ?" (queries/parameterize "update table set name = :name where id = :id" {:id 1 :name "hello"})))) 24 | 25 | (testing "in clause" 26 | (is (= "in (?,?,?)" (queries/parameterize "in (:list)" {:list [1 2 3]}))))) 27 | 28 | (deftest sql-vec-test 29 | (testing "insert" 30 | (is (= ["insert into (id, created_at) values (?, ?)" 1 2] (queries/sql-vec "insert into (id, created_at) values (:id, :created_at)" {:id 1 :created-at 2})))) 31 | 32 | (testing "update" 33 | (is (= ["update table set name = ? where id = ?" 1 2] (queries/sql-vec "update table set name = :name where id = :id" {:name 1 :id 2}))))) 34 | -------------------------------------------------------------------------------- /docs/contribution.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | 3 | * [Channels](#user-content-channels) 4 | * [Bug Reports](#user-content-bug-reports) 5 | * [Coding Style](#user-content-coding-style) 6 | * [Documentation](#user-content-documentation) 7 | 8 | Open Source projects are maintained and backed by a **vibrant community** of developers and collaborators. 9 | 10 | You can and should actively participate in the development and the future of Coast either by contributing to the source code, improving documentation, reporting potential bugs and/or testing new features. 11 | 12 | ## Channels 13 | 14 | There are two ways to communicate with Coast's small community 15 | 16 | 1. [Github Repos](https://github.com/coast-framework): Share bugs or create feature requests against the repos 17 | 18 | 2. [Twitter](https://twitter.com/coastonclojure): Stay in touch with the progress on the project every day and be informed about awesome projects built with coast 19 | 20 | ## Bug Reports 21 | 22 | Any and all bug reports are welcome, there are no formatting requirements or requirements of any kind! 23 | 24 | Bugs will hopefully be fixed as they come in, but usually get fixed in a week or so 25 | 26 | PRs are also very welcome! 27 | 28 | ## Coding Style 29 | 30 | Unfortunately, clojure doesn’t have any official coding style yet so coast uses [this guide](http://tonsky.me/blog/clojurefmt/) 31 | 32 | ## Documentation 33 | 34 | When adding a new feature to the core of the framework, be sure to create or add to one of the markdown doc files in `docs/`. 35 | 36 | This will help everyone understand your feature and keep the documentation updated. 37 | -------------------------------------------------------------------------------- /src/coast/db/errors.clj: -------------------------------------------------------------------------------- 1 | (ns coast.db.errors 2 | (:require [coast.utils :as utils] 3 | [clojure.string :as string])) 4 | 5 | (defn not-null-constraint [s] 6 | (let [col (-> (re-find #"null value in column \"(.*)\" violates not-null constraint" s) 7 | (second))] 8 | (if (nil? col) 9 | {} 10 | {(keyword col) (str (utils/humanize col) " cannot be blank") 11 | ::error :not-null 12 | :db.constraints/not-null (keyword col)}))) 13 | 14 | (defn unique-constraint [s] 15 | (let [[name cs vs] (->> (re-seq #"(?s)duplicate key value violates unique constraint \"(.*)\".*Detail: Key \((.*)\)=\((.*)\)" s) 16 | (first) 17 | (drop 1))] 18 | (when (every? some? [name cs vs]) 19 | (let [table (first (string/split name #"_")) 20 | cols (->> (string/split cs #",") 21 | (map string/trim) 22 | (map keyword)) 23 | msg-values (map #(str (utils/humanize %) " already exists") cols) 24 | values (->> (string/split vs #",") 25 | (map string/trim)) 26 | m (zipmap cols values) 27 | message-map (zipmap cols msg-values)] 28 | (merge message-map {:db.constraints/unique name 29 | ::error :unique-constraint 30 | ::value m 31 | ::message (str "That " table " already exists")}))))) 32 | 33 | (defn error-map [ex] 34 | (let [s (.getMessage ex) 35 | m1 (not-null-constraint s) 36 | m2 (unique-constraint s)] 37 | (merge m1 m2))) 38 | -------------------------------------------------------------------------------- /src/coast/validation.clj: -------------------------------------------------------------------------------- 1 | (ns coast.validation 2 | (:require [jkkramer.verily :as v] 3 | [coast.utils :as utils] 4 | [clojure.string :as string] 5 | [coast.error :refer [raise]])) 6 | 7 | (defn fmt-validation [result] 8 | (let [{:keys [keys msg]} result] 9 | (map #(vector % (str (utils/humanize %) " " msg)) keys))) 10 | 11 | (defn fmt-validations [results] 12 | (when (some? results) 13 | (->> (map fmt-validation results) 14 | (mapcat identity) 15 | (into {})))) 16 | 17 | (defn validate 18 | "Validate the map `m` with a vector of rules `validations`. 19 | 20 | For example: 21 | ``` 22 | (validate {:customer/id 123 23 | :customer/email \"sean@example.com\"} 24 | [[:required [:customer/id :customer/email]] 25 | [:email [:customer/email]]]) 26 | ;; => {:customer/id 123 27 | :customer/email \"sean@example.com\"} 28 | 29 | (validate {} [[:required [:customer/id] \"can't be blank\"]]) 30 | ;; => Unhandled clojure.lang.ExceptionInfo 31 | ;; Invalid data: :customer/id 32 | ;; {:type :invalid, 33 | ;; :errors #:customer{:id \"Id can't be blank\"}, 34 | ;; :coast.validation/error :validation, 35 | ;; :coast.error/raise true} 36 | ``` 37 | 38 | See [Validator](https://coastonclojure.com/docs/validator.md) for more. 39 | " 40 | [m validations] 41 | (let [errors (-> (v/validate m validations) 42 | (fmt-validations))] 43 | (if (empty? errors) 44 | m 45 | (raise (str "Invalid data: " (string/join ", " (keys errors))) 46 | {:type :invalid 47 | :errors errors 48 | ::error :validation})))) 49 | -------------------------------------------------------------------------------- /docs/directory-structure.md: -------------------------------------------------------------------------------- 1 | # Directory Structure 2 | 3 | * [Directory Structure](#user-content-directory-structure) 4 | 5 | The Coast directory structure may feel overwhelming at first glance since there are a handful of pre-configured directories. 6 | 7 | Eventually, you'll understand what is there because of clojure, java or coast itself, but in the mean time it's a decent way to structure web apps with tools.deps 8 | 9 | A standard Coast installation looks something like so: 10 | ```bash 11 | . 12 | ├── Makefile 13 | ├── README.md 14 | ├── bin 15 | │   └── repl.clj 16 | ├── db 17 | │   └── migrations/ 18 | │   └── associations.clj 19 | ├── db.edn 20 | ├── deps.edn 21 | ├── env.edn 22 | ├── resources 23 | │   ├── assets.edn 24 | │   └── public 25 | │   ├── css 26 | │   │   └── app.css 27 | │   └── js 28 | │   └── app.js 29 | ├── src 30 | │   ├── components.clj 31 | │   ├── home.clj 32 | │   ├── routes.clj 33 | │   └── server.clj 34 | └── test 35 | └── server_test.clj 36 | ``` 37 | 38 | ## Root Directories 39 | 40 | ### bin 41 | 42 | The `bin` directory is the home of the [nREPL](https://github.com/nrepl/nrepl) server. 43 | 44 | ### db 45 | 46 | The `db` directory is used to store all database related files. 47 | 48 | ### resources 49 | 50 | The `resources` directory is used to serve static assets over HTTP. 51 | 52 | This directory is mapped to the root of your website: 53 | 54 | ```clojure 55 | ; actual file is stored in /resources/css/app.css 56 | [:link {:rel "stylesheet" :href "/app.css"}] 57 | ``` 58 | 59 | ### src 60 | 61 | The `src` directory is used to store `components` (re-usable bits of html), and your application code, one file for each set of routes. 62 | 63 | ### test 64 | 65 | The `test` directory is used to store all your application tests. 66 | -------------------------------------------------------------------------------- /src/coast/responses.clj: -------------------------------------------------------------------------------- 1 | (ns coast.responses) 2 | 3 | (def content-type-headers {:html {"content-type" "text/html"} 4 | :json {"content-type" "application/json"}}) 5 | 6 | (defn response 7 | ([status body headers] 8 | (let [m {:status status 9 | :body body 10 | :headers headers}] 11 | (if (contains? #{:html :json} headers) 12 | (assoc m :headers (get content-type-headers headers)) 13 | m))) 14 | ([status body] 15 | (response status body {}))) 16 | 17 | (defn flash 18 | "Inject a string `s` that will be persistent after the redirect." 19 | [response s] 20 | (assoc response :flash s)) 21 | 22 | (defn redirect 23 | "Return a Ring response map with status code 302 and url `url`" 24 | [url] 25 | {:status 302 26 | :body "" 27 | :headers {"Location" url}}) 28 | 29 | (def ok 30 | "Return a Ring response map with status code 200" 31 | (partial response 200)) 32 | 33 | (def created 34 | "Return a Ring response map with status code 201" 35 | (partial response 201)) 36 | 37 | (def accepted 38 | "Return a Ring response map with status code 202" 39 | (partial response 202)) 40 | 41 | (def no-content 42 | "Return a Ring response map with status code 204" 43 | (partial response 204)) 44 | 45 | (def bad-request 46 | "Return a Ring response map with status code 400" 47 | (partial response 400)) 48 | 49 | (def unauthorized 50 | "Return a Ring response map with status code 401" 51 | (partial response 401)) 52 | 53 | (def not-found 54 | "Return a Ring response map with status code 404" 55 | (partial response 404)) 56 | 57 | (def forbidden 58 | "Return a Ring response map with status code 403" 59 | (partial response 403)) 60 | 61 | (def server-error 62 | "Return a Ring response map with status code 500" 63 | (partial response 500)) 64 | -------------------------------------------------------------------------------- /src/coast.clj: -------------------------------------------------------------------------------- 1 | (ns coast 2 | (:require [coast.potemkin.namespaces :as namespaces] 3 | [hiccup2.core] 4 | [coast.db] 5 | [coast.db.connection] 6 | [coast.theta] 7 | [coast.env] 8 | [coast.time2] 9 | [coast.components] 10 | [coast.responses] 11 | [coast.utils] 12 | [coast.error] 13 | [coast.router] 14 | [coast.validation]) 15 | (:refer-clojure :exclude [update])) 16 | 17 | (namespaces/import-vars 18 | [coast.responses 19 | ok 20 | bad-request 21 | not-found 22 | unauthorized 23 | server-error 24 | redirect 25 | flash] 26 | 27 | [coast.error 28 | raise 29 | rescue] 30 | 31 | [coast.db 32 | q 33 | pull 34 | transact 35 | delete 36 | insert 37 | update 38 | first! 39 | pluck 40 | fetch 41 | execute! 42 | find-by 43 | transaction 44 | upsert 45 | any-rows?] 46 | 47 | [coast.db.connection 48 | connection] 49 | 50 | [coast.validation 51 | validate] 52 | 53 | [coast.components 54 | csrf 55 | form 56 | js 57 | css] 58 | 59 | [coast.router 60 | routes 61 | wrap-routes 62 | prefix-routes 63 | with 64 | with-prefix] 65 | 66 | [coast.middleware 67 | wrap-with-layout 68 | with-layout 69 | wrap-layout 70 | site-routes 71 | site 72 | api-routes 73 | api 74 | content-type?] 75 | 76 | [coast.theta 77 | server 78 | app 79 | url-for 80 | action-for 81 | redirect-to 82 | form-for] 83 | 84 | [coast.env 85 | env] 86 | 87 | [coast.utils 88 | uuid 89 | intern-var 90 | xhr?] 91 | 92 | [coast.time2 93 | now 94 | datetime 95 | instant 96 | strftime] 97 | 98 | [hiccup2.core 99 | raw 100 | html]) 101 | -------------------------------------------------------------------------------- /src/coast/db/update.clj: -------------------------------------------------------------------------------- 1 | (ns coast.db.update 2 | (:require [coast.db.schema :as db.schema] 3 | [coast.time :as time] 4 | [coast.utils :as utils] 5 | [clojure.string :as string])) 6 | 7 | (defn col [k] 8 | (if (qualified-ident? k) 9 | (str (-> k namespace utils/snake) "." (-> k name utils/snake)) 10 | (-> k name utils/snake))) 11 | 12 | (defn unqualified-col [k] 13 | (col (keyword (name k)))) 14 | 15 | (defn idents [t] 16 | (let [schema-idents (:idents (db.schema/fetch))] 17 | (filter #(contains? schema-idents (first %)) t))) 18 | 19 | (defn where [t] 20 | (let [idents (idents t)] 21 | (if (empty? idents) 22 | (throw (Exception. "db/transact requires at least one ident")) 23 | {:where (str "where " (->> idents 24 | (map (fn [[k _]] (str (col k) " = ?"))) 25 | (string/join " and ")))}))) 26 | 27 | (defn cols [t] 28 | (let [schema-cols (set (conj (:cols (db.schema/fetch)) :updated-at))] 29 | (filter #(contains? schema-cols (first %)) t))) 30 | 31 | (defn table [t] 32 | (str (->> (map first t) 33 | (filter qualified-ident?) 34 | (first) 35 | (namespace) 36 | (utils/snake)))) 37 | 38 | (defn update-set [t] 39 | (let [cols (cols t) 40 | table (table t)] 41 | {:update (str "update " table " set " 42 | (->> (map (fn [[k _]] (str (unqualified-col k) " = ?")) cols) 43 | (string/join ", ")))})) 44 | 45 | (defn sql-map [t] 46 | (apply merge (update-set t) 47 | (where t))) 48 | 49 | (defn sql-vec [m] 50 | (let [t (->> (assoc m :updated-at (time/now)) 51 | (map identity)) 52 | cols (cols t) 53 | idents (idents t) 54 | {:keys [where update]} (sql-map t)] 55 | (vec (concat [(string/join " " (filter some? [update where "returning *"]))] 56 | (map second cols) 57 | (map second idents))))) 58 | -------------------------------------------------------------------------------- /docs/csrf-protection.md: -------------------------------------------------------------------------------- 1 | # CSRF Protection 2 | 3 | * [How it works](#user-content-how-it-works) 4 | * [Components](#user-content-components) 5 | 6 | Cross-Site Request Forgery (CSRF) allows an attacker to perform actions on behalf of another person without their knowledge or permission. 7 | 8 | Coast protects your application from CSRF attacks by denying unidentified requests. HTTP requests with *POST, PUT and DELETE* methods are checked to make sure that the right people from the right place invoke these requests. 9 | 10 | ## How It Works 11 | 12 | 1. Coast creates a *CSRF secret* for each request on your site. 13 | 2. A corresponding token for the secret is generated for each request and passed to all `form` and `form-for` functions in the `csrf` and `*anti-forgery-token*` bindings 14 | 3. Whenever a *POST*, *PUT* or *DELETE* request is made, the middleware verifies the token with the secret to make sure it is valid. 15 | 16 | ## Components 17 | 18 | Coast makes three components available for easy CSRF integration 19 | 20 | A hidden input with the csrf token: 21 | 22 | #### `csrf` 23 | 24 | ```clojure 25 | (ns some-ns 26 | (:require [coast])) 27 | 28 | [:form {:action "/" :method :post} 29 | (coast/csrf)] 30 | ``` 31 | 32 | A form with the hidden input already added to the body: 33 | 34 | #### `form` 35 | 36 | ```clojure 37 | (ns some-ns 38 | (:require [coast])) 39 | 40 | (coast/form {:action "/" :method :post}) ; already includes the `csrf` part 41 | ``` 42 | 43 | And finally a form that includes the csrf hidden input in the body, and also takes a route handler name instead of a map: 44 | 45 | ```clojure 46 | ; example routes 47 | [:post "/customers" :customer/create] 48 | [:put "/customers/:customer-id" :customer/change] 49 | 50 | (coast/form-for :customer/create) 51 | ; ... inputs go here 52 | 53 | (coast/form-for :customer/change {:customer/id 123}) 54 | ; ... inputs go here 55 | ``` 56 | 57 | Coast was designed to ensure you don't have to think about low-level details of web applications like CSRF protection but it's always nice to know what's going on under the hood. 58 | -------------------------------------------------------------------------------- /docs/handlers.md: -------------------------------------------------------------------------------- 1 | # Handlers 2 | 3 | * [Creating Handlers](#user-content-creating-handlers) 4 | * [Using Handlers](#user-content-using-handlers) 5 | 6 | Handlers are represented by routes, grouping related request handling logic into single files, and are the common point of interaction between your database, html and any other services you may need. 7 | 8 | NOTE: A handler's only job is to respond to a HTTP request. 9 | 10 | ## Creating Handlers 11 | 12 | To create a new handler function files, use the `coast gen code` command: 13 | 14 | ```bash 15 | coast gen code author 16 | ``` 17 | 18 | This command creates a boilerplate file in the `src` folder: 19 | 20 | ```clojure 21 | ; src/author.clj 22 | (ns author 23 | (:require [coast])) 24 | 25 | 26 | (defn index [request]) 27 | (defn view [request]) 28 | (defn build [request]) 29 | (defn create [request]) 30 | (defn edit [request]) 31 | (defn change [request]) 32 | (defn delete [request]) 33 | ``` 34 | 35 | ## Using Handlers 36 | 37 | A handler can be accessed from a route. 38 | 39 | This is done by referencing the handler as a **keyword** in your route definition: 40 | 41 | ```clojure 42 | ; routes.clj 43 | [:get "/authors" :author/index] 44 | ``` 45 | 46 | The part before the `/` is a reference to the handler file (e.g. `author.clj`). 47 | 48 | The part after the `/` is the name of the function you want to call (e.g. `index`). 49 | 50 | For example: 51 | 52 | ```clojure 53 | ; routes.clj 54 | 55 | ; src/author.clj -> (defn index [request]) 56 | [:get "/authors" :author/index] 57 | 58 | ; src/admin/dashboard.clj -> (defn index [request]) 59 | [:get "/authors" :admin.dashboard/index] 60 | 61 | ; src/a/deep/path/file.clj -> (defn create [request]) 62 | [:get "/a-deep-path" :a.deep.path.file/create] 63 | ``` 64 | 65 | As your defined handler functions are route handlers, they will receive the [request map](/docs/request-lifecycle.md) as an argument. 66 | 67 | ```clojure 68 | ; src/author.clj 69 | 70 | (ns author 71 | (:require [coast])) 72 | 73 | (defn index [request] 74 | (let [params (:params request) 75 | session (:session request) 76 | errors (:errors request)] 77 | ; code generating a response goes here 78 | )) 79 | ``` 80 | -------------------------------------------------------------------------------- /src/coast/components.clj: -------------------------------------------------------------------------------- 1 | (ns coast.components 2 | (:require [ring.middleware.anti-forgery :refer [*anti-forgery-token*]] 3 | [coast.env :refer [env]] 4 | [coast.assets :as assets])) 5 | 6 | (defn csrf 7 | "Return a hidden input with the csrf token." 8 | ([attrs] 9 | [:input (assoc attrs 10 | :type "hidden" 11 | :name "__anti-forgery-token" 12 | :value *anti-forgery-token*)]) 13 | ([] 14 | (csrf {}))) 15 | 16 | (defn form 17 | "Return a form with the hidden input already added to the body" 18 | [params & body] 19 | [:form (dissoc params :_method) 20 | (csrf) 21 | (when (contains? #{:patch :put :delete} (:_method params)) 22 | [:input {:type "hidden" :name "_method" :value (:_method params)}]) 23 | body]) 24 | 25 | (defn css 26 | "Adds a link tag to a CSS bundle. 27 | 28 | Relative path (to CSS files in the public directory): 29 | 30 | ```clojure 31 | (coast/css \"bundle.css\") 32 | 33 | ; assuming the assets.edn looks something like this 34 | {\"bundle.css\" [\"style.css\"]} 35 | ``` 36 | 37 | The code above outputs: 38 | 39 | ```html 40 | 41 | ``` 42 | " 43 | ([req bundle opts] 44 | (let [files (assets/bundle (env :coast-env) bundle)] 45 | (for [href files] 46 | [:link (merge {:href href :type "text/css" :rel "stylesheet"} opts)]))) 47 | ([req bundle] 48 | (css nil bundle {})) 49 | ([bundle] 50 | (css nil bundle))) 51 | 52 | (defn js 53 | "Adds a script tag to a JS bundle. 54 | 55 | ```clojure 56 | (coast/js \"bundle.js\") 57 | 58 | ; assuming the assets.edn looks something like this 59 | {\"bundle.js\" [\"app.js\" \"app2.js\"]} 60 | ``` 61 | 62 | The code above outputs: 63 | 64 | ```html 65 | 113 | 114 | ``` 115 | 116 | in development. 117 | 118 | NOTE: In production, the assets are bundled and minified into one js file and one css file. 119 | 120 | #### url-for 121 | Returns the URL for a route. 122 | 123 | For example, using the following example route… 124 | 125 | ```clojure 126 | [:get "/customers/:customer-id" :customer/show] 127 | ``` 128 | 129 | …if you pass the route name and any route parameters… 130 | 131 | ```clojure 132 | [:div 133 | [:a {:href (url-for :customer/show {:customer/id 123})} 134 | "View customer"]] 135 | ``` 136 | 137 | …the route URL will render like so: 138 | 139 | ```html 140 | 141 | View customer 142 | 143 | ``` 144 | 145 | #### CSRF 146 | You can access the CSRF token and input field using one of the following helpers. 147 | 148 | #### `csrf` 149 | 150 | ```clojure 151 | [:form {:action "/" :method "POST"} 152 | (coast/csrf)] 153 | ``` 154 | 155 | Which renders 156 | 157 | ```html 158 |
159 | 160 | 161 | ``` 162 | 163 | #### Forms 164 | You never really have to know about the csrf field itself because coast has built in form components as well 165 | 166 | #### `form` 167 | 168 | ```clojure 169 | (coast/form (coast/action-for :customer/change {:customer/id 123}) 170 | [:input {:type "text" :name "first-name" :value ""}]) 171 | ``` 172 | 173 | The csrf field is automatically appended to the `coast/form` form. 174 | 175 | #### `form-for` 176 | 177 | ```clojure 178 | (coast/form-for :customer/change {:customer/id 123} 179 | [:input {:type "text" :name "first-name" :value ""}]) 180 | ``` 181 | 182 | `form-for` is a convenience function that takes a coast route name and any route parameters followed by the rest of the form. 183 | 184 | ## View Logic 185 | Coast uses clojure code to insert conditional logic into hiccup vectors 186 | 187 | Here's an example of one way to do authentication: 188 | 189 | ```clojure 190 | (defn index [request] 191 | (let [session (:session request)] 192 | [:div 193 | 194 | (if session 195 | "You are logged in!" 196 | [:a {:href "/login"} "Click here to log in"])])) 197 | ``` 198 | 199 | If you need to loop through a list of things, here is one way to do it: 200 | 201 | ```clojure 202 | (ns post 203 | (:require [coast])) 204 | 205 | (defn index [request] 206 | (let [posts (coast/q '[:select * :from post])] 207 | [:ul 208 | (for [post posts] 209 | [:li (:post/title post)])])) 210 | ``` 211 | 212 | #### `raw` 213 | 214 | Newer versions of hiccup (2.0.0 and greater) escape all html by default, if you need to render 215 | a string as html, you'll have to explicitly call raw 216 | 217 | ```clojure 218 | (coast/raw [:div "is fine now"]) 219 | ``` 220 | 221 | ## Components 222 | 223 | Keeping track of hiccup code can become unwieldy if the functions get long enough, similar to HTML code. 224 | 225 | Coast has a way of separating bits of HTML into smaller chunks that can be called on when needed. Components. 226 | 227 | Here's a basic example: 228 | 229 | ```clojure 230 | (defn modal [title & content] 231 | [:div {:class "modal" :tabindex "-1" :role "dialog"} 232 | [:div {:class "modal-dialog" :role "document"} 233 | [:div {:class "modal-content"} 234 | [:div {:class "modal-header"} 235 | [:h5 {:class "modal-title"} title] 236 | [:button {:type "button" :class "close" :data-dismiss "modal" :aria-label "Close"} 237 | [:span {:aria-hidden "true"} 238 | "×"] 239 | ]] 240 | [:div {:class "modal-body"} content] 241 | [:div {:class "modal-footer"} 242 | [:button {:type "button" :class "btn btn-primary"} 243 | "Save changes"] 244 | [:button {:type "button" :class "btn btn-secondary" :data-dismiss "modal"} 245 | "Close"] 246 | ]] 247 | ]]) 248 | ``` 249 | 250 | Use the `modal` component like this: 251 | 252 | ```clojure 253 | (defn index [request] 254 | [:div 255 | [:a {:href "#" :id "show-modal"}] 256 | 257 | (modal "My Modal" 258 | [:p "My modal body goes here"] 259 | [:div "In fact multiple things can go here"])]]) 260 | ``` 261 | 262 | This is assuming you have the requisite js somewhere. 263 | 264 | ## Layout 265 | 266 | Layouts in coast are specified alongside the routes which makes supporting multiple layouts a little easier 267 | 268 | Here's an example: 269 | 270 | ```clojure 271 | (defn my-layout-function [request body] 272 | [:html 273 | [:head 274 | [:meta {:name "viewport" :content "width=device-width, initial-scale=1"}] 275 | (coast/css "bundle.css") 276 | (coast/js "bundle.js")] 277 | [:body 278 | body]]) 279 | 280 | 281 | (defn my-other-layout-function [request body] 282 | [:html 283 | [:head 284 | [:meta {:name "viewport" :content "width=device-width, initial-scale=1"}] 285 | (coast/css "bundle.css") 286 | (coast/js "bundle.js")] 287 | [:body 288 | body]]) 289 | 290 | 291 | (def routes 292 | (coast/routes 293 | (coast/site 294 | (coast/with-layout :my-layout-function 295 | [:get "/" :home/index] 296 | [:resource :customer] 297 | 298 | (coast/with-layout :my-other-layout-function 299 | [:get "/other-route" :other/route]))))) 300 | ``` 301 | 302 | ## Syntax 303 | 304 | Hiccup also offers a more terse syntax in case you get tired of writing out html identifiers and class names in maps: 305 | 306 | This in html: 307 | 308 | ```html 309 |
310 | ``` 311 | 312 | ...becomes this in normal hiccup 313 | 314 | ```clojure 315 | [:div {:id "my-id" :class "btn btn-solid"}] 316 | ``` 317 | 318 | ...becomes this in terse hiccup 319 | 320 | ```clojure 321 | [:div#my-id.btn.btn-solid] 322 | ``` 323 | --------------------------------------------------------------------------------