init biff project
This commit is contained in:
commit
c652b828e8
26 changed files with 1220 additions and 0 deletions
3
.dockerignore
Normal file
3
.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.git
|
||||
node_modules
|
||||
.cpcache
|
||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
|
|
@ -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/
|
||||
46
Dockerfile
Normal file
46
Dockerfile
Normal file
|
|
@ -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"]
|
||||
7
README.md
Normal file
7
README.md
Normal file
|
|
@ -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`.
|
||||
1
cljfmt-indents.edn
Normal file
1
cljfmt-indents.edn
Normal file
|
|
@ -0,0 +1 @@
|
|||
{submit-tx [[:inner 0]]}
|
||||
23
deps.edn
Normal file
23
deps.edn
Normal file
|
|
@ -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"]}}}
|
||||
86
dev/repl.clj
Normal file
86
dev/repl.clj
Normal file
|
|
@ -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"})))
|
||||
14
dev/tasks.clj
Normal file
14
dev/tasks.clj
Normal file
|
|
@ -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))
|
||||
64
resources/config.edn
Normal file
64
resources/config.edn
Normal file
|
|
@ -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
|
||||
}
|
||||
34
resources/config.template.env
Normal file
34
resources/config.template.env
Normal file
|
|
@ -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 }}
|
||||
10
resources/fixtures.edn
Normal file
10
resources/fixtures.edn
Normal file
|
|
@ -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}]
|
||||
BIN
resources/public/img/glider.png
Normal file
BIN
resources/public/img/glider.png
Normal file
Binary file not shown.
1
resources/public/js/main.js
Normal file
1
resources/public/js/main.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
// When plain htmx isn't quite enough, you can stick some custom JS here.
|
||||
12
resources/tailwind.config.js
Normal file
12
resources/tailwind.config.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
module.exports = {
|
||||
content: [
|
||||
'./src/**/*',
|
||||
'./resources/**/*',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
],
|
||||
}
|
||||
35
resources/tailwind.css
Normal file
35
resources/tailwind.css
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
p {
|
||||
@apply mb-3;
|
||||
}
|
||||
|
||||
ul {
|
||||
@apply list-disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
@apply list-decimal;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
@apply my-3 pl-10;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply bg-blue-500 hover:bg-blue-700 text-center py-2 px-4 rounded disabled:opacity-50 text-white;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.link {
|
||||
@apply text-blue-600 hover:underline;
|
||||
}
|
||||
}
|
||||
|
||||
.grecaptcha-badge { visibility: hidden; }
|
||||
127
server-setup.sh
Normal file
127
server-setup.sh
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env bash
|
||||
set -x
|
||||
set -e
|
||||
|
||||
BIFF_PROFILE=${1:-prod}
|
||||
CLJ_VERSION=1.11.1.1165
|
||||
TRENCH_VERSION=0.4.0
|
||||
if [ $(uname -m) = "aarch64" ]; then
|
||||
ARCH=arm64
|
||||
else
|
||||
ARCH=amd64
|
||||
fi
|
||||
TRENCH_FILE=trenchman_${TRENCH_VERSION}_linux_${ARCH}.tar.gz
|
||||
|
||||
echo waiting for apt to finish
|
||||
while (ps aux | grep [a]pt); do
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# Dependencies
|
||||
apt-get update
|
||||
apt-get upgrade
|
||||
apt-get -y install default-jre rlwrap ufw git snapd
|
||||
bash < <(curl -s https://download.clojure.org/install/linux-install-$CLJ_VERSION.sh)
|
||||
bash < <(curl -s https://raw.githubusercontent.com/babashka/babashka/master/install)
|
||||
curl -sSLf https://github.com/athos/trenchman/releases/download/v$TRENCH_VERSION/$TRENCH_FILE | tar zxvfC - /usr/local/bin trench
|
||||
|
||||
# Non-root user
|
||||
useradd -m app
|
||||
mkdir -m 700 -p /home/app/.ssh
|
||||
cp /root/.ssh/authorized_keys /home/app/.ssh
|
||||
chown -R app:app /home/app/.ssh
|
||||
|
||||
# Git deploys - only used if you don't have rsync on your machine
|
||||
set_up_app () {
|
||||
cd
|
||||
mkdir repo.git
|
||||
cd repo.git
|
||||
git init --bare
|
||||
cat > 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 ...
|
||||
94
src/snippets.clj
Normal file
94
src/snippets.clj
Normal file
|
|
@ -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)
|
||||
153
src/snippets/app.clj
Normal file
153
src/snippets/app.clj
Normal file
|
|
@ -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})
|
||||
92
src/snippets/email.clj
Normal file
92
src/snippets/email.clj
Normal file
|
|
@ -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))))
|
||||
174
src/snippets/home.clj
Normal file
174
src/snippets/home.clj
Normal file
|
|
@ -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}]]})
|
||||
60
src/snippets/middleware.clj
Normal file
60
src/snippets/middleware.clj
Normal file
|
|
@ -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))
|
||||
20
src/snippets/schema.clj
Normal file
20
src/snippets/schema.clj
Normal file
|
|
@ -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})
|
||||
3
src/snippets/settings.clj
Normal file
3
src/snippets/settings.clj
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
(ns snippets.settings)
|
||||
|
||||
(def app-name "My Application")
|
||||
60
src/snippets/ui.clj
Normal file
60
src/snippets/ui.clj
Normal file
|
|
@ -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.")]))})
|
||||
40
src/snippets/worker.clj
Normal file
40
src/snippets/worker.clj
Normal file
|
|
@ -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}]})
|
||||
45
test/snippets_test.clj
Normal file
45
test/snippets_test.clj
Normal file
|
|
@ -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))))))
|
||||
Loading…
Add table
Add a link
Reference in a new issue