Browse Source

PR corrections

test/local-setup
Silberengel 3 weeks ago
parent
commit
3e376642d2
  1. 15
      .env.prod.example
  2. 8
      .gitignore
  3. 8
      README.md
  4. 118
      compose.prod.yml
  5. 2
      compose.yaml
  6. 8
      config/config.exs
  7. 4
      config/prod.exs
  8. 31
      config/runtime.exs
  9. 50
      dev.sh
  10. 64
      lib/gc_index_relay_web/plugs/cors.ex
  11. 12
      lib/gc_index_relay_web/plugs/relay_info.ex
  12. 89
      scripts/deploy_prod.sh
  13. 85
      test/gc_index_relay_web/plugs/cors_plug_test.exs
  14. 5
      test/gc_index_relay_web/relay_unit_test.exs

15
.env.prod.example

@ -1,15 +0,0 @@
# Copy to .env.prod, fill in, then: set -a && source .env.prod && set +a
# Or use: export $(grep -v '^#' .env.prod | xargs)
POSTGRES_HOST=postgres
POSTGRES_DB=gc_index_relay_prod
POSTGRES_USER=postgres
POSTGRES_PASSWORD=change-me
POSTGRES_RUNTIME_USER=gc_index_relay
POSTGRES_RUNTIME_PASSWORD=change-me-runtime
SECRET_KEY_BASE=generate-with-mix-phx-gen-secret
PHX_HOST=gc-http-relay.imwald.eu
# Optional: pin release image (default latest)
# TAG=0.2.0

8
.gitignore vendored

@ -32,5 +32,13 @@ gc_index_relay-*.tar
# Secrets -- copy from .env.example and fill in values # Secrets -- copy from .env.example and fill in values
.env .env
# Personal / production deploy layout (Docker Hub, server env, etc.) — keep locally, not in git
compose.prod.yml
compose.prod.override.yml
scripts/deploy_prod.sh
.env.prod
.env.prod.example
.env.production
# Local Postgres data # Local Postgres data
/pgdata/ /pgdata/

8
README.md

