biff-sqlite starter
This commit is contained in:
commit
162ff2f407
36 changed files with 1846 additions and 0 deletions
BIN
.DS_Store
vendored
Normal file
BIN
.DS_Store
vendored
Normal file
Binary file not shown.
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/.cpcache
|
||||
/.nrepl-port
|
||||
/bin
|
||||
/config.edn
|
||||
/config.sh
|
||||
/config.env
|
||||
/secrets.env
|
||||
/storage/
|
||||
/tailwindcss
|
||||
/target
|
||||
.calva/
|
||||
.clj-kondo/
|
||||
.lsp/
|
||||
.portal/
|
||||
4
Dockerfile
Normal file
4
Dockerfile
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from clojure:temurin-17-alpine
|
||||
EXPOSE 8080
|
||||
ENV BIFF_PROFILE=prod
|
||||
clj -M:prod
|
||||
22
LICENSE
Normal file
22
LICENSE
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 Jacob O'Bryant - Biff, biff-postgres
|
||||
Copyright (c) 2024 Luciano Laratelli - Modifications to work with sqlite (`biff-sqlite`)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
36
README.md
Normal file
36
README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# biff-sqlite
|
||||
|
||||
This is a modified version of
|
||||
[biff-postgres](https://github.com/jacobobryant/biff-postgres/tree/master) to
|
||||
use SQLite. In addition to replacing PostgreSQL with SQLite, I've also:
|
||||
- removed tailwind (in favor of [pico.css](https://picocss.com/)) and recaptcha
|
||||
- removed all links to unpkg by vendoring all css and js directly
|
||||
- brought in [HoneySQL](https://github.com/seancorfield/honeysql) to replace writing raw SQL
|
||||
- brought in [Migratus](https://github.com/yogthos/migratus) to manage migrations
|
||||
- attempted to silence all kondo warnings
|
||||
|
||||
# getting started
|
||||
|
||||
``` bash
|
||||
MY_PROJECT_NAME="dashboard-for-ynab"
|
||||
git clone https://git.sr.ht/~luciano/biff-sqlite $MY_PROJECT_NAME
|
||||
cd $MY_PROJECT_NAME
|
||||
rm -rf .git
|
||||
git init
|
||||
git add .
|
||||
git commit -m "biff-sqlite starter"
|
||||
clj-kondo --lint "$(clojure -Spath)" --dependencies --parallel --copy-configs
|
||||
clj -M:dev dev
|
||||
```
|
||||
|
||||
Run `clj -M:dev dev` like usual and then connect with a REPL on :7888.
|
||||
|
||||
You may want to rename the `com.biffweb` namespace prefix to your own, as well
|
||||
as change the strings `my_project` and `my-project` everywhere they're present.
|
||||
That's left as an exercise for the reader at this time.
|
||||
|
||||
# Deploying to production
|
||||
|
||||
I intend to deploy projects built off this template with fly.io but I haven't
|
||||
set everything up for that just yet. Essentially you need to configure a fly
|
||||
volume, make an SQLite db there, then point the #prod DB_URL at it.
|
||||
1
cljfmt-indents.edn
Normal file
1
cljfmt-indents.edn
Normal file
|
|
@ -0,0 +1 @@
|
|||
{submit-tx [[:inner 0]]}
|
||||
45
deps.edn
Normal file
45
deps.edn
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{:paths ["src" "resources" "target/resources"]
|
||||
:deps {com.biffweb/biff {:git/url "https://github.com/jacobobryant/biff"
|
||||
:git/sha "ada149e"
|
||||
:git/tag "v1.8.2"
|
||||
:exclusions [com.xtdb/xtdb-core
|
||||
com.xtdb/xtdb-rocksdb
|
||||
com.xtdb/xtdb-jdbc]}
|
||||
|
||||
com.biffweb/xtdb-mock {:git/url "https://github.com/jacobobryant/biff"
|
||||
:git/sha "92d78a1"
|
||||
:git/tag "v0.7.18"
|
||||
:deps/root "libs/xtdb-mock"}
|
||||
|
||||
com.github.seancorfield/next.jdbc {:mvn/version "1.3.894"}
|
||||
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.3"}
|
||||
com.github.clj-easy/graal-build-time {:mvn/version "1.0.5"}
|
||||
|
||||
;; 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"}
|
||||
org.slf4j/slf4j-api {:mvn/version "2.0.13"}
|
||||
|
||||
com.taoensso/slf4j-telemere {:mvn/version "1.0.0-beta14"}
|
||||
com.taoensso/telemere {:mvn/version "1.0.0-beta14"}
|
||||
|
||||
org.xerial/sqlite-jdbc {:mvn/version "3.45.2.0"}
|
||||
com.github.seancorfield/honeysql {:mvn/version "2.6.1126"}
|
||||
migratus/migratus {:mvn/version "1.5.6"}}
|
||||
|
||||
:aliases
|
||||
{:dev {:extra-deps {com.biffweb/tasks {:git/url "https://github.com/jacobobryant/biff", :git/sha "ada149e", :git/tag "v1.8.2", :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" "com.biffweb.my-project"]}}}
|
||||
98
dev/repl.clj
Normal file
98
dev/repl.clj
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
(ns repl
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[com.biffweb :as biff]
|
||||
[com.biffweb.my-project :as main]
|
||||
[com.biffweb.my-project.util.db :as db]
|
||||
[honey.sql :as sql]
|
||||
[honey.sql.helpers :refer [drop-table]]
|
||||
[migratus.core :as migratus]
|
||||
[next.jdbc :as jdbc]))
|
||||
|
||||
;; 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 []
|
||||
(let [{:keys [example/ds] :as _ctx} (get-context)]
|
||||
(jdbc/execute! ds (db/new-user-statement "a@example.com"))))
|
||||
|
||||
(defn reset-db! []
|
||||
(let [{:keys [example/ds]} (get-context)
|
||||
tables [:users :auth_code]
|
||||
drop-statements (map (fn [table] (sql/format (drop-table :if-exists table))) tables)]
|
||||
(db/execute-all! ds drop-statements)
|
||||
|
||||
(jdbc/execute! ds [(slurp (io/resource "migrations.sql"))])))
|
||||
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(defn new-migration [migration-name]
|
||||
(migratus/create (com.biffweb.my-project/ctx->migratus-config (get-context)) migration-name))
|
||||
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(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
|
||||
:postmark/api-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, you can reset the database by calling reset-db!
|
||||
;; (DON'T do that in prod) and calling add-fixtures again.
|
||||
(reset-db!)
|
||||
(add-fixtures)
|
||||
|
||||
;; Create a user
|
||||
(let [{:keys [example/ds] :as _ctx} (get-context)]
|
||||
(jdbc/execute! ds (db/new-user-statement "hello@example.com")))
|
||||
|
||||
;; Query the database
|
||||
(let [{:keys [example/ds] :as _ctx} (get-context)]
|
||||
(jdbc/execute! ds (sql/format {:select :* :from :users})))
|
||||
|
||||
;; Update an existing user's email address
|
||||
(let [{:keys [example/ds] :as _ctx} (get-context)]
|
||||
(jdbc/execute! ds (sql/format {:update :users
|
||||
:set {:email "new.address@example.com"}
|
||||
:where [:= :email "hello@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"})))
|
||||
50
dev/tasks.clj
Normal file
50
dev/tasks.clj
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
(ns tasks
|
||||
(:require [com.biffweb.task-runner :refer [run-task]]
|
||||
[com.biffweb.tasks :as tasks]
|
||||
[com.biffweb.tasks.lazy.babashka.fs :as fs]
|
||||
[com.biffweb.tasks.lazy.babashka.process :refer [shell]]
|
||||
[com.biffweb.tasks.lazy.clojure.java.io :as io]
|
||||
[com.biffweb.tasks.lazy.com.biffweb.config :as config]))
|
||||
|
||||
(def config (delay (config/use-aero-config {:biff.config/skip-validation true})))
|
||||
|
||||
(defn hello
|
||||
"Says 'Hello'"
|
||||
[]
|
||||
(println "Hello"))
|
||||
|
||||
(defn dev
|
||||
"Starts the app locally.
|
||||
|
||||
After running, wait for the `System started` message. Connect your editor to
|
||||
nrepl port 7888 (by default). Whenever you save a file, Biff will:
|
||||
|
||||
- Evaluate any changed Clojure files
|
||||
- Regenerate static HTML files
|
||||
- Run tests"
|
||||
[]
|
||||
(if-not (fs/exists? "target/resources")
|
||||
;; This is an awful hack. We have to run the app in a new process, otherwise
|
||||
;; target/resources won't be included in the classpath. Downside of not
|
||||
;; using bb tasks anymore -- no longer have a lightweight parent process
|
||||
;; that can create the directory before starting the JVM.
|
||||
(do
|
||||
(io/make-parents "target/resources/_")
|
||||
(shell "clj" "-M:dev" "dev"))
|
||||
(let [{:keys [biff.tasks/main-ns biff.nrepl/port] :as _ctx} @config]
|
||||
|
||||
(when-not (fs/exists? "config.env")
|
||||
(run-task "generate-config"))
|
||||
(when (fs/exists? "package.json")
|
||||
(shell "npm install"))
|
||||
(spit ".nrepl-port" port)
|
||||
((requiring-resolve (symbol (str main-ns) "-main"))))))
|
||||
|
||||
;; Tasks should be vars (#'hello instead of hello) so that `clj -M:dev help` can
|
||||
;; print their docstrings.
|
||||
(def custom-tasks
|
||||
{"hello" #'hello
|
||||
"dev" #'dev})
|
||||
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(def tasks (merge tasks/tasks custom-tasks))
|
||||
46
resources/config.edn
Normal file
46
resources/config.edn
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
;; 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 "http://localhost:8080"}
|
||||
:biff/host #profile {:dev "0.0.0.0"
|
||||
:default "localhost"}
|
||||
:biff/port 8080
|
||||
|
||||
:example/db-url #profile {:prod "jdbc:sqlite:storage/site.db"
|
||||
:dev "jdbc:sqlite:storage/site.db"}
|
||||
|
||||
:biff.beholder/enabled #profile {:dev true :default false}
|
||||
: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}
|
||||
|
||||
:postmark/api-key #biff/secret POSTMARK_API_KEY
|
||||
:postmark/from #biff/env POSTMARK_FROM
|
||||
|
||||
: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 com.biffweb.my-project
|
||||
:biff.tasks/on-soft-deploy "\"(com.biffweb.my-project/on-save @com.biffweb.my-project/system)\""
|
||||
:biff.tasks/generate-assets-fn com.biffweb.my-project/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"]
|
||||
|
||||
;; Uncomment this line if you're on Windows/don't have rsync and your local branch is
|
||||
;; called main instead of master:
|
||||
;; :biff.tasks/deploy-cmd ["git" "push" "prod" "main:master"]
|
||||
:biff.tasks/deploy-cmd ["git" "push" "prod" "master"]
|
||||
;; Uncomment this line if you have any ssh-related problems:
|
||||
;; :biff.tasks/skip-ssh-agent true
|
||||
}
|
||||
5
resources/config.env
Normal file
5
resources/config.env
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
BIFF_PROFILE="dev"
|
||||
COOKIE_SECRET=t6JiKWp/L4wfZ+9C+5WFUA==
|
||||
JWT_SECRET=RFswnM9hACuUxhMOX8l4UvTQqoWz0yUoaRXYwnoaPdE=
|
||||
31
resources/config.template.env
Normal file
31
resources/config.template.env
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# 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
|
||||
|
||||
# Postmark is used to send email sign-in links. Sign up at https://postmarkapp.com/
|
||||
POSTMARK_API_KEY=
|
||||
# Change to the address of your sending identity. Set a reply-to address on your sending identity if
|
||||
# you want to receive replies and your from address isn't configured for receiving.
|
||||
POSTMARK_FROM=
|
||||
|
||||
# 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=
|
||||
|
||||
# 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 }}
|
||||
|
||||
DEV_POSTGRES_URL=postgresql://user:abc123@localhost:5432/main
|
||||
#PROD_POSTGRES_URL=...
|
||||
1
resources/migrations/20240331175207-create-user.down.sql
Normal file
1
resources/migrations/20240331175207-create-user.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DELETE TABLE IF EXISTS users;
|
||||
7
resources/migrations/20240331175207-create-user.up.sql
Normal file
7
resources/migrations/20240331175207-create-user.up.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE IF NOT EXISTS users (
|
||||
id uuid PRIMARY KEY,
|
||||
email text NOT NULL UNIQUE,
|
||||
joined_at timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||
foo text,
|
||||
bar text
|
||||
);
|
||||
|
|
@ -0,0 +1 @@
|
|||
DELETE TABLE IF EXISTS auth_code;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE IF NOT EXISTS auth_code (
|
||||
id uuid PRIMARY KEY,
|
||||
email text NOT NULL UNIQUE,
|
||||
code text NOT NULL,
|
||||
created_at timestamp NOT NULL,
|
||||
failed_attempts integer DEFAULT 0
|
||||
);
|
||||
4
resources/public/css/pico.colors.min.css
vendored
Normal file
4
resources/public/css/pico.colors.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
resources/public/css/pico.min.css
vendored
Normal file
4
resources/public/css/pico.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
resources/public/img/glider.png
Normal file
BIN
resources/public/img/glider.png
Normal file
Binary file not shown.
45
resources/public/js/htmx-1.9.11-ext-multi-swap.min.js
vendored
Normal file
45
resources/public/js/htmx-1.9.11-ext-multi-swap.min.js
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
(function () {
|
||||
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api;
|
||||
|
||||
htmx.defineExtension('multi-swap', {
|
||||
init: function (apiRef) {
|
||||
api = apiRef;
|
||||
},
|
||||
isInlineSwap: function (swapStyle) {
|
||||
return swapStyle.indexOf('multi:') === 0;
|
||||
},
|
||||
handleSwap: function (swapStyle, target, fragment, settleInfo) {
|
||||
if (swapStyle.indexOf('multi:') === 0) {
|
||||
var selectorToSwapStyle = {};
|
||||
var elements = swapStyle.replace(/^multi\s*:\s*/, '').split(/\s*,\s*/);
|
||||
|
||||
elements.map(function (element) {
|
||||
var split = element.split(/\s*:\s*/);
|
||||
var elementSelector = split[0];
|
||||
var elementSwapStyle = typeof (split[1]) !== "undefined" ? split[1] : "innerHTML";
|
||||
|
||||
if (elementSelector.charAt(0) !== '#') {
|
||||
console.error("HTMX multi-swap: unsupported selector '" + elementSelector + "'. Only ID selectors starting with '#' are supported.");
|
||||
return;
|
||||
}
|
||||
|
||||
selectorToSwapStyle[elementSelector] = elementSwapStyle;
|
||||
});
|
||||
|
||||
for (var selector in selectorToSwapStyle) {
|
||||
var swapStyle = selectorToSwapStyle[selector];
|
||||
var elementToSwap = fragment.querySelector(selector);
|
||||
if (elementToSwap) {
|
||||
api.oobSwap(swapStyle, elementToSwap, settleInfo);
|
||||
} else {
|
||||
console.warn("HTMX multi-swap: selector '" + selector + "' not found in source content.");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
476
resources/public/js/htmx-1.9.11-ext-ws.min.js
vendored
Normal file
476
resources/public/js/htmx-1.9.11-ext-ws.min.js
vendored
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
/*
|
||||
WebSockets Extension
|
||||
============================
|
||||
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api;
|
||||
|
||||
htmx.defineExtension("ws", {
|
||||
|
||||
/**
|
||||
* init is called once, when this extension is first registered.
|
||||
* @param {import("../htmx").HtmxInternalApi} apiRef
|
||||
*/
|
||||
init: function (apiRef) {
|
||||
|
||||
// Store reference to internal API
|
||||
api = apiRef;
|
||||
|
||||
// Default function for creating new EventSource objects
|
||||
if (!htmx.createWebSocket) {
|
||||
htmx.createWebSocket = createWebSocket;
|
||||
}
|
||||
|
||||
// Default setting for reconnect delay
|
||||
if (!htmx.config.wsReconnectDelay) {
|
||||
htmx.config.wsReconnectDelay = "full-jitter";
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
*/
|
||||
onEvent: function (name, evt) {
|
||||
var parent = evt.target || evt.detail.elt;
|
||||
|
||||
switch (name) {
|
||||
|
||||
// Try to close the socket when elements are removed
|
||||
case "htmx:beforeCleanupElement":
|
||||
|
||||
var internalData = api.getInternalData(parent)
|
||||
|
||||
if (internalData.webSocket) {
|
||||
internalData.webSocket.close();
|
||||
}
|
||||
return;
|
||||
|
||||
// Try to create websockets when elements are processed
|
||||
case "htmx:beforeProcessNode":
|
||||
forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function (child) {
|
||||
ensureWebSocket(child)
|
||||
});
|
||||
forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) {
|
||||
ensureWebSocketSend(child)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function splitOnWhitespace(trigger) {
|
||||
return trigger.trim().split(/\s+/);
|
||||
}
|
||||
|
||||
function getLegacyWebsocketURL(elt) {
|
||||
var legacySSEValue = api.getAttributeValue(elt, "hx-ws");
|
||||
if (legacySSEValue) {
|
||||
var values = splitOnWhitespace(legacySSEValue);
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var value = values[i].split(/:(.+)/);
|
||||
if (value[0] === "connect") {
|
||||
return value[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocket creates a new WebSocket on the designated element, using
|
||||
* the element's "ws-connect" attribute.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @returns
|
||||
*/
|
||||
function ensureWebSocket(socketElt) {
|
||||
|
||||
// If the element containing the WebSocket connection no longer exists, then
|
||||
// do not connect/reconnect the WebSocket.
|
||||
if (!api.bodyContains(socketElt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the source straight from the element's value
|
||||
var wssSource = api.getAttributeValue(socketElt, "ws-connect")
|
||||
|
||||
if (wssSource == null || wssSource === "") {
|
||||
var legacySource = getLegacyWebsocketURL(socketElt);
|
||||
if (legacySource == null) {
|
||||
return;
|
||||
} else {
|
||||
wssSource = legacySource;
|
||||
}
|
||||
}
|
||||
|
||||
// Guarantee that the wssSource value is a fully qualified URL
|
||||
if (wssSource.indexOf("/") === 0) {
|
||||
var base_part = location.hostname + (location.port ? ':' + location.port : '');
|
||||
if (location.protocol === 'https:') {
|
||||
wssSource = "wss://" + base_part + wssSource;
|
||||
} else if (location.protocol === 'http:') {
|
||||
wssSource = "ws://" + base_part + wssSource;
|
||||
}
|
||||
}
|
||||
|
||||
var socketWrapper = createWebsocketWrapper(socketElt, function () {
|
||||
return htmx.createWebSocket(wssSource)
|
||||
});
|
||||
|
||||
socketWrapper.addEventListener('message', function (event) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var response = event.data;
|
||||
if (!api.triggerEvent(socketElt, "htmx:wsBeforeMessage", {
|
||||
message: response,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.withExtensions(socketElt, function (extension) {
|
||||
response = extension.transformResponse(response, null, socketElt);
|
||||
});
|
||||
|
||||
var settleInfo = api.makeSettleInfo(socketElt);
|
||||
var fragment = api.makeFragment(response);
|
||||
|
||||
if (fragment.children.length) {
|
||||
var children = Array.from(fragment.children);
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
api.oobSwap(api.getAttributeValue(children[i], "hx-swap-oob") || "true", children[i], settleInfo);
|
||||
}
|
||||
}
|
||||
|
||||
api.settleImmediately(settleInfo.tasks);
|
||||
api.triggerEvent(socketElt, "htmx:wsAfterMessage", { message: response, socketWrapper: socketWrapper.publicInterface })
|
||||
});
|
||||
|
||||
// Put the WebSocket into the HTML Element's custom data.
|
||||
api.getInternalData(socketElt).webSocket = socketWrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} WebSocketWrapper
|
||||
* @property {WebSocket} socket
|
||||
* @property {Array<{message: string, sendElt: Element}>} messageQueue
|
||||
* @property {number} retryCount
|
||||
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
|
||||
* @property {(message: string, sendElt: Element) => void} send
|
||||
* @property {(event: string, handler: Function) => void} addEventListener
|
||||
* @property {() => void} handleQueuedMessages
|
||||
* @property {() => void} init
|
||||
* @property {() => void} close
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* @param socketElt
|
||||
* @param socketFunc
|
||||
* @returns {WebSocketWrapper}
|
||||
*/
|
||||
function createWebsocketWrapper(socketElt, socketFunc) {
|
||||
var wrapper = {
|
||||
socket: null,
|
||||
messageQueue: [],
|
||||
retryCount: 0,
|
||||
|
||||
/** @type {Object<string, Function[]>} */
|
||||
events: {},
|
||||
|
||||
addEventListener: function (event, handler) {
|
||||
if (this.socket) {
|
||||
this.socket.addEventListener(event, handler);
|
||||
}
|
||||
|
||||
if (!this.events[event]) {
|
||||
this.events[event] = [];
|
||||
}
|
||||
|
||||
this.events[event].push(handler);
|
||||
},
|
||||
|
||||
sendImmediately: function (message, sendElt) {
|
||||
if (!this.socket) {
|
||||
api.triggerErrorEvent()
|
||||
}
|
||||
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
|
||||
message: message,
|
||||
socketWrapper: this.publicInterface
|
||||
})) {
|
||||
this.socket.send(message);
|
||||
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
|
||||
message: message,
|
||||
socketWrapper: this.publicInterface
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
send: function (message, sendElt) {
|
||||
if (this.socket.readyState !== this.socket.OPEN) {
|
||||
this.messageQueue.push({ message: message, sendElt: sendElt });
|
||||
} else {
|
||||
this.sendImmediately(message, sendElt);
|
||||
}
|
||||
},
|
||||
|
||||
handleQueuedMessages: function () {
|
||||
while (this.messageQueue.length > 0) {
|
||||
var queuedItem = this.messageQueue[0]
|
||||
if (this.socket.readyState === this.socket.OPEN) {
|
||||
this.sendImmediately(queuedItem.message, queuedItem.sendElt);
|
||||
this.messageQueue.shift();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
init: function () {
|
||||
if (this.socket && this.socket.readyState === this.socket.OPEN) {
|
||||
// Close discarded socket
|
||||
this.socket.close()
|
||||
}
|
||||
|
||||
// Create a new WebSocket and event handlers
|
||||
/** @type {WebSocket} */
|
||||
var socket = socketFunc();
|
||||
|
||||
// The event.type detail is added for interface conformance with the
|
||||
// other two lifecycle events (open and close) so a single handler method
|
||||
// can handle them polymorphically, if required.
|
||||
api.triggerEvent(socketElt, "htmx:wsConnecting", { event: { type: 'connecting' } });
|
||||
|
||||
this.socket = socket;
|
||||
|
||||
socket.onopen = function (e) {
|
||||
wrapper.retryCount = 0;
|
||||
api.triggerEvent(socketElt, "htmx:wsOpen", { event: e, socketWrapper: wrapper.publicInterface });
|
||||
wrapper.handleQueuedMessages();
|
||||
}
|
||||
|
||||
socket.onclose = function (e) {
|
||||
// If socket should not be connected, stop further attempts to establish connection
|
||||
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
|
||||
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
|
||||
var delay = getWebSocketReconnectDelay(wrapper.retryCount);
|
||||
setTimeout(function () {
|
||||
wrapper.retryCount += 1;
|
||||
wrapper.init();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// Notify client code that connection has been closed. Client code can inspect `event` field
|
||||
// to determine whether closure has been valid or abnormal
|
||||
api.triggerEvent(socketElt, "htmx:wsClose", { event: e, socketWrapper: wrapper.publicInterface })
|
||||
};
|
||||
|
||||
socket.onerror = function (e) {
|
||||
api.triggerErrorEvent(socketElt, "htmx:wsError", { error: e, socketWrapper: wrapper });
|
||||
maybeCloseWebSocketSource(socketElt);
|
||||
};
|
||||
|
||||
var events = this.events;
|
||||
Object.keys(events).forEach(function (k) {
|
||||
events[k].forEach(function (e) {
|
||||
socket.addEventListener(k, e);
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
close: function () {
|
||||
this.socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
wrapper.init();
|
||||
|
||||
wrapper.publicInterface = {
|
||||
send: wrapper.send.bind(wrapper),
|
||||
sendImmediately: wrapper.sendImmediately.bind(wrapper),
|
||||
queue: wrapper.messageQueue
|
||||
};
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocketSend attaches trigger handles to elements with
|
||||
* "ws-send" attribute
|
||||
* @param {HTMLElement} elt
|
||||
*/
|
||||
function ensureWebSocketSend(elt) {
|
||||
var legacyAttribute = api.getAttributeValue(elt, "hx-ws");
|
||||
if (legacyAttribute && legacyAttribute !== 'send') {
|
||||
return;
|
||||
}
|
||||
|
||||
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
|
||||
processWebSocketSend(webSocketParent, elt);
|
||||
}
|
||||
|
||||
/**
|
||||
* hasWebSocket function checks if a node has webSocket instance attached
|
||||
* @param {HTMLElement} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasWebSocket(node) {
|
||||
return api.getInternalData(node).webSocket != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* processWebSocketSend adds event listeners to the <form> element so that
|
||||
* messages can be sent to the WebSocket server when the form is submitted.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @param {HTMLElement} sendElt
|
||||
*/
|
||||
function processWebSocketSend(socketElt, sendElt) {
|
||||
var nodeData = api.getInternalData(sendElt);
|
||||
var triggerSpecs = api.getTriggerSpecs(sendElt);
|
||||
triggerSpecs.forEach(function (ts) {
|
||||
api.addTriggerHandler(sendElt, ts, nodeData, function (elt, evt) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {WebSocketWrapper} */
|
||||
var socketWrapper = api.getInternalData(socketElt).webSocket;
|
||||
var headers = api.getHeaders(sendElt, api.getTarget(sendElt));
|
||||
var results = api.getInputValues(sendElt, 'post');
|
||||
var errors = results.errors;
|
||||
var rawParameters = results.values;
|
||||
var expressionVars = api.getExpressionVars(sendElt);
|
||||
var allParameters = api.mergeObjects(rawParameters, expressionVars);
|
||||
var filteredParameters = api.filterValues(allParameters, sendElt);
|
||||
|
||||
var sendConfig = {
|
||||
parameters: filteredParameters,
|
||||
unfilteredParameters: allParameters,
|
||||
headers: headers,
|
||||
errors: errors,
|
||||
|
||||
triggeringEvent: evt,
|
||||
messageBody: undefined,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
};
|
||||
|
||||
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (errors && errors.length > 0) {
|
||||
api.triggerEvent(elt, 'htmx:validation:halted', errors);
|
||||
return;
|
||||
}
|
||||
|
||||
var body = sendConfig.messageBody;
|
||||
if (body === undefined) {
|
||||
var toSend = Object.assign({}, sendConfig.parameters);
|
||||
if (sendConfig.headers)
|
||||
toSend['HEADERS'] = headers;
|
||||
body = JSON.stringify(toSend);
|
||||
}
|
||||
|
||||
socketWrapper.send(body, elt);
|
||||
|
||||
if (evt && api.shouldCancel(evt, elt)) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
|
||||
* @param {number} retryCount // The number of retries that have already taken place
|
||||
* @returns {number}
|
||||
*/
|
||||
function getWebSocketReconnectDelay(retryCount) {
|
||||
|
||||
/** @type {"full-jitter" | ((retryCount:number) => number)} */
|
||||
var delay = htmx.config.wsReconnectDelay;
|
||||
if (typeof delay === 'function') {
|
||||
return delay(retryCount);
|
||||
}
|
||||
if (delay === 'full-jitter') {
|
||||
var exp = Math.min(retryCount, 6);
|
||||
var maxDelay = 1000 * Math.pow(2, exp);
|
||||
return maxDelay * Math.random();
|
||||
}
|
||||
|
||||
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"');
|
||||
}
|
||||
|
||||
/**
|
||||
* maybeCloseWebSocketSource checks to the if the element that created the WebSocket
|
||||
* still exists in the DOM. If NOT, then the WebSocket is closed and this function
|
||||
* returns TRUE. If the element DOES EXIST, then no action is taken, and this function
|
||||
* returns FALSE.
|
||||
*
|
||||
* @param {*} elt
|
||||
* @returns
|
||||
*/
|
||||
function maybeCloseWebSocketSource(elt) {
|
||||
if (!api.bodyContains(elt)) {
|
||||
api.getInternalData(elt).webSocket.close();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* createWebSocket is the default method for creating new WebSocket objects.
|
||||
* it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns WebSocket
|
||||
*/
|
||||
function createWebSocket(url) {
|
||||
var sock = new WebSocket(url, []);
|
||||
sock.binaryType = htmx.config.wsBinaryType;
|
||||
return sock;
|
||||
}
|
||||
|
||||
/**
|
||||
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} attributeName
|
||||
*/
|
||||
function queryAttributeOnThisOrChildren(elt, attributeName) {
|
||||
|
||||
var result = []
|
||||
|
||||
// If the parent element also contains the requested attribute, then add it to the results too.
|
||||
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-ws")) {
|
||||
result.push(elt);
|
||||
}
|
||||
|
||||
// Search all child nodes that match the requested attribute
|
||||
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [data-hx-ws], [hx-ws]").forEach(function (node) {
|
||||
result.push(node)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T[]} arr
|
||||
* @param {(T) => void} func
|
||||
*/
|
||||
function forEach(arr, func) {
|
||||
if (arr) {
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
func(arr[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
1
resources/public/js/htmx-1.9.11.min.js
vendored
Normal file
1
resources/public/js/htmx-1.9.11.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
resources/public/js/hyperscript-0.9.8.min.js
vendored
Normal file
1
resources/public/js/hyperscript-0.9.8.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
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.
|
||||
126
server-setup.sh
Normal file
126
server-setup.sh
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
#!/usr/bin/env bash
|
||||
set -x
|
||||
set -e
|
||||
|
||||
BIFF_PROFILE=${1:-prod}
|
||||
CLJ_VERSION=1.11.1.1165
|
||||
TRENCH_VERSION=0.4.0
|
||||
TRENCH_FILE=trenchman_${TRENCH_VERSION}_linux_amd64.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)
|
||||
wget https://github.com/athos/trenchman/releases/download/v$TRENCH_VERSION/$TRENCH_FILE
|
||||
mkdir .trench_tmp
|
||||
tar -xf $TRENCH_FILE --directory .trench_tmp
|
||||
mv .trench_tmp/trench /usr/local/bin/
|
||||
rm -rf $TRENCH_FILE .trench_tmp
|
||||
|
||||
# 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 ...
|
||||
107
src/com/biffweb/my_project.clj
Normal file
107
src/com/biffweb/my_project.clj
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
(ns com.biffweb.my-project
|
||||
(:require
|
||||
[migratus.core :as migratus]
|
||||
[clojure.test :as test]
|
||||
;; [clojure.tools.logging :as log]
|
||||
[clojure.tools.namespace.repl :as tn-repl]
|
||||
[com.biffweb :as biff]
|
||||
[com.biffweb.my-project.app :as app]
|
||||
[com.biffweb.my-project.auth-module :as auth-module]
|
||||
[com.biffweb.my-project.email :as email]
|
||||
[com.biffweb.my-project.home :as home]
|
||||
[com.biffweb.my-project.middleware :as mid]
|
||||
[com.biffweb.my-project.ui :as ui]
|
||||
[com.biffweb.my-project.worker :as worker]
|
||||
[taoensso.telemere :as t]
|
||||
[taoensso.telemere.tools-logging :as tlog]
|
||||
[taoensso.telemere.timbre :as log]
|
||||
[next.jdbc :as jdbc]
|
||||
[nrepl.cmdline :as nrepl-cmd])
|
||||
(:gen-class))
|
||||
|
||||
(def modules
|
||||
[app/module
|
||||
(auth-module/module {})
|
||||
home/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)
|
||||
(biff/catchall (require 'com.biffweb.my-project-test))
|
||||
(test/run-all-tests #"com.biffweb.my-project.*-test"))
|
||||
|
||||
(def initial-system
|
||||
{:biff/modules #'modules
|
||||
:biff/merge-context-fn identity
|
||||
:biff/send-email #'email/send-email
|
||||
:biff/handler #'handler
|
||||
:biff.beholder/on-save #'on-save
|
||||
:biff.middleware/on-error #'ui/on-error
|
||||
:example/chat-clients (atom #{})})
|
||||
|
||||
(defonce system (atom {}))
|
||||
|
||||
(defn ctx->migratus-config [ctx]
|
||||
{:store :database
|
||||
:migration-dir "migrations/"
|
||||
:migration-table-name "migrations"
|
||||
:db {:connection (jdbc/get-connection (:example/db-url ctx))
|
||||
:managed-connection? true}})
|
||||
|
||||
(defn use-sqlite [ctx]
|
||||
(let [db-url (get ctx :example/db-url)
|
||||
ds (jdbc/get-datasource db-url)
|
||||
migration-config (ctx->migratus-config ctx)]
|
||||
|
||||
(migratus/init migration-config)
|
||||
(migratus/migrate migration-config)
|
||||
(assoc ctx :example/ds ds)))
|
||||
|
||||
(def components
|
||||
[biff/use-aero-config
|
||||
use-sqlite
|
||||
biff/use-queues
|
||||
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)
|
||||
64
src/com/biffweb/my_project/app.clj
Normal file
64
src/com/biffweb/my_project/app.clj
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
(ns com.biffweb.my-project.app
|
||||
(:require [com.biffweb :as biff]
|
||||
[com.biffweb.my-project.middleware :as mid]
|
||||
[com.biffweb.my-project.ui :as ui]
|
||||
[honey.sql :as sql]
|
||||
[com.biffweb.my-project.settings :as settings]
|
||||
[next.jdbc :as jdbc]))
|
||||
|
||||
(defn bar-form [{:keys [value]}]
|
||||
(biff/form
|
||||
{:hx-post "/app/set-bar"
|
||||
:hx-swap "outerHTML"}
|
||||
[:label {:for "bar"} "Bar: "
|
||||
[:span (pr-str value)]]
|
||||
[:div
|
||||
[:input#bar {:type "text" :name "bar" :value value}]
|
||||
[:button {:type "submit"} "Update"]]
|
||||
"This demonstrates updating a value with HTMX."))
|
||||
|
||||
(defn set-bar [{:keys [example/ds session params] :as _ctx}]
|
||||
(jdbc/execute! ds (sql/format {:update :users
|
||||
:set {:bar (:bar params)}
|
||||
:where [:= :id (:uid session)]}))
|
||||
(biff/render (bar-form {:value (:bar params)})))
|
||||
|
||||
(defn app [{:keys [session example/ds] :as _ctx}]
|
||||
(let [query (sql/format {:select [:*]
|
||||
:from [:users] :where [:= :id (:uid session)]})
|
||||
{:users/keys [email bar]} (jdbc/execute-one! ds query)]
|
||||
(ui/page
|
||||
{}
|
||||
[:header.container
|
||||
[:hgroup
|
||||
{:style
|
||||
{:display "flex"
|
||||
:align-items "center"
|
||||
:justify-content "space-between"}}
|
||||
"some text for " email
|
||||
(biff/form
|
||||
{:action "/auth/signout"
|
||||
:class "inline"}
|
||||
[:button {:type "submit"}
|
||||
"Sign out"])]]
|
||||
|
||||
(bar-form {:value bar}))))
|
||||
|
||||
(def about-page
|
||||
(ui/page
|
||||
{:base/title (str "About " settings/app-name)}
|
||||
[:p "This app was made with "
|
||||
[:a {: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-bar" {:post set-bar}]]
|
||||
:api-routes [["/api/echo" {:post echo}]]})
|
||||
225
src/com/biffweb/my_project/auth_module.clj
Normal file
225
src/com/biffweb/my_project/auth_module.clj
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
(ns com.biffweb.my-project.auth-module
|
||||
(:require
|
||||
[com.biffweb :as biff]
|
||||
[com.biffweb.my-project.util.db :as db]
|
||||
[honey.sql :as sql]
|
||||
[next.jdbc :as jdbc]))
|
||||
|
||||
(defn email-valid? [_ctx email]
|
||||
(and email
|
||||
(re-matches #".+@.+\..+" email)
|
||||
(not (re-find #"\s" email))))
|
||||
|
||||
(defn new-link [{:keys [biff.auth/check-state
|
||||
biff/base-url
|
||||
biff/secret
|
||||
anti-forgery-token]}
|
||||
email]
|
||||
(str base-url "/auth/verify-link/"
|
||||
(biff/jwt-encrypt
|
||||
(cond-> {:intent "signin"
|
||||
:email email
|
||||
:exp-in (* 60 60)}
|
||||
check-state (assoc :state (biff/sha256 anti-forgery-token)))
|
||||
(secret :biff/jwt-secret))))
|
||||
|
||||
(defn new-code [length]
|
||||
;; We use (SecureRandom.) instead of (SecureRandom/getInstanceStrong) because
|
||||
;; the latter can block, and on some shared hosts often does. Blocking is
|
||||
;; fine for e.g. generating environment variables in a new project, but we
|
||||
;; don't want to block here.
|
||||
;; https://tersesystems.com/blog/2015/12/17/the-right-way-to-use-securerandom/
|
||||
(let [rng (java.security.SecureRandom.)]
|
||||
(format (str "%0" length "d")
|
||||
(.nextInt rng (dec (int (Math/pow 10 length)))))))
|
||||
|
||||
(defn send-link! [{:keys [biff.auth/email-validator
|
||||
biff/send-email
|
||||
params]
|
||||
:as ctx}]
|
||||
(let [email (biff/normalize-email (:email params))
|
||||
url (new-link ctx email)
|
||||
user-id (delay (db/get-user-id ctx email))]
|
||||
(cond
|
||||
(not (email-validator ctx email))
|
||||
{:success false :error "invalid-email"}
|
||||
|
||||
(not (send-email ctx
|
||||
{:template :signin-link
|
||||
:to email
|
||||
:url url
|
||||
:user-exists (some? @user-id)}))
|
||||
{:success false :error "send-failed"}
|
||||
|
||||
:else
|
||||
{:success true :email email :user-id @user-id})))
|
||||
|
||||
(defn verify-link [{:keys [biff.auth/check-state
|
||||
biff/secret
|
||||
path-params
|
||||
params
|
||||
anti-forgery-token]}]
|
||||
(let [{:keys [intent email state]} (-> (merge params path-params)
|
||||
:token
|
||||
(biff/jwt-decrypt (secret :biff/jwt-secret)))
|
||||
valid-state (= state (biff/sha256 anti-forgery-token))
|
||||
valid-email (= email (:email params))]
|
||||
(cond
|
||||
(not= intent "signin")
|
||||
{:success false :error "invalid-link"}
|
||||
|
||||
(or (not check-state) valid-state valid-email)
|
||||
{:success true :email email}
|
||||
|
||||
(some? (:email params))
|
||||
{:success false :error "invalid-email"}
|
||||
|
||||
:else
|
||||
{:success false :error "invalid-state"})))
|
||||
|
||||
(defn send-code! [{:keys [biff.auth/email-validator
|
||||
biff/send-email
|
||||
params]
|
||||
:as ctx}]
|
||||
(let [email (biff/normalize-email (:email params))
|
||||
code (new-code 6)
|
||||
user-id (delay (db/get-user-id ctx email))]
|
||||
(cond
|
||||
(not (email-validator ctx email))
|
||||
{:success false :error "invalid-email"}
|
||||
|
||||
(not (send-email ctx
|
||||
{:template :signin-code
|
||||
:to email
|
||||
:code code
|
||||
:user-exists (some? @user-id)}))
|
||||
{:success false :error "send-failed"}
|
||||
|
||||
:else
|
||||
{:success true :email email :code code :user-id @user-id})))
|
||||
|
||||
;;; HANDLERS -------------------------------------------------------------------
|
||||
|
||||
(defn send-link-handler [{:keys [biff.auth/single-opt-in
|
||||
example/ds
|
||||
params]
|
||||
:as ctx}]
|
||||
(let [{:keys [success error email user-id]} (send-link! ctx)]
|
||||
(when (and success single-opt-in (not user-id))
|
||||
(jdbc/execute! ds (db/new-user-statement email)))
|
||||
{:status 303
|
||||
:headers {"location" (if success
|
||||
(str "/link-sent?email=" (:email params))
|
||||
(str (:on-error params "/") "?error=" error))}}))
|
||||
|
||||
(defn verify-link-handler [{:keys [biff.auth/app-path
|
||||
biff.auth/invalid-link-path
|
||||
example/ds
|
||||
session
|
||||
params
|
||||
path-params]
|
||||
:as ctx}]
|
||||
(let [{:keys [success error email]} (verify-link ctx)
|
||||
existing-user-id (when success (db/get-user-id ctx email))
|
||||
token (:token (merge params path-params))]
|
||||
(when (and success (not existing-user-id))
|
||||
(jdbc/execute! ds (db/new-user-statement email)))
|
||||
{:status 303
|
||||
:headers {"location" (cond
|
||||
success
|
||||
app-path
|
||||
|
||||
(= error "invalid-state")
|
||||
(str "/verify-link?token=" token)
|
||||
|
||||
(= error "invalid-email")
|
||||
(str "/verify-link?error=incorrect-email&token=" token)
|
||||
|
||||
:else
|
||||
invalid-link-path)}
|
||||
:session (cond-> session
|
||||
success (assoc :uid (or existing-user-id
|
||||
(db/get-user-id ctx email))))}))
|
||||
|
||||
(defn send-code-handler [{:keys [biff.auth/single-opt-in
|
||||
params]
|
||||
:as ctx}]
|
||||
(let [{:keys [success error email code user-id]} (send-code! ctx)
|
||||
statements (when success
|
||||
(concat
|
||||
[[(str "INSERT INTO auth_code (id, email, code, created_at, failed_attempts) VALUES (?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT (email) DO UPDATE "
|
||||
"SET (code, created_at, failed_attempts) = (EXCLUDED.code, EXCLUDED.created_at, EXCLUDED.failed_attempts)")
|
||||
(random-uuid) email code (java.sql.Timestamp. (System/currentTimeMillis)) 0]]
|
||||
(when (and single-opt-in (not user-id))
|
||||
[(db/new-user-statement email)])))]
|
||||
(db/execute-all! ctx statements)
|
||||
{:status 303
|
||||
:headers {"location" (if success
|
||||
(str "/verify-code?email=" (:email params))
|
||||
(str (:on-error params "/") "?error=" error))}}))
|
||||
|
||||
(defn verify-code-handler [{:keys [biff.auth/app-path
|
||||
example/ds
|
||||
params
|
||||
session]
|
||||
:as ctx}]
|
||||
(let [email (biff/normalize-email (:email params))
|
||||
code (jdbc/execute-one! ds (sql/format {:select :*
|
||||
:from :auth_code
|
||||
:where [:= :email email]}))
|
||||
_ (println code)
|
||||
success (and (some? code)
|
||||
(< (:auth_code/failed_attempts code) 3)
|
||||
(not (biff/elapsed? (java.util.Date. (:auth_code/created_at code)) :now 3 :minutes))
|
||||
(= (:code params) (:auth_code/code code)))
|
||||
existing-user-id (when success (db/get-user-id ctx email))
|
||||
statements (cond
|
||||
success
|
||||
(concat [(sql/format {:delete-from :auth_code
|
||||
:where [:= :id (:auth_code/id code)]})]
|
||||
|
||||
(when-not existing-user-id
|
||||
[(db/new-user-statement email)]))
|
||||
|
||||
(and (not success)
|
||||
(some? code)
|
||||
(< (:auth_code/failed_attempts code) 3))
|
||||
[(sql/format {:update :auth_code
|
||||
:set {:failed_attempts (inc (:auth_code/failed_attempts code))}
|
||||
:where [:= :id (:auth_code/id code)]})])]
|
||||
(db/execute-all! ctx statements)
|
||||
(if success
|
||||
{:status 303
|
||||
:headers {"location" app-path}
|
||||
:session (assoc session :uid (or existing-user-id
|
||||
(db/get-user-id ctx email)))}
|
||||
{:status 303
|
||||
:headers {"location" (str "/verify-code?error=invalid-code&email=" email)}})))
|
||||
|
||||
(defn signout [{:keys [session]}]
|
||||
{:status 303
|
||||
:headers {"location" "/"}
|
||||
:session (dissoc session :uid)})
|
||||
|
||||
;;; ----------------------------------------------------------------------------
|
||||
|
||||
(def default-options
|
||||
#:biff.auth{:app-path "/app"
|
||||
:invalid-link-path "/signin?error=invalid-link"
|
||||
:check-state true
|
||||
:single-opt-in false
|
||||
:email-validator email-valid?})
|
||||
|
||||
(defn wrap-options [handler options]
|
||||
(fn [ctx]
|
||||
(handler (merge options ctx))))
|
||||
|
||||
(defn module [options]
|
||||
{:routes [["/auth" {:middleware [[wrap-options (merge default-options options)]]}
|
||||
["/send-link" {:post send-link-handler}]
|
||||
["/verify-link/:token" {:get verify-link-handler}]
|
||||
["/verify-link" {:post verify-link-handler}]
|
||||
["/send-code" {:post send-code-handler}]
|
||||
["/verify-code" {:post verify-code-handler}]
|
||||
["/signout" {:post signout}]]]})
|
||||
90
src/com/biffweb/my_project/email.clj
Normal file
90
src/com/biffweb/my_project/email.clj
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
(ns com.biffweb.my-project.email
|
||||
(:require [camel-snake-kebab.core :as csk]
|
||||
[camel-snake-kebab.extras :as cske]
|
||||
[clj-http.client :as http]
|
||||
[com.biffweb.my-project.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 to
|
||||
:subject subject
|
||||
:html-body (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-body (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.")
|
||||
:message-stream "outbound"}))
|
||||
|
||||
(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 to
|
||||
:subject subject
|
||||
:html-body (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-body (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.")
|
||||
:message-stream "outbound"}))
|
||||
|
||||
(defn template [k opts]
|
||||
((case k
|
||||
:signin-link signin-link
|
||||
:signin-code signin-code)
|
||||
opts))
|
||||
|
||||
(defn send-postmark [{:keys [biff/secret postmark/from]} form-params]
|
||||
(let [result (http/post "https://api.postmarkapp.com/email"
|
||||
{:headers {"X-Postmark-Server-Token" (secret :postmark/api-key)}
|
||||
:as :json
|
||||
:content-type :json
|
||||
:form-params (merge {:from from} (cske/transform-keys csk/->PascalCase form-params))
|
||||
:throw-exceptions false})
|
||||
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-body form-params))
|
||||
(println)
|
||||
(println "To send emails instead of printing them to the console, add your"
|
||||
"API key for Postmark to config.env.")
|
||||
true)
|
||||
|
||||
(defn send-email [{:keys [biff/secret] :as ctx} opts]
|
||||
(let [form-params (if-some [template-key (:template opts)]
|
||||
(template template-key opts)
|
||||
opts)]
|
||||
(if (every? some? [(secret :postmark/api-key)])
|
||||
(send-postmark ctx form-params)
|
||||
(send-console ctx form-params))))
|
||||
126
src/com/biffweb/my_project/home.clj
Normal file
126
src/com/biffweb/my_project/home.clj
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
(ns com.biffweb.my-project.home
|
||||
(:require
|
||||
[com.biffweb :as biff]
|
||||
[com.biffweb.my-project.middleware :as mid]
|
||||
[com.biffweb.my-project.ui :as ui]
|
||||
[com.biffweb.my-project.settings :as settings]))
|
||||
|
||||
(defn home-page [{:keys [params] :as ctx}]
|
||||
(ui/page
|
||||
ctx
|
||||
[:article
|
||||
(biff/form
|
||||
{:action "/auth/send-link"
|
||||
:id "signup"
|
||||
:hidden {:on-error "/"}}
|
||||
[:h2 (str "Sign up for " settings/app-name)]
|
||||
[:fieldset {:role "group"}
|
||||
[:input#email {:name "email"
|
||||
:type "email"
|
||||
:autocomplete "email"
|
||||
:placeholder "Enter your email address"}]
|
||||
[:input
|
||||
{:type "submit"
|
||||
:value "Sign up"}]]
|
||||
|
||||
(when-some [error (:error params)]
|
||||
[:<>
|
||||
[:p.pico-color-red-500
|
||||
(case error
|
||||
"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.")]])
|
||||
[:small "Already have an account? " [:a.link {:href "/signin"} "Sign in"] "."])]))
|
||||
|
||||
(defn link-sent [{:keys [params] :as ctx}]
|
||||
(ui/page
|
||||
ctx
|
||||
[:h2 "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
|
||||
[:article
|
||||
[:h2 (str "Sign up for " settings/app-name)]
|
||||
(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:"]]
|
||||
[:fieldset {:role "group"}
|
||||
[:input#email {:name "email"
|
||||
:type "email"
|
||||
:autocomplete "email"
|
||||
:placeholder "Enter your email address"}]
|
||||
[:input
|
||||
{:type "submit"
|
||||
:value "Sign in"}]])
|
||||
(when-some [error (:error params)]
|
||||
[:small.pico-color-red-500
|
||||
(case error
|
||||
"incorrect-email" "Incorrect email address. Try again."
|
||||
"There was an error.")])]))
|
||||
|
||||
(defn signin-page [{:keys [params] :as ctx}]
|
||||
(ui/page
|
||||
ctx
|
||||
[:article
|
||||
(biff/form
|
||||
{:action "/auth/send-code"
|
||||
:id "signin"
|
||||
:hidden {:on-error "/signin"}}
|
||||
[:h2 "Sign in to " settings/app-name]
|
||||
[:fieldset {:role "group"}
|
||||
[:input#email {:name "email"
|
||||
:type "email"
|
||||
:autocomplete "email"
|
||||
:placeholder "Enter your email address"}]
|
||||
[:input
|
||||
{:type "submit"
|
||||
:value "Sign in"}]]
|
||||
|
||||
(when-some [error (:error params)]
|
||||
[:div
|
||||
[:small.pico-color-red-500
|
||||
(case error
|
||||
"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.")]])
|
||||
[:small "Don't have an account yet? " [:a.link {:href "/"} "Sign up"] "."])]))
|
||||
|
||||
(defn enter-code-page [{:keys [params] :as ctx}]
|
||||
(ui/page
|
||||
ctx
|
||||
[:article
|
||||
(biff/form
|
||||
{:action "/auth/verify-code"
|
||||
:id "code-form"
|
||||
:hidden {:email (:email params)}}
|
||||
[:div [:label {:for "code"} "Enter the 6-digit code that we sent to "
|
||||
[:span.font-bold (:email params)]]]
|
||||
[:input#code {:name "code" :type "text"}]
|
||||
(when-some [error (:error params)]
|
||||
[:small.pico-color-red-500
|
||||
(case error
|
||||
"invalid-code" "Invalid code."
|
||||
"There was an error.")]))
|
||||
|
||||
(biff/form
|
||||
{:action "/auth/send-code"
|
||||
:id "signin"
|
||||
:hidden {:email (:email params)
|
||||
:on-error "/signin"}})]))
|
||||
|
||||
(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}]]})
|
||||
63
src/com/biffweb/my_project/middleware.clj
Normal file
63
src/com/biffweb/my_project/middleware.clj
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
(ns com.biffweb.my-project.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.
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(defn wrap-debug [handler]
|
||||
(fn [ctx]
|
||||
(let [response (handler ctx)]
|
||||
(println "REQUEST")
|
||||
(biff/pprint ctx)
|
||||
#_{:clj-kondo/ignore [:inline-def :clojure-lsp/unused-public-var]}
|
||||
(def ctx* ctx)
|
||||
(println "RESPONSE")
|
||||
(biff/pprint response)
|
||||
#_{:clj-kondo/ignore [:inline-def :clojure-lsp/unused-public-var]}
|
||||
(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))
|
||||
3
src/com/biffweb/my_project/settings.clj
Normal file
3
src/com/biffweb/my_project/settings.clj
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
(ns com.biffweb.my-project.settings)
|
||||
|
||||
(def app-name "my_project")
|
||||
85
src/com/biffweb/my_project/ui.clj
Normal file
85
src/com/biffweb/my_project/ui.clj
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
(ns com.biffweb.my-project.ui
|
||||
(:require [cheshire.core :as cheshire]
|
||||
[com.biffweb.my-project.settings :as settings]
|
||||
[ring.middleware.anti-forgery :as csrf]
|
||||
[rum.core :as rum]))
|
||||
|
||||
(defn the-base-html
|
||||
[{:base/keys [title
|
||||
description
|
||||
lang
|
||||
image
|
||||
icon
|
||||
url
|
||||
canonical
|
||||
_font-families
|
||||
head]}
|
||||
& contents]
|
||||
[:html
|
||||
{:lang lang
|
||||
:style {:min-height "100%"
|
||||
:height "auto"}}
|
||||
[:head
|
||||
[:title title]
|
||||
[:meta {:name "description" :content description}]
|
||||
[:meta {:content title :property "og:title"}]
|
||||
[:meta {:content description :property "og:description"}]
|
||||
(when image
|
||||
[:<>
|
||||
[:meta {:content image :property "og:image"}]
|
||||
[:meta {:content "summary_large_image" :name "twitter:card"}]])
|
||||
(when-some [url (or url canonical)]
|
||||
[:meta {:content url :property "og:url"}])
|
||||
(when-some [url (or canonical url)]
|
||||
[:link {:ref "canonical" :href url}])
|
||||
[:meta {:name "viewport" :content "width=device-width, initial-scale=1"}]
|
||||
(when icon
|
||||
[:link {:rel "icon"
|
||||
:type "image/png"
|
||||
:sizes "16x16"
|
||||
:href icon}])
|
||||
[:meta {:charset "utf-8"}]
|
||||
(into [:<>] head)]
|
||||
[:body
|
||||
[:main.container
|
||||
contents]]])
|
||||
|
||||
(defn base [ctx & body]
|
||||
(apply
|
||||
the-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 "/css/pico.min.css"}]
|
||||
[:link {:rel "stylesheet" :href "/css/pico.colors.min.css"}]
|
||||
[:script {:src "/js/main.js"}]
|
||||
[:script {:src "/js/htmx-1.9.11.min.js"}]
|
||||
[:script {:src "/js/htmx-1.9.11-ext-ws.min.js"}]
|
||||
[:script {:src "/js/htmx-1.9.11-ext-multi-swap.min.js"}]
|
||||
[:script {:src "/js/hyperscript-0.9.8.min.js"}]]
|
||||
head))))
|
||||
body))
|
||||
|
||||
(defn page [ctx & body]
|
||||
(base
|
||||
ctx
|
||||
[:div
|
||||
(when (bound? #'csrf/*anti-forgery-token*)
|
||||
{:hx-headers (cheshire/generate-string
|
||||
{:x-csrf-token csrf/*anti-forgery-token*})})
|
||||
body]))
|
||||
|
||||
(defn on-error [{:keys [status _ex] :as ctx}]
|
||||
{:status status
|
||||
:headers {"content-type" "text/html"}
|
||||
:body (rum/render-static-markup
|
||||
(page
|
||||
ctx
|
||||
[:h1
|
||||
(if (= status 404)
|
||||
"Page not found."
|
||||
"Something went wrong.")]))})
|
||||
24
src/com/biffweb/my_project/util/db.clj
Normal file
24
src/com/biffweb/my_project/util/db.clj
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
(ns com.biffweb.my-project.util.db
|
||||
(:require [next.jdbc :as jdbc]
|
||||
[honey.sql :as sql]))
|
||||
|
||||
(defn execute-all! [{:keys [example/ds]} statements]
|
||||
(when (not-empty statements)
|
||||
(jdbc/with-transaction [tx ds]
|
||||
(doseq [statement statements]
|
||||
(jdbc/execute! tx statement)))))
|
||||
|
||||
(defn new-user-statement [email]
|
||||
(sql/format {:insert-into :users
|
||||
:columns [:id :email]
|
||||
:values [[(random-uuid) email]]
|
||||
:on-conflict :email
|
||||
:do-nothing true}))
|
||||
|
||||
(defn get-user-id [{:keys [example/ds]} email]
|
||||
(-> (jdbc/execute! ds (sql/format {:select :id
|
||||
:from :users
|
||||
:where [:= :email email]}))
|
||||
|
||||
first
|
||||
:users/id))
|
||||
27
src/com/biffweb/my_project/worker.clj
Normal file
27
src/com/biffweb/my_project/worker.clj
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
(ns com.biffweb.my-project.worker
|
||||
(:require [clojure.tools.logging :as log]
|
||||
[com.biffweb :as biff]
|
||||
[honey.sql :as sql]
|
||||
[next.jdbc :as jdbc]))
|
||||
|
||||
(defn every-n-minutes [n]
|
||||
(iterate #(biff/add-seconds % (* 60 n)) (java.util.Date.)))
|
||||
|
||||
(defn print-usage [{:keys [example/ds]}]
|
||||
;; For a real app, you can have this run once per day and send you the output
|
||||
;; in an email.
|
||||
(let [n-users (:count (jdbc/execute-one! ds
|
||||
(sql/format {:select [[[:count :*] :count]]
|
||||
:from :users})))]
|
||||
(log/info "There are" n-users "users.")))
|
||||
|
||||
(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)}]
|
||||
:queues [{:id :echo
|
||||
:consumer #'echo-consumer}]})
|
||||
6
test/com/biffweb/my_project_test.clj
Normal file
6
test/com/biffweb/my_project_test.clj
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
(ns com.biffweb.my-project-test
|
||||
;; If you add more test files, require them here so that they'll get loaded by com.biffweb.my-project/on-save
|
||||
(:require [clojure.test :refer [deftest is]]))
|
||||
|
||||
(deftest example-test
|
||||
(is (= 4 (+ 2 2))))
|
||||
Loading…
Reference in a new issue