├── 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 | --------------------------------------------------------------------------------