@ -64,13 +64,7 @@ source .env
mix phx.server mix phx.server
``` ```
The relay is available at [http://localhost:4000](http://localhost:4000). The relay is available at [http://localhost:4000](http://localhost:4000). After edits to `config/config.exs`, `config/dev.exs`, or `config/runtime.exs`, restart the server manually; other code is hot-reloaded.
During development, use `dev.sh` instead to get automatic server restarts when config files change:
```bash
source .env && ./dev.sh
```
## API ## API

118
compose.prod.yml

@ -1,118 +0,0 @@
# Production stack: Apache (on the host) terminates TLS and proxies to 127.0.0.1:4000.
#
# Helper script: ./scripts/deploy_prod.sh --help
#
# --- Local: build and push ---
# cp .env.prod.example .env.prod && edit secrets
# export TAG=0.2.0 # optional; relay/migrator use :latest if unset
# docker login
# ./scripts/deploy_prod.sh build-push
#
# --- Remote: pull and run ---
# ./scripts/deploy_prod.sh deploy
#
# Images (repository: silberengel/gc-http-relay):
# :${TAG} — Phoenix release (relay + migrator)
# :setup — one-shot DB user bootstrap (tag is literal "setup")
services:
postgres:
image: docker.io/apache/age:release_PG17_1.6.0
restart: unless-stopped
user: 1000:1000
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB in .env.prod}
POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env.prod}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env.prod}
command: >
postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
cpus: "1.00"
memory: 1G
reservations:
cpus: "0.50"
memory: 512M
networks:
- internal
setup:
image: docker.io/silberengel/gc-http-relay:setup
build:
context: .
dockerfile: ./docker/setup.Dockerfile
command: ["/usr/local/bin/usersetup.sh"]
restart: "no"
depends_on:
postgres:
condition: service_healthy
environment:
POSTGRES_HOST: ${POSTGRES_HOST:-postgres}
POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env.prod}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env.prod}
POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB in .env.prod}
POSTGRES_RUNTIME_USER: ${POSTGRES_RUNTIME_USER:?set POSTGRES_RUNTIME_USER in .env.prod}
POSTGRES_RUNTIME_PASSWORD: ${POSTGRES_RUNTIME_PASSWORD:?set POSTGRES_RUNTIME_PASSWORD in .env.prod}
networks:
- internal
migrator:
image: docker.io/silberengel/gc-http-relay:${TAG:-latest}
build:
context: .
dockerfile: ./docker/server.Dockerfile
command: ["/app/bin/migrate"]
restart: "no"
depends_on:
postgres:
condition: service_healthy
setup:
condition: service_completed_successfully
environment:
DATABASE_URL: "ecto://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-postgres}/${POSTGRES_DB}"
SECRET_KEY_BASE: ${SECRET_KEY_BASE:?set SECRET_KEY_BASE in .env.prod}
networks:
- internal
relay:
image: docker.io/silberengel/gc-http-relay:${TAG:-latest}
build:
context: .
dockerfile: ./docker/server.Dockerfile
command: ["/app/bin/server"]
restart: unless-stopped
deploy:
resources:
limits:
cpus: "1.00"
memory: 1G
reservations:
cpus: "0.50"
memory: 512M
depends_on:
postgres:
condition: service_healthy
migrator:
condition: service_completed_successfully
ports:
- "127.0.0.1:4000:4000"
environment:
DATABASE_URL: "ecto://${POSTGRES_RUNTIME_USER}:${POSTGRES_RUNTIME_PASSWORD}@${POSTGRES_HOST:-postgres}/${POSTGRES_DB}"
SECRET_KEY_BASE: ${SECRET_KEY_BASE:?set SECRET_KEY_BASE in .env.prod}
PHX_HOST: ${PHX_HOST:?set PHX_HOST in .env.prod (public hostname, no scheme)}
networks:
- internal
networks:
internal:
volumes:
pgdata:

2
compose.yaml

@ -1,7 +1,7 @@
services: services:
postgres: postgres:
image: docker.io/apache/age:release_PG17_1.6.0 image: docker.io/apache/age:release_PG17_1.6.0
container_name: postgres_01 container_name: postgress_01
restart: unless-stopped restart: unless-stopped
user: 1000:1000 # Should match host user user: 1000:1000 # Should match host user
ports: ports:

8
config/config.exs

@ -11,6 +11,14 @@ config :gc_index_relay,
ecto_repos: [GcIndexRelay.Repo], ecto_repos: [GcIndexRelay.Repo],
generators: [timestamp_type: :utc_datetime] generators: [timestamp_type: :utc_datetime]
# In production, set `CORS_ENABLED=false` (runtime) or disable here when a reverse proxy
# adds CORS. Use `allow_origins: ["https://app.example.com"]` to restrict browser access.
config :gc_index_relay, :cors,
enabled: true,
allow_origins: "*",
allow_methods: "GET, POST, DELETE, OPTIONS",
allow_headers: "content-type, authorization"
# Configure the endpoint # Configure the endpoint
config :gc_index_relay, GcIndexRelayWeb.Endpoint, config :gc_index_relay, GcIndexRelayWeb.Endpoint,
url: [host: "localhost"], url: [host: "localhost"],

4
config/prod.exs

@ -24,5 +24,9 @@ config :swoosh, local: false
# Do not print debug messages in production # Do not print debug messages in production
config :logger, level: :info config :logger, level: :info
# In-app CORS: disable when your L7 proxy sets CORS headers, e.g.:
# config :gc_index_relay, :cors, enabled: false
# Or use CORS_ENABLED / CORS_ALLOW_ORIGINS in runtime.exs.
# Runtime production configuration, including reading # Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs. # of environment variables, is done on config/runtime.exs.

31
config/runtime.exs

