Commit Diff


commit - /dev/null
commit + a661165cc7bc169b44fc78c8f76d04e720826524
blob - /dev/null
blob + b1c6bc851b2dd0d76e7563e3b0b9c0dd3cc46d56 (mode 644)
--- /dev/null
+++ .gitignore
@@ -0,0 +1,4 @@
+/.expert/
+/_build/
+/deps/
+khal_notifier
blob - /dev/null
blob + 605ba8a854ad8f4f7f0dc6e67e47b26aedc8804f (mode 644)
--- /dev/null
+++ BUILD
@@ -0,0 +1,13 @@
+load("//bazel:local-deploy.bzl", "local_deploy")
+
+# you have to `mix escript.build` first and then copy the binary to /opt/tools
+filegroup(
+    name = "binary",
+    srcs = ["shirt_linkener"],
+    visibility = ["//visibility:public"],
+)
+
+local_deploy(
+    name = "deploy",
+    srcs = [":binary"],
+)
blob - /dev/null
blob + d04fe85e03d8be1364d6f1698728c0cd9efde52f (mode 644)
--- /dev/null
+++ Containerfile
@@ -0,0 +1,20 @@
+FROM docker.io/elixir:1.19-slim
+
+ENV MIX_ENV=prod
+ENV DEBIAN_FRONTEND=noninteractive
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    ca-certificates \
+    && rm -rf /var/lib/apt/lists/
+
+WORKDIR /app
+COPY mix.exs mix.lock ./
+
+RUN mix local.hex --force && mix local.rebar --force && MIX_ENV=prod mix deps.get --only prod
+
+COPY config ./config
+COPY lib ./lib
+
+RUN MIX_ENV=prod mix compile
+
+CMD ["mix", "run", "--no-halt"]
blob - /dev/null
blob + 32dc8a03c9eeee1534b5d6023c33820a6b162c50 (mode 644)
--- /dev/null
+++ Makefile
@@ -0,0 +1,32 @@
+.PHONY: all build format lint check clean run
+
+AUTH_TOKEN := demo
+BASE_URL := http://localhost
+PORT := 4000
+DB_PATH := shortener.db
+
+export AUTH_TOKEN
+export BASE_URL
+export PORT
+export DB_PATH
+
+all: build
+
+build:
+	mix deps.get
+	mix compile
+
+run:
+	mix run --no-halt
+
+format:
+	mix format
+
+lint:
+	mix credo --strict
+
+check:
+	mix dialyzer
+
+clean:
+	mix clean
blob - /dev/null
blob + 0ee46b333710ea711abb7114dd082a1f0ec79ace (mode 644)
--- /dev/null
+++ README.md
@@ -0,0 +1,46 @@
+# shirts
+
+A URL shortener. Links are stored in a SQLite database and served under a configurable base URL.
+
+## Building
+
+```sh
+mix deps.get
+mix release
+```
+
+## Usage
+
+### Create a short link
+
+```sh
+curl -X POST http://localhost:8721/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 \
+  -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d '{"url": "https://example.com/very/long/path", "code": "my-code"}'
+```
+
+### Follow a short link
+
+```
+GET /s/<code>
+```
+
+## 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     |
blob - /dev/null
blob + 7c57dd4f685188a09459a894ce21e35394d8d2dd (mode 644)
--- /dev/null
+++ config/config.exs
@@ -0,0 +1,7 @@
+import Config
+
+config :shortener,
+  port: System.get_env("PORT", "4000") |> String.to_integer(),
+  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 - /dev/null
blob + 519ad59acb7c5c68bc62286661866a8e1bf013fd (mode 644)
--- /dev/null
+++ config/runtime.exs
@@ -0,0 +1,10 @@
+import Config
+
+if config_env() == :prod do
+  config :shortener,
+    port: System.get_env("PORT") |> then(&(if &1, do: String.to_integer(&1), else: 4000)),
+    host: System.get_env("HOST") || "127.0.0.1",
+    auth_token: System.fetch_env!("AUTH_TOKEN"),
+    db_path: System.get_env("DB_PATH") || "data.db",
+    base_url: System.get_env("BASE_URL") || "http://localhost"
+end
blob - /dev/null
blob + 4af396196520e8ce6be0adef854f26c2ba982be8 (mode 644)
--- /dev/null
+++ flake.lock
@@ -0,0 +1,27 @@
+{
+  "nodes": {
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1778124196,
+        "narHash": "sha256-pYEytCNic/czazbV9r3tbQ6BZzqRBg/41x2dIC5ymOo=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "68a8af93ff4297686cb68880845e61e5e2e41d92",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixpkgs-unstable",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "nixpkgs": "nixpkgs"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
blob - /dev/null
blob + ef0b5ad3bfc222a9fff8740d4935f52bb89fee27 (mode 644)
--- /dev/null
+++ flake.nix
@@ -0,0 +1,28 @@
+{
+  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
+
+  outputs = {nixpkgs, ...}: {
+    packages = nixpkgs.lib.genAttrs ["x86_64-linux" "aarch64-linux"] (system: let
+      pkgs = nixpkgs.legacyPackages.${system};
+    in {
+      default = pkgs.beamPackages.mixRelease {
+        pname = "shortener";
+        version = "0.0.1";
+        src = ./.;
+
+        nativeBuildInputs = [pkgs.beamPackages.elixir_1_18];
+
+        MIX_HOME = "nix-mix";
+        HEX_HOME = "nix-hex";
+        ELIXIR_MAKE_CACHE_DIR = "nix-elixir-make";
+
+        mixFodDeps = pkgs.beamPackages.fetchMixDeps {
+          pname = "shortener-deps";
+          version = "0.0.1";
+          src = ./.;
+          sha256 = "sha256-U/tNP3s7b4bBUrQx5l4VtDHpV9DsWj6zBYj8jsM4Acw=";
+        };
+      };
+    });
+  };
+}
blob - /dev/null
blob + 87d59f64389a6b86b3177a046b36a8ef2709ec49 (mode 644)
--- /dev/null
+++ lib/shortener/application.ex
@@ -0,0 +1,37 @@
+defmodule Shortener.Application do
+  @moduledoc """
+  Shortener application entry point.
+  Starts the database and the HTTP server (Bandit).
+  """
+  use Application
+  require Logger
+
+  @impl true
+  def start(_type, _args) do
+    port = Application.fetch_env!(:shortener, :port)
+    host = Application.fetch_env!(:shortener, :host)
+    db_path = Application.fetch_env!(:shortener, :db_path)
+
+    children = [
+      {Shortener.DB, db_path},
+      {Bandit, plug: Shortener.Router, port: port, ip: parse_ip(host)}
+    ]
+
+    Logger.info("Starting shortener on #{host}:#{port}")
+
+    Supervisor.start_link(children, strategy: :one_for_one, name: Shortener.Supervisor)
+  end
+
+  @impl true
+  def stop(_state) do
+    Logger.info("Shortener application stopped")
+    :ok
+  end
+
+  defp parse_ip(ip_str) do
+    case :inet.parse_address(to_charlist(ip_str)) do
+      {:ok, ip} -> ip
+      _ -> {127, 0, 0, 1}
+    end
+  end
+end
blob - /dev/null
blob + e0bfd0736b17115e0e00fe1620397d3ee950d088 (mode 644)
--- /dev/null
+++ lib/shortener/db.ex
@@ -0,0 +1,83 @@
+defmodule Shortener.DB do
+  @moduledoc """
+  SQLite database interface using Exqlite.
+  Stores and retrieves short links.
+  """
+
+  use GenServer
+  require Logger
+
+  def start_link(db_path) do
+    GenServer.start_link(__MODULE__, db_path, name: __MODULE__)
+  end
+
+  @impl true
+  def init(db_path) do
+    Process.flag(:trap_exit, true)
+    {:ok, conn} = Exqlite.Sqlite3.open(db_path)
+
+    :ok =
+      Exqlite.Sqlite3.execute(conn, """
+        CREATE TABLE IF NOT EXISTS shirts (
+          code TEXT PRIMARY KEY,
+          url  TEXT NOT NULL,
+          created_at TEXT NOT NULL DEFAULT (datetime('now'))
+        )
+      """)
+
+    Logger.info("Database ready at #{db_path}")
+    {:ok, conn}
+  end
+
+  @doc "Insert a new short code → url mapping. Returns :ok or {:error, reason}."
+  def insert(code, url) do
+    GenServer.call(__MODULE__, {:insert, code, url})
+  end
+
+  @doc "Look up a URL by short code. Returns {:ok, url} or :not_found."
+  def lookup(code) do
+    GenServer.call(__MODULE__, {:lookup, code})
+  end
+
+  # --- server callbacks ---
+
+  @impl true
+  def handle_call({:insert, code, url}, _from, conn) do
+    result =
+      with {:ok, stmt} <-
+             Exqlite.Sqlite3.prepare(conn, "INSERT INTO shirts (code, url) VALUES (?1, ?2)"),
+           :ok <- Exqlite.Sqlite3.bind(stmt, [code, url]),
+           :done <- Exqlite.Sqlite3.step(conn, stmt) do
+        _ = Exqlite.Sqlite3.release(conn, stmt)
+        :ok
+      else
+        {:error, reason} -> {:error, reason}
+      end
+
+    {:reply, result, conn}
+  end
+
+  def handle_call({:lookup, code}, _from, conn) do
+    result =
+      with {:ok, stmt} <-
+             Exqlite.Sqlite3.prepare(conn, "SELECT url FROM shirts WHERE code = ?1"),
+           :ok <- Exqlite.Sqlite3.bind(stmt, [code]),
+           step_result <- Exqlite.Sqlite3.step(conn, stmt) do
+        _ = Exqlite.Sqlite3.release(conn, stmt)
+
+        case step_result do
+          {:row, [url]} -> {:ok, url}
+          :done -> :not_found
+          {:error, reason} -> {:error, reason}
+        end
+      end
+
+    {:reply, result, conn}
+  end
+
+  @impl true
+  def terminate(_reason, conn) do
+    Logger.info("Closing database connection")
+    Exqlite.Sqlite3.close(conn)
+  end
+end
blob - /dev/null
blob + a9074146fb1c87fffa061ea78027929c88487e4c (mode 644)
--- /dev/null
+++ lib/shortener/router.ex
@@ -0,0 +1,111 @@
+defmodule Shortener.Router do
+  @moduledoc """
+  Plug router:
+    POST /curtail  — create a short link (requires Bearer token)
+    GET  /s/:code — redirect to the original URL
+  """
+
+  use Plug.Router
+
+  plug Plug.Logger
+  plug :match
+  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),
+         code <- Map.get(conn.body_params, "code") || random_code(),
+         :ok <- Shortener.DB.insert(code, url) do
+      base_url = Application.fetch_env!(:shortener, :base_url)
+      short_url = "#{base_url}/s/#{code}"
+
+      conn
+      |> put_resp_content_type("application/json")
+      |> send_resp(201, Jason.encode!(%{short_url: short_url, code: code, url: url}))
+    else
+      {:error, :unauthorized} ->
+        json(conn, 401, %{error: "unauthorized"})
+
+      {:error, :missing_url} ->
+        json(conn, 422, %{error: "missing required field: url"})
+
+      {:error, :invalid_url} ->
+        json(conn, 422, %{error: "url must be a valid http or https URL"})
+
+      {:error, _reason} ->
+        json(conn, 409, %{error: "could not save link"})
+    end
+  end
+
+  # ---------------------------------------------------------------------------
+  # GET /s/:code  — redirect
+  # ---------------------------------------------------------------------------
+  get "/s/:code" do
+    case Shortener.DB.lookup(code) do
+      {:ok, url} ->
+        conn
+        |> put_resp_header("location", url)
+        |> send_resp(301, "")
+
+      :not_found ->
+        json(conn, 404, %{error: "not found"})
+
+      {:error, _} ->
+        json(conn, 500, %{error: "internal error"})
+    end
+  end
+
+  # ---------------------------------------------------------------------------
+  # Catch-all
+  # ---------------------------------------------------------------------------
+  match _ do
+    json(conn, 404, %{error: "not found"})
+  end
+
+  # ---------------------------------------------------------------------------
+  # Helpers
+  # ---------------------------------------------------------------------------
+
+  defp check_auth(conn) do
+    expected = "Bearer #{Application.fetch_env!(:shortener, :auth_token)}"
+
+    case get_req_header(conn, "authorization") do
+      [^expected] -> :ok
+      _ -> {:error, :unauthorized}
+    end
+  end
+
+  defp fetch_url(conn) do
+    case conn.body_params do
+      %{"url" => url} when is_binary(url) and url != "" ->
+        uri = URI.parse(url)
+
+        if uri.scheme in ["http", "https"] and is_binary(uri.host) and uri.host != "" do
+          {:ok, url}
+        else
+          {:error, :invalid_url}
+        end
+
+      _ ->
+        {:error, :missing_url}
+    end
+  end
+
+  defp random_code do
+    :crypto.strong_rand_bytes(6)
+    |> Base.url_encode64(padding: false)
+  end
+
+  defp json(conn, status, body) do
+    conn
+    |> put_resp_content_type("application/json")
+    |> send_resp(status, Jason.encode!(body))
+  end
+end
blob - /dev/null
blob + ee4ba2d4b7e632de2bd5302f8988089923f3ec47 (mode 644)
--- /dev/null
+++ mix.exs
@@ -0,0 +1,31 @@
+defmodule Shortener.MixProject do
+  use Mix.Project
+
+  def project do
+    [
+      app: :shortener,
+      version: "0.0.1",
+      elixir: "~> 1.18",
+      start_permanent: Mix.env() == :prod,
+      deps: deps()
+    ]
+  end
+
+  def application do
+    [
+      extra_applications: [:logger],
+      mod: {Shortener.Application, []}
+    ]
+  end
+
+  defp deps do
+    [
+      {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
+      {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
+      {:bandit, "~> 1.10"},
+      {:plug, "~> 1.19"},
+      {:exqlite, "~> 0.35"},
+      {:jason, "~> 1.4"}
+    ]
+  end
+end
blob - /dev/null
blob + 87b1dab30aff03a7b254e304adfac3daaec6b0bc (mode 644)
--- /dev/null
+++ mix.lock
@@ -0,0 +1,27 @@
+%{
+  "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"},
+  "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
+  "burrito": {:hex, :burrito, "1.5.0", "d68ec01df2871f1d5bc603b883a78546c75761ac73c1bec1b7ae2cc74790fcd1", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, ">= 0.5.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "3861abda7bffa733862b48da3e03df0b4cd41abf6fd24b91745f5c16d971e5fa"},
+  "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
+  "credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"},
+  "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
+  "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
+  "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
+  "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
+  "exqlite": {:hex, :exqlite, "0.35.0", "90741471945db42b66cd8ca3149af317f00c22c769cc6b06e8b0a08c5924aae5", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a009e303767a28443e546ac8aab2539429f605e9acdc38bd43f3b13f1568bca9"},
+  "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
+  "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
+  "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
+  "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
+  "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
+  "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
+  "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
+  "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
+  "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
+  "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
+  "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
+  "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
+  "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
+  "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"},
+  "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
+}