├── resources
└── io
│ └── github
│ └── abogoyavlensky
│ └── clojure_stack_lite
│ ├── db_sqlite
│ └── .gitkeep
│ ├── resources_migrations_postgres
│ └── .gitkeep
│ ├── substitutions
│ ├── src_db_sql_result_set_config_postgres.edn
│ ├── deps_edn_db_driver_deps_postgres.edn
│ ├── deps_edn_db_driver_deps_sqlite.edn
│ ├── kamal-deploy-config-sqlite.txt
│ ├── bb_edn_clj_repl_cmd_sqlite.edn
│ ├── resources_config_edn_sqlite.edn
│ ├── kamal-deploy-secrets-postgres.txt
│ ├── bb_deploy_kamal.edn
│ ├── readme_deploy_kamal.md
│ ├── deps_edn_auth_deps.edn
│ ├── bb_edn_clj_repl_cmd_postgres.edn
│ ├── resources_config_edn_postgres.edn
│ ├── github_workflows_deploy_ci_deploy_env_vars_postgres.txt
│ ├── deps_edn_db_test_deps_postgres.edn
│ ├── bb_edn_daisyui.edn
│ ├── kamal-deploy-config-postgres.txt
│ ├── test_utils_db_setup_sqlite.clj
│ └── test_utils_db_setup_postgres.clj
│ ├── resources_public_css_default
│ └── input.css
│ ├── root
│ ├── resources
│ │ ├── config.dev.edn
│ │ ├── public
│ │ │ ├── images
│ │ │ │ ├── icon@180px.png
│ │ │ │ ├── icon@192px.png
│ │ │ │ ├── icon@32px.png
│ │ │ │ ├── icon@512px.png
│ │ │ │ └── icon.svg
│ │ │ └── manifest.json
│ │ ├── logback.xml
│ │ └── config.edn
│ ├── .clj-kondo
│ │ └── config.edn
│ ├── .cljfmt.edn
│ ├── .mise.toml
│ ├── .dockerignore
│ ├── .gitignore
│ ├── .github
│ │ ├── actions
│ │ │ └── cache-clojure-deps
│ │ │ │ └── action.yaml
│ │ └── workflows
│ │ │ └── checks.yaml
│ ├── Dockerfile
│ ├── README.md
│ ├── dev
│ │ └── user.clj
│ ├── deps.edn
│ ├── bb.edn
│ └── LICENSE
│ ├── resources_migrations_sqlite
│ └── 0001.up.sql
│ ├── src_auth
│ ├── auth
│ │ ├── spec.clj
│ │ ├── queries.clj
│ │ ├── handlers.clj
│ │ └── views.clj
│ ├── handlers.clj
│ ├── views.clj
│ └── routes.clj
│ ├── src
│ ├── core.clj
│ ├── routes.clj
│ ├── handlers.clj
│ ├── db.clj
│ ├── views.clj
│ └── server.clj
│ ├── resources_public_css_daisyui
│ └── input.css
│ ├── docker-compose-postgres
│ └── docker-compose.yaml
│ ├── resources_migrations_postgres_auth
│ └── 0001.up.sql
│ ├── resources_migrations_sqlite_auth
│ └── 0002.up.sql
│ ├── template.edn
│ ├── kamal
│ ├── secrets
│ └── deploy.yml
│ ├── test
│ ├── home_test.clj
│ └── test_utils.clj
│ ├── github_workflows_deploy_yaml_kamal
│ └── deploy.yaml
│ └── test_auth
│ ├── auth_logout_test.clj
│ ├── auth_register_test.clj
│ ├── auth_forgot_password_test.clj
│ ├── auth_login_test.clj
│ ├── auth_account_test.clj
│ └── auth_reset_password_test.clj
├── .mise.toml
├── .gitignore
├── bb.edn
├── .github
├── actions
│ └── cache-clojure-deps
│ │ └── action.yaml
└── workflows
│ ├── release.yaml
│ └── checks.yaml
├── test
└── io
│ └── github
│ └── abogoyavlensky
│ └── clojure_stack_lite.clj
├── LICENSE
├── deps.edn
├── src
└── io
│ └── github
│ └── abogoyavlensky
│ └── clojure_stack_lite.clj
└── README.md
/resources/io/github/abogoyavlensky/clojure_stack_lite/db_sqlite/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/resources_migrations_postgres/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/src_db_sql_result_set_config_postgres.edn:
--------------------------------------------------------------------------------
1 |
2 | :return-keys true
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/resources_public_css_default/input.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss' source("../../../src");
2 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/deps_edn_db_driver_deps_postgres.edn:
--------------------------------------------------------------------------------
1 | org.postgresql/postgresql {:mvn/version "42.7.8"}
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/deps_edn_db_driver_deps_sqlite.edn:
--------------------------------------------------------------------------------
1 | org.xerial/sqlite-jdbc {:mvn/version "3.51.1.0"}
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/kamal-deploy-config-sqlite.txt:
--------------------------------------------------------------------------------
1 |
2 | volumes:
3 | - "/root/{{main/ns}}-db:/app/db"
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/bb_edn_clj_repl_cmd_sqlite.edn:
--------------------------------------------------------------------------------
1 | {:doc "Run built-in Clojure REPL"
2 | :task (shell "clj -A:dev:test")}
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/resources/config.dev.edn:
--------------------------------------------------------------------------------
1 | #merge [#include "config.edn"
2 | {:integrant-extras.process/process {:cmd ["bb" "css-watch"]}}]
3 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/resources_config_edn_sqlite.edn:
--------------------------------------------------------------------------------
1 | {:default "jdbc:sqlite:db/{{main/ns}}.sqlite"
2 | :test "jdbc:sqlite::memory:"}
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/.clj-kondo/config.edn:
--------------------------------------------------------------------------------
1 | {:linters {:single-key-in {:level :warning}
2 | :shadowed-var {:level :warning}
3 | :refer-all {:exclude [clojure.test]}}}
4 |
--------------------------------------------------------------------------------
/.mise.toml:
--------------------------------------------------------------------------------
1 | [tools]
2 | java = "temurin-21.0.2+13.0.LTS"
3 | clojure = "1.12.0.1479"
4 | babashka = "1.12.194"
5 | tailwindcss = "4.1.3"
6 |
7 | [alias]
8 | tailwindcss = "asdf:https://github.com/virtualstaticvoid/asdf-tailwindcss"
9 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/kamal-deploy-secrets-postgres.txt:
--------------------------------------------------------------------------------
1 | DATABASE_URL=$DATABASE_URL
2 | POSTGRES_DB=$POSTGRES_DB
3 | POSTGRES_USER=$POSTGRES_USER
4 | POSTGRES_PASSWORD=$POSTGRES_PASSWORD
5 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/bb_deploy_kamal.edn:
--------------------------------------------------------------------------------
1 |
2 |
3 | kamal {:doc "Deploy application using Kamal"
4 | :task (apply shell "kamal" (concat *command-line-args* ["--config-file" ".kamal/deploy.yml"]))}
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/resources/public/images/icon@180px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/abogoyavlensky/clojure-stack-lite/HEAD/resources/io/github/abogoyavlensky/clojure_stack_lite/root/resources/public/images/icon@180px.png
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/resources/public/images/icon@192px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/abogoyavlensky/clojure-stack-lite/HEAD/resources/io/github/abogoyavlensky/clojure_stack_lite/root/resources/public/images/icon@192px.png
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/resources/public/images/icon@32px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/abogoyavlensky/clojure-stack-lite/HEAD/resources/io/github/abogoyavlensky/clojure_stack_lite/root/resources/public/images/icon@32px.png
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/resources/public/images/icon@512px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/abogoyavlensky/clojure-stack-lite/HEAD/resources/io/github/abogoyavlensky/clojure_stack_lite/root/resources/public/images/icon@512px.png
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/readme_deploy_kamal.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ## Deployment
4 |
5 | For detailed deployment instructions, refer to the documentation:
6 |
7 | - [Kamal](https://stack.bogoyavlensky.com/docs/lite/kamal)
8 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/.cljfmt.edn:
--------------------------------------------------------------------------------
1 | {:parallel? true
2 | :sort-ns-references? true
3 | :remove-multiple-non-indenting-spaces? true
4 | :split-keypairs-over-multiple-lines? true
5 | :function-arguments-indentation :cursive}
6 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/deps_edn_auth_deps.edn:
--------------------------------------------------------------------------------
1 |
2 | ; auth
3 | buddy/buddy-hashers {:mvn/version "2.0.167"}
4 | buddy/buddy-auth {:mvn/version "3.0.323"}
5 | buddy/buddy-sign {:mvn/version "3.6.1-359"}
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/resources_migrations_sqlite/0001.up.sql:
--------------------------------------------------------------------------------
1 | -- Turning on write-ahead logging (WAL) mode for SQLite database to prevent concurrency issues
2 | -- https://til.simonwillison.net/sqlite/enabling-wal-mode
3 | PRAGMA journal_mode=WAL;
4 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/bb_edn_clj_repl_cmd_postgres.edn:
--------------------------------------------------------------------------------
1 | {:doc "Run docker compose and built-in Clojure REPL"
2 | :task (do
3 | (shell "docker compose up -d")
4 | (shell "clj -A:dev:test"))}
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/resources_config_edn_postgres.edn:
--------------------------------------------------------------------------------
1 | {:default #env DATABASE_URL
2 | :dev "jdbc:postgresql://localhost:5432/localdb?user=test&password=test"
3 | :test "jdbc:tc:postgresql:15.2-alpine3.17:///testdb?TC_DAEMON=true"}
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/src_auth/auth/spec.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.auth.spec)
2 |
3 | (def Email
4 | [:and
5 | [:string
6 | {:min 5
7 | :max 254}]
8 | [:re
9 | {:error/message "Invalid email format"}
10 | #"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$"]])
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .classpath
3 | .clj-kondo/*
4 | !.clj-kondo/config.edn
5 | .cpcache
6 | .lsp/
7 | lsp/
8 | .nrepl-history
9 | .nrepl-port
10 | .vscode
11 | *.class
12 | *.jar
13 | *~
14 | *.iml
15 | /classes
16 | /target
17 | .calva
18 | .DS_Store
19 | .portal/
20 | .env
21 | .env.local
22 | *.sqlite*
23 | tmpl*
24 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/src/core.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.core
2 | (:gen-class)
3 | (:require [integrant-extras.core :as ig-extras]))
4 |
5 | (defn -main
6 | "Run application system in production."
7 | []
8 | (ig-extras/run-system {:profile :prod
9 | :config-path "config.edn"}))
10 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/github_workflows_deploy_ci_deploy_env_vars_postgres.txt:
--------------------------------------------------------------------------------
1 |
2 | DATABASE_URL: ${{ secrets.DATABASE_URL }}
3 | POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
4 | POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
5 | POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/resources_public_css_daisyui/input.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss' source("../../../src");
2 |
3 | @plugin "../js/daisyui.js";
4 |
5 | /* Optional for custom themes – Docs: https://daisyui.com/docs/themes/#how-to-add-a-new-custom-theme */
6 | @plugin "../js/daisyui-theme.js"{
7 | /* custom theme here */
8 | }
9 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/deps_edn_db_test_deps_postgres.edn:
--------------------------------------------------------------------------------
1 |
2 | org.testcontainers/testcontainers {:mvn/version "2.0.3"}
3 | org.testcontainers/testcontainers-jdbc {:mvn/version "2.0.3"}
4 | org.testcontainers/testcontainers-postgresql {:mvn/version "2.0.3"}
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/.mise.toml:
--------------------------------------------------------------------------------
1 | [tools]
2 | java = "temurin-21.0.2+13.0.LTS"
3 | clojure = "1.12.1.1550"
4 | babashka = "1.12.206"
5 | clj-kondo = "2025.06.05"
6 | cljfmt = "0.13.1"
7 | tailwindcss = "4.1.11"
8 |
9 | [alias]
10 | cljfmt = "asdf:https://github.com/b-social/asdf-cljfmt"
11 | tailwindcss = "asdf:https://github.com/virtualstaticvoid/asdf-tailwindcss"
12 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/bb_edn_daisyui.edn:
--------------------------------------------------------------------------------
1 |
2 | {:url "https://github.com/saadeghi/daisyui/releases/download/v5.0.28/daisyui.js"
3 | :filepath "js/daisyui.js"}
4 | {:url "https://github.com/saadeghi/daisyui/releases/download/v5.0.28/daisyui-theme.js"
5 | :filepath "js/daisyui-theme.js"}
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/docker-compose-postgres/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | volumes:
2 | postgres_data_dev: {}
3 |
4 | services:
5 | db-local:
6 | image: postgres:17.4-alpine3.21
7 | ports:
8 | - "5432:5432"
9 | environment:
10 | - POSTGRES_DB=localdb
11 | - POSTGRES_USER=test
12 | - POSTGRES_PASSWORD=test
13 | volumes:
14 | - postgres_data_dev:/var/lib/postgresql/data
15 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/kamal-deploy-config-postgres.txt:
--------------------------------------------------------------------------------
1 | - DATABASE_URL
2 |
3 | # Database
4 | accessories:
5 | db:
6 | image: postgres:17.4-alpine3.21
7 | host: <%= ENV['SERVER_IP'] %>
8 | env:
9 | secret:
10 | - POSTGRES_DB
11 | - POSTGRES_USER
12 | - POSTGRES_PASSWORD
13 | directories:
14 | - {{main/file}}_postgres_data:/var/lib/postgresql/data
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/src/routes.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.routes
2 | (:require [ring.util.response :as response]
3 | [{{main/ns}}.handlers :as handlers]))
4 |
5 | (def routes
6 | [["/" {:name ::home
7 | :get {:handler handlers/home-handler}
8 | :responses {200 {:body string?}}}]
9 | ["/health" {:name ::health-check
10 | :get {:handler (fn [_] (response/response "OK"))}}]])
11 |
--------------------------------------------------------------------------------
/bb.edn:
--------------------------------------------------------------------------------
1 | {:tasks
2 | {:enter (println "Running task:" (:name (current-task)))
3 |
4 | test {:doc "Run tests for the template config"
5 | :task (clojure "-X:test")}
6 |
7 | new {:doc "Create a new project"
8 | :task (apply clojure "-X:local-new :name tmpl-prj :overwrite :delete :debug true" *command-line-args*)}
9 |
10 | release {:doc "Create and push a new git tag based on provided version"
11 | :task (clojure "-T:slim tag :push true")}}}
12 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/.dockerignore:
--------------------------------------------------------------------------------
1 | # See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files.
2 |
3 | # Ignore git directory.
4 | /.git/
5 | /.gitignore
6 |
7 | # Ignore Clojure CLI cache-dir
8 | /.cpcache
9 |
10 | # Ignore manifest-edn cache-dir
11 | /resources-hashed
12 |
13 | # Ignore CI service files.
14 | /.github
15 |
16 | # Ignore Docker-related files
17 | /.dockerignore
18 | /Dockerfile*
19 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/resources_migrations_postgres_auth/0001.up.sql:
--------------------------------------------------------------------------------
1 | -- Create users table with authentication-related fields
2 | CREATE TABLE IF NOT EXISTS "user" (
3 | "id" SERIAL PRIMARY KEY,
4 | "email" TEXT NOT NULL UNIQUE,
5 | "password" TEXT NOT NULL,
6 | "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
7 | );
8 |
9 | -- Create an index on email for faster lookups
10 | CREATE INDEX IF NOT EXISTS idx_user_email ON "user" ("email");
11 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/resources_migrations_sqlite_auth/0002.up.sql:
--------------------------------------------------------------------------------
1 | -- Create users table with authentication-related fields
2 | CREATE TABLE IF NOT EXISTS "user" (
3 | id INTEGER UNIQUE PRIMARY KEY AUTOINCREMENT NOT NULL,
4 | email TEXT NOT NULL UNIQUE,
5 | password TEXT NOT NULL,
6 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
7 | );
8 |
9 | -- Create an index on email for faster lookups
10 | CREATE INDEX IF NOT EXISTS idx_user_email ON "user" (email);
11 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .classpath
3 | .clj-kondo/*
4 | !.clj-kondo/config.edn
5 | .cpcache
6 | .lsp/
7 | lsp/
8 | log/
9 | .nrepl-history
10 | .nrepl-port
11 | .vscode
12 | *.class
13 | *.jar
14 | *~
15 | *.iml
16 | /classes
17 | /target
18 | .calva
19 | .DS_Store
20 | .portal/
21 | .env
22 | .env.local
23 | *.sqlite*
24 | .aider*
25 | .testcontainers.properties
26 | output/
27 | resources/public/css/output.*
28 | */manifest.edn
29 | resources-hashed/
30 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/template.edn:
--------------------------------------------------------------------------------
1 | {:data-fn io.github.abogoyavlensky.clojure-stack-lite/data-fn
2 | :template-fn io.github.abogoyavlensky.clojure-stack-lite/template-fn
3 | :post-process-fn io.github.abogoyavlensky.clojure-stack-lite/post-process-fn
4 | :debug false
5 | :daisyui false
6 | :auth false
7 | :deploy :kamal
8 | :db :sqlite
9 | :transform
10 | [["src" "src/{{main/file}}"]
11 | ["test" "test/{{main/file}}"]
12 | ["resources_public_css_default" "resources/public/css"]]}
13 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/src/handlers.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.handlers
2 | (:require [reitit-extras.core :as reitit-extras]
3 | [ring.util.response :as response]
4 | [{{main/ns}}.views :as views]))
5 |
6 | (defn default-handler
7 | [error-text status-code]
8 | (fn [_]
9 | (-> (views/error-page error-text)
10 | (reitit-extras/render-html)
11 | (response/status status-code))))
12 |
13 | (defn home-handler
14 | [_]
15 | (reitit-extras/render-html views/home-page))
16 |
--------------------------------------------------------------------------------
/.github/actions/cache-clojure-deps/action.yaml:
--------------------------------------------------------------------------------
1 | name: Cache Clojure deps
2 |
3 | inputs:
4 | key-label:
5 | description: 'Additional label for cache key'
6 | default: 'deps'
7 |
8 | runs:
9 | using: composite
10 | steps:
11 | - uses: actions/checkout@v6.0.1
12 | - name: Cache Clojure deps
13 | uses: actions/cache@v4
14 | with:
15 | path: |
16 | ~/.m2/repository
17 | ~/.gitlibs
18 | ~/.clojure
19 | ~/.cpcache
20 | key: ${{ runner.os }}-clojure-${{ inputs.key-label }}-${{ hashFiles('**/deps.edn') }}
21 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/test_utils_db_setup_sqlite.clj:
--------------------------------------------------------------------------------
1 | (defn with-truncated-tables
2 | "Remove all data from all tables except migrations."
3 | [f]
4 | (let [db (::db/db *test-system*)]
5 | (doseq [table (->> {:select [:name]
6 | :from [:sqlite_master]
7 | :where [:= :type "table"]}
8 | (db/exec! db)
9 | (map (comp keyword :name)))
10 | :when (not= :ragtime_migrations table)]
11 | (db/exec! db {:delete-from table}))
12 | (f)))
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/kamal/secrets:
--------------------------------------------------------------------------------
1 | # Secrets defined here are available for reference under registry/password, env/secret, builder/secrets,
2 | # and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either
3 | # password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git.
4 |
5 | # DEPLOY
6 | SERVER_IP=$SERVER_IP
7 | REGISTRY_USERNAME=$REGISTRY_USERNAME
8 | REGISTRY_PASSWORD=$REGISTRY_PASSWORD
9 | APP_DOMAIN=$APP_DOMAIN
10 | SESSION_SECRET_KEY=$SESSION_SECRET_KEY
11 | {{deploy-secrets-kamal}}
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/resources/logback.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | %date{ISO8601} %-5level %logger{36} - %msg %mdc%n
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/substitutions/test_utils_db_setup_postgres.clj:
--------------------------------------------------------------------------------
1 | (defn with-truncated-tables
2 | "Remove all data from all tables except migrations."
3 | [f]
4 | (let [db (::db/db *test-system*)]
5 | (doseq [table (->> {:select [:tablename]
6 | :from [:pg_tables]
7 | :where [:= :schemaname "public"]}
8 | (db/exec! db)
9 | (mapv #(-> % :tablename keyword)))
10 | :when (not= :ragtime_migrations table)]
11 | (db/exec! db {:truncate table}))
12 | (f)))
--------------------------------------------------------------------------------
/.github/workflows/release.yaml:
--------------------------------------------------------------------------------
1 | name: Deploy Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - "*"
7 |
8 | jobs:
9 | release:
10 | runs-on: ubuntu-latest
11 | permissions:
12 | contents: write
13 | steps:
14 | - uses: actions/checkout@v6.0.1
15 | - name: Publish GitHub Release
16 | env:
17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
18 | TAG: ${{ github.ref_name }}
19 | run: |
20 | gh release create "$TAG" \
21 | --repo="$GITHUB_REPOSITORY" \
22 | --title="${TAG}" \
23 | --generate-notes
24 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/.github/actions/cache-clojure-deps/action.yaml:
--------------------------------------------------------------------------------
1 | name: Cache Clojure deps
2 |
3 | inputs:
4 | key-label:
5 | description: 'Additional label for cache key'
6 | default: 'deps'
7 |
8 | runs:
9 | using: composite
10 | steps:
11 | - uses: actions/checkout@v6.0.1
12 | - name: Cache Clojure deps
13 | uses: actions/cache@v4
14 | with:
15 | path: |
16 | ~/.m2/repository
17 | ~/.gitlibs
18 | ~/.clojure
19 | ~/.cpcache
20 | key: ${{ runner.os }}-clojure-${{ inputs.key-label }}-${{ hashFiles('**/deps.edn') }}
21 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/resources/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "{{main/ns}}",
3 | "short_name": "{{main/ns}}",
4 | "start_url": "/",
5 | // TODO: add domain of the project
6 | // "id": "https://{{main/ns}}.com/",
7 | "display": "standalone",
8 | "theme_color": "#ffffff",
9 | "icons": [
10 | { "src": "/assets/images/icon@192px.png", "type": "image/png", "sizes": "192x192" },
11 | { "src": "/assets/images/icon@512px.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" },
12 | { "src": "/assets/images/icon@512px.png", "type": "image/png", "sizes": "512x512" }
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/src_auth/handlers.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.handlers
2 | (:require [reitit-extras.core :as reitit-extras]
3 | [ring.util.response :as response]
4 | [{{main/ns}}.views :as views]))
5 |
6 | (defn default-handler
7 | [error-text status-code]
8 | (fn [_]
9 | (-> (views/error-page error-text)
10 | (reitit-extras/render-html)
11 | (response/status status-code))))
12 |
13 | (defn home-handler
14 | [{router :reitit.core/router
15 | :as request}]
16 | (-> {:user (:identity request)
17 | :router router}
18 | (views/home-page)
19 | (reitit-extras/render-html)))
20 |
--------------------------------------------------------------------------------
/test/io/github/abogoyavlensky/clojure_stack_lite.clj:
--------------------------------------------------------------------------------
1 | (ns io.github.abogoyavlensky.clojure-stack-lite
2 | (:require [clojure.edn :as edn]
3 | [clojure.java.io :as io]
4 | [clojure.spec.alpha :as s]
5 | [clojure.test :refer :all]
6 | ; for the Specs
7 | [org.corfield.new]))
8 |
9 | (deftest test-validation-lite-template
10 | (testing "template.edn is valid."
11 | (let [template (edn/read-string (slurp (io/resource "io/github/abogoyavlensky/clojure_stack_lite/template.edn")))]
12 | (is (s/valid? :org.corfield.new/template template)
13 | (s/explain-str :org.corfield.new/template template)))))
14 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/kamal/deploy.yml:
--------------------------------------------------------------------------------
1 | <% require "dotenv"; Dotenv.load(".env") %>
2 |
3 | service: {{main/ns}}
4 | image: <%= ENV['REGISTRY_USERNAME'] %>/{{main/ns}}
5 | servers:
6 | - <%= ENV['SERVER_IP'] %>
7 |
8 | proxy:
9 | ssl: true
10 | host: <%= ENV['APP_DOMAIN'] %>
11 | healthcheck:
12 | path: /health
13 |
14 | registry:
15 | server: ghcr.io
16 | username:
17 | - REGISTRY_USERNAME
18 | password:
19 | - REGISTRY_PASSWORD
20 |
21 | builder:
22 | arch: amd64
23 | remote: ssh://root@<%= ENV['SERVER_IP'] %>
24 | cache:
25 | type: gha
26 |
27 | env:
28 | secret:
29 | - SESSION_SECRET_KEY
30 | {{deploy-config-kamal}}
31 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/resources/config.edn:
--------------------------------------------------------------------------------
1 | {:{{main/ns}}.db/db
2 | {:jdbc-url #profile {{db-config}}}
3 |
4 | :{{main/ns}}.server/server
5 | {:options {:port #profile {:default 8000
6 | :prod 80
7 | :test #free-port true}
8 | :session-secret-key #profile {:default "test-secret-key"
9 | :prod #env SESSION_SECRET_KEY}
10 | :cookie-attrs-secure? #profile {:default false
11 | :prod true}
12 | :auto-reload? #profile {:default false
13 | :dev true}
14 | :cache-assets? #profile {:default false
15 | :prod true}}
16 | :db #ig/ref :{{main/ns}}.db/db}}
17 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/test/home_test.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.home-test
2 | (:require [clj-http.client :as http]
3 | [clojure.test :refer :all]
4 | [hickory.select :as select]
5 | [reitit-extras.tests :as reitit-extras]
6 | [{{main/ns}}.server :as-alias server]
7 | [{{main/ns}}.test-utils :as utils]))
8 |
9 | (use-fixtures :once
10 | (utils/with-system))
11 |
12 | (use-fixtures :each
13 | utils/with-truncated-tables)
14 |
15 | (deftest test-home-page-is-loaded-correctly
16 | (let [url (reitit-extras/get-server-url (utils/server) :host)
17 | body (utils/response->hickory (http/get url))]
18 | (is (= "Clojure Stack Lite"
19 | (->> body
20 | (select/select (select/tag :span))
21 | (first)
22 | :content
23 | (first))))))
24 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/src_auth/auth/queries.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.auth.queries
2 | (:require [buddy.hashers :as hashers]
3 | [{{main/ns}}.db :as db]))
4 |
5 | (defn create-user!
6 | [db {:keys [email password]}]
7 | (db/exec-one! db {:insert-into :user
8 | :values [{:email email
9 | :password (hashers/derive password {:alg :bcrypt+sha512})}]
10 | :returning [:*]}))
11 |
12 | (defn get-user
13 | [db email]
14 | (db/exec-one! db {:select [:*]
15 | :from [:user]
16 | :where [:= :email email]}))
17 |
18 | (defn update-password!
19 | [db {:keys [id password-hash]}]
20 | (db/exec-one! db {:update :user
21 | :set {:password password-hash}
22 | :where [:= :id id]
23 | :returning [:*]}))
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Andrey Bogoyavlenskiy
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM clojure:temurin-21-tools-deps-1.12.1.1550-alpine AS build
2 |
3 | WORKDIR /app
4 |
5 | ARG BB_VERSION=1.12.206
6 | ARG TAILWIND_VERSION=v4.1.11
7 |
8 | # Install tailwindcss
9 | RUN wget -qO /usr/local/bin/tailwindcss "https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64-musl" \
10 | && chmod +x /usr/local/bin/tailwindcss
11 |
12 | # Install bb
13 | RUN wget -qO- https://raw.githubusercontent.com/babashka/babashka/master/install \
14 | | bash -s -- --version "${BB_VERSION}" --static
15 |
16 | # bb: (trigger) install of Clojure tools and download deps
17 | COPY bb.edn /app
18 | RUN bb prepare
19 |
20 | # clj: download deps
21 | COPY deps.edn /app
22 | RUN bb deps
23 |
24 | # Build uberjar
25 | COPY . /app
26 | RUN bb build
27 |
28 |
29 | FROM eclipse-temurin:21.0.2_13-jre-alpine
30 | # TODO: update github username to manage images on the ghcr.io
31 | LABEL org.opencontainers.image.source=https://github.com/{{developer}}/{{main/ns}}
32 |
33 | WORKDIR /app
34 | COPY --from=build /app/target/standalone.jar /app/standalone.jar
35 |
36 | EXPOSE 80
37 | # Increase the max memory limit to your needs
38 | CMD ["java", "-Xmx256m", "-jar", "standalone.jar"]
39 |
--------------------------------------------------------------------------------
/deps.edn:
--------------------------------------------------------------------------------
1 | {:deps {org.clojure/clojure {:mvn/version "1.12.3"}
2 | io.github.seancorfield/deps-new {:git/tag "v0.8.0" :git/sha "2f96530"}}
3 |
4 | :paths ["src" "resources"]
5 |
6 | :aliases {:test {:extra-paths ["test"]
7 | :extra-deps {eftest/eftest {:mvn/version "0.6.0"}
8 | cloverage/cloverage {:mvn/version "1.2.4"}}
9 | :exec-fn cloverage.coverage/run-project
10 | :exec-args {:test-ns-path ["test"]
11 | :src-ns-path ["src"]
12 | :runner :eftest
13 | :runner-opts {:multithread? false}}}
14 |
15 | :local-new {:extra-deps {io.github.seancorfield/deps-new {:git/tag "v0.8.0"
16 | :git/sha "2f96530"}
17 | clojure-stack-lite/clojure-stack-lite {:local/root "."}}
18 | :exec-fn org.corfield.new/create
19 | :exec-args {:template io.github.abogoyavlensky/clojure-stack-lite}}
20 |
21 | ; This alias is used to create and push just a new git tag based on version
22 | :slim {:deps {io.github.abogoyavlensky/slim {:mvn/version "0.3.2"}}
23 | :ns-default slim.lib
24 | :exec-args {:version "0.2.1"}}}}
25 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/.github/workflows/checks.yaml:
--------------------------------------------------------------------------------
1 | name: Checks
2 |
3 | on:
4 | pull_request:
5 | branches: [ master ]
6 | workflow_call:
7 |
8 | jobs:
9 | lint-fmt:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v6.0.1
13 | - uses: ./.github/actions/cache-clojure-deps
14 | with:
15 | key-label: 'lint'
16 | - uses: jdx/mise-action@v3.5.1
17 | with:
18 | install_args: "babashka cljfmt clj-kondo java clojure"
19 | - name: Lint and format
20 | run: bb fmt-check && bb deps && bb lint-init && bb lint
21 |
22 | outdated:
23 | runs-on: ubuntu-latest
24 | steps:
25 | - uses: actions/checkout@v6.0.1
26 | - uses: ./.github/actions/cache-clojure-deps
27 | with:
28 | key-label: 'outdated'
29 | - uses: jdx/mise-action@v3.5.1
30 | with:
31 | install_args: "babashka java clojure"
32 | - name: Outdated deps
33 | run: bb deps && bb outdated-check
34 |
35 | tests:
36 | runs-on: ubuntu-latest
37 | steps:
38 | - uses: actions/checkout@v6.0.1
39 | - uses: ./.github/actions/cache-clojure-deps
40 | with:
41 | key-label: 'tests'
42 | - uses: jdx/mise-action@v3.5.1
43 | with:
44 | install_args: "babashka java clojure"
45 | - name: Run tests
46 | run: bb deps && bb test
47 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/README.md:
--------------------------------------------------------------------------------
1 | # {{main/ns}}
2 |
3 | _This application is generated with [clojure-stack-lite](https://github.com/abogoyavlensky/clojure-stack-lite)._
4 |
5 | _TODO: add project description_
6 |
7 |
8 | ## Development
9 |
10 | Install Java, Clojure, Babashka, TailwindCSS and other tools manually or via [mise](https://mise.jdx.dev/):
11 |
12 | ```shell
13 | mise trust && mise install
14 | ```
15 |
16 | Check all available commands:
17 |
18 | ```shell
19 | bb tasks
20 | ```
21 |
22 | Run lint, formatting, tests and checking outdated dependencies:
23 |
24 | ```shell
25 | bb check
26 | ```
27 |
28 | Run server with built-in REPL from terminal:
29 |
30 | > [!NOTE]
31 | > If you're using PostgreSQL, [Docker](https://docs.docker.com/engine/install/) should be installed
32 |
33 | ```shell
34 | bb clj-repl
35 | (reset)
36 | ````
37 |
38 | Once server is started, it will automatically reload on code changes in the backend and TailwindCSS classes.
39 | The server should be available at `http://localhost:8000`.
40 |
41 | ## Update assets
42 |
43 | The idea is to vendor all js-files in the project repo eliminating build step for js part.
44 |
45 | Once you want to update the version of AlpineJS, HTMX or add a new asset, edit version in bb.edn file at `fetch-assets` and run:
46 |
47 | ```shell
48 | bb fetch-assets
49 | ```
50 |
51 | Your assets will be updated in `resources/public` folder.{{readme-deploy-kamal}}
52 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/test/test_utils.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.test-utils
2 | (:require [clj-reload.core :as reload]
3 | [hickory.core :as hickory]
4 | [integrant.core :as ig]
5 | [integrant-extras.core :as ig-extras]
6 | [{{main/ns}}.db :as db]
7 | [{{main/ns}}.server :as server]))
8 |
9 | (def ^:const TEST-CSRF-TOKEN "test-csrf-token")
10 | (def ^:const TEST-SECRET-KEY "test-secret-key")
11 |
12 | (def ^:dynamic *test-system* nil)
13 |
14 | (defn with-system
15 | "Run the test system before tests."
16 | ([]
17 | (with-system nil))
18 | ([config-path]
19 | (fn
20 | [test-fn]
21 | (let [test-config (ig-extras/get-config :test config-path)]
22 | (ig/load-namespaces test-config)
23 | (reload/reload)
24 | (binding [*test-system* (ig/init test-config)]
25 | (try
26 | (test-fn)
27 | (finally
28 | (ig/halt! *test-system*))))))))
29 |
30 | {{test-utils-db-setup}}
31 |
32 | (defn response->hickory
33 | "Convert a Ring response body to a Hickory document."
34 | [response]
35 | (-> response
36 | :body
37 | (hickory/parse)
38 | (hickory/as-hickory)))
39 |
40 | (defn db
41 | "Get the database connection from the test system."
42 | []
43 | (::db/db *test-system*))
44 |
45 | (defn server
46 | "Get the server instance from the test system."
47 | []
48 | (::server/server *test-system*))
49 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/dev/user.clj:
--------------------------------------------------------------------------------
1 | (ns user
2 | (:require [clj-reload.core :as reload]
3 | [clojure.repl.deps :as repl-deps]
4 | [malli.dev :as malli-dev]
5 | [eftest.runner :as eftest]
6 | [eftest.report.pretty :as eftest-report]
7 | [integrant.repl :as ig-repl]
8 | [integrant.repl.state :as state]
9 | [integrant-extras.core :as ig-extras]))
10 |
11 | (ig-repl/set-reload-options! {:dirs ["dev" "src" "test"], :file-pattern #"\.clj"})
12 | (malli-dev/start!)
13 |
14 | (defn reset
15 | "Restart system."
16 | []
17 | (ig-repl/set-prep! #(ig-extras/read-config :dev "config.dev.edn"))
18 | (ig-repl/reset))
19 |
20 | (defn stop
21 | "Stop system."
22 | []
23 | (ig-repl/halt))
24 |
25 | (defn run-all-tests
26 | "Run all tests for the project."
27 | []
28 | (reload/reload)
29 | (eftest/run-tests (eftest/find-tests "test") {:report eftest-report/report
30 | :multithread? false}))
31 |
32 | (comment
33 | ; It's convenient to bind shortcuts to these functions in your editor.
34 | ; Start or restart system
35 | (reset)
36 | ; Check system state
37 | (keys state/system)
38 | ; Stop system
39 | (stop)
40 | ; Run all project tests
41 | (run-all-tests)
42 | ; Refresh namespaces
43 | (reload/reload)
44 |
45 | ; Example of add-lib dynamically
46 | ; Sync all new libs at once
47 | (repl-deps/sync-deps)
48 | ; or sync a specific lib
49 | (repl-deps/add-lib 'hiccup/hiccup {:mvn/version "2.0.0-RC3"}))
50 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/github_workflows_deploy_yaml_kamal/deploy.yaml:
--------------------------------------------------------------------------------
1 | name: Deploy
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 |
7 | jobs:
8 | checks:
9 | uses: ./.github/workflows/checks.yaml
10 |
11 | deploy:
12 | runs-on: ubuntu-latest
13 | permissions:
14 | contents: read
15 | packages: write
16 | timeout-minutes: 20
17 | needs: [ checks ]
18 | steps:
19 | - uses: actions/checkout@v6.0.1
20 | - uses: webfactory/ssh-agent@v0.9.1
21 | with:
22 | ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
23 | - uses: jdx/mise-action@v3.5.1
24 | with:
25 | install_args: "babashka"
26 |
27 | - name: Setup Docker Buildx
28 | uses: docker/setup-buildx-action@v3
29 |
30 | - name: Expose GitHub Runtime for cache
31 | uses: crazy-max/ghaction-github-runtime@v3
32 |
33 | - uses: ruby/setup-ruby@v1
34 | with:
35 | ruby-version: 3.3.0
36 | bundler-cache: true
37 |
38 | - name: Install kamal
39 | run: gem install kamal -v 2.3.0
40 |
41 | - name: Build and deploy
42 | env:
43 | SERVER_IP: ${{ secrets.SERVER_IP }}
44 | REGISTRY_USERNAME: ${{ github.actor }}
45 | REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
46 | APP_DOMAIN: ${{ secrets.APP_DOMAIN }}
47 | SESSION_SECRET_KEY: ${{ secrets.SESSION_SECRET_KEY }}{{ci-deploy-env-vars}}
48 | run: bb kamal deploy --version=${{ github.sha }}
49 |
50 | - name: Kamal Release
51 | if: ${{ cancelled() }}
52 | run: bb kamal lock release
53 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/src/db.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.db
2 | (:require [clojure.tools.logging :as log]
3 | [hikari-cp.core :as cp]
4 | [honey.sql :as honey]
5 | [integrant-extras.core :as ig-extras]
6 | [integrant.core :as ig]
7 | [next.jdbc :as jdbc]
8 | ; Import for converting timestamp fields
9 | [next.jdbc.date-time]
10 | [next.jdbc.result-set :as jdbc-rs]
11 | [ragtime.next-jdbc :as ragtime-jdbc]
12 | [ragtime.repl :as ragtime-repl]))
13 |
14 | ; Common functions
15 |
16 | (def ^:private sql-params
17 | {:builder-fn jdbc-rs/as-unqualified-kebab-maps{{sql-result-set-config}}})
18 |
19 | (defn exec!
20 | "Send query to db and return vector of result items."
21 | [db query]
22 | (let [query-sql (honey/format query {:quoted true})]
23 | (jdbc/execute! db query-sql sql-params)))
24 |
25 | (defn exec-one!
26 | "Send query to db and return single result item."
27 | [db query]
28 | (let [query-sql (honey/format query {:quoted true})]
29 | (jdbc/execute-one! db query-sql sql-params)))
30 |
31 | ; Component
32 |
33 | (defmethod ig/assert-key ::db
34 | [_ params]
35 | (ig-extras/validate-schema!
36 | {:component ::db
37 | :data params
38 | :schema [:map
39 | [:jdbc-url string?]]}))
40 |
41 | (defmethod ig/init-key ::db
42 | [_ options]
43 | (log/info "[DB] Starting database connection pool...")
44 | (let [datasource (cp/make-datasource options)]
45 | (ragtime-repl/migrate
46 | {:datastore (ragtime-jdbc/sql-database datasource)
47 | :migrations (ragtime-jdbc/load-resources "migrations")})
48 | datasource))
49 |
50 | (defmethod ig/halt-key! ::db
51 | [_ datasource]
52 | (log/info "[DB] Closing database connection pool...")
53 | (cp/close-datasource datasource))
54 |
--------------------------------------------------------------------------------
/.github/workflows/checks.yaml:
--------------------------------------------------------------------------------
1 | name: Checks
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | tags:
7 | - "*"
8 | pull_request:
9 | branches: [ master ]
10 |
11 | jobs:
12 | tests:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v6.0.1
16 | - uses: ./.github/actions/cache-clojure-deps
17 | - uses: jdx/mise-action@v3.5.1
18 | with:
19 | install_args: "babashka java clojure"
20 | - name: Run tests
21 | run: bb test
22 |
23 | - name: Generate project - default
24 | run: |
25 | bb new
26 | cd tmpl-prj
27 | mise trust && mise install
28 | bb lint-init && bb lint
29 | bb outdated-check
30 | bb test
31 |
32 | - name: Generate project - daisyui
33 | run: |
34 | rm -rf tmpl-prj
35 | bb new :daisyui true
36 | cd tmpl-prj
37 | mise trust && mise install
38 | bb css-build
39 | bb fetch-assets
40 | bb css-build
41 |
42 | - name: Generate project - auth sqlite
43 | run: |
44 | rm -rf tmpl-prj
45 | bb new :auth true
46 | cd tmpl-prj
47 | mise trust && mise install
48 | bb lint-init && bb lint
49 | bb outdated-check
50 | bb test
51 |
52 | - name: Generate project - auth postgres
53 | run: |
54 | rm -rf tmpl-prj
55 | bb new :auth true :db :postgres
56 | cd tmpl-prj
57 | mise trust && mise install
58 | bb lint-init && bb lint
59 | bb outdated-check
60 | bb test
61 |
62 | - name: Generate project - postgres
63 | run: |
64 | bb new :db :postgres
65 | cd tmpl-prj
66 | mise trust && mise install
67 | bb lint-init && bb lint
68 | bb outdated-check
69 | bb test
70 |
71 | - name: Generate project - no deployment
72 | run: |
73 | bb new :deploy :none
74 | cd tmpl-prj
75 | mise trust && mise install
76 | bb lint-init && bb lint
77 | bb test
78 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/resources/public/images/icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/deps.edn:
--------------------------------------------------------------------------------
1 | {:deps {org.clojure/clojure {:mvn/version "1.12.4"}
2 | ; logging
3 | org.clojure/tools.logging {:mvn/version "1.3.0"}
4 | ch.qos.logback/logback-classic {:mvn/version "1.5.22"}
5 | ; system & config
6 | integrant/integrant {:mvn/version "1.0.1"}
7 | io.github.abogoyavlensky/integrant-extras {:mvn/version "0.1.2"}
8 | ; server
9 | metosin/reitit-ring {:mvn/version "0.9.2"}
10 | metosin/reitit-middleware {:mvn/version "0.9.2"}
11 | metosin/reitit-malli {:mvn/version "0.9.2"}
12 | io.github.abogoyavlensky/reitit-extras {:mvn/version "0.2.3"}
13 | ring/ring-jetty-adapter {:mvn/version "1.15.3"}
14 | io.github.abogoyavlensky/manifest-edn {:mvn/version "0.1.1"}{{auth-deps}}
15 | ; db
16 | hikari-cp/hikari-cp {:mvn/version "3.3.0"}
17 | {{db-driver-deps}}
18 | com.github.seancorfield/next.jdbc {:mvn/version "1.3.1086"}
19 | com.github.seancorfield/honeysql {:mvn/version "2.7.1364"}
20 | dev.weavejester/ragtime {:mvn/version "0.12.1"}}
21 |
22 | :paths ["src" "resources"]
23 |
24 | :aliases {:dev {:extra-paths ["dev"]
25 | :extra-deps {integrant/repl {:mvn/version "0.5.0"}
26 | ring/ring-devel {:mvn/version "1.15.3"}}}
27 |
28 | :test {:extra-paths ["test"]
29 | :extra-deps {eftest/eftest {:mvn/version "0.6.0"}
30 | cloverage/cloverage {:mvn/version "1.2.4"}
31 | io.github.tonsky/clj-reload {:mvn/version "1.0.0"}
32 | clj-http/clj-http {:mvn/version "3.13.1"}
33 | circleci/bond {:mvn/version "0.6.0"}
34 | org.clj-commons/hickory {:mvn/version "0.7.7"}{{db-test-deps}}}
35 | :exec-fn cloverage.coverage/run-project
36 | :exec-args {:test-ns-path ["test"]
37 | :src-ns-path ["src"]
38 | :runner :eftest
39 | :runner-opts {:multithread? false}}}
40 |
41 | :outdated {:extra-deps {com.github.liquidz/antq {:mvn/version "2.11.1276"}}
42 | :main-opts ["-m" "antq.core" "--no-diff"]}
43 |
44 | :build {:deps {io.github.abogoyavlensky/slim {:mvn/version "0.3.2"}}
45 | :ns-default slim.app
46 | :exec-args {:main-ns {{main/ns}}.core
47 | :src-dirs ["src" "resources" "resources-hashed"]}}}}
48 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/test_auth/auth_logout_test.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.auth-logout-test
2 | (:require [clj-http.client :as http]
3 | [clojure.test :refer :all]
4 | [{{main/ns}}.auth.queries :as queries]
5 | [{{main/ns}}.test-utils :as utils]
6 | [reitit-extras.tests :as reitit-extras]))
7 |
8 | (use-fixtures :once
9 | (utils/with-system))
10 |
11 | (use-fixtures :each
12 | utils/with-truncated-tables)
13 |
14 | (deftest test-post-logout
15 | (let [base-url (reitit-extras/get-server-url (utils/server))
16 | logout-url (str base-url "/auth/logout")
17 | user (queries/create-user! (utils/db) {:email "user@example.com"
18 | :password "password123"})
19 | response (http/post logout-url {:cookies (reitit-extras/session-cookies
20 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN
21 | :identity (select-keys user [:id :email])}
22 | utils/TEST-SECRET-KEY)
23 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN}})
24 | session-cookie-value (get-in response [:cookies "ring-session" :value])]
25 | (testing "Logout should redirect to home page"
26 | (is (= 200 (:status response)))
27 | (is (= "/" (get (:headers response) "HX-Redirect"))))
28 | (testing "Session should be empty after logout"
29 | (is (= {} (reitit-extras/decrypt-session-from-cookie session-cookie-value utils/TEST-SECRET-KEY))))))
30 |
31 | (deftest test-post-logout-unauthenticated
32 | (let [base-url (reitit-extras/get-server-url (utils/server))
33 | logout-url (str base-url "/auth/logout")
34 | response (http/post logout-url {:redirect-strategy :none
35 | :cookies (reitit-extras/session-cookies
36 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
37 | utils/TEST-SECRET-KEY)
38 | :headers {reitit-extras/CSRF-TOKEN-HEADER utils/TEST-CSRF-TOKEN}})
39 | session-cookie-value (get-in response [:cookies "ring-session" :value])]
40 | (testing "Logout should redirect to home page"
41 | (is (= 200 (:status response)))
42 | (is (= "/" (get (:headers response) "HX-Redirect"))))
43 | (testing "Session should be empty after logout"
44 | (is (= {} (reitit-extras/decrypt-session-from-cookie session-cookie-value utils/TEST-SECRET-KEY))))))
45 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/bb.edn:
--------------------------------------------------------------------------------
1 | {:deps {io.github.abogoyavlensky/manifest-edn {:mvn/version "0.1.1"}
2 | babashka/fs {:mvn/version "0.5.30"}}
3 | :tasks
4 | {:init (do
5 | (def input-css-file "resources/public/css/input.css")
6 | (def output-css-file "resources/public/css/output.css"))
7 | :enter (println "Running task:" (:name (current-task)))
8 |
9 | deps {:doc "Install all deps"
10 | :task (do
11 | (clojure "-P -X:dev")
12 | (clojure "-P -X:test")
13 | (clojure "-P -X:outdated")
14 | (clojure "-P -X:build")
15 | (clojure "-P"))}
16 |
17 | clj-repl {{clj-repl-cmd}}
18 |
19 | fmt-check {:doc "Check code formatting"
20 | :task (shell "cljfmt" "check")}
21 |
22 | fmt {:doc "Fix code formatting"
23 | :task (shell "cljfmt" "fix")}
24 |
25 | lint-init {:doc "Linting project's classpath"
26 | :task (shell "clj-kondo" "--parallel" "--dependencies" "--copy-configs"
27 | "--lint" (with-out-str (clojure "-Spath")))}
28 |
29 | lint {:doc "Linting project's code"
30 | :task (shell "clj-kondo" "--parallel" "--lint" "src" "test")}
31 |
32 | outdated-check {:doc "Check outdated Clojure deps versions"
33 | :task (clojure "-M:outdated")}
34 |
35 | outdated {:doc "Upgrade outdated Clojure deps versions"
36 | :task (clojure "-M:outdated --upgrade --force")}
37 |
38 | test {:doc "Run tests"
39 | :task (clojure "-X:test")}
40 |
41 | check {:doc "Run all code checks and tests"
42 | :depends [fmt lint outdated test]}
43 |
44 | css-watch {:doc "Rebuild css on file change in watch mode"
45 | ; Use "mise exec" to be able to run local version in repl
46 | :requires ([babashka.fs :as fs])
47 | :task (let [tailwind (if (fs/which "mise")
48 | ["mise" "exec" "--" "tailwindcss"]
49 | ["tailwindcss"])]
50 | (apply shell (concat tailwind ["-i" input-css-file "-o" output-css-file "--watch"])))}
51 |
52 | css-build {:doc "Build minified css"
53 | :task (shell "tailwindcss" "-i" input-css-file "-o" output-css-file "--minify")}
54 |
55 | fetch-assets {:doc "Fetch static file assets from URLs"
56 | :requires ([manifest-edn.core :as manifest])
57 | :task (manifest/fetch-assets!
58 | [{:url "https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"
59 | :filepath "js/htmx.min.js"}
60 | {:url "https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"
61 | :filepath "js/alpinejs.min.js"}{{fetch-assets-urls}}])}
62 |
63 | build {:doc "Build application uberjar"
64 | :requires ([manifest-edn.core :as manifest])
65 | :task (do
66 | (run 'css-build)
67 | (manifest/hash-assets!)
68 | (clojure "-T:build build"))}{{bb-deploy-kamal}}}}
69 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/src/views.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.views
2 | (:require [manifest-edn.core :as manifest]))
3 |
4 | (defn base
5 | "Base component for html page."
6 | [content]
7 | [:html
8 | {:lang "en"}
9 | [:head
10 | [:meta {:charset "UTF-8"}]
11 | [:meta {:name "viewport"
12 | :content "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"}]
13 | [:meta {:name "msapplication-TileColor"
14 | :content "#ffffff"}]
15 | [:link {:rel "manifest"
16 | :href "/assets/manifest.json"}]
17 | [:link {:rel "icon"
18 | :href (manifest/asset "images/icon@32px.png")}]
19 | [:link {:rel "icon"
20 | :href (manifest/asset "images/icon.svg")
21 | :type "image/svg+xml"}]
22 | [:link {:rel "apple-touch-icon"
23 | :sizes "180x180"
24 | :href (manifest/asset "images/icon@180px.png")}]
25 | [:link {:type "text/css"
26 | :href (manifest/asset "css/output.css")
27 | :rel "stylesheet"}]
28 | [:title "Clojure Stack Lite | A Template for Clojure Projects"]]
29 | [:body
30 | content
31 | [:script {:type "text/javascript"
32 | :src (manifest/asset "js/htmx.min.js")
33 | :defer true}]
34 | [:script {:type "text/javascript"
35 | :src (manifest/asset "js/alpinejs.min.js")
36 | :defer true}]]])
37 |
38 | (defn error-page
39 | [text]
40 | (base
41 | [:div {:class ["mt-56"]}
42 | [:div {:class ["mx-auto" "text-center"]}
43 | [:h1 {:class ["text-5xl"]} text]]]))
44 |
45 | (def home-page
46 | (base
47 | ; ========= TODO: Update home page ========================
48 | [:div
49 | {:class ["text-slate-800" "min-h-screen" "flex" "flex-col"]}
50 | [:main {:class ["flex-grow" "flex" "items-center" "justify-center"]}
51 | [:div {:class ["container" "mx-auto" "px-4" "max-w-4xl" "text-center"]}
52 | [:h1 {:class ["text-6xl" "font-bold" "mb-6" "text-slate-900"]} "Welcome to "
53 | [:span {:class ["bg-gradient-to-r" "from-emerald-400" "to-sky-400" "bg-clip-text" "text-transparent" "relative"]}
54 | "Clojure Stack Lite"]]
55 | [:p {:class ["text-2xl" "mb-10" "text-slate-600"]} "A lightweight, modern template to jumpstart your Clojure project"]
56 | [:p {:class ["text-lg" "mb-12" "text-slate-500"]}
57 | "To begin, modify the existing view in " [:code {:class ["bg-slate-100" "px-1" "rounded"]} "src/{{main/file}}/views/home.clj"]
58 | " or add a new route in " [:code {:class ["bg-slate-100" "px-1" "rounded"]} "src/{{main/file}}/routes.clj"]
59 | " and define a handler in " [:code {:class ["bg-slate-100" "px-1" "rounded"]} "src/{{main/file}}/handlers.clj"]]
60 | [:div {:class ["mb-16"]}
61 | [:a {:class ["bg-slate-900" "hover:bg-slate-800" "text-white" "font-medium" "py-3" "px-8" "rounded-lg" "transition-colors" "duration-200" "mr-4"]
62 | :href "https://stack.bogoyavlensky.com/docs/lite/tutorial"
63 | :target "_blank"} "Get Started"]]]]
64 | [:footer {:class ["py-6" "text-center" "text-sm" "text-slate-500"]}
65 | [:p "Made with ❤️ for the Clojure community"]]]))
66 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/src_auth/views.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.views
2 | (:require [manifest-edn.core :as manifest]
3 | [{{main/ns}}.routes :as-alias routes]
4 | [reitit-extras.core :as ext]))
5 |
6 | (defn base
7 | "Base component for html page."
8 | [content]
9 | [:html
10 | {:lang "en"}
11 | [:head
12 | [:meta {:charset "UTF-8"}]
13 | [:meta {:name "viewport"
14 | :content "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"}]
15 | [:meta {:name "msapplication-TileColor"
16 | :content "#ffffff"}]
17 | [:link {:rel "manifest"
18 | :href "/assets/manifest.json"}]
19 | [:link {:rel "icon"
20 | :href (manifest/asset "images/icon@32px.png")}]
21 | [:link {:rel "icon"
22 | :href (manifest/asset "images/icon.svg")
23 | :type "image/svg+xml"}]
24 | [:link {:rel "apple-touch-icon"
25 | :sizes "180x180"
26 | :href (manifest/asset "images/icon@180px.png")}]
27 | [:link {:type "text/css"
28 | :href (manifest/asset "css/output.css")
29 | :rel "stylesheet"}]
30 | [:title "Clojure Stack Lite | A Template for Clojure Projects"]]
31 | [:body
32 | content
33 | [:script {:type "text/javascript"
34 | :src (manifest/asset "js/htmx.min.js")
35 | :defer true}]
36 | [:script {:type "text/javascript"
37 | :src (manifest/asset "js/alpinejs.min.js")
38 | :defer true}]]])
39 |
40 | (defn error-page
41 | [text]
42 | (base
43 | [:div {:class ["mt-56"]}
44 | [:div {:class ["mx-auto" "text-center"]}
45 | [:h1 {:class ["text-5xl"]} text]]]))
46 |
47 | (defn button
48 | [{:keys [url text props]}]
49 | [:a (merge {:class ["bg-white dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-700"
50 | "text-slate-900 dark:text-white font-medium py-2 px-4 rounded-lg "
51 | "border border-slate-300 dark:border-slate-600 transition-colors duration-200"]
52 | :href url}
53 | props)
54 | text])
55 |
56 | (defn home-page
57 | [{:keys [user router]}]
58 | (base
59 | ; ========= TODO: Update home page ========================
60 | [:div
61 | {:class ["text-slate-800" "min-h-screen" "flex" "flex-col"]}
62 | [:nav {:class ["absolute" "top-0" "right-1/4" "p-4"]}
63 | (if (some? user)
64 | [:div {:class ["flex" "gap-4" "items-center" "justify-center"]}
65 | [:p {:class ["text-slate-900" "font-semibold" "mx-auto"]} (:email user)]
66 | (button {:url (ext/get-route router ::routes/account)
67 | :text "Account"})
68 | (button {:text "Logout"
69 | :url "#"
70 | :props {:hx-post (ext/get-route router ::routes/logout)
71 | :hx-headers (ext/csrf-token-json)}})]
72 | [:div {:class ["flex" "gap-4"]}
73 | (button {:url (ext/get-route router ::routes/login)
74 | :text "Login"})
75 | (button {:url (ext/get-route router ::routes/register)
76 | :text "Register"})])]
77 | [:main {:class ["flex-grow" "flex" "items-center" "justify-center"]}
78 | [:div {:class ["container" "mx-auto" "px-4" "max-w-4xl" "text-center"]}
79 | [:h1 {:class ["text-6xl" "font-bold" "mb-6" "text-slate-900"]} "Welcome to "
80 | [:span {:class ["bg-gradient-to-r" "from-emerald-400" "to-sky-400" "bg-clip-text" "text-transparent" "relative"]}
81 | "Clojure Stack Lite"]]
82 | [:p {:class ["text-2xl" "mb-10" "text-slate-600"]} "A lightweight, modern template to jumpstart your Clojure project"]
83 | [:p {:class ["text-lg" "mb-12" "text-slate-500"]}
84 | "To begin, modify the existing view in " [:code {:class ["bg-slate-100" "px-1" "rounded"]} "src/{{main/ns}}/views/home.clj"]
85 | " or add a new route in " [:code {:class ["bg-slate-100" "px-1" "rounded"]} "src/{{main/ns}}/routes.clj"]
86 | " and define a handler in " [:code {:class ["bg-slate-100" "px-1" "rounded"]} "src/{{main/ns}}/handlers.clj"]]
87 | [:div {:class ["mb-16"]}
88 | [:a {:class ["bg-slate-900" "hover:bg-slate-800" "text-white" "font-medium" "py-3" "px-8" "rounded-lg" "transition-colors" "duration-200" "mr-4"]
89 | :href "https://stack.bogoyavlensky.com/docs/lite/tutorial"
90 | :target "_blank"} "Get Started"]]]]
91 | [:footer {:class ["py-6" "text-center" "text-sm" "text-slate-500"]}
92 | [:p "Made with ❤️ for the Clojure community"]]]))
93 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/src_auth/routes.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.routes
2 | (:require [buddy.auth :as buddy-auth]
3 | [buddy.auth.backends :as backends]
4 | [buddy.auth.middleware :as auth-middleware]
5 | [{{main/ns}}.auth.handlers :as auth-handlers]
6 | [{{main/ns}}.auth.spec :as spec]
7 | [{{main/ns}}.handlers :as handlers]
8 | [reitit-extras.core :as ext]
9 | [ring.util.response :as response]))
10 |
11 | (defn wrap-login-required
12 | "Middleware used in routes that require authentication. Buddy checks
13 | if request key :identity is set to truthy value by any previous middleware.
14 | If the request is not authenticated, then redirect to Login page."
15 | [handler]
16 | (fn [{router :reitit.core/router
17 | :as request}]
18 | (if (buddy-auth/authenticated? request)
19 | (handler request)
20 | (response/redirect (ext/get-route router ::login)))))
21 |
22 | (defn wrap-already-logged-in
23 | "Middleware used in routes that require authentication. Buddy checks
24 | if request key :identity is set to truthy value by any previous middleware.
25 | If the request is not authenticated, then redirect to Login page."
26 | [handler]
27 | (fn [{router :reitit.core/router
28 | :as request}]
29 | (if (buddy-auth/authenticated? request)
30 | (response/redirect (ext/get-route router ::home))
31 | (handler request))))
32 |
33 | (def routes
34 | (let [auth-backend (backends/session)]
35 | [["/" {:name ::home
36 | :middleware [[auth-middleware/wrap-authentication auth-backend]]
37 | :get {:handler handlers/home-handler}
38 | :responses {200 {:body string?}}}]
39 | ["/health" {:name ::health
40 | :get {:handler (fn [_] (response/response "OK"))}}]
41 | ["/auth"
42 | [""
43 | {:middleware [[auth-middleware/wrap-authentication auth-backend]
44 | wrap-already-logged-in]}
45 | ["/register" {:name ::register
46 | :get {:handler auth-handlers/get-register}
47 | :post {:handler auth-handlers/post-register
48 | :parameters {:form [:map
49 | [:email spec/Email]
50 | [:password [:string {:min 8}]]]}
51 | :responses {200 {:body string?}}}}]
52 | ["/login" {:name ::login
53 | :get {:handler auth-handlers/get-login}
54 | :post {:handler auth-handlers/post-login
55 | :parameters {:form [:map
56 | [:email spec/Email]
57 | [:password [:string {:min 1}]]]}
58 | :responses {200 {:body string?}}}}]
59 | ["/forgot-password" {:name ::forgot-password
60 | :get {:handler auth-handlers/get-forgot-password}
61 | :post {:handler auth-handlers/post-forgot-password
62 | :parameters {:form [:map
63 | [:email spec/Email]]}
64 | :responses {200 {:body string?}}}}]
65 | ["/reset-password" {:name ::reset-password
66 | :get {:handler auth-handlers/get-reset-password
67 | :parameters {:query [:map
68 | [:token string?]]}}
69 | :post {:handler auth-handlers/post-reset-password
70 | :parameters {:form [:map
71 | [:password [:string {:min 8}]]
72 | [:confirm-password [:string {:min 8}]]
73 | [:token string?]]}
74 | :responses {200 {:body string?}}}}]]
75 | ["/logout" {:name ::logout
76 | :middleware [[auth-middleware/wrap-authentication auth-backend]]
77 | :post {:handler auth-handlers/post-logout}}]]
78 | ["/account"
79 | {:middleware [[auth-middleware/wrap-authentication auth-backend]
80 | wrap-login-required]}
81 | ["" {:name ::account
82 | :get {:handler auth-handlers/get-account
83 | :responses {200 {:body string?}}}}]
84 | ["/change-password" {:name ::change-password
85 | :post {:handler auth-handlers/post-change-password
86 | :parameters {:form [:map
87 | [:current-password [:string {:min 1}]]
88 | [:new-password [:string {:min 8}]]
89 | [:confirm-new-password [:string {:min 8}]]]}
90 | :responses {200 {:body string?}}}}]]]))
91 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/src/server.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.server
2 | (:require [clojure.tools.logging :as log]
3 | [integrant-extras.core :as ig-extras]
4 | [integrant.core :as ig]
5 | [muuntaja.core :as muuntaja-core]
6 | [reitit-extras.core :as reitit-extras]
7 | [reitit.coercion.malli :as coercion-malli]
8 | [reitit.dev.pretty :as pretty]
9 | [reitit.ring :as ring]
10 | [reitit.ring.coercion :as ring-coercion]
11 | [reitit.ring.middleware.multipart :as ring-multipart]
12 | [reitit.ring.middleware.muuntaja :as muuntaja]
13 | [reitit.ring.middleware.parameters :as ring-parameters]
14 | [ring.adapter.jetty :as jetty]
15 | [ring.middleware.anti-forgery :as anti-forgery]
16 | [ring.middleware.content-type :as content-type]
17 | [ring.middleware.cookies :as ring-cookies]
18 | [ring.middleware.default-charset :as default-charset]
19 | [ring.middleware.keyword-params :as keyword-params]
20 | [ring.middleware.nested-params :as nested-params]
21 | [ring.middleware.not-modified :as not-modified]
22 | [ring.middleware.session :as ring-session]
23 | [ring.middleware.session.cookie :as ring-session-cookie]
24 | [ring.middleware.ssl :as ring-ssl]
25 | [ring.middleware.x-headers :as x-headers]
26 | [{{main/ns}}.handlers :as handlers]
27 | [{{main/ns}}.routes :as app-routes])
28 | (:import com.zaxxer.hikari.HikariDataSource))
29 |
30 | (defmethod ig/assert-key ::server
31 | [_ params]
32 | (ig-extras/validate-schema!
33 | {:component ::server
34 | :data params
35 | :schema [:map
36 | [:options
37 | [:map
38 | [:port pos-int?]
39 | [:session-secret-key string?]
40 | [:cookie-attrs-secure? boolean?]
41 | [:auto-reload? boolean?]
42 | [:cache-assets? {:optional true} boolean?]
43 | [:cache-control {:optional true} string?]]]
44 | [:db [:fn
45 | {:error/message "Invalid datasource type"}
46 | #(instance? HikariDataSource %)]]]}))
47 |
48 | (defn ring-handler
49 | "Return main application handler for server-side rendering."
50 | [{:keys [options]
51 | :as context}]
52 | (let [session-store (ring-session-cookie/cookie-store
53 | {:key (reitit-extras/string->16-byte-array
54 | (:session-secret-key options))})]
55 | (ring/ring-handler
56 | (ring/router
57 | app-routes/routes
58 | {:exception pretty/exception
59 | :data {:muuntaja muuntaja-core/instance
60 | :coercion coercion-malli/coercion
61 | :middleware [[x-headers/wrap-content-type-options :nosniff]
62 | [x-headers/wrap-frame-options :sameorigin]
63 | ring-ssl/wrap-hsts
64 | reitit-extras/wrap-xss-protection
65 | not-modified/wrap-not-modified
66 | content-type/wrap-content-type
67 | [default-charset/wrap-default-charset "utf-8"]
68 | ring-cookies/wrap-cookies
69 | [ring-session/wrap-session
70 | {:cookie-attrs {:secure (:cookie-attrs-secure? options)
71 | :http-only true}
72 | :flash true
73 | :store session-store}]
74 | ; add handler options to request
75 | [reitit-extras/wrap-context context]
76 | ; parse any request parameters
77 | ring-parameters/parameters-middleware
78 | ring-multipart/multipart-middleware
79 | nested-params/wrap-nested-params
80 | keyword-params/wrap-keyword-params
81 | ; negotiate request and response
82 | muuntaja/format-middleware
83 | ; check CSRF token
84 | anti-forgery/wrap-anti-forgery
85 | ; handle exceptions
86 | reitit-extras/exception-middleware
87 | ; coerce request and response to spec
88 | ring-coercion/coerce-exceptions-middleware
89 | reitit-extras/non-throwing-coerce-request-middleware
90 | ring-coercion/coerce-response-middleware]}})
91 | (ring/routes
92 | (reitit-extras/create-resource-handler-cached {:path "/assets/"
93 | :cached? (:cache-assets? options)
94 | :cache-control (:cache-control options)})
95 | (ring/redirect-trailing-slash-handler)
96 | (ring/create-default-handler {:not-found (handlers/default-handler "Page not found" 404)
97 | :method-not-allowed (handlers/default-handler "Method not allowed" 405)
98 | :not-acceptable (handlers/default-handler "Not acceptable" 406)})))))
99 |
100 | (defmethod ig/init-key ::server
101 | [_ {:keys [options]
102 | :as context}]
103 | (log/info "[SERVER] Starting server...")
104 | (let [handler-fn #(ring-handler context)
105 | handler (if (:auto-reload? options)
106 | (reitit-extras/wrap-reload handler-fn)
107 | (handler-fn))]
108 | (jetty/run-jetty handler {:port (:port options)
109 | :host "0.0.0.0"
110 | :join? false})))
111 |
112 | (defmethod ig/halt-key! ::server
113 | [_ server]
114 | (log/info "[SERVER] Stopping server...")
115 | (.stop server))
116 |
--------------------------------------------------------------------------------
/src/io/github/abogoyavlensky/clojure_stack_lite.clj:
--------------------------------------------------------------------------------
1 | (ns io.github.abogoyavlensky.clojure-stack-lite
2 | (:require [clojure.java.io :as io]))
3 |
4 | (def SUBSTITUTIONS-BASE-DIR
5 | "io/github/abogoyavlensky/clojure_stack_lite/substitutions/")
6 |
7 | (def DB-TYPES #{:sqlite :postgres})
8 | (def DEPLOY-TYPES #{:kamal :none})
9 |
10 | (def SUBSTITUTIONS-MAPPING
11 | {:daisyui {:fetch-assets-urls "bb_edn_daisyui.edn"}
12 | :sqlite {:clj-repl-cmd "bb_edn_clj_repl_cmd_sqlite.edn"
13 | :db-config "resources_config_edn_sqlite.edn"
14 | :deploy-config-kamal "kamal-deploy-config-sqlite.txt"
15 | :db-driver-deps "deps_edn_db_driver_deps_sqlite.edn"
16 | :test-utils-db-setup "test_utils_db_setup_sqlite.clj"}
17 | :postgres {:clj-repl-cmd "bb_edn_clj_repl_cmd_postgres.edn"
18 | :db-config "resources_config_edn_postgres.edn"
19 | :sql-result-set-config "src_db_sql_result_set_config_postgres.edn"
20 | :ci-deploy-env-vars "github_workflows_deploy_ci_deploy_env_vars_postgres.txt"
21 | :deploy-config-kamal "kamal-deploy-config-postgres.txt"
22 | :deploy-secrets-kamal "kamal-deploy-secrets-postgres.txt"
23 | :db-driver-deps "deps_edn_db_driver_deps_postgres.edn"
24 | :db-test-deps "deps_edn_db_test_deps_postgres.edn"
25 | :test-utils-db-setup "test_utils_db_setup_postgres.clj"}
26 | :auth {:auth-deps "deps_edn_auth_deps.edn"}
27 | :kamal {:readme-deploy-kamal "readme_deploy_kamal.md"
28 | :bb-deploy-kamal "bb_deploy_kamal.edn"}})
29 |
30 | (defn- get-file-content
31 | [file-name]
32 | (slurp (io/resource (str SUBSTITUTIONS-BASE-DIR file-name))))
33 |
34 | (defn- replace-vars
35 | [mapping]
36 | (reduce-kv
37 | (fn [acc k v]
38 | (assoc acc k (get-file-content v)))
39 | {}
40 | mapping))
41 |
42 | (defn data-fn
43 | "Example data-fn handler.
44 |
45 | Result is merged onto existing options data.
46 | Returning nil means no changes to options data."
47 | [data]
48 | (let [db (keyword (:db data :sqlite))
49 | deploy (keyword (:deploy data :kamal))]
50 |
51 | (when-not (contains? DB-TYPES db)
52 | (throw (Exception. "Invalid db type. Supported types are: :sqlite, :postgres")))
53 |
54 | (when-not (contains? DEPLOY-TYPES deploy)
55 | (throw (Exception. "Invalid deploy type. Supported types are: :kamal, :none")))
56 |
57 | (cond-> {:db db
58 | :deploy deploy
59 | :fetch-assets-urls ""
60 | :clj-repl-cmd ""
61 | :db-config ""
62 | :sql-result-set-config ""
63 | :ci-deploy-env-vars ""
64 | :test-utils-db-setup ""
65 | :deploy-config-kamal ""
66 | :deploy-secrets-kamal ""
67 | :db-driver-deps ""
68 | :db-test-deps ""
69 | :auth-deps ""
70 | :readme-deploy-kamal ""
71 | :bb-deploy-kamal ""}
72 | (:daisyui data) (merge (replace-vars (:daisyui SUBSTITUTIONS-MAPPING)))
73 | (:auth data) (merge (replace-vars (:auth SUBSTITUTIONS-MAPPING)))
74 | db (merge (replace-vars (get SUBSTITUTIONS-MAPPING db)))
75 | deploy (merge (replace-vars (get SUBSTITUTIONS-MAPPING deploy))))))
76 |
77 | (defn post-process-fn
78 | "Example post-process-fn handler.
79 |
80 | Can programmatically modify files in the generated project."
81 | [edn data]
82 | (when (true? (:debug data))
83 | (println "post-process-fn not modifying" (:target-dir data))))
84 |
85 | (def RENAMINGS
86 | {:daisyui {"resources_public_css_default" "resources_public_css_daisyui"}})
87 |
88 | (def EXTENSIONS
89 | {:daisyui [["resources_public_js_daisyui" "resources/public/js"]]
90 | :sqlite [["db_sqlite" "db"]
91 | ["resources_migrations_sqlite" "resources/migrations"]]
92 | :postgres [["resources_migrations_postgres" "resources/migrations"]
93 | ["docker-compose-postgres" ""]]
94 | :auth [["src_auth" "src/{{main/file}}"]
95 | ["test_auth" "test/{{main/file}}"]]
96 | :auth-sqlite [["resources_migrations_sqlite_auth" "resources/migrations"]]
97 | :auth-postgres [["resources_migrations_postgres_auth" "resources/migrations"]]
98 | :kamal [["github_workflows_deploy_yaml_kamal" ".github/workflows"]
99 | ["kamal" ".kamal"]]})
100 |
101 | (defn- apply-transform-source-dir
102 | [suffix transform]
103 | (let [transform-renamed (reduce
104 | (fn [acc v]
105 | (let [origin-source-dir (first v)
106 | new-source-dir (get-in RENAMINGS [suffix origin-source-dir])]
107 | (if (some? new-source-dir)
108 | (conj acc (assoc v 0 new-source-dir))
109 | (conj acc v))))
110 | []
111 | transform)]
112 | (concat transform-renamed (get EXTENSIONS suffix))))
113 |
114 | ; Transform dirs
115 | (defn template-fn
116 | "Example template-fn handler.
117 |
118 | Result is used as the EDN for the template."
119 | [edn data]
120 | (when (true? (:debug data))
121 | (println "template-fn has got data:")
122 | (prn data)
123 | (println "template-fn given edn:")
124 | (prn edn))
125 |
126 | (let [db (:db data (:db edn))
127 | deploy (:deploy data (:deploy edn))
128 | new-transform (cond->> (:transform edn)
129 | (:daisyui data) (apply-transform-source-dir :daisyui)
130 | (:auth data) (apply-transform-source-dir :auth)
131 | (and (:auth data) (= :sqlite db)) (apply-transform-source-dir :auth-sqlite)
132 | (and (:auth data) (= :postgres db)) (apply-transform-source-dir :auth-postgres)
133 | db (apply-transform-source-dir db)
134 | deploy (apply-transform-source-dir deploy))
135 | result (assoc edn :transform new-transform)]
136 | (when (true? (:debug data))
137 | (println "template-fn returning edn:")
138 | (prn result))
139 | result))
140 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/test_auth/auth_register_test.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.auth-register-test
2 | (:require [buddy.hashers :as hashers]
3 | [clj-http.client :as http]
4 | [clojure.test :refer :all]
5 | [hickory.select :as select]
6 | [{{main/ns}}.db :as db]
7 | [{{main/ns}}.server :as-alias server]
8 | [{{main/ns}}.test-utils :as utils]
9 | [reitit-extras.tests :as reitit-extras]))
10 |
11 | (use-fixtures :once
12 | (utils/with-system))
13 |
14 | (use-fixtures :each
15 | utils/with-truncated-tables)
16 |
17 | (deftest test-get-register-ok
18 | (let [base-url (reitit-extras/get-server-url (utils/server))
19 | response (http/get (str base-url "/auth/register"))
20 | body (utils/response->hickory response)]
21 | (is (= "Register"
22 | (->> body
23 | (select/select (select/tag :h2))
24 | (first)
25 | :content
26 | (first))))
27 | (is (= #{"__anti-forgery-token" "email" "password"}
28 | (->> body
29 | (select/select (select/tag :input))
30 | (map (comp :name :attrs))
31 | (set))))
32 | (is (= {:hx-post "/auth/register"
33 | :hx-target "#form-register"
34 | :id "form-register"}
35 | (dissoc (->> body (select/select (select/tag :form)) first :attrs)
36 | :class :hx-swap)))))
37 |
38 | (deftest test-post-register-ok
39 | (let [base-url (reitit-extras/get-server-url (utils/server))
40 | url (str base-url "/auth/register")
41 | response (http/post url {:cookies (reitit-extras/session-cookies
42 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
43 | utils/TEST-SECRET-KEY)
44 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
45 | :email "user@gmail.com"
46 | :password "secret-password"}})
47 | user (db/exec-one! (utils/db) {:select [:email :password]
48 | :from [:user]})]
49 | (is (= "user@gmail.com" (:email user)))
50 | (is (true? (:valid (hashers/verify "secret-password" (:password user) {:alg :bcrypt+sha512}))))
51 | (is (= 200 (:status response)))
52 | (is (= "/" (get (:headers response) "HX-Redirect")))))
53 |
54 | (deftest test-post-register-user-already-exists
55 | (let [base-url (reitit-extras/get-server-url (utils/server))
56 | url (str base-url "/auth/register")
57 | test-email "existing@gmail.com"
58 | ; First, register a user to create the existing account
59 | _ (http/post url {:cookies (reitit-extras/session-cookies
60 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
61 | utils/TEST-SECRET-KEY)
62 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
63 | :email test-email
64 | :password "first-password"}})
65 | ; Now try to register again with the same email
66 | response (http/post url {:cookies (reitit-extras/session-cookies
67 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
68 | utils/TEST-SECRET-KEY)
69 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
70 | :email test-email
71 | :password "second-password"}})
72 | body (utils/response->hickory response)
73 | error-messages (select/select (select/class :error-message) body)
74 | inputs (select/select (select/tag :input) body)]
75 | (is (= 1 (count error-messages)))
76 | (is (= 200 (:status response)))
77 | (is (= ["User already exists"] (-> error-messages first :content)))
78 | (is (= test-email (->> inputs
79 | (filter #(= "email" (get-in % [:attrs :name])))
80 | first
81 | :attrs
82 | :value)))))
83 |
84 | (deftest test-post-register-invalid-email
85 | (let [base-url (reitit-extras/get-server-url (utils/server))
86 | url (str base-url "/auth/register")
87 | invalid-email "not-an-email"
88 | response (http/post url {:cookies (reitit-extras/session-cookies
89 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
90 | utils/TEST-SECRET-KEY)
91 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
92 | :email invalid-email
93 | :password "some-password"}})
94 | body (utils/response->hickory response)
95 | error-messages (select/select (select/class :error-message) body)
96 | inputs (select/select (select/tag :input) body)]
97 | (is (= 1 (count error-messages)))
98 | (is (= 200 (:status response)))
99 | (is (= ["Invalid email format"] (-> error-messages first :content)))
100 | (is (= invalid-email (->> inputs
101 | (filter #(= "email" (get-in % [:attrs :name])))
102 | first
103 | :attrs
104 | :value)))))
105 |
106 | (deftest test-post-register-password-too-short
107 | (let [base-url (reitit-extras/get-server-url (utils/server))
108 | url (str base-url "/auth/register")
109 | test-email "test@example.com"
110 | response (http/post url {:cookies (reitit-extras/session-cookies
111 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
112 | utils/TEST-SECRET-KEY)
113 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
114 | :email test-email
115 | :password "1234567"}})
116 | body (utils/response->hickory response)
117 | error-messages (select/select (select/class :error-message) body)
118 | inputs (select/select (select/tag :input) body)]
119 | (is (= 1 (count error-messages)))
120 | (is (= 200 (:status response)))
121 | (is (= ["Should be at least 8 characters"] (-> error-messages first :content)))
122 | (is (= test-email (->> inputs
123 | (filter #(= "email" (get-in % [:attrs :name])))
124 | first
125 | :attrs
126 | :value)))))
127 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/test_auth/auth_forgot_password_test.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.auth-forgot-password-test
2 | (:require [bond.james :as bond]
3 | [clj-http.client :as http]
4 | [clojure.test :refer :all]
5 | [hickory.select :as select]
6 | [{{main/ns}}.auth.handlers :as handlers]
7 | [{{main/ns}}.auth.queries :as queries]
8 | [{{main/ns}}.test-utils :as utils]
9 | [reitit-extras.tests :as reitit-extras]))
10 |
11 | (use-fixtures :once
12 | (utils/with-system))
13 |
14 | (use-fixtures :each
15 | utils/with-truncated-tables)
16 |
17 | (deftest test-get-forgot-password-ok
18 | (let [base-url (reitit-extras/get-server-url (utils/server))
19 | response (http/get (str base-url "/auth/forgot-password"))
20 | body (utils/response->hickory response)]
21 | (testing "Check page title and form structure"
22 | (is (= "Forgot your password?"
23 | (->> body
24 | (select/select (select/tag :h2))
25 | (first)
26 | :content
27 | (first)))))
28 |
29 | (testing "Check form has required fields"
30 | (is (= #{(name reitit-extras/CSRF-TOKEN-FORM-KEY) "email"}
31 | (->> body
32 | (select/select (select/tag :input))
33 | (map (comp :name :attrs))
34 | (set)))))
35 |
36 | (testing "Check form properties"
37 | (is (= {:hx-post "/auth/forgot-password"
38 | :hx-target "#form-forgot-password"
39 | :id "form-forgot-password"}
40 | (dissoc (->> body (select/select (select/tag :form)) first :attrs)
41 | :class :hx-swap))))))
42 |
43 | (deftest test-get-forgot-password-already-logged-in
44 | (let [base-url (reitit-extras/get-server-url (utils/server))
45 | url (str base-url "/auth/forgot-password")
46 | user (queries/create-user! (utils/db) {:email "user@example.com"
47 | :password "password123"})
48 | response (http/get url {:redirect-strategy :none
49 | :cookies (reitit-extras/session-cookies
50 | {:identity (select-keys user [:id :email])}
51 | utils/TEST-SECRET-KEY)})]
52 |
53 | (testing "Should get a redirect to home page"
54 | (is (= 302 (:status response)))
55 | (is (= "/" (get-in response [:headers "Location"]))))))
56 |
57 | (deftest test-post-forgot-password-existing-email
58 | (bond/with-spy [handlers/send-email!]
59 | (let [base-url (reitit-extras/get-server-url (utils/server))
60 | test-email "user@example.com"
61 | _ (queries/create-user! (utils/db) {:email test-email
62 | :password "password123"})
63 | url (str base-url "/auth/forgot-password")
64 | response (http/post url {:cookies (reitit-extras/session-cookies
65 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
66 | utils/TEST-SECRET-KEY)
67 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
68 | :email test-email}})]
69 |
70 | (testing "Should show success message"
71 | (is (= 200 (:status response)))
72 | (is (some? (->> (utils/response->hickory response)
73 | (select/select (select/find-in-text #"check your email.*"))
74 | (first)))))
75 |
76 | (testing "Send email to user for existing email"
77 | (is (= 1 (-> handlers/send-email! bond/calls count)))))))
78 |
79 | (deftest test-post-forgot-password-nonexistent-email
80 | (bond/with-spy [handlers/send-email!]
81 | (let [base-url (reitit-extras/get-server-url (utils/server))
82 | url (str base-url "/auth/forgot-password")
83 | response (http/post url {:cookies (reitit-extras/session-cookies
84 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
85 | utils/TEST-SECRET-KEY)
86 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
87 | :email "nonexistent@example.com"}})]
88 | (testing "Should show success message even for nonexistent email"
89 | (is (= 200 (:status response)))
90 | (is (some? (->> (utils/response->hickory response)
91 | (select/select (select/find-in-text #"check your email.*"))
92 | (first)))))
93 |
94 | (testing "Do not send email if user does not exist"
95 | (is (= 0 (-> #'handlers/send-email! bond/calls count)))))))
96 |
97 | (deftest test-post-forgot-password-invalid-email
98 | (bond/with-spy [handlers/send-email!]
99 | (let [base-url (reitit-extras/get-server-url (utils/server))
100 | url (str base-url "/auth/forgot-password")
101 | invalid-email "not-an-email"
102 | response (http/post url {:cookies (reitit-extras/session-cookies
103 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
104 | utils/TEST-SECRET-KEY)
105 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
106 | :email invalid-email}})
107 | body (utils/response->hickory response)
108 | error-messages (select/select (select/class :error-message) body)
109 | inputs (select/select (select/tag :input) body)]
110 |
111 | (testing "Should return error for invalid email format"
112 | (is (= 1 (count error-messages)))
113 | (is (= 200 (:status response)))
114 | (is (= ["Invalid email format"] (-> error-messages first :content)))
115 | (is (= invalid-email (->> inputs
116 | (filter #(= "email" (get-in % [:attrs :name])))
117 | first
118 | :attrs
119 | :value))))
120 |
121 | (testing "Do not send email if user does not exist"
122 | (is (= 0 (-> #'handlers/send-email! bond/calls count)))))))
123 |
124 | (deftest test-post-forgot-password-missing-email
125 | (let [base-url (reitit-extras/get-server-url (utils/server))
126 | url (str base-url "/auth/forgot-password")
127 | response (http/post url {:cookies (reitit-extras/session-cookies
128 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
129 | utils/TEST-SECRET-KEY)
130 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN}})
131 | error-messages (->> response
132 | (utils/response->hickory)
133 | (select/select (select/class :error-message)))]
134 | (is (= 200 (:status response)))
135 | (is (pos? (count error-messages)))))
136 |
137 | (deftest test-post-forgot-password-empty-email
138 | (let [base-url (reitit-extras/get-server-url (utils/server))
139 | url (str base-url "/auth/forgot-password")
140 | response (http/post url {:cookies (reitit-extras/session-cookies
141 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
142 | utils/TEST-SECRET-KEY)
143 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN}})
144 | error-messages (->> response
145 | (utils/response->hickory)
146 | (select/select (select/class :error-message)))]
147 | (is (= 200 (:status response)))
148 | (is (pos? (count error-messages)))))
149 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Clojure Stack Lite
2 |
3 | A quick way to start a full-stack Clojure app with server-side rendering.
4 | Built on a powerful yet lightweight stack featuring SQLite/PostgreSQL, HTMX, AlpineJS, and TailwindCSS v4.
5 |
6 | This template is designed to be lightweight and easy to use, with a focus on rapid development and deployment. Minimal distraction and sane defaults. Everything is streamlined to give you a solid foundation for building modern web applications.
7 |
8 | Get started building your new Clojure application in seconds and be productive!
9 |
10 | ## Usage
11 |
12 | 1. Create a new Clojure project using Clojure CLI:
13 | ```bash
14 | clojure -Ttools install-latest :lib io.github.seancorfield/deps-new :as new
15 | clojure -Tnew create :template io.github.abogoyavlensky/clojure-stack-lite :name myproject
16 | ```
17 |
18 | > Requires Clojure CLI tools version `1.12.0.1479` or later.
19 |
20 | Or alternatively using [neil](https://github.com/babashka/neil):
21 |
22 | ```bash
23 | brew install babashka/brew/neil
24 | neil new io.github.abogoyavlensky/clojure-stack-lite myproject
25 | ```
26 |
27 | 2. Start development (with [mise](https://mise.jdx.dev/getting-started.html)):
28 |
29 | ```shell
30 | cd myproject
31 | mise trust && mise install
32 | bb clj-repl
33 | (reset)
34 | ```
35 |
36 | The server should be available at `http://localhost:8000`.
37 | Check out project's README.md and template documentation for more information on how to use the project.
38 |
39 | > [!TIP]
40 | > Edit some details before going to production: label in `Dockerfile`,
41 | domain in `resources/public/manifest.json` and description in `README.md`
42 |
43 | ## Features
44 |
45 | - 🏗️ Robust Clojure stack powered by Integrant and Reitit/Ring
46 | - 🎨 Lightweight frontend using HTMX, AlpineJS and TailwindCSS v4 (with optional DaisyUI
47 | components)
48 | - 📦 SQLite/PostgreSQL database (you choose)
49 | - 🔄 Zero-downtime deployment via Kamal
50 | - ⚡ GitHub Actions CI/CD pipeline
51 | - 🧪 Integration and unit testing setup with coverage
52 | - 🔍 Linting, formatting and deps version management
53 | - ⚙️ deps.edn and Babashka Tasks for efficient project management
54 | - 📱 Basic PWA support out of the box (without service worker)
55 | - 🔒 Optional authentication and registration flow
56 |
57 | ## Stack
58 |
59 | ### Backend
60 | - **Integrant**: Component lifecycle management for application
61 | - **Reitit**: Fast data-driven routing
62 | - **Ring/Jetty**: HTTP server adapter
63 | - **Hiccup**: HTML generation from Clojure data structures
64 | - **Malli**: Data validation and specification
65 |
66 | ### Database
67 |
68 | - **SQLite/PostgreSQL**: Choose between file-based SQLite or enterprise-grade PostgreSQL
69 | - **next.jdbc**: JDBC-based database access
70 | - **HoneySQL**: SQL as Clojure data structures
71 | - **Ragtime**: Database migrations
72 |
73 | ### Frontend
74 | - **HTMX 2**: HTML extensions for AJAX without writing JavaScript
75 | - **AlpineJS 3**: Lightweight JavaScript framework for adding behavior
76 | - **TailwindCSS 4**: Utility-first CSS framework
77 | - [OPTIONAL] **DaisyUI**: A UI Component library (`:daisyui true` option)
78 |
79 | ### Development
80 | - **Babashka**: Project management with tasks
81 | - **clj-kondo**: Static analyzer and linter
82 | - **cljfmt**: Code formatter
83 | - **eftest/cloverage**: Testing and code coverage
84 |
85 | ### Deployment
86 | - **Docker**: Containerization
87 | - **Kamal**: Zero-downtime deployments
88 | - **GitHub Actions**: CI/CD workflows
89 |
90 | ### Features
91 | - Authentication and registration flow (`:auth true` option)
92 | - Login, logout, registration, password reset, password change, account page
93 |
94 | ## Project structure
95 |
96 | The template generates a Clojure project with the following structure:
97 |
98 | ```
99 | ├── .clj-kondo/ # Clojure linting configuration
100 | ├── .github/ # GitHub Actions workflows and configurations
101 | ├── .kamal/ # Kamal deployment configuration (only used with Kamal)
102 | ├── db/ # Empty database directory for database files (only used with SQLite)
103 | ├── dev/ # Development configuration directory
104 | │ └── user.clj # User-specific development configuration
105 | ├── resources/ # Static resources and configuration files
106 | │ ├── public/ # Public assets (CSS, JS, images)
107 | │ ├── migrations/ # Database migration files
108 | │ ├── config.edn # Main configuration file for the application
109 | │ ├── config.dev.edn # Development-specific configuration
110 | │ └── logback.xml # Logging configuration file
111 | ├── src/ # Source code directory
112 | │ └── {{name}} # Main namespace directory
113 | │ ├── core.clj # Application entry point
114 | │ ├── db.clj # Database system component and main operations
115 | │ ├── handlers.clj # HTTP request handlers
116 | │ ├── routes.clj # Route definitions
117 | │ ├── server.clj # Server system component
118 | │ └── views.clj # HTML templates and components with Hiccup
119 | ├── test/ # Test files directory
120 | │ └── {{name}} # Test namespace directory
121 | │ ├── home_test.clj # Example test for home page
122 | │ └── test_utils.clj # Test utilities
123 | ├── .cljfmt.edn # Formatting configuration
124 | ├── .gitignore # Git ignore rules
125 | ├── .mise.toml # mise-en-place configuration with system dependencies
126 | ├── .dockerignore # Docker ignore rules
127 | ├── bb.edn # Babashka tasks configuration for managing application
128 | ├── deps.edn # Clojure dependencies and aliases
129 | ├── Dockerfile # Dockerfile for building the application image
130 | ├── docker-compose.yaml # Run PostgreSQL database for local development (only used with PostgreSQL)
131 | ├── LICENSE # License file, AGPLv3 by default, for motivation check: https://plausible.io/blog/open-source-licenses
132 | └── README.md # Project documentation
133 | ```
134 |
135 | ## Options
136 |
137 | The template offers customization options for generating your project:
138 |
139 | - `:db` - Choose between SQLite or PostgreSQL for your database (*Default: `:sqlite`*)
140 | - Available values: `:sqlite` or `:postgres`
141 | - `:daisyui` - Include [DaisyUI](https://daisyui.com/), a component library for TailwindCSS (*Default: `false`*)
142 | - Available values: `true`
143 | - `:deploy` - Choose between Kamal and no deployment configuration (*Default:* `:kamal`)
144 | - Available values: `:kamal` or `:none`
145 | - `:auth` - Add authentication and registration flow (*Default: `false`*)
146 | - Available values: `true`
147 |
148 | Possible values: `false | true`
149 |
150 | Usage example:
151 |
152 | ```shell
153 | clojure -Tnew create :template io.github.abogoyavlensky/clojure-stack-lite :name myproject :daisyui true
154 | ```
155 |
156 | ## Authentication flow preview
157 |
158 | https://github.com/user-attachments/assets/187daef7-d39d-4794-8dd4-2a88b735c826
159 |
160 | ## Roadmap
161 |
162 | - [x] DaisyUI support
163 | - [x] PostgreSQL support
164 | - [x] No deployment option
165 | - [x] Register/Auth flow
166 | - [ ] Sentry support
167 | - [ ] More frontend tool options (TwinSpark, Datastar)
168 | - [ ] Queue support
169 | - [ ] Websocket support
170 |
171 | ## Links
172 |
173 | - [Integrant + Aero](https://lambdaisland.com/blog/2019-12-11-advent-of-parens-11-integrant-in-practice)
174 | - [Start TailwindCSS as part of the app system](https://shagunagrawal.me/posts/multiplayer-board-game-in-clojure/#repl)
175 | - [Auto-reloading Ring/Reitit](https://bogoyavlensky.com/blog/auto-reloading-ring/)
176 | - [Sessions with Ring/Reitit](https://github.com/metosin/reitit/issues/205)
177 | - [Clojure + Kamal](https://bogoyavlensky.com/blog/deploying-full-stack-clojure-app-with-kamal/)
178 | - [CI with Kamal](https://igor.works/blog/evolution-of-github-action-for-kamal)
179 |
180 | ## Template Development
181 |
182 | ### Requirements
183 |
184 | To work with this template, you need:
185 |
186 | 1. [mise](https://mise.jdx.dev/) (recommended) or manual installation of:
187 | - Java
188 | - Clojure
189 | - Babashka
190 |
191 | ### Getting Started
192 |
193 | All management tasks:
194 | ```shell
195 | bb tasks
196 | The following tasks are available:
197 |
198 | test Run tests for the template config
199 | new Create a new project
200 | release Create and push a new git tag based on provided version
201 | ```
202 |
203 | After you updated the template and ran tests `bb test`, you can create a new project to check if everything works as expected:
204 |
205 | ```shell
206 | bb new
207 | ```
208 | The new project will be created in the `tmpl` directory at the root that is ignored by git.
209 |
210 | ### Release
211 |
212 | Once you are ready to release a new version of the template, bump version in `deps.edn`:
213 |
214 | ```
215 | :aliases -> :build -> :exec-args -> :version -> "0.1.1
216 | ```
217 |
218 | and then run the following command:
219 |
220 | ```shell
221 | bb release
222 | ```
223 |
224 | A new git tag based on latest version will be created and pushed to the repository.
225 |
226 | ## License
227 | MIT License
228 | Copyright (c) 2025 Andrey Bogoyavlenskiy
229 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/test_auth/auth_login_test.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.auth-login-test
2 | (:require [clj-http.client :as http]
3 | [clojure.test :refer :all]
4 | [hickory.select :as select]
5 | [{{main/ns}}.auth.queries :as queries]
6 | [{{main/ns}}.test-utils :as utils]
7 | [reitit-extras.tests :as reitit-extras]))
8 |
9 | (use-fixtures :once
10 | (utils/with-system))
11 |
12 | (use-fixtures :each
13 | utils/with-truncated-tables)
14 |
15 | (deftest test-get-login-ok
16 | (let [base-url (reitit-extras/get-server-url (utils/server))
17 | response (http/get (str base-url "/auth/login"))
18 | body (utils/response->hickory response)]
19 | (is (= "Login"
20 | (->> body
21 | (select/select (select/tag :h2))
22 | (first)
23 | :content
24 | (first))))
25 | (is (= #{(name reitit-extras/CSRF-TOKEN-FORM-KEY) "email" "password"}
26 | (->> body
27 | (select/select (select/tag :input))
28 | (map (comp :name :attrs))
29 | (set))))
30 | (is (= {:hx-post "/auth/login"
31 | :hx-target "#form-login"
32 | :id "form-login"}
33 | (dissoc (->> body (select/select (select/tag :form)) first :attrs)
34 | :class :hx-swap)))))
35 |
36 | (deftest test-post-login-success
37 | (let [base-url (reitit-extras/get-server-url (utils/server))
38 | login-url (str base-url "/auth/login")
39 | test-email "user@example.com"
40 | test-password "password123"
41 | _ (queries/create-user! (utils/db) {:email test-email
42 | :password test-password})
43 | response (http/post login-url
44 | {:cookies (reitit-extras/session-cookies
45 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
46 | utils/TEST-SECRET-KEY)
47 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
48 | :email test-email
49 | :password test-password}})]
50 | (is (= 200 (:status response)))
51 | (is (= "/" (get (:headers response) "HX-Redirect")))))
52 |
53 | (deftest test-post-login-invalid-email
54 | (let [base-url (reitit-extras/get-server-url (utils/server))
55 | url (str base-url "/auth/login")
56 | invalid-email "not-an-email"
57 | response (http/post url {:cookies (reitit-extras/session-cookies
58 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
59 | utils/TEST-SECRET-KEY)
60 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
61 | :email invalid-email
62 | :password "some-password"}})
63 | body (utils/response->hickory response)
64 | error-messages (select/select (select/class :error-message) body)
65 | inputs (select/select (select/tag :input) body)]
66 | (is (= 1 (count error-messages)))
67 | (is (= 200 (:status response)))
68 | (is (= ["Invalid email format"] (-> error-messages first :content)))
69 | (is (= invalid-email (->> inputs
70 | (filter #(= "email" (get-in % [:attrs :name])))
71 | first
72 | :attrs
73 | :value)))))
74 |
75 | (deftest test-post-login-incorrect-password
76 | (let [base-url (reitit-extras/get-server-url (utils/server))
77 | login-url (str base-url "/auth/login")
78 | test-email "user2@example.com"
79 | _ (queries/create-user! (utils/db) {:email test-email
80 | :password "password123"})
81 | response (http/post login-url {:cookies (reitit-extras/session-cookies
82 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
83 | utils/TEST-SECRET-KEY)
84 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
85 | :email test-email
86 | :password "wrong-password"}})
87 | body (utils/response->hickory response)
88 | error-messages (select/select (select/class :error-message) body)
89 | inputs (select/select (select/tag :input) body)]
90 | (is (= 1 (count error-messages)))
91 | (is (= 200 (:status response)))
92 | (is (= ["Invalid email or password"] (-> error-messages first :content)))
93 | (is (= test-email (->> inputs
94 | (filter #(= "email" (get-in % [:attrs :name])))
95 | first
96 | :attrs
97 | :value)))))
98 |
99 | (deftest test-post-login-nonexistent-user
100 | (let [base-url (reitit-extras/get-server-url (utils/server))
101 | login-url (str base-url "/auth/login")
102 | nonexistent-email "nonexistent@example.com"
103 | response (http/post login-url {:cookies (reitit-extras/session-cookies
104 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
105 | utils/TEST-SECRET-KEY)
106 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
107 | :email nonexistent-email
108 | :password "some-password"}})
109 | body (utils/response->hickory response)
110 | error-messages (select/select (select/class :error-message) body)
111 | inputs (select/select (select/tag :input) body)]
112 | (is (= 1 (count error-messages)))
113 | (is (= 200 (:status response)))
114 | (is (= ["Invalid email or password"] (-> error-messages first :content)))
115 | (is (= nonexistent-email (->> inputs
116 | (filter #(= "email" (get-in % [:attrs :name])))
117 | first
118 | :attrs
119 | :value)))))
120 |
121 | (deftest test-get-login-already-logged-in
122 | (let [base-url (reitit-extras/get-server-url (utils/server))
123 | login-url (str base-url "/auth/login")
124 | user (queries/create-user! (utils/db) {:email "user@example.com"
125 | :password "password123"})
126 | response (http/get login-url {:redirect-strategy :none
127 | :cookies (reitit-extras/session-cookies
128 | {:identity (select-keys user [:id :email])}
129 | utils/TEST-SECRET-KEY)})]
130 | (is (= 302 (:status response)))
131 | (is (= "/" (get-in response [:headers "Location"])))))
132 |
133 | (deftest test-post-login-missing-email
134 | (let [base-url (reitit-extras/get-server-url (utils/server))
135 | login-url (str base-url "/auth/login")
136 | response (http/post login-url {:cookies (reitit-extras/session-cookies
137 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
138 | utils/TEST-SECRET-KEY)
139 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
140 | :password "some-password"}})
141 | error-messages (->> response
142 | (utils/response->hickory)
143 | (select/select (select/class :error-message)))]
144 | (is (= 200 (:status response)))
145 | (is (pos? (count error-messages)))))
146 |
147 | (deftest test-post-login-missing-password
148 | (let [base-url (reitit-extras/get-server-url (utils/server))
149 | login-url (str base-url "/auth/login")
150 | response (http/post login-url {:cookies (reitit-extras/session-cookies
151 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
152 | utils/TEST-SECRET-KEY)
153 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
154 | :email "test@example.com"}})
155 | error-messages (->> response
156 | (utils/response->hickory)
157 | (select/select (select/class :error-message)))]
158 | (is (= 200 (:status response)))
159 | (is (pos? (count error-messages)))))
160 |
161 | (deftest test-post-login-empty-fields
162 | (let [base-url (reitit-extras/get-server-url (utils/server))
163 | login-url (str base-url "/auth/login")
164 | response (http/post login-url {:cookies (reitit-extras/session-cookies
165 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
166 | utils/TEST-SECRET-KEY)
167 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
168 | :email ""
169 | :password ""}})
170 | error-messages (->> response
171 | (utils/response->hickory)
172 | (select/select (select/class :error-message)))]
173 | (is (= 200 (:status response)))
174 | (is (pos? (count error-messages)))))
175 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/src_auth/auth/handlers.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.auth.handlers
2 | (:require [buddy.hashers :as hashers]
3 | [buddy.sign.jwt :as jwt]
4 | [clojure.string :as str]
5 | [{{main/ns}}.auth.queries :as queries]
6 | [{{main/ns}}.auth.views :as views]
7 | [{{main/ns}}.routes :as-alias routes]
8 | [reitit-extras.core :as ext]
9 | [ring.util.response :as response])
10 | (:import [java.sql SQLException]
11 | [java.time Instant Duration]))
12 |
13 | (def ^:const PASSWORD-HASH-ALGORITHM :bcrypt+sha512)
14 | (def ^:const JWT-ALGORITHM :hs256)
15 |
16 | ; Common response utilities
17 | (defn redirect-with-session
18 | "Create an HTMX redirect response with session data"
19 | [router route-name session-data]
20 | (-> (ext/render-html [:div])
21 | (response/header "HX-Redirect" (ext/get-route router route-name))
22 | (assoc :session session-data)))
23 |
24 | (defn build-reset-url
25 | "Build a password reset URL with token"
26 | [request router token]
27 | (str (-> request :headers (get "host"))
28 | (ext/get-route router ::routes/reset-password)
29 | "?token=" token))
30 |
31 | ; Password utilities
32 | (defn verify-password
33 | "Safely verify a password, returning {:valid boolean}"
34 | [password user-password]
35 | (try
36 | (hashers/verify password user-password {:alg PASSWORD-HASH-ALGORITHM})
37 | (catch Exception _e
38 | {:valid false})
39 | (catch AssertionError _e
40 | {:valid false})))
41 |
42 | ; JWT utilities
43 | (defn create-reset-token
44 | "Create a password reset JWT token"
45 | [user-id email secret-key]
46 | (let [now (Instant/now)
47 | claims {:sub user-id
48 | :email email
49 | :exp (.getEpochSecond (.plus now (Duration/ofHours 24)))
50 | :iat (.getEpochSecond now)}]
51 | (jwt/sign claims secret-key {:alg JWT-ALGORITHM})))
52 |
53 | (defn verify-reset-token
54 | "Verify and decode a password reset token"
55 | [token secret-key]
56 | (try
57 | {:valid true
58 | :claims (jwt/unsign token secret-key {:alg JWT-ALGORITHM})}
59 | (catch Exception _e
60 | {:valid false})))
61 |
62 | (defn get-register
63 | "Display the user registration form"
64 | [{router :reitit.core/router}]
65 | (-> {:router router}
66 | (views/register-page)
67 | (ext/render-html)))
68 |
69 | (defn- auth-session
70 | [user]
71 | {:identity (select-keys user [:id :email])})
72 |
73 | (defn post-register
74 | "Process user registration form submission"
75 | [{:keys [context errors parameters params]
76 | router :reitit.core/router}]
77 | (if (some? errors)
78 | (-> {:router router
79 | :values params
80 | :errors (:humanized errors)}
81 | (views/register-form)
82 | (ext/render-html))
83 | (let [{:keys [email password]} (:form parameters)
84 | base-data {:router router
85 | :values params}]
86 | (try
87 | (let [user (queries/create-user! (:db context) {:email email
88 | :password password})]
89 | (redirect-with-session router ::routes/home (auth-session user)))
90 | (catch SQLException e
91 | (let [error-msg (if (re-find #"unique constraint" (str/lower-case (ex-message e)))
92 | "user already exists"
93 | "unexpected database error while creating account")]
94 | (-> (assoc base-data :errors {:email [error-msg]})
95 | (views/register-form)
96 | (ext/render-html))))
97 | (catch Exception _e
98 | (-> (assoc base-data :errors {:common ["unexpected server error"]})
99 | (views/register-form)
100 | (ext/render-html)))))))
101 |
102 | (defn get-login
103 | "Display the user login form"
104 | [{router :reitit.core/router}]
105 | (-> {:router router}
106 | (views/login-page)
107 | (ext/render-html)))
108 |
109 | (defn post-login
110 | "Process user login form submission"
111 | [{:keys [errors params parameters context]
112 | router :reitit.core/router}]
113 | (if (some? errors)
114 | (-> {:router router
115 | :values params
116 | :errors (:humanized errors)}
117 | (views/login-form)
118 | (ext/render-html))
119 | (let [{:keys [email password]} (:form parameters)
120 | user (queries/get-user (:db context) email)
121 | {:keys [valid]} (verify-password password (:password user))]
122 | (if (and (some? user) valid)
123 | (redirect-with-session router ::routes/home (auth-session user))
124 | (-> {:router router
125 | :values params
126 | :errors {:common ["Invalid email or password"]}}
127 | (views/login-form)
128 | (ext/render-html))))))
129 |
130 | (defn post-logout
131 | "Log out the current user and redirect to home"
132 | [{router :reitit.core/router}]
133 | (redirect-with-session router ::routes/home nil))
134 |
135 | (defn get-account
136 | "Display the user account page"
137 | [request]
138 | (-> {:user (:identity request)
139 | :router (:reitit.core/router request)}
140 | (views/account-page)
141 | (ext/render-html)))
142 |
143 | (defn post-change-password
144 | "Process password change form submission"
145 | [{:keys [context errors parameters params]
146 | user :identity
147 | router :reitit.core/router}]
148 | (if (seq errors)
149 | (-> {:user user
150 | :router router
151 | :values params
152 | :errors (:humanized errors)}
153 | (views/change-password-form)
154 | (ext/render-html))
155 | (let [{:keys [current-password new-password confirm-new-password]} (:form parameters)
156 | user (queries/get-user (:db context) (:email user))
157 | {:keys [valid]} (verify-password current-password (:password user))
158 | base-data {:user user
159 | :router router
160 | :values params}]
161 | (cond
162 | (not valid)
163 | (-> base-data
164 | (assoc :errors {:current-password ["Current password is incorrect"]})
165 | (views/change-password-form)
166 | (ext/render-html))
167 |
168 | (not= new-password confirm-new-password)
169 | (-> (assoc base-data :errors {:common ["New passwords do not match"]})
170 | (views/change-password-form)
171 | (ext/render-html))
172 |
173 | (= current-password new-password)
174 | (-> (assoc base-data :errors {:common ["New password must be different from current password"]})
175 | (views/change-password-form)
176 | (ext/render-html))
177 |
178 | :else
179 | (let [password-hash (hashers/derive new-password {:alg PASSWORD-HASH-ALGORITHM})]
180 | (queries/update-password! (:db context) {:id (:id user)
181 | :password-hash password-hash})
182 | (-> (assoc base-data :password-changed? true)
183 | (dissoc :values)
184 | (views/change-password-form)
185 | (ext/render-html)))))))
186 |
187 | (defn get-forgot-password
188 | "Display the forgot password form"
189 | [{router :reitit.core/router}]
190 | (-> {:router router}
191 | (views/forgot-password-page)
192 | (ext/render-html)))
193 |
194 | (defn send-email!
195 | "Send password reset email (currently prints to console)"
196 | [{:keys [email reset-link]}]
197 | ; TODO: send email instead of printing to console
198 | (println (str "============================================\n"
199 | "Password Reset Link for: " email "\n"
200 | reset-link "\n"
201 | "============================================\n")))
202 |
203 | (defn post-forgot-password
204 | "Process forgot password form submission and send reset email"
205 | [{:keys [errors params parameters context]
206 | router :reitit.core/router
207 | :as request}]
208 | (if (seq errors)
209 | (-> {:router router
210 | :values params
211 | :errors (:humanized errors)}
212 | (views/forgot-password-form)
213 | (ext/render-html))
214 | (let [{:keys [email]} (:form parameters)
215 | user (queries/get-user (:db context) email)]
216 | (when (some? user)
217 | (let [token (create-reset-token (:id user) email (:session-secret-key (:options context)))
218 | reset-link (build-reset-url request router token)]
219 | (send-email! {:email email
220 | :reset-link reset-link})))
221 | (-> {:router router
222 | :email-sent? true}
223 | (views/forgot-password-form)
224 | (ext/render-html)))))
225 |
226 | (defn get-reset-password
227 | "Display the password reset form with token validation"
228 | [{:keys [parameters context]
229 | router :reitit.core/router}]
230 | (let [token (get-in parameters [:query :token])
231 | {:keys [valid claims]} (verify-reset-token token (:session-secret-key (:options context)))]
232 | (if valid
233 | (ext/render-html (views/reset-password-page {:router router
234 | :token token
235 | :email (:email claims)}))
236 | (-> (ext/render-html (views/invalid-reset-token-page {:router router}))
237 | (response/status 400)))))
238 |
239 | (defn post-reset-password
240 | "Process password reset form submission"
241 | [{:keys [errors params parameters context]
242 | router :reitit.core/router}]
243 | (let [token (:token params)
244 | {:keys [password confirm-password]} (:form parameters)
245 | {:keys [valid claims]} (verify-reset-token token (:session-secret-key (:options context)))
246 | base-data {:router router
247 | :values params
248 | :email (:email claims)
249 | :token token}]
250 | (cond
251 | (not valid)
252 | (-> (assoc base-data :errors {:common ["Invalid or expired token"]})
253 | (views/reset-password-form)
254 | (ext/render-html))
255 |
256 | (seq errors)
257 | (-> (assoc base-data :errors (:humanized errors))
258 | (views/reset-password-form)
259 | (ext/render-html))
260 |
261 | (not= password confirm-password)
262 | (-> (assoc base-data :errors {:common ["Passwords do not match"]})
263 | (views/reset-password-form)
264 | (ext/render-html))
265 |
266 | :else
267 | (let [user-id (:sub claims)
268 | password-hash (hashers/derive password {:alg PASSWORD-HASH-ALGORITHM})]
269 | (queries/update-password! (:db context) {:id user-id
270 | :password-hash password-hash})
271 | (ext/render-html (views/password-reset-success-page {:router router}))))))
272 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/test_auth/auth_account_test.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.auth-account-test
2 | (:require [clj-http.client :as http]
3 | [clojure.test :refer :all]
4 | [hickory.select :as select]
5 | [{{main/ns}}.auth.queries :as queries]
6 | [{{main/ns}}.test-utils :as utils]
7 | [reitit-extras.tests :as reitit-extras]))
8 |
9 | (use-fixtures :once
10 | (utils/with-system))
11 |
12 | (use-fixtures :each
13 | utils/with-truncated-tables)
14 |
15 | (deftest test-get-account-ok
16 | (let [base-url (reitit-extras/get-server-url (utils/server))
17 | user (queries/create-user! (utils/db) {:email "user@example.com"
18 | :password "secure-password"})
19 | url (str base-url "/account")
20 | response (http/get url {:cookies (reitit-extras/session-cookies
21 | {:identity (select-keys user [:id :email])}
22 | utils/TEST-SECRET-KEY)})
23 | body (utils/response->hickory response)]
24 |
25 | (testing "Account heading"
26 | (is (= 200 (:status response)))
27 | (is (= "Account settings"
28 | (->> body
29 | (select/select (select/tag :h2))
30 | (first)
31 | :content
32 | (first)))))
33 |
34 | (testing "User email is displayed"
35 | (is (some? (->> body
36 | (select/select (select/find-in-text #".*user@example.com.*"))
37 | (first)))))))
38 |
39 | (deftest test-get-account-unauthenticated
40 | (let [base-url (reitit-extras/get-server-url (utils/server))
41 | account-url (str base-url "/account")
42 | response (http/get account-url {:redirect-strategy :none})]
43 | (is (= 302 (:status response)))
44 | (is (= "/auth/login" (get-in response [:headers "Location"])))))
45 |
46 | (deftest test-post-change-password-ok
47 | (let [base-url (reitit-extras/get-server-url (utils/server))
48 | original-password "original-password"
49 | new-password "new-secure-password"
50 | user (queries/create-user! (utils/db) {:email "password-change@example.com"
51 | :password original-password})
52 | change-response (http/post (str base-url "/account/change-password")
53 | {:cookies (reitit-extras/session-cookies
54 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN
55 | :identity (select-keys user [:id :email])}
56 | utils/TEST-SECRET-KEY)
57 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
58 | :current-password original-password
59 | :new-password new-password
60 | :confirm-new-password new-password}})]
61 | (is (= 200 (:status change-response)))
62 | (is (some? (->> (utils/response->hickory change-response)
63 | (select/select (select/find-in-text #".*Password Updated Successfully.*"))
64 | (first))))))
65 |
66 | (deftest test-get-account-form-structure
67 | (let [base-url (reitit-extras/get-server-url (utils/server))
68 | user (queries/create-user! (utils/db) {:email "user@example.com"
69 | :password "secure-password"})
70 | account-url (str base-url "/account")
71 | response (http/get account-url {:cookies (reitit-extras/session-cookies
72 | {:identity (select-keys user [:id :email])}
73 | utils/TEST-SECRET-KEY)})
74 | body (utils/response->hickory response)]
75 |
76 | (testing "Verify change password form exists"
77 | (is (= 200 (:status response)))
78 | (let [form (->> body
79 | (select/select (select/tag :form))
80 | (filter #(= "/account/change-password" (get-in % [:attrs :hx-post])))
81 | (first))]
82 | (is (some? form))
83 | (is (= "form-change-password" (get-in form [:attrs :id])))))
84 |
85 | (testing "Verify form has required fields"
86 | (let [inputs (->> body
87 | (select/select (select/tag :input))
88 | (map (comp :name :attrs))
89 | (set))]
90 | (is (contains? inputs "current-password"))
91 | (is (contains? inputs "new-password"))
92 | (is (contains? inputs "confirm-new-password"))
93 | (is (contains? inputs (name reitit-extras/CSRF-TOKEN-FORM-KEY)))))))
94 |
95 | (deftest test-post-change-password-wrong-current-password
96 | (let [base-url (reitit-extras/get-server-url (utils/server))
97 | test-email "user@example.com"
98 | user (queries/create-user! (utils/db) {:email test-email
99 | :password "correct-password"})
100 | url (str base-url "/account/change-password")
101 | new-password "new-secure-password"
102 | response (http/post url
103 | {:cookies (reitit-extras/session-cookies
104 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN
105 | :identity (select-keys user [:id :email])}
106 | utils/TEST-SECRET-KEY)
107 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
108 | :current-password "wrong-password"
109 | :new-password new-password
110 | :confirm-new-password new-password}})
111 | error-messages (->> response
112 | (utils/response->hickory)
113 | (select/select (select/class :error-message)))]
114 | (is (= 200 (:status response)))
115 | (is (= 1 (count error-messages)))
116 | (is (= ["Current password is incorrect"] (-> error-messages first :content)))))
117 |
118 | (deftest test-post-change-password-mismatch
119 | (let [base-url (reitit-extras/get-server-url (utils/server))
120 | test-email "user@example.com"
121 | current-password "current-password"
122 | user (queries/create-user! (utils/db) {:email test-email
123 | :password current-password})
124 | change-password-url (str base-url "/account/change-password")
125 | response (http/post change-password-url
126 | {:cookies (reitit-extras/session-cookies
127 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN
128 | :identity (select-keys user [:id :email])}
129 | utils/TEST-SECRET-KEY)
130 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
131 | :current-password current-password
132 | :new-password "new-secure-password"
133 | :confirm-new-password "different-password"}})
134 | error-messages (->> response
135 | (utils/response->hickory)
136 | (select/select (select/class :error-message)))]
137 | (is (= 200 (:status response)))
138 | (is (= 1 (count error-messages)))
139 | (is (= ["New passwords do not match"] (-> error-messages first :content)))))
140 |
141 | (deftest test-post-change-password-missing-fields
142 | (let [base-url (reitit-extras/get-server-url (utils/server))
143 | current-password "current-password"
144 | user (queries/create-user! (utils/db) {:email "user@example.com"
145 | :password current-password})
146 | change-password-url (str base-url "/account/change-password")
147 | response (http/post change-password-url
148 | {:cookies (reitit-extras/session-cookies
149 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN
150 | :identity (select-keys user [:id :email])}
151 | utils/TEST-SECRET-KEY)
152 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
153 | :current-password current-password
154 | :confirm-new-password "some-password"}})
155 | error-messages (->> response
156 | (utils/response->hickory)
157 | (select/select (select/class :error-message)))]
158 | (is (= 200 (:status response)))
159 | (is (pos? (count error-messages)))))
160 |
161 | (deftest test-post-change-password-too-short
162 | (let [base-url (reitit-extras/get-server-url (utils/server))
163 | current-password "current-password"
164 | short-password "123"
165 | user (queries/create-user! (utils/db) {:email "user@example.com"
166 | :password current-password})
167 | change-password-url (str base-url "/account/change-password")
168 | response (http/post change-password-url
169 | {:cookies (reitit-extras/session-cookies
170 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN
171 | :identity (select-keys user [:id :email])}
172 | utils/TEST-SECRET-KEY)
173 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
174 | :current-password current-password
175 | :new-password short-password
176 | :confirm-new-password short-password}})
177 | error-messages (->> response
178 | (utils/response->hickory)
179 | (select/select (select/class :error-message)))]
180 | (is (= 200 (:status response)))
181 | (is (pos? (count error-messages)))))
182 |
183 | (deftest test-post-change-password-same-as-current
184 | (let [base-url (reitit-extras/get-server-url (utils/server))
185 | current-password "same-password"
186 | user (queries/create-user! (utils/db) {:email "user@example.com"
187 | :password current-password})
188 | change-password-url (str base-url "/account/change-password")
189 | response (http/post change-password-url
190 | {:cookies (reitit-extras/session-cookies
191 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN
192 | :identity (select-keys user [:id :email])}
193 | utils/TEST-SECRET-KEY)
194 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
195 | :current-password current-password
196 | :new-password current-password
197 | :confirm-new-password current-password}})
198 | error-messages (->> response
199 | (utils/response->hickory)
200 | (select/select (select/class :error-message)))]
201 | (is (= 200 (:status response)))
202 | (is (= 1 (count error-messages)))
203 | (is (= ["New password must be different from current password"] (-> error-messages first :content)))))
204 |
205 | (deftest test-post-change-password-unauthenticated
206 | (let [base-url (reitit-extras/get-server-url (utils/server))
207 | change-password-url (str base-url "/account/change-password")
208 | response (http/post change-password-url
209 | {:redirect-strategy :none
210 | ; no auth session in cookies
211 | :cookies (reitit-extras/session-cookies
212 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
213 | utils/TEST-SECRET-KEY)
214 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
215 | :current-password "current"
216 | :new-password "new"
217 | :confirm-new-password "new"}})]
218 | (is (= 302 (:status response)))
219 | (is (= "/auth/login" (get-in response [:headers "Location"])))))
220 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/test_auth/auth_reset_password_test.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.auth-reset-password-test
2 | (:require [buddy.sign.jwt :as jwt]
3 | [clj-http.client :as http]
4 | [clojure.test :refer :all]
5 | [hickory.select :as select]
6 | [{{main/ns}}.auth.queries :as queries]
7 | [{{main/ns}}.test-utils :as utils]
8 | [reitit-extras.tests :as reitit-extras])
9 | (:import (java.time Duration Instant)))
10 |
11 | (use-fixtures :once
12 | (utils/with-system))
13 |
14 | (use-fixtures :each
15 | utils/with-truncated-tables)
16 |
17 | (defn- create-test-token
18 | ([email user-id] (create-test-token email user-id 24))
19 | ([email user-id hours-valid]
20 | (let [now (Instant/now)
21 | claims {:sub user-id
22 | :email email
23 | :exp (.getEpochSecond (.plus now (Duration/ofHours hours-valid)))
24 | :iat (.getEpochSecond now)}]
25 | (jwt/sign claims utils/TEST-SECRET-KEY {:alg :hs256}))))
26 |
27 | (defn- create-expired-token [email user-id]
28 | (let [past-time (Instant/parse "2020-01-01T00:00:00Z")
29 | claims {:sub user-id
30 | :email email
31 | :exp (.getEpochSecond past-time)
32 | :iat (.getEpochSecond past-time)}]
33 | (jwt/sign claims utils/TEST-SECRET-KEY {:alg :hs256})))
34 |
35 | (deftest test-get-reset-password-valid-token
36 | (let [base-url (reitit-extras/get-server-url (utils/server))
37 | test-email "user@example.com"
38 | user (queries/create-user! (utils/db) {:email test-email
39 | :password "password123"})
40 | token (create-test-token test-email (:id user))
41 | url (str base-url "/auth/reset-password?token=" token)
42 | response (http/get url)
43 | body (utils/response->hickory response)]
44 |
45 | (testing "Should show reset password form"
46 | (is (= 200 (:status response)))
47 | (is (= "Reset Your Password"
48 | (->> body
49 | (select/select (select/tag :h2))
50 | (first)
51 | :content
52 | (first)))))
53 |
54 | (testing "Check form has required fields"
55 | (let [inputs (->> body
56 | (select/select (select/tag :input))
57 | (map (comp :name :attrs))
58 | (set))]
59 | (is (contains? inputs "password"))
60 | (is (contains? inputs "confirm-password"))
61 | (is (contains? inputs "token"))
62 | (is (contains? inputs (name reitit-extras/CSRF-TOKEN-FORM-KEY)))))
63 |
64 | (testing "Check email is displayed"
65 | (is (some? (->> body
66 | (select/select (select/find-in-text #".*user@example.com.*"))
67 | (first)))))))
68 |
69 | (deftest test-get-reset-password-invalid-token
70 | (let [base-url (reitit-extras/get-server-url (utils/server))
71 | invalid-token "invalid.jwt.token"
72 | url (str base-url "/auth/reset-password?token=" invalid-token)
73 | response (http/get url {:throw-exceptions false})]
74 |
75 | (testing "Should show error page with 400 status"
76 | (is (= 400 (:status response)))
77 | (is (some? (->> (utils/response->hickory response)
78 | (select/select (select/find-in-text #".*invalid.*"))
79 | (first)))))))
80 |
81 | (deftest test-get-reset-password-expired-token
82 | (let [base-url (reitit-extras/get-server-url (utils/server))
83 | test-email "user@example.com"
84 | test-password "password123"
85 | user (queries/create-user! (utils/db) {:email test-email
86 | :password test-password})
87 | expired-token (create-expired-token test-email (:id user))
88 | url (str base-url "/auth/reset-password?token=" expired-token)
89 | response (http/get url {:throw-exceptions false})]
90 |
91 | (is (= 400 (:status response)))
92 | (is (some? (->> (utils/response->hickory response)
93 | (select/select (select/find-in-text #".*Invalid Reset Link.*"))
94 | (first))))))
95 |
96 | (deftest test-get-reset-password-missing-token
97 | (let [base-url (reitit-extras/get-server-url (utils/server))
98 | url (str base-url "/auth/reset-password")
99 | response (http/get url {:throw-exceptions false})]
100 | (is (= 400 (:status response)))
101 | (is (some? (->> (utils/response->hickory response)
102 | (select/select
103 | (select/find-in-text
104 | #".*The password reset link you used is invalid or has expired.*"))
105 | (first))))))
106 |
107 | (deftest test-get-reset-password-already-logged-in
108 | (let [base-url (reitit-extras/get-server-url (utils/server))
109 | test-email "user@example.com"
110 | user (queries/create-user! (utils/db) {:email test-email
111 | :password "password123"})
112 | token (create-test-token test-email (:id user))
113 | url (str base-url "/auth/reset-password?token=" token)
114 | response (http/get url {:redirect-strategy :none
115 | :cookies (reitit-extras/session-cookies
116 | {:identity (select-keys user [:id :email])}
117 | utils/TEST-SECRET-KEY)})]
118 | (is (= 302 (:status response)))
119 | (is (= "/" (get-in response [:headers "Location"])))))
120 |
121 | (deftest test-post-reset-password-valid
122 | (let [base-url (reitit-extras/get-server-url (utils/server))
123 | url (str base-url "/auth/reset-password")
124 | test-email "user@example.com"
125 | new-password "new-secure-password"
126 | user (queries/create-user! (utils/db) {:email test-email
127 | :password "original-password"})
128 | token (create-test-token test-email (:id user))
129 | response (http/post url {:cookies (reitit-extras/session-cookies
130 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
131 | utils/TEST-SECRET-KEY)
132 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
133 | :password new-password
134 | :confirm-password new-password
135 | :token token}})]
136 | (testing "Should show success message"
137 | (is (= 200 (:status response)))
138 | (is (some? (->> (utils/response->hickory response)
139 | (select/select (select/find-in-text #".*Password Reset Successful.*"))
140 | (first)))))
141 |
142 | (testing "Verify password was actually changed in database"
143 | (let [updated-user (queries/get-user (utils/db) test-email)]
144 | (is (not= (:password user) (:password updated-user)))))))
145 |
146 | (deftest test-post-reset-password-invalid-token
147 | (let [base-url (reitit-extras/get-server-url (utils/server))
148 | url (str base-url "/auth/reset-password")
149 | new-password "new-secure-password"
150 | response (http/post url {:throw-exceptions false
151 | :cookies (reitit-extras/session-cookies
152 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
153 | utils/TEST-SECRET-KEY)
154 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
155 | :password new-password
156 | :confirm-password new-password
157 | :token "invalid-jwt-token"}})]
158 | (is (= 200 (:status response)))
159 | (is (some? (->> (utils/response->hickory response)
160 | (select/select (select/find-in-text #".*Invalid or expired token.*"))
161 | (first))))))
162 |
163 | (deftest test-post-reset-password-expired-token
164 | (let [base-url (reitit-extras/get-server-url (utils/server))
165 | url (str base-url "/auth/reset-password")
166 | test-email "user@example.com"
167 | original-password "original-password"
168 | new-password "new-secure-password"
169 | user (queries/create-user! (utils/db) {:email test-email
170 | :password original-password})
171 | expired-token (create-expired-token test-email (:id user))
172 | response (http/post url {:cookies (reitit-extras/session-cookies
173 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
174 | utils/TEST-SECRET-KEY)
175 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
176 | :password new-password
177 | :confirm-password new-password
178 | :token expired-token}})]
179 | (is (= 200 (:status response)))
180 | (is (some? (->> (utils/response->hickory response)
181 | (select/select (select/find-in-text #".*Invalid or expired token.*"))
182 | (first))))))
183 |
184 | (deftest test-post-reset-password-mismatch
185 | (let [base-url (reitit-extras/get-server-url (utils/server))
186 | url (str base-url "/auth/reset-password")
187 | test-email "user@example.com"
188 | user (queries/create-user! (utils/db) {:email test-email
189 | :password "original-password"})
190 | token (create-test-token test-email (:id user))
191 | response (http/post url {:cookies (reitit-extras/session-cookies
192 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
193 | utils/TEST-SECRET-KEY)
194 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
195 | :password "new-secure-password"
196 | :confirm-password "different-password"
197 | :token token}})
198 | body (utils/response->hickory response)
199 | error-messages (select/select (select/class :error-message) body)]
200 | (is (= 200 (:status response)))
201 | (is (= 1 (count error-messages)))
202 | (is (= ["Passwords do not match"] (-> error-messages first :content)))))
203 |
204 | (deftest test-post-reset-password-too-short
205 | (let [base-url (reitit-extras/get-server-url (utils/server))
206 | url (str base-url "/auth/reset-password")
207 | test-email "user@example.com"
208 | short-password "123"
209 | user (queries/create-user! (utils/db) {:email test-email
210 | :password "original-password"})
211 | token (create-test-token test-email (:id user))
212 | response (http/post url {:cookies (reitit-extras/session-cookies
213 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
214 | utils/TEST-SECRET-KEY)
215 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
216 | :password short-password
217 | :confirm-password short-password
218 | :token token}})
219 | body (utils/response->hickory response)
220 | error-messages (select/select (select/class :error-message) body)]
221 | (is (= 200 (:status response)))
222 | (is (pos? (count error-messages)))))
223 |
224 | (deftest test-post-reset-password-missing-fields
225 | (let [base-url (reitit-extras/get-server-url (utils/server))
226 | url (str base-url "/auth/reset-password")
227 | test-email "user@example.com"
228 | user (queries/create-user! (utils/db) {:email test-email
229 | :password "original-password"})
230 | token (create-test-token test-email (:id user))
231 | response (http/post url {:cookies (reitit-extras/session-cookies
232 | {reitit-extras/CSRF-TOKEN-SESSION-KEY utils/TEST-CSRF-TOKEN}
233 | utils/TEST-SECRET-KEY)
234 | :form-params {reitit-extras/CSRF-TOKEN-FORM-KEY utils/TEST-CSRF-TOKEN
235 | :confirm-password "some-password"
236 | :token token}})
237 | body (utils/response->hickory response)
238 | error-messages (select/select (select/class :error-message) body)]
239 | (is (= 200 (:status response)))
240 | (is (pos? (count error-messages)))))
241 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/src_auth/auth/views.clj:
--------------------------------------------------------------------------------
1 | (ns {{main/ns}}.auth.views
2 | (:require [clojure.string :as str]
3 | [{{main/ns}}.routes :as-alias routes]
4 | [{{main/ns}}.views :as views]
5 | [reitit-extras.core :as ext]))
6 |
7 | (defn- form-input
8 | [{:keys [input-name input-label input-type input-value errors props]}]
9 | [:div
10 | [:label {:class ["mb-2" "inline-block" "text-sm" "text-gray-800" "sm:text-base"]
11 | :for input-name} (str/capitalize (or input-label input-name))]
12 | [:input (merge {:class ["w-full" "rounded-lg" "border" "px-3" "py-2" "text-gray-800"
13 | "outline-none" "ring-indigo-300" "transition" "duration-100" "focus:ring"
14 | (when (seq errors) "border-red-500")]
15 | :name input-name
16 | :type input-type
17 | :value input-value
18 | :autocorrect "off"
19 | :autocapitalize "none"}
20 | props)]
21 | (for [error-message errors]
22 | [:p {:class ["text-red-500" "text-sm" "mt-1" "error-message"]} (str/capitalize error-message)])])
23 |
24 | (defn register-form
25 | [{:keys [router errors values]}]
26 | [:form
27 | {:id "form-register"
28 | :class ["mx-auto" "max-w-lg"]
29 | :hx-post (ext/get-route router ::routes/register)
30 | :hx-target "#form-register"
31 | :hx-swap "outerHTML"}
32 | (ext/csrf-token-html)
33 | [:div {:class ["flex" "flex-col" "gap-4" "p-4" "md:p-8"]}
34 | (form-input {:input-name "email"
35 | :input-type "email"
36 | :input-value (:email values)
37 | :errors (:email errors)
38 | :required true
39 | :props {:autocomplete "email"}})
40 | (form-input {:input-name "password"
41 | :input-type "password"
42 | :input-value (:password values)
43 | :errors (:password errors)
44 | :required true
45 | :props {:autocomplete "new-password"}})
46 | [:button
47 | {:class ["block" "rounded-lg" "bg-gray-800" "px-8" "py-3" "text-center" "text-sm"
48 | "font-semibold" "text-white" "outline-none" "ring-gray-300" "transition"
49 | "duration-100" "hover:bg-gray-700" "focus-visible:ring" "active:bg-gray-600"
50 | "md:text-base" "cursor-pointer"]
51 | :type "submit"}
52 | "Create an account"]]
53 | [:div
54 | [:p {:class ["text-center" "text-sm" "text-gray-500"]}
55 | "Already registered? "
56 | [:a {:class ["text-indigo-500" "transition" "duration-100" "hover:text-indigo-600" "active:text-indigo-700"]
57 | :href (ext/get-route router ::routes/login)} "Log in"]]]])
58 |
59 | (defn register-page
60 | [{:keys [router]
61 | :as args}]
62 | (views/base
63 | [:div {:class ["bg-white" "py-6" "sm:py-8" "lg:py-12" "mt-20"]}
64 | [:nav {:class ["absolute" "top-0" "left-1/4" "p-4"]}
65 | [:div {:class ["flex" "gap-4"]}
66 | (views/button {:url (ext/get-route router ::routes/home)
67 | :text "<- Home page"})]]
68 | [:div {:class ["mx-auto" "max-w-screen-2xl" "px-4" "md:px-8"]}
69 | [:h2 {:class ["mb-4" "text-center" "text-2xl" "font-bold" "text-gray-800" "md:mb-8" "lg:text-3xl"]} "Register"]
70 | (register-form args)]]))
71 |
72 | (defn- common-errors
73 | [errors]
74 | (when errors
75 | [:div {:class ["text-red-500" "text-sm" "mt-1" "border" "border-red-300" "bg-red-50" "rounded" "p-2"]}
76 | (for [err errors]
77 | [:div {:class ["flex" "items-start"]}
78 | [:span {:class ["mr-2"]} "•"]
79 | [:span {:class ["error-message"]} err]])]))
80 |
81 | (defn login-form
82 | [{:keys [router values errors]}]
83 | [:form
84 | {:id "form-login"
85 | :class ["mx-auto" "max-w-lg"]
86 | :hx-post (ext/get-route router ::routes/login)
87 | :hx-target "#form-login"
88 | :hx-swap "outerHTML"}
89 | (ext/csrf-token-html)
90 | [:div {:class ["flex" "flex-col" "gap-4" "p-4" "md:p-8"]}
91 | (form-input {:input-name "email"
92 | :input-type "email"
93 | :input-value (:email values)
94 | :errors (:email errors)
95 | :required true
96 | :props {:autocomplete "email"}})
97 | (form-input {:input-name "password"
98 | :input-type "password"
99 | :input-value (:password values)
100 | :errors (:password errors)
101 | :required true
102 | :props {:autocomplete "new-password"}})
103 | (common-errors (:common errors))
104 | [:div {:class ["flex" "items-end" "justify-end" "py-2"]}
105 | [:a {:class ["text-indigo-500" "transition" "duration-100" "hover:text-indigo-600" "active:text-indigo-700"]
106 | :href (ext/get-route router ::routes/forgot-password)} "Forgot password?"]]
107 | [:button {:class ["block" "rounded-lg" "bg-gray-800" "px-8" "py-3" "text-center"
108 | "text-sm" "font-semibold" "text-white" "outline-none" "ring-gray-300"
109 | "transition" "duration-100" "hover:bg-gray-700" "focus-visible:ring"
110 | "active:bg-gray-600" "md:text-base" "cursor-pointer"]} "Log in"]]
111 | [:div {:class ["flex" "items-center" "justify-center" "p-4"]}
112 | [:p {:class ["text-center" "text-sm" "text-gray-500"]}
113 | "Don't have an account? "
114 | [:a {:class ["text-indigo-500" "transition" "duration-100" "hover:text-indigo-600" "active:text-indigo-700"]
115 | :href (ext/get-route router ::routes/register)} "Register"]]]])
116 |
117 | (defn login-page
118 | [{:keys [router]
119 | :as args}]
120 | (views/base
121 | [:div {:class ["bg-white" "py-6" "sm:py-8" "lg:py-12" "mt-20"]}
122 | [:nav {:class ["absolute" "top-0" "left-1/4" "p-4"]}
123 | [:div {:class ["flex" "gap-4"]}
124 | (views/button {:url (ext/get-route router ::routes/home)
125 | :text "<- Home page"})]]
126 | [:div {:class ["mx-auto" "max-w-screen-2xl" "px-4" "md:px-8"]}
127 | [:h2 {:class ["mb-4" "text-center" "text-2xl" "font-bold" "text-gray-800" "md:mb-8" "lg:text-3xl"]} "Login"]
128 | (login-form args)]]))
129 |
130 | (defn password-change-success
131 | []
132 | [:div {:class ["w-full" "max-w-md" "mx-auto" "p-6" "bg-green-50" "rounded-lg" "shadow-md"]}
133 | [:div {:class ["text-center"]}
134 | [:h3 {:class ["text-xl" "font-semibold" "text-green-800" "mb-2"]} "Password Updated Successfully"]
135 | [:p {:class ["text-green-700"]} "Your password has been changed."]]])
136 |
137 | (defn change-password-form
138 | [{:keys [router values errors password-changed?]}]
139 | [:form {:id "form-change-password"
140 | :hx-post (ext/get-route router ::routes/change-password)
141 | :hx-swap "outerHTML"
142 | :hx-target "#form-change-password"
143 | :class ["space-y-4"]}
144 | (ext/csrf-token-html)
145 | (form-input {:input-name "current-password"
146 | :input-label "Current Password"
147 | :input-type "password"
148 | :input-value (:current-password values)
149 | :errors (:current-password errors)
150 | :required true})
151 | (form-input {:input-name "new-password"
152 | :input-label "New Password"
153 | :input-type "password"
154 | :input-value (:new-password values)
155 | :errors (:new-password errors)
156 | :required true})
157 | (form-input {:input-name "confirm-new-password"
158 | :input-label "Confirm New Password"
159 | :input-type "password"
160 | :input-value (:confirm-new-password values)
161 | :errors (:confirm-new-password errors)
162 | :required true})
163 | (common-errors (:common errors))
164 | (when password-changed?
165 | (password-change-success))
166 |
167 | [:button {:type "submit"
168 | :class ["block" "rounded-lg" "bg-gray-800" "px-8" "py-3" "text-center" "text-sm"
169 | "font-semibold" "text-white" "outline-none" "ring-gray-300" "transition"
170 | "duration-100" "hover:bg-gray-700" "focus-visible:ring" "active:bg-gray-600"
171 | "md:text-base"]}
172 | "Update Password"]])
173 |
174 | (defn account-page
175 | [{:keys [user router]
176 | :as args}]
177 | (views/base
178 | [:div {:class ["bg-white" "py-6" "sm:py-8" "lg:py-12" "mt-20"]}
179 | [:nav {:class ["absolute" "top-0" "left-1/4" "p-4"]}
180 | [:div {:class ["flex" "gap-4"]}
181 | (views/button {:url (ext/get-route router ::routes/home)
182 | :text "<- Home page"})]]
183 | [:div {:class ["mx-auto" "max-w-screen-2xl" "px-4" "md:px-8"]}
184 | [:h2 {:class ["mb-4" "text-center" "text-2xl" "font-bold" "text-gray-800" "md:mb-8" "lg:text-3xl"]} "Account settings"]
185 | [:div {:class ["mx-auto" "max-w-lg"]}
186 | [:div {:class ["flex" "flex-col" "gap-4" "p-4" "md:p-8"]}
187 | [:h3 {:class ["text-2xl" "font-semibold" "text-gray-800" "mb-4"]} "User Information"]
188 | [:div {:class ["text-gray-800" "text-md" "font-semibold"]} (str "Email: " (:email user))]
189 | [:h3 {:class ["text-2xl" "font-semibold" "text-gray-800" "mb-4" "mt-12"]} "Change Password"]
190 | (change-password-form args)]]]]))
191 |
192 | (defn forgot-password-form
193 | [{:keys [router values errors email-sent?]}]
194 | (if email-sent?
195 | [:p {:class ["text-center" "text-sm" "text-gray-500"]}
196 | "If you are a registered user, please check your email for the password reset link that we've sent you."]
197 | [:form
198 | {:id "form-forgot-password"
199 | :class ["mx-auto" "max-w-lg"]
200 | :hx-post (ext/get-route router ::routes/forgot-password)
201 | :hx-target "#form-forgot-password"
202 | :hx-swap "outerHTML"}
203 | (ext/csrf-token-html)
204 | [:p
205 | {:class ["text-center" "text-sm" "text-gray-500"]}
206 | "Enter your email address and we'll send you a link to reset your password."]
207 | [:div {:class ["flex" "flex-col" "gap-4" "p-4" "md:p-8"]}
208 | (form-input {:input-name "email"
209 | :input-type "email"
210 | :input-value (:email values)
211 | :errors (:email errors)
212 | :required true
213 | :props {:autocomplete "email"}})
214 | [:button
215 | {:class ["block" "rounded-lg" "bg-gray-800" "px-8" "py-3" "text-center"
216 | "text-sm" "font-semibold" "text-white" "outline-none"
217 | "ring-gray-300" "transition" "duration-100" "hover:bg-gray-700"
218 | "focus-visible:ring" "active:bg-gray-600" "md:text-base"
219 | "cursor-pointer"]}
220 | "Send password reset instructions"]]]))
221 |
222 | (defn forgot-password-page
223 | [{:keys [router]
224 | :as args}]
225 | (views/base
226 | [:div {:class ["bg-white" "py-6" "sm:py-8" "lg:py-12" "mt-20"]}
227 | [:nav {:class ["absolute" "top-0" "left-1/4" "p-4"]}
228 | [:div {:class ["flex" "gap-4"]}
229 | (views/button {:url (ext/get-route router ::routes/home)
230 | :text "<- Home page"})]]
231 | [:div {:class ["mx-auto" "max-w-screen-2xl" "px-4" "md:px-8"]}
232 | [:h2 {:class ["mb-4" "text-center" "text-2xl" "font-bold" "text-gray-800" "md:mb-8" "lg:text-3xl"]} "Forgot your password?"]
233 | (forgot-password-form args)]]))
234 |
235 | (defn reset-password-form
236 | [{:keys [router values errors token email]}]
237 | [:div {:id "form-reset-password"
238 | :class ["mx-auto" "max-w-screen-2xl" "px-4" "md:px-8"]}
239 | [:h2 {:class ["mb-4" "text-center" "text-2xl" "font-bold" "text-gray-800" "md:mb-8" "lg:text-3xl"]} "Reset Your Password"]
240 | [:p {:class ["text-center" "text-sm" "text-gray-500" "mb-4"]} "Enter a new password for " [:strong email]]
241 | [:form
242 | {:class ["mx-auto" "max-w-lg"]
243 | :hx-post (ext/get-route router ::routes/reset-password)
244 | :hx-target "#form-reset-password"
245 | :hx-swap "outerHTML"}
246 | (ext/csrf-token-html)
247 | [:input {:type "hidden"
248 | :name "token"
249 | :value token}]
250 | [:div {:class ["flex" "flex-col" "gap-4" "p-4" "md:p-8"]}
251 | (form-input {:input-name "password"
252 | :input-label "New password"
253 | :input-type "password"
254 | :input-value (:password values)
255 | :errors (:password errors)
256 | :required true
257 | :props {:autocomplete "new-password"}})
258 | (form-input {:input-name "confirm-password"
259 | :input-label "Confirm password"
260 | :input-type "password"
261 | :input-value (:confirm-password values)
262 | :errors (:confirm-password errors)
263 | :required true
264 | :props {:autocomplete "new-password"}})
265 | (common-errors (:common errors))
266 | [:button
267 | {:class ["block" "rounded-lg" "bg-gray-800" "px-8" "py-3" "text-center" "text-sm"
268 | "font-semibold" "text-white" "outline-none" "ring-gray-300" "transition"
269 | "duration-100" "hover:bg-gray-700" "focus-visible:ring" "active:bg-gray-600"
270 | "md:text-base" "cursor-pointer"]
271 | :type "submit"}
272 | "Reset Password"]]]])
273 |
274 | (defn reset-password-page
275 | [{:keys [router token email]}]
276 | (views/base
277 | [:div {:class ["bg-white" "py-6" "sm:py-8" "lg:py-12" "mt-20"]}
278 | [:nav {:class ["absolute" "top-0" "left-1/4" "p-4"]}
279 | [:div {:class ["flex" "gap-4"]}
280 | (views/button {:url (ext/get-route router ::routes/home)
281 | :text "<- Home page"})]]
282 | (reset-password-form {:router router
283 | :token token
284 | :email email})]))
285 |
286 | (defn invalid-reset-token-page
287 | [{:keys [router]}]
288 | (views/base
289 | [:div {:class ["bg-white" "py-6" "sm:py-8" "lg:py-12" "mt-20"]}
290 | [:nav {:class ["absolute" "top-0" "left-1/4" "p-4"]}
291 | [:div {:class ["flex" "gap-4"]}
292 | (views/button {:url (ext/get-route router ::routes/home)
293 | :text "<- Home page"})]]
294 | [:div {:class ["mx-auto" "max-w-screen-2xl" "px-4" "md:px-8"]}
295 | [:h2 {:class ["mb-4" "text-center" "text-2xl" "font-bold" "text-gray-800" "md:mb-8" "lg:text-3xl"]} "Invalid Reset Link"]
296 | [:div {:class ["mx-auto" "max-w-lg"]}
297 | [:div {:class ["flex" "flex-col" "gap-4" "p-4" "md:p-8"]}
298 | [:p {:class ["text-center" "text-gray-600"]} "The password reset link you used is invalid or has expired."]
299 | [:div {:class ["flex" "justify-center" "mt-4"]}
300 | [:a
301 | {:class ["text-indigo-500" "transition" "duration-100" "hover:text-indigo-600" "active:text-indigo-700"]
302 | :href (ext/get-route router ::routes/forgot-password)}
303 | "Request a new password reset link"]]]]]]))
304 |
305 | (defn password-reset-success-page
306 | [{:keys [router]}]
307 | (views/base
308 | [:div {:class ["bg-white" "py-6" "sm:py-8" "lg:py-12" "mt-20"]}
309 | [:nav {:class ["absolute" "top-0" "left-1/4" "p-4"]}
310 | [:div {:class ["flex" "gap-4"]}
311 | (views/button {:url (ext/get-route router ::routes/home)
312 | :text "<- Home page"})]]
313 | [:div {:class ["mx-auto" "max-w-screen-2xl" "px-4" "md:px-8"]}
314 | [:h2 {:class ["mb-4" "text-center" "text-2xl" "font-bold" "text-gray-800" "md:mb-8" "lg:text-3xl"]} "Password Reset Successfully"]
315 | [:div {:class ["mx-auto" "max-w-lg"]}
316 | [:div {:class ["flex" "flex-col" "gap-4" "p-4" "md:p-8"]}
317 | [:p {:class ["text-center" "text-gray-600"]} "Your password has been changed successfully."]
318 | [:div {:class ["flex" "justify-center" "mt-4"]}
319 | (views/button {:url (ext/get-route router ::routes/login)
320 | :text "Log in with your new password"})]]]]]))
321 |
--------------------------------------------------------------------------------
/resources/io/github/abogoyavlensky/clojure_stack_lite/root/LICENSE:
--------------------------------------------------------------------------------
1 | GNU AFFERO GENERAL PUBLIC LICENSE
2 | Version 3, 19 November 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU Affero General Public License is a free, copyleft license for
11 | software and other kinds of works, specifically designed to ensure
12 | cooperation with the community in the case of network server software.
13 |
14 | The licenses for most software and other practical works are designed
15 | to take away your freedom to share and change the works. By contrast,
16 | our General Public Licenses are intended to guarantee your freedom to
17 | share and change all versions of a program--to make sure it remains free
18 | software for all its users.
19 |
20 | When we speak of free software, we are referring to freedom, not
21 | price. Our General Public Licenses are designed to make sure that you
22 | have the freedom to distribute copies of free software (and charge for
23 | them if you wish), that you receive source code or can get it if you
24 | want it, that you can change the software or use pieces of it in new
25 | free programs, and that you know you can do these things.
26 |
27 | Developers that use our General Public Licenses protect your rights
28 | with two steps: (1) assert copyright on the software, and (2) offer
29 | you this License which gives you legal permission to copy, distribute
30 | and/or modify the software.
31 |
32 | A secondary benefit of defending all users' freedom is that
33 | improvements made in alternate versions of the program, if they
34 | receive widespread use, become available for other developers to
35 | incorporate. Many developers of free software are heartened and
36 | encouraged by the resulting cooperation. However, in the case of
37 | software used on network servers, this result may fail to come about.
38 | The GNU General Public License permits making a modified version and
39 | letting the public access it on a server without ever releasing its
40 | source code to the public.
41 |
42 | The GNU Affero General Public License is designed specifically to
43 | ensure that, in such cases, the modified source code becomes available
44 | to the community. It requires the operator of a network server to
45 | provide the source code of the modified version running there to the
46 | users of that server. Therefore, public use of a modified version, on
47 | a publicly accessible server, gives the public access to the source
48 | code of the modified version.
49 |
50 | An older license, called the Affero General Public License and
51 | published by Affero, was designed to accomplish similar goals. This is
52 | a different license, not a version of the Affero GPL, but Affero has
53 | released a new version of the Affero GPL which permits relicensing under
54 | this license.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | TERMS AND CONDITIONS
60 |
61 | 0. Definitions.
62 |
63 | "This License" refers to version 3 of the GNU Affero General Public License.
64 |
65 | "Copyright" also means copyright-like laws that apply to other kinds of
66 | works, such as semiconductor masks.
67 |
68 | "The Program" refers to any copyrightable work licensed under this
69 | License. Each licensee is addressed as "you". "Licensees" and
70 | "recipients" may be individuals or organizations.
71 |
72 | To "modify" a work means to copy from or adapt all or part of the work
73 | in a fashion requiring copyright permission, other than the making of an
74 | exact copy. The resulting work is called a "modified version" of the
75 | earlier work or a work "based on" the earlier work.
76 |
77 | A "covered work" means either the unmodified Program or a work based
78 | on the Program.
79 |
80 | To "propagate" a work means to do anything with it that, without
81 | permission, would make you directly or secondarily liable for
82 | infringement under applicable copyright law, except executing it on a
83 | computer or modifying a private copy. Propagation includes copying,
84 | distribution (with or without modification), making available to the
85 | public, and in some countries other activities as well.
86 |
87 | To "convey" a work means any kind of propagation that enables other
88 | parties to make or receive copies. Mere interaction with a user through
89 | a computer network, with no transfer of a copy, is not conveying.
90 |
91 | An interactive user interface displays "Appropriate Legal Notices"
92 | to the extent that it includes a convenient and prominently visible
93 | feature that (1) displays an appropriate copyright notice, and (2)
94 | tells the user that there is no warranty for the work (except to the
95 | extent that warranties are provided), that licensees may convey the
96 | work under this License, and how to view a copy of this License. If
97 | the interface presents a list of user commands or options, such as a
98 | menu, a prominent item in the list meets this criterion.
99 |
100 | 1. Source Code.
101 |
102 | The "source code" for a work means the preferred form of the work
103 | for making modifications to it. "Object code" means any non-source
104 | form of a work.
105 |
106 | A "Standard Interface" means an interface that either is an official
107 | standard defined by a recognized standards body, or, in the case of
108 | interfaces specified for a particular programming language, one that
109 | is widely used among developers working in that language.
110 |
111 | The "System Libraries" of an executable work include anything, other
112 | than the work as a whole, that (a) is included in the normal form of
113 | packaging a Major Component, but which is not part of that Major
114 | Component, and (b) serves only to enable use of the work with that
115 | Major Component, or to implement a Standard Interface for which an
116 | implementation is available to the public in source code form. A
117 | "Major Component", in this context, means a major essential component
118 | (kernel, window system, and so on) of the specific operating system
119 | (if any) on which the executable work runs, or a compiler used to
120 | produce the work, or an object code interpreter used to run it.
121 |
122 | The "Corresponding Source" for a work in object code form means all
123 | the source code needed to generate, install, and (for an executable
124 | work) run the object code and to modify the work, including scripts to
125 | control those activities. However, it does not include the work's
126 | System Libraries, or general-purpose tools or generally available free
127 | programs which are used unmodified in performing those activities but
128 | which are not part of the work. For example, Corresponding Source
129 | includes interface definition files associated with source files for
130 | the work, and the source code for shared libraries and dynamically
131 | linked subprograms that the work is specifically designed to require,
132 | such as by intimate data communication or control flow between those
133 | subprograms and other parts of the work.
134 |
135 | The Corresponding Source need not include anything that users
136 | can regenerate automatically from other parts of the Corresponding
137 | Source.
138 |
139 | The Corresponding Source for a work in source code form is that
140 | same work.
141 |
142 | 2. Basic Permissions.
143 |
144 | All rights granted under this License are granted for the term of
145 | copyright on the Program, and are irrevocable provided the stated
146 | conditions are met. This License explicitly affirms your unlimited
147 | permission to run the unmodified Program. The output from running a
148 | covered work is covered by this License only if the output, given its
149 | content, constitutes a covered work. This License acknowledges your
150 | rights of fair use or other equivalent, as provided by copyright law.
151 |
152 | You may make, run and propagate covered works that you do not
153 | convey, without conditions so long as your license otherwise remains
154 | in force. You may convey covered works to others for the sole purpose
155 | of having them make modifications exclusively for you, or provide you
156 | with facilities for running those works, provided that you comply with
157 | the terms of this License in conveying all material for which you do
158 | not control copyright. Those thus making or running the covered works
159 | for you must do so exclusively on your behalf, under your direction
160 | and control, on terms that prohibit them from making any copies of
161 | your copyrighted material outside their relationship with you.
162 |
163 | Conveying under any other circumstances is permitted solely under
164 | the conditions stated below. Sublicensing is not allowed; section 10
165 | makes it unnecessary.
166 |
167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168 |
169 | No covered work shall be deemed part of an effective technological
170 | measure under any applicable law fulfilling obligations under article
171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172 | similar laws prohibiting or restricting circumvention of such
173 | measures.
174 |
175 | When you convey a covered work, you waive any legal power to forbid
176 | circumvention of technological measures to the extent such circumvention
177 | is effected by exercising rights under this License with respect to
178 | the covered work, and you disclaim any intention to limit operation or
179 | modification of the work as a means of enforcing, against the work's
180 | users, your or third parties' legal rights to forbid circumvention of
181 | technological measures.
182 |
183 | 4. Conveying Verbatim Copies.
184 |
185 | You may convey verbatim copies of the Program's source code as you
186 | receive it, in any medium, provided that you conspicuously and
187 | appropriately publish on each copy an appropriate copyright notice;
188 | keep intact all notices stating that this License and any
189 | non-permissive terms added in accord with section 7 apply to the code;
190 | keep intact all notices of the absence of any warranty; and give all
191 | recipients a copy of this License along with the Program.
192 |
193 | You may charge any price or no price for each copy that you convey,
194 | and you may offer support or warranty protection for a fee.
195 |
196 | 5. Conveying Modified Source Versions.
197 |
198 | You may convey a work based on the Program, or the modifications to
199 | produce it from the Program, in the form of source code under the
200 | terms of section 4, provided that you also meet all of these conditions:
201 |
202 | a) The work must carry prominent notices stating that you modified
203 | it, and giving a relevant date.
204 |
205 | b) The work must carry prominent notices stating that it is
206 | released under this License and any conditions added under section
207 | 7. This requirement modifies the requirement in section 4 to
208 | "keep intact all notices".
209 |
210 | c) You must license the entire work, as a whole, under this
211 | License to anyone who comes into possession of a copy. This
212 | License will therefore apply, along with any applicable section 7
213 | additional terms, to the whole of the work, and all its parts,
214 | regardless of how they are packaged. This License gives no
215 | permission to license the work in any other way, but it does not
216 | invalidate such permission if you have separately received it.
217 |
218 | d) If the work has interactive user interfaces, each must display
219 | Appropriate Legal Notices; however, if the Program has interactive
220 | interfaces that do not display Appropriate Legal Notices, your
221 | work need not make them do so.
222 |
223 | A compilation of a covered work with other separate and independent
224 | works, which are not by their nature extensions of the covered work,
225 | and which are not combined with it such as to form a larger program,
226 | in or on a volume of a storage or distribution medium, is called an
227 | "aggregate" if the compilation and its resulting copyright are not
228 | used to limit the access or legal rights of the compilation's users
229 | beyond what the individual works permit. Inclusion of a covered work
230 | in an aggregate does not cause this License to apply to the other
231 | parts of the aggregate.
232 |
233 | 6. Conveying Non-Source Forms.
234 |
235 | You may convey a covered work in object code form under the terms
236 | of sections 4 and 5, provided that you also convey the
237 | machine-readable Corresponding Source under the terms of this License,
238 | in one of these ways:
239 |
240 | a) Convey the object code in, or embodied in, a physical product
241 | (including a physical distribution medium), accompanied by the
242 | Corresponding Source fixed on a durable physical medium
243 | customarily used for software interchange.
244 |
245 | b) Convey the object code in, or embodied in, a physical product
246 | (including a physical distribution medium), accompanied by a
247 | written offer, valid for at least three years and valid for as
248 | long as you offer spare parts or customer support for that product
249 | model, to give anyone who possesses the object code either (1) a
250 | copy of the Corresponding Source for all the software in the
251 | product that is covered by this License, on a durable physical
252 | medium customarily used for software interchange, for a price no
253 | more than your reasonable cost of physically performing this
254 | conveying of source, or (2) access to copy the
255 | Corresponding Source from a network server at no charge.
256 |
257 | c) Convey individual copies of the object code with a copy of the
258 | written offer to provide the Corresponding Source. This
259 | alternative is allowed only occasionally and noncommercially, and
260 | only if you received the object code with such an offer, in accord
261 | with subsection 6b.
262 |
263 | d) Convey the object code by offering access from a designated
264 | place (gratis or for a charge), and offer equivalent access to the
265 | Corresponding Source in the same way through the same place at no
266 | further charge. You need not require recipients to copy the
267 | Corresponding Source along with the object code. If the place to
268 | copy the object code is a network server, the Corresponding Source
269 | may be on a different server (operated by you or a third party)
270 | that supports equivalent copying facilities, provided you maintain
271 | clear directions next to the object code saying where to find the
272 | Corresponding Source. Regardless of what server hosts the
273 | Corresponding Source, you remain obligated to ensure that it is
274 | available for as long as needed to satisfy these requirements.
275 |
276 | e) Convey the object code using peer-to-peer transmission, provided
277 | you inform other peers where the object code and Corresponding
278 | Source of the work are being offered to the general public at no
279 | charge under subsection 6d.
280 |
281 | A separable portion of the object code, whose source code is excluded
282 | from the Corresponding Source as a System Library, need not be
283 | included in conveying the object code work.
284 |
285 | A "User Product" is either (1) a "consumer product", which means any
286 | tangible personal property which is normally used for personal, family,
287 | or household purposes, or (2) anything designed or sold for incorporation
288 | into a dwelling. In determining whether a product is a consumer product,
289 | doubtful cases shall be resolved in favor of coverage. For a particular
290 | product received by a particular user, "normally used" refers to a
291 | typical or common use of that class of product, regardless of the status
292 | of the particular user or of the way in which the particular user
293 | actually uses, or expects or is expected to use, the product. A product
294 | is a consumer product regardless of whether the product has substantial
295 | commercial, industrial or non-consumer uses, unless such uses represent
296 | the only significant mode of use of the product.
297 |
298 | "Installation Information" for a User Product means any methods,
299 | procedures, authorization keys, or other information required to install
300 | and execute modified versions of a covered work in that User Product from
301 | a modified version of its Corresponding Source. The information must
302 | suffice to ensure that the continued functioning of the modified object
303 | code is in no case prevented or interfered with solely because
304 | modification has been made.
305 |
306 | If you convey an object code work under this section in, or with, or
307 | specifically for use in, a User Product, and the conveying occurs as
308 | part of a transaction in which the right of possession and use of the
309 | User Product is transferred to the recipient in perpetuity or for a
310 | fixed term (regardless of how the transaction is characterized), the
311 | Corresponding Source conveyed under this section must be accompanied
312 | by the Installation Information. But this requirement does not apply
313 | if neither you nor any third party retains the ability to install
314 | modified object code on the User Product (for example, the work has
315 | been installed in ROM).
316 |
317 | The requirement to provide Installation Information does not include a
318 | requirement to continue to provide support service, warranty, or updates
319 | for a work that has been modified or installed by the recipient, or for
320 | the User Product in which it has been modified or installed. Access to a
321 | network may be denied when the modification itself materially and
322 | adversely affects the operation of the network or violates the rules and
323 | protocols for communication across the network.
324 |
325 | Corresponding Source conveyed, and Installation Information provided,
326 | in accord with this section must be in a format that is publicly
327 | documented (and with an implementation available to the public in
328 | source code form), and must require no special password or key for
329 | unpacking, reading or copying.
330 |
331 | 7. Additional Terms.
332 |
333 | "Additional permissions" are terms that supplement the terms of this
334 | License by making exceptions from one or more of its conditions.
335 | Additional permissions that are applicable to the entire Program shall
336 | be treated as though they were included in this License, to the extent
337 | that they are valid under applicable law. If additional permissions
338 | apply only to part of the Program, that part may be used separately
339 | under those permissions, but the entire Program remains governed by
340 | this License without regard to the additional permissions.
341 |
342 | When you convey a copy of a covered work, you may at your option
343 | remove any additional permissions from that copy, or from any part of
344 | it. (Additional permissions may be written to require their own
345 | removal in certain cases when you modify the work.) You may place
346 | additional permissions on material, added by you to a covered work,
347 | for which you have or can give appropriate copyright permission.
348 |
349 | Notwithstanding any other provision of this License, for material you
350 | add to a covered work, you may (if authorized by the copyright holders of
351 | that material) supplement the terms of this License with terms:
352 |
353 | a) Disclaiming warranty or limiting liability differently from the
354 | terms of sections 15 and 16 of this License; or
355 |
356 | b) Requiring preservation of specified reasonable legal notices or
357 | author attributions in that material or in the Appropriate Legal
358 | Notices displayed by works containing it; or
359 |
360 | c) Prohibiting misrepresentation of the origin of that material, or
361 | requiring that modified versions of such material be marked in
362 | reasonable ways as different from the original version; or
363 |
364 | d) Limiting the use for publicity purposes of names of licensors or
365 | authors of the material; or
366 |
367 | e) Declining to grant rights under trademark law for use of some
368 | trade names, trademarks, or service marks; or
369 |
370 | f) Requiring indemnification of licensors and authors of that
371 | material by anyone who conveys the material (or modified versions of
372 | it) with contractual assumptions of liability to the recipient, for
373 | any liability that these contractual assumptions directly impose on
374 | those licensors and authors.
375 |
376 | All other non-permissive additional terms are considered "further
377 | restrictions" within the meaning of section 10. If the Program as you
378 | received it, or any part of it, contains a notice stating that it is
379 | governed by this License along with a term that is a further
380 | restriction, you may remove that term. If a license document contains
381 | a further restriction but permits relicensing or conveying under this
382 | License, you may add to a covered work material governed by the terms
383 | of that license document, provided that the further restriction does
384 | not survive such relicensing or conveying.
385 |
386 | If you add terms to a covered work in accord with this section, you
387 | must place, in the relevant source files, a statement of the
388 | additional terms that apply to those files, or a notice indicating
389 | where to find the applicable terms.
390 |
391 | Additional terms, permissive or non-permissive, may be stated in the
392 | form of a separately written license, or stated as exceptions;
393 | the above requirements apply either way.
394 |
395 | 8. Termination.
396 |
397 | You may not propagate or modify a covered work except as expressly
398 | provided under this License. Any attempt otherwise to propagate or
399 | modify it is void, and will automatically terminate your rights under
400 | this License (including any patent licenses granted under the third
401 | paragraph of section 11).
402 |
403 | However, if you cease all violation of this License, then your
404 | license from a particular copyright holder is reinstated (a)
405 | provisionally, unless and until the copyright holder explicitly and
406 | finally terminates your license, and (b) permanently, if the copyright
407 | holder fails to notify you of the violation by some reasonable means
408 | prior to 60 days after the cessation.
409 |
410 | Moreover, your license from a particular copyright holder is
411 | reinstated permanently if the copyright holder notifies you of the
412 | violation by some reasonable means, this is the first time you have
413 | received notice of violation of this License (for any work) from that
414 | copyright holder, and you cure the violation prior to 30 days after
415 | your receipt of the notice.
416 |
417 | Termination of your rights under this section does not terminate the
418 | licenses of parties who have received copies or rights from you under
419 | this License. If your rights have been terminated and not permanently
420 | reinstated, you do not qualify to receive new licenses for the same
421 | material under section 10.
422 |
423 | 9. Acceptance Not Required for Having Copies.
424 |
425 | You are not required to accept this License in order to receive or
426 | run a copy of the Program. Ancillary propagation of a covered work
427 | occurring solely as a consequence of using peer-to-peer transmission
428 | to receive a copy likewise does not require acceptance. However,
429 | nothing other than this License grants you permission to propagate or
430 | modify any covered work. These actions infringe copyright if you do
431 | not accept this License. Therefore, by modifying or propagating a
432 | covered work, you indicate your acceptance of this License to do so.
433 |
434 | 10. Automatic Licensing of Downstream Recipients.
435 |
436 | Each time you convey a covered work, the recipient automatically
437 | receives a license from the original licensors, to run, modify and
438 | propagate that work, subject to this License. You are not responsible
439 | for enforcing compliance by third parties with this License.
440 |
441 | An "entity transaction" is a transaction transferring control of an
442 | organization, or substantially all assets of one, or subdividing an
443 | organization, or merging organizations. If propagation of a covered
444 | work results from an entity transaction, each party to that
445 | transaction who receives a copy of the work also receives whatever
446 | licenses to the work the party's predecessor in interest had or could
447 | give under the previous paragraph, plus a right to possession of the
448 | Corresponding Source of the work from the predecessor in interest, if
449 | the predecessor has it or can get it with reasonable efforts.
450 |
451 | You may not impose any further restrictions on the exercise of the
452 | rights granted or affirmed under this License. For example, you may
453 | not impose a license fee, royalty, or other charge for exercise of
454 | rights granted under this License, and you may not initiate litigation
455 | (including a cross-claim or counterclaim in a lawsuit) alleging that
456 | any patent claim is infringed by making, using, selling, offering for
457 | sale, or importing the Program or any portion of it.
458 |
459 | 11. Patents.
460 |
461 | A "contributor" is a copyright holder who authorizes use under this
462 | License of the Program or a work on which the Program is based. The
463 | work thus licensed is called the contributor's "contributor version".
464 |
465 | A contributor's "essential patent claims" are all patent claims
466 | owned or controlled by the contributor, whether already acquired or
467 | hereafter acquired, that would be infringed by some manner, permitted
468 | by this License, of making, using, or selling its contributor version,
469 | but do not include claims that would be infringed only as a
470 | consequence of further modification of the contributor version. For
471 | purposes of this definition, "control" includes the right to grant
472 | patent sublicenses in a manner consistent with the requirements of
473 | this License.
474 |
475 | Each contributor grants you a non-exclusive, worldwide, royalty-free
476 | patent license under the contributor's essential patent claims, to
477 | make, use, sell, offer for sale, import and otherwise run, modify and
478 | propagate the contents of its contributor version.
479 |
480 | In the following three paragraphs, a "patent license" is any express
481 | agreement or commitment, however denominated, not to enforce a patent
482 | (such as an express permission to practice a patent or covenant not to
483 | sue for patent infringement). To "grant" such a patent license to a
484 | party means to make such an agreement or commitment not to enforce a
485 | patent against the party.
486 |
487 | If you convey a covered work, knowingly relying on a patent license,
488 | and the Corresponding Source of the work is not available for anyone
489 | to copy, free of charge and under the terms of this License, through a
490 | publicly available network server or other readily accessible means,
491 | then you must either (1) cause the Corresponding Source to be so
492 | available, or (2) arrange to deprive yourself of the benefit of the
493 | patent license for this particular work, or (3) arrange, in a manner
494 | consistent with the requirements of this License, to extend the patent
495 | license to downstream recipients. "Knowingly relying" means you have
496 | actual knowledge that, but for the patent license, your conveying the
497 | covered work in a country, or your recipient's use of the covered work
498 | in a country, would infringe one or more identifiable patents in that
499 | country that you have reason to believe are valid.
500 |
501 | If, pursuant to or in connection with a single transaction or
502 | arrangement, you convey, or propagate by procuring conveyance of, a
503 | covered work, and grant a patent license to some of the parties
504 | receiving the covered work authorizing them to use, propagate, modify
505 | or convey a specific copy of the covered work, then the patent license
506 | you grant is automatically extended to all recipients of the covered
507 | work and works based on it.
508 |
509 | A patent license is "discriminatory" if it does not include within
510 | the scope of its coverage, prohibits the exercise of, or is
511 | conditioned on the non-exercise of one or more of the rights that are
512 | specifically granted under this License. You may not convey a covered
513 | work if you are a party to an arrangement with a third party that is
514 | in the business of distributing software, under which you make payment
515 | to the third party based on the extent of your activity of conveying
516 | the work, and under which the third party grants, to any of the
517 | parties who would receive the covered work from you, a discriminatory
518 | patent license (a) in connection with copies of the covered work
519 | conveyed by you (or copies made from those copies), or (b) primarily
520 | for and in connection with specific products or compilations that
521 | contain the covered work, unless you entered into that arrangement,
522 | or that patent license was granted, prior to 28 March 2007.
523 |
524 | Nothing in this License shall be construed as excluding or limiting
525 | any implied license or other defenses to infringement that may
526 | otherwise be available to you under applicable patent law.
527 |
528 | 12. No Surrender of Others' Freedom.
529 |
530 | If conditions are imposed on you (whether by court order, agreement or
531 | otherwise) that contradict the conditions of this License, they do not
532 | excuse you from the conditions of this License. If you cannot convey a
533 | covered work so as to satisfy simultaneously your obligations under this
534 | License and any other pertinent obligations, then as a consequence you may
535 | not convey it at all. For example, if you agree to terms that obligate you
536 | to collect a royalty for further conveying from those to whom you convey
537 | the Program, the only way you could satisfy both those terms and this
538 | License would be to refrain entirely from conveying the Program.
539 |
540 | 13. Remote Network Interaction; Use with the GNU General Public License.
541 |
542 | Notwithstanding any other provision of this License, if you modify the
543 | Program, your modified version must prominently offer all users
544 | interacting with it remotely through a computer network (if your version
545 | supports such interaction) an opportunity to receive the Corresponding
546 | Source of your version by providing access to the Corresponding Source
547 | from a network server at no charge, through some standard or customary
548 | means of facilitating copying of software. This Corresponding Source
549 | shall include the Corresponding Source for any work covered by version 3
550 | of the GNU General Public License that is incorporated pursuant to the
551 | following paragraph.
552 |
553 | Notwithstanding any other provision of this License, you have
554 | permission to link or combine any covered work with a work licensed
555 | under version 3 of the GNU General Public License into a single
556 | combined work, and to convey the resulting work. The terms of this
557 | License will continue to apply to the part which is the covered work,
558 | but the work with which it is combined will remain governed by version
559 | 3 of the GNU General Public License.
560 |
561 | 14. Revised Versions of this License.
562 |
563 | The Free Software Foundation may publish revised and/or new versions of
564 | the GNU Affero General Public License from time to time. Such new versions
565 | will be similar in spirit to the present version, but may differ in detail to
566 | address new problems or concerns.
567 |
568 | Each version is given a distinguishing version number. If the
569 | Program specifies that a certain numbered version of the GNU Affero General
570 | Public License "or any later version" applies to it, you have the
571 | option of following the terms and conditions either of that numbered
572 | version or of any later version published by the Free Software
573 | Foundation. If the Program does not specify a version number of the
574 | GNU Affero General Public License, you may choose any version ever published
575 | by the Free Software Foundation.
576 |
577 | If the Program specifies that a proxy can decide which future
578 | versions of the GNU Affero General Public License can be used, that proxy's
579 | public statement of acceptance of a version permanently authorizes you
580 | to choose that version for the Program.
581 |
582 | Later license versions may give you additional or different
583 | permissions. However, no additional obligations are imposed on any
584 | author or copyright holder as a result of your choosing to follow a
585 | later version.
586 |
587 | 15. Disclaimer of Warranty.
588 |
589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597 |
598 | 16. Limitation of Liability.
599 |
600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608 | SUCH DAMAGES.
609 |
610 | 17. Interpretation of Sections 15 and 16.
611 |
612 | If the disclaimer of warranty and limitation of liability provided
613 | above cannot be given local legal effect according to their terms,
614 | reviewing courts shall apply local law that most closely approximates
615 | an absolute waiver of all civil liability in connection with the
616 | Program, unless a warranty or assumption of liability accompanies a
617 | copy of the Program in return for a fee.
618 |
619 | END OF TERMS AND CONDITIONS
620 |
621 | How to Apply These Terms to Your New Programs
622 |
623 | If you develop a new program, and you want it to be of the greatest
624 | possible use to the public, the best way to achieve this is to make it
625 | free software which everyone can redistribute and change under these terms.
626 |
627 | To do so, attach the following notices to the program. It is safest
628 | to attach them to the start of each source file to most effectively
629 | state the exclusion of warranty; and each file should have at least
630 | the "copyright" line and a pointer to where the full notice is found.
631 |
632 |
633 | Copyright (C) {{now/year}} {{developer}}
634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published
637 | by the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
662 |
--------------------------------------------------------------------------------