├── .cljfmt.edn ├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bb.edn ├── cljfmt-indents.edn ├── deps.edn ├── dev.sh ├── dev └── deps.edn ├── libs ├── config │ └── deps.edn ├── task-runner │ ├── deps.edn │ └── src │ │ └── com │ │ └── biffweb │ │ ├── task_runner.clj │ │ └── task_runner │ │ ├── lazy.clj │ │ └── lazy │ │ └── clojure │ │ └── string.clj ├── tasks │ ├── deps.edn │ └── src │ │ └── com │ │ └── biffweb │ │ ├── tasks.clj │ │ └── tasks │ │ └── lazy │ │ ├── babashka │ │ ├── fs.clj │ │ └── process.clj │ │ ├── clojure │ │ ├── java │ │ │ ├── io.clj │ │ │ └── shell.clj │ │ ├── stacktrace.clj │ │ ├── string.clj │ │ └── tools │ │ │ └── build │ │ │ └── api.clj │ │ ├── com │ │ └── biffweb │ │ │ └── config.clj │ │ ├── hato │ │ └── client.clj │ │ ├── nextjournal │ │ └── beholder.clj │ │ └── nrepl │ │ └── cmdline.clj └── xtdb-mock │ ├── deps.edn │ └── src │ └── xtdb │ └── api.clj ├── new-project.clj ├── src └── com │ ├── biffweb.clj │ └── biffweb │ ├── aliases │ ├── xtdb1.clj │ └── xtdb2.clj │ ├── experimental.clj │ ├── experimental │ └── auth.clj │ └── impl │ ├── auth.clj │ ├── htmx_refresh.clj │ ├── middleware.clj │ ├── misc.clj │ ├── queues.clj │ ├── rum.clj │ ├── time.clj │ ├── util.clj │ ├── util │ ├── ns.clj │ ├── reload.clj │ └── s3.clj │ ├── xtdb.clj │ └── xtdb2.clj ├── starter ├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── cljfmt-indents.edn ├── deps.edn ├── dev │ ├── repl.clj │ └── tasks.clj ├── resources │ ├── config.edn │ ├── config.template.env │ ├── fixtures.edn │ ├── public │ │ ├── img │ │ │ └── glider.png │ │ └── js │ │ │ └── main.js │ ├── tailwind.config.js │ └── tailwind.css ├── server-setup.sh ├── src │ └── com │ │ ├── example.clj │ │ └── example │ │ ├── app.clj │ │ ├── email.clj │ │ ├── home.clj │ │ ├── middleware.clj │ │ ├── schema.clj │ │ ├── settings.clj │ │ ├── ui.clj │ │ └── worker.clj └── test │ └── com │ └── example_test.clj ├── tasks ├── deps.edn └── src │ └── com │ └── biffweb │ └── tasks.clj ├── test ├── main │ └── com │ │ └── biffweb │ │ └── impl │ │ └── middleware_test.clj ├── xtdb1 │ └── com │ │ └── biffweb │ │ └── impl │ │ └── xtdb_test.clj └── xtdb2 │ └── com │ └── biffweb │ ├── auth_test.clj │ └── impl │ └── xtdb2_test.clj └── xtdb2-starter ├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── cljfmt-indents.edn ├── deps.edn ├── dev ├── repl.clj └── tasks.clj ├── resources ├── config.edn ├── config.template.env ├── public │ ├── img │ │ └── glider.png │ └── js │ │ └── main.js ├── tailwind.config.js └── tailwind.css ├── server-setup.sh ├── src └── com │ ├── example.clj │ └── example │ ├── app.clj │ ├── email.clj │ ├── home.clj │ ├── middleware.clj │ ├── schema.clj │ ├── settings.clj │ ├── ui.clj │ └── worker.clj └── test └── com └── example_test.clj /.cljfmt.edn: -------------------------------------------------------------------------------- 1 | {:align-form-columns? true 2 | :align-map-columns? true 3 | :extra-indents {submit-tx [[:inner 0]]}} 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * linguist-vendored 2 | *.clj* linguist-vendored=false 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [jacobobryant] 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Clojure Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: Set up JDK 21 13 | uses: actions/setup-java@v4 14 | with: 15 | distribution: 'temurin' 16 | java-version: '21' 17 | 18 | - name: Install Clojure CLI 19 | run: | 20 | version=1.12.2.1565 21 | curl -L -O https://download.clojure.org/install/linux-install-$version.sh 22 | chmod +x linux-install-$version.sh 23 | sudo ./linux-install-$version.sh 24 | 25 | - name: Run main tests 26 | run: cd dev; clojure -M:test:test-main:test-xtdb2 27 | - name: Run xtdb1 tests 28 | run: cd dev; clojure -M:test:test-xtdb1 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pom.xml 2 | pom.xml.asc 3 | *.jar 4 | *.class 5 | /lib/ 6 | /classes/ 7 | /target/ 8 | /checkouts/ 9 | .lein-deps-sum 10 | .lein-repl-history 11 | .lein-plugins/ 12 | .lein-failures 13 | .nrepl-port 14 | .cpcache/ 15 | data/ 16 | site/ 17 | .firebase/ 18 | .calva/ 19 | .clj-kondo/ 20 | .lsp/ 21 | .portal/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | See [GitHub releases](https://github.com/jacobobryant/biff/releases) for significant releases. Some 2 | patch versions are only documented in the git commit log until the next release is published. 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jacob O'Bryant 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Biff 2 | 3 | A Clojure web framework for solo developers. See [biffweb.com](https://biffweb.com). 4 | 5 | ## Contributing 6 | 7 | Documentation source is located at [github.com/jacobobryant/biffweb.com](https://github.com/jacobobryant/biffweb.com), 8 | under `content/docs`. Feel free to submit corrections. 9 | 10 | Also check out the [content library](https://biffweb.com/docs/library/). If you write an experience report, how-to guide (could be as simple 11 | as pasting some code into a gist) or other Biff-related blog post, I'll add it there. 12 | 13 | PRs for the code are also welcome. To hack on Biff, either run `bb dev` or `cd starter; clj -M:dev dev`. (The starter 14 | project's deps.edn declares a local dependency on the Biff library code.) 15 | 16 | Finally, check out [the roadmap](https://github.com/users/jacobobryant/projects/2). These are the main tasks I'm planning to work on myself, and many of them are 17 | exploratory. If any of them look interesting to you, I'd be happy to chat more. 18 | 19 | ## Sponsors 20 | 21 | Thanks to [JUXT](https://juxt.pro), [Clojurists Together](https://www.clojuriststogether.org/) and [other 22 | individuals](https://github.com/sponsors/jacobobryant) for sponsoring Biff! 23 | -------------------------------------------------------------------------------- /bb.edn: -------------------------------------------------------------------------------- 1 | {:tasks {dev (clojure "-M:dev") 2 | format (clojure "-M:format") 3 | lint (shell "clj-kondo" "--lint" "src") 4 | postgres (shell "docker" "run" "--rm" 5 | "-e" "POSTGRES_DB=main" 6 | "-e" "POSTGRES_USER=foo" 7 | "-e" "POSTGRES_PASSWORD=bar" 8 | "-p" "5432:5432" 9 | "-v" "/home/jacob/dev/biff/target/postgres:/var/lib/postgresql/data" 10 | "postgres")}} 11 | -------------------------------------------------------------------------------- /cljfmt-indents.edn: -------------------------------------------------------------------------------- 1 | {submit-tx [[:inner 0]]} 2 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | :deps {org.clojure/clojure {:mvn/version "1.12.2"} 3 | buddy/buddy-sign {:mvn/version "3.6.1-359"} 4 | cider/cider-nrepl {:mvn/version "0.57.0"} 5 | clj-http/clj-http {:mvn/version "3.13.1"} 6 | com.nextjournal/beholder {:mvn/version "1.0.3"} 7 | jarohen/chime {:mvn/version "0.3.3"} 8 | lambdaisland/uri {:mvn/version "1.19.155"} 9 | metosin/malli {:mvn/version "0.19.1"} 10 | metosin/muuntaja {:mvn/version "0.6.11"} 11 | metosin/reitit-ring {:mvn/version "0.9.1"} 12 | nrepl/nrepl {:mvn/version "1.4.0"} 13 | org.clojure/tools.logging {:mvn/version "1.3.0"} 14 | org.clojure/tools.namespace {:mvn/version "1.5.0"} 15 | refactor-nrepl/refactor-nrepl {:mvn/version "3.11.0"} 16 | ring/ring-defaults {:mvn/version "0.7.0"} 17 | ring/ring-jetty-adapter {:mvn/version "1.15.1"} 18 | rum/rum {:mvn/version "0.12.11" 19 | :exclusions [cljsjs/react cljsjs/react-dom]} 20 | io.github.biffweb/config {:git/tag "v1.0" :git/sha "cc7ba13"} 21 | 22 | ;; XTDB 1 deps. See dev/deps.edn for XTDB 2 deps. 23 | com.xtdb/xtdb-core {:mvn/version "1.24.5"} 24 | com.xtdb/xtdb-jdbc {:mvn/version "1.24.5"} 25 | com.xtdb/xtdb-rocksdb {:mvn/version "1.24.5"} 26 | org.postgresql/postgresql {:mvn/version "42.7.7"}}} 27 | -------------------------------------------------------------------------------- /dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd dev 3 | clj -M:nrepl:test-main:test-xtdb2 4 | -------------------------------------------------------------------------------- /dev/deps.edn: -------------------------------------------------------------------------------- 1 | {:deps {com.biffweb/biff {:local/root ".."} 2 | org.slf4j/slf4j-simple {:mvn/version "2.0.0-alpha5"}} 3 | :aliases {:nrepl {:main-opts ["-m" "nrepl.cmdline" "-p" "7888"]} 4 | :test {:extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"}} 5 | :main-opts ["-m" "cognitect.test-runner"] 6 | :exec-fn cognitect.test-runner.api/test} 7 | :test-main {:extra-paths ["../test/main"] 8 | :extra-deps {cheshire/cheshire {:mvn/version "6.1.0"}}} 9 | :test-xtdb1 {:extra-paths ["../test/xtdb1"]} 10 | :test-xtdb2 {:extra-paths ["../test/xtdb2"] 11 | :deps {com.biffweb/biff {:local/root ".." 12 | :exclusions [com.xtdb/xtdb-core 13 | com.xtdb/xtdb-jdbc 14 | com.xtdb/xtdb-rocksdb 15 | org.postgresql/postgresql]} 16 | org.slf4j/slf4j-simple {:mvn/version "2.0.0-alpha5"} 17 | com.xtdb/xtdb-api {:mvn/version "2.x-20251024.124141-1"} 18 | com.xtdb/xtdb-aws {:mvn/version "2.x-20251024.124141-1"} 19 | com.xtdb/xtdb-core {:mvn/version "2.x-20251024.124141-1"}} 20 | :jvm-opts ["--add-opens=java.base/java.nio=ALL-UNNAMED" 21 | "-Dio.netty.tryReflectionSetAccessible=true"]} 22 | :format {:extra-deps {dev.weavejester/cljfmt {:mvn/version "0.15.3"}} 23 | :main-opts ["-m" "cljfmt.main" "--project-root" ".." "fix"]}} 24 | :mvn/repos 25 | {"central" {:url "https://repo1.maven.org/maven2/"} 26 | "clojars" {:url "https://clojars.org/repo"} 27 | "sonatype-snapshots" {:url "https://central.sonatype.com/repository/maven-snapshots/"}}} 28 | -------------------------------------------------------------------------------- /libs/config/deps.edn: -------------------------------------------------------------------------------- 1 | ;; This lib has been moved to a separate repo. 2 | {:deps {io.github.biffweb/config {:git/tag "v1.0" :git/sha "cc7ba13"}}} 3 | -------------------------------------------------------------------------------- /libs/task-runner/deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"]} 2 | -------------------------------------------------------------------------------- /libs/task-runner/src/com/biffweb/task_runner.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.task-runner 2 | (:require [com.biffweb.task-runner.lazy.clojure.string :as str])) 3 | 4 | (def tasks {}) 5 | 6 | (defn- print-help [tasks] 7 | (let [col-width (apply max (mapv count (keys tasks)))] 8 | (println "Available commands:") 9 | (println) 10 | (doseq [[task-name task-var] (sort-by key tasks) 11 | :let [doc (some-> (:doc (meta task-var)) 12 | str/split-lines 13 | first)]] 14 | (printf (str " %-" col-width "s%s\n") 15 | task-name 16 | (if doc 17 | (str " - " doc) 18 | ""))))) 19 | 20 | (defn- print-help-for [task-fn] 21 | (let [{:keys [doc] :or {doc ""}} (meta task-fn) 22 | lines (str/split-lines doc) 23 | indent (some->> lines 24 | rest 25 | (remove (comp empty? str/trim)) 26 | not-empty 27 | (mapv #(count (take-while #{\ } %))) 28 | (apply min)) 29 | doc (str (first lines) "\n" 30 | (->> (rest lines) 31 | (map #(subs % (min (count %) indent))) 32 | (str/join "\n")))] 33 | (println doc))) 34 | 35 | (defn run-task [task-name & args] 36 | (let [task-fn (get tasks task-name)] 37 | (cond 38 | (nil? task-fn) 39 | (binding [*out* *err*] 40 | (println (str "Unrecognized task: " task-name)) 41 | (System/exit 1)) 42 | 43 | (#{"help" "--help" "-h"} (first args)) 44 | (print-help-for task-fn) 45 | 46 | :else 47 | (apply task-fn args)))) 48 | 49 | (defn -main 50 | ([tasks-sym] 51 | (-main tasks-sym "--help")) 52 | ([tasks-sym task-name & args] 53 | (let [tasks @(requiring-resolve (symbol tasks-sym))] 54 | (if (contains? #{"help" "--help" "-h" nil} task-name) 55 | (print-help tasks) 56 | (do 57 | (alter-var-root #'tasks (constantly tasks)) 58 | (apply run-task task-name args))) 59 | (shutdown-agents)))) 60 | -------------------------------------------------------------------------------- /libs/task-runner/src/com/biffweb/task_runner/lazy.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.task-runner.lazy 2 | (:refer-clojure :exclude [refer])) 3 | 4 | (defmacro refer [sym & [sym-alias]] 5 | (let [sym-alias (or sym-alias (symbol (name sym)))] 6 | `(defn ~sym-alias [& args#] 7 | (apply (requiring-resolve '~sym) args#)))) 8 | 9 | (defmacro refer-many [& args] 10 | `(do 11 | ~@(for [[ns-sym fn-syms] (partition 2 args) 12 | fn-sym fn-syms] 13 | `(refer ~(symbol (name ns-sym) (name fn-sym)))))) 14 | -------------------------------------------------------------------------------- /libs/task-runner/src/com/biffweb/task_runner/lazy/clojure/string.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.task-runner.lazy.clojure.string 2 | (:refer-clojure :exclude [replace]) 3 | (:require [com.biffweb.task-runner.lazy :as lazy])) 4 | 5 | (lazy/refer-many clojure.string [includes? join lower-case split split-lines trim replace]) 6 | -------------------------------------------------------------------------------- /libs/tasks/deps.edn: -------------------------------------------------------------------------------- 1 | {:deps {com.biffweb/config {:git/url "https://github.com/jacobobryant/biff" 2 | :git/tag "v0.7.25" 3 | :git/sha "7e920b2" 4 | :deps/root "libs/config"} 5 | com.biffweb/task-runner {:git/url "https://github.com/jacobobryant/biff" 6 | :git/sha "bb1feb6f68f42ac3faa02c8c15b31aa21037dc63" 7 | :deps/root "libs/task-runner"} 8 | babashka/fs {:mvn/version "0.5.20"} 9 | babashka/process {:mvn/version "0.5.21"} 10 | cider/cider-nrepl {:mvn/version "0.28.3"} 11 | com.nextjournal/beholder {:mvn/version "1.0.2"} 12 | hato/hato {:mvn/version "0.9.0"} 13 | io.github.clojure/tools.build {:git/tag "v0.9.6" :git/sha "8e78bcc"} 14 | nrepl/nrepl {:mvn/version "1.0.0"} 15 | refactor-nrepl/refactor-nrepl {:mvn/version "3.6.0"}} 16 | :paths ["src"]} 17 | -------------------------------------------------------------------------------- /libs/tasks/src/com/biffweb/tasks/lazy/babashka/fs.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.tasks.lazy.babashka.fs 2 | (:require [com.biffweb.task-runner.lazy :as lazy])) 3 | 4 | (lazy/refer-many babashka.fs [exists? which set-posix-file-permissions delete-tree parent]) 5 | -------------------------------------------------------------------------------- /libs/tasks/src/com/biffweb/tasks/lazy/babashka/process.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.tasks.lazy.babashka.process 2 | (:require [com.biffweb.task-runner.lazy :as lazy])) 3 | 4 | (lazy/refer-many babashka.process [shell process]) 5 | -------------------------------------------------------------------------------- /libs/tasks/src/com/biffweb/tasks/lazy/clojure/java/io.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.tasks.lazy.clojure.java.io 2 | (:require [com.biffweb.task-runner.lazy :as lazy])) 3 | 4 | (lazy/refer-many clojure.java.io [copy file make-parents reader resource]) 5 | -------------------------------------------------------------------------------- /libs/tasks/src/com/biffweb/tasks/lazy/clojure/java/shell.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.tasks.lazy.clojure.java.shell 2 | (:refer-clojure :exclude [replace]) 3 | (:require [com.biffweb.task-runner.lazy :as lazy])) 4 | 5 | (lazy/refer-many clojure.java.shell [sh]) 6 | -------------------------------------------------------------------------------- /libs/tasks/src/com/biffweb/tasks/lazy/clojure/stacktrace.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.tasks.lazy.clojure.stacktrace 2 | (:require [com.biffweb.task-runner.lazy :as lazy])) 3 | 4 | (lazy/refer-many clojure.stacktrace [print-stack-trace]) 5 | -------------------------------------------------------------------------------- /libs/tasks/src/com/biffweb/tasks/lazy/clojure/string.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.tasks.lazy.clojure.string 2 | (:refer-clojure :exclude [replace]) 3 | (:require [com.biffweb.task-runner.lazy :as lazy])) 4 | 5 | (lazy/refer-many clojure.string [includes? join lower-case split split-lines trim replace]) 6 | -------------------------------------------------------------------------------- /libs/tasks/src/com/biffweb/tasks/lazy/clojure/tools/build/api.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.tasks.lazy.clojure.tools.build.api 2 | (:require [com.biffweb.task-runner.lazy :as lazy])) 3 | 4 | (lazy/refer-many clojure.tools.build.api [delete copy-dir compile-clj uber create-basis]) 5 | -------------------------------------------------------------------------------- /libs/tasks/src/com/biffweb/tasks/lazy/com/biffweb/config.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.tasks.lazy.com.biffweb.config 2 | (:require [com.biffweb.task-runner.lazy :as lazy])) 3 | 4 | (lazy/refer-many com.biffweb.config [use-aero-config]) 5 | -------------------------------------------------------------------------------- /libs/tasks/src/com/biffweb/tasks/lazy/hato/client.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.tasks.lazy.hato.client 2 | (:refer-clojure :exclude [get]) 3 | (:require [com.biffweb.task-runner.lazy :as lazy])) 4 | 5 | (lazy/refer-many hato.client [get]) 6 | -------------------------------------------------------------------------------- /libs/tasks/src/com/biffweb/tasks/lazy/nextjournal/beholder.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.tasks.lazy.nextjournal.beholder 2 | (:refer-clojure :exclude [get]) 3 | (:require [com.biffweb.task-runner.lazy :as lazy])) 4 | 5 | (lazy/refer-many nextjournal.beholder [watch]) 6 | -------------------------------------------------------------------------------- /libs/tasks/src/com/biffweb/tasks/lazy/nrepl/cmdline.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.tasks.lazy.nrepl.cmdline 2 | (:require [com.biffweb.task-runner.lazy :as lazy])) 3 | 4 | (lazy/refer-many nrepl.cmdline [-main]) 5 | -------------------------------------------------------------------------------- /libs/xtdb-mock/deps.edn: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /libs/xtdb-mock/src/xtdb/api.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb.api 2 | (:refer-clojure :exclude [sync])) 3 | 4 | (def ^:private functions 5 | '[await-tx 6 | db 7 | entity 8 | latest-completed-tx 9 | listen 10 | open-q 11 | open-tx-log 12 | q 13 | start-node 14 | submit-tx 15 | sync 16 | tx-committed? 17 | with-tx]) 18 | 19 | (defn- fail [& args] 20 | (throw (ex-info (str "Unsupported operation. You're trying to call an XTDB function, but com.biffweb/xtdb-mock " 21 | "is in your dependencies.") 22 | {}))) 23 | 24 | (doseq [sym functions] 25 | (intern 'xtdb.api sym fail)) 26 | -------------------------------------------------------------------------------- /new-project.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.new-project 2 | (:require [clojure.java.shell :as shell] 3 | [clojure.java.io :as io] 4 | [clojure.string :as str])) 5 | 6 | (def repo-url "https://github.com/jacobobryant/biff") 7 | 8 | (defn sh 9 | [& args] 10 | (let [result (apply shell/sh args)] 11 | (if (= 0 (:exit result)) 12 | (:out result) 13 | (throw (ex-info (:err result) result))))) 14 | 15 | (defn prompt [msg] 16 | (print msg) 17 | (flush) 18 | (or (not-empty (read-line)) 19 | (recur msg))) 20 | 21 | (defn ns->path [s] 22 | (-> s 23 | (str/replace "-" "_") 24 | (str/replace "." "/"))) 25 | 26 | (defn rmrf [file] 27 | (when (.isDirectory file) 28 | (run! rmrf (.listFiles file))) 29 | (io/delete-file file)) 30 | 31 | (defn fetch-refs [] 32 | (-> (sh "git" "ls-remote" (str repo-url ".git")) 33 | (str/split #"\s+") 34 | (->> (partition 2) 35 | (map (comp vec reverse)) 36 | (into {})))) 37 | 38 | (defn die [& message] 39 | (binding [*out* *err*] 40 | (apply println message) 41 | (System/exit 1))) 42 | 43 | (defn shell-expand [s] 44 | (try 45 | (sh "bash" "-c" (str "echo -n " s)) 46 | (catch Exception e 47 | s))) 48 | 49 | (defn -main 50 | ([] (-main "release" "starter")) 51 | ([branch] (-main branch "starter")) 52 | ([branch starter-dir] 53 | (let [ref->commit (fetch-refs) 54 | commit (ref->commit (str "refs/heads/" branch)) 55 | _ (when-not commit 56 | (die "Invalid git branch:" branch)) 57 | tag (some-> (filter (fn [[ref_ commit_]] 58 | (and (= commit commit_) 59 | (str/starts-with? ref_ "refs/tags/v"))) 60 | ref->commit) 61 | ffirst 62 | (str/replace "refs/tags/" "")) 63 | coordinates (if tag 64 | {:git/url repo-url 65 | :git/sha (subs commit 0 7) 66 | :git/tag tag} 67 | {:git/url repo-url 68 | :git/sha commit}) 69 | dir (->> (prompt "Enter name for project directory: ") 70 | shell-expand 71 | (io/file)) 72 | main-ns (prompt "Enter main namespace (e.g. com.example): ") 73 | tmp (io/file dir "tmp") 74 | starter (io/file tmp "biff" starter-dir)] 75 | (io/make-parents (io/file tmp "_")) 76 | (sh "git" "clone" "--single-branch" "--branch" branch repo-url :dir tmp) 77 | (doseq [src (->> (file-seq starter) 78 | (filter #(.isFile %))) 79 | :let [relative (-> (.getPath src) 80 | (str/replace #"\\" "/") 81 | (str/replace-first (re-pattern (str ".*?biff/" starter-dir "/")) 82 | "") 83 | (str/replace "com/example" (ns->path main-ns))) 84 | dest (io/file dir relative)]] 85 | (io/make-parents dest) 86 | (binding [*print-namespace-maps* false] 87 | (spit dest 88 | (-> src 89 | slurp 90 | (str/replace "com.example" main-ns) 91 | (str/replace ":local/root \"..\"" (subs (pr-str coordinates) 92 | 1 93 | (dec (count (pr-str coordinates))))) 94 | (str/replace "{:local/root \"../libs/tasks\"}" 95 | (pr-str (assoc coordinates :deps/root "libs/tasks"))))))) 96 | (rmrf tmp) 97 | (io/make-parents dir "target/resources/_") 98 | (println) 99 | (println "Your project is ready. Run the following commands to get started:") 100 | (println) 101 | (println " cd" (.getPath dir)) 102 | (println " git init") 103 | (println " git add .") 104 | (println " git commit -m \"First commit\"") 105 | (println " clj -M:dev dev") 106 | (println) 107 | (println "Run `clj -M:dev --help` for a list of available commands.") 108 | (println "(Consider adding `alias biff='clj -M:dev'` to your .bashrc)") 109 | (println) 110 | (System/exit 0)))) 111 | 112 | ;; Workaround since *command-line-args* now includes options passed to bb. The docs now tell people 113 | ;; to run this script with clj instead of bb, but it does still work with bb. 114 | (apply -main (cond->> *command-line-args* 115 | (= "-e" (first *command-line-args*)) (drop 2))) 116 | -------------------------------------------------------------------------------- /src/com/biffweb/aliases/xtdb1.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.aliases.xtdb1 2 | (:refer-clojure :exclude [sync]) 3 | (:require [com.biffweb.impl.util :as util :refer [resolve-optional]])) 4 | 5 | (def await-tx (resolve-optional 'xtdb.api/await-tx)) 6 | (def db (resolve-optional 'xtdb.api/db)) 7 | (def entity (resolve-optional 'xtdb.api/entity)) 8 | (def latest-completed-tx (resolve-optional 'xtdb.api/latest-completed-tx)) 9 | (def listen (resolve-optional 'xtdb.api/listen)) 10 | (def open-q (resolve-optional 'xtdb.api/open-q)) 11 | (def open-tx-log (resolve-optional 'xtdb.api/open-tx-log)) 12 | (def q (resolve-optional 'xtdb.api/q)) 13 | (def start-node (resolve-optional 'xtdb.api/start-node)) 14 | (def submit-tx (resolve-optional 'xtdb.api/submit-tx)) 15 | (def sync (resolve-optional 'xtdb.api/sync)) 16 | (def tx-committed? (resolve-optional 'xtdb.api/tx-committed?)) 17 | (def with-tx (resolve-optional 'xtdb.api/with-tx)) 18 | -------------------------------------------------------------------------------- /src/com/biffweb/aliases/xtdb2.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.aliases.xtdb2 2 | (:require [com.biffweb.impl.util :as util :refer [resolve-optional]])) 3 | 4 | (def q (resolve-optional 'xtdb.api/q)) 5 | (def plan-q (resolve-optional 'xtdb.api/plan-q)) 6 | (def start-node (resolve-optional 'xtdb.node/start-node)) 7 | (def submit-tx (resolve-optional 'xtdb.api/submit-tx)) 8 | (def ->normal-form-str (resolve-optional 'xtdb.util/->normal-form-str)) 9 | -------------------------------------------------------------------------------- /src/com/biffweb/experimental.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.experimental 2 | (:require [com.biffweb.impl.xtdb2 :as xt2])) 3 | 4 | ;;;; XTDB 2 5 | 6 | (defn use-xtdb2 7 | "Start an XTDB node with some basic default configuration. 8 | 9 | log: one of #{:local :kafka} (default :local) 10 | storage: one of #{:local :remote} (default :local) 11 | bucket, 12 | endpoint, 13 | access-key, 14 | secret-key: S3 config used when storage is :remote. secret-key is accessed 15 | via :biff/secret. 16 | 17 | You can connect to the node with `psql -h localhost -p -U xtdb xtdb`. 18 | The port number will be printed to stdout." 19 | [{:keys [biff/secret] 20 | :biff.xtdb2/keys [storage log] 21 | :biff.xtdb2.storage/keys [bucket endpoint access-key secret-key] 22 | :or {storage :local log :local} 23 | :as ctx}] 24 | (xt2/use-xtdb2 ctx)) 25 | 26 | (defn use-xtdb2-listener 27 | "Polls XTDB for new transactions and passes new/updated records to :on-tx. 28 | 29 | The xt.txs table is polled once per second. `biffx/submit-tx` will trigger a poll immediately. 30 | When there's a new transaction, the given tables will be queried for records that have been 31 | put/patched in that transaction. Deleted records are not included. Any :on-tx functions 32 | registered in :biff/modules will be called once per record as `(on-tx ctx record)`." 33 | [{:keys [biff/node biff/modules biff.xtdb.listener/tables] :as ctx}] 34 | (xt2/use-xtdb2-listener ctx)) 35 | 36 | (defn submit-tx 37 | "Same as xtdb.api/submit-tx, but calls `validate-tx` first. 38 | 39 | Also triggers a transaction log poll if use-xtdb2-listener is in use." 40 | [& args] 41 | (apply xt2/submit-tx args)) 42 | 43 | (defn validate-tx 44 | "Validates records in :put-docs/:patch-docs operations against the given Malli schema. 45 | 46 | The table keyword in each operation is used as the Malli schema and should be defined in 47 | `(:registry malli-opts)`. Throws an exception if there are validation failures. Otherwise returns 48 | true. 49 | 50 | For :patch-docs operations, all keys in the schema are treated as optional: this function does 51 | not guarantee that the resulting record will have all the required keys, only that the keys 52 | you've supplied are valid." 53 | [tx malli-opts] 54 | (xt2/validate-tx tx malli-opts)) 55 | 56 | (defn where-clause 57 | "Returns an SQL string that checks equality for the given keys. 58 | 59 | Example: 60 | 61 | (where-clause [:user/email :user/favorite-color]) 62 | => \"user$email = ? and user$favorite_color = ?\"" 63 | [kvs] 64 | (xt2/where-clause kvs)) 65 | 66 | (defn assert-unique 67 | "Returns SQL to assert there is at most 1 record with the given key/values in 68 | the table for schema.. 69 | 70 | Example: 71 | 72 | (assert-unique :user {:user/email \"hello@example.com\"}) 73 | => [\"assert 1 >= (select count(*) from users where user$email = ?\" 74 | \"hello@example.com\"]" 75 | [schema kvs] 76 | (xt2/assert-unique schema kvs)) 77 | 78 | (defn select-from-where [columns table kvs] 79 | "Returns SQL for a basic `select ... from ... where ...` query. 80 | 81 | Example: 82 | 83 | (select-from-where [:xt/id :user/joined-at] \"user\" {:user/email \"hello@example.com\"}) 84 | => [\"select _id, user$joined_at from user where user$email = ?\" \"hello@example.com\"]" 85 | (xt2/select-from-where columns table kvs)) 86 | 87 | (defn prefix-uuid 88 | "Replaces the first two bytes in uuid-rest with those from uuid-prefix. 89 | 90 | This can improve locality/query performance for records that are often queried for together. 91 | For example, records belonging to a particular user can have a :xt/id value that's prefixed with 92 | the user record's :xt/id value." 93 | [uuid-prefix uuid-rest] 94 | (xt2/prefix-uuid uuid-prefix uuid-rest)) 95 | 96 | (defn tx-log 97 | "Returns a lazy sequence of all historical records ordered by :xt/system-from. 98 | 99 | tables: a collection of tables (strings) to include in the results. If not supplied, `tx-log` 100 | will query for all tables in the 'public' table schema. 101 | 102 | after-inst: if supplied, only returns historical records with a :xt/system-from value greater 103 | than this. 104 | 105 | Records also include :xt/system-from, :xt/system-to, :xt/valid-from, and :xt/valid-to keys, as 106 | well as a :biff.xtdb/table key (a string)." 107 | [node & {:keys [tables after-inst] :as opts}] 108 | (xt2/tx-log node opts)) 109 | 110 | -------------------------------------------------------------------------------- /src/com/biffweb/experimental/auth.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.experimental.auth 2 | (:require [com.biffweb :as biff] 3 | [com.biffweb.experimental :as biffx] 4 | [clj-http.client :as http] 5 | ;; This namespace is used instead of xtdb.api in case XTDB 2 is not on the classpath. 6 | ;; If you're copying this file into your own project, you can change this to xtdb.api. 7 | [com.biffweb.aliases.xtdb2 :as xt]) 8 | (:import [java.util UUID] 9 | [java.time Instant])) 10 | 11 | (defn passed-recaptcha? [{:keys [biff/secret biff.recaptcha/threshold params] 12 | :or {threshold 0.5}}] 13 | (or (nil? (secret :recaptcha/secret-key)) 14 | (let [{:keys [success score]} 15 | (:body 16 | (http/post "https://www.google.com/recaptcha/api/siteverify" 17 | {:form-params {:secret (secret :recaptcha/secret-key) 18 | :response (:g-recaptcha-response params)} 19 | :as :json}))] 20 | (and success (or (nil? score) (<= threshold score)))))) 21 | 22 | (defn email-valid? [_ctx email] 23 | (and email 24 | (re-matches #".+@.+\..+" email) 25 | (not (re-find #"\s" email)))) 26 | 27 | (defn new-link [{:keys [biff.auth/check-state 28 | biff/base-url 29 | biff/secret 30 | anti-forgery-token]} 31 | email] 32 | (str base-url "/auth/verify-link/" 33 | (biff/jwt-encrypt 34 | (cond-> {:intent "signin" 35 | :email email 36 | :exp-in (* 60 60)} 37 | check-state (assoc :state (biff/sha256 anti-forgery-token))) 38 | (secret :biff/jwt-secret)))) 39 | 40 | (defn new-code [length] 41 | ;; We use (SecureRandom.) instead of (SecureRandom/getInstanceStrong) because 42 | ;; the latter can block, and on some shared hosts often does. Blocking is 43 | ;; fine for e.g. generating environment variables in a new project, but we 44 | ;; don't want to block here. 45 | ;; https://tersesystems.com/blog/2015/12/17/the-right-way-to-use-securerandom/ 46 | (let [rng (java.security.SecureRandom.)] 47 | (format (str "%0" length "d") 48 | (.nextInt rng (dec (int (Math/pow 10 length))))))) 49 | 50 | (defn send-link! [{:keys [biff.auth/email-validator 51 | biff/node 52 | biff.auth/get-user-id 53 | biff/send-email 54 | params] 55 | :as ctx}] 56 | (let [email (biff/normalize-email (:email params)) 57 | url (new-link ctx email) 58 | user-id (delay (get-user-id node email))] 59 | (cond 60 | (not (passed-recaptcha? ctx)) 61 | {:success false :error "recaptcha"} 62 | 63 | (not (email-validator ctx email)) 64 | {:success false :error "invalid-email"} 65 | 66 | (not (send-email ctx 67 | {:template :signin-link 68 | :to email 69 | :url url 70 | :user-exists (some? @user-id)})) 71 | {:success false :error "send-failed"} 72 | 73 | :else 74 | {:success true :email email :user-id @user-id}))) 75 | 76 | (defn verify-link [{:keys [biff.auth/check-state 77 | biff/secret 78 | path-params 79 | params 80 | anti-forgery-token]}] 81 | (let [{:keys [intent email state]} (-> (merge params path-params) 82 | :token 83 | (biff/jwt-decrypt (secret :biff/jwt-secret))) 84 | valid-state (= state (biff/sha256 anti-forgery-token)) 85 | valid-email (= email (:email params))] 86 | (cond 87 | (not= intent "signin") 88 | {:success false :error "invalid-link"} 89 | 90 | (or (not check-state) valid-state valid-email) 91 | {:success true :email email} 92 | 93 | (some? (:email params)) 94 | {:success false :error "invalid-email"} 95 | 96 | :else 97 | {:success false :error "invalid-state"}))) 98 | 99 | (defn send-code! [{:keys [biff.auth/email-validator 100 | biff/node 101 | biff/send-email 102 | biff.auth/get-user-id 103 | params] 104 | :as ctx}] 105 | (let [email (biff/normalize-email (:email params)) 106 | code (new-code 6) 107 | user-id (delay (get-user-id node email))] 108 | (cond 109 | (not (passed-recaptcha? ctx)) 110 | {:success false :error "recaptcha"} 111 | 112 | (not (email-validator ctx email)) 113 | {:success false :error "invalid-email"} 114 | 115 | (not (send-email ctx 116 | {:template :signin-code 117 | :to email 118 | :code code 119 | :user-exists (some? @user-id)})) 120 | {:success false :error "send-failed"} 121 | 122 | :else 123 | {:success true :email email :code code :user-id @user-id}))) 124 | 125 | ;;; HANDLERS ------------------------------------------------------------------- 126 | 127 | (defn send-link-handler [{:keys [biff.auth/single-opt-in 128 | biff.auth/new-user-tx 129 | params] 130 | :as ctx}] 131 | (let [{:keys [success error email user-id]} (send-link! ctx)] 132 | (when (and success single-opt-in (not user-id)) 133 | (biffx/submit-tx ctx (new-user-tx ctx email))) 134 | {:status 303 135 | :headers {"location" (if success 136 | (str "/link-sent?email=" (:email params)) 137 | (str (:on-error params "/") "?error=" error))}})) 138 | 139 | (defn verify-link-handler [{:keys [biff.auth/app-path 140 | biff.auth/invalid-link-path 141 | biff.auth/new-user-tx 142 | biff.auth/get-user-id 143 | biff/node 144 | session 145 | params 146 | path-params] 147 | :as ctx}] 148 | (let [{:keys [success error email]} (verify-link ctx) 149 | existing-user-id (when success (get-user-id node email)) 150 | token (:token (merge params path-params))] 151 | (when (and success (not existing-user-id)) 152 | (biffx/submit-tx ctx (new-user-tx ctx email))) 153 | {:status 303 154 | :headers {"location" (cond 155 | success 156 | app-path 157 | 158 | (= error "invalid-state") 159 | (str "/verify-link?token=" token) 160 | 161 | (= error "invalid-email") 162 | (str "/verify-link?error=incorrect-email&token=" token) 163 | 164 | :else 165 | invalid-link-path)} 166 | :session (cond-> session 167 | success (assoc :uid (or existing-user-id 168 | (get-user-id node email))))})) 169 | 170 | (defn- uuid-from [s] 171 | (UUID/nameUUIDFromBytes (.getBytes s))) 172 | 173 | (defn send-code-handler [{:keys [biff.auth/single-opt-in 174 | biff.auth/new-user-tx 175 | params] 176 | :as ctx}] 177 | (let [{:keys [success error email code user-id]} (send-code! ctx)] 178 | (when success 179 | (biffx/submit-tx ctx 180 | (concat [[:put-docs :biff.auth/code 181 | {:xt/id (uuid-from email) 182 | :code code 183 | :created-at (Instant/now) 184 | :failed-attempts 0}]] 185 | (when (and single-opt-in (not user-id)) 186 | (new-user-tx ctx email))))) 187 | {:status 303 188 | :headers {"location" (if success 189 | (str "/verify-code?email=" (:email params)) 190 | (str (:on-error params "/") "?error=" error))}})) 191 | 192 | (defn verify-code-handler [{:keys [biff.auth/app-path 193 | biff.auth/new-user-tx 194 | biff.auth/get-user-id 195 | biff/node 196 | params 197 | session] 198 | :as ctx}] 199 | (let [email (biff/normalize-email (:email params)) 200 | code-id (uuid-from email) 201 | code (first (xt/q node [(str "select code, created_at, failed_attempts " 202 | "from \"biff.auth\".code " 203 | "where _id = ?") 204 | code-id])) 205 | success (and (passed-recaptcha? ctx) 206 | (some? code) 207 | (< (:failed-attempts code) 3) 208 | (not (biff/elapsed? (:created-at code) :now 3 :minutes)) 209 | (= (:code params) (:code code))) 210 | existing-user-id (when success (get-user-id node email)) 211 | tx (cond 212 | success 213 | (concat [[:delete-docs :biff.auth/code code-id]] 214 | (when-not existing-user-id 215 | (new-user-tx ctx email))) 216 | 217 | (and (not success) 218 | (some? code) 219 | (< (:failed-attempts code) 3)) 220 | [[(str "update \"biff.auth\".code " 221 | "set failed_attempts = failed_attempts + 1 " 222 | "where _id = ?") 223 | code-id]])] 224 | (biffx/submit-tx ctx tx) 225 | (if success 226 | {:status 303 227 | :headers {"location" app-path} 228 | :session (assoc session :uid (or existing-user-id 229 | (get-user-id node email)))} 230 | {:status 303 231 | :headers {"location" (str "/verify-code?error=invalid-code&email=" email)}}))) 232 | 233 | (defn signout [{:keys [session]}] 234 | {:status 303 235 | :headers {"location" "/"} 236 | :session (dissoc session :uid)}) 237 | 238 | ;;; ---------------------------------------------------------------------------- 239 | 240 | (defn new-user-tx [_ctx email] 241 | [[:put-docs :user {:xt/id (random-uuid) 242 | :email email 243 | :joined-at (Instant/now)}] 244 | ["assert 1 >= (select count(*) from user where email = ?)" email]]) 245 | 246 | (defn get-user-id [node email] 247 | (-> (xt/q node ["select _id from user where email = ?" email]) 248 | first 249 | :xt/id)) 250 | 251 | (def default-options 252 | #:biff.auth{:app-path "/app" 253 | :invalid-link-path "/signin?error=invalid-link" 254 | :check-state true 255 | :new-user-tx new-user-tx 256 | :get-user-id get-user-id 257 | :single-opt-in false 258 | :email-validator email-valid?}) 259 | 260 | (defn wrap-options [handler options] 261 | (fn [ctx] 262 | (handler (merge options ctx)))) 263 | 264 | (defn module [options] 265 | {:schema {:biff.auth.code/id :uuid 266 | :biff.auth/code [:map {:closed true} 267 | [:xt/id :biff.auth.code/id] 268 | [:biff.auth.code/email :string] 269 | [:biff.auth.code/code :string] 270 | [:biff.auth.code/created-at inst?] 271 | [:biff.auth.code/failed-attempts integer?]]} 272 | :routes [["/auth" {:middleware [[wrap-options (merge default-options options)]]} 273 | ["/send-link" {:post send-link-handler}] 274 | ["/verify-link/:token" {:get verify-link-handler}] 275 | ["/verify-link" {:post verify-link-handler}] 276 | ["/send-code" {:post send-code-handler}] 277 | ["/verify-code" {:post verify-code-handler}] 278 | ["/signout" {:post signout}]]]}) 279 | -------------------------------------------------------------------------------- /src/com/biffweb/impl/auth.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.impl.auth 2 | (:require [com.biffweb.impl.misc :as bmisc] 3 | [com.biffweb.impl.rum :as brum] 4 | [com.biffweb.impl.time :as btime] 5 | [com.biffweb.impl.util :as butil] 6 | [com.biffweb.impl.xtdb :as bxt] 7 | ;;; NOTE: if you copy this file into your own project, remove the 8 | ;;; above lines and replace them with the com.biffweb namespace: 9 | ;[com.biffweb :as biff] 10 | [clj-http.client :as http] 11 | [xtdb.api :as-alias xt] 12 | ;; This namespace is used instead of xtdb.api in case XTDB 1 is not on the classpath. 13 | ;; If you're copying this file into your own project, you can change this to xtdb.api. 14 | [com.biffweb.aliases.xtdb1 :as xta])) 15 | 16 | (defn passed-recaptcha? [{:keys [biff/secret biff.recaptcha/threshold params] 17 | :or {threshold 0.5}}] 18 | (or (nil? (secret :recaptcha/secret-key)) 19 | (let [{:keys [success score]} 20 | (:body 21 | (http/post "https://www.google.com/recaptcha/api/siteverify" 22 | {:form-params {:secret (secret :recaptcha/secret-key) 23 | :response (:g-recaptcha-response params)} 24 | :as :json}))] 25 | (and success (or (nil? score) (<= threshold score)))))) 26 | 27 | (defn email-valid? [ctx email] 28 | (and email 29 | (re-matches #".+@.+\..+" email) 30 | (not (re-find #"\s" email)))) 31 | 32 | (defn new-link [{:keys [biff.auth/check-state 33 | biff/base-url 34 | biff/secret 35 | anti-forgery-token]} 36 | email] 37 | (str base-url "/auth/verify-link/" 38 | (bmisc/jwt-encrypt 39 | (cond-> {:intent "signin" 40 | :email email 41 | :exp-in (* 60 60)} 42 | check-state (assoc :state (butil/sha256 anti-forgery-token))) 43 | (secret :biff/jwt-secret)))) 44 | 45 | (defn new-code [length] 46 | ;; We use (SecureRandom.) instead of (SecureRandom/getInstanceStrong) because 47 | ;; the latter can block, and on some shared hosts often does. Blocking is 48 | ;; fine for e.g. generating environment variables in a new project, but we 49 | ;; don't want to block here. 50 | ;; https://tersesystems.com/blog/2015/12/17/the-right-way-to-use-securerandom/ 51 | (let [rng (java.security.SecureRandom.)] 52 | (format (str "%0" length "d") 53 | (.nextInt rng (dec (int (Math/pow 10 length))))))) 54 | 55 | (defn send-link! [{:keys [biff.auth/email-validator 56 | biff/db 57 | biff.auth/get-user-id 58 | biff/send-email 59 | params] 60 | :as ctx}] 61 | (let [email (butil/normalize-email (:email params)) 62 | url (new-link ctx email) 63 | user-id (delay (get-user-id db email))] 64 | (cond 65 | (not (passed-recaptcha? ctx)) 66 | {:success false :error "recaptcha"} 67 | 68 | (not (email-validator ctx email)) 69 | {:success false :error "invalid-email"} 70 | 71 | (not (send-email ctx 72 | {:template :signin-link 73 | :to email 74 | :url url 75 | :user-exists (some? @user-id)})) 76 | {:success false :error "send-failed"} 77 | 78 | :else 79 | {:success true :email email :user-id @user-id}))) 80 | 81 | (defn verify-link [{:keys [biff.auth/check-state 82 | biff/secret 83 | path-params 84 | params 85 | anti-forgery-token]}] 86 | (let [{:keys [intent email state]} (-> (merge params path-params) 87 | :token 88 | (bmisc/jwt-decrypt (secret :biff/jwt-secret))) 89 | valid-state (= state (butil/sha256 anti-forgery-token)) 90 | valid-email (= email (:email params))] 91 | (cond 92 | (not= intent "signin") 93 | {:success false :error "invalid-link"} 94 | 95 | (or (not check-state) valid-state valid-email) 96 | {:success true :email email} 97 | 98 | (some? (:email params)) 99 | {:success false :error "invalid-email"} 100 | 101 | :else 102 | {:success false :error "invalid-state"}))) 103 | 104 | (defn send-code! [{:keys [biff.auth/email-validator 105 | biff/db 106 | biff/send-email 107 | biff.auth/get-user-id 108 | params] 109 | :as ctx}] 110 | (let [email (butil/normalize-email (:email params)) 111 | code (new-code 6) 112 | user-id (delay (get-user-id db email))] 113 | (cond 114 | (not (passed-recaptcha? ctx)) 115 | {:success false :error "recaptcha"} 116 | 117 | (not (email-validator ctx email)) 118 | {:success false :error "invalid-email"} 119 | 120 | (not (send-email ctx 121 | {:template :signin-code 122 | :to email 123 | :code code 124 | :user-exists (some? @user-id)})) 125 | {:success false :error "send-failed"} 126 | 127 | :else 128 | {:success true :email email :code code :user-id @user-id}))) 129 | 130 | ;;; HANDLERS ------------------------------------------------------------------- 131 | 132 | (defn send-link-handler [{:keys [biff.auth/single-opt-in 133 | biff.auth/new-user-tx 134 | biff/db 135 | params] 136 | :as ctx}] 137 | (let [{:keys [success error email user-id]} (send-link! ctx)] 138 | (when (and success single-opt-in (not user-id)) 139 | (bxt/submit-tx (assoc ctx :biff.xtdb/retry false) (new-user-tx ctx email))) 140 | {:status 303 141 | :headers {"location" (if success 142 | (str "/link-sent?email=" (:email params)) 143 | (str (:on-error params "/") "?error=" error))}})) 144 | 145 | (defn verify-link-handler [{:keys [biff.auth/app-path 146 | biff.auth/invalid-link-path 147 | biff.auth/new-user-tx 148 | biff.auth/get-user-id 149 | biff.xtdb/node 150 | session 151 | params 152 | path-params] 153 | :as ctx}] 154 | (let [{:keys [success error email]} (verify-link ctx) 155 | existing-user-id (when success (get-user-id (xta/db node) email)) 156 | token (:token (merge params path-params))] 157 | (when (and success (not existing-user-id)) 158 | (bxt/submit-tx ctx (new-user-tx ctx email))) 159 | {:status 303 160 | :headers {"location" (cond 161 | success 162 | app-path 163 | 164 | (= error "invalid-state") 165 | (str "/verify-link?token=" token) 166 | 167 | (= error "invalid-email") 168 | (str "/verify-link?error=incorrect-email&token=" token) 169 | 170 | :else 171 | invalid-link-path)} 172 | :session (cond-> session 173 | success (assoc :uid (or existing-user-id 174 | (get-user-id (xta/db node) email))))})) 175 | 176 | (defn send-code-handler [{:keys [biff.auth/single-opt-in 177 | biff.auth/new-user-tx 178 | biff/db 179 | params] 180 | :as ctx}] 181 | (let [{:keys [success error email code user-id]} (send-code! ctx)] 182 | (when success 183 | (bxt/submit-tx (assoc ctx :biff.xtdb/retry false) 184 | (concat [{:db/doc-type :biff.auth/code 185 | :db.op/upsert {:biff.auth.code/email email} 186 | :biff.auth.code/code code 187 | :biff.auth.code/created-at :db/now 188 | :biff.auth.code/failed-attempts 0}] 189 | (when (and single-opt-in (not user-id)) 190 | (new-user-tx ctx email))))) 191 | {:status 303 192 | :headers {"location" (if success 193 | (str "/verify-code?email=" (:email params)) 194 | (str (:on-error params "/") "?error=" error))}})) 195 | 196 | (defn verify-code-handler [{:keys [biff.auth/app-path 197 | biff.auth/new-user-tx 198 | biff.auth/get-user-id 199 | biff.xtdb/node 200 | biff/db 201 | params 202 | session] 203 | :as ctx}] 204 | (let [email (butil/normalize-email (:email params)) 205 | code (bxt/lookup db :biff.auth.code/email email) 206 | success (and (passed-recaptcha? ctx) 207 | (some? code) 208 | (< (:biff.auth.code/failed-attempts code) 3) 209 | (not (btime/elapsed? (:biff.auth.code/created-at code) :now 3 :minutes)) 210 | (= (:code params) (:biff.auth.code/code code))) 211 | existing-user-id (when success (get-user-id db email)) 212 | tx (cond 213 | success 214 | (concat [[::xt/delete (:xt/id code)]] 215 | (when-not existing-user-id 216 | (new-user-tx ctx email))) 217 | 218 | (and (not success) 219 | (some? code) 220 | (< (:biff.auth.code/failed-attempts code) 3)) 221 | [{:db/doc-type :biff.auth/code 222 | :db/op :update 223 | :xt/id (:xt/id code) 224 | :biff.auth.code/failed-attempts [:db/add 1]}])] 225 | (bxt/submit-tx ctx tx) 226 | (if success 227 | {:status 303 228 | :headers {"location" app-path} 229 | :session (assoc session :uid (or existing-user-id 230 | (get-user-id (xta/db node) email)))} 231 | {:status 303 232 | :headers {"location" (str "/verify-code?error=invalid-code&email=" email)}}))) 233 | 234 | (defn signout [{:keys [session]}] 235 | {:status 303 236 | :headers {"location" "/"} 237 | :session (dissoc session :uid)}) 238 | 239 | ;;; ---------------------------------------------------------------------------- 240 | 241 | (defn new-user-tx [ctx email] 242 | [{:db/doc-type :user 243 | :db.op/upsert {:user/email email} 244 | :user/joined-at :db/now}]) 245 | 246 | (defn get-user-id [db email] 247 | (bxt/lookup-id db :user/email email)) 248 | 249 | (def default-options 250 | #:biff.auth{:app-path "/app" 251 | :invalid-link-path "/signin?error=invalid-link" 252 | :check-state true 253 | :new-user-tx new-user-tx 254 | :get-user-id get-user-id 255 | :single-opt-in false 256 | :email-validator email-valid?}) 257 | 258 | (defn wrap-options [handler options] 259 | (fn [ctx] 260 | (handler (merge options ctx)))) 261 | 262 | (defn module [options] 263 | {:schema {:biff.auth.code/id :uuid 264 | :biff.auth/code [:map {:closed true} 265 | [:xt/id :biff.auth.code/id] 266 | [:biff.auth.code/email :string] 267 | [:biff.auth.code/code :string] 268 | [:biff.auth.code/created-at inst?] 269 | [:biff.auth.code/failed-attempts integer?]]} 270 | :routes [["/auth" {:middleware [[wrap-options (merge default-options options)]]} 271 | ["/send-link" {:post send-link-handler}] 272 | ["/verify-link/:token" {:get verify-link-handler}] 273 | ["/verify-link" {:post verify-link-handler}] 274 | ["/send-code" {:post send-code-handler}] 275 | ["/verify-code" {:post verify-code-handler}] 276 | ["/signout" {:post signout}]]]}) 277 | 278 | 279 | ;; No one should be depending on this var since this namespace isn't part of the 280 | ;; public API, but it doesn't hurt to add an alias anyway... 281 | (def plugin module) 282 | 283 | ;;; FRONTEND HELPERS ----------------------------------------------------------- 284 | 285 | (def recaptcha-disclosure 286 | [:div {:style {:font-size "0.75rem" 287 | :line-height "1rem" 288 | :color "#4b5563"}} 289 | "This site is protected by reCAPTCHA and the Google " 290 | [:a {:href "https://policies.google.com/privacy" 291 | :target "_blank" 292 | :style {:text-decoration "underline"}} 293 | "Privacy Policy"] " and " 294 | [:a {:href "https://policies.google.com/terms" 295 | :target "_blank" 296 | :style {:text-decoration "underline"}} 297 | "Terms of Service"] " apply."]) 298 | 299 | (defn recaptcha-callback [fn-name form-id] 300 | [:script 301 | (brum/unsafe 302 | (str "function " fn-name "(token) { " 303 | "document.getElementById('" form-id "').submit();" 304 | "}"))]) 305 | -------------------------------------------------------------------------------- /src/com/biffweb/impl/htmx_refresh.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.impl.htmx-refresh 2 | (:require [com.biffweb.impl.rum :as brum] 3 | [clojure.string :as str] 4 | [ring.websocket :as ws] 5 | [ring.util.response :as ru-response] 6 | [rum.core :as rum])) 7 | 8 | (defn send-message! [{:keys [biff.refresh/clients]} content] 9 | (let [html (rum/render-static-markup 10 | [:div#biff-refresh {:hx-swap-oob "innerHTML"} 11 | content])] 12 | (doseq [ws @clients] 13 | (ws/send ws html)))) 14 | 15 | (defn ws-handler [{:keys [biff.refresh/clients] :as ctx}] 16 | {:status 101 17 | :headers {"upgrade" "websocket" 18 | "connection" "upgrade"} 19 | ::ws/listener {:on-open (fn [ws] 20 | (swap! clients conj ws)) 21 | :on-close (fn [ws status-code reason] 22 | (swap! clients disj ws))}}) 23 | 24 | (def snippet 25 | (str (rum/render-static-markup 26 | [:div#biff-refresh {:hx-ext "ws" 27 | :ws-connect "/__biff/refresh"}]) 28 | "")) 29 | 30 | (defn insert-refresh-snippet [{:keys [body] :as response}] 31 | (if-let [body-str (and (str/includes? (or (ru-response/get-header response "content-type") "") "text/html") 32 | (cond 33 | (string? body) body 34 | (#{java.io.InputStream java.io.File} (type body)) (slurp body)))] 35 | (-> response 36 | (assoc :body (str/replace body-str "" snippet)) 37 | (update :headers dissoc (some-> (ru-response/find-header response "content-length") key))) 38 | response)) 39 | 40 | (defn wrap-htmx-refresh [handler] 41 | (fn [{:keys [uri] :as ctx}] 42 | (if (= uri "/__biff/refresh") 43 | (ws-handler ctx) 44 | (insert-refresh-snippet (handler ctx))))) 45 | 46 | (defn send-refresh-command [ctx {:clojure.tools.namespace.reload/keys [error error-ns]}] 47 | (send-message! ctx (if (some? error) 48 | [:script (assoc (brum/unsafe "alert(document.querySelector('[data-biff-refresh-message]').getAttribute('data-biff-refresh-message'))") 49 | :data-biff-refresh-message 50 | (str "Compilation error in namespace " error-ns ": " 51 | (.getMessage (.getCause error))))] 52 | [:script (brum/unsafe "location.reload()")]))) 53 | 54 | (defn use-htmx-refresh [{:keys [biff/handler biff.refresh/enabled] :as ctx}] 55 | (if-not enabled 56 | ctx 57 | (-> ctx 58 | (assoc :biff.refresh/clients (atom #{})) 59 | (update :biff/handler wrap-htmx-refresh) 60 | (update :biff.eval/on-eval conj #'send-refresh-command)))) 61 | -------------------------------------------------------------------------------- /src/com/biffweb/impl/middleware.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.impl.middleware 2 | (:require [clojure.string :as str] 3 | [clojure.tools.logging :as log] 4 | [com.biffweb.impl.util :as util] 5 | [com.biffweb.impl.xtdb :as bxt] 6 | [muuntaja.middleware :as muuntaja] 7 | [ring.middleware.anti-forgery :as anti-forgery] 8 | [ring.middleware.content-type :refer [wrap-content-type]] 9 | [ring.middleware.defaults :as rd] 10 | [ring.middleware.resource :as res] 11 | [ring.middleware.session :as session] 12 | [ring.middleware.session.cookie :as cookie] 13 | [ring.middleware.ssl :as ssl] 14 | [rum.core :as rum])) 15 | 16 | (defn wrap-debug [handler] 17 | (fn [ctx] 18 | (util/pprint [:request ctx]) 19 | (let [resp (handler ctx)] 20 | (util/pprint [:response resp]) 21 | resp))) 22 | 23 | (defn wrap-anti-forgery-websockets [handler] 24 | (fn [{:keys [biff/base-url headers] :as ctx}] 25 | (if (and (str/includes? (str/lower-case (get headers "upgrade" "")) "websocket") 26 | (str/includes? (str/lower-case (get headers "connection" "")) "upgrade") 27 | (some? base-url) 28 | (not= base-url (get headers "origin"))) 29 | {:status 403 30 | :headers {"content-type" "text/plain"} 31 | :body "Forbidden"} 32 | (handler ctx)))) 33 | 34 | (defn wrap-render-rum [handler] 35 | (fn [ctx] 36 | (let [response (handler ctx)] 37 | (if (vector? response) 38 | {:status 200 39 | :headers {"content-type" "text/html"} 40 | :body (str "\n" (rum/render-static-markup response))} 41 | response)))) 42 | 43 | ;; Deprecated; wrap-resource does this inline now. 44 | (defn wrap-index-files [handler {:keys [index-files] 45 | :or {index-files ["index.html"]}}] 46 | (fn [ctx] 47 | (->> index-files 48 | (map #(update ctx :uri str/replace-first #"/?$" (str "/" %))) 49 | (into [ctx]) 50 | (some (wrap-content-type handler))))) 51 | 52 | (defn wrap-resource 53 | ([handler] 54 | (fn [{:biff.middleware/keys [root index-files] 55 | :or {root "public" 56 | index-files ["index.html"]} 57 | :as ctx}] 58 | (or (->> index-files 59 | (map #(update ctx :uri str/replace-first #"/?$" (str "/" %))) 60 | (into [ctx]) 61 | (some (wrap-content-type #(res/resource-request % root)))) 62 | (handler ctx)))) 63 | ;; Deprecated, use 1-arg arity 64 | ([handler {:biff.middleware/keys [root index-files] 65 | :or {root "public" 66 | index-files ["index.html"]}}] 67 | (let [resource-handler (wrap-index-files 68 | #(res/resource-request % root) 69 | {:index-files index-files})] 70 | (fn [ctx] 71 | (or (resource-handler ctx) 72 | (handler ctx)))))) 73 | 74 | (defn wrap-internal-error 75 | ([handler] 76 | (fn [{:biff.middleware/keys [on-error] 77 | :or {on-error util/default-on-error} 78 | :as ctx}] 79 | (try 80 | (handler ctx) 81 | (catch Throwable t 82 | (log/error t "Exception while handling request") 83 | (on-error (assoc ctx :status 500 :ex t)))))) 84 | ;; Deprecated, use 1-arg arity 85 | ([handler {:biff.middleware/keys [on-error] 86 | :or {on-error util/default-on-error}}] 87 | (fn [ctx] 88 | (try 89 | (handler ctx) 90 | (catch Throwable t 91 | (log/error t "Exception while handling request") 92 | (on-error (assoc ctx :status 500 :ex t))))))) 93 | 94 | (defn wrap-log-requests [handler] 95 | (fn [ctx] 96 | (let [start (System/nanoTime) 97 | resp (handler ctx) 98 | stop (System/nanoTime) 99 | duration (quot (- stop start) 1000000)] 100 | (log/infof "%3sms %s %-4s %s" 101 | (str duration) 102 | (:status resp "nil") 103 | (name (:request-method ctx)) 104 | (str (:uri ctx) 105 | (when-some [qs (:query-string ctx)] 106 | (str "?" qs)))) 107 | resp))) 108 | 109 | (defn wrap-https-scheme [handler] 110 | (fn [{:keys [biff.middleware/secure] :or {secure true} :as ctx}] 111 | (handler (if (and secure (= :http (:scheme ctx))) 112 | (assoc ctx :scheme :https) 113 | ctx)))) 114 | 115 | (defn wrap-session [handler] 116 | (fn [{:keys [biff/secret] 117 | :biff.middleware/keys [session-store 118 | cookie-secret 119 | secure 120 | session-max-age 121 | session-same-site] 122 | :or {session-max-age (* 60 60 24 60) 123 | secure true 124 | session-same-site :lax} 125 | :as ctx}] 126 | (let [cookie-secret (if secret 127 | (secret :biff.middleware/cookie-secret) 128 | ;; For backwards compatibility 129 | cookie-secret) 130 | session-store (if cookie-secret 131 | (cookie/cookie-store 132 | {:key (util/base64-decode cookie-secret)}) 133 | session-store) 134 | handler (session/wrap-session 135 | handler 136 | {:cookie-attrs {:max-age session-max-age 137 | :same-site session-same-site 138 | :http-only true 139 | :secure secure} 140 | :store session-store})] 141 | (handler ctx)))) 142 | 143 | (defn wrap-ssl [handler] 144 | (fn [{:keys [biff.middleware/secure 145 | biff.middleware/hsts 146 | biff.middleware/ssl-redirect] 147 | :or {secure true 148 | hsts true 149 | ssl-redirect false} 150 | :as ctx}] 151 | (let [handler (if secure 152 | (cond-> handler 153 | hsts ssl/wrap-hsts 154 | ssl-redirect ssl/wrap-ssl-redirect) 155 | handler)] 156 | (handler ctx)))) 157 | 158 | ;;; Deprecated 159 | 160 | (defn wrap-site-defaults [handler] 161 | (-> handler 162 | wrap-render-rum 163 | wrap-anti-forgery-websockets 164 | anti-forgery/wrap-anti-forgery 165 | wrap-session 166 | muuntaja/wrap-params 167 | muuntaja/wrap-format 168 | (rd/wrap-defaults (-> rd/site-defaults 169 | (assoc-in [:security :anti-forgery] false) 170 | (assoc-in [:responses :absolute-redirects] true) 171 | (assoc :session false) 172 | (assoc :static false))))) 173 | 174 | (defn wrap-api-defaults [handler] 175 | (-> handler 176 | muuntaja/wrap-params 177 | muuntaja/wrap-format 178 | (rd/wrap-defaults rd/api-defaults))) 179 | 180 | (defn wrap-base-defaults [handler] 181 | (-> handler 182 | wrap-https-scheme 183 | wrap-resource 184 | wrap-internal-error 185 | wrap-ssl 186 | wrap-log-requests)) 187 | 188 | (defn use-wrap-ctx [{:keys [biff/handler] :as ctx}] 189 | (assoc ctx :biff/handler (fn [req] 190 | (handler (merge (bxt/merge-context ctx) req))))) 191 | 192 | (defn wrap-ring-defaults 193 | "Deprecated" 194 | [handler {:keys [biff/secret] 195 | :biff.middleware/keys [session-store 196 | cookie-secret 197 | secure 198 | session-max-age] 199 | :or {session-max-age (* 60 60 24 60) 200 | secure true} 201 | :as ctx}] 202 | (let [cookie-secret (if secret 203 | (secret :biff.middleware/cookie-secret) 204 | ;; For backwards compatibility 205 | cookie-secret) 206 | session-store (if cookie-secret 207 | (cookie/cookie-store 208 | {:key (util/base64-decode cookie-secret)}) 209 | session-store) 210 | changes {[:responses :absolute-redirects] true 211 | [:session :store] session-store 212 | [:session :cookie-name] "ring-session" 213 | [:session :cookie-attrs :max-age] session-max-age 214 | [:session :cookie-attrs :same-site] :lax 215 | [:security :anti-forgery] false 216 | [:security :ssl-redirect] false 217 | [:static] false} 218 | ring-defaults (reduce (fn [m [path value]] 219 | (assoc-in m path value)) 220 | (if secure 221 | rd/secure-site-defaults 222 | rd/site-defaults) 223 | changes)] 224 | (cond-> handler 225 | true (rd/wrap-defaults ring-defaults) 226 | ;; This is necessary when using a reverse proxy (e.g. Nginx), otherwise 227 | ;; wrap-absolute-redirects will set the redirect scheme to http. 228 | secure wrap-https-scheme))) 229 | 230 | (defn wrap-env 231 | "Deprecated" 232 | [handler ctx] 233 | (fn [req] 234 | (handler (merge (bxt/merge-context ctx) req)))) 235 | 236 | (defn wrap-inner-defaults 237 | "Deprecated" 238 | [handler opts] 239 | (-> handler 240 | muuntaja/wrap-params 241 | muuntaja/wrap-format 242 | (wrap-resource opts) 243 | (wrap-internal-error opts) 244 | wrap-log-requests)) 245 | 246 | (defn wrap-outer-defaults 247 | "Deprecated" 248 | [handler opts] 249 | (-> handler 250 | (wrap-ring-defaults opts) 251 | (wrap-env opts))) 252 | -------------------------------------------------------------------------------- /src/com/biffweb/impl/misc.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.impl.misc 2 | (:require [buddy.core.nonce :as nonce] 3 | [buddy.sign.jwt :as jwt] 4 | [chime.core :as chime] 5 | [clj-http.client :as http] 6 | [clojure.string :as str] 7 | [clojure.tools.logging :as log] 8 | [com.biffweb.impl.time :as time] 9 | [com.biffweb.impl.util :as util] 10 | [com.biffweb.impl.xtdb :as bxt] 11 | [nextjournal.beholder :as beholder] 12 | [reitit.ring :as reitit-ring] 13 | [ring.adapter.jetty :as jetty])) 14 | 15 | (defn use-beholder [{:biff.beholder/keys [on-save exts paths enabled] 16 | :or {paths ["src" "resources" "test"] 17 | enabled true} 18 | :as ctx}] 19 | (if-not enabled 20 | ctx 21 | (let [;; Poor man's debouncer -- don't want to pull in core.async just for 22 | ;; this, and don't want to spend time figuring out how else to do it. 23 | last-called (atom (java.util.Date.)) 24 | watch (apply beholder/watch 25 | (fn [{:keys [path]}] 26 | (when (and (or (empty? exts) 27 | (some #(str/ends-with? path %) exts)) 28 | (time/elapsed? @last-called :now 1 :seconds)) 29 | ;; Give all the files some time to get written before invoking the callback. 30 | (Thread/sleep 100) 31 | (util/catchall-verbose (on-save ctx)) 32 | (reset! last-called (java.util.Date.)))) 33 | paths)] 34 | (update ctx :biff/stop conj #(beholder/stop watch))))) 35 | 36 | ;; Deprecated 37 | (defn use-hawk [{:biff.hawk/keys [on-save exts paths] 38 | :or {paths ["src" "resources"]} 39 | :as ctx}] 40 | (use-beholder (merge {:biff.beholder/on-save on-save 41 | :biff.beholder/exts exts 42 | :biff.beholder/paths paths} 43 | ctx))) 44 | 45 | (defn reitit-handler [{:keys [router routes on-error]}] 46 | (let [make-error-handler (fn [status] 47 | (fn [ctx] 48 | ((or on-error 49 | (:biff.middleware/on-error ctx on-error) 50 | util/default-on-error) 51 | (assoc ctx :status status))))] 52 | (reitit-ring/ring-handler 53 | (or router (reitit-ring/router routes)) 54 | (reitit-ring/routes 55 | (reitit-ring/redirect-trailing-slash-handler) 56 | (reitit-ring/create-default-handler 57 | {:not-found (make-error-handler 404) 58 | :method-not-allowed (make-error-handler 405) 59 | :not-acceptable (make-error-handler 406)}))))) 60 | 61 | (defn use-jetty [{:biff/keys [host port handler] 62 | :or {host "localhost" 63 | port 8080} 64 | :as ctx}] 65 | (let [server (jetty/run-jetty (fn [req] 66 | (handler (merge (bxt/merge-context ctx) req))) 67 | {:host host 68 | :port port 69 | :join? false})] 70 | (log/info "Jetty running on" (str "http://" host ":" port)) 71 | (update ctx :biff/stop conj #(.stop server)))) 72 | 73 | (defn mailersend [{:keys [mailersend/api-key 74 | mailersend/defaults]} 75 | opts] 76 | (let [opts (reduce (fn [opts [path x]] 77 | (update-in opts path #(or % x))) 78 | opts 79 | defaults)] 80 | (try 81 | (get-in 82 | (http/post "https://api.mailersend.com/v1/email" 83 | {:content-type :json 84 | :oauth-token api-key 85 | :form-params opts}) 86 | [:headers "X-Message-Id"]) 87 | (catch Exception e 88 | (log/error e "MailerSend exception") 89 | false)))) 90 | 91 | (defn jwt-encrypt 92 | [claims secret] 93 | (jwt/encrypt 94 | (-> claims 95 | (assoc :exp (time/add-seconds (time/now) (:exp-in claims))) 96 | (dissoc :exp-in)) 97 | (util/base64-decode secret) 98 | {:alg :a256kw :enc :a128gcm})) 99 | 100 | (defn jwt-decrypt 101 | [token secret] 102 | (try 103 | (jwt/decrypt 104 | token 105 | (util/base64-decode secret) 106 | {:alg :a256kw :enc :a128gcm}) 107 | (catch Exception _ 108 | nil))) 109 | 110 | (defn use-chime 111 | [{:keys [biff/features biff/plugins biff/modules biff.chime/tasks] :as ctx}] 112 | (reduce (fn [ctx {:keys [schedule task error-handler]}] 113 | (let [f (fn [_] (task (bxt/merge-context ctx))) 114 | opts (when error-handler {:error-handler error-handler}) 115 | scheduler (chime/chime-at (schedule) f opts)] 116 | (update ctx :biff/stop conj #(.close scheduler)))) 117 | ctx 118 | (or tasks 119 | (some->> (or modules plugins features) deref (mapcat :tasks))))) 120 | 121 | (defn generate-secret [length] 122 | (let [buffer (byte-array length)] 123 | (.nextBytes (java.security.SecureRandom/getInstanceStrong) buffer) 124 | (.encodeToString (java.util.Base64/getEncoder) buffer))) 125 | 126 | (defn use-random-default-secrets [ctx] 127 | (merge ctx 128 | (when (nil? (:biff.middleware/cookie-secret ctx)) 129 | (log/warn ":biff.middleware/cookie-secret is empty, using random value") 130 | {:biff.middleware/cookie-secret (generate-secret 16)}) 131 | (when (nil? (:biff/jwt-secret ctx)) 132 | (log/warn ":biff/jwt-secret is empty, using random value") 133 | {:biff/jwt-secret (generate-secret 32)}))) 134 | 135 | (defn get-secret [ctx k] 136 | (some-> (get ctx k) 137 | (System/getenv) 138 | not-empty)) 139 | 140 | (defn use-secrets [ctx] 141 | (when-not (every? #(get-secret ctx %) [:biff.middleware/cookie-secret :biff/jwt-secret]) 142 | (binding [*out* *err*] 143 | (println "Secrets are missing. Run `bb generate-secrets` and edit secrets.env.") 144 | (System/exit 1))) 145 | (assoc ctx :biff/secret #(get-secret ctx %))) 146 | 147 | (defn doc-schema [{:keys [required optional closed wildcards] 148 | :or {closed true}}] 149 | (let [ks (->> (concat required optional) 150 | (map #(cond-> % (not (keyword? %)) first))) 151 | schema (vec (concat [:map {:closed (and (not wildcards) closed)}] 152 | required 153 | (for [x optional 154 | :let [[k & rst] (if (keyword? x) 155 | [x] 156 | x) 157 | [opts rst] (if (map? (first rst)) 158 | [(first rst) (rest rst)] 159 | [{} rst]) 160 | opts (assoc opts :optional true)]] 161 | (into [k opts] rst)))) 162 | schema (if-not wildcards 163 | schema 164 | [:and 165 | schema 166 | [:fn (fn [doc] 167 | (every? (fn [[k v]] 168 | (if-let [v-pred (and (keyword? k) 169 | (wildcards (symbol (namespace k))))] 170 | (v-pred v) 171 | (not closed))) 172 | (apply dissoc doc ks)))]])] 173 | schema)) 174 | -------------------------------------------------------------------------------- /src/com/biffweb/impl/queues.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.impl.queues 2 | (:require [com.biffweb.impl.xtdb :as bxt] 3 | [com.biffweb.impl.util :as util]) 4 | (:import [java.util.concurrent 5 | PriorityBlockingQueue 6 | TimeUnit 7 | Executors 8 | Callable])) 9 | 10 | (defn- consume [ctx {:keys [queue consumer continue]}] 11 | (while @continue 12 | (when-some [job (.poll queue 1 TimeUnit/SECONDS)] 13 | (util/catchall-verbose 14 | (consumer (merge (bxt/merge-context ctx) 15 | {:biff/job job 16 | :biff/queue queue}))) 17 | (flush)))) 18 | 19 | (defn- stop [{:keys [biff.queues/stop-timeout] 20 | :or {stop-timeout 10000}} configs] 21 | (let [timeout (+ (System/nanoTime) (* stop-timeout (Math/pow 10 6)))] 22 | (some-> (first configs) 23 | :continue 24 | (reset! false)) 25 | (run! #(.shutdown (:executor %)) configs) 26 | (doseq [{:keys [executor]} configs 27 | :let [time-left (- timeout (System/nanoTime))] 28 | :when (< 0 time-left)] 29 | (.awaitTermination executor time-left TimeUnit/NANOSECONDS)) 30 | (run! #(.shutdownNow (:executor %)) configs))) 31 | 32 | (defn- default-queue [] 33 | (PriorityBlockingQueue. 11 (fn [a b] 34 | (compare (:biff/priority a 10) 35 | (:biff/priority b 10))))) 36 | 37 | (defn- init [{:keys [biff/features 38 | biff/plugins 39 | biff/modules 40 | biff.queues/enabled-ids]}] 41 | (let [continue (atom true)] 42 | (->> @(or modules plugins features) 43 | (mapcat :queues) 44 | (filter (fn [q] 45 | (or (nil? enabled-ids) (contains? enabled-ids (:id q))))) 46 | (map (fn [{:keys [id n-threads consumer queue-fn] 47 | :or {n-threads 1 48 | queue-fn default-queue}}] 49 | {:id id 50 | :n-threads n-threads 51 | :consumer consumer 52 | :queue (queue-fn) 53 | :executor (Executors/newFixedThreadPool n-threads) 54 | :continue continue}))))) 55 | 56 | (defn use-queues [ctx] 57 | (let [configs (init ctx) 58 | queues (into {} (map (juxt :id :queue) configs)) 59 | ctx (-> ctx 60 | (assoc :biff/queues queues) 61 | (update :biff/stop conj #(stop ctx configs)))] 62 | (doseq [{:keys [executor n-threads] :as config} configs 63 | _ (range n-threads)] 64 | (.submit executor ^Callable #(consume ctx config))) 65 | ctx)) 66 | 67 | (defn submit-job [ctx queue-id job] 68 | (.add (get-in ctx [:biff/queues queue-id]) job)) 69 | 70 | (defn submit-job-for-result [{:keys [biff.queues/result-timeout] 71 | :or {result-timeout 20000} 72 | :as ctx} 73 | queue-id 74 | job] 75 | (let [p (promise) 76 | result (if result-timeout 77 | (delay (deref p result-timeout ::timeout)) 78 | p)] 79 | (submit-job ctx queue-id (assoc job :biff/callback #(deliver p %))) 80 | (delay (cond 81 | (= @result ::timeout) 82 | (throw (ex-info "Timed out while waiting for job result" {:queue-id queue-id :job job})) 83 | 84 | (instance? Exception @result) 85 | (throw @result) 86 | 87 | :else 88 | @result)))) 89 | -------------------------------------------------------------------------------- /src/com/biffweb/impl/rum.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.impl.rum 2 | (:require [clojure.java.io :as io] 3 | [clojure.string :as str] 4 | [com.biffweb.impl.util :as util] 5 | [ring.middleware.anti-forgery :as anti-forgery] 6 | [rum.core :as rum])) 7 | 8 | (defn render [body] 9 | {:status 200 10 | :headers {"content-type" "text/html; charset=utf-8"} 11 | :body (str "\n" (rum/render-static-markup body))}) 12 | 13 | (defn unsafe [html] 14 | {:dangerouslySetInnerHTML {:__html html}}) 15 | 16 | (def emdash [:span (unsafe "—")]) 17 | 18 | (def endash [:span (unsafe "–")]) 19 | 20 | (def nbsp [:span (unsafe " ")]) 21 | 22 | (defn g-fonts 23 | [families] 24 | [:link {:href (apply str "https://fonts.googleapis.com/css2?display=swap" 25 | (for [f families] 26 | (str "&family=" f))) 27 | :rel "stylesheet"}]) 28 | 29 | (defn base-html 30 | [{:base/keys [title 31 | description 32 | lang 33 | image 34 | icon 35 | url 36 | canonical 37 | font-families 38 | head]} 39 | & contents] 40 | [:html 41 | {:lang lang 42 | :style {:min-height "100%" 43 | :height "auto"}} 44 | [:head 45 | [:title title] 46 | [:meta {:name "description" :content description}] 47 | [:meta {:content title :property "og:title"}] 48 | [:meta {:content description :property "og:description"}] 49 | (when image 50 | [:<> 51 | [:meta {:content image :property "og:image"}] 52 | [:meta {:content "summary_large_image" :name "twitter:card"}]]) 53 | (when-some [url (or url canonical)] 54 | [:meta {:content url :property "og:url"}]) 55 | (when-some [url (or canonical url)] 56 | [:link {:ref "canonical" :href url}]) 57 | [:meta {:name "viewport" :content "width=device-width, initial-scale=1"}] 58 | (when icon 59 | [:link {:rel "icon" 60 | :type "image/png" 61 | :sizes "16x16" 62 | :href icon}]) 63 | [:meta {:charset "utf-8"}] 64 | (when (not-empty font-families) 65 | [:<> 66 | [:link {:href "https://fonts.googleapis.com", :rel "preconnect"}] 67 | [:link {:crossorigin "crossorigin", 68 | :href "https://fonts.gstatic.com", 69 | :rel "preconnect"}] 70 | (g-fonts font-families)]) 71 | (into [:<>] head)] 72 | [:body 73 | {:style {:position "absolute" 74 | :width "100%" 75 | :min-height "100%" 76 | :display "flex" 77 | :flex-direction "column"}} 78 | contents]]) 79 | 80 | (defn form 81 | [{:keys [hidden] :as opts} & body] 82 | [:form (-> (merge {:method "post"} opts) 83 | (dissoc :hidden) 84 | (assoc-in [:style :margin-bottom] 0)) 85 | (for [[k v] (util/assoc-some hidden "__anti-forgery-token" anti-forgery/*anti-forgery-token*)] 86 | [:input {:type "hidden" 87 | :name k 88 | :value v}]) 89 | body]) 90 | 91 | ;; you could say that rum is one of our main exports 92 | (defn export-rum 93 | [pages dir] 94 | (doseq [[path rum] pages 95 | :let [full-path (cond-> (str dir path) 96 | (str/ends-with? path "/") (str "index.html"))]] 97 | (io/make-parents full-path) 98 | (spit full-path (str "\n" (rum/render-static-markup rum))))) 99 | -------------------------------------------------------------------------------- /src/com/biffweb/impl/time.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.impl.time) 2 | 3 | (def rfc3339 "yyyy-MM-dd'T'HH:mm:ss.SSSXXX") 4 | 5 | (defn parse-date [date & [format]] 6 | (.parse (new java.text.SimpleDateFormat (or format rfc3339)) date)) 7 | 8 | (defn format-date [date & [format]] 9 | (.format (new java.text.SimpleDateFormat (or format rfc3339)) date)) 10 | 11 | (defn crop-date [d fmt] 12 | (-> d 13 | (format-date fmt) 14 | (parse-date fmt))) 15 | 16 | (defn crop-day [t] 17 | (crop-date t "yyyy-MM-dd")) 18 | 19 | (defn- expand-now [x] 20 | (if (= x :now) 21 | (java.util.Date.) 22 | x)) 23 | 24 | (defn seconds-between [t1 t2] 25 | (quot (- (inst-ms (expand-now t2)) (inst-ms (expand-now t1))) 1000)) 26 | 27 | (defn seconds-in [x unit] 28 | (case unit 29 | :seconds x 30 | :minutes (* x 60) 31 | :hours (* x 60 60) 32 | :days (* x 60 60 24) 33 | :weeks (* x 60 60 24 7))) 34 | 35 | (defn elapsed? [t1 t2 x unit] 36 | (<= (seconds-in x unit) 37 | (seconds-between t1 t2))) 38 | 39 | (defn between-hours? [t h1 h2] 40 | (let [hours (/ (mod (quot (inst-ms t) (* 1000 60)) 41 | (* 60 24)) 42 | 60.0)] 43 | (if (< h1 h2) 44 | (<= h1 hours h2) 45 | (or (<= h1 hours) 46 | (<= hours h2))))) 47 | 48 | (defn add-seconds [date seconds] 49 | (java.util.Date/from (.plusSeconds (.toInstant date) seconds))) 50 | 51 | (defn now [] 52 | (java.util.Date.)) 53 | -------------------------------------------------------------------------------- /src/com/biffweb/impl/util.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.impl.util 2 | (:require 3 | [clojure.edn :as edn] 4 | [clojure.java.io :as io] 5 | [clojure.java.shell :as shell] 6 | [clojure.pprint :as pp] 7 | [clojure.repl.deps :as repl-deps] 8 | [clojure.spec.alpha :as spec] 9 | [clojure.string :as str] 10 | [clojure.tools.logging :as log] 11 | [clojure.tools.namespace.repl :as tn-repl] 12 | [clojure.walk :as walk] 13 | [com.biffweb.impl.time :as time]) 14 | (:import 15 | [java.io FileNotFoundException])) 16 | 17 | (defmacro catchall-verbose 18 | [& body] 19 | `(try 20 | ~@body 21 | (catch Exception e# 22 | (log/error e#) 23 | nil))) 24 | 25 | (defn start-system [system-atom init] 26 | (reset! system-atom (merge {:biff/stop '()} init)) 27 | (loop [{[f & components] :biff/components :as ctx} init] 28 | (when (some? f) 29 | (log/info "starting:" (str f)) 30 | (recur (reset! system-atom (f (assoc ctx :biff/components components)))))) 31 | (log/info "System started.") 32 | @system-atom) 33 | 34 | (defn refresh [{:keys [biff/after-refresh biff/stop]}] 35 | (doseq [f stop] 36 | (log/info "stopping:" (str f)) 37 | (f)) 38 | (tn-repl/refresh :after after-refresh)) 39 | 40 | (let [deps-last-modified (atom (.lastModified (io/file "deps.edn")))] 41 | (defn add-libs [opts] 42 | (let [last-modified (.lastModified (io/file "deps.edn"))] 43 | (when (not= last-modified @deps-last-modified) 44 | (reset! deps-last-modified last-modified) 45 | (binding [*repl* true] 46 | (repl-deps/sync-deps opts)))))) 47 | 48 | (defn ppr-str [x] 49 | (with-out-str 50 | (binding [*print-namespace-maps* false] 51 | (pp/pprint x)))) 52 | 53 | (defn pprint [object & [writer]] 54 | (binding [*print-namespace-maps* false] 55 | (if writer 56 | (pp/pprint object writer) 57 | (pp/pprint object))) 58 | (flush)) 59 | 60 | (defn base64-encode [bs] 61 | (.encodeToString (java.util.Base64/getEncoder) bs)) 62 | 63 | (defn base64-decode [s] 64 | (.decode (java.util.Base64/getDecoder) s)) 65 | 66 | (defn sha256 [string] 67 | (let [digest (.digest (java.security.MessageDigest/getInstance "SHA-256") (.getBytes string "UTF-8"))] 68 | (apply str (map (partial format "%02x") digest)))) 69 | 70 | (defn assoc-some [m & kvs] 71 | (->> kvs 72 | (partition 2) 73 | (filter (comp some? second)) 74 | (map vec) 75 | (into m))) 76 | 77 | (defn safe-merge [& ms] 78 | (reduce (fn [m1 m2] 79 | (let [dupes (filter #(contains? m1 %) (keys m2))] 80 | (when (not-empty dupes) 81 | (throw (ex-info (str "Maps contain duplicate keys: " (str/join ", " dupes)) 82 | {:keys dupes}))) 83 | (merge m1 m2))) 84 | {} 85 | ms)) 86 | 87 | (defn sh [& args] 88 | (let [result (apply shell/sh args)] 89 | (if (= 0 (:exit result)) 90 | (:out result) 91 | (throw (ex-info (:err result) result))))) 92 | 93 | (defn use-when [f & components] 94 | (fn [ctx] 95 | (if (f ctx) 96 | (reduce (fn [system component] 97 | (log/info "starting:" (str component)) 98 | (component system)) 99 | ctx 100 | components) 101 | ctx))) 102 | 103 | (defn anomaly? [x] 104 | (spec/valid? (spec/keys :req [:cognitect.anomalies/category] 105 | :opt [:cognitect.anomalies/message]) 106 | x)) 107 | 108 | (defn anom [category & [message & [opts]]] 109 | (merge opts 110 | {:cognitect.anomalies/category (keyword "cognitect.anomalies" (name category))} 111 | (when message 112 | {:cognitect.anomalies/message message}))) 113 | 114 | (def http-status->msg 115 | {100 "Continue" 116 | 101 "Switching Protocols" 117 | 102 "Processing" 118 | 200 "OK" 119 | 201 "Created" 120 | 202 "Accepted" 121 | 203 "Non-Authoritative Information" 122 | 204 "No Content" 123 | 205 "Reset Content" 124 | 206 "Partial Content" 125 | 207 "Multi-Status" 126 | 208 "Already Reported" 127 | 226 "IM Used" 128 | 300 "Multiple Choices" 129 | 301 "Moved Permanently" 130 | 302 "Found" 131 | 303 "See Other" 132 | 304 "Not Modified" 133 | 305 "Use Proxy" 134 | 306 "Reserved" 135 | 307 "Temporary Redirect" 136 | 308 "Permanent Redirect" 137 | 400 "Bad Request" 138 | 401 "Unauthorized" 139 | 402 "Payment Required" 140 | 403 "Forbidden" 141 | 404 "Not Found" 142 | 405 "Method Not Allowed" 143 | 406 "Not Acceptable" 144 | 407 "Proxy Authentication Required" 145 | 408 "Request Timeout" 146 | 409 "Conflict" 147 | 410 "Gone" 148 | 411 "Length Required" 149 | 412 "Precondition Failed" 150 | 413 "Request Entity Too Large" 151 | 414 "Request-URI Too Long" 152 | 415 "Unsupported Media Type" 153 | 416 "Requested Range Not Satisfiable" 154 | 417 "Expectation Failed" 155 | 422 "Unprocessable Entity" 156 | 423 "Locked" 157 | 424 "Failed Dependency" 158 | 425 "Unassigned" 159 | 426 "Upgrade Required" 160 | 427 "Unassigned" 161 | 428 "Precondition Required" 162 | 429 "Too Many Requests" 163 | 430 "Unassigned" 164 | 431 "Request Header Fields Too Large" 165 | 500 "Internal Server Error" 166 | 501 "Not Implemented" 167 | 502 "Bad Gateway" 168 | 503 "Service Unavailable" 169 | 504 "Gateway Timeout" 170 | 505 "HTTP Version Not Supported" 171 | 506 "Variant Also Negotiates (Experimental)" 172 | 507 "Insufficient Storage" 173 | 508 "Loop Detected" 174 | 509 "Unassigned" 175 | 510 "Not Extended" 176 | 511 "Network Authentication Required"}) 177 | 178 | (defn default-on-error [{:keys [status]}] 179 | {:status status 180 | :headers {"content-type" "text/html"} 181 | :body (str "

