commit b78885c4292ba35b9c15abc9f312edb1ffb2d927 from: mtmn date: Thu Jun 25 20:08:40 2026 UTC config: use `Config.Reader` for config parsing commit - 14823e8a735acd0afe8936642bc20c7d20904897 commit + b78885c4292ba35b9c15abc9f312edb1ffb2d927 blob - 43b9f8002f4fea4dd167cf6ad65cf6fb2d29a7a9 blob + 0397dccbafebf8d743df2307847609d503ff1f35 --- README.md +++ README.md @@ -15,7 +15,7 @@ mix release ### Create a short link ```sh -curl -X POST http://localhost:8721/curtail \ +curl -X POST http://localhost:4000/curtail \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/very/long/path"}' @@ -24,7 +24,7 @@ curl -X POST http://localhost:8721/curtail \ Optionally provide a custom code: ```sh -curl -X POST http://localhost:8721/curtail \ +curl -X POST http://localhost:4000/curtail \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/very/long/path", "code": "my-code"}' @@ -33,14 +33,14 @@ curl -X POST http://localhost:8721/curtail \ ### List all short links ```sh -curl http://localhost:8721/history \ +curl http://localhost:4000/history \ -H "Authorization: Bearer " ``` Returns a table ordered by creation time. Request JSON instead with an `Accept` header: ```sh -curl http://localhost:8721/history \ +curl http://localhost:4000/history \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` @@ -55,10 +55,50 @@ GET /s/ ## Configuration -| Variable | Description | -|------------|--------------------------------------| -| `PORT` | Port to listen on (default: `8721`) | -| `HOST` | Host to bind to (default: `127.0.0.1`) | -| `BASE_URL` | Base URL used when generating links | -| `AUTH_TOKEN` | Bearer token for the create endpoint | -| `DB_PATH` | Path to the SQLite database file | +Runtime configuration is resolved with the following precedence: + +1. Environment variables +2. `/etc/shirts.conf` (a native Elixir config file; see `config/shirts.conf.example`) +3. Built-in defaults + +| Variable | Description | Default | +|--------------|--------------------------------------|----------------| +| `PORT` | Port to listen on | `4000` | +| `HOST` | Host/interface to bind to | `127.0.0.1` | +| `BASE_URL` | Base URL used when generating links | `http://localhost` | +| `AUTH_TOKEN` | Bearer token for the create endpoint | _required_ | +| `DB_PATH` | Path to the SQLite database file | `data.db` | + +In production, `AUTH_TOKEN` must be set either via the `AUTH_TOKEN` environment +variable or in `/etc/shirts.conf`, otherwise the application refuses to start. + +### Config file + +`/etc/shirts.conf` is an optional native Elixir config file. Copy the example +and edit it: + +```sh +cp config/shirts.conf.example /etc/shirts.conf +``` + +```elixir +import Config + +config :shirts, + port: 4000, + host: "127.0.0.1", + auth_token: "change-me", + db_path: "/var/db/shirts/data.db", + base_url: "https://shirts.example" +``` + +### Release settings + +The following are read natively by the Elixir release runtime (not by the +config file above); set them as environment variables: + +| Variable | Description | +|-----------------------|--------------------------------------| +| `RELEASE_COOKIE` | Distribution cookie for the BEAM node | +| `RELEASE_NODE` | Node name, e.g. `shirts@127.0.0.1` | +| `RELEASE_DISTRIBUTION`| `name` / `sname` for node distribution | blob - 4c71fded6ba028239409f1abb6fd337c85dd2d66 (mode 644) blob + /dev/null --- config/config.exs +++ /dev/null @@ -1,8 +0,0 @@ -import Config - -config :shirts, - port: System.get_env("PORT", "4000") |> String.to_integer(), - host: System.get_env("HOST", "127.0.0.1"), - auth_token: System.get_env("AUTH_TOKEN", "changeme"), - db_path: System.get_env("DB_PATH", "data.db"), - base_url: System.get_env("BASE_URL", "http://localhost") blob - 1b7aebe30f53e1899976c373ef9a2ed6474097ec blob + 42705acc3aa5190bce7f51bfd72122576495acdd --- config/runtime.exs +++ config/runtime.exs @@ -1,40 +1,37 @@ import Config -if config_env() == :prod do - conf = - case File.read("/etc/shirts.conf") do - {:ok, contents} -> - contents - |> String.split("\n") - |> Enum.reduce(%{}, fn line, acc -> - line = String.trim(line) +prod? = config_env() == :prod - case String.split(line, "=", parts: 2) do - [k, v] when byte_size(k) > 0 -> - Map.put(acc, k, v |> String.trim("\"") |> String.trim("'")) - - _ -> - acc - end - end) - - {:error, _} -> - %{} - end - - get = fn key, default -> - case System.get_env(key) do - nil -> Map.get(conf, key, default) - val -> val - end +shirts = + if prod? and File.exists?("/etc/shirts.conf") do + Access.get(Config.Reader.read!("/etc/shirts.conf"), :shirts, []) + else + [] end - config :shirts, - port: get.("PORT", "4000") |> String.to_integer(), - host: get.("HOST", "127.0.0.1"), - auth_token: - System.get_env("AUTH_TOKEN") || Map.get(conf, "AUTH_TOKEN") || - raise("AUTH_TOKEN not set in environment or /etc/shirts.conf"), - db_path: get.("DB_PATH", "data.db"), - base_url: get.("BASE_URL", "http://localhost") +get = fn env_name, file_key, default -> + case System.get_env(env_name) do + nil -> Access.get(shirts, file_key, default) + val -> val + end end + +port = + case get.("PORT", :port, 4000) do + p when is_integer(p) -> p + p when is_binary(p) -> String.to_integer(p) + end + +auth_token = + case get.("AUTH_TOKEN", :auth_token, nil) do + nil when prod? -> raise "AUTH_TOKEN not set in environment or /etc/shirts.conf" + nil -> "changeme" + token -> token + end + +config :shirts, + port: port, + host: get.("HOST", :host, "127.0.0.1"), + auth_token: auth_token, + db_path: get.("DB_PATH", :db_path, "data.db"), + base_url: get.("BASE_URL", :base_url, "http://localhost") blob - /dev/null blob + 77a91511d57b69e03aaa2a1d662145cd81d167f0 (mode 644) --- /dev/null +++ config/shirts.conf.example @@ -0,0 +1,8 @@ +import Config + +config :shirts, + port: 4000, + host: "127.0.0.1", + auth_token: "change-me", + db_path: "/var/db/shirts/data.db", + base_url: "https://shirts.example" blob - 0f11e17583ed46319f6c76600318bd485344c0e0 blob + 836ad699c5e8d64f9e5d08637c4ca8bcfb34473f --- justfile +++ justfile @@ -3,31 +3,24 @@ export BASE_URL := "http://localhost" export PORT := "4000" export DB_PATH := "shirts.db" -# List available recipes default: @just --list -# Fetch deps and compile build: mix deps.get mix compile -# Run the server run: mix run --no-halt -# Format sources format: mix format -# Lint with credo lint: mix credo --strict -# Type-check with dialyzer check: mix dialyzer -# Remove build artifacts clean: - mix clean + mix clean \ No newline at end of file blob - 092700072553550e2204e3d3e869b0a773467f86 blob + deab86b4743b64738b1bafa0be05ffd30a70941b --- lib/shortener/router.ex +++ lib/shortener/router.ex @@ -1,9 +1,9 @@ defmodule Shirts.Router do @moduledoc """ Plug router: - POST /curtail — create a short link - GET /history — list all short links - GET /s/:code — redirect to the original URL + POST /curtail - create a short link + GET /history - list all short links + GET /s/:code - redirect to the original URL """ use Plug.Router @@ -13,12 +13,6 @@ defmodule Shirts.Router do plug(Plug.Parsers, parsers: [:json], json_decoder: Jason) plug(:dispatch) - # --------------------------------------------------------------------------- - # POST /l/curtail - # Body (JSON): {"url": "https://example.com/very/long/path"} - # Optional: {"url": "...", "code": "my-custom-code"} - # Header: Authorization: Bearer - # --------------------------------------------------------------------------- post "/curtail" do with :ok <- check_auth(conn), {:ok, url} <- fetch_url(conn), @@ -43,9 +37,6 @@ defmodule Shirts.Router do end end - # --------------------------------------------------------------------------- - # GET /history — list all short links - # --------------------------------------------------------------------------- get "/history" do with :ok <- check_auth(conn), {:ok, shirts} <- Shirts.DB.list() do @@ -59,9 +50,6 @@ defmodule Shirts.Router do end end - # --------------------------------------------------------------------------- - # GET /s/:code — redirect - # --------------------------------------------------------------------------- get "/s/:code" do case Shirts.DB.lookup(code) do {:ok, url} -> @@ -77,17 +65,10 @@ defmodule Shirts.Router do end end - # --------------------------------------------------------------------------- - # Catch-all - # --------------------------------------------------------------------------- match _ do json(conn, 404, %{error: "not found"}) end - # --------------------------------------------------------------------------- - # Helpers - # --------------------------------------------------------------------------- - defp check_auth(conn) do expected = "Bearer #{Application.fetch_env!(:shirts, :auth_token)}"