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