" (http-status->msg status) "

")}) 182 | 183 | (defn- wrap-deref [form syms] 184 | (walk/postwalk (fn [sym] 185 | (if (contains? syms sym) 186 | `(deref ~sym) 187 | sym)) 188 | form)) 189 | 190 | (defn letd* [bindings & body] 191 | (let [[bindings syms] (->> bindings 192 | destructure 193 | (partition 2) 194 | (reduce (fn [[bindings syms] [sym form]] 195 | [(into bindings [sym `(delay ~(wrap-deref form syms))]) 196 | (conj syms sym)]) 197 | [[] #{}]))] 198 | `(let ~bindings 199 | ~@(wrap-deref body syms)))) 200 | 201 | (defn fix-print* [& body] 202 | `(binding [*out* (alter-var-root #'*out* identity) 203 | *err* (alter-var-root #'*err* identity) 204 | *flush-on-newline* (alter-var-root #'*flush-on-newline* identity)] 205 | ~@body)) 206 | 207 | (defn delete-old-files [{:keys [dir exts age-seconds] 208 | :or {age-seconds 30}}] 209 | (doseq [file (file-seq (io/file dir)) 210 | :when (and (.isFile file) 211 | (time/elapsed? (java.util.Date. (.lastModified file)) 212 | :now 213 | age-seconds 214 | :seconds) 215 | (or (empty? exts) 216 | (some #(str/ends-with? (.getPath file) %) exts)))] 217 | (log/info "deleting" file) 218 | (io/delete-file file))) 219 | 220 | (defn join [sep xs] 221 | (rest (mapcat vector (repeat sep) xs))) 222 | 223 | (defn normalize-email [email] 224 | (some-> email str/trim str/lower-case not-empty)) 225 | 226 | (defn try-resolve [sym] 227 | (try (requiring-resolve sym) (catch FileNotFoundException _))) 228 | 229 | (defn resolve-optional [sym] 230 | (or (try-resolve sym) 231 | (fn [& args] 232 | (throw (UnsupportedOperationException. 233 | (str sym " could not be resolved. You're missing an optional dependency.")))))) 234 | 235 | ;;;; Deprecated 236 | 237 | (defn read-config [path] 238 | (let [env (keyword (or (System/getenv "BIFF_ENV") "prod")) 239 | env->config (edn/read-string (slurp path)) 240 | config-keys (concat (get-in env->config [env :merge]) [env]) 241 | config (apply merge (map env->config config-keys))] 242 | config)) 243 | -------------------------------------------------------------------------------- /src/com/biffweb/impl/util/ns.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.impl.util.ns 2 | (:require [clojure.string :as str])) 3 | 4 | (defn ns-parts [nspace] 5 | (if (empty? (str nspace)) 6 | [] 7 | (str/split (str nspace) #"\."))) 8 | 9 | (defn select-ns [m nspace] 10 | (let [parts (ns-parts nspace)] 11 | (->> (keys m) 12 | (filterv (fn [k] 13 | (= parts (take (count parts) (ns-parts (namespace k)))))) 14 | (select-keys m)))) 15 | 16 | (defn select-ns-as [m ns-from ns-to] 17 | (into {} 18 | (map (fn [[k v]] 19 | (let [new-ns-parts (->> (ns-parts (namespace k)) 20 | (drop (count (ns-parts ns-from))) 21 | (concat (ns-parts ns-to)))] 22 | [(if (empty? new-ns-parts) 23 | (keyword (name k)) 24 | (keyword (str/join "." new-ns-parts) (name k))) 25 | v]))) 26 | (select-ns m ns-from))) 27 | 28 | (comment 29 | (select-ns-as {:a 1} nil 'b.c) ; #:b.c{:a 1} 30 | (select-ns-as {:a.b/c 1} 'a 'd) ; #:d.b{:c 1} 31 | (select-ns-as {:a.b/c 1 :a.c/d 2} 'a.b nil) ; {:c 1} 32 | ) 33 | -------------------------------------------------------------------------------- /src/com/biffweb/impl/util/reload.clj: -------------------------------------------------------------------------------- 1 | ;; The code in this file has been copied from https://github.com/jakemcc/reload 2 | ;; and is licensed under the EPL version 1.0 (or any later version). 3 | (ns com.biffweb.impl.util.reload 4 | (:require [clojure.repl :as repl] 5 | [clojure.string :as str] 6 | [clojure.tools.namespace.dir :as dir] 7 | [clojure.tools.namespace.reload :as reload] 8 | clojure.tools.namespace.repl 9 | [clojure.tools.namespace.track :as track])) 10 | 11 | (defonce global-tracker (atom (track/tracker))) 12 | 13 | (def remove-disabled #'clojure.tools.namespace.repl/remove-disabled) 14 | 15 | (defn- print-pending-reloads [tracker] 16 | (when-let [r (seq (::track/load tracker))] 17 | (prn :reloading r))) 18 | 19 | (defn print-and-return [tracker] 20 | (if-let [e (::reload/error tracker)] 21 | (do (when (thread-bound? #'*e) 22 | (set! *e e)) 23 | (prn :error-while-loading (::reload/error-ns tracker)) 24 | (repl/pst e) 25 | e) 26 | :ok)) 27 | 28 | (defn refresh [tracker directories] 29 | (let [directories (filterv (set (str/split (System/getProperty "java.class.path") #":")) directories) 30 | new-tracker (dir/scan-dirs tracker directories) 31 | new-tracker (remove-disabled new-tracker)] 32 | (print-pending-reloads new-tracker) 33 | (let [new-tracker (reload/track-reload (assoc new-tracker ::track/unload []))] 34 | (print-and-return new-tracker) 35 | new-tracker))) 36 | -------------------------------------------------------------------------------- /src/com/biffweb/impl/util/s3.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.impl.util.s3 2 | (:require [com.biffweb.impl.util :as bu] 3 | [buddy.core.mac :as mac] 4 | [clj-http.client :as http] 5 | [clojure.string :as str])) 6 | 7 | (defn hmac-sha1-base64 [secret s] 8 | (-> (mac/hash s {:key secret :alg :hmac+sha1}) 9 | bu/base64-encode)) 10 | 11 | (defn md5-base64 [body] 12 | (with-open [f (cond 13 | (string? body) (java.io.ByteArrayInputStream. (.getBytes body)) 14 | :else (java.io.FileInputStream. body))] 15 | (let [buffer (byte-array 1024) 16 | md (java.security.MessageDigest/getInstance "MD5")] 17 | (loop [nread (.read f buffer)] 18 | (if (pos? nread) 19 | (do 20 | (.update md buffer 0 nread) 21 | (recur (.read f buffer))) 22 | (bu/base64-encode (.digest md))))))) 23 | 24 | (defn body->bytes [body] 25 | (cond 26 | (string? body) (.getBytes body) 27 | :else (let [out (byte-array (.length body))] 28 | (with-open [in (java.io.FileInputStream. body)] 29 | (.read in out) 30 | out)))) 31 | 32 | (defn s3-request [{:keys [biff/secret] 33 | :biff.s3/keys [origin 34 | access-key 35 | bucket 36 | key 37 | method 38 | headers 39 | body]}] 40 | ;; See https://docs.aws.amazon.com/AmazonS3/latest/userguide/RESTAuthentication.html 41 | (let [date (.format (doto (new java.text.SimpleDateFormat "EEE, dd MMM yyyy HH:mm:ss Z") 42 | (.setTimeZone (java.util.TimeZone/getTimeZone "UTC"))) 43 | (java.util.Date.)) 44 | path (str "/" bucket "/" key) 45 | md5 (some-> body md5-base64) 46 | headers' (->> headers 47 | (mapv (fn [[k v]] 48 | [(str/trim (str/lower-case k)) (str/trim v)])) 49 | (into {})) 50 | content-type (get headers' "content-type") 51 | headers' (->> headers' 52 | (filterv (fn [[k v]] 53 | (str/starts-with? k "x-amz-"))) 54 | (sort-by first) 55 | (mapv (fn [[k v]] 56 | (str k ":" v "\n"))) 57 | (apply str)) 58 | string-to-sign (str method "\n" md5 "\n" content-type "\n" date "\n" headers' path) 59 | signature (hmac-sha1-base64 (secret :biff.s3/secret-key) string-to-sign) 60 | auth (str "AWS " access-key ":" signature) 61 | s3-opts {:method method 62 | :url (str origin path) 63 | :headers (merge {"Authorization" auth 64 | "Date" date 65 | "Content-MD5" md5} 66 | headers) 67 | :body (some-> body body->bytes)}] 68 | (http/request s3-opts))) 69 | -------------------------------------------------------------------------------- /src/com/biffweb/impl/xtdb2.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.impl.xtdb2 2 | (:require 3 | [clojure.string :as str] 4 | [com.biffweb.impl.util :as util] 5 | [com.biffweb.aliases.xtdb2 :as xta] 6 | [malli.core :as malli] 7 | [malli.error :as malli.e] 8 | [malli.util :as malli.u]) 9 | (:import 10 | [java.util UUID] 11 | [java.util.concurrent LinkedBlockingQueue TimeUnit])) 12 | 13 | (def have-dep (some? (util/try-resolve 'xtdb.api/execute-tx))) 14 | 15 | (defn- get-conn [node] 16 | (.build (.createConnectionBuilder node))) 17 | 18 | (defmacro ensure-dep [& body] 19 | (if-not have-dep 20 | `(throw (UnsupportedOperationException. 21 | "To call this function, you must add com.xtdb/xtdb-core v2 to your dependencies.")) 22 | `(do ~@body))) 23 | 24 | (defn where-clause [ks] 25 | (ensure-dep 26 | (->> ks 27 | (mapv #(str (xta/->normal-form-str %) " = ?")) 28 | (str/join " and ")))) 29 | 30 | (defn assert-unique [table kvs] 31 | (into [(str "assert 1 >= (select count(*) from " table " where " 32 | (where-clause (keys kvs)))] 33 | (vals kvs))) 34 | 35 | (defn select-from-where [columns table kvs] 36 | (into [(str "select " (str/join ", " (mapv xta/->normal-form-str columns)) 37 | " from " table 38 | " where " (where-clause (keys kvs)))] 39 | (vals kvs))) 40 | 41 | (defn use-xtdb2-config [{:keys [biff/secret] 42 | :biff.xtdb2/keys [storage log] 43 | :biff.xtdb2.storage/keys [bucket endpoint access-key secret-key] 44 | :or {storage :local log :local}}] 45 | (let [secret-key (secret :biff.xtdb2.storage/secret-key)] 46 | {:log [log 47 | (case log 48 | :local {:path "storage/xtdb2/log"} 49 | :kafka {:bootstrap-servers "localhost:9092" 50 | :topic "xtdb-log" 51 | ;; The default prod config for Biff apps uses remote storage and 52 | ;; local log, so if kafka is being used, it'll probably be in the 53 | ;; context of migrating from a local log. So might as well bump this 54 | ;; pre-emptively. 55 | :epoch 1})] 56 | :storage [storage 57 | (case storage 58 | :local {:path "storage/xtdb2/storage"} 59 | :remote {:object-store [:s3 60 | {:bucket bucket 61 | :endpoint endpoint 62 | :credentials {:access-key access-key 63 | :secret-key secret-key}}] 64 | :local-disk-cache "storage/xtdb2/storage-cache"})]})) 65 | 66 | (defn all-system-times 67 | ([node] 68 | (all-system-times node #xt/instant "1970-01-01T00:00:00Z")) 69 | ([node after-inst] 70 | (lazy-seq 71 | (let [results (into [] 72 | (map :system-time) 73 | (xta/plan-q node 74 | ["select system_time from xt.txs 75 | where committed = true 76 | and system_time > ? 77 | order by system_time asc limit 1000" 78 | after-inst]))] 79 | (concat results 80 | (some->> (peek results) 81 | (all-system-times node))))))) 82 | 83 | (defn all-tables [node] 84 | (->> (xta/q node (str "SELECT table_schema, table_name " 85 | "FROM information_schema.tables " 86 | "WHERE table_type = 'BASE TABLE' AND " 87 | "table_schema NOT IN ('pg_catalog', 'information_schema');")) 88 | (filterv #(= "public" (:table-schema %))) 89 | (mapv :table-name))) 90 | 91 | (defn tx-log [node & {:keys [tables after-inst]}] 92 | (let [after-inst (or after-inst #xt/instant "1970-01-01T00:00:00Z") 93 | tables (or tables (all-tables node))] 94 | (->> (all-system-times node after-inst) 95 | (partition-all 1000) 96 | (mapcat (fn [system-times] 97 | (let [start (first system-times) 98 | end (last system-times)] 99 | (->> tables 100 | (pmap (fn [table] 101 | (mapv #(assoc % :biff.xtdb/table table) 102 | (xta/q node [(str "select *, _system_from, _system_to, _valid_from, _valid_to " 103 | "from " table " for all system_time " 104 | "where _system_from >= ? " 105 | "and _system_from <= ? " 106 | "order by _system_from") 107 | start 108 | end])))) 109 | (apply concat) 110 | (sort-by :xt/system-from)))))))) 111 | 112 | (defn latest-system-time [node] 113 | (get-in (xta/q node "select max(system_time) from xt.txs where committed = true") 114 | [0 :xt/column-1])) 115 | 116 | (defn use-xtdb2-listener [{:keys [biff/node biff/modules biff.xtdb.listener/tables] :as ctx}] 117 | (let [continue (atom true) 118 | done (promise) 119 | ;; Wait for system time to settle 120 | system-time (atom (loop [old-t nil 121 | new-t (latest-system-time node)] 122 | (if (= old-t new-t) 123 | new-t 124 | (do 125 | (Thread/sleep 1000) 126 | (recur new-t (latest-system-time node)))))) 127 | stop-fn (fn [] 128 | (reset! continue false) 129 | (deref done 10000 nil)) 130 | queue (LinkedBlockingQueue. 1) 131 | poll-now #(.offer queue true)] 132 | (future 133 | (do 134 | (util/catchall-verbose 135 | (while @continue 136 | (.poll queue 1 TimeUnit/SECONDS) 137 | (let [listeners (not-empty (keep :on-tx @modules)) 138 | prev-t @system-time 139 | latest-t (when listeners 140 | (latest-system-time node))] 141 | (when (and listeners (not= prev-t latest-t)) 142 | (reset! system-time latest-t) 143 | (doseq [record (tx-log node {:after-inst prev-t :tables tables}) 144 | listener listeners] 145 | (util/catchall-verbose (listener ctx record))))))) 146 | (deliver done nil))) 147 | (-> ctx 148 | (assoc :biff.xtdb.listener/poll-now poll-now) 149 | (update :biff/stop conj stop-fn)))) 150 | 151 | (defn use-xtdb2 [ctx] 152 | (ensure-dep 153 | (let [node (xta/start-node (use-xtdb2-config ctx))] 154 | (-> ctx 155 | (assoc :biff/node node) 156 | (update :biff/stop conj #(.close node)))))) 157 | 158 | (defn prefix-uuid [uuid-prefix uuid-rest] 159 | (UUID/fromString (str (subs (str uuid-prefix) 0 4) 160 | (subs (str uuid-rest) 4)))) 161 | 162 | (defn validate-tx [tx malli-opts] 163 | (doseq [tx-op tx 164 | :when (#{:put-docs :patch-docs} (first tx-op)) 165 | :let [[op opts & records] tx-op 166 | table (if (keyword? opts) 167 | opts 168 | (:into opts)) 169 | schema (cond-> (malli/schema table malli-opts) 170 | (= op :patch-docs) malli.u/optional-keys)]] 171 | (doseq [record records] 172 | (when-not (some? (:xt/id record)) 173 | (throw (ex-info "Record is missing an :xt/id value." 174 | {:table table 175 | :record record}))) 176 | (when-not (malli/validate schema record malli-opts) 177 | (throw (ex-info "Record doesn't match schema." 178 | {:table table 179 | :record record 180 | :explain (malli.e/humanize (malli/explain schema record))}))))) 181 | true) 182 | 183 | (defn submit-tx [{:keys [biff/node biff.xtdb.listener/poll-now biff/malli-opts]} tx & [opts]] 184 | (validate-tx tx @malli-opts) 185 | (let [result (xta/submit-tx node tx opts)] 186 | (when poll-now (poll-now)) 187 | result)) 188 | -------------------------------------------------------------------------------- /starter/.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | .cpcache 4 | -------------------------------------------------------------------------------- /starter/.gitignore: -------------------------------------------------------------------------------- 1 | /.cpcache 2 | /.nrepl-port 3 | /bin 4 | /config.edn 5 | /config.sh 6 | /config.env 7 | /node_modules 8 | /secrets.env 9 | /storage/ 10 | /tailwindcss 11 | /target 12 | .calva/ 13 | .clj-kondo/ 14 | .lsp/ 15 | .portal/ 16 | .shadow-cljs/ 17 | -------------------------------------------------------------------------------- /starter/Dockerfile: -------------------------------------------------------------------------------- 1 | # The default deploy instructions (https://biffweb.com/docs/reference/production/) don't 2 | # use Docker, but this file is provided in case you'd like to deploy with containers. 3 | # 4 | # When running the container, make sure you set any environment variables defined in config.env, 5 | # e.g. using whatever tools your deployment platform provides for setting environment variables. 6 | # 7 | # Run these commands to test this file locally: 8 | # 9 | # docker build -t your-app . 10 | # docker run --rm -e BIFF_PROFILE=dev -v $PWD/config.env:/app/config.env your-app 11 | 12 | # This is the base builder image, construct the jar file in this one 13 | # it uses alpine for a small image 14 | FROM clojure:temurin-21-tools-deps-alpine AS jre-build 15 | 16 | ENV TAILWIND_VERSION=v3.2.4 17 | 18 | # Install the missing packages and applications in a single layer 19 | RUN apk add curl rlwrap && curl -L -o /usr/local/bin/tailwindcss \ 20 | https://github.com/tailwindlabs/tailwindcss/releases/download/$TAILWIND_VERSION/tailwindcss-linux-x64 \ 21 | && chmod +x /usr/local/bin/tailwindcss 22 | 23 | WORKDIR /app 24 | COPY src ./src 25 | COPY dev ./dev 26 | COPY resources ./resources 27 | COPY deps.edn . 28 | 29 | # construct the application jar 30 | RUN clj -M:dev uberjar && cp target/jar/app.jar . && rm -r target 31 | 32 | # This stage (see multi-stage builds) is a bare Java container 33 | # copy over the uberjar from the builder image and run the application 34 | FROM eclipse-temurin:21-alpine 35 | WORKDIR /app 36 | 37 | # Take the uberjar from the base image and put it in the final image 38 | COPY --from=jre-build /app/app.jar /app/app.jar 39 | 40 | EXPOSE 8080 41 | 42 | # By default, run in PROD profile 43 | ENV BIFF_PROFILE=prod 44 | ENV HOST=0.0.0.0 45 | ENV PORT=8080 46 | CMD ["/opt/java/openjdk/bin/java", "-XX:-OmitStackTraceInFastThrow", "-XX:+CrashOnOutOfMemoryError", "-jar", "app.jar"] 47 | -------------------------------------------------------------------------------- /starter/README.md: -------------------------------------------------------------------------------- 1 | # Biff starter project 2 | 3 | This is the starter project for Biff. 4 | 5 | Run `clj -M:dev dev` to get started. See `clj -M:dev --help` for other commands. 6 | 7 | Consider adding `alias biff='clj -M:dev'` to your `.bashrc`. 8 | -------------------------------------------------------------------------------- /starter/cljfmt-indents.edn: -------------------------------------------------------------------------------- 1 | {submit-tx [[:inner 0]]} 2 | -------------------------------------------------------------------------------- /starter/deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources" "target/resources"] 2 | :deps {com.biffweb/biff {:local/root ".."} 3 | cheshire/cheshire {:mvn/version "6.1.0"} 4 | 5 | ;; Notes on logging: https://gist.github.com/jacobobryant/76b7a08a07d5ef2cc076b048d078f1f3 6 | org.slf4j/slf4j-simple {:mvn/version "2.0.0-alpha5"} 7 | org.slf4j/log4j-over-slf4j {:mvn/version "1.7.36"} 8 | org.slf4j/jul-to-slf4j {:mvn/version "1.7.36"} 9 | org.slf4j/jcl-over-slf4j {:mvn/version "1.7.36"}} 10 | :aliases 11 | {:dev {:extra-deps {com.biffweb/tasks {:local/root "../libs/tasks"}} 12 | :extra-paths ["dev" "test"] 13 | :jvm-opts ["-XX:-OmitStackTraceInFastThrow" 14 | "-XX:+CrashOnOutOfMemoryError" 15 | "-Dbiff.env.BIFF_PROFILE=dev"] 16 | :main-opts ["-m" "com.biffweb.task-runner" "tasks/tasks"]} 17 | :prod {:jvm-opts ["-XX:-OmitStackTraceInFastThrow" 18 | "-XX:+CrashOnOutOfMemoryError" 19 | "-Dbiff.env.BIFF_PROFILE=prod"] 20 | :main-opts ["-m" "com.example"]}}} 21 | -------------------------------------------------------------------------------- /starter/dev/repl.clj: -------------------------------------------------------------------------------- 1 | (ns repl 2 | (:require [com.example :as main] 3 | [com.biffweb :as biff :refer [q]] 4 | [clojure.edn :as edn] 5 | [clojure.java.io :as io])) 6 | 7 | ;; REPL-driven development 8 | ;; ---------------------------------------------------------------------------------------- 9 | ;; If you're new to REPL-driven development, Biff makes it easy to get started: whenever 10 | ;; you save a file, your changes will be evaluated. Biff is structured so that in most 11 | ;; cases, that's all you'll need to do for your changes to take effect. (See main/refresh 12 | ;; below for more details.) 13 | ;; 14 | ;; The `clj -M:dev dev` command also starts an nREPL server on port 7888, so if you're 15 | ;; already familiar with REPL-driven development, you can connect to that with your editor. 16 | ;; 17 | ;; If you're used to jacking in with your editor first and then starting your app via the 18 | ;; REPL, you will need to instead connect your editor to the nREPL server that `clj -M:dev 19 | ;; dev` starts. e.g. if you use emacs, instead of running `cider-jack-in`, you would run 20 | ;; `cider-connect`. See "Connecting to a Running nREPL Server:" 21 | ;; https://docs.cider.mx/cider/basics/up_and_running.html#connect-to-a-running-nrepl-server 22 | ;; ---------------------------------------------------------------------------------------- 23 | 24 | ;; This function should only be used from the REPL. Regular application code 25 | ;; should receive the system map from the parent Biff component. For example, 26 | ;; the use-jetty component merges the system map into incoming Ring requests. 27 | (defn get-context [] 28 | (biff/merge-context @main/system)) 29 | 30 | (defn add-fixtures [] 31 | (biff/submit-tx (get-context) 32 | (-> (io/resource "fixtures.edn") 33 | slurp 34 | edn/read-string))) 35 | 36 | (defn check-config [] 37 | (let [prod-config (biff/use-aero-config {:biff.config/profile "prod"}) 38 | dev-config (biff/use-aero-config {:biff.config/profile "dev"}) 39 | ;; Add keys for any other secrets you've added to resources/config.edn 40 | secret-keys [:biff.middleware/cookie-secret 41 | :biff/jwt-secret 42 | :mailersend/api-key 43 | :recaptcha/secret-key 44 | ; ... 45 | ] 46 | get-secrets (fn [{:keys [biff/secret] :as config}] 47 | (into {} 48 | (map (fn [k] 49 | [k (secret k)])) 50 | secret-keys))] 51 | {:prod-config prod-config 52 | :dev-config dev-config 53 | :prod-secrets (get-secrets prod-config) 54 | :dev-secrets (get-secrets dev-config)})) 55 | 56 | (comment 57 | ;; Call this function if you make a change to main/initial-system, 58 | ;; main/components, :tasks, :queues, config.env, or deps.edn. 59 | (main/refresh) 60 | 61 | ;; Call this in dev if you'd like to add some seed data to your database. If 62 | ;; you edit the seed data (in resources/fixtures.edn), you can reset the 63 | ;; database by running `rm -r storage/xtdb` (DON'T run that in prod), 64 | ;; restarting your app, and calling add-fixtures again. 65 | (add-fixtures) 66 | 67 | ;; Query the database 68 | (let [{:keys [biff/db] :as ctx} (get-context)] 69 | (q db 70 | '{:find (pull user [*]) 71 | :where [[user :user/email]]})) 72 | 73 | ;; Update an existing user's email address 74 | (let [{:keys [biff/db] :as ctx} (get-context) 75 | user-id (biff/lookup-id db :user/email "hello@example.com")] 76 | (biff/submit-tx ctx 77 | [{:db/doc-type :user 78 | :xt/id user-id 79 | :db/op :update 80 | :user/email "new.address@example.com"}])) 81 | 82 | (sort (keys (get-context))) 83 | 84 | ;; Check the terminal for output. 85 | (biff/submit-job (get-context) :echo {:foo "bar"}) 86 | (deref (biff/submit-job-for-result (get-context) :echo {:foo "bar"}))) 87 | -------------------------------------------------------------------------------- /starter/dev/tasks.clj: -------------------------------------------------------------------------------- 1 | (ns tasks 2 | (:require [com.biffweb.tasks :as tasks])) 3 | 4 | (defn hello 5 | "Says 'Hello'" 6 | [] 7 | (println "Hello")) 8 | 9 | ;; Tasks should be vars (#'hello instead of hello) so that `clj -M:dev help` can 10 | ;; print their docstrings. 11 | (def custom-tasks 12 | {"hello" #'hello}) 13 | 14 | (def tasks (merge tasks/tasks custom-tasks)) 15 | -------------------------------------------------------------------------------- /starter/resources/config.edn: -------------------------------------------------------------------------------- 1 | ;; See https://github.com/juxt/aero and https://biffweb.com/docs/api/utilities/#use-aero-config. 2 | ;; #biff/env and #biff/secret will load values from the environment and from config.env. 3 | {:biff/base-url #profile {:prod #join ["https://" #biff/env DOMAIN] 4 | :default #join ["http://localhost:" #ref [:biff/port]]} 5 | :biff/host #or [#biff/env "HOST" 6 | #profile {:dev "0.0.0.0" 7 | :default "localhost"}] 8 | :biff/port #long #or [#biff/env "PORT" 8080] 9 | 10 | :biff.xtdb/dir "storage/xtdb" 11 | :biff.xtdb/topology #keyword #or [#profile {:prod #biff/env "PROD_XTDB_TOPOLOGY" 12 | :default #biff/env "XTDB_TOPOLOGY"} 13 | "standalone"] 14 | :biff.xtdb.jdbc/jdbcUrl #biff/secret "XTDB_JDBC_URL" 15 | 16 | :biff.beholder/enabled #profile {:dev true :default false} 17 | :biff.beholder/paths ["src" "resources" "test"] 18 | :biff.add-libs/aliases #profile {:dev [:dev] :prod [:prod]} 19 | :biff/eval-paths ["src" "test"] 20 | :biff.middleware/secure #profile {:dev false :default true} 21 | :biff.middleware/cookie-secret #biff/secret COOKIE_SECRET 22 | :biff/jwt-secret #biff/secret JWT_SECRET 23 | :biff.refresh/enabled #profile {:dev true :default false} 24 | 25 | :mailersend/api-key #biff/secret MAILERSEND_API_KEY 26 | :mailersend/from #biff/env MAILERSEND_FROM 27 | :mailersend/reply-to #biff/env MAILERSEND_REPLY_TO 28 | 29 | :recaptcha/secret-key #biff/secret RECAPTCHA_SECRET_KEY 30 | :recaptcha/site-key #biff/env RECAPTCHA_SITE_KEY 31 | 32 | :biff.nrepl/port #or [#biff/env NREPL_PORT "7888"] 33 | :biff.nrepl/args ["--port" #ref [:biff.nrepl/port] 34 | "--middleware" "[cider.nrepl/cider-middleware,refactor-nrepl.middleware/wrap-refactor]"] 35 | 36 | :biff.system-properties/user.timezone "UTC" 37 | :biff.system-properties/clojure.tools.logging.factory "clojure.tools.logging.impl/slf4j-factory" 38 | 39 | :biff.tasks/server #biff/env DOMAIN 40 | :biff.tasks/main-ns com.example 41 | :biff.tasks/on-soft-deploy "\"(com.example/on-save @com.example/system)\"" 42 | :biff.tasks/generate-assets-fn com.example/generate-assets! 43 | :biff.tasks/css-output "target/resources/public/css/main.css" 44 | :biff.tasks/deploy-untracked-files [#ref [:biff.tasks/css-output] 45 | "config.env"] 46 | 47 | ;; `clj -M:dev prod-dev` will run the soft-deploy task whenever files in these directories are changed. 48 | :biff.tasks/watch-dirs ["src" "dev" "resources" "test"] 49 | 50 | ;; The version of the Taliwind standalone bin to install. See `clj -M:dev css -h`. If you change 51 | ;; this, run `rm bin/tailwindcss; clj -M:dev install-tailwind`. 52 | :biff.tasks/tailwind-version "v3.4.17" 53 | 54 | ;; :rsync is the default if rsync is on the path; otherwise :git is the default. Set this to :git 55 | ;; if you have rsync on the path but still want to deploy with git. 56 | ;; :biff.tasks/deploy-with :rsync 57 | 58 | ;; Uncomment this line if you're deploying with git and your local branch is called main instead of 59 | ;; master: 60 | ;; :biff.tasks/git-deploy-cmd ["git" "push" "prod" "main:master"] 61 | :biff.tasks/git-deploy-cmd ["git" "push" "prod" "master"] 62 | 63 | ;; Uncomment this line if you have any ssh-related problems: 64 | ;; :biff.tasks/skip-ssh-agent true 65 | } 66 | -------------------------------------------------------------------------------- /starter/resources/config.template.env: -------------------------------------------------------------------------------- 1 | # This file contains config that is not checked into git. See resources/config.edn for more config 2 | # options. 3 | 4 | # Where will your app be deployed? 5 | DOMAIN=example.com 6 | 7 | # Mailersend is used to send email sign-in links. Sign up at https://www.mailersend.com/ 8 | MAILERSEND_API_KEY= 9 | # This must be an email address that uses the same domain that you've verified in MailerSend. 10 | MAILERSEND_FROM= 11 | # This is where emails will be sent when users hit reply. It can be any email address. 12 | MAILERSEND_REPLY_TO= 13 | 14 | # Recaptcha is used to protect your sign-in page from bots. Go to 15 | # https://www.google.com/recaptcha/about/ and add a site. Select v2 invisible. Add localhost and the 16 | # value of DOMAIN above to your list of allowed domains. 17 | RECAPTCHA_SITE_KEY= 18 | RECAPTCHA_SECRET_KEY= 19 | 20 | XTDB_TOPOLOGY=standalone 21 | # Uncomment these to use Postgres for storage in production: 22 | #PROD_XTDB_TOPOLOGY=jdbc 23 | #XTDB_JDBC_URL=jdbc:postgresql://host:port/dbname?user=alice&password=abc123&sslmode=require 24 | 25 | # What port should the nrepl server be started on (in dev and prod)? 26 | NREPL_PORT=7888 27 | 28 | 29 | ## Autogenerated. Create new secrets with `clj -M:dev generate-secrets` 30 | 31 | # Used to encrypt session cookies. 32 | COOKIE_SECRET={{ new-secret 16 }} 33 | # Used to encrypt email sign-in links. 34 | JWT_SECRET={{ new-secret 32 }} 35 | -------------------------------------------------------------------------------- /starter/resources/fixtures.edn: -------------------------------------------------------------------------------- 1 | ;; Biff transaction. See https://biffweb.com/docs/reference/transactions/ 2 | [{:db/doc-type :user 3 | :xt/id :db.id/user-a 4 | :user/email "a@example.com" 5 | :user/foo "Some Value" 6 | :user/joined-at :db/now} 7 | {:db/doc-type :msg 8 | :msg/user :db.id/user-a 9 | :msg/text "hello there" 10 | :msg/sent-at :db/now}] 11 | -------------------------------------------------------------------------------- /starter/resources/public/img/glider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobobryant/biff/a075fae22993451aab1d3f83af7ac793f5dc67a5/starter/resources/public/img/glider.png -------------------------------------------------------------------------------- /starter/resources/public/js/main.js: -------------------------------------------------------------------------------- 1 | // When plain htmx isn't quite enough, you can stick some custom JS here. 2 | -------------------------------------------------------------------------------- /starter/resources/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './src/**/*', 4 | './resources/**/*', 5 | ], 6 | theme: { 7 | extend: {}, 8 | }, 9 | plugins: [ 10 | require('@tailwindcss/forms'), 11 | ], 12 | } 13 | -------------------------------------------------------------------------------- /starter/resources/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | p { 7 | @apply mb-3; 8 | } 9 | 10 | ul { 11 | @apply list-disc; 12 | } 13 | 14 | ol { 15 | @apply list-decimal; 16 | } 17 | 18 | ul, ol { 19 | @apply my-3 pl-10; 20 | } 21 | } 22 | 23 | @layer components { 24 | .btn { 25 | @apply bg-blue-500 hover:bg-blue-700 text-center py-2 px-4 rounded disabled:opacity-50 text-white; 26 | } 27 | } 28 | 29 | @layer utilities { 30 | .link { 31 | @apply text-blue-600 hover:underline; 32 | } 33 | } 34 | 35 | .grecaptcha-badge { visibility: hidden; } 36 | -------------------------------------------------------------------------------- /starter/server-setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -x 3 | set -e 4 | 5 | BIFF_PROFILE=${1:-prod} 6 | CLJ_VERSION=1.12.2.1565 7 | TRENCH_VERSION=0.4.0 8 | if [ $(uname -m) = "aarch64" ]; then 9 | ARCH=arm64 10 | else 11 | ARCH=amd64 12 | fi 13 | TRENCH_FILE=trenchman_${TRENCH_VERSION}_linux_${ARCH}.tar.gz 14 | 15 | echo waiting for apt to finish 16 | while (ps aux | grep [a]pt); do 17 | sleep 3 18 | done 19 | 20 | # Dependencies 21 | apt-get update 22 | apt-get upgrade 23 | apt-get -y install default-jre rlwrap ufw git snapd 24 | bash < <(curl -s https://download.clojure.org/install/linux-install-$CLJ_VERSION.sh) 25 | bash < <(curl -s https://raw.githubusercontent.com/babashka/babashka/master/install) 26 | curl -sSLf https://github.com/athos/trenchman/releases/download/v$TRENCH_VERSION/$TRENCH_FILE | tar zxvfC - /usr/local/bin trench 27 | 28 | # Non-root user 29 | useradd -m app 30 | mkdir -m 700 -p /home/app/.ssh 31 | cp /root/.ssh/authorized_keys /home/app/.ssh 32 | chown -R app:app /home/app/.ssh 33 | 34 | # Git deploys - only used if you don't have rsync on your machine 35 | set_up_app () { 36 | cd 37 | mkdir repo.git 38 | cd repo.git 39 | git init --bare 40 | cat > hooks/post-receive << EOD 41 | #!/usr/bin/env bash 42 | git --work-tree=/home/app --git-dir=/home/app/repo.git checkout -f 43 | EOD 44 | chmod +x hooks/post-receive 45 | } 46 | sudo -u app bash -c "$(declare -f set_up_app); set_up_app" 47 | 48 | # Systemd service 49 | cat > /etc/systemd/system/app.service << EOD 50 | [Unit] 51 | Description=app 52 | StartLimitIntervalSec=500 53 | StartLimitBurst=5 54 | 55 | [Service] 56 | User=app 57 | Restart=on-failure 58 | RestartSec=5s 59 | Environment="BIFF_PROFILE=$BIFF_PROFILE" 60 | WorkingDirectory=/home/app 61 | ExecStart=/bin/sh -c "mkdir -p target/resources; clj -M:prod" 62 | 63 | [Install] 64 | WantedBy=multi-user.target 65 | EOD 66 | systemctl enable app 67 | cat > /etc/systemd/journald.conf << EOD 68 | [Journal] 69 | Storage=persistent 70 | EOD 71 | systemctl restart systemd-journald 72 | cat > /etc/sudoers.d/restart-app << EOD 73 | app ALL= NOPASSWD: /bin/systemctl reset-failed app.service 74 | app ALL= NOPASSWD: /bin/systemctl restart app 75 | app ALL= NOPASSWD: /usr/bin/systemctl reset-failed app.service 76 | app ALL= NOPASSWD: /usr/bin/systemctl restart app 77 | EOD 78 | chmod 440 /etc/sudoers.d/restart-app 79 | 80 | # Firewall 81 | ufw allow OpenSSH 82 | ufw --force enable 83 | 84 | # Web dependencies 85 | apt-get -y install nginx 86 | snap install core 87 | snap refresh core 88 | snap install --classic certbot 89 | ln -s /snap/bin/certbot /usr/bin/certbot 90 | 91 | # Nginx 92 | rm /etc/nginx/sites-enabled/default 93 | cat > /etc/nginx/sites-available/app << EOD 94 | server { 95 | listen 80 default_server; 96 | listen [::]:80 default_server; 97 | server_name _; 98 | gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; 99 | root /home/app/target/resources/public; 100 | location / { 101 | try_files \$uri \$uri/index.html @resources; 102 | } 103 | location @resources { 104 | root /home/app/resources/public; 105 | try_files \$uri \$uri/index.html @proxy; 106 | } 107 | location @proxy { 108 | proxy_pass http://localhost:8080; 109 | proxy_http_version 1.1; 110 | proxy_set_header Host \$host; 111 | proxy_set_header Upgrade \$http_upgrade; 112 | proxy_set_header Connection "Upgrade"; 113 | proxy_set_header X-Real-IP \$remote_addr; 114 | } 115 | } 116 | EOD 117 | ln -s /etc/nginx/sites-{available,enabled}/app 118 | 119 | # Firewall 120 | ufw allow "Nginx Full" 121 | 122 | # Let's encrypt 123 | certbot --nginx 124 | 125 | # App dependencies 126 | # If you need to install additional packages for your app, you can do it here. 127 | # apt-get -y install ... 128 | -------------------------------------------------------------------------------- /starter/src/com/example.clj: -------------------------------------------------------------------------------- 1 | (ns com.example 2 | (:require [com.biffweb :as biff] 3 | [com.example.email :as email] 4 | [com.example.app :as app] 5 | [com.example.home :as home] 6 | [com.example.middleware :as mid] 7 | [com.example.ui :as ui] 8 | [com.example.worker :as worker] 9 | [com.example.schema :as schema] 10 | [clojure.test :as test] 11 | [clojure.tools.logging :as log] 12 | [clojure.tools.namespace.repl :as tn-repl] 13 | [malli.core :as malc] 14 | [malli.registry :as malr] 15 | [nrepl.cmdline :as nrepl-cmd]) 16 | (:gen-class)) 17 | 18 | (def modules 19 | [app/module 20 | (biff/authentication-module {}) 21 | home/module 22 | schema/module 23 | worker/module]) 24 | 25 | (def routes [["" {:middleware [mid/wrap-site-defaults]} 26 | (keep :routes modules)] 27 | ["" {:middleware [mid/wrap-api-defaults]} 28 | (keep :api-routes modules)]]) 29 | 30 | (def handler (-> (biff/reitit-handler {:routes routes}) 31 | mid/wrap-base-defaults)) 32 | 33 | (def static-pages (apply biff/safe-merge (map :static modules))) 34 | 35 | (defn generate-assets! [ctx] 36 | (biff/export-rum static-pages "target/resources/public") 37 | (biff/delete-old-files {:dir "target/resources/public" 38 | :exts [".html"]})) 39 | 40 | (defn on-save [ctx] 41 | (biff/add-libs ctx) 42 | (biff/eval-files! ctx) 43 | (generate-assets! ctx) 44 | (test/run-all-tests #"com.example.*-test")) 45 | 46 | (def malli-opts 47 | {:registry (malr/composite-registry 48 | malc/default-registry 49 | (apply biff/safe-merge (keep :schema modules)))}) 50 | 51 | (def initial-system 52 | {:biff/modules #'modules 53 | :biff/send-email #'email/send-email 54 | :biff/handler #'handler 55 | :biff/malli-opts #'malli-opts 56 | :biff.beholder/on-save #'on-save 57 | :biff.middleware/on-error #'ui/on-error 58 | :biff.xtdb/tx-fns biff/tx-fns 59 | :com.example/chat-clients (atom #{})}) 60 | 61 | (defonce system (atom {})) 62 | 63 | (def components 64 | [biff/use-aero-config 65 | biff/use-xtdb 66 | biff/use-queues 67 | biff/use-xtdb-tx-listener 68 | biff/use-htmx-refresh 69 | biff/use-jetty 70 | biff/use-chime 71 | biff/use-beholder]) 72 | 73 | (defn start [] 74 | (let [new-system (reduce (fn [system component] 75 | (log/info "starting:" (str component)) 76 | (component system)) 77 | initial-system 78 | components)] 79 | (reset! system new-system) 80 | (generate-assets! new-system) 81 | (log/info "System started.") 82 | (log/info "Go to" (:biff/base-url new-system)) 83 | new-system)) 84 | 85 | (defn -main [] 86 | (let [{:keys [biff.nrepl/args]} (start)] 87 | (apply nrepl-cmd/-main args))) 88 | 89 | (defn refresh [] 90 | (doseq [f (:biff/stop @system)] 91 | (log/info "stopping:" (str f)) 92 | (f)) 93 | (tn-repl/refresh :after `start) 94 | :done) 95 | -------------------------------------------------------------------------------- /starter/src/com/example/app.clj: -------------------------------------------------------------------------------- 1 | (ns com.example.app 2 | (:require [com.biffweb :as biff :refer [q]] 3 | [com.example.middleware :as mid] 4 | [com.example.ui :as ui] 5 | [com.example.settings :as settings] 6 | [rum.core :as rum] 7 | [xtdb.api :as xt] 8 | [ring.websocket :as ws] 9 | [cheshire.core :as cheshire])) 10 | 11 | (defn set-foo [{:keys [session params] :as ctx}] 12 | (biff/submit-tx ctx 13 | [{:db/op :update 14 | :db/doc-type :user 15 | :xt/id (:uid session) 16 | :user/foo (:foo params)}]) 17 | {:status 303 18 | :headers {"location" "/app"}}) 19 | 20 | (defn bar-form [{:keys [value]}] 21 | (biff/form 22 | {:hx-post "/app/set-bar" 23 | :hx-swap "outerHTML"} 24 | [:label.block {:for "bar"} "Bar: " 25 | [:span.font-mono (pr-str value)]] 26 | [:.h-1] 27 | [:.flex 28 | [:input.w-full#bar {:type "text" :name "bar" :value value}] 29 | [:.w-3] 30 | [:button.btn {:type "submit"} "Update"]] 31 | [:.h-1] 32 | [:.text-sm.text-gray-600 33 | "This demonstrates updating a value with HTMX."])) 34 | 35 | (defn set-bar [{:keys [session params] :as ctx}] 36 | (biff/submit-tx ctx 37 | [{:db/op :update 38 | :db/doc-type :user 39 | :xt/id (:uid session) 40 | :user/bar (:bar params)}]) 41 | (biff/render (bar-form {:value (:bar params)}))) 42 | 43 | (defn message [{:msg/keys [text sent-at]}] 44 | [:.mt-3 {:_ "init send newMessage to #message-header"} 45 | [:.text-gray-600 (biff/format-date sent-at "dd MMM yyyy HH:mm:ss")] 46 | [:div text]]) 47 | 48 | (defn notify-clients [{:keys [com.example/chat-clients]} tx] 49 | (doseq [[op & args] (::xt/tx-ops tx) 50 | :when (= op ::xt/put) 51 | :let [[doc] args] 52 | :when (contains? doc :msg/text) 53 | :let [html (rum/render-static-markup 54 | [:div#messages {:hx-swap-oob "afterbegin"} 55 | (message doc)])] 56 | ws @chat-clients] 57 | (ws/send ws html))) 58 | 59 | (defn send-message [{:keys [session] :as ctx} {:keys [text]}] 60 | (let [{:keys [text]} (cheshire/parse-string text true)] 61 | (biff/submit-tx ctx 62 | [{:db/doc-type :msg 63 | :msg/user (:uid session) 64 | :msg/text text 65 | :msg/sent-at :db/now}]))) 66 | 67 | (defn chat [{:keys [biff/db]}] 68 | (let [messages (q db 69 | '{:find (pull msg [*]) 70 | :in [t0] 71 | :where [[msg :msg/sent-at t] 72 | [(<= t0 t)]]} 73 | (biff/add-seconds (java.util.Date.) (* -60 10)))] 74 | [:div {:hx-ext "ws" :ws-connect "/app/chat"} 75 | [:form.mb-0 {:ws-send true 76 | :_ "on submit set value of #message to ''"} 77 | [:label.block {:for "message"} "Write a message"] 78 | [:.h-1] 79 | [:textarea.w-full#message {:name "text"}] 80 | [:.h-1] 81 | [:.text-sm.text-gray-600 82 | "Sign in with an incognito window to have a conversation with yourself."] 83 | [:.h-2] 84 | [:div [:button.btn {:type "submit"} "Send message"]]] 85 | [:.h-6] 86 | [:div#message-header 87 | {:_ "on newMessage put 'Messages sent in the past 10 minutes:' into me"} 88 | (if (empty? messages) 89 | "No messages yet." 90 | "Messages sent in the past 10 minutes:")] 91 | [:div#messages 92 | (map message (sort-by :msg/sent-at #(compare %2 %1) messages))]])) 93 | 94 | (defn app [{:keys [session biff/db] :as ctx}] 95 | (let [{:user/keys [email foo bar]} (xt/entity db (:uid session))] 96 | (ui/page 97 | {} 98 | [:div "Signed in as " email ". " 99 | (biff/form 100 | {:action "/auth/signout" 101 | :class "inline"} 102 | [:button.text-blue-500.hover:text-blue-800 {:type "submit"} 103 | "Sign out"]) 104 | "."] 105 | [:.h-6] 106 | (biff/form 107 | {:action "/app/set-foo"} 108 | [:label.block {:for "foo"} "Foo: " 109 | [:span.font-mono (pr-str foo)]] 110 | [:.h-1] 111 | [:.flex 112 | [:input.w-full#foo {:type "text" :name "foo" :value foo}] 113 | [:.w-3] 114 | [:button.btn {:type "submit"} "Update"]] 115 | [:.h-1] 116 | [:.text-sm.text-gray-600 117 | "This demonstrates updating a value with a plain old form."]) 118 | [:.h-6] 119 | (bar-form {:value bar}) 120 | [:.h-6] 121 | (chat ctx)))) 122 | 123 | (defn ws-handler [{:keys [com.example/chat-clients] :as ctx}] 124 | {:status 101 125 | :headers {"upgrade" "websocket" 126 | "connection" "upgrade"} 127 | ::ws/listener {:on-open (fn [ws] 128 | (swap! chat-clients conj ws)) 129 | :on-message (fn [ws text-message] 130 | (send-message ctx {:ws ws :text text-message})) 131 | :on-close (fn [ws status-code reason] 132 | (swap! chat-clients disj ws))}}) 133 | 134 | (def about-page 135 | (ui/page 136 | {:base/title (str "About " settings/app-name)} 137 | [:p "This app was made with " 138 | [:a.link {:href "https://biffweb.com"} "Biff"] "."])) 139 | 140 | (defn echo [{:keys [params]}] 141 | {:status 200 142 | :headers {"content-type" "application/json"} 143 | :body params}) 144 | 145 | (def module 146 | {:static {"/about/" about-page} 147 | :routes ["/app" {:middleware [mid/wrap-signed-in]} 148 | ["" {:get app}] 149 | ["/set-foo" {:post set-foo}] 150 | ["/set-bar" {:post set-bar}] 151 | ["/chat" {:get ws-handler}]] 152 | :api-routes [["/api/echo" {:post echo}]] 153 | :on-tx notify-clients}) 154 | -------------------------------------------------------------------------------- /starter/src/com/example/email.clj: -------------------------------------------------------------------------------- 1 | (ns com.example.email 2 | (:require [clj-http.client :as http] 3 | [com.example.settings :as settings] 4 | [clojure.tools.logging :as log] 5 | [rum.core :as rum])) 6 | 7 | (defn signin-link [{:keys [to url user-exists]}] 8 | (let [[subject action] (if user-exists 9 | [(str "Sign in to " settings/app-name) "sign in"] 10 | [(str "Sign up for " settings/app-name) "sign up"])] 11 | {:to [{:email to}] 12 | :subject subject 13 | :html (rum/render-static-markup 14 | [:html 15 | [:body 16 | [:p "We received a request to " action " to " settings/app-name 17 | " using this email address. Click this link to " action ":"] 18 | [:p [:a {:href url :target "_blank"} "Click here to " action "."]] 19 | [:p "This link will expire in one hour. " 20 | "If you did not request this link, you can ignore this email."]]]) 21 | :text (str "We received a request to " action " to " settings/app-name 22 | " using this email address. Click this link to " action ":\n" 23 | "\n" 24 | url "\n" 25 | "\n" 26 | "This link will expire in one hour. If you did not request this link, " 27 | "you can ignore this email.")})) 28 | 29 | (defn signin-code [{:keys [to code user-exists]}] 30 | (let [[subject action] (if user-exists 31 | [(str "Sign in to " settings/app-name) "sign in"] 32 | [(str "Sign up for " settings/app-name) "sign up"])] 33 | {:to [{:email to}] 34 | :subject subject 35 | :html (rum/render-static-markup 36 | [:html 37 | [:body 38 | [:p "We received a request to " action " to " settings/app-name 39 | " using this email address. Enter the following code to " action ":"] 40 | [:p {:style {:font-size "2rem"}} code] 41 | [:p 42 | "This code will expire in three minutes. " 43 | "If you did not request this code, you can ignore this email."]]]) 44 | :text (str "We received a request to " action " to " settings/app-name 45 | " using this email address. Enter the following code to " action ":\n" 46 | "\n" 47 | code "\n" 48 | "\n" 49 | "This code will expire in three minutes. If you did not request this code, " 50 | "you can ignore this email.")})) 51 | 52 | (defn template [k opts] 53 | ((case k 54 | :signin-link signin-link 55 | :signin-code signin-code) 56 | opts)) 57 | 58 | (defn send-mailersend [{:keys [biff/secret mailersend/from mailersend/reply-to]} form-params] 59 | (let [result (http/post "https://api.mailersend.com/v1/email" 60 | {:oauth-token (secret :mailersend/api-key) 61 | :content-type :json 62 | :throw-exceptions false 63 | :as :json 64 | :form-params (merge {:from {:email from :name settings/app-name} 65 | :reply_to {:email reply-to :name settings/app-name}} 66 | form-params)}) 67 | success (< (:status result) 400)] 68 | (when-not success 69 | (log/error (:body result))) 70 | success)) 71 | 72 | (defn send-console [ctx form-params] 73 | (println "TO:" (:to form-params)) 74 | (println "SUBJECT:" (:subject form-params)) 75 | (println) 76 | (println (:text form-params)) 77 | (println) 78 | (println "To send emails instead of printing them to the console, add your" 79 | "API keys for MailerSend and Recaptcha to config.env.") 80 | true) 81 | 82 | (defn send-email [{:keys [biff/secret recaptcha/site-key] :as ctx} opts] 83 | (let [form-params (if-some [template-key (:template opts)] 84 | (template template-key opts) 85 | opts)] 86 | (if (every? some? [(secret :mailersend/api-key) 87 | (secret :recaptcha/secret-key) 88 | site-key]) 89 | (send-mailersend ctx form-params) 90 | (send-console ctx form-params)))) 91 | -------------------------------------------------------------------------------- /starter/src/com/example/home.clj: -------------------------------------------------------------------------------- 1 | (ns com.example.home 2 | (:require [com.biffweb :as biff] 3 | [com.example.middleware :as mid] 4 | [com.example.ui :as ui] 5 | [com.example.settings :as settings])) 6 | 7 | (def email-disabled-notice 8 | [:.text-sm.mt-3.bg-blue-100.rounded.p-2 9 | "Until you add API keys for MailerSend and reCAPTCHA, we'll print your sign-up " 10 | "link to the console. See config.edn."]) 11 | 12 | (defn home-page [{:keys [recaptcha/site-key params] :as ctx}] 13 | (ui/page 14 | (assoc ctx ::ui/recaptcha true) 15 | (biff/form 16 | {:action "/auth/send-link" 17 | :id "signup" 18 | :hidden {:on-error "/"}} 19 | (biff/recaptcha-callback "submitSignup" "signup") 20 | [:h2.text-2xl.font-bold (str "Sign up for " settings/app-name)] 21 | [:.h-3] 22 | [:.flex 23 | [:input#email {:name "email" 24 | :type "email" 25 | :autocomplete "email" 26 | :placeholder "Enter your email address"}] 27 | [:.w-3] 28 | [:button.btn.g-recaptcha 29 | (merge (when site-key 30 | {:data-sitekey site-key 31 | :data-callback "submitSignup"}) 32 | {:type "submit"}) 33 | "Sign up"]] 34 | (when-some [error (:error params)] 35 | [:<> 36 | [:.h-1] 37 | [:.text-sm.text-red-600 38 | (case error 39 | "recaptcha" (str "You failed the recaptcha test. Try again, " 40 | "and make sure you aren't blocking scripts from Google.") 41 | "invalid-email" "Invalid email. Try again with a different address." 42 | "send-failed" (str "We weren't able to send an email to that address. " 43 | "If the problem persists, try another address.") 44 | "There was an error.")]]) 45 | [:.h-1] 46 | [:.text-sm "Already have an account? " [:a.link {:href "/signin"} "Sign in"] "."] 47 | [:.h-3] 48 | biff/recaptcha-disclosure 49 | email-disabled-notice))) 50 | 51 | (defn link-sent [{:keys [params] :as ctx}] 52 | (ui/page 53 | ctx 54 | [:h2.text-xl.font-bold "Check your inbox"] 55 | [:p "We've sent a sign-in link to " [:span.font-bold (:email params)] "."])) 56 | 57 | (defn verify-email-page [{:keys [params] :as ctx}] 58 | (ui/page 59 | ctx 60 | [:h2.text-2xl.font-bold (str "Sign up for " settings/app-name)] 61 | [:.h-3] 62 | (biff/form 63 | {:action "/auth/verify-link" 64 | :hidden {:token (:token params)}} 65 | [:div [:label {:for "email"} 66 | "It looks like you opened this link on a different device or browser than the one " 67 | "you signed up on. For verification, please enter the email you signed up with:"]] 68 | [:.h-3] 69 | [:.flex 70 | [:input#email {:name "email" :type "email" 71 | :placeholder "Enter your email address"}] 72 | [:.w-3] 73 | [:button.btn {:type "submit"} 74 | "Sign in"]]) 75 | (when-some [error (:error params)] 76 | [:<> 77 | [:.h-1] 78 | [:.text-sm.text-red-600 79 | (case error 80 | "incorrect-email" "Incorrect email address. Try again." 81 | "There was an error.")]]))) 82 | 83 | (defn signin-page [{:keys [recaptcha/site-key params] :as ctx}] 84 | (ui/page 85 | (assoc ctx ::ui/recaptcha true) 86 | (biff/form 87 | {:action "/auth/send-code" 88 | :id "signin" 89 | :hidden {:on-error "/signin"}} 90 | (biff/recaptcha-callback "submitSignin" "signin") 91 | [:h2.text-2xl.font-bold "Sign in to " settings/app-name] 92 | [:.h-3] 93 | [:.flex 94 | [:input#email {:name "email" 95 | :type "email" 96 | :autocomplete "email" 97 | :placeholder "Enter your email address"}] 98 | [:.w-3] 99 | [:button.btn.g-recaptcha 100 | (merge (when site-key 101 | {:data-sitekey site-key 102 | :data-callback "submitSignin"}) 103 | {:type "submit"}) 104 | "Sign in"]] 105 | (when-some [error (:error params)] 106 | [:<> 107 | [:.h-1] 108 | [:.text-sm.text-red-600 109 | (case error 110 | "recaptcha" (str "You failed the recaptcha test. Try again, " 111 | "and make sure you aren't blocking scripts from Google.") 112 | "invalid-email" "Invalid email. Try again with a different address." 113 | "send-failed" (str "We weren't able to send an email to that address. " 114 | "If the problem persists, try another address.") 115 | "invalid-link" "Invalid or expired link. Sign in to get a new link." 116 | "not-signed-in" "You must be signed in to view that page." 117 | "There was an error.")]]) 118 | [:.h-1] 119 | [:.text-sm "Don't have an account yet? " [:a.link {:href "/"} "Sign up"] "."] 120 | [:.h-3] 121 | biff/recaptcha-disclosure 122 | email-disabled-notice))) 123 | 124 | (defn enter-code-page [{:keys [recaptcha/site-key params] :as ctx}] 125 | (ui/page 126 | (assoc ctx ::ui/recaptcha true) 127 | (biff/form 128 | {:action "/auth/verify-code" 129 | :id "code-form" 130 | :hidden {:email (:email params)}} 131 | (biff/recaptcha-callback "submitCode" "code-form") 132 | [:div [:label {:for "code"} "Enter the 6-digit code that we sent to " 133 | [:span.font-bold (:email params)]]] 134 | [:.h-1] 135 | [:.flex 136 | [:input#code {:name "code" :type "text"}] 137 | [:.w-3] 138 | [:button.btn.g-recaptcha 139 | (merge (when site-key 140 | {:data-sitekey site-key 141 | :data-callback "submitCode"}) 142 | {:type "submit"}) 143 | "Sign in"]]) 144 | (when-some [error (:error params)] 145 | [:<> 146 | [:.h-1] 147 | [:.text-sm.text-red-600 148 | (case error 149 | "invalid-code" "Invalid code." 150 | "There was an error.")]]) 151 | [:.h-3] 152 | (biff/form 153 | {:action "/auth/send-code" 154 | :id "signin" 155 | :hidden {:email (:email params) 156 | :on-error "/signin"}} 157 | (biff/recaptcha-callback "submitSignin" "signin") 158 | [:button.link.g-recaptcha 159 | (merge (when site-key 160 | {:data-sitekey site-key 161 | :data-callback "submitSignin"}) 162 | {:type "submit"}) 163 | "Send another code"]))) 164 | 165 | (def module 166 | {:routes [["" {:middleware [mid/wrap-redirect-signed-in]} 167 | ["/" {:get home-page}]] 168 | ["/link-sent" {:get link-sent}] 169 | ["/verify-link" {:get verify-email-page}] 170 | ["/signin" {:get signin-page}] 171 | ["/verify-code" {:get enter-code-page}]]}) 172 | -------------------------------------------------------------------------------- /starter/src/com/example/middleware.clj: -------------------------------------------------------------------------------- 1 | (ns com.example.middleware 2 | (:require [com.biffweb :as biff] 3 | [muuntaja.middleware :as muuntaja] 4 | [ring.middleware.anti-forgery :as csrf] 5 | [ring.middleware.defaults :as rd])) 6 | 7 | (defn wrap-redirect-signed-in [handler] 8 | (fn [{:keys [session] :as ctx}] 9 | (if (some? (:uid session)) 10 | {:status 303 11 | :headers {"location" "/app"}} 12 | (handler ctx)))) 13 | 14 | (defn wrap-signed-in [handler] 15 | (fn [{:keys [session] :as ctx}] 16 | (if (some? (:uid session)) 17 | (handler ctx) 18 | {:status 303 19 | :headers {"location" "/signin?error=not-signed-in"}}))) 20 | 21 | ;; Stick this function somewhere in your middleware stack below if you want to 22 | ;; inspect what things look like before/after certain middleware fns run. 23 | (defn wrap-debug [handler] 24 | (fn [ctx] 25 | (let [response (handler ctx)] 26 | (println "REQUEST") 27 | (biff/pprint ctx) 28 | (def ctx* ctx) 29 | (println "RESPONSE") 30 | (biff/pprint response) 31 | (def response* response) 32 | response))) 33 | 34 | (defn wrap-site-defaults [handler] 35 | (-> handler 36 | biff/wrap-render-rum 37 | biff/wrap-anti-forgery-websockets 38 | csrf/wrap-anti-forgery 39 | biff/wrap-session 40 | muuntaja/wrap-params 41 | muuntaja/wrap-format 42 | (rd/wrap-defaults (-> rd/site-defaults 43 | (assoc-in [:security :anti-forgery] false) 44 | (assoc-in [:responses :absolute-redirects] true) 45 | (assoc :session false) 46 | (assoc :static false))))) 47 | 48 | (defn wrap-api-defaults [handler] 49 | (-> handler 50 | muuntaja/wrap-params 51 | muuntaja/wrap-format 52 | (rd/wrap-defaults rd/api-defaults))) 53 | 54 | (defn wrap-base-defaults [handler] 55 | (-> handler 56 | biff/wrap-https-scheme 57 | biff/wrap-resource 58 | biff/wrap-internal-error 59 | biff/wrap-ssl 60 | biff/wrap-log-requests)) 61 | -------------------------------------------------------------------------------- /starter/src/com/example/schema.clj: -------------------------------------------------------------------------------- 1 | (ns com.example.schema) 2 | 3 | (def schema 4 | {:user/id :uuid 5 | :user [:map {:closed true} 6 | [:xt/id :user/id] 7 | [:user/email :string] 8 | [:user/joined-at inst?] 9 | [:user/foo {:optional true} :string] 10 | [:user/bar {:optional true} :string]] 11 | 12 | :msg/id :uuid 13 | :msg [:map {:closed true} 14 | [:xt/id :msg/id] 15 | [:msg/user :user/id] 16 | [:msg/text :string] 17 | [:msg/sent-at inst?]]}) 18 | 19 | (def module 20 | {:schema schema}) 21 | -------------------------------------------------------------------------------- /starter/src/com/example/settings.clj: -------------------------------------------------------------------------------- 1 | (ns com.example.settings) 2 | 3 | (def app-name "My Application") 4 | -------------------------------------------------------------------------------- /starter/src/com/example/ui.clj: -------------------------------------------------------------------------------- 1 | (ns com.example.ui 2 | (:require [cheshire.core :as cheshire] 3 | [clojure.java.io :as io] 4 | [com.example.settings :as settings] 5 | [com.biffweb :as biff] 6 | [ring.middleware.anti-forgery :as csrf] 7 | [ring.util.response :as ring-response] 8 | [rum.core :as rum])) 9 | 10 | (defn static-path [path] 11 | (if-some [last-modified (some-> (io/resource (str "public" path)) 12 | ring-response/resource-data 13 | :last-modified 14 | (.getTime))] 15 | (str path "?t=" last-modified) 16 | path)) 17 | 18 | (defn base [{:keys [::recaptcha] :as ctx} & body] 19 | (apply 20 | biff/base-html 21 | (-> ctx 22 | (merge #:base{:title settings/app-name 23 | :lang "en-US" 24 | :icon "/img/glider.png" 25 | :description (str settings/app-name " Description") 26 | :image "https://clojure.org/images/clojure-logo-120b.png"}) 27 | (update :base/head (fn [head] 28 | (concat [[:link {:rel "stylesheet" :href (static-path "/css/main.css")}] 29 | [:script {:src (static-path "/js/main.js")}] 30 | [:script {:src "https://unpkg.com/htmx.org@2.0.7"}] 31 | [:script {:src "https://unpkg.com/htmx-ext-ws@2.0.2/ws.js"}] 32 | [:script {:src "https://unpkg.com/hyperscript.org@0.9.14"}] 33 | (when recaptcha 34 | [:script {:src "https://www.google.com/recaptcha/api.js" 35 | :async "async" :defer "defer"}])] 36 | head)))) 37 | body)) 38 | 39 | (defn page [ctx & body] 40 | (base 41 | ctx 42 | [:.flex-grow] 43 | [:.p-3.mx-auto.max-w-screen-sm.w-full 44 | (when (bound? #'csrf/*anti-forgery-token*) 45 | {:hx-headers (cheshire/generate-string 46 | {:x-csrf-token csrf/*anti-forgery-token*})}) 47 | body] 48 | [:.flex-grow] 49 | [:.flex-grow])) 50 | 51 | (defn on-error [{:keys [status ex] :as ctx}] 52 | {:status status 53 | :headers {"content-type" "text/html"} 54 | :body (rum/render-static-markup 55 | (page 56 | ctx 57 | [:h1.text-lg.font-bold 58 | (if (= status 404) 59 | "Page not found." 60 | "Something went wrong.")]))}) 61 | -------------------------------------------------------------------------------- /starter/src/com/example/worker.clj: -------------------------------------------------------------------------------- 1 | (ns com.example.worker 2 | (:require [clojure.tools.logging :as log] 3 | [com.biffweb :as biff :refer [q]] 4 | [xtdb.api :as xt])) 5 | 6 | (defn every-n-minutes [n] 7 | (iterate #(biff/add-seconds % (* 60 n)) (java.util.Date.))) 8 | 9 | (defn print-usage [{:keys [biff/db]}] 10 | ;; For a real app, you can have this run once per day and send you the output 11 | ;; in an email. 12 | (let [n-users (nth (q db 13 | '{:find (count user) 14 | :where [[user :user/email]]}) 15 | 0 16 | 0)] 17 | (log/info "There are" n-users "users."))) 18 | 19 | (defn alert-new-user [{:keys [biff.xtdb/node]} tx] 20 | (doseq [_ [nil] 21 | :let [db-before (xt/db node {::xt/tx-id (dec (::xt/tx-id tx))})] 22 | [op & args] (::xt/tx-ops tx) 23 | :when (= op ::xt/put) 24 | :let [[doc] args] 25 | :when (and (contains? doc :user/email) 26 | (nil? (xt/entity db-before (:xt/id doc))))] 27 | ;; You could send this as an email instead of printing. 28 | (log/info "WOAH there's a new user"))) 29 | 30 | (defn echo-consumer [{:keys [biff/job] :as ctx}] 31 | (prn :echo job) 32 | (when-some [callback (:biff/callback job)] 33 | (callback job))) 34 | 35 | (def module 36 | {:tasks [{:task #'print-usage 37 | :schedule #(every-n-minutes 5)}] 38 | :on-tx alert-new-user 39 | :queues [{:id :echo 40 | :consumer #'echo-consumer}]}) 41 | -------------------------------------------------------------------------------- /starter/test/com/example_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.example-test 2 | (:require [cheshire.core :as cheshire] 3 | [clojure.string :as str] 4 | [clojure.test :refer [deftest is]] 5 | [com.biffweb :as biff :refer [test-xtdb-node]] 6 | [com.example :as main] 7 | [com.example.app :as app] 8 | [malli.generator :as mg] 9 | [rum.core :as rum] 10 | [xtdb.api :as xt])) 11 | 12 | (deftest example-test 13 | (is (= 4 (+ 2 2)))) 14 | 15 | (defn get-context [node] 16 | {:biff.xtdb/node node 17 | :biff/db (xt/db node) 18 | :biff/malli-opts #'main/malli-opts}) 19 | 20 | (deftest send-message-test 21 | (with-open [node (test-xtdb-node [])] 22 | (let [message (mg/generate :string) 23 | user (mg/generate :user main/malli-opts) 24 | ctx (assoc (get-context node) :session {:uid (:xt/id user)}) 25 | _ (app/send-message ctx {:text (cheshire/generate-string {:text message})}) 26 | db (xt/db node) ; get a fresh db value so it contains any transactions 27 | ; that send-message submitted. 28 | doc (biff/lookup db :msg/text message)] 29 | (is (some? doc)) 30 | (is (= (:msg/user doc) (:xt/id user)))))) 31 | 32 | (deftest chat-test 33 | (let [n-messages (+ 3 (rand-int 10)) 34 | now (java.util.Date.) 35 | messages (for [doc (mg/sample :msg (assoc main/malli-opts :size n-messages))] 36 | (assoc doc :msg/sent-at now))] 37 | (with-open [node (test-xtdb-node messages)] 38 | (let [response (app/chat {:biff/db (xt/db node)}) 39 | html (rum/render-html response)] 40 | (is (str/includes? html "Messages sent in the past 10 minutes:")) 41 | (is (not (str/includes? html "No messages yet."))) 42 | ;; If you add Jsoup to your dependencies, you can use DOM selectors instead of just regexes: 43 | ;(is (= n-messages (count (.select (Jsoup/parse html) "#messages > *")))) 44 | (is (= n-messages (count (re-seq #"init send newMessage to #message-header" html)))) 45 | (is (every? #(str/includes? html (:msg/text %)) messages)))))) 46 | -------------------------------------------------------------------------------- /tasks/deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"]} 2 | -------------------------------------------------------------------------------- /test/main/com/biffweb/impl/middleware_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.impl.middleware-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [cheshire.core :as cheshire] 4 | [com.biffweb :as biff])) 5 | 6 | (def default-request 7 | {:request-method :get 8 | :uri "/" 9 | :scheme :https 10 | :headers {"host" "example.com"} 11 | :biff.middleware/cookie-secret (biff/generate-secret 16)}) 12 | 13 | (defn call-with-headers [handler ctx] 14 | (let [resp (handler (merge default-request ctx))] 15 | (cond-> resp 16 | (not (string? (:body resp))) (update :body slurp) 17 | true (dissoc :session)))) 18 | 19 | (defn string->stream 20 | ([s] (string->stream s "UTF-8")) 21 | ([s encoding] 22 | (-> s 23 | (.getBytes encoding) 24 | (java.io.ByteArrayInputStream.)))) 25 | 26 | (def param-handler 27 | (-> (fn [{:keys [params] :as ctx}] 28 | {:status 200 29 | :headers {"Content-Type" "text/plain"} 30 | :body (pr-str params)}) 31 | biff/wrap-site-defaults 32 | biff/wrap-base-defaults)) 33 | 34 | (defn constant-handler [response] 35 | (-> (constantly response) 36 | biff/wrap-site-defaults 37 | biff/wrap-base-defaults)) 38 | 39 | (defn call 40 | ([handler ctx] 41 | (let [ctx (cond-> ctx 42 | (:body ctx) (update :body string->stream)) 43 | resp (handler (merge default-request ctx))] 44 | (cond-> resp 45 | (not (string? (:body resp))) (update :body slurp) 46 | true (dissoc :session :headers)))) 47 | ([ctx] 48 | (call param-handler ctx))) 49 | 50 | (deftest middleware 51 | (is (= (update (call-with-headers param-handler {}) :headers dissoc "Set-Cookie") 52 | {:status 200, 53 | :headers 54 | {"Content-Type" "text/plain; charset=utf-8", 55 | "Content-Length" "2" 56 | "X-Frame-Options" "SAMEORIGIN", 57 | "X-Content-Type-Options" "nosniff", 58 | "Strict-Transport-Security" "max-age=31536000; includeSubDomains"}, 59 | :body "{}"})) 60 | 61 | (is (= (call param-handler {:query-string "foo=bar"}) 62 | {:status 200, :body "{:foo \"bar\"}"})) 63 | 64 | (is (= (call param-handler {:method :post 65 | :headers {"content-type" "application/json"} 66 | :body (cheshire/generate-string {:baz "quux"})}) 67 | {:status 200, :body "{:baz \"quux\"}"})) 68 | 69 | (is (= (call param-handler {:method :post 70 | :headers {"content-type" "application/x-www-form-urlencoded"} 71 | :body "foo=bar"}) 72 | {:status 200, :body "{:foo \"bar\"}"})) 73 | 74 | (is (= (call (constant-handler {:status 200 75 | :headers {"Content-Type" "application/edn"} 76 | :body (pr-str {:foo :bar})}) 77 | {}) 78 | {:status 200, :body "{:foo :bar}"})) 79 | 80 | (is (= (call (constant-handler {:status 200 81 | :body {:foo :bar}}) 82 | {:headers {"accept" "application/edn"}}) 83 | {:status 200, :body "{:foo :bar}"}))) 84 | -------------------------------------------------------------------------------- /test/xtdb1/com/biffweb/impl/xtdb_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.impl.xtdb-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [xtdb.api :as xt] 4 | [com.biffweb :as biff :refer [test-xtdb-node]] 5 | [com.biffweb.impl.xtdb :as impl] 6 | [malli.core :as malc] 7 | [malli.registry :as malr])) 8 | 9 | (def schema 10 | {:user/id :keyword 11 | :user/email :string 12 | :user/foo :string 13 | :user/bar :string 14 | :user [:map {:closed true} 15 | [:xt/id :user/id] 16 | :user/email 17 | [:user/foo {:optional true}] 18 | [:user/bar {:optional true}]] 19 | 20 | :msg/id :keyword 21 | :msg/user :user/id 22 | :msg/text :string 23 | :msg/sent-at inst? 24 | :msg [:map {:closed true} 25 | [:xt/id :msg/id] 26 | :msg/user 27 | :msg/text 28 | :msg/sent-at]}) 29 | 30 | (def malli-opts {:registry (malr/composite-registry malc/default-registry schema)}) 31 | 32 | (deftest ensure-unique 33 | (with-open [node (test-xtdb-node [{:foo "bar"}])] 34 | (let [db (xt/db node)] 35 | (is (nil? (xt/with-tx 36 | db 37 | [[::xt/put {:xt/id (random-uuid) 38 | :foo "bar"}] 39 | [::xt/fn :biff/ensure-unique {:foo "bar"}]]))) 40 | (is (some? (xt/with-tx 41 | db 42 | [[::xt/put {:xt/id (random-uuid) 43 | :foo "baz"}] 44 | [::xt/fn :biff/ensure-unique {:foo "bar"}]])))))) 45 | 46 | (deftest tx-upsert 47 | (with-open [node (test-xtdb-node [{:xt/id :id/foo 48 | :foo "bar"}])] 49 | (is (= (biff/tx-xform-upsert 50 | {:biff/db (xt/db node)} 51 | [{:db/doc-type :user 52 | :db.op/upsert {:foo "bar"} 53 | :baz "quux"}]) 54 | '({:db/doc-type :user, 55 | :baz "quux", 56 | :foo "bar", 57 | :db/op :merge, 58 | :xt/id :id/foo}))) 59 | (is (= (biff/tx-xform-upsert 60 | {:biff/db (xt/db node)} 61 | [{:db/doc-type :user 62 | :db.op/upsert {:foo "eh"} 63 | :baz "quux"}]) 64 | '({:db/doc-type :user, 65 | :baz "quux", 66 | :foo "eh", 67 | :db/op :merge} 68 | [:xtdb.api/fn :biff/ensure-unique {:foo "eh"}]))))) 69 | 70 | (deftest tx-unique 71 | (is (= (biff/tx-xform-unique 72 | nil 73 | [{:foo "bar" 74 | :baz [:db/unique "quux"] 75 | :spam [:db/unique "eggs"]} 76 | {:hello "there"}]) 77 | '({:foo "bar", :baz "quux", :spam "eggs"} 78 | [:xtdb.api/fn :biff/ensure-unique {:baz "quux"}] 79 | [:xtdb.api/fn :biff/ensure-unique {:spam "eggs"}] 80 | {:hello "there"})))) 81 | 82 | (deftest tx-tmp-ids 83 | (let [[{:keys [a b c]} 84 | {:keys [d]}] (biff/tx-xform-tmp-ids 85 | nil 86 | [{:a 1 87 | :b :db.id/foo 88 | :c :db.id/bar} 89 | {:d :db.id/foo}])] 90 | (is (every? uuid? [b c d])) 91 | (is (= b d)) 92 | (is (not= b c)))) 93 | 94 | (defn get-context [node] 95 | {:biff/db (xt/db node) 96 | :biff/now #inst "1970" 97 | :biff/malli-opts #'malli-opts}) 98 | 99 | (def test-docs [{:xt/id :user/alice 100 | :user/email "alice@example.com"} 101 | {:xt/id :user/bob 102 | :user/email "bob@example.com"}]) 103 | 104 | (deftest tx-default 105 | (with-open [node (test-xtdb-node (into test-docs 106 | [{:xt/id :user/carol 107 | :user/email "carol@example.com" 108 | :user/foo "x"}]))] 109 | (is (= (biff/biff-tx->xt 110 | (get-context node) 111 | [{:db/doc-type :user 112 | :db/op :update 113 | :xt/id :user/bob 114 | :user/foo [:db/default "default-value"]} 115 | {:db/doc-type :user 116 | :db/op :update 117 | :xt/id :user/carol 118 | :user/foo [:db/default "default-value"]}]) 119 | '([:xtdb.api/match 120 | :user/bob 121 | {:user/email "bob@example.com", :xt/id :user/bob}] 122 | [:xtdb.api/put 123 | {:user/email "bob@example.com", 124 | :xt/id :user/bob, 125 | :user/foo "default-value"}] 126 | [:xtdb.api/match 127 | :user/carol 128 | {:user/email "carol@example.com", :user/foo "x", :xt/id :user/carol}] 129 | [:xtdb.api/put 130 | {:user/email "carol@example.com", :user/foo "x", :xt/id :user/carol}]))))) 131 | 132 | (deftest tx-all 133 | (with-open [node (test-xtdb-node test-docs)] 134 | (is (= (biff/biff-tx->xt 135 | (get-context node) 136 | [{:db/doc-type :user 137 | :db.op/upsert {:user/email "alice@example.com"} 138 | :user/foo "bar"} 139 | {:db/doc-type :user 140 | :db/op :update 141 | :xt/id :user/bob 142 | :user/bar "baz"}]) 143 | '([:xtdb.api/match 144 | :user/alice 145 | {:user/email "alice@example.com", :xt/id :user/alice}] 146 | [:xtdb.api/put 147 | {:user/email "alice@example.com", 148 | :xt/id :user/alice, 149 | :user/foo "bar"}] 150 | [:xtdb.api/match 151 | :user/bob 152 | {:user/email "bob@example.com", :xt/id :user/bob}] 153 | [:xtdb.api/put 154 | {:user/email "bob@example.com", :xt/id :user/bob, :user/bar "baz"}]))))) 155 | 156 | (deftest lookup 157 | (with-open [node (test-xtdb-node [{:xt/id :user/alice 158 | :user/email "alice@example.com" 159 | :user/foo "foo"} 160 | {:xt/id :user/bob 161 | :user/email "bob@example.com" 162 | :user/foo "foo"} 163 | {:xt/id :user/carol 164 | :user/email "bob@example.com"} 165 | {:xt/id :msg/a 166 | :msg/user :user/alice 167 | :msg/text "hello" 168 | :msg/sent-at #inst "1970"} 169 | {:xt/id :msg/b 170 | :msg/user :user/alice 171 | :msg/text "there" 172 | :msg/sent-at #inst "1971"}])] 173 | (let [db (xt/db node)] 174 | (is (= :user/alice (biff/lookup-id db :user/email "alice@example.com"))) 175 | (is (= '(:user/alice :user/bob) (sort (biff/lookup-id-all db :user/foo "foo")))) 176 | (is (= {:user/email "alice@example.com", :user/foo "foo", :xt/id :user/alice} 177 | (biff/lookup db :user/email "alice@example.com"))) 178 | (is (= '({:user/email "alice@example.com", :user/foo "foo", :xt/id :user/alice} 179 | {:user/email "bob@example.com", :user/foo "foo", :xt/id :user/bob}) 180 | (sort-by :user/email (biff/lookup-all db :user/foo "foo")))) 181 | (is (= '{:user/email "alice@example.com", 182 | :user/foo "foo", 183 | :xt/id :user/alice, 184 | :user/messages 185 | ({:msg/user :user/alice, 186 | :msg/text "hello", 187 | :msg/sent-at #inst "1970-01-01T00:00:00.000-00:00", 188 | :xt/id :msg/a} 189 | {:msg/user :user/alice, 190 | :msg/text "there", 191 | :msg/sent-at #inst "1971-01-01T00:00:00.000-00:00", 192 | :xt/id :msg/b})} 193 | (-> (biff/lookup db 194 | '[* {(:msg/_user {:as :user/messages}) [*]}] 195 | :user/email 196 | "alice@example.com") 197 | (update :user/messages #(sort-by :msg/sent-at %))))) 198 | (is (#{:user/alice :user/bob} (biff/lookup-id db :user/foo "foo")))))) 199 | 200 | (deftest apply-special-vals 201 | (is (= (impl/apply-special-vals {:a 1 202 | :b 2 203 | :d #{1 2 3 4} 204 | :e 5 205 | :g 8} 206 | {:b :db/dissoc 207 | :c [:db/union 3] 208 | :d [:db/difference 4] 209 | :e [:db/add 2] 210 | :f [:db/default 6] 211 | :g [:db/default 7]}) 212 | {:a 1, :d #{1 3 2}, :e 7, :g 8, :c #{3}, :f 6}))) 213 | -------------------------------------------------------------------------------- /test/xtdb2/com/biffweb/auth_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.biffweb.auth-test 2 | (:require 3 | [xtdb.api :as xt] 4 | [xtdb.node :as xtn]) 5 | (:import 6 | [java.time Instant])) 7 | 8 | (defn latest-snapshot-time [node] 9 | (some-> (xt/status node) 10 | :latest-completed-tx 11 | :system-time 12 | (.toInstant))) 13 | 14 | 15 | (comment 16 | 17 | (def node (xtn/start-node {})) 18 | (.close node) 19 | 20 | (xt/submit-tx node 21 | [[:put-docs :biff.auth/code 22 | {:xt/id #uuid "010958e1-daab-48da-8271-c71f0d9c7359" 23 | :code "abc123" 24 | :created-at #xt/instant "2025-10-28T02:26:23.275714693Z" 25 | :failed-attempts 0}]]) 26 | 27 | (xt/submit-tx node 28 | [[(str "update \"biff.auth\".code " 29 | "set failed_attempts = failed_attempts + 1 " 30 | "where _id = ?") 31 | #uuid "010958e1-daab-48da-8271-c71f0d9c7359"]]) 32 | 33 | (xt/q node "select _id from \"biff.auth\".code") 34 | (xt/q node "select * from user" {:snapshot-time (latest-snapshot-time node)}) 35 | 36 | (xt/q node (str "SELECT table_schema, table_name " 37 | "FROM information_schema.tables " 38 | "WHERE table_type = 'BASE TABLE' AND " 39 | "table_schema NOT IN ('pg_catalog', 'information_schema');")) 40 | 41 | 42 | (xt/submit-tx node [["delete from user"]]) 43 | 44 | (do 45 | (xt/execute-tx node [[:put-docs :user {:xt/id (random-uuid) 46 | :user/email "bob@example.com"}]]) 47 | (xt/q node "select * from user") 48 | ) 49 | 50 | 51 | (meta (with-meta node {:biff.xtdb/snapshot-time (latest-snapshot-time node)})) 52 | 53 | (meta node) 54 | 55 | (:system node) 56 | 57 | ) 58 | 59 | 60 | ;; (defn- q [node query & [opts]] 61 | ;; (xt/q node 62 | ;; query 63 | ;; (merge (:biff.xtdb/q-opts (meta node)) opts))) 64 | ;; 65 | ;; (defn- with-snapshot-time [node snapshot-time] 66 | ;; (with-meta node {:biff.xtdb/q-opts {:snapshot-time snapshot-time}})) 67 | ;; 68 | -------------------------------------------------------------------------------- /test/xtdb2/com/biffweb/impl/xtdb2_test.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb2.com.biffweb.impl.xtdb2-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [com.biffweb.impl.xtdb2 :as xt2])) 4 | 5 | (def user 6 | [:map {:closed true 7 | :biff/table "users"} 8 | [:xt/id :int] 9 | [:user/email :string] 10 | [:user/favorite-color {:optional true} :keyword]]) 11 | 12 | (def user-no-table 13 | [:map {:closed true} 14 | [:xt/id :int] 15 | [:user/email :string]]) 16 | 17 | (deftest where-clause 18 | (is (= (xt2/where-clause [:foo :foo/bar :foo.bar/baz :foo.bar/baz-quux]) 19 | "foo = ? and foo$bar = ? and foo$bar$baz = ? and foo$bar$baz_quux = ?"))) 20 | 21 | (deftest put-patch 22 | (is (= (xt2/put user {:xt/id 1 :user/email "hello@example.com"}) 23 | [:put-docs "users" {:xt/id 1, :user/email "hello@example.com"}])) 24 | (is (thrown-with-msg? clojure.lang.ExceptionInfo 25 | #"Unable to infer a table name." 26 | (xt2/put user-no-table {:xt/id 1 :user/email "hello@example.com"}))) 27 | (is (= (xt2/put :any {:xt/id 1 :user/email "hello@example.com"}) 28 | [:put-docs "any" {:xt/id 1, :user/email "hello@example.com"}])) 29 | (is (thrown-with-msg? clojure.lang.ExceptionInfo 30 | #"Record is missing an :xt/id value." 31 | (xt2/put user {:user/email "hello@example.com"}))) 32 | (is (thrown-with-msg? clojure.lang.ExceptionInfo 33 | #"Record doesn't match schema." 34 | (xt2/put user {:xt/id 1}))) 35 | (is (= (xt2/patch user {:xt/id 1 :user/favorite-color :blue}) 36 | [:patch-docs "users" {:xt/id 1, :user/favorite-color :blue}]))) 37 | 38 | (deftest assert-unique 39 | (is (= (xt2/assert-unique user {:user/email "hello@example.com"}) 40 | ["assert 1 >= (select count(*) from users where user$email = ?" 41 | "hello@example.com"]))) 42 | 43 | (deftest select-from-where 44 | (is (= (xt2/select-from-where [:xt/id :user/joined-at] 45 | user 46 | {:user/email "hello@example.com"}) 47 | ["select _id, user$joined_at from users where user$email = ?" 48 | "hello@example.com"]))) 49 | 50 | (deftest use-xtdb2 51 | (is (= (xt2/use-xtdb2-config {:biff/secret {}}) 52 | {:log [:local {:path "storage/xtdb2/log"}], 53 | :storage [:local {:path "storage/xtdb2/storage"}]})) 54 | (is (= (xt2/use-xtdb2-config {:biff/secret {} 55 | :biff.xtdb2/log :kafka}) 56 | {:log [:kafka 57 | {:bootstrap-servers "localhost:9092", :topic "xtdb-log", :epoch 1}], 58 | :storage [:local {:path "storage/xtdb2/storage"}]})) 59 | (is (= (xt2/use-xtdb2-config {:biff/secret {:biff.xtdb2.storage/secret-key "secret-key"} 60 | :biff.xtdb2/storage :remote 61 | :biff.xtdb2.storage/bucket "bucket" 62 | :biff.xtdb2.storage/endpoint "endpoint" 63 | :biff.xtdb2.storage/access-key "access-key"}) 64 | {:log [:local {:path "storage/xtdb2/log"}], 65 | :storage [:remote 66 | {:object-store 67 | [:s3 68 | {:bucket "bucket", 69 | :endpoint "endpoint", 70 | :credentials {:access-key "access-key", :secret-key "secret-key"}}], 71 | :local-disk-cache "storage/xtdb2/storage-cache"}]}))) 72 | 73 | (deftest uuid-helpers 74 | (is (= (xt2/prefix-uuid #uuid "7cf9cf46-b962-42d0-94ad-a28e1b73de9e" 75 | #uuid "55e61b22-5fc2-40d4-9148-6a172afe63df") 76 | #uuid "7cf9cf46-b962-42d0-9148-6a172afe63df")) 77 | (is (uuid? (xt2/squuid)))) 78 | -------------------------------------------------------------------------------- /xtdb2-starter/.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | .cpcache 4 | -------------------------------------------------------------------------------- /xtdb2-starter/.gitignore: -------------------------------------------------------------------------------- 1 | /.cpcache 2 | /.nrepl-port 3 | /bin 4 | /config.edn 5 | /config.sh 6 | /config.env 7 | /node_modules 8 | /secrets.env 9 | /storage/ 10 | /tailwindcss 11 | /target 12 | .calva/ 13 | .clj-kondo/ 14 | .lsp/ 15 | .portal/ 16 | .shadow-cljs/ 17 | -------------------------------------------------------------------------------- /xtdb2-starter/Dockerfile: -------------------------------------------------------------------------------- 1 | # The default deploy instructions (https://biffweb.com/docs/reference/production/) don't 2 | # use Docker, but this file is provided in case you'd like to deploy with containers. 3 | # 4 | # When running the container, make sure you set any environment variables defined in config.env, 5 | # e.g. using whatever tools your deployment platform provides for setting environment variables. 6 | # 7 | # Run these commands to test this file locally: 8 | # 9 | # docker build -t your-app . 10 | # docker run --rm -e BIFF_PROFILE=dev -v $PWD/config.env:/app/config.env your-app 11 | 12 | # This is the base builder image, construct the jar file in this one 13 | # it uses alpine for a small image 14 | FROM clojure:temurin-21-tools-deps-alpine AS jre-build 15 | 16 | ENV TAILWIND_VERSION=v3.2.4 17 | 18 | # Install the missing packages and applications in a single layer 19 | RUN apk add curl rlwrap && curl -L -o /usr/local/bin/tailwindcss \ 20 | https://github.com/tailwindlabs/tailwindcss/releases/download/$TAILWIND_VERSION/tailwindcss-linux-x64 \ 21 | && chmod +x /usr/local/bin/tailwindcss 22 | 23 | WORKDIR /app 24 | COPY src ./src 25 | COPY dev ./dev 26 | COPY resources ./resources 27 | COPY deps.edn . 28 | 29 | # construct the application jar 30 | RUN clj -M:dev uberjar && cp target/jar/app.jar . && rm -r target 31 | 32 | # This stage (see multi-stage builds) is a bare Java container 33 | # copy over the uberjar from the builder image and run the application 34 | FROM eclipse-temurin:21-alpine 35 | WORKDIR /app 36 | 37 | # Take the uberjar from the base image and put it in the final image 38 | COPY --from=jre-build /app/app.jar /app/app.jar 39 | 40 | EXPOSE 8080 41 | 42 | # By default, run in PROD profile 43 | ENV BIFF_PROFILE=prod 44 | ENV HOST=0.0.0.0 45 | ENV PORT=8080 46 | CMD ["/opt/java/openjdk/bin/java", "-XX:-OmitStackTraceInFastThrow", "-XX:+CrashOnOutOfMemoryError", "-jar", "app.jar"] 47 | -------------------------------------------------------------------------------- /xtdb2-starter/README.md: -------------------------------------------------------------------------------- 1 | # Biff starter project 2 | 3 | This is the starter project for Biff. 4 | 5 | Run `clj -M:dev dev` to get started. See `clj -M:dev --help` for other commands. 6 | 7 | Consider adding `alias biff='clj -M:dev'` to your `.bashrc`. 8 | -------------------------------------------------------------------------------- /xtdb2-starter/cljfmt-indents.edn: -------------------------------------------------------------------------------- 1 | {submit-tx [[:inner 0]]} 2 | -------------------------------------------------------------------------------- /xtdb2-starter/deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources" "target/resources"] 2 | :deps {com.biffweb/biff {:local/root ".." 3 | :exclusions [com.xtdb/xtdb-core 4 | com.xtdb/xtdb-jdbc 5 | com.xtdb/xtdb-rocksdb 6 | org.postgresql/postgresql]} 7 | com.xtdb/xtdb-api {:mvn/version "2.x-20251024.124141-1"} 8 | com.xtdb/xtdb-aws {:mvn/version "2.x-20251024.124141-1"} 9 | com.xtdb/xtdb-core {:mvn/version "2.x-20251024.124141-1"} 10 | cheshire/cheshire {:mvn/version "6.1.0"} 11 | 12 | ;; Notes on logging: https://gist.github.com/jacobobryant/76b7a08a07d5ef2cc076b048d078f1f3 13 | org.slf4j/slf4j-simple {:mvn/version "2.0.0-alpha5"} 14 | org.slf4j/log4j-over-slf4j {:mvn/version "1.7.36"} 15 | org.slf4j/jul-to-slf4j {:mvn/version "1.7.36"} 16 | org.slf4j/jcl-over-slf4j {:mvn/version "1.7.36"}} 17 | :aliases 18 | {:dev {:extra-deps {com.biffweb/tasks {:local/root "../libs/tasks"}} 19 | :extra-paths ["dev" "test"] 20 | :jvm-opts ["--add-opens=java.base/java.nio=ALL-UNNAMED" 21 | "-Dio.netty.tryReflectionSetAccessible=true" 22 | "-XX:-OmitStackTraceInFastThrow" 23 | "-XX:+CrashOnOutOfMemoryError" 24 | "-Dbiff.env.BIFF_PROFILE=dev"] 25 | :main-opts ["-m" "com.biffweb.task-runner" "tasks/tasks"]} 26 | :prod {:jvm-opts ["--add-opens=java.base/java.nio=ALL-UNNAMED" 27 | "-Dio.netty.tryReflectionSetAccessible=true" 28 | "-XX:-OmitStackTraceInFastThrow" 29 | "-XX:+CrashOnOutOfMemoryError" 30 | "-Dbiff.env.BIFF_PROFILE=prod"] 31 | :main-opts ["-m" "com.example"]}} 32 | :mvn/repos 33 | {"central" {:url "https://repo1.maven.org/maven2/"} 34 | "clojars" {:url "https://clojars.org/repo"} 35 | "sonatype-snapshots" {:url "https://central.sonatype.com/repository/maven-snapshots/"}}} 36 | -------------------------------------------------------------------------------- /xtdb2-starter/dev/repl.clj: -------------------------------------------------------------------------------- 1 | (ns repl 2 | (:require 3 | [com.biffweb :as biff] 4 | [com.biffweb.experimental :as biffx] 5 | [com.example :as main] 6 | [xtdb.api :as xt]) 7 | (:import 8 | [java.time Instant])) 9 | 10 | ;; REPL-driven development 11 | ;; ---------------------------------------------------------------------------------------- 12 | ;; If you're new to REPL-driven development, Biff makes it easy to get started: whenever 13 | ;; you save a file, your changes will be evaluated. Biff is structured so that in most 14 | ;; cases, that's all you'll need to do for your changes to take effect. (See main/refresh 15 | ;; below for more details.) 16 | ;; 17 | ;; The `clj -M:dev dev` command also starts an nREPL server on port 7888, so if you're 18 | ;; already familiar with REPL-driven development, you can connect to that with your editor. 19 | ;; 20 | ;; If you're used to jacking in with your editor first and then starting your app via the 21 | ;; REPL, you will need to instead connect your editor to the nREPL server that `clj -M:dev 22 | ;; dev` starts. e.g. if you use emacs, instead of running `cider-jack-in`, you would run 23 | ;; `cider-connect`. See "Connecting to a Running nREPL Server:" 24 | ;; https://docs.cider.mx/cider/basics/up_and_running.html#connect-to-a-running-nrepl-server 25 | ;; ---------------------------------------------------------------------------------------- 26 | 27 | ;; This function should only be used from the REPL. Regular application code 28 | ;; should receive the system map from the parent Biff component. For example, 29 | ;; the use-jetty component merges the system map into incoming Ring requests. 30 | (defn get-context [] 31 | (biff/merge-context @main/system)) 32 | 33 | (defn add-fixtures [] 34 | (let [user-id (random-uuid)] 35 | (biffx/submit-tx (get-context) 36 | [[:put-docs :user {:xt/id user-id 37 | :email "a@example.com" 38 | :foo "Some Value" 39 | :joined-at (Instant/now)}] 40 | [:put-docs :msg {:xt/id (random-uuid) 41 | :user user-id 42 | :text "hello there" 43 | :sent-at (Instant/now)}]]))) 44 | 45 | (defn check-config [] 46 | (let [prod-config (biff/use-aero-config {:biff.config/profile "prod"}) 47 | dev-config (biff/use-aero-config {:biff.config/profile "dev"}) 48 | ;; Add keys for any other secrets you've added to resources/config.edn 49 | secret-keys [:biff.middleware/cookie-secret 50 | :biff/jwt-secret 51 | :mailersend/api-key 52 | :recaptcha/secret-key 53 | ; ... 54 | ] 55 | get-secrets (fn [{:keys [biff/secret] :as config}] 56 | (into {} 57 | (map (fn [k] 58 | [k (secret k)])) 59 | secret-keys))] 60 | {:prod-config prod-config 61 | :dev-config dev-config 62 | :prod-secrets (get-secrets prod-config) 63 | :dev-secrets (get-secrets dev-config)})) 64 | 65 | (comment 66 | ;; Call this function if you make a change to main/initial-system, 67 | ;; main/components, :tasks, :queues, config.env, or deps.edn. 68 | (main/refresh) 69 | 70 | ;; Call this in dev if you'd like to add some seed data to your database. If you edit the seed 71 | ;; data, you can reset the database by running `rm -r storage/xtdb2` (DON'T run that in prod), 72 | ;; restarting your app, and calling add-fixtures again. 73 | (add-fixtures) 74 | 75 | ;; Query the database 76 | (let [{:keys [biff/node]} (get-context)] 77 | (xt/q node "select * from user")) 78 | 79 | ;; Update an existing user's email address 80 | (let [{:keys [biff/node] :as ctx} (get-context) 81 | [{user-id :xt/id}] (xt/q node ["select _id from user where email = ?" 82 | "hello@example.com"])] 83 | (biffx/submit-tx ctx 84 | [[:patch-docs :user {:xt/id user-id 85 | :email "new.address@example.com"}]])) 86 | 87 | (sort (keys (get-context))) 88 | 89 | ;; Check the terminal for output. 90 | (biff/submit-job (get-context) :echo {:foo "bar"}) 91 | (deref (biff/submit-job-for-result (get-context) :echo {:foo "bar"}))) 92 | -------------------------------------------------------------------------------- /xtdb2-starter/dev/tasks.clj: -------------------------------------------------------------------------------- 1 | (ns tasks 2 | (:require [com.biffweb.tasks :as tasks])) 3 | 4 | (defn hello 5 | "Says 'Hello'" 6 | [] 7 | (println "Hello")) 8 | 9 | ;; Tasks should be vars (#'hello instead of hello) so that `clj -M:dev help` can 10 | ;; print their docstrings. 11 | (def custom-tasks 12 | {"hello" #'hello}) 13 | 14 | (def tasks (merge tasks/tasks custom-tasks)) 15 | -------------------------------------------------------------------------------- /xtdb2-starter/resources/config.edn: -------------------------------------------------------------------------------- 1 | ;; See https://github.com/juxt/aero and https://biffweb.com/docs/api/utilities/#use-aero-config. 2 | ;; #biff/env and #biff/secret will load values from the environment and from config.env. 3 | {:biff/base-url #profile {:prod #join ["https://" #biff/env DOMAIN] 4 | :default #join ["http://localhost:" #ref [:biff/port]]} 5 | :biff/host #or [#biff/env "HOST" 6 | #profile {:dev "0.0.0.0" 7 | :default "localhost"}] 8 | :biff/port #long #or [#biff/env "PORT" 8080] 9 | 10 | :biff.xtdb2/storage #keyword #or [#profile {:prod #biff/env PROD_XTDB_STORAGE} 11 | "local"] 12 | :biff.xtdb2.storage/bucket #biff/env XTDB_STORAGE_BUCKET 13 | :biff.xtdb2.storage/endpoint #biff/env XTDB_STORAGE_ENDPOINT 14 | :biff.xtdb2.storage/access-key #biff/env XTDB_STORAGE_ACCESS_KEY 15 | :biff.xtdb2.storage/secret-key #biff/secret XTDB_STORAGE_SECRET_KEY 16 | 17 | :biff.beholder/enabled #profile {:dev true :default false} 18 | :biff.beholder/paths ["src" "resources" "test"] 19 | :biff.add-libs/aliases #profile {:dev [:dev] :prod [:prod]} 20 | :biff/eval-paths ["src" "test"] 21 | :biff.middleware/secure #profile {:dev false :default true} 22 | :biff.middleware/cookie-secret #biff/secret COOKIE_SECRET 23 | :biff/jwt-secret #biff/secret JWT_SECRET 24 | :biff.refresh/enabled #profile {:dev true :default false} 25 | 26 | :mailersend/api-key #biff/secret MAILERSEND_API_KEY 27 | :mailersend/from #biff/env MAILERSEND_FROM 28 | :mailersend/reply-to #biff/env MAILERSEND_REPLY_TO 29 | 30 | :recaptcha/secret-key #biff/secret RECAPTCHA_SECRET_KEY 31 | :recaptcha/site-key #biff/env RECAPTCHA_SITE_KEY 32 | 33 | :biff.nrepl/port #or [#biff/env NREPL_PORT "7888"] 34 | :biff.nrepl/args ["--port" #ref [:biff.nrepl/port] 35 | "--middleware" "[cider.nrepl/cider-middleware,refactor-nrepl.middleware/wrap-refactor]"] 36 | 37 | :biff.system-properties/user.timezone "UTC" 38 | :biff.system-properties/clojure.tools.logging.factory "clojure.tools.logging.impl/slf4j-factory" 39 | 40 | :biff.tasks/server #biff/env DOMAIN 41 | :biff.tasks/main-ns com.example 42 | :biff.tasks/on-soft-deploy "\"(com.example/on-save @com.example/system)\"" 43 | :biff.tasks/generate-assets-fn com.example/generate-assets! 44 | :biff.tasks/css-output "target/resources/public/css/main.css" 45 | :biff.tasks/deploy-untracked-files [#ref [:biff.tasks/css-output] 46 | "config.env"] 47 | 48 | ;; `clj -M:dev prod-dev` will run the soft-deploy task whenever files in these directories are changed. 49 | :biff.tasks/watch-dirs ["src" "dev" "resources" "test"] 50 | 51 | ;; The version of the Taliwind standalone bin to install. See `clj -M:dev css -h`. If you change 52 | ;; this, run `rm bin/tailwindcss; clj -M:dev install-tailwind`. 53 | :biff.tasks/tailwind-version "v3.4.17" 54 | 55 | ;; :rsync is the default if rsync is on the path; otherwise :git is the default. Set this to :git 56 | ;; if you have rsync on the path but still want to deploy with git. 57 | ;; :biff.tasks/deploy-with :rsync 58 | 59 | ;; Uncomment this line if you're deploying with git and your local branch is called main instead of 60 | ;; master: 61 | ;; :biff.tasks/git-deploy-cmd ["git" "push" "prod" "main:master"] 62 | :biff.tasks/git-deploy-cmd ["git" "push" "prod" "master"] 63 | 64 | ;; Uncomment this line if you have any ssh-related problems: 65 | ;; :biff.tasks/skip-ssh-agent true 66 | } 67 | -------------------------------------------------------------------------------- /xtdb2-starter/resources/config.template.env: -------------------------------------------------------------------------------- 1 | # This file contains config that is not checked into git. See resources/config.edn for more config 2 | # options. 3 | 4 | # Where will your app be deployed? 5 | DOMAIN=example.com 6 | 7 | # Mailersend is used to send email sign-in links. Sign up at https://www.mailersend.com/ 8 | MAILERSEND_API_KEY= 9 | # This must be an email address that uses the same domain that you've verified in MailerSend. 10 | MAILERSEND_FROM= 11 | # This is where emails will be sent when users hit reply. It can be any email address. 12 | MAILERSEND_REPLY_TO= 13 | 14 | # Recaptcha is used to protect your sign-in page from bots. Go to 15 | # https://www.google.com/recaptcha/about/ and add a site. Select v2 invisible. Add localhost and the 16 | # value of DOMAIN above to your list of allowed domains. 17 | RECAPTCHA_SITE_KEY= 18 | RECAPTCHA_SECRET_KEY= 19 | 20 | # Edit and uncomment these to use S3 for production storage (recommended): 21 | #PROD_XTDB_STORAGE=remote 22 | #XTDB_STORAGE_BUCKET=xtdb-example 23 | #XTDB_STORAGE_ENDPOINT=https://nyc3.digitaloceanspaces.com 24 | #XTDB_STORAGE_ACCESS_KEY=abc 25 | #XTDB_STORAGE_SECRET_KEY=123 26 | 27 | # What port should the nrepl server be started on (in dev and prod)? 28 | NREPL_PORT=7888 29 | 30 | 31 | ## Autogenerated. Create new secrets with `clj -M:dev generate-secrets` 32 | 33 | # Used to encrypt session cookies. 34 | COOKIE_SECRET={{ new-secret 16 }} 35 | # Used to encrypt email sign-in links. 36 | JWT_SECRET={{ new-secret 32 }} 37 | -------------------------------------------------------------------------------- /xtdb2-starter/resources/public/img/glider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobobryant/biff/a075fae22993451aab1d3f83af7ac793f5dc67a5/xtdb2-starter/resources/public/img/glider.png -------------------------------------------------------------------------------- /xtdb2-starter/resources/public/js/main.js: -------------------------------------------------------------------------------- 1 | // When plain htmx isn't quite enough, you can stick some custom JS here. 2 | -------------------------------------------------------------------------------- /xtdb2-starter/resources/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './src/**/*', 4 | './resources/**/*', 5 | ], 6 | theme: { 7 | extend: {}, 8 | }, 9 | plugins: [ 10 | require('@tailwindcss/forms'), 11 | ], 12 | } 13 | -------------------------------------------------------------------------------- /xtdb2-starter/resources/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | p { 7 | @apply mb-3; 8 | } 9 | 10 | ul { 11 | @apply list-disc; 12 | } 13 | 14 | ol { 15 | @apply list-decimal; 16 | } 17 | 18 | ul, ol { 19 | @apply my-3 pl-10; 20 | } 21 | } 22 | 23 | @layer components { 24 | .btn { 25 | @apply bg-blue-500 hover:bg-blue-700 text-center py-2 px-4 rounded disabled:opacity-50 text-white; 26 | } 27 | } 28 | 29 | @layer utilities { 30 | .link { 31 | @apply text-blue-600 hover:underline; 32 | } 33 | } 34 | 35 | .grecaptcha-badge { visibility: hidden; } 36 | -------------------------------------------------------------------------------- /xtdb2-starter/server-setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -x 3 | set -e 4 | 5 | BIFF_PROFILE=${1:-prod} 6 | CLJ_VERSION=1.12.2.1565 7 | TRENCH_VERSION=0.4.0 8 | if [ $(uname -m) = "aarch64" ]; then 9 | ARCH=arm64 10 | else 11 | ARCH=amd64 12 | fi 13 | TRENCH_FILE=trenchman_${TRENCH_VERSION}_linux_${ARCH}.tar.gz 14 | 15 | echo waiting for apt to finish 16 | while (ps aux | grep [a]pt); do 17 | sleep 3 18 | done 19 | 20 | # Dependencies 21 | apt-get update 22 | apt-get upgrade 23 | apt-get -y install default-jre rlwrap ufw git snapd 24 | bash < <(curl -s https://download.clojure.org/install/linux-install-$CLJ_VERSION.sh) 25 | bash < <(curl -s https://raw.githubusercontent.com/babashka/babashka/master/install) 26 | curl -sSLf https://github.com/athos/trenchman/releases/download/v$TRENCH_VERSION/$TRENCH_FILE | tar zxvfC - /usr/local/bin trench 27 | 28 | # Non-root user 29 | useradd -m app 30 | mkdir -m 700 -p /home/app/.ssh 31 | cp /root/.ssh/authorized_keys /home/app/.ssh 32 | chown -R app:app /home/app/.ssh 33 | 34 | # Git deploys - only used if you don't have rsync on your machine 35 | set_up_app () { 36 | cd 37 | mkdir repo.git 38 | cd repo.git 39 | git init --bare 40 | cat > hooks/post-receive << EOD 41 | #!/usr/bin/env bash 42 | git --work-tree=/home/app --git-dir=/home/app/repo.git checkout -f 43 | EOD 44 | chmod +x hooks/post-receive 45 | } 46 | sudo -u app bash -c "$(declare -f set_up_app); set_up_app" 47 | 48 | # Systemd service 49 | cat > /etc/systemd/system/app.service << EOD 50 | [Unit] 51 | Description=app 52 | StartLimitIntervalSec=500 53 | StartLimitBurst=5 54 | 55 | [Service] 56 | User=app 57 | Restart=on-failure 58 | RestartSec=5s 59 | Environment="BIFF_PROFILE=$BIFF_PROFILE" 60 | WorkingDirectory=/home/app 61 | ExecStart=/bin/sh -c "mkdir -p target/resources; clj -M:prod" 62 | 63 | [Install] 64 | WantedBy=multi-user.target 65 | EOD 66 | systemctl enable app 67 | cat > /etc/systemd/journald.conf << EOD 68 | [Journal] 69 | Storage=persistent 70 | EOD 71 | systemctl restart systemd-journald 72 | cat > /etc/sudoers.d/restart-app << EOD 73 | app ALL= NOPASSWD: /bin/systemctl reset-failed app.service 74 | app ALL= NOPASSWD: /bin/systemctl restart app 75 | app ALL= NOPASSWD: /usr/bin/systemctl reset-failed app.service 76 | app ALL= NOPASSWD: /usr/bin/systemctl restart app 77 | EOD 78 | chmod 440 /etc/sudoers.d/restart-app 79 | 80 | # Firewall 81 | ufw allow OpenSSH 82 | ufw --force enable 83 | 84 | # Web dependencies 85 | apt-get -y install nginx 86 | snap install core 87 | snap refresh core 88 | snap install --classic certbot 89 | ln -s /snap/bin/certbot /usr/bin/certbot 90 | 91 | # Nginx 92 | rm /etc/nginx/sites-enabled/default 93 | cat > /etc/nginx/sites-available/app << EOD 94 | server { 95 | listen 80 default_server; 96 | listen [::]:80 default_server; 97 | server_name _; 98 | gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; 99 | root /home/app/target/resources/public; 100 | location / { 101 | try_files \$uri \$uri/index.html @resources; 102 | } 103 | location @resources { 104 | root /home/app/resources/public; 105 | try_files \$uri \$uri/index.html @proxy; 106 | } 107 | location @proxy { 108 | proxy_pass http://localhost:8080; 109 | proxy_http_version 1.1; 110 | proxy_set_header Host \$host; 111 | proxy_set_header Upgrade \$http_upgrade; 112 | proxy_set_header Connection "Upgrade"; 113 | proxy_set_header X-Real-IP \$remote_addr; 114 | } 115 | } 116 | EOD 117 | ln -s /etc/nginx/sites-{available,enabled}/app 118 | 119 | # Firewall 120 | ufw allow "Nginx Full" 121 | 122 | # Let's encrypt 123 | certbot --nginx 124 | 125 | # App dependencies 126 | # If you need to install additional packages for your app, you can do it here. 127 | # apt-get -y install ... 128 | -------------------------------------------------------------------------------- /xtdb2-starter/src/com/example.clj: -------------------------------------------------------------------------------- 1 | (ns com.example 2 | (:require [com.biffweb :as biff] 3 | [com.biffweb.experimental :as biffx] 4 | [com.biffweb.experimental.auth :as biff-auth] 5 | [com.example.email :as email] 6 | [com.example.app :as app] 7 | [com.example.home :as home] 8 | [com.example.middleware :as mid] 9 | [com.example.ui :as ui] 10 | [com.example.worker :as worker] 11 | [com.example.schema :as schema] 12 | [clojure.test :as test] 13 | [clojure.tools.logging :as log] 14 | [clojure.tools.namespace.repl :as tn-repl] 15 | [malli.core :as malc] 16 | [malli.registry :as malr] 17 | [nrepl.cmdline :as nrepl-cmd]) 18 | (:gen-class)) 19 | 20 | (def modules 21 | [app/module 22 | (biff-auth/module {}) 23 | home/module 24 | schema/module 25 | worker/module]) 26 | 27 | (def routes [["" {:middleware [mid/wrap-site-defaults]} 28 | (keep :routes modules)] 29 | ["" {:middleware [mid/wrap-api-defaults]} 30 | (keep :api-routes modules)]]) 31 | 32 | (def handler (-> (biff/reitit-handler {:routes routes}) 33 | mid/wrap-base-defaults)) 34 | 35 | (def static-pages (apply biff/safe-merge (map :static modules))) 36 | 37 | (defn generate-assets! [_ctx] 38 | (biff/export-rum static-pages "target/resources/public") 39 | (biff/delete-old-files {:dir "target/resources/public" 40 | :exts [".html"]})) 41 | 42 | (defn on-save [ctx] 43 | (biff/add-libs ctx) 44 | (biff/eval-files! ctx) 45 | (generate-assets! ctx) 46 | (test/run-all-tests #"com.example.*-test")) 47 | 48 | (def malli-opts 49 | {:registry (malr/composite-registry 50 | malc/default-registry 51 | (apply biff/safe-merge (keep :schema modules)))}) 52 | 53 | (def initial-system 54 | {:biff/modules #'modules 55 | :biff/send-email #'email/send-email 56 | :biff/handler #'handler 57 | :biff/malli-opts #'malli-opts 58 | :biff.beholder/on-save #'on-save 59 | :biff.middleware/on-error #'ui/on-error 60 | :biff.xtdb.listener/tables ["user" "msg"] 61 | :com.example/chat-clients (atom #{})}) 62 | 63 | (defonce system (atom {})) 64 | 65 | (def components 66 | [biff/use-aero-config 67 | biffx/use-xtdb2 68 | biff/use-queues 69 | biffx/use-xtdb2-listener 70 | biff/use-htmx-refresh 71 | biff/use-jetty 72 | biff/use-chime 73 | biff/use-beholder]) 74 | 75 | (defn start [] 76 | (let [new-system (reduce (fn [system component] 77 | (log/info "starting:" (str component)) 78 | (component system)) 79 | initial-system 80 | components)] 81 | (reset! system new-system) 82 | (generate-assets! new-system) 83 | (log/info "System started.") 84 | (log/info "Go to" (:biff/base-url new-system)) 85 | new-system)) 86 | 87 | (defn -main [] 88 | (let [{:keys [biff.nrepl/args]} (start)] 89 | (apply nrepl-cmd/-main args))) 90 | 91 | (defn refresh [] 92 | (doseq [f (:biff/stop @system)] 93 | (log/info "stopping:" (str f)) 94 | (f)) 95 | (tn-repl/refresh :after `start) 96 | :done) 97 | -------------------------------------------------------------------------------- /xtdb2-starter/src/com/example/app.clj: -------------------------------------------------------------------------------- 1 | (ns com.example.app 2 | (:require 3 | [cheshire.core :as cheshire] 4 | [com.biffweb :as biff] 5 | [com.biffweb.experimental :as biffx] 6 | [com.example.middleware :as mid] 7 | [com.example.settings :as settings] 8 | [com.example.ui :as ui] 9 | [ring.websocket :as ws] 10 | [rum.core :as rum] 11 | [xtdb.api :as xt]) 12 | (:import 13 | [java.time Instant])) 14 | 15 | (defn set-foo [{:keys [session params] :as ctx}] 16 | (biffx/submit-tx ctx 17 | [[:patch-docs :user {:xt/id (:uid session) :foo (:foo params)}]]) 18 | {:status 303 19 | :headers {"location" "/app"}}) 20 | 21 | (defn bar-form [{:keys [value]}] 22 | (biff/form 23 | {:hx-post "/app/set-bar" 24 | :hx-swap "outerHTML"} 25 | [:label.block {:for "bar"} "Bar: " 26 | [:span.font-mono (pr-str value)]] 27 | [:.h-1] 28 | [:.flex 29 | [:input.w-full#bar {:type "text" :name "bar" :value value}] 30 | [:.w-3] 31 | [:button.btn {:type "submit"} "Update"]] 32 | [:.h-1] 33 | [:.text-sm.text-gray-600 34 | "This demonstrates updating a value with HTMX."])) 35 | 36 | (defn set-bar [{:keys [session params] :as ctx}] 37 | (biffx/submit-tx ctx 38 | [[:patch-docs :user {:xt/id (:uid session) :bar (:bar params)}]]) 39 | (biff/render (bar-form {:value (:bar params)}))) 40 | 41 | (defn message [{:keys [content sent-at]}] 42 | [:.mt-3 {:_ "init send newMessage to #message-header"} 43 | [:.text-gray-600 (biff/format-date (java.util.Date/from (.toInstant sent-at)) "dd MMM yyyy HH:mm:ss")] 44 | [:div content]]) 45 | 46 | (defn notify-clients [{:keys [com.example/chat-clients]} record] 47 | (when (= "msg" (:biff.xtdb/table record)) 48 | (let [html (rum/render-static-markup 49 | [:div#messages {:hx-swap-oob "afterbegin"} 50 | (message record)])] 51 | (doseq [ws @chat-clients] 52 | (ws/send ws html))))) 53 | 54 | (defn send-message [{:keys [session] :as ctx} {:keys [text]}] 55 | (let [{:keys [content]} (cheshire/parse-string text true)] 56 | (biffx/submit-tx ctx 57 | [[:put-docs :msg {:xt/id (random-uuid) 58 | :user (:uid session) 59 | :content content 60 | :sent-at (Instant/now)}]]))) 61 | 62 | (defn chat [{:keys [biff/node]}] 63 | (let [messages (xt/q node 64 | ["select content, sent_at from msg where sent_at >= ?" 65 | (.minusSeconds (Instant/now) (* 60 10))])] 66 | [:div {:hx-ext "ws" :ws-connect "/app/chat"} 67 | [:form.mb-0 {:ws-send true 68 | :_ "on submit set value of #message to ''"} 69 | [:label.block {:for "message"} "Write a message"] 70 | [:.h-1] 71 | [:textarea.w-full#message {:name "content"}] 72 | [:.h-1] 73 | [:.text-sm.text-gray-600 74 | "Sign in with an incognito window to have a conversation with yourself."] 75 | [:.h-2] 76 | [:div [:button.btn {:type "submit"} "Send message"]]] 77 | [:.h-6] 78 | [:div#message-header 79 | {:_ "on newMessage put 'Messages sent in the past 10 minutes:' into me"} 80 | (if (empty? messages) 81 | "No messages yet." 82 | "Messages sent in the past 10 minutes:")] 83 | [:div#messages 84 | (map message (sort-by :sent-at #(compare %2 %1) messages))]])) 85 | 86 | (defn app [{:keys [biff/node session] :as ctx}] 87 | (let [[{:keys [email foo bar]}] (xt/q node ["select email, foo, bar from user where _id = ?" 88 | (:uid session)])] 89 | (ui/page 90 | {} 91 | [:div "Signed in as " email ". " 92 | (biff/form 93 | {:action "/auth/signout" 94 | :class "inline"} 95 | [:button.text-blue-500.hover:text-blue-800 {:type "submit"} 96 | "Sign out"]) 97 | "."] 98 | [:.h-6] 99 | (biff/form 100 | {:action "/app/set-foo"} 101 | [:label.block {:for "foo"} "Foo: " 102 | [:span.font-mono (pr-str foo)]] 103 | [:.h-1] 104 | [:.flex 105 | [:input.w-full#foo {:type "text" :name "foo" :value foo}] 106 | [:.w-3] 107 | [:button.btn {:type "submit"} "Update"]] 108 | [:.h-1] 109 | [:.text-sm.text-gray-600 110 | "This demonstrates updating a value with a plain old form."]) 111 | [:.h-6] 112 | (bar-form {:value bar}) 113 | [:.h-6] 114 | (chat ctx)))) 115 | 116 | (defn ws-handler [{:keys [com.example/chat-clients] :as ctx}] 117 | {:status 101 118 | :headers {"upgrade" "websocket" 119 | "connection" "upgrade"} 120 | ::ws/listener {:on-open (fn [ws] 121 | (swap! chat-clients conj ws)) 122 | :on-message (fn [ws text-message] 123 | (send-message ctx {:ws ws :text text-message})) 124 | :on-close (fn [ws status-code reason] 125 | (swap! chat-clients disj ws))}}) 126 | 127 | (def about-page 128 | (ui/page 129 | {:base/title (str "About " settings/app-name)} 130 | [:p "This app was made with " 131 | [:a.link {:href "https://biffweb.com"} "Biff"] "."])) 132 | 133 | (defn echo [{:keys [params]}] 134 | {:status 200 135 | :headers {"content-type" "application/json"} 136 | :body params}) 137 | 138 | (def module 139 | {:static {"/about/" about-page} 140 | :routes ["/app" {:middleware [mid/wrap-signed-in]} 141 | ["" {:get app}] 142 | ["/set-foo" {:post set-foo}] 143 | ["/set-bar" {:post set-bar}] 144 | ["/chat" {:get ws-handler}]] 145 | :api-routes [["/api/echo" {:post echo}]] 146 | :on-tx notify-clients}) 147 | -------------------------------------------------------------------------------- /xtdb2-starter/src/com/example/email.clj: -------------------------------------------------------------------------------- 1 | (ns com.example.email 2 | (:require [clj-http.client :as http] 3 | [com.example.settings :as settings] 4 | [clojure.tools.logging :as log] 5 | [rum.core :as rum])) 6 | 7 | (defn signin-link [{:keys [to url user-exists]}] 8 | (let [[subject action] (if user-exists 9 | [(str "Sign in to " settings/app-name) "sign in"] 10 | [(str "Sign up for " settings/app-name) "sign up"])] 11 | {:to [{:email to}] 12 | :subject subject 13 | :html (rum/render-static-markup 14 | [:html 15 | [:body 16 | [:p "We received a request to " action " to " settings/app-name 17 | " using this email address. Click this link to " action ":"] 18 | [:p [:a {:href url :target "_blank"} "Click here to " action "."]] 19 | [:p "This link will expire in one hour. " 20 | "If you did not request this link, you can ignore this email."]]]) 21 | :text (str "We received a request to " action " to " settings/app-name 22 | " using this email address. Click this link to " action ":\n" 23 | "\n" 24 | url "\n" 25 | "\n" 26 | "This link will expire in one hour. If you did not request this link, " 27 | "you can ignore this email.")})) 28 | 29 | (defn signin-code [{:keys [to code user-exists]}] 30 | (let [[subject action] (if user-exists 31 | [(str "Sign in to " settings/app-name) "sign in"] 32 | [(str "Sign up for " settings/app-name) "sign up"])] 33 | {:to [{:email to}] 34 | :subject subject 35 | :html (rum/render-static-markup 36 | [:html 37 | [:body 38 | [:p "We received a request to " action " to " settings/app-name 39 | " using this email address. Enter the following code to " action ":"] 40 | [:p {:style {:font-size "2rem"}} code] 41 | [:p 42 | "This code will expire in three minutes. " 43 | "If you did not request this code, you can ignore this email."]]]) 44 | :text (str "We received a request to " action " to " settings/app-name 45 | " using this email address. Enter the following code to " action ":\n" 46 | "\n" 47 | code "\n" 48 | "\n" 49 | "This code will expire in three minutes. If you did not request this code, " 50 | "you can ignore this email.")})) 51 | 52 | (defn template [k opts] 53 | ((case k 54 | :signin-link signin-link 55 | :signin-code signin-code) 56 | opts)) 57 | 58 | (defn send-mailersend [{:keys [biff/secret mailersend/from mailersend/reply-to]} form-params] 59 | (let [result (http/post "https://api.mailersend.com/v1/email" 60 | {:oauth-token (secret :mailersend/api-key) 61 | :content-type :json 62 | :throw-exceptions false 63 | :as :json 64 | :form-params (merge {:from {:email from :name settings/app-name} 65 | :reply_to {:email reply-to :name settings/app-name}} 66 | form-params)}) 67 | success (< (:status result) 400)] 68 | (when-not success 69 | (log/error (:body result))) 70 | success)) 71 | 72 | (defn send-console [_ctx form-params] 73 | (println "TO:" (:to form-params)) 74 | (println "SUBJECT:" (:subject form-params)) 75 | (println) 76 | (println (:text form-params)) 77 | (println) 78 | (println "To send emails instead of printing them to the console, add your" 79 | "API keys for MailerSend and Recaptcha to config.env.") 80 | true) 81 | 82 | (defn send-email [{:keys [biff/secret recaptcha/site-key] :as ctx} opts] 83 | (let [form-params (if-some [template-key (:template opts)] 84 | (template template-key opts) 85 | opts)] 86 | (if (every? some? [(secret :mailersend/api-key) 87 | (secret :recaptcha/secret-key) 88 | site-key]) 89 | (send-mailersend ctx form-params) 90 | (send-console ctx form-params)))) 91 | -------------------------------------------------------------------------------- /xtdb2-starter/src/com/example/home.clj: -------------------------------------------------------------------------------- 1 | (ns com.example.home 2 | (:require [com.biffweb :as biff] 3 | [com.example.middleware :as mid] 4 | [com.example.ui :as ui] 5 | [com.example.settings :as settings])) 6 | 7 | (def email-disabled-notice 8 | [:.text-sm.mt-3.bg-blue-100.rounded.p-2 9 | "Until you add API keys for MailerSend and reCAPTCHA, we'll print your sign-up " 10 | "link to the console. See config.edn."]) 11 | 12 | (defn home-page [{:keys [recaptcha/site-key params] :as ctx}] 13 | (ui/page 14 | (assoc ctx ::ui/recaptcha true) 15 | (biff/form 16 | {:action "/auth/send-link" 17 | :id "signup" 18 | :hidden {:on-error "/"}} 19 | (biff/recaptcha-callback "submitSignup" "signup") 20 | [:h2.text-2xl.font-bold (str "Sign up for " settings/app-name)] 21 | [:.h-3] 22 | [:.flex 23 | [:input#email {:name "email" 24 | :type "email" 25 | :autocomplete "email" 26 | :placeholder "Enter your email address"}] 27 | [:.w-3] 28 | [:button.btn.g-recaptcha 29 | (merge (when site-key 30 | {:data-sitekey site-key 31 | :data-callback "submitSignup"}) 32 | {:type "submit"}) 33 | "Sign up"]] 34 | (when-some [error (:error params)] 35 | [:<> 36 | [:.h-1] 37 | [:.text-sm.text-red-600 38 | (case error 39 | "recaptcha" (str "You failed the recaptcha test. Try again, " 40 | "and make sure you aren't blocking scripts from Google.") 41 | "invalid-email" "Invalid email. Try again with a different address." 42 | "send-failed" (str "We weren't able to send an email to that address. " 43 | "If the problem persists, try another address.") 44 | "There was an error.")]]) 45 | [:.h-1] 46 | [:.text-sm "Already have an account? " [:a.link {:href "/signin"} "Sign in"] "."] 47 | [:.h-3] 48 | biff/recaptcha-disclosure 49 | email-disabled-notice))) 50 | 51 | (defn link-sent [{:keys [params] :as ctx}] 52 | (ui/page 53 | ctx 54 | [:h2.text-xl.font-bold "Check your inbox"] 55 | [:p "We've sent a sign-in link to " [:span.font-bold (:email params)] "."])) 56 | 57 | (defn verify-email-page [{:keys [params] :as ctx}] 58 | (ui/page 59 | ctx 60 | [:h2.text-2xl.font-bold (str "Sign up for " settings/app-name)] 61 | [:.h-3] 62 | (biff/form 63 | {:action "/auth/verify-link" 64 | :hidden {:token (:token params)}} 65 | [:div [:label {:for "email"} 66 | "It looks like you opened this link on a different device or browser than the one " 67 | "you signed up on. For verification, please enter the email you signed up with:"]] 68 | [:.h-3] 69 | [:.flex 70 | [:input#email {:name "email" :type "email" 71 | :placeholder "Enter your email address"}] 72 | [:.w-3] 73 | [:button.btn {:type "submit"} 74 | "Sign in"]]) 75 | (when-some [error (:error params)] 76 | [:<> 77 | [:.h-1] 78 | [:.text-sm.text-red-600 79 | (case error 80 | "incorrect-email" "Incorrect email address. Try again." 81 | "There was an error.")]]))) 82 | 83 | (defn signin-page [{:keys [recaptcha/site-key params] :as ctx}] 84 | (ui/page 85 | (assoc ctx ::ui/recaptcha true) 86 | (biff/form 87 | {:action "/auth/send-code" 88 | :id "signin" 89 | :hidden {:on-error "/signin"}} 90 | (biff/recaptcha-callback "submitSignin" "signin") 91 | [:h2.text-2xl.font-bold "Sign in to " settings/app-name] 92 | [:.h-3] 93 | [:.flex 94 | [:input#email {:name "email" 95 | :type "email" 96 | :autocomplete "email" 97 | :placeholder "Enter your email address"}] 98 | [:.w-3] 99 | [:button.btn.g-recaptcha 100 | (merge (when site-key 101 | {:data-sitekey site-key 102 | :data-callback "submitSignin"}) 103 | {:type "submit"}) 104 | "Sign in"]] 105 | (when-some [error (:error params)] 106 | [:<> 107 | [:.h-1] 108 | [:.text-sm.text-red-600 109 | (case error 110 | "recaptcha" (str "You failed the recaptcha test. Try again, " 111 | "and make sure you aren't blocking scripts from Google.") 112 | "invalid-email" "Invalid email. Try again with a different address." 113 | "send-failed" (str "We weren't able to send an email to that address. " 114 | "If the problem persists, try another address.") 115 | "invalid-link" "Invalid or expired link. Sign in to get a new link." 116 | "not-signed-in" "You must be signed in to view that page." 117 | "There was an error.")]]) 118 | [:.h-1] 119 | [:.text-sm "Don't have an account yet? " [:a.link {:href "/"} "Sign up"] "."] 120 | [:.h-3] 121 | biff/recaptcha-disclosure 122 | email-disabled-notice))) 123 | 124 | (defn enter-code-page [{:keys [recaptcha/site-key params] :as ctx}] 125 | (ui/page 126 | (assoc ctx ::ui/recaptcha true) 127 | (biff/form 128 | {:action "/auth/verify-code" 129 | :id "code-form" 130 | :hidden {:email (:email params)}} 131 | (biff/recaptcha-callback "submitCode" "code-form") 132 | [:div [:label {:for "code"} "Enter the 6-digit code that we sent to " 133 | [:span.font-bold (:email params)]]] 134 | [:.h-1] 135 | [:.flex 136 | [:input#code {:name "code" :type "text"}] 137 | [:.w-3] 138 | [:button.btn.g-recaptcha 139 | (merge (when site-key 140 | {:data-sitekey site-key 141 | :data-callback "submitCode"}) 142 | {:type "submit"}) 143 | "Sign in"]]) 144 | (when-some [error (:error params)] 145 | [:<> 146 | [:.h-1] 147 | [:.text-sm.text-red-600 148 | (case error 149 | "invalid-code" "Invalid code." 150 | "There was an error.")]]) 151 | [:.h-3] 152 | (biff/form 153 | {:action "/auth/send-code" 154 | :id "signin" 155 | :hidden {:email (:email params) 156 | :on-error "/signin"}} 157 | (biff/recaptcha-callback "submitSignin" "signin") 158 | [:button.link.g-recaptcha 159 | (merge (when site-key 160 | {:data-sitekey site-key 161 | :data-callback "submitSignin"}) 162 | {:type "submit"}) 163 | "Send another code"]))) 164 | 165 | (def module 166 | {:routes [["" {:middleware [mid/wrap-redirect-signed-in]} 167 | ["/" {:get home-page}]] 168 | ["/link-sent" {:get link-sent}] 169 | ["/verify-link" {:get verify-email-page}] 170 | ["/signin" {:get signin-page}] 171 | ["/verify-code" {:get enter-code-page}]]}) 172 | -------------------------------------------------------------------------------- /xtdb2-starter/src/com/example/middleware.clj: -------------------------------------------------------------------------------- 1 | (ns com.example.middleware 2 | (:require [com.biffweb :as biff] 3 | [muuntaja.middleware :as muuntaja] 4 | [ring.middleware.anti-forgery :as csrf] 5 | [ring.middleware.defaults :as rd])) 6 | 7 | (defn wrap-redirect-signed-in [handler] 8 | (fn [{:keys [session] :as ctx}] 9 | (if (some? (:uid session)) 10 | {:status 303 11 | :headers {"location" "/app"}} 12 | (handler ctx)))) 13 | 14 | (defn wrap-signed-in [handler] 15 | (fn [{:keys [session] :as ctx}] 16 | (if (some? (:uid session)) 17 | (handler ctx) 18 | {:status 303 19 | :headers {"location" "/signin?error=not-signed-in"}}))) 20 | 21 | ;; Stick this function somewhere in your middleware stack below if you want to 22 | ;; inspect what things look like before/after certain middleware fns run. 23 | (defn wrap-debug [handler] 24 | (fn [ctx] 25 | (let [response (handler ctx)] 26 | (println "REQUEST") 27 | (biff/pprint ctx) 28 | (def ctx* ctx) 29 | (println "RESPONSE") 30 | (biff/pprint response) 31 | (def response* response) 32 | response))) 33 | 34 | (defn wrap-site-defaults [handler] 35 | (-> handler 36 | biff/wrap-render-rum 37 | biff/wrap-anti-forgery-websockets 38 | csrf/wrap-anti-forgery 39 | biff/wrap-session 40 | muuntaja/wrap-params 41 | muuntaja/wrap-format 42 | (rd/wrap-defaults (-> rd/site-defaults 43 | (assoc-in [:security :anti-forgery] false) 44 | (assoc-in [:responses :absolute-redirects] true) 45 | (assoc :session false) 46 | (assoc :static false))))) 47 | 48 | (defn wrap-api-defaults [handler] 49 | (-> handler 50 | muuntaja/wrap-params 51 | muuntaja/wrap-format 52 | (rd/wrap-defaults rd/api-defaults))) 53 | 54 | (defn wrap-base-defaults [handler] 55 | (-> handler 56 | biff/wrap-https-scheme 57 | biff/wrap-resource 58 | biff/wrap-internal-error 59 | biff/wrap-ssl 60 | biff/wrap-log-requests)) 61 | -------------------------------------------------------------------------------- /xtdb2-starter/src/com/example/schema.clj: -------------------------------------------------------------------------------- 1 | (ns com.example.schema) 2 | 3 | (def ? {:optional true}) 4 | 5 | (def schema 6 | {::string [:string {:max 1000}] 7 | 8 | :user [:map {:closed true} 9 | [:xt/id :uuid] 10 | [:email ::string] 11 | [:joined-at inst?] 12 | [:foo ? ::string] 13 | [:bar ? ::string]] 14 | 15 | :msg [:map {:closed true} 16 | [:xt/id :uuid] 17 | [:user :uuid] 18 | [:content [:string {:max 10000}]] 19 | [:sent-at inst?]]}) 20 | 21 | (def module 22 | {:schema schema}) 23 | -------------------------------------------------------------------------------- /xtdb2-starter/src/com/example/settings.clj: -------------------------------------------------------------------------------- 1 | (ns com.example.settings) 2 | 3 | (def app-name "My Application") 4 | -------------------------------------------------------------------------------- /xtdb2-starter/src/com/example/ui.clj: -------------------------------------------------------------------------------- 1 | (ns com.example.ui 2 | (:require [cheshire.core :as cheshire] 3 | [clojure.java.io :as io] 4 | [com.example.settings :as settings] 5 | [com.biffweb :as biff] 6 | [ring.middleware.anti-forgery :as csrf] 7 | [ring.util.response :as ring-response] 8 | [rum.core :as rum])) 9 | 10 | (defn static-path [path] 11 | (if-some [last-modified (some-> (io/resource (str "public" path)) 12 | ring-response/resource-data 13 | :last-modified 14 | (.getTime))] 15 | (str path "?t=" last-modified) 16 | path)) 17 | 18 | (defn base [{:keys [::recaptcha] :as ctx} & body] 19 | (apply 20 | biff/base-html 21 | (-> ctx 22 | (merge #:base{:title settings/app-name 23 | :lang "en-US" 24 | :icon "/img/glider.png" 25 | :description (str settings/app-name " Description") 26 | :image "https://clojure.org/images/clojure-logo-120b.png"}) 27 | (update :base/head (fn [head] 28 | (concat [[:link {:rel "stylesheet" :href (static-path "/css/main.css")}] 29 | [:script {:src (static-path "/js/main.js")}] 30 | [:script {:src "https://unpkg.com/htmx.org@2.0.7"}] 31 | [:script {:src "https://unpkg.com/htmx-ext-ws@2.0.2/ws.js"}] 32 | [:script {:src "https://unpkg.com/hyperscript.org@0.9.14"}] 33 | (when recaptcha 34 | [:script {:src "https://www.google.com/recaptcha/api.js" 35 | :async "async" :defer "defer"}])] 36 | head)))) 37 | body)) 38 | 39 | (defn page [ctx & body] 40 | (base 41 | ctx 42 | [:.flex-grow] 43 | [:.p-3.mx-auto.max-w-screen-sm.w-full 44 | (when (bound? #'csrf/*anti-forgery-token*) 45 | {:hx-headers (cheshire/generate-string 46 | {:x-csrf-token csrf/*anti-forgery-token*})}) 47 | body] 48 | [:.flex-grow] 49 | [:.flex-grow])) 50 | 51 | (defn on-error [{:keys [status ex] :as ctx}] 52 | {:status status 53 | :headers {"content-type" "text/html"} 54 | :body (rum/render-static-markup 55 | (page 56 | ctx 57 | [:h1.text-lg.font-bold 58 | (if (= status 404) 59 | "Page not found." 60 | "Something went wrong.")]))}) 61 | -------------------------------------------------------------------------------- /xtdb2-starter/src/com/example/worker.clj: -------------------------------------------------------------------------------- 1 | (ns com.example.worker 2 | (:require [clojure.tools.logging :as log] 3 | [com.biffweb :as biff :refer [q]] 4 | [xtdb.api :as xt])) 5 | 6 | (defn every-n-minutes [n] 7 | (iterate #(biff/add-seconds % (* 60 n)) (java.util.Date.))) 8 | 9 | (defn print-usage [{:keys [biff/node]}] 10 | ;; For a real app, you can have this run once per day and send you the output 11 | ;; in an email. 12 | (let [[{n-users :cnt}] (xt/q node "select count(*) as cnt from users")] 13 | (log/info "There are" n-users "users."))) 14 | 15 | (defn alert-new-user [{:keys [biff/node]} record] 16 | (when (and (= (:biff.xtdb/table record) "user") 17 | (-> (xt/q node 18 | ["select count(*) as cnt from user where _id = ?" (:xt/id record)] 19 | {:snapshot-time (.. (:xt/system-from record) 20 | (toInstant) 21 | (minusNanos 1))}) 22 | first 23 | :cnt 24 | (= 0))) 25 | ;; You could send this as an email instead of printing. 26 | (log/info "WOAH there's a new user: " (pr-str record)))) 27 | 28 | (defn echo-consumer [{:keys [biff/job] :as ctx}] 29 | (prn :echo job) 30 | (when-some [callback (:biff/callback job)] 31 | (callback job))) 32 | 33 | (def module 34 | {:tasks [{:task #'print-usage 35 | :schedule #(every-n-minutes 5)}] 36 | :on-tx alert-new-user 37 | :queues [{:id :echo 38 | :consumer #'echo-consumer}]}) 39 | -------------------------------------------------------------------------------- /xtdb2-starter/test/com/example_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.example-test 2 | (:require [cheshire.core :as cheshire] 3 | [clojure.string :as str] 4 | [clojure.test :refer [deftest is]] 5 | [com.biffweb :as biff :refer [test-xtdb-node]] 6 | [com.example :as main] 7 | [com.example.app :as app] 8 | [malli.generator :as mg] 9 | [rum.core :as rum] 10 | [xtdb.api :as xt])) 11 | 12 | (deftest example-test 13 | (is (= 4 (+ 2 2)))) 14 | 15 | ;; TODO 16 | 17 | ;; (defn get-context [node] 18 | ;; {:biff.xtdb/node node 19 | ;; :biff/db (xt/db node) 20 | ;; :biff/malli-opts #'main/malli-opts}) 21 | ;; 22 | ;; (deftest send-message-test 23 | ;; (with-open [node (test-xtdb-node [])] 24 | ;; (let [message (mg/generate :string) 25 | ;; user (mg/generate :user main/malli-opts) 26 | ;; ctx (assoc (get-context node) :session {:uid (:xt/id user)}) 27 | ;; _ (app/send-message ctx {:text (cheshire/generate-string {:text message})}) 28 | ;; db (xt/db node) ; get a fresh db value so it contains any transactions 29 | ;; ; that send-message submitted. 30 | ;; doc (biff/lookup db :msg/text message)] 31 | ;; (is (some? doc)) 32 | ;; (is (= (:msg/user doc) (:xt/id user)))))) 33 | ;; 34 | ;; (deftest chat-test 35 | ;; (let [n-messages (+ 3 (rand-int 10)) 36 | ;; now (java.util.Date.) 37 | ;; messages (for [doc (mg/sample :msg (assoc main/malli-opts :size n-messages))] 38 | ;; (assoc doc :msg/sent-at now))] 39 | ;; (with-open [node (test-xtdb-node messages)] 40 | ;; (let [response (app/chat {:biff/db (xt/db node)}) 41 | ;; html (rum/render-html response)] 42 | ;; (is (str/includes? html "Messages sent in the past 10 minutes:")) 43 | ;; (is (not (str/includes? html "No messages yet."))) 44 | ;; ;; If you add Jsoup to your dependencies, you can use DOM selectors instead of just regexes: 45 | ;; ;(is (= n-messages (count (.select (Jsoup/parse html) "#messages > *")))) 46 | ;; (is (= n-messages (count (re-seq #"init send newMessage to #message-header" html)))) 47 | ;; (is (every? #(str/includes? html (:msg/text %)) messages)))))) 48 | --------------------------------------------------------------------------------