commit - 14823e8a735acd0afe8936642bc20c7d20904897
commit + b78885c4292ba35b9c15abc9f312edb1ffb2d927
blob - 43b9f8002f4fea4dd167cf6ad65cf6fb2d29a7a9
blob + 0397dccbafebf8d743df2307847609d503ff1f35
--- README.md
+++ README.md
### Create a short link
```sh
-curl -X POST http://localhost:8721/curtail \
+curl -X POST http://localhost:4000/curtail \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/very/long/path"}'
Optionally provide a custom code:
```sh
-curl -X POST http://localhost:8721/curtail \
+curl -X POST http://localhost:4000/curtail \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/very/long/path", "code": "my-code"}'
### List all short links
```sh
-curl http://localhost:8721/history \
+curl http://localhost:4000/history \
-H "Authorization: Bearer <token>"
```
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 <token>" \
-H "Accept: application/json"
```
## 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
-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
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
+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
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
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
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 <token>
- # ---------------------------------------------------------------------------
post "/curtail" do
with :ok <- check_auth(conn),
{:ok, url} <- fetch_url(conn),
end
end
- # ---------------------------------------------------------------------------
- # GET /history — list all short links
- # ---------------------------------------------------------------------------
get "/history" do
with :ok <- check_auth(conn),
{:ok, shirts} <- Shirts.DB.list() do
end
end
- # ---------------------------------------------------------------------------
- # GET /s/:code — redirect
- # ---------------------------------------------------------------------------
get "/s/:code" do
case Shirts.DB.lookup(code) do
{:ok, url} ->
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)}"