@ -75,6 +75,37 @@ if config_env() == :prod do
], ],
secret_key_base: secret_key_base secret_key_base: secret_key_base
cors_base = Application.get_env(:gc_index_relay, :cors) || []
cors_enabled =
case System.get_env("CORS_ENABLED") do
nil -> Keyword.get(cors_base, :enabled, true)
v -> String.downcase(String.trim(v)) in ~w(true 1 yes)
end
allow_origins =
case System.get_env("CORS_ALLOW_ORIGINS") do
nil ->
Keyword.get(cors_base, :allow_origins, "*")
raw ->
raw
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> case do
[] -> "*"
["*"] -> "*"
list -> list
end
end
config :gc_index_relay,
:cors,
cors_base
|> Keyword.put(:enabled, cors_enabled)
|> Keyword.put(:allow_origins, allow_origins)
# ## SSL Support # ## SSL Support
# #
# To get SSL working, you will need to add the `https` key # To get SSL working, you will need to add the `https` key

50
dev.sh

@ -1,50 +0,0 @@
#!/usr/bin/env bash
# Development server with auto-restart on config file changes.
#
# Normal code changes (controllers, templates, etc.) are still hot-reloaded
# by Phoenix automatically. This script only handles the cases Phoenix can't:
# config/config.exs, config/dev.exs, and config/runtime.exs.
#
# Usage:
# chmod +x dev.sh
# source .env && ./dev.sh
set -euo pipefail
CONFIG_FILES=(
config/config.exs
config/dev.exs
config/runtime.exs
)
cleanup() {
if [ -n "${SERVER_PID:-}" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
echo ""
echo "[dev] Stopping server (pid $SERVER_PID)..."
kill "$SERVER_PID"
wait "$SERVER_PID" 2>/dev/null || true
fi
exit 0
}
trap cleanup INT TERM
echo "[dev] Watching config files for changes: ${CONFIG_FILES[*]}"
echo "[dev] Normal code changes are still hot-reloaded automatically."
echo "[dev] Press Ctrl+C to stop."
echo ""
while true; do
echo "[dev] Starting server..."
mix phx.server &
SERVER_PID=$!
# Block until any config file is modified
inotifywait -q -e modify "${CONFIG_FILES[@]}" 2>/dev/null
echo ""
echo "[dev] Config changed — restarting server..."
kill "$SERVER_PID" 2>/dev/null
wait "$SERVER_PID" 2>/dev/null || true
echo ""
done

64
lib/gc_index_relay_web/plugs/cors.ex

@ -2,9 +2,14 @@ defmodule GcIndexRelayWeb.Plugs.CORS do
@moduledoc """ @moduledoc """
CORS plug for the Nostr relay REST API. CORS plug for the Nostr relay REST API.
Nostr clients can be hosted on any origin, so this plug allows all origins Configure under `:cors` for `:gc_index_relay` (see `config/config.exs`).
on all API routes. Preflight OPTIONS requests are halted here and returned
a 200 before they reach the router. * `enabled` when `false`, the plug is a no-op (use when a reverse proxy handles CORS).
* `allow_origins` `\"*\"` or a list of exact `Origin` values to echo back.
* `allow_methods` / `allow_headers` forwarded as response headers when CORS applies.
Preflight `OPTIONS` requests are halted with 200 when CORS is enabled; when disabled,
they continue to the router.
""" """
import Plug.Conn import Plug.Conn
@ -12,13 +17,56 @@ defmodule GcIndexRelayWeb.Plugs.CORS do
def init(opts), do: opts def init(opts), do: opts
def call(conn, _opts) do def call(conn, _opts) do
conn cors = Application.get_env(:gc_index_relay, :cors, [])
|> put_resp_header("access-control-allow-origin", "*")
|> put_resp_header("access-control-allow-methods", "GET, POST, DELETE, OPTIONS") if Keyword.get(cors, :enabled, true) do
|> put_resp_header("access-control-allow-headers", "content-type, authorization") allow_origins = Keyword.get(cors, :allow_origins, "*")
|> handle_preflight() allow_methods = Keyword.get(cors, :allow_methods, "GET, POST, DELETE, OPTIONS")
allow_headers = Keyword.get(cors, :allow_headers, "content-type, authorization")
origin_value = resolve_allow_origin(conn, allow_origins)
if origin_value == nil && restrictive_allowlist?(allow_origins) do
handle_preflight(conn)
else
conn
|> put_resp_header("access-control-allow-origin", origin_value)
|> put_resp_header("access-control-allow-methods", allow_methods)
|> put_resp_header("access-control-allow-headers", allow_headers)
|> handle_preflight()
end
else
conn
end
end end
defp resolve_allow_origin(_conn, "*"), do: "*"
defp resolve_allow_origin(conn, allow_origins) when is_list(allow_origins) do
cond do
"*" in allow_origins ->
"*"
Enum.any?(allow_origins, &(&1 == "*")) ->
"*"
true ->
case get_req_header(conn, "origin") do
[origin] -> if Enum.member?(allow_origins, origin), do: origin, else: nil
_ -> nil
end
end
end
defp resolve_allow_origin(_conn, _), do: "*"
defp restrictive_allowlist?("*"), do: false
defp restrictive_allowlist?(list) when is_list(list) do
"*" not in list
end
defp restrictive_allowlist?(_), do: false
defp handle_preflight(%Plug.Conn{method: "OPTIONS"} = conn) do defp handle_preflight(%Plug.Conn{method: "OPTIONS"} = conn) do
conn conn
|> send_resp(200, "") |> send_resp(200, "")

12
lib/gc_index_relay_web/plugs/relay_info.ex

@ -58,8 +58,12 @@ defmodule GcIndexRelayWeb.Plugs.RelayInfo do
defp resolve_field(map, key, base_url) do defp resolve_field(map, key, base_url) do
case Map.get(map, key) do case Map.get(map, key) do
nil -> map nil ->
"" -> map map
"" ->
map
url when is_binary(url) -> url when is_binary(url) ->
if String.starts_with?(url, ["http://", "https://"]) do if String.starts_with?(url, ["http://", "https://"]) do
map map
@ -67,7 +71,9 @@ defmodule GcIndexRelayWeb.Plugs.RelayInfo do
path = if String.starts_with?(url, "/"), do: url, else: "/#{url}" path = if String.starts_with?(url, "/"), do: url, else: "/#{url}"
Map.put(map, key, "#{base_url}#{path}") Map.put(map, key, "#{base_url}#{path}")
end end
_ -> map
_ ->
map
end end
end end
end end

89
scripts/deploy_prod.sh

@ -1,89 +0,0 @@
#!/usr/bin/env bash
# Production Docker Compose helper — run ./scripts/deploy_prod.sh --help
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
COMPOSE="${COMPOSE:-docker compose}"
COMPOSE_FILE="${COMPOSE_FILE:-compose.prod.yml}"
ENV_FILE="${ENV_FILE:-.env.prod}"
APP_IMAGES=(setup relay migrator)
usage() {
cat <<'EOF'
Production Docker Compose helper (compose.prod.yml).
./scripts/deploy_prod.sh build build app images (setup, relay, migrator)
./scripts/deploy_prod.sh push push those images (run docker login first)
./scripts/deploy_prod.sh build-push build then push
./scripts/deploy_prod.sh pull pull images
./scripts/deploy_prod.sh up start stack (detached)
./scripts/deploy_prod.sh deploy pull then up -d (typical on server)
./scripts/deploy_prod.sh down stop stack
./scripts/deploy_prod.sh ps docker compose ps
./scripts/deploy_prod.sh logs [svc] follow logs (default: all services)
Env: ENV_FILE (default .env.prod), COMPOSE_FILE, TAG, COMPOSE (default "docker compose")
EOF
exit "${1:-0}"
}
require_env_file() {
if [[ ! -f "$ENV_FILE" ]]; then
echo "error: missing env file: $ENV_FILE" >&2
echo " cp .env.prod.example .env.prod && edit, or set ENV_FILE=..." >&2
exit 1
fi
}
compose() {
require_env_file
$COMPOSE --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
}
cmd="${1:-}"
[[ -z "$cmd" ]] && usage 1
[[ "$cmd" == "-h" || "$cmd" == "--help" ]] && usage 0
shift || true
case "$cmd" in
help)
usage 0
;;
build)
compose build "${APP_IMAGES[@]}"
;;
push)
compose push "${APP_IMAGES[@]}"
;;
build-push)
compose build "${APP_IMAGES[@]}"
compose push "${APP_IMAGES[@]}"
;;
pull)
compose pull
;;
up)
compose up -d
;;
deploy)
compose pull
compose up -d
;;
down)
compose down
;;
ps)
compose ps "$@"
;;
logs)
compose logs -f "$@"
;;
*)
echo "error: unknown command: $cmd" >&2
usage 1
;;
esac

