biff-sqlite starter

This commit is contained in:
Luciano Laratelli 2025-03-11 19:42:37 -04:00
commit 162ff2f407
36 changed files with 1846 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

14
.gitignore vendored Normal file
View 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
View file

@ -0,0 +1,4 @@
from clojure:temurin-17-alpine
EXPOSE 8080
ENV BIFF_PROFILE=prod
clj -M:prod

22
LICENSE Normal file
View 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
View 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
View file

@ -0,0 +1 @@
{submit-tx [[:inner 0]]}

45
deps.edn Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
BIFF_PROFILE="dev"
COOKIE_SECRET=t6JiKWp/L4wfZ+9C+5WFUA==
JWT_SECRET=RFswnM9hACuUxhMOX8l4UvTQqoWz0yUoaRXYwnoaPdE=

View 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=...

View file

@ -0,0 +1 @@
DELETE TABLE IF EXISTS users;

View 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
);

View file

@ -0,0 +1 @@
DELETE TABLE IF EXISTS auth_code;

View file

@ -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
);

File diff suppressed because one or more lines are too long

4
resources/public/css/pico.min.css vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

View 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;
}
}
});
})();

View 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]);
}
}
}
})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
// When plain htmx isn't quite enough, you can stick some custom JS here.

126
server-setup.sh Normal file
View 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 ...

View 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)

View 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}]]})

View 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}]]]})

View 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))))

View 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}]]})

View 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))

View file

@ -0,0 +1,3 @@
(ns com.biffweb.my-project.settings)
(def app-name "my_project")

View 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.")]))})

View 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))

View 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}]})

View 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))))