diff --git a/.env.example b/.env.example index c4c6a99..b729271 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,6 @@ WORKER_DELAY_SECONDS=3600 REPORTED_LOG_LEVEL=warn LOG_APPENDER=println # SENTRY_DSN= only needed when LOG_APPENDER=sentry + +# Ring secure-site-defaults (recommended behind HTTPS/nginx) +SECURE_DEFAULTS=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db5abbd..db49b9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,6 @@ jobs: --health-retries 5 env: - LEIN_ROOT: "true" JVM_OPTS: -Xmx2g DATABASE_URL: jdbc:postgresql://localhost:5432/self-destruct-test?user=selfdestruct&password=selfdestruct DATABASE_ENCRYPTION_KEY: changemedbkey2 @@ -47,25 +46,30 @@ jobs: distribution: temurin java-version: "21" - - name: Set up Clojure / Leiningen + - name: Set up Clojure CLI uses: DeLaGuardo/setup-clojure@13.6.1 with: - # 2.13.0 currently fails to install via setup-clojure; stay on 2.12.x - lein: 2.12.0 + cli: 1.12.5.1664 - - name: Cache Maven dependencies + - name: Cache Maven / Clojure deps uses: actions/cache@v4 with: - path: ~/.m2/repository - key: ${{ runner.os }}-m2-${{ hashFiles('project.clj') }} + path: | + ~/.m2/repository + ~/.gitlibs + .cpcache + key: ${{ runner.os }}-clj-${{ hashFiles('deps.edn') }} restore-keys: | - ${{ runner.os }}-m2- + ${{ runner.os }}-clj- - name: Install dependencies - run: lein deps + run: clojure -P -M:test:dev:build - name: Run database migrations - run: lein migrate + run: clojure -M:migrate - name: Run tests - run: lein test + run: clojure -M:test + + - name: Build uberjar + run: clojure -T:build uber diff --git a/.gitignore b/.gitignore index 7e99d42..d2b8683 100644 --- a/.gitignore +++ b/.gitignore @@ -4,11 +4,9 @@ /checkouts/ /resources/public/css/ .cpcache/ -.lein-* .nrepl-port *.jar *.class pom.xml pom.xml.asc -profiles.clj .env diff --git a/DEPLOYMENT_FREEBSD.md b/DEPLOYMENT_FREEBSD.md index 234dc00..9c3b2b8 100644 --- a/DEPLOYMENT_FREEBSD.md +++ b/DEPLOYMENT_FREEBSD.md @@ -1,34 +1,30 @@ # self-destruct FreeBSD Deployment -Deploy self-destruct on a FreeBSD home server using the same pattern as -DoThisWeek: dedicated app user, PostgreSQL, `.env`, Leiningen uberjar, `rc.d` -service, nginx reverse proxy, certbot, and daily DB backups. +Deploy self-destruct on FreeBSD with a dedicated app user, PostgreSQL, `.env`, +Clojure CLI uberjar, `rc.d` service, nginx reverse proxy, certbot, and daily DB +backups. ## Assumptions -- PostgreSQL, nginx, certbot, firewall rules, and (optionally) Cloudflare DDNS - are already available on the server. +- PostgreSQL, nginx, certbot, and basic firewall rules are available (or you will + install them as you go). - `sudo` is available. -- The server has outbound HTTPS for GitHub, Maven/Clojars, Cloudflare, and - Let's Encrypt. +- The server has outbound HTTPS for GitHub, Maven/Clojars, and Let's Encrypt. - App path is `/home/selfdestruct/self-destruct`. - App user is `selfdestruct`. -- App port is `4003` (DoThisWeek uses `4002`). +- App port is `4003`. ## 1. Install Java Runtime and Build Tools ```sh -sudo pkg install openjdk21 git +sudo pkg install openjdk21 git clojure ``` -Install Leiningen if it is not already on the system (e.g. `sudo pkg install -leiningen`, or place a `lein` script on `PATH`). - Verify: ```sh /usr/local/openjdk21/bin/java -version -lein version +clj -Sdescribe ``` The deployment scripts force `JAVA_HOME=/usr/local/openjdk21` and put @@ -78,7 +74,7 @@ psql -U selfdestruct -h localhost selfdestruct_prod ``` If needed, update `pg_hba.conf` to allow `md5` or `scram-sha-256` -authentication for localhost connections, matching the other deployed apps. +authentication for localhost TCP connections. ## 4. Create Production Environment @@ -109,8 +105,8 @@ sudo -u selfdestruct sh -c 'cd /home/selfdestruct/self-destruct && ./scripts/bui This: -- Fetches dependencies with `lein deps` -- Builds `target/self-destruct.jar` with `lein uberjar` +- Fetches dependencies with `clojure -P` +- Builds `target/self-destruct.jar` with `clojure -T:build uber` - Runs migrations with `java -jar target/self-destruct.jar --migrate` Optional local server test from the FreeBSD host: @@ -171,29 +167,20 @@ sudo sysrc nginx_enable="YES" sudo service nginx start ``` -## 8. Configure DNS and Cloudflare DDNS +## 8. Configure DNS -In Cloudflare (or your DNS provider): +At your DNS provider: -- Create an A record for `@` pointing to the server public IP +- Create an A (or AAAA) record for the apex hostname pointing at the server - Prefer a CNAME for `www` pointing at the apex hostname -- Keep records DNS-only unless you intentionally want a CDN proxy - -If you already run a shared Cloudflare DDNS script on this host, add another -zone entry for this domain (same pattern as DoThisWeek / other apps). Example: - -```sh -sudo sysrc cloudflare_ddns_zoneN_id="YOUR_ZONE_ID" -sudo sysrc cloudflare_ddns_zoneN_record_id="YOUR_RECORD_ID" -sudo sysrc cloudflare_ddns_zoneN_name="your.domain.example" -``` +- If your DNS provider offers a CDN/proxy toggle (e.g. Cloudflare orange-cloud), + keep records DNS-only until certificates are issued, unless you intentionally + want that proxy in front of nginx -Then update the DDNS script to call `process_zone` for the new zone and verify: - -```sh -sudo /usr/local/bin/cloudflare-ddns.sh -tail /var/log/cloudflare-ddns.log -``` +If the server has a dynamic public IP, configure DDNS so the apex record stays +current before requesting certificates. If `www` is an A/AAAA record instead of +a CNAME, manage that record with DDNS too — otherwise it can go stale when the +IP changes. ## 9. Configure HTTPS @@ -211,12 +198,15 @@ Verify HTTPS: curl -I https://your.domain.example ``` -Certbot renewal should already be configured if other apps use it. Verify: +Confirm certbot renewal is scheduled (or set it up), then dry-run: ```sh sudo certbot renew --dry-run ``` +Production `.env` sets `SECURE_DEFAULTS=true`, so prefer finishing HTTPS before +relying on browser sessions over plain HTTP. + ## 10. Configure Database Backups Run as root: @@ -296,5 +286,5 @@ curl -s https://your.domain.example/health | Backup log | /var/log/selfdestruct-backup.log | | Backup time | 3:45 AM daily | | Backup retention | 30 days | -| Build | `lein uberjar` | +| Build | `clojure -T:build uber` | | Migrate | `java -jar target/self-destruct.jar --migrate` | diff --git a/README.org b/README.org index 44adaea..0b384f5 100644 --- a/README.org +++ b/README.org @@ -5,7 +5,7 @@ ** Prerequisites - [[https://adoptium.net/][Java]] 17 or newer (21 recommended; required by Ring / Jetty 12) - - [[https://github.com/technomancy/leiningen][Leiningen]] 2.11.0 or newer (2.12.x recommended) + - [[https://clojure.org/guides/install_clojure][Clojure CLI]] (~clj~ / ~clojure~) - PostgreSQL available over TCP with a role/password your JDBC URL can use @@ -13,13 +13,15 @@ *** Set Environment Variables & Secrets - Copy the example file, then edit values to match your machine: + Copy the local example, then edit values to match your machine: #+BEGIN_SRC sh - cp profiles_example.clj profiles.clj + cp dev.env.example .env #+END_SRC - ~profiles.clj~ is gitignored. The example includes local defaults for: + ~.env~ is gitignored. On startup the app loads it automatically for any + variables not already set in the process environment (so exported env vars + and CI/production still win). The example includes: - ~DATABASE_URL~ — JDBC URL with user/password (not Unix peer auth) - ~DATABASE_ENCRYPTION_KEY~ — key used to encrypt messages at rest @@ -28,26 +30,25 @@ *** Setup Local Development Database - Create a Postgres role and database that match ~profiles.clj~ (example uses - user/password ~selfdestruct~ / ~selfdestruct~): + Create a Postgres role and database that match ~dev.env.example~ (user/password + ~selfdestruct~ / ~selfdestruct~): #+BEGIN_SRC sh createuser selfdestruct psql -c "ALTER USER selfdestruct WITH PASSWORD 'selfdestruct';" createdb -O selfdestruct self-destruct-dev - lein migrate + clj -M:migrate #+END_SRC - If you use a different role, update ~:database-url~ in ~profiles.clj~ (and - export ~DATABASE_URL~ if you override it in the shell). JDBC connections use - TCP auth; peer auth via Unix sockets is not used. + If you use a different role, update ~DATABASE_URL~ in ~.env~. JDBC connections + use TCP auth; peer auth via Unix sockets is not used. *** Run the app After migrations have been applied: #+BEGIN_SRC sh - lein dev + clj -M:dev-main #+END_SRC That starts Garden CSS auto-compile and the Ring server (default port 8000). @@ -55,39 +56,49 @@ Equivalent pieces if you prefer to run them separately: #+BEGIN_SRC sh - lein garden auto - lein ring server + clj -M:css watch + clj -M:run -- --port 8000 #+END_SRC + ~:run~ uses the production entrypoint (~-main~). ~:dev-main~ uses + ~-dev-main~ with Ring reload. + *** Running Tests #+BEGIN_SRC sh ./run-tests-local.sh #+END_SRC - This recreates ~self-destruct-test~, runs migrations, then ~lein test~. - Ensure the JDBC role in ~profiles.clj~ / ~DATABASE_URL~ can create that database - (or create it yourself and point ~DATABASE_URL~ at it). + This recreates ~self-destruct-test~, runs migrations, then ~clj -M:test~. + Ensure your Postgres role can create that database (or create it yourself and + point ~DATABASE_URL~ at it). ** Continuous Integration Pull requests and pushes to ~master~ run on GitHub Actions (~.github/workflows/ci.yml~): - Postgres service, ~lein migrate~, then ~lein test~. + Postgres service, ~clj -M:migrate~, ~clj -M:test~, and an uberjar build. ** Database Migrations - Migratus handles schema changes; files live in ~resources/migrations~. - - Create a migration with ~lein migratus create ~, then edit the ~up~ / ~down~ SQL. - - Apply migrations with ~lein migrate~ (runs the app ~--migrate~ CLI) or + - Create a new pair of ~*.up.sql~ / ~*.down.sql~ files under ~resources/migrations~ + (timestamp prefix, e.g. ~20260731120000-add-thing.up.sql~). + - Apply migrations with ~clj -M:migrate~ or ~java -jar target/self-destruct.jar --migrate~ in production. -** Production Setup +** Production Build + + #+BEGIN_SRC sh + clj -T:build uber + #+END_SRC + + Writes ~target/self-destruct.jar~ (compiles Garden CSS first, then AOT + uberjar). - Primary target is a FreeBSD home server. See [[file:DEPLOYMENT_FREEBSD.md][DEPLOYMENT_FREEBSD.md]] - for install, ~rc.d~, nginx, certbot, and backup scripts under ~scripts/~. + For FreeBSD production deploy (app user, ~rc.d~, nginx, certbot, backups), see + [[file:DEPLOYMENT_FREEBSD.md][DEPLOYMENT_FREEBSD.md]] and the scripts under ~scripts/~. Required environment variables (also listed in [[file:.env.example][.env.example]]): diff --git a/build.clj b/build.clj new file mode 100644 index 0000000..a82757d --- /dev/null +++ b/build.clj @@ -0,0 +1,38 @@ +(ns build + (:require [clojure.java.io :as io] + [clojure.tools.build.api :as b] + [garden.core :as garden])) + + +(def class-dir "target/classes") +(def basis (delay (b/create-basis {:project "deps.edn"}))) +(def uber-file "target/self-destruct.jar") +(def css-output "resources/public/css/style.css") + + +(defn clean [_] + (b/delete {:path "target"}) + (b/delete {:path "resources/public/css"})) + + +(defn compile-css [_] + (require 'self-destruct.css) + (let [style @(resolve 'self-destruct.css/style)] + (io/make-parents css-output) + (spit css-output (apply garden/css {:pretty-print? true} style)) + (println "Wrote" css-output))) + + +(defn uber [_] + (clean nil) + (compile-css nil) + (b/copy-dir {:src-dirs ["src" "resources"] + :target-dir class-dir}) + (b/compile-clj {:basis @basis + :ns-compile '[self-destruct.core] + :class-dir class-dir}) + (b/uber {:class-dir class-dir + :uber-file uber-file + :basis @basis + :main 'self-destruct.core}) + (println "Uberjar written to" uber-file)) diff --git a/deps.edn b/deps.edn new file mode 100644 index 0000000..23948d6 --- /dev/null +++ b/deps.edn @@ -0,0 +1,78 @@ +{:paths ["src" "resources"] + + :deps {org.clojure/clojure {:mvn/version "1.12.5"} + + ;; core + ring/ring {:mvn/version "1.15.5"} + compojure/compojure {:mvn/version "1.7.2"} + + ;; environment + environ/environ {:mvn/version "1.2.0"} + org.clojure/tools.cli {:mvn/version "1.1.230"} + + ;; database + com.layerware/hugsql {:mvn/version "0.5.3"} + org.clojure/java.jdbc {:mvn/version "0.7.12"} + org.postgresql/postgresql {:mvn/version "42.7.7"} + migratus/migratus {:mvn/version "1.6.7"} + com.github.seancorfield/next.jdbc {:mvn/version "1.3.1048"} + + ;; logging + com.taoensso/timbre {:mvn/version "6.8.0"} + raven-clj/raven-clj {:mvn/version "1.7.0"} + + ;; security + buddy/buddy-core {:mvn/version "1.12.0-430"} + + ;; ui + hiccup/hiccup {:mvn/version "2.0.0"} + garden/garden {:mvn/version "1.3.10"} + + ;; middleware + ring/ring-defaults {:mvn/version "0.7.1"} + + ;; data + cheshire/cheshire {:mvn/version "5.13.0"} + + ;; scheduling + tea-time/tea-time {:mvn/version "1.0.1"} + + ;; hosted assets + ring-webjars/ring-webjars {:mvn/version "0.3.1"} + org.webjars/font-awesome {:mvn/version "6.7.2"}} + + :aliases + { :dev + {:extra-paths ["dev"] + :extra-deps {ring/ring-mock {:mvn/version "0.6.2"} + org.clojure/tools.namespace {:mvn/version "1.5.0"}}} + + :run + {:main-opts ["-m" "self-destruct.core"]} + + :dev-main + {:extra-paths ["dev"] + :extra-deps {ring/ring-mock {:mvn/version "0.6.2"}} + :main-opts ["-m" "dev"]} + + :migrate + {:main-opts ["-m" "self-destruct.core" "--migrate"]} + + :css + {:extra-paths ["dev"] + :main-opts ["-m" "css"]} + + :test + {:extra-paths ["test"] + :extra-deps {ring/ring-mock {:mvn/version "0.6.2"} + io.github.cognitect-labs/test-runner + {:git/tag "v0.5.1" :git/sha "dfb30dd"}} + :main-opts ["-m" "cognitect.test-runner"] + :exec-fn cognitect.test-runner.api/test} + + :build + {:deps {io.github.clojure/tools.build {:mvn/version "0.10.10"} + garden/garden {:mvn/version "1.3.10"}} + ;; "." hosts build.clj; "src" is needed so compile-css can require Garden styles + :paths ["." "src"] + :ns-default build}}} diff --git a/dev.env.example b/dev.env.example new file mode 100644 index 0000000..394cb3b --- /dev/null +++ b/dev.env.example @@ -0,0 +1,15 @@ +# Local development defaults (copy to .env) +# cp dev.env.example .env +# +# The app loads .env automatically for unset variables. Process env / CI / +# production exports always take precedence. +DATABASE_URL=jdbc:postgresql://localhost:5432/self-destruct-dev?user=selfdestruct&password=selfdestruct +DATABASE_ENCRYPTION_KEY=changemedbkey1 +SESSION_COOKIE_KEY=changecookiekey1 + +PORT=8000 +ENABLE_WORKERS=false +MESSAGE_EXPIRE_MINUTES=1440 +WORKER_DELAY_SECONDS=3600 +REPORTED_LOG_LEVEL=debug +LOG_APPENDER=println diff --git a/dev/css.clj b/dev/css.clj new file mode 100644 index 0000000..780fadd --- /dev/null +++ b/dev/css.clj @@ -0,0 +1,44 @@ +(ns css + "Compile Garden stylesheets to resources/public/css/style.css." + (:require [clojure.java.io :as io] + [garden.core :as garden])) + + +(def output-path "resources/public/css/style.css") + + +(defn compile-once! + [] + (require 'self-destruct.css) + (let [style @(resolve 'self-destruct.css/style)] + (io/make-parents output-path) + (spit output-path (apply garden/css {:pretty-print? true} style)) + (println "Wrote" output-path))) + + +(defn watch! + "Recompile CSS when the stylesheet source changes." + [] + (println "Watching Garden CSS (Ctrl-C to stop)...") + (loop [last-mtime 0] + (let [source (io/file "src/self_destruct/css.clj") + mtime (.lastModified source)] + (when (> mtime last-mtime) + (try + (require 'self-destruct.css :reload) + (compile-once!) + (catch Exception e + (println "CSS compile failed:" (.getMessage e))))) + (Thread/sleep 1000) + (recur mtime)))) + + +(defn -main + [& args] + (case (first args) + "once" (compile-once!) + "watch" (watch!) + nil (compile-once!) + (do + (println "Usage: clj -M:css [once|watch]") + (System/exit 1)))) diff --git a/dev/dev.clj b/dev/dev.clj new file mode 100644 index 0000000..9ddf13f --- /dev/null +++ b/dev/dev.clj @@ -0,0 +1,15 @@ +(ns dev + "Development entrypoint: optional CSS watch + reloadable Ring server." + (:require [css] + [self-destruct.core :as core])) + + +(defn -main + [& args] + (future + (try + (css/watch!) + (catch Exception e + (binding [*out* *err*] + (println "CSS watch stopped:" (.getMessage e)))))) + (apply core/-dev-main args)) diff --git a/profiles_example.clj b/profiles_example.clj deleted file mode 100644 index 138845f..0000000 --- a/profiles_example.clj +++ /dev/null @@ -1,20 +0,0 @@ -{:profiles/dev - {:env - {:database-url "jdbc:postgresql://localhost:5432/self-destruct-dev?user=selfdestruct&password=selfdestruct" - :database-encryption-key "changemedbkey1" - :reported-log-level "debug" - :session-cookie-key "changecookiekey1" - :enable-workers "false" - :message-expire-minutes "1440" - :worker-delay-seconds "3600"}} - :profiles/test - {:env - {:database-url "jdbc:postgresql://localhost:5432/self-destruct-test?user=selfdestruct&password=selfdestruct" - :database-encryption-key "changemedbkey2" - :reported-log-level "debug" - :session-cookie-key "changecookiekey2" - :disable-anti-forgery "true" - :enable-workers "false"}} - :profiles/prod - ;; intentionally empty to ensure builds do not rely on run time values - {:env {}}} diff --git a/project.clj b/project.clj deleted file mode 100644 index 0ec4ab6..0000000 --- a/project.clj +++ /dev/null @@ -1,95 +0,0 @@ -(defproject self-destruct "0.1.0-SNAPSHOT" - :description "run your own self destucting note service" - :url "http://github.com/chadhs/self-destruct" - :min-lein-version "2.11.0" - :dependencies [[org.clojure/clojure "1.12.5"] - ;;; core - [ring/ring "1.15.5"] - [compojure "1.7.2"] - ;;; environment - [environ "1.2.0"] - [org.clojure/tools.cli "1.1.230"] - ;;; database - [com.layerware/hugsql "0.5.3"] - [org.clojure/java.jdbc "0.7.12"] - [org.postgresql/postgresql "42.7.7"] - [migratus "1.6.7"] - ;;; logging - [com.taoensso/timbre "6.8.0"] - [raven-clj "1.7.0"] ; timbre sentry support - ;;; security - [buddy/buddy-core "1.12.0-430"] - ;;; ui - [hiccup/hiccup "2.0.0"] - [garden "1.3.10"] - ;;; middleware - [ring/ring-defaults "0.7.1"] - ;;; data - [cheshire "5.13.0"] - ;;; scheduling - [tea-time "1.0.1"] - ;;; hosted assests - [ring-webjars "0.3.1"] - [org.webjars/font-awesome "6.7.2"]] - - - :plugins [[lein-environ "1.2.0"] - [lein-ring "0.12.6"] - [migratus-lein "0.7.3"] - [lein-garden "0.3.0"] - [lein-pdo "0.1.1"]] - - - :ring {:init self-destruct.core/init - :handler self-destruct.core/app - :port 8000 - :auto-refresh? true} - - - :garden {:builds [{:source-paths ["src"] - :id "style" - :stylesheet self-destruct.css/style - :compiler {:output-to "resources/public/css/style.css" - :pretty-print? true}}]} - - - :clean-targets ^{:protect false} ["resources/public/css"] - - - :migratus {:store :database - :migration-dir "migrations" - :db {:jdbcUrl ~(or (System/getenv "DATABASE_URL") - "jdbc:postgresql://localhost:5432/self-destruct-dev?user=selfdestruct&password=selfdestruct")}} - - - :profiles {:uberjar {:aot :all - :env {:secure-defaults "true"}} - :dev [:project/dev :profiles/dev] - :test [:project/test :profiles/test] - :prod [:project/prod :profiles/prod ] - ;; only edit :profiles/* in profiles.clj - :profiles/dev {} - :profiles/test {} - :profiles/prod {} - :project/dev {:main self-destruct.core/-dev-main - :dependencies [[ring/ring-mock "0.6.2"]]} - :project/test {:dependencies [[ring/ring-mock "0.6.2"]]} - :project/prod {}} - - - :main self-destruct.core - - - :uberjar-name "self-destruct.jar" - - - :prep-tasks ["clean" ["garden" "once"] "compile"] - - - ;; Use the app CLI for migrations so we always run migratus 1.6+ / next.jdbc - ;; from project deps (migratus-lein still pins an older migratus). - :aliases {"migrate" ["run" "--" "--migrate"] - "dev" ["pdo" ["garden" "auto"] ["ring" "server-headless"]]} - - - ) diff --git a/run-tests-local.sh b/run-tests-local.sh index ea01191..781e606 100755 --- a/run-tests-local.sh +++ b/run-tests-local.sh @@ -1,12 +1,20 @@ #!/bin/sh set -e +ROOT="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" +cd "$ROOT" + export DATABASE_URL="${DATABASE_URL:-jdbc:postgresql://localhost:5432/self-destruct-test?user=selfdestruct&password=selfdestruct}" +export DATABASE_ENCRYPTION_KEY="${DATABASE_ENCRYPTION_KEY:-changemedbkey2}" +export SESSION_COOKIE_KEY="${SESSION_COOKIE_KEY:-changecookiekey2}" +export DISABLE_ANTI_FORGERY="${DISABLE_ANTI_FORGERY:-true}" +export ENABLE_WORKERS="${ENABLE_WORKERS:-false}" +export PGPASSWORD="${PGPASSWORD:-selfdestruct}" -if psql -lqt | cut -d \| -f 1 | grep -qw self-destruct-test; then - dropdb self-destruct-test +if psql -h localhost -U selfdestruct -lqt | cut -d \| -f 1 | grep -qw self-destruct-test; then + dropdb -h localhost -U selfdestruct self-destruct-test fi -createdb self-destruct-test -lein migrate -lein with-profile test test +createdb -h localhost -U selfdestruct self-destruct-test +clj -M:migrate +clj -M:test diff --git a/scripts/build-prod.sh b/scripts/build-prod.sh index ab488fd..e1c9479 100755 --- a/scripts/build-prod.sh +++ b/scripts/build-prod.sh @@ -19,7 +19,7 @@ if ! "${JAVA_HOME}/bin/java" -version 2>&1 | grep -q 'version "21'; then exit 1 fi -if [ ! -f "project.clj" ]; then +if [ ! -f "deps.edn" ]; then echo "Error: Please run this script from the self-destruct project root directory" exit 1 fi @@ -29,12 +29,15 @@ if [ ! -f ".env" ]; then exit 1 fi -if ! command -v lein >/dev/null 2>&1; then - echo "Error: lein not found on PATH" - echo "Install Leiningen (e.g. sudo pkg install leiningen) or place lein in PATH" +if ! command -v clojure >/dev/null 2>&1 && ! command -v clj >/dev/null 2>&1; then + echo "Error: clojure/clj not found on PATH" + echo "Install the Clojure CLI (e.g. sudo pkg install clojure)" exit 1 fi +CLJ_CMD="clojure" +command -v clojure >/dev/null 2>&1 || CLJ_CMD="clj" + echo "Loading environment variables..." while IFS= read -r line || [ -n "$line" ]; do case "$line" in @@ -56,14 +59,14 @@ while IFS= read -r line || [ -n "$line" ]; do done < .env echo "Fetching Clojure dependencies..." -lein deps +"$CLJ_CMD" -P -M:build if [ $? -ne 0 ]; then echo "Error: Failed to fetch dependencies" exit 1 fi echo "Building uberjar..." -lein uberjar +"$CLJ_CMD" -T:build uber if [ $? -ne 0 ]; then echo "Error: Failed to build uberjar" exit 1 diff --git a/scripts/create-env.sh b/scripts/create-env.sh index dcfd38d..dc49386 100755 --- a/scripts/create-env.sh +++ b/scripts/create-env.sh @@ -3,7 +3,7 @@ echo "Creating production environment file..." # Check if we're in the right directory -if [ ! -f "project.clj" ]; then +if [ ! -f "deps.edn" ]; then echo "Error: Please run this script from the self-destruct project root directory" exit 1 fi @@ -60,8 +60,8 @@ WORKER_DELAY_SECONDS=3600 REPORTED_LOG_LEVEL=warn LOG_APPENDER=println -# Optional: Ring secure-site-defaults when terminating TLS at nginx -# SECURE_DEFAULTS=true +# Ring secure-site-defaults (TLS terminated at nginx) +SECURE_DEFAULTS=true # Optional reference (not read by the app) APP_BASE_URL=https://${DOMAIN} diff --git a/src/self_destruct/config.clj b/src/self_destruct/config.clj index b71892c..df8fed1 100644 --- a/src/self_destruct/config.clj +++ b/src/self_destruct/config.clj @@ -1,5 +1,7 @@ (ns self-destruct.config - (:require [environ.core :as environ] + ;; self-destruct.env must load first so .env is available before environ init + (:require [self-destruct.env] + [environ.core :as environ] [migratus.core :as migratus] [taoensso.timbre :as timbre]) (:import [java.nio.charset StandardCharsets])) diff --git a/src/self_destruct/core.clj b/src/self_destruct/core.clj index d523542..814bc1c 100644 --- a/src/self_destruct/core.clj +++ b/src/self_destruct/core.clj @@ -116,7 +116,7 @@ (let [parsed (parse-opts args cli-options) port (get-in parsed [:options :port])] (config/configure-logging) - ;; honor --help/--migrate in dev as well (lein run defaults to -dev-main) + ;; honor --help/--migrate in dev as well (clj -M:dev-main) (if-let [exit-code (handle-cli-options parsed)] (System/exit exit-code) (do diff --git a/src/self_destruct/env.clj b/src/self_destruct/env.clj new file mode 100644 index 0000000..627d0c0 --- /dev/null +++ b/src/self_destruct/env.clj @@ -0,0 +1,70 @@ +(ns self-destruct.env + "Load a local .env file into JVM system properties for environ. + + Existing process environment variables always win. Values from .env are + applied only when the corresponding env var is unset/blank — so production + and CI (which export vars before starting Java) are unchanged, while local + `clj` commands pick up `.env` automatically." + (:require [clojure.java.io :as io] + [clojure.string :as str])) + + +(defn- keywordize + [k] + (-> (name k) + str/lower-case + (str/replace #"[_\\.]" "-") + keyword)) + + +(defn- parse-dotenv + "Parse KEY=VAL lines. Ignores blanks and # comments. Splits on the first =." + [contents] + (into [] + (keep (fn [line] + (let [line (str/trim line)] + (when (and (not (str/blank? line)) + (not (str/starts-with? line "#")) + (str/includes? line "=")) + (let [idx (str/index-of line "=") + k (str/trim (subs line 0 idx)) + v (str/trim (subs line (inc idx)))] + (when (re-matches #"[A-Za-z_][A-Za-z0-9_]*" k) + [k v]))))) + (str/split-lines contents)))) + + +(defn load-dotenv! + "Load `.env` from the process working directory (or `file` if given). + + - Skips keys already present in the process environment + - Sets JVM system properties so environ can see them + - If environ is already initialized, merges missing keys into `environ.core/env` + + Returns the parsed pairs (possibly empty), or nil when no file exists." + ([] + (load-dotenv! (io/file ".env"))) + ([file] + (let [f (io/file file)] + (when (.isFile f) + (let [pairs (parse-dotenv (slurp f))] + (doseq [[k v] pairs] + (when (str/blank? (System/getenv k)) + (System/setProperty k v))) + (when-let [environ-ns (find-ns 'environ.core)] + (when-let [env-var (ns-resolve environ-ns 'env)] + (alter-var-root + env-var + (fn [current] + (reduce (fn [m [k v]] + (let [kw (keywordize k)] + (if (str/blank? (str (get m kw))) + (assoc m kw v) + m))) + current + pairs))))) + pairs))))) + + +;; Load before other app namespaces require environ (see self-destruct.config). +(load-dotenv!)