85
test/gc_index_relay_web/plugs/cors_plug_test.exs

@ -0,0 +1,85 @@
defmodule GcIndexRelayWeb.Plugs.CorsPlugTest do
@moduledoc false
use GcIndexRelayWeb.ConnCase, async: false
@moduletag :unit
setup do
previous = Application.get_env(:gc_index_relay, :cors)
on_exit(fn -> Application.put_env(:gc_index_relay, :cors, previous) end)
{:ok, previous_cors: previous}
end
describe "allow_origins allowlist" do
setup %{previous_cors: previous} do
base = previous || []
Application.put_env(
:gc_index_relay,
:cors,
Keyword.merge(base,
enabled: true,
allow_origins: ["https://client.example.com"]
)
)
:ok
end
test "reflects Origin when it matches the allowlist", %{conn: conn} do
conn =
conn
|> put_req_header("accept", "application/json")
|> put_req_header("origin", "https://client.example.com")
|> get(~p"/api")
assert get_resp_header(conn, "access-control-allow-origin") == [
"https://client.example.com"
]
end
test "omits Access-Control-Allow-Origin when Origin does not match", %{conn: conn} do
conn =
conn
|> put_req_header("accept", "application/json")
|> put_req_header("origin", "https://evil.example.com")
|> get(~p"/api")
assert get_resp_header(conn, "access-control-allow-origin") == []
end
test "preflight without matching Origin has no CORS allow headers", %{conn: conn} do
conn =
conn
|> put_req_header("origin", "https://evil.example.com")
|> options("/api/events")
assert conn.status == 200
assert conn.resp_body == ""
assert get_resp_header(conn, "access-control-allow-origin") == []
end
end
describe "CORS disabled" do
setup %{previous_cors: previous} do
base = previous || []
Application.put_env(:gc_index_relay, :cors, Keyword.merge(base, enabled: false))
:ok
end
test "does not set CORS headers on GET", %{conn: conn} do
conn =
conn
|> put_req_header("accept", "application/json")
|> get(~p"/api")
assert get_resp_header(conn, "access-control-allow-origin") == []
end
test "does not handle OPTIONS preflight (no CORS headers)", %{conn: conn} do
conn = options(conn, "/api/events")
assert get_resp_header(conn, "access-control-allow-origin") == []
end
end
end

5
test/gc_index_relay_web/relay_unit_test.exs

@ -71,8 +71,11 @@ defmodule GcIndexRelayWeb.RelayUnitTest do
end end
test "relay includes CORS headers on a POST response", %{conn: conn} do test "relay includes CORS headers on a POST response", %{conn: conn} do
conn = post(conn, ~p"/api/events/filter", %{"limit" => 10}) # Reject before DB (NIP-70 protected) so unit tests need no running Repo.
event = valid_pub_event_fixture(%{tags: [["-"]]})
conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)})
assert json_response(conn, 400)
assert get_resp_header(conn, "access-control-allow-origin") == ["*"] assert get_resp_header(conn, "access-control-allow-origin") == ["*"]
end end

Loading…
Cancel
Save