From c652b828e84e55fc594e9806f986393b497e0e78 Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Wed, 4 Jun 2025 10:47:38 +0200 Subject: [PATCH] init biff project --- .dockerignore | 3 + .gitignore | 16 +++ Dockerfile | 46 +++++++++ README.md | 7 ++ cljfmt-indents.edn | 1 + deps.edn | 23 +++++ dev/repl.clj | 86 ++++++++++++++++ dev/tasks.clj | 14 +++ resources/config.edn | 64 ++++++++++++ resources/config.template.env | 34 +++++++ resources/fixtures.edn | 10 ++ resources/public/img/glider.png | Bin 0 -> 1195 bytes resources/public/js/main.js | 1 + resources/tailwind.config.js | 12 +++ resources/tailwind.css | 35 +++++++ server-setup.sh | 127 +++++++++++++++++++++++ src/snippets.clj | 94 +++++++++++++++++ src/snippets/app.clj | 153 ++++++++++++++++++++++++++++ src/snippets/email.clj | 92 +++++++++++++++++ src/snippets/home.clj | 174 ++++++++++++++++++++++++++++++++ src/snippets/middleware.clj | 60 +++++++++++ src/snippets/schema.clj | 20 ++++ src/snippets/settings.clj | 3 + src/snippets/ui.clj | 60 +++++++++++ src/snippets/worker.clj | 40 ++++++++ test/snippets_test.clj | 45 +++++++++ 26 files changed, 1220 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cljfmt-indents.edn create mode 100644 deps.edn create mode 100644 dev/repl.clj create mode 100644 dev/tasks.clj create mode 100644 resources/config.edn create mode 100644 resources/config.template.env create mode 100644 resources/fixtures.edn create mode 100644 resources/public/img/glider.png create mode 100644 resources/public/js/main.js create mode 100644 resources/tailwind.config.js create mode 100644 resources/tailwind.css create mode 100644 server-setup.sh create mode 100644 src/snippets.clj create mode 100644 src/snippets/app.clj create mode 100644 src/snippets/email.clj create mode 100644 src/snippets/home.clj create mode 100644 src/snippets/middleware.clj create mode 100644 src/snippets/schema.clj create mode 100644 src/snippets/settings.clj create mode 100644 src/snippets/ui.clj create mode 100644 src/snippets/worker.clj create mode 100644 test/snippets_test.clj diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cb00074 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +node_modules +.cpcache diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4b30e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +/.cpcache +/.nrepl-port +/bin +/config.edn +/config.sh +/config.env +/node_modules +/secrets.env +/storage/ +/tailwindcss +/target +.calva/ +.clj-kondo/ +.lsp/ +.portal/ +.shadow-cljs/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..53d51fc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# The default deploy instructions (https://biffweb.com/docs/reference/production/) don't +# use Docker, but this file is provided in case you'd like to deploy with containers. +# +# When running the container, make sure you set any environment variables defined in config.env, +# e.g. using whatever tools your deployment platform provides for setting environment variables. +# +# Run these commands to test this file locally: +# +# docker build -t your-app . +# docker run --rm -e BIFF_PROFILE=dev -v $PWD/config.env:/app/config.env your-app + +# This is the base builder image, construct the jar file in this one +# it uses alpine for a small image +FROM clojure:temurin-21-tools-deps-alpine AS jre-build + +ENV TAILWIND_VERSION=v3.2.4 + +# Install the missing packages and applications in a single layer +RUN apk add curl rlwrap && curl -L -o /usr/local/bin/tailwindcss \ + https://github.com/tailwindlabs/tailwindcss/releases/download/$TAILWIND_VERSION/tailwindcss-linux-x64 \ + && chmod +x /usr/local/bin/tailwindcss + +WORKDIR /app +COPY src ./src +COPY dev ./dev +COPY resources ./resources +COPY deps.edn . + +# construct the application jar +RUN clj -M:dev uberjar && cp target/jar/app.jar . && rm -r target + +# This stage (see multi-stage builds) is a bare Java container +# copy over the uberjar from the builder image and run the application +FROM eclipse-temurin:21-alpine +WORKDIR /app + +# Take the uberjar from the base image and put it in the final image +COPY --from=jre-build /app/app.jar /app/app.jar + +EXPOSE 8080 + +# By default, run in PROD profile +ENV BIFF_PROFILE=prod +ENV HOST=0.0.0.0 +ENV PORT=8080 +CMD ["/opt/java/openjdk/bin/java", "-XX:-OmitStackTraceInFastThrow", "-XX:+CrashOnOutOfMemoryError", "-jar", "app.jar"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f0368a1 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Biff starter project + +This is the starter project for Biff. + +Run `clj -M:dev dev` to get started. See `clj -M:dev --help` for other commands. + +Consider adding `alias biff='clj -M:dev'` to your `.bashrc`. diff --git a/cljfmt-indents.edn b/cljfmt-indents.edn new file mode 100644 index 0000000..e522409 --- /dev/null +++ b/cljfmt-indents.edn @@ -0,0 +1 @@ +{submit-tx [[:inner 0]]} diff --git a/deps.edn b/deps.edn new file mode 100644 index 0000000..7912b21 --- /dev/null +++ b/deps.edn @@ -0,0 +1,23 @@ +{:paths ["src" "resources" "target/resources"] + :deps {com.biffweb/biff #:git{:url "https://github.com/jacobobryant/biff", :sha "1570ccc", :tag "v1.8.29"} + camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"} + metosin/muuntaja {:mvn/version "0.6.8"} + ring/ring-defaults {:mvn/version "0.3.4"} + org.clojure/clojure {:mvn/version "1.11.1"} + + ;; Notes on logging: https://gist.github.com/jacobobryant/76b7a08a07d5ef2cc076b048d078f1f3 + org.slf4j/slf4j-simple {:mvn/version "2.0.0-alpha5"} + org.slf4j/log4j-over-slf4j {:mvn/version "1.7.36"} + org.slf4j/jul-to-slf4j {:mvn/version "1.7.36"} + org.slf4j/jcl-over-slf4j {:mvn/version "1.7.36"}} + :aliases + {:dev {:extra-deps {com.biffweb/tasks {:git/url "https://github.com/jacobobryant/biff", :git/sha "1570ccc", :git/tag "v1.8.29", :deps/root "libs/tasks"}} + :extra-paths ["dev" "test"] + :jvm-opts ["-XX:-OmitStackTraceInFastThrow" + "-XX:+CrashOnOutOfMemoryError" + "-Dbiff.env.BIFF_PROFILE=dev"] + :main-opts ["-m" "com.biffweb.task-runner" "tasks/tasks"]} + :prod {:jvm-opts ["-XX:-OmitStackTraceInFastThrow" + "-XX:+CrashOnOutOfMemoryError" + "-Dbiff.env.BIFF_PROFILE=prod"] + :main-opts ["-m" "snippets"]}}} diff --git a/dev/repl.clj b/dev/repl.clj new file mode 100644 index 0000000..450cb1d --- /dev/null +++ b/dev/repl.clj @@ -0,0 +1,86 @@ +(ns repl + (:require [snippets :as main] + [com.biffweb :as biff :refer [q]] + [clojure.edn :as edn] + [clojure.java.io :as io])) + +;; REPL-driven development +;; ---------------------------------------------------------------------------------------- +;; If you're new to REPL-driven development, Biff makes it easy to get started: whenever +;; you save a file, your changes will be evaluated. Biff is structured so that in most +;; cases, that's all you'll need to do for your changes to take effect. (See main/refresh +;; below for more details.) +;; +;; The `clj -M:dev dev` command also starts an nREPL server on port 7888, so if you're +;; already familiar with REPL-driven development, you can connect to that with your editor. +;; +;; If you're used to jacking in with your editor first and then starting your app via the +;; REPL, you will need to instead connect your editor to the nREPL server that `clj -M:dev +;; dev` starts. e.g. if you use emacs, instead of running `cider-jack-in`, you would run +;; `cider-connect`. See "Connecting to a Running nREPL Server:" +;; https://docs.cider.mx/cider/basics/up_and_running.html#connect-to-a-running-nrepl-server +;; ---------------------------------------------------------------------------------------- + +;; This function should only be used from the REPL. Regular application code +;; should receive the system map from the parent Biff component. For example, +;; the use-jetty component merges the system map into incoming Ring requests. +(defn get-context [] + (biff/merge-context @main/system)) + +(defn add-fixtures [] + (biff/submit-tx (get-context) + (-> (io/resource "fixtures.edn") + slurp + edn/read-string))) + +(defn check-config [] + (let [prod-config (biff/use-aero-config {:biff.config/profile "prod"}) + dev-config (biff/use-aero-config {:biff.config/profile "dev"}) + ;; Add keys for any other secrets you've added to resources/config.edn + secret-keys [:biff.middleware/cookie-secret + :biff/jwt-secret + :mailersend/api-key + :recaptcha/secret-key + ; ... + ] + get-secrets (fn [{:keys [biff/secret] :as config}] + (into {} + (map (fn [k] + [k (secret k)])) + secret-keys))] + {:prod-config prod-config + :dev-config dev-config + :prod-secrets (get-secrets prod-config) + :dev-secrets (get-secrets dev-config)})) + +(comment + ;; Call this function if you make a change to main/initial-system, + ;; main/components, :tasks, :queues, config.env, or deps.edn. + (main/refresh) + + ;; Call this in dev if you'd like to add some seed data to your database. If + ;; you edit the seed data (in resources/fixtures.edn), you can reset the + ;; database by running `rm -r storage/xtdb` (DON'T run that in prod), + ;; restarting your app, and calling add-fixtures again. + (add-fixtures) + + ;; Query the database + (let [{:keys [biff/db] :as ctx} (get-context)] + (q db + '{:find (pull user [*]) + :where [[user :user/email]]})) + + ;; Update an existing user's email address + (let [{:keys [biff/db] :as ctx} (get-context) + user-id (biff/lookup-id db :user/email "hello@example.com")] + (biff/submit-tx ctx + [{:db/doc-type :user + :xt/id user-id + :db/op :update + :user/email "new.address@example.com"}])) + + (sort (keys (get-context))) + + ;; Check the terminal for output. + (biff/submit-job (get-context) :echo {:foo "bar"}) + (deref (biff/submit-job-for-result (get-context) :echo {:foo "bar"}))) diff --git a/dev/tasks.clj b/dev/tasks.clj new file mode 100644 index 0000000..1f4f7f2 --- /dev/null +++ b/dev/tasks.clj @@ -0,0 +1,14 @@ +(ns tasks + (:require [com.biffweb.tasks :as tasks])) + +(defn hello + "Says 'Hello'" + [] + (println "Hello")) + +;; Tasks should be vars (#'hello instead of hello) so that `clj -M:dev help` can +;; print their docstrings. +(def custom-tasks + {"hello" #'hello}) + +(def tasks (merge tasks/tasks custom-tasks)) diff --git a/resources/config.edn b/resources/config.edn new file mode 100644 index 0000000..b6c9f08 --- /dev/null +++ b/resources/config.edn @@ -0,0 +1,64 @@ +;; See https://github.com/juxt/aero and https://biffweb.com/docs/api/utilities/#use-aero-config. +;; #biff/env and #biff/secret will load values from the environment and from config.env. +{:biff/base-url #profile {:prod #join ["https://" #biff/env DOMAIN] + :default #join ["http://localhost:" #ref [:biff/port]]} + :biff/host #or [#biff/env "HOST" + #profile {:dev "0.0.0.0" + :default "localhost"}] + :biff/port #long #or [#biff/env "PORT" 8080] + + :biff.xtdb/dir "storage/xtdb" + :biff.xtdb/topology #keyword #or [#profile {:prod #biff/env "PROD_XTDB_TOPOLOGY" + :default #biff/env "XTDB_TOPOLOGY"} + "standalone"] + :biff.xtdb.jdbc/jdbcUrl #biff/secret "XTDB_JDBC_URL" + + :biff.beholder/enabled #profile {:dev true :default false} + :biff.beholder/paths ["src" "resources" "test"] + :biff/eval-paths ["src" "test"] + :biff.middleware/secure #profile {:dev false :default true} + :biff.middleware/cookie-secret #biff/secret COOKIE_SECRET + :biff/jwt-secret #biff/secret JWT_SECRET + :biff.refresh/enabled #profile {:dev true :default false} + + :mailersend/api-key #biff/secret MAILERSEND_API_KEY + :mailersend/from #biff/env MAILERSEND_FROM + :mailersend/reply-to #biff/env MAILERSEND_REPLY_TO + + :recaptcha/secret-key #biff/secret RECAPTCHA_SECRET_KEY + :recaptcha/site-key #biff/env RECAPTCHA_SITE_KEY + + :biff.nrepl/port #or [#biff/env NREPL_PORT "7888"] + :biff.nrepl/args ["--port" #ref [:biff.nrepl/port] + "--middleware" "[cider.nrepl/cider-middleware,refactor-nrepl.middleware/wrap-refactor]"] + + :biff.system-properties/user.timezone "UTC" + :biff.system-properties/clojure.tools.logging.factory "clojure.tools.logging.impl/slf4j-factory" + + :biff.tasks/server #biff/env DOMAIN + :biff.tasks/main-ns snippets + :biff.tasks/on-soft-deploy "\"(snippets/on-save @snippets/system)\"" + :biff.tasks/generate-assets-fn snippets/generate-assets! + :biff.tasks/css-output "target/resources/public/css/main.css" + :biff.tasks/deploy-untracked-files [#ref [:biff.tasks/css-output] + "config.env"] + + ;; `clj -M:dev prod-dev` will run the soft-deploy task whenever files in these directories are changed. + :biff.tasks/watch-dirs ["src" "dev" "resources" "test"] + + ;; The version of the Taliwind standalone bin to install. See `clj -M:dev css -h`. If you change + ;; this, run `rm bin/tailwindcss; clj -M:dev install-tailwind`. + :biff.tasks/tailwind-version "v3.4.17" + + ;; :rsync is the default if rsync is on the path; otherwise :git is the default. Set this to :git + ;; if you have rsync on the path but still want to deploy with git. + ;; :biff.tasks/deploy-with :rsync + + ;; Uncomment this line if you're deploying with git and your local branch is called main instead of + ;; master: + ;; :biff.tasks/git-deploy-cmd ["git" "push" "prod" "main:master"] + :biff.tasks/git-deploy-cmd ["git" "push" "prod" "master"] + + ;; Uncomment this line if you have any ssh-related problems: + ;; :biff.tasks/skip-ssh-agent true + } diff --git a/resources/config.template.env b/resources/config.template.env new file mode 100644 index 0000000..24c3def --- /dev/null +++ b/resources/config.template.env @@ -0,0 +1,34 @@ +# This file contains config that is not checked into git. See resources/config.edn for more config +# options. + +# Where will your app be deployed? +DOMAIN=example.com + +# Mailersend is used to send email sign-in links. Sign up at https://www.mailersend.com/ +MAILERSEND_API_KEY= +# This must be an email address that uses the same domain that you've verified in MailerSend. +MAILERSEND_FROM= +# This is where emails will be sent when users hit reply. It can be any email address. +MAILERSEND_REPLY_TO= + +# Recaptcha is used to protect your sign-in page from bots. Go to +# https://www.google.com/recaptcha/about/ and add a site. Select v2 invisible. Add localhost and the +# value of DOMAIN above to your list of allowed domains. +RECAPTCHA_SITE_KEY= +RECAPTCHA_SECRET_KEY= + +XTDB_TOPOLOGY=standalone +# Uncomment these to use Postgres for storage in production: +#PROD_XTDB_TOPOLOGY=jdbc +#XTDB_JDBC_URL=jdbc:postgresql://host:port/dbname?user=alice&password=abc123&sslmode=require + +# What port should the nrepl server be started on (in dev and prod)? +NREPL_PORT=7888 + + +## Autogenerated. Create new secrets with `clj -M:dev generate-secrets` + +# Used to encrypt session cookies. +COOKIE_SECRET={{ new-secret 16 }} +# Used to encrypt email sign-in links. +JWT_SECRET={{ new-secret 32 }} diff --git a/resources/fixtures.edn b/resources/fixtures.edn new file mode 100644 index 0000000..a8d2785 --- /dev/null +++ b/resources/fixtures.edn @@ -0,0 +1,10 @@ +;; Biff transaction. See https://biffweb.com/docs/reference/transactions/ +[{:db/doc-type :user + :xt/id :db.id/user-a + :user/email "a@example.com" + :user/foo "Some Value" + :user/joined-at :db/now} + {:db/doc-type :msg + :msg/user :db.id/user-a + :msg/text "hello there" + :msg/sent-at :db/now}] diff --git a/resources/public/img/glider.png b/resources/public/img/glider.png new file mode 100644 index 0000000000000000000000000000000000000000..83301658fe9d1fb3edd260b9d7ea269bcf90f396 GIT binary patch literal 1195 zcmZ8hTTc@~6kaYGKtZe*0wQcoPzh0M*|xhHfBJ9piu}VvA7YYbAzDQ#9 zNfRGTjK2G3^u-VoO}s>7^nt|a1O5TSU+^2wSd`>^mpNxUJ#%*2jn$P4q3+U>suD#} zN`pO}15m#}5lGosUC9NO#mSzLG<1Fw{r{Bm;Dep*Lvue~hMOqw-VKcb)$AtND5wBu zD(bqRo0~!7J z80<=lt|rFWZ7FDOtqqH}mQRV0oCK}FksV}3TddOF z%EPsmANcj`2~1WDPW0LcMDmVm?;h;W{x`rm6b1k}BslBmvOFCYpI?XrB1(b$?LB;1 zrQ-Q$&sGDJDtvpNObN+0{(74d3nW8LBPX^!LCeN#&~UD_6YlS%vVmOMme+iu4XML` zxZd(p`(W*i4wlyh4lyR)>d5*sSe$koK!``=BtG3+xV2E=LgLJ~*P~PhtK|BJpkq^@ z*)=g}_4`AxxbTRzSENfM;Ib@8eg|By9BLbWy8)*w;;`v>oF^Pb4aUt$u1@jlM4DW0MKfnMrX)_wv&w z*Gx~;GW|N|widPZsM};wPov-GHn^@uy_(@;HutzKukH6SgIUaD=u9)Y#p hooks/post-receive << EOD +#!/usr/bin/env bash +git --work-tree=/home/app --git-dir=/home/app/repo.git checkout -f +EOD + chmod +x hooks/post-receive +} +sudo -u app bash -c "$(declare -f set_up_app); set_up_app" + +# Systemd service +cat > /etc/systemd/system/app.service << EOD +[Unit] +Description=app +StartLimitIntervalSec=500 +StartLimitBurst=5 + +[Service] +User=app +Restart=on-failure +RestartSec=5s +Environment="BIFF_PROFILE=$BIFF_PROFILE" +WorkingDirectory=/home/app +ExecStart=/bin/sh -c "mkdir -p target/resources; clj -M:prod" + +[Install] +WantedBy=multi-user.target +EOD +systemctl enable app +cat > /etc/systemd/journald.conf << EOD +[Journal] +Storage=persistent +EOD +systemctl restart systemd-journald +cat > /etc/sudoers.d/restart-app << EOD +app ALL= NOPASSWD: /bin/systemctl reset-failed app.service +app ALL= NOPASSWD: /bin/systemctl restart app +app ALL= NOPASSWD: /usr/bin/systemctl reset-failed app.service +app ALL= NOPASSWD: /usr/bin/systemctl restart app +EOD +chmod 440 /etc/sudoers.d/restart-app + +# Firewall +ufw allow OpenSSH +ufw --force enable + +# Web dependencies +apt-get -y install nginx +snap install core +snap refresh core +snap install --classic certbot +ln -s /snap/bin/certbot /usr/bin/certbot + +# Nginx +rm /etc/nginx/sites-enabled/default +cat > /etc/nginx/sites-available/app << EOD +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + root /home/app/target/resources/public; + location / { + try_files \$uri \$uri/index.html @resources; + } + location @resources { + root /home/app/resources/public; + try_files \$uri \$uri/index.html @proxy; + } + location @proxy { + proxy_pass http://localhost:8080; + proxy_http_version 1.1; + proxy_set_header Host \$host; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header X-Real-IP \$remote_addr; + } +} +EOD +ln -s /etc/nginx/sites-{available,enabled}/app + +# Firewall +ufw allow "Nginx Full" + +# Let's encrypt +certbot --nginx + +# App dependencies +# If you need to install additional packages for your app, you can do it here. +# apt-get -y install ... diff --git a/src/snippets.clj b/src/snippets.clj new file mode 100644 index 0000000..e7b1657 --- /dev/null +++ b/src/snippets.clj @@ -0,0 +1,94 @@ +(ns snippets + (:require [com.biffweb :as biff] + [snippets.email :as email] + [snippets.app :as app] + [snippets.home :as home] + [snippets.middleware :as mid] + [snippets.ui :as ui] + [snippets.worker :as worker] + [snippets.schema :as schema] + [clojure.test :as test] + [clojure.tools.logging :as log] + [clojure.tools.namespace.repl :as tn-repl] + [malli.core :as malc] + [malli.registry :as malr] + [nrepl.cmdline :as nrepl-cmd]) + (:gen-class)) + +(def modules + [app/module + (biff/authentication-module {}) + home/module + schema/module + worker/module]) + +(def routes [["" {:middleware [mid/wrap-site-defaults]} + (keep :routes modules)] + ["" {:middleware [mid/wrap-api-defaults]} + (keep :api-routes modules)]]) + +(def handler (-> (biff/reitit-handler {:routes routes}) + mid/wrap-base-defaults)) + +(def static-pages (apply biff/safe-merge (map :static modules))) + +(defn generate-assets! [ctx] + (biff/export-rum static-pages "target/resources/public") + (biff/delete-old-files {:dir "target/resources/public" + :exts [".html"]})) + +(defn on-save [ctx] + (biff/add-libs) + (biff/eval-files! ctx) + (generate-assets! ctx) + (test/run-all-tests #"snippets.*-test")) + +(def malli-opts + {:registry (malr/composite-registry + malc/default-registry + (apply biff/safe-merge (keep :schema modules)))}) + +(def initial-system + {:biff/modules #'modules + :biff/send-email #'email/send-email + :biff/handler #'handler + :biff/malli-opts #'malli-opts + :biff.beholder/on-save #'on-save + :biff.middleware/on-error #'ui/on-error + :biff.xtdb/tx-fns biff/tx-fns + :snippets/chat-clients (atom #{})}) + +(defonce system (atom {})) + +(def components + [biff/use-aero-config + biff/use-xtdb + biff/use-queues + biff/use-xtdb-tx-listener + biff/use-htmx-refresh + biff/use-jetty + biff/use-chime + biff/use-beholder]) + +(defn start [] + (let [new-system (reduce (fn [system component] + (log/info "starting:" (str component)) + (component system)) + initial-system + components)] + (reset! system new-system) + (generate-assets! new-system) + (log/info "System started.") + (log/info "Go to" (:biff/base-url new-system)) + new-system)) + +(defn -main [] + (let [{:keys [biff.nrepl/args]} (start)] + (apply nrepl-cmd/-main args))) + +(defn refresh [] + (doseq [f (:biff/stop @system)] + (log/info "stopping:" (str f)) + (f)) + (tn-repl/refresh :after `start) + :done) diff --git a/src/snippets/app.clj b/src/snippets/app.clj new file mode 100644 index 0000000..b705b9a --- /dev/null +++ b/src/snippets/app.clj @@ -0,0 +1,153 @@ +(ns snippets.app + (:require [com.biffweb :as biff :refer [q]] + [snippets.middleware :as mid] + [snippets.ui :as ui] + [snippets.settings :as settings] + [rum.core :as rum] + [xtdb.api :as xt] + [ring.adapter.jetty9 :as jetty] + [cheshire.core :as cheshire])) + +(defn set-foo [{:keys [session params] :as ctx}] + (biff/submit-tx ctx + [{:db/op :update + :db/doc-type :user + :xt/id (:uid session) + :user/foo (:foo params)}]) + {:status 303 + :headers {"location" "/app"}}) + +(defn bar-form [{:keys [value]}] + (biff/form + {:hx-post "/app/set-bar" + :hx-swap "outerHTML"} + [:label.block {:for "bar"} "Bar: " + [:span.font-mono (pr-str value)]] + [:.h-1] + [:.flex + [:input.w-full#bar {:type "text" :name "bar" :value value}] + [:.w-3] + [:button.btn {:type "submit"} "Update"]] + [:.h-1] + [:.text-sm.text-gray-600 + "This demonstrates updating a value with HTMX."])) + +(defn set-bar [{:keys [session params] :as ctx}] + (biff/submit-tx ctx + [{:db/op :update + :db/doc-type :user + :xt/id (:uid session) + :user/bar (:bar params)}]) + (biff/render (bar-form {:value (:bar params)}))) + +(defn message [{:msg/keys [text sent-at]}] + [:.mt-3 {:_ "init send newMessage to #message-header"} + [:.text-gray-600 (biff/format-date sent-at "dd MMM yyyy HH:mm:ss")] + [:div text]]) + +(defn notify-clients [{:keys [snippets/chat-clients]} tx] + (doseq [[op & args] (::xt/tx-ops tx) + :when (= op ::xt/put) + :let [[doc] args] + :when (contains? doc :msg/text) + :let [html (rum/render-static-markup + [:div#messages {:hx-swap-oob "afterbegin"} + (message doc)])] + ws @chat-clients] + (jetty/send! ws html))) + +(defn send-message [{:keys [session] :as ctx} {:keys [text]}] + (let [{:keys [text]} (cheshire/parse-string text true)] + (biff/submit-tx ctx + [{:db/doc-type :msg + :msg/user (:uid session) + :msg/text text + :msg/sent-at :db/now}]))) + +(defn chat [{:keys [biff/db]}] + (let [messages (q db + '{:find (pull msg [*]) + :in [t0] + :where [[msg :msg/sent-at t] + [(<= t0 t)]]} + (biff/add-seconds (java.util.Date.) (* -60 10)))] + [:div {:hx-ext "ws" :ws-connect "/app/chat"} + [:form.mb-0 {:ws-send true + :_ "on submit set value of #message to ''"} + [:label.block {:for "message"} "Write a message"] + [:.h-1] + [:textarea.w-full#message {:name "text"}] + [:.h-1] + [:.text-sm.text-gray-600 + "Sign in with an incognito window to have a conversation with yourself."] + [:.h-2] + [:div [:button.btn {:type "submit"} "Send message"]]] + [:.h-6] + [:div#message-header + {:_ "on newMessage put 'Messages sent in the past 10 minutes:' into me"} + (if (empty? messages) + "No messages yet." + "Messages sent in the past 10 minutes:")] + [:div#messages + (map message (sort-by :msg/sent-at #(compare %2 %1) messages))]])) + +(defn app [{:keys [session biff/db] :as ctx}] + (let [{:user/keys [email foo bar]} (xt/entity db (:uid session))] + (ui/page + {} + [:div "Signed in as " email ". " + (biff/form + {:action "/auth/signout" + :class "inline"} + [:button.text-blue-500.hover:text-blue-800 {:type "submit"} + "Sign out"]) + "."] + [:.h-6] + (biff/form + {:action "/app/set-foo"} + [:label.block {:for "foo"} "Foo: " + [:span.font-mono (pr-str foo)]] + [:.h-1] + [:.flex + [:input.w-full#foo {:type "text" :name "foo" :value foo}] + [:.w-3] + [:button.btn {:type "submit"} "Update"]] + [:.h-1] + [:.text-sm.text-gray-600 + "This demonstrates updating a value with a plain old form."]) + [:.h-6] + (bar-form {:value bar}) + [:.h-6] + (chat ctx)))) + +(defn ws-handler [{:keys [snippets/chat-clients] :as ctx}] + {:status 101 + :headers {"upgrade" "websocket" + "connection" "upgrade"} + :ws {:on-connect (fn [ws] + (swap! chat-clients conj ws)) + :on-text (fn [ws text-message] + (send-message ctx {:ws ws :text text-message})) + :on-close (fn [ws status-code reason] + (swap! chat-clients disj ws))}}) + +(def about-page + (ui/page + {:base/title (str "About " settings/app-name)} + [:p "This app was made with " + [:a.link {:href "https://biffweb.com"} "Biff"] "."])) + +(defn echo [{:keys [params]}] + {:status 200 + :headers {"content-type" "application/json"} + :body params}) + +(def module + {:static {"/about/" about-page} + :routes ["/app" {:middleware [mid/wrap-signed-in]} + ["" {:get app}] + ["/set-foo" {:post set-foo}] + ["/set-bar" {:post set-bar}] + ["/chat" {:get ws-handler}]] + :api-routes [["/api/echo" {:post echo}]] + :on-tx notify-clients}) diff --git a/src/snippets/email.clj b/src/snippets/email.clj new file mode 100644 index 0000000..a31a769 --- /dev/null +++ b/src/snippets/email.clj @@ -0,0 +1,92 @@ +(ns snippets.email + (:require [camel-snake-kebab.core :as csk] + [camel-snake-kebab.extras :as cske] + [clj-http.client :as http] + [snippets.settings :as settings] + [clojure.tools.logging :as log] + [rum.core :as rum])) + +(defn signin-link [{:keys [to url user-exists]}] + (let [[subject action] (if user-exists + [(str "Sign in to " settings/app-name) "sign in"] + [(str "Sign up for " settings/app-name) "sign up"])] + {:to [{:email to}] + :subject subject + :html (rum/render-static-markup + [:html + [:body + [:p "We received a request to " action " to " settings/app-name + " using this email address. Click this link to " action ":"] + [:p [:a {:href url :target "_blank"} "Click here to " action "."]] + [:p "This link will expire in one hour. " + "If you did not request this link, you can ignore this email."]]]) + :text (str "We received a request to " action " to " settings/app-name + " using this email address. Click this link to " action ":\n" + "\n" + url "\n" + "\n" + "This link will expire in one hour. If you did not request this link, " + "you can ignore this email.")})) + +(defn signin-code [{:keys [to code user-exists]}] + (let [[subject action] (if user-exists + [(str "Sign in to " settings/app-name) "sign in"] + [(str "Sign up for " settings/app-name) "sign up"])] + {:to [{:email to}] + :subject subject + :html (rum/render-static-markup + [:html + [:body + [:p "We received a request to " action " to " settings/app-name + " using this email address. Enter the following code to " action ":"] + [:p {:style {:font-size "2rem"}} code] + [:p + "This code will expire in three minutes. " + "If you did not request this code, you can ignore this email."]]]) + :text (str "We received a request to " action " to " settings/app-name + " using this email address. Enter the following code to " action ":\n" + "\n" + code "\n" + "\n" + "This code will expire in three minutes. If you did not request this code, " + "you can ignore this email.")})) + +(defn template [k opts] + ((case k + :signin-link signin-link + :signin-code signin-code) + opts)) + +(defn send-mailersend [{:keys [biff/secret mailersend/from mailersend/reply-to]} form-params] + (let [result (http/post "https://api.mailersend.com/v1/email" + {:oauth-token (secret :mailersend/api-key) + :content-type :json + :throw-exceptions false + :as :json + :form-params (merge {:from {:email from :name settings/app-name} + :reply_to {:email reply-to :name settings/app-name}} + form-params)}) + success (< (:status result) 400)] + (when-not success + (log/error (:body result))) + success)) + +(defn send-console [ctx form-params] + (println "TO:" (:to form-params)) + (println "SUBJECT:" (:subject form-params)) + (println) + (println (:text form-params)) + (println) + (println "To send emails instead of printing them to the console, add your" + "API keys for MailerSend and Recaptcha to config.env.") + true) + +(defn send-email [{:keys [biff/secret recaptcha/site-key] :as ctx} opts] + (let [form-params (if-some [template-key (:template opts)] + (template template-key opts) + opts)] + (if (every? some? [(secret :mailersend/api-key) + (secret :recaptcha/secret-key) + site-key]) + (send-mailersend ctx form-params) + (send-console ctx form-params)))) diff --git a/src/snippets/home.clj b/src/snippets/home.clj new file mode 100644 index 0000000..c98c82c --- /dev/null +++ b/src/snippets/home.clj @@ -0,0 +1,174 @@ +(ns snippets.home + (:require [clj-http.client :as http] + [com.biffweb :as biff] + [snippets.middleware :as mid] + [snippets.ui :as ui] + [snippets.settings :as settings] + [rum.core :as rum] + [xtdb.api :as xt])) + +(def email-disabled-notice + [:.text-sm.mt-3.bg-blue-100.rounded.p-2 + "Until you add API keys for MailerSend and reCAPTCHA, we'll print your sign-up " + "link to the console. See config.edn."]) + +(defn home-page [{:keys [recaptcha/site-key params] :as ctx}] + (ui/page + (assoc ctx ::ui/recaptcha true) + (biff/form + {:action "/auth/send-link" + :id "signup" + :hidden {:on-error "/"}} + (biff/recaptcha-callback "submitSignup" "signup") + [:h2.text-2xl.font-bold (str "Sign up for " settings/app-name)] + [:.h-3] + [:.flex + [:input#email {:name "email" + :type "email" + :autocomplete "email" + :placeholder "Enter your email address"}] + [:.w-3] + [:button.btn.g-recaptcha + (merge (when site-key + {:data-sitekey site-key + :data-callback "submitSignup"}) + {:type "submit"}) + "Sign up"]] + (when-some [error (:error params)] + [:<> + [:.h-1] + [:.text-sm.text-red-600 + (case error + "recaptcha" (str "You failed the recaptcha test. Try again, " + "and make sure you aren't blocking scripts from Google.") + "invalid-email" "Invalid email. Try again with a different address." + "send-failed" (str "We weren't able to send an email to that address. " + "If the problem persists, try another address.") + "There was an error.")]]) + [:.h-1] + [:.text-sm "Already have an account? " [:a.link {:href "/signin"} "Sign in"] "."] + [:.h-3] + biff/recaptcha-disclosure + email-disabled-notice))) + +(defn link-sent [{:keys [params] :as ctx}] + (ui/page + ctx + [:h2.text-xl.font-bold "Check your inbox"] + [:p "We've sent a sign-in link to " [:span.font-bold (:email params)] "."])) + +(defn verify-email-page [{:keys [params] :as ctx}] + (ui/page + ctx + [:h2.text-2xl.font-bold (str "Sign up for " settings/app-name)] + [:.h-3] + (biff/form + {:action "/auth/verify-link" + :hidden {:token (:token params)}} + [:div [:label {:for "email"} + "It looks like you opened this link on a different device or browser than the one " + "you signed up on. For verification, please enter the email you signed up with:"]] + [:.h-3] + [:.flex + [:input#email {:name "email" :type "email" + :placeholder "Enter your email address"}] + [:.w-3] + [:button.btn {:type "submit"} + "Sign in"]]) + (when-some [error (:error params)] + [:<> + [:.h-1] + [:.text-sm.text-red-600 + (case error + "incorrect-email" "Incorrect email address. Try again." + "There was an error.")]]))) + +(defn signin-page [{:keys [recaptcha/site-key params] :as ctx}] + (ui/page + (assoc ctx ::ui/recaptcha true) + (biff/form + {:action "/auth/send-code" + :id "signin" + :hidden {:on-error "/signin"}} + (biff/recaptcha-callback "submitSignin" "signin") + [:h2.text-2xl.font-bold "Sign in to " settings/app-name] + [:.h-3] + [:.flex + [:input#email {:name "email" + :type "email" + :autocomplete "email" + :placeholder "Enter your email address"}] + [:.w-3] + [:button.btn.g-recaptcha + (merge (when site-key + {:data-sitekey site-key + :data-callback "submitSignin"}) + {:type "submit"}) + "Sign in"]] + (when-some [error (:error params)] + [:<> + [:.h-1] + [:.text-sm.text-red-600 + (case error + "recaptcha" (str "You failed the recaptcha test. Try again, " + "and make sure you aren't blocking scripts from Google.") + "invalid-email" "Invalid email. Try again with a different address." + "send-failed" (str "We weren't able to send an email to that address. " + "If the problem persists, try another address.") + "invalid-link" "Invalid or expired link. Sign in to get a new link." + "not-signed-in" "You must be signed in to view that page." + "There was an error.")]]) + [:.h-1] + [:.text-sm "Don't have an account yet? " [:a.link {:href "/"} "Sign up"] "."] + [:.h-3] + biff/recaptcha-disclosure + email-disabled-notice))) + +(defn enter-code-page [{:keys [recaptcha/site-key params] :as ctx}] + (ui/page + (assoc ctx ::ui/recaptcha true) + (biff/form + {:action "/auth/verify-code" + :id "code-form" + :hidden {:email (:email params)}} + (biff/recaptcha-callback "submitCode" "code-form") + [:div [:label {:for "code"} "Enter the 6-digit code that we sent to " + [:span.font-bold (:email params)]]] + [:.h-1] + [:.flex + [:input#code {:name "code" :type "text"}] + [:.w-3] + [:button.btn.g-recaptcha + (merge (when site-key + {:data-sitekey site-key + :data-callback "submitCode"}) + {:type "submit"}) + "Sign in"]]) + (when-some [error (:error params)] + [:<> + [:.h-1] + [:.text-sm.text-red-600 + (case error + "invalid-code" "Invalid code." + "There was an error.")]]) + [:.h-3] + (biff/form + {:action "/auth/send-code" + :id "signin" + :hidden {:email (:email params) + :on-error "/signin"}} + (biff/recaptcha-callback "submitSignin" "signin") + [:button.link.g-recaptcha + (merge (when site-key + {:data-sitekey site-key + :data-callback "submitSignin"}) + {:type "submit"}) + "Send another code"]))) + +(def module + {:routes [["" {:middleware [mid/wrap-redirect-signed-in]} + ["/" {:get home-page}]] + ["/link-sent" {:get link-sent}] + ["/verify-link" {:get verify-email-page}] + ["/signin" {:get signin-page}] + ["/verify-code" {:get enter-code-page}]]}) diff --git a/src/snippets/middleware.clj b/src/snippets/middleware.clj new file mode 100644 index 0000000..35a11bd --- /dev/null +++ b/src/snippets/middleware.clj @@ -0,0 +1,60 @@ +(ns snippets.middleware + (:require [com.biffweb :as biff] + [muuntaja.middleware :as muuntaja] + [ring.middleware.anti-forgery :as csrf] + [ring.middleware.defaults :as rd])) + +(defn wrap-redirect-signed-in [handler] + (fn [{:keys [session] :as ctx}] + (if (some? (:uid session)) + {:status 303 + :headers {"location" "/app"}} + (handler ctx)))) + +(defn wrap-signed-in [handler] + (fn [{:keys [session] :as ctx}] + (if (some? (:uid session)) + (handler ctx) + {:status 303 + :headers {"location" "/signin?error=not-signed-in"}}))) + +;; Stick this function somewhere in your middleware stack below if you want to +;; inspect what things look like before/after certain middleware fns run. +(defn wrap-debug [handler] + (fn [ctx] + (let [response (handler ctx)] + (println "REQUEST") + (biff/pprint ctx) + (def ctx* ctx) + (println "RESPONSE") + (biff/pprint response) + (def response* response) + response))) + +(defn wrap-site-defaults [handler] + (-> handler + biff/wrap-render-rum + biff/wrap-anti-forgery-websockets + csrf/wrap-anti-forgery + biff/wrap-session + muuntaja/wrap-params + muuntaja/wrap-format + (rd/wrap-defaults (-> rd/site-defaults + (assoc-in [:security :anti-forgery] false) + (assoc-in [:responses :absolute-redirects] true) + (assoc :session false) + (assoc :static false))))) + +(defn wrap-api-defaults [handler] + (-> handler + muuntaja/wrap-params + muuntaja/wrap-format + (rd/wrap-defaults rd/api-defaults))) + +(defn wrap-base-defaults [handler] + (-> handler + biff/wrap-https-scheme + biff/wrap-resource + biff/wrap-internal-error + biff/wrap-ssl + biff/wrap-log-requests)) diff --git a/src/snippets/schema.clj b/src/snippets/schema.clj new file mode 100644 index 0000000..e90459a --- /dev/null +++ b/src/snippets/schema.clj @@ -0,0 +1,20 @@ +(ns snippets.schema) + +(def schema + {:user/id :uuid + :user [:map {:closed true} + [:xt/id :user/id] + [:user/email :string] + [:user/joined-at inst?] + [:user/foo {:optional true} :string] + [:user/bar {:optional true} :string]] + + :msg/id :uuid + :msg [:map {:closed true} + [:xt/id :msg/id] + [:msg/user :user/id] + [:msg/text :string] + [:msg/sent-at inst?]]}) + +(def module + {:schema schema}) diff --git a/src/snippets/settings.clj b/src/snippets/settings.clj new file mode 100644 index 0000000..1e2261c --- /dev/null +++ b/src/snippets/settings.clj @@ -0,0 +1,3 @@ +(ns snippets.settings) + +(def app-name "My Application") diff --git a/src/snippets/ui.clj b/src/snippets/ui.clj new file mode 100644 index 0000000..b4ab373 --- /dev/null +++ b/src/snippets/ui.clj @@ -0,0 +1,60 @@ +(ns snippets.ui + (:require [cheshire.core :as cheshire] + [clojure.java.io :as io] + [snippets.settings :as settings] + [com.biffweb :as biff] + [ring.middleware.anti-forgery :as csrf] + [ring.util.response :as ring-response] + [rum.core :as rum])) + +(defn static-path [path] + (if-some [last-modified (some-> (io/resource (str "public" path)) + ring-response/resource-data + :last-modified + (.getTime))] + (str path "?t=" last-modified) + path)) + +(defn base [{:keys [::recaptcha] :as ctx} & body] + (apply + biff/base-html + (-> ctx + (merge #:base{:title settings/app-name + :lang "en-US" + :icon "/img/glider.png" + :description (str settings/app-name " Description") + :image "https://clojure.org/images/clojure-logo-120b.png"}) + (update :base/head (fn [head] + (concat [[:link {:rel "stylesheet" :href (static-path "/css/main.css")}] + [:script {:src (static-path "/js/main.js")}] + [:script {:src "https://unpkg.com/htmx.org@2.0.4"}] + [:script {:src "https://unpkg.com/htmx-ext-ws@2.0.1/ws.js"}] + [:script {:src "https://unpkg.com/hyperscript.org@0.9.13"}] + (when recaptcha + [:script {:src "https://www.google.com/recaptcha/api.js" + :async "async" :defer "defer"}])] + head)))) + body)) + +(defn page [ctx & body] + (base + ctx + [:.flex-grow] + [:.p-3.mx-auto.max-w-screen-sm.w-full + (when (bound? #'csrf/*anti-forgery-token*) + {:hx-headers (cheshire/generate-string + {:x-csrf-token csrf/*anti-forgery-token*})}) + body] + [:.flex-grow] + [:.flex-grow])) + +(defn on-error [{:keys [status ex] :as ctx}] + {:status status + :headers {"content-type" "text/html"} + :body (rum/render-static-markup + (page + ctx + [:h1.text-lg.font-bold + (if (= status 404) + "Page not found." + "Something went wrong.")]))}) diff --git a/src/snippets/worker.clj b/src/snippets/worker.clj new file mode 100644 index 0000000..7f9a1a7 --- /dev/null +++ b/src/snippets/worker.clj @@ -0,0 +1,40 @@ +(ns snippets.worker + (:require [clojure.tools.logging :as log] + [com.biffweb :as biff :refer [q]] + [xtdb.api :as xt])) + +(defn every-n-minutes [n] + (iterate #(biff/add-seconds % (* 60 n)) (java.util.Date.))) + +(defn print-usage [{:keys [biff/db]}] + ;; For a real app, you can have this run once per day and send you the output + ;; in an email. + (let [n-users (nth (q db + '{:find (count user) + :where [[user :user/email]]}) + 0 + 0)] + (log/info "There are" n-users "users."))) + +(defn alert-new-user [{:keys [biff.xtdb/node]} tx] + (doseq [_ [nil] + :let [db-before (xt/db node {::xt/tx-id (dec (::xt/tx-id tx))})] + [op & args] (::xt/tx-ops tx) + :when (= op ::xt/put) + :let [[doc] args] + :when (and (contains? doc :user/email) + (nil? (xt/entity db-before (:xt/id doc))))] + ;; You could send this as an email instead of printing. + (log/info "WOAH there's a new user"))) + +(defn echo-consumer [{:keys [biff/job] :as ctx}] + (prn :echo job) + (when-some [callback (:biff/callback job)] + (callback job))) + +(def module + {:tasks [{:task #'print-usage + :schedule #(every-n-minutes 5)}] + :on-tx alert-new-user + :queues [{:id :echo + :consumer #'echo-consumer}]}) diff --git a/test/snippets_test.clj b/test/snippets_test.clj new file mode 100644 index 0000000..5c96250 --- /dev/null +++ b/test/snippets_test.clj @@ -0,0 +1,45 @@ +(ns snippets-test + (:require [cheshire.core :as cheshire] + [clojure.string :as str] + [clojure.test :refer [deftest is]] + [com.biffweb :as biff :refer [test-xtdb-node]] + [snippets :as main] + [snippets.app :as app] + [malli.generator :as mg] + [rum.core :as rum] + [xtdb.api :as xt])) + +(deftest example-test + (is (= 4 (+ 2 2)))) + +(defn get-context [node] + {:biff.xtdb/node node + :biff/db (xt/db node) + :biff/malli-opts #'main/malli-opts}) + +(deftest send-message-test + (with-open [node (test-xtdb-node [])] + (let [message (mg/generate :string) + user (mg/generate :user main/malli-opts) + ctx (assoc (get-context node) :session {:uid (:xt/id user)}) + _ (app/send-message ctx {:text (cheshire/generate-string {:text message})}) + db (xt/db node) ; get a fresh db value so it contains any transactions + ; that send-message submitted. + doc (biff/lookup db :msg/text message)] + (is (some? doc)) + (is (= (:msg/user doc) (:xt/id user)))))) + +(deftest chat-test + (let [n-messages (+ 3 (rand-int 10)) + now (java.util.Date.) + messages (for [doc (mg/sample :msg (assoc main/malli-opts :size n-messages))] + (assoc doc :msg/sent-at now))] + (with-open [node (test-xtdb-node messages)] + (let [response (app/chat {:biff/db (xt/db node)}) + html (rum/render-html response)] + (is (str/includes? html "Messages sent in the past 10 minutes:")) + (is (not (str/includes? html "No messages yet."))) + ;; If you add Jsoup to your dependencies, you can use DOM selectors instead of just regexes: + ;(is (= n-messages (count (.select (Jsoup/parse html) "#messages > *")))) + (is (= n-messages (count (re-seq #"init send newMessage to #message-header" html)))) + (is (every? #(str/includes? html (:msg/text %)) messages))))))