Compare commits
No commits in common. 'test/local-setup' and 'master' have entirely different histories.
test/local
...
master
61 changed files with 468 additions and 2113 deletions
@ -1,14 +0,0 @@
@@ -1,14 +0,0 @@
|
||||
# Copy this file to .env and fill in values, then run: source .env |
||||
# The defaults below match setup.sh and compose.yaml: Postgres on host port 5455 (maps to 5432 in the container). |
||||
|
||||
export POSTGRES_HOST=localhost |
||||
export POSTGRES_PORT=5455 |
||||
export POSTGRES_USER=postgres |
||||
export POSTGRES_PASSWORD=postgres |
||||
export POSTGRES_DB=gc_index_relay_dev |
||||
export REQUIRE_DB=true |
||||
|
||||
# Optional — only if you use docker compose for setup/migrator/mercury (override compose defaults): |
||||
# export POSTGRES_RUNTIME_USER=gc_index_relay |
||||
# export POSTGRES_RUNTIME_PASSWORD=gc_index_relay_runtime |
||||
# export SECRET_KEY_BASE="$(mix phx.gen.secret)" |
||||
@ -1,8 +1,9 @@
@@ -1,8 +1,9 @@
|
||||
defmodule GcIndexRelay do |
||||
@moduledoc """ |
||||
Mercury Index-Relay — a Nostr relay by GitCitadel. |
||||
GcIndexRelay keeps the contexts that define your domain |
||||
and business logic. |
||||
|
||||
Organises the application's domain contexts: event storage, validation, |
||||
filtering, and querying via `GcIndexRelay.Nostr`. |
||||
Contexts are also responsible for managing your data, regardless |
||||
if it comes from the database, an external API or others. |
||||
""" |
||||
end |
||||
|
||||
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
defmodule GcIndexRelay.Mailer do |
||||
use Swoosh.Mailer, otp_app: :gc_index_relay |
||||
end |
||||
@ -1,30 +0,0 @@
@@ -1,30 +0,0 @@
|
||||
defmodule GcIndexRelayWeb.ApiController do |
||||
use GcIndexRelayWeb, :controller |
||||
|
||||
def index(conn, _params) do |
||||
relay_info = Application.fetch_env!(:gc_index_relay, :relay_info) |
||||
|
||||
json(conn, %{ |
||||
relay: Keyword.fetch!(relay_info, :name), |
||||
version: |
||||
Keyword.get(relay_info, :version, Application.spec(:gc_index_relay, :vsn)) |> to_string(), |
||||
endpoints: [ |
||||
%{ |
||||
method: "GET", |
||||
path: "/api/events", |
||||
description: "List events (requires filter params)" |
||||
}, |
||||
%{ |
||||
method: "POST", |
||||
path: "/api/events/filter", |
||||
description: "Query events with a NIP-01 filter body" |
||||
}, |
||||
%{method: "GET", path: "/api/events/:id", description: "Get a single event by ID"}, |
||||
%{method: "POST", path: "/api/events", description: "Publish a new event"}, |
||||
%{method: "DELETE", path: "/api/events/:id", description: "Delete an event by ID"}, |
||||
%{method: "GET", path: "/api/swagger", description: "Swagger UI"}, |
||||
%{method: "GET", path: "/health", description: "Health check"} |
||||
] |
||||
}) |
||||
end |
||||
end |
||||
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
defmodule GcIndexRelayWeb.EventHTML do |
||||
use GcIndexRelayWeb, :html |
||||
|
||||
embed_templates "event_html/*" |
||||
end |
||||
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
<Layouts.app flash={@flash}> |
||||
<section> |
||||
<h2>New Event</h2> |
||||
<%!-- TODO: Display form for event details --%> |
||||
</section> |
||||
</Layouts.app> |
||||
@ -1,123 +1,201 @@
@@ -1,123 +1,201 @@
|
||||
<Layouts.flash_group flash={@flash} /> |
||||
<div style="max-width:480px; margin:0 auto; padding:2rem 1.25rem;"> |
||||
<div style="text-align:center; margin-bottom:2rem;"> |
||||
<img |
||||
src={~p"/images/mercury_icon.png"} |
||||
alt="Mercury Index-Relay" |
||||
style="width:112px; height:112px; margin:0 auto 1rem;" |
||||
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]"> |
||||
<svg |
||||
viewBox="0 0 1480 957" |
||||
fill="none" |
||||
aria-hidden="true" |
||||
class="absolute inset-0 h-full w-full" |
||||
preserveAspectRatio="xMinYMid slice" |
||||
> |
||||
<path fill="#EE7868" d="M0 0h1480v957H0z" /> |
||||
<path |
||||
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z" |
||||
fill="#FF9F92" |
||||
/> |
||||
<h1 style="font-size:1.5rem; font-weight:700; margin-bottom:0.25rem;"> |
||||
Mercury Index-Relay |
||||
</h1> |
||||
<p style="font-size:0.8rem; opacity:0.6; margin-bottom:0.75rem;"> |
||||
by GitCitadel · v{Application.spec(:gc_index_relay, :vsn)} |
||||
</p> |
||||
<p style="font-size:0.9rem; line-height:1.6;"> |
||||
A Nostr index relay for the HTTP protocol. Specialises in swift retrieval |
||||
of publications, repos, and graphs of related events. |
||||
</p> |
||||
</div> |
||||
|
||||
<div style="margin-bottom:2rem;"> |
||||
<a |
||||
href={~p"/api/swagger"} |
||||
class="btn btn-primary" |
||||
style="display:block; width:100%; margin-bottom:0.5rem;" |
||||
> |
||||
Swagger UI |
||||
</a> |
||||
<a href={~p"/api"} class="btn btn-outline" style="display:block; width:100%;"> |
||||
API Index |
||||
</a> |
||||
</div> |
||||
|
||||
<div style="margin-bottom:2rem; display:flex; flex-direction:column; gap:0.75rem;"> |
||||
<div class="rounded-box border border-base-300" style="padding:0.875rem;"> |
||||
<strong style="font-size:0.875rem;">REST API</strong> |
||||
<p style="font-size:0.8rem; margin-top:0.25rem;"> |
||||
Full event lifecycle over HTTP — publish, query, fetch by ID, delete. |
||||
</p> |
||||
</div> |
||||
<div class="rounded-box border border-base-300" style="padding:0.875rem;"> |
||||
<strong style="font-size:0.875rem;">NIP-01 Filters</strong> |
||||
<p style="font-size:0.8rem; margin-top:0.25rem;"> |
||||
Filter by kind, author, tags, and time window. Supports #p, #e, and more. |
||||
</p> |
||||
</div> |
||||
<div class="rounded-box border border-base-300" style="padding:0.875rem;"> |
||||
<strong style="font-size:0.875rem;">NIP-11 & NIP-70</strong> |
||||
<p style="font-size:0.8rem; margin-top:0.25rem;"> |
||||
Relay info at <code>GET /</code> with <code>Accept: application/nostr+json</code>. |
||||
Protected events are rejected. |
||||
</p> |
||||
<path |
||||
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z" |
||||
fill="#FA8372" |
||||
/> |
||||
<path |
||||
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z" |
||||
fill="#E96856" |
||||
fill-opacity=".6" |
||||
/> |
||||
<path |
||||
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z" |
||||
fill="#C42652" |
||||
fill-opacity=".2" |
||||
/> |
||||
<path |
||||
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z" |
||||
fill="#A41C42" |
||||
fill-opacity=".2" |
||||
/> |
||||
<path |
||||
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z" |
||||
fill="#A41C42" |
||||
fill-opacity=".2" |
||||
/> |
||||
</svg> |
||||
</div> |
||||
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32"> |
||||
<div class="mx-auto max-w-xl lg:mx-0"> |
||||
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true"> |
||||
<path |
||||
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z" |
||||
fill="#FD4F00" |
||||
/> |
||||
</svg> |
||||
<div class="mt-10 flex justify-between items-center"> |
||||
<h1 class="flex items-center text-sm font-semibold leading-6"> |
||||
Phoenix Framework |
||||
<small class="badge badge-warning badge-sm ml-3"> |
||||
v{Application.spec(:phoenix, :vsn)} |
||||
</small> |
||||
</h1> |
||||
</div> |
||||
</div> |
||||
|
||||
<div style="margin-bottom:2rem;"> |
||||
<p style="font-size:0.7rem; font-weight:600; opacity:0.55; text-transform:uppercase; letter-spacing:0.08em; margin-bottom:0.5rem;"> |
||||
Endpoints |
||||
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance"> |
||||
Peace of mind from prototype to production. |
||||
</p> |
||||
<p class="mt-4 leading-7 text-base-content/70"> |
||||
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. |
||||
</p> |
||||
<div |
||||
class="rounded-box border border-base-300" |
||||
style="font-size:0.8rem; overflow:hidden;" |
||||
> |
||||
<%= for {method, path, desc} <- [ |
||||
{"GET", "/api", "List available endpoints"}, |
||||
{"GET", "/api/events", "Cacheable query (since, until, limit)"}, |
||||
{"POST", "/api/events/filter", "Filter query with JSON body"}, |
||||
{"GET", "/api/events/:id", "Fetch a single event by ID"}, |
||||
{"POST", "/api/events", "Publish a new event"}, |
||||
{"DELETE", "/api/events/:id", "Delete an event by ID"}, |
||||
{"GET", "/api/swagger", "Interactive Swagger UI"} |
||||
] do %> |
||||
<div style="padding:0.5rem 0.875rem; border-bottom:1px solid var(--fallback-b3,oklch(var(--b3)/1));"> |
||||
<p style="font-family:monospace; font-size:0.75rem; opacity:0.7;">{method} {path}</p> |
||||
<p style="margin-top:0.15rem;">{desc}</p> |
||||
<div class="flex"> |
||||
<div class="w-full sm:w-auto"> |
||||
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3"> |
||||
<a |
||||
href="https://hexdocs.pm/phoenix/overview.html" |
||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6" |
||||
> |
||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105"> |
||||
</span> |
||||
<span class="relative flex items-center gap-4 sm:flex-col"> |
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6"> |
||||
<path d="m12 4 10-2v18l-10 2V4Z" fill="currentColor" fill-opacity=".15" /> |
||||
<path |
||||
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2" |
||||
stroke="currentColor" |
||||
stroke-width="2" |
||||
stroke-linecap="round" |
||||
stroke-linejoin="round" |
||||
/> |
||||
</svg> |
||||
Guides & Docs |
||||
</span> |
||||
</a> |
||||
<a |
||||
href="https://github.com/phoenixframework/phoenix" |
||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6" |
||||
> |
||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105"> |
||||
</span> |
||||
<span class="relative flex items-center gap-4 sm:flex-col"> |
||||
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6"> |
||||
<path |
||||
fill="currentColor" |
||||
fill-rule="evenodd" |
||||
clip-rule="evenodd" |
||||
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z" |
||||
/> |
||||
</svg> |
||||
Source Code |
||||
</span> |
||||
</a> |
||||
<a |
||||
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"} |
||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6" |
||||
> |
||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105"> |
||||
</span> |
||||
<span class="relative flex items-center gap-4 sm:flex-col"> |
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6"> |
||||
<path |
||||
d="M12 1v6M12 17v6" |
||||
stroke="currentColor" |
||||
stroke-width="2" |
||||
stroke-linecap="round" |
||||
stroke-linejoin="round" |
||||
/> |
||||
<circle |
||||
cx="12" |
||||
cy="12" |
||||
r="4" |
||||
fill="currentColor" |
||||
fill-opacity=".15" |
||||
stroke="currentColor" |
||||
stroke-width="2" |
||||
stroke-linecap="round" |
||||
stroke-linejoin="round" |
||||
/> |
||||
</svg> |
||||
Changelog |
||||
</span> |
||||
</a> |
||||
</div> |
||||
<% end %> |
||||
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-base-content/80 sm:grid-cols-2"> |
||||
<div> |
||||
<a |
||||
href="https://elixirforum.com" |
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content" |
||||
> |
||||
<svg |
||||
viewBox="0 0 16 16" |
||||
aria-hidden="true" |
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content" |
||||
> |
||||
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" /> |
||||
</svg> |
||||
Discuss on the Elixir Forum |
||||
</a> |
||||
</div> |
||||
<div> |
||||
<a |
||||
href="https://discord.gg/elixir" |
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content" |
||||
> |
||||
<svg |
||||
viewBox="0 0 16 16" |
||||
aria-hidden="true" |
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content" |
||||
> |
||||
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" /> |
||||
</svg> |
||||
Join our Discord server |
||||
</a> |
||||
</div> |
||||
<div> |
||||
<a |
||||
href="https://elixir-slack.community/" |
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content" |
||||
> |
||||
<svg |
||||
viewBox="0 0 16 16" |
||||
aria-hidden="true" |
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content" |
||||
> |
||||
<path d="M3.361 10.11a1.68 1.68 0 1 1-1.68-1.681h1.68v1.682ZM4.209 10.11a1.68 1.68 0 1 1 3.361 0v4.21a1.68 1.68 0 1 1-3.361 0v-4.21ZM5.89 3.361a1.68 1.68 0 1 1 1.681-1.68v1.68H5.89ZM5.89 4.209a1.68 1.68 0 1 1 0 3.361H1.68a1.68 1.68 0 1 1 0-3.361h4.21ZM12.639 5.89a1.68 1.68 0 1 1 1.68 1.681h-1.68V5.89ZM11.791 5.89a1.68 1.68 0 1 1-3.361 0V1.68a1.68 1.68 0 0 1 3.361 0v4.21ZM10.11 12.639a1.68 1.68 0 1 1-1.681 1.68v-1.68h1.682ZM10.11 11.791a1.68 1.68 0 1 1 0-3.361h4.21a1.68 1.68 0 1 1 0 3.361h-4.21Z" /> |
||||
</svg> |
||||
Join us on Slack |
||||
</a> |
||||
</div> |
||||
<div> |
||||
<a |
||||
href="https://fly.io/docs/elixir/getting-started/" |
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content" |
||||
> |
||||
<svg |
||||
viewBox="0 0 20 20" |
||||
aria-hidden="true" |
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content" |
||||
> |
||||
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" /> |
||||
</svg> |
||||
Deploy your application |
||||
</a> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<footer style="text-align:center; font-size:0.75rem; opacity:0.6; padding-top:1rem; border-top:1px solid oklch(var(--b3)/1);"> |
||||
<a |
||||
href="https://gitcitadel.com" |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
class="hover:opacity-100" |
||||
style="display:inline-flex; align-items:center; gap:0.3rem; vertical-align:middle;" |
||||
> |
||||
<img |
||||
src={~p"/images/gitcitadel_icon.png"} |
||||
alt="" |
||||
style="width:16px; height:16px;" |
||||
/>GitCitadel |
||||
</a> |
||||
· |
||||
<a |
||||
href="https://git.imwald.eu/silberengel/gc_index_relay" |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
class="hover:opacity-100" |
||||
> |
||||
Relay Repo |
||||
</a> |
||||
· |
||||
<a |
||||
href="https://github.com/ShadowySupercode" |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
class="hover:opacity-100" |
||||
> |
||||
GitCitadel on GitHub |
||||
</a> |
||||
· |
||||
<a |
||||
href="https://alexandria.gitcitadel.eu/events?q=GitCitadel%40gitcitadel.com" |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
class="hover:opacity-100" |
||||
> |
||||
GitCitadel on Nostr |
||||
</a> |
||||
</footer> |
||||
</div> |
||||
|
||||
@ -1,80 +0,0 @@
@@ -1,80 +0,0 @@
|
||||
defmodule GcIndexRelayWeb.Plugs.CORS do |
||||
@moduledoc """ |
||||
CORS plug for the Nostr relay REST API. |
||||
|
||||
Configure under `:cors` for `:gc_index_relay` (see `config/config.exs`). |
||||
|
||||
* `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 |
||||
|
||||
def init(opts), do: opts |
||||
|
||||
def call(conn, _opts) do |
||||
cors = Application.get_env(:gc_index_relay, :cors, []) |
||||
|
||||
if Keyword.get(cors, :enabled, true) do |
||||
allow_origins = Keyword.get(cors, :allow_origins, "*") |
||||
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 |
||||
|
||||
defp resolve_allow_origin(_conn, "*"), do: "*" |
||||
|
||||
defp resolve_allow_origin(conn, allow_origins) when is_list(allow_origins) do |
||||
if "*" in allow_origins do |
||||
"*" |
||||
else |
||||
origin_from_allowlist(conn, allow_origins) |
||||
end |
||||
end |
||||
|
||||
defp resolve_allow_origin(_conn, _), do: "*" |
||||
|
||||
defp origin_from_allowlist(conn, allow_origins) do |
||||
case get_req_header(conn, "origin") do |
||||
[origin] -> origin_if_allowed(origin, allow_origins) |
||||
_ -> nil |
||||
end |
||||
end |
||||
|
||||
defp origin_if_allowed(origin, allow_origins) do |
||||
if Enum.member?(allow_origins, origin), do: origin, else: nil |
||||
end |
||||
|
||||
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 |
||||
conn |
||||
|> send_resp(200, "") |
||||
|> halt() |
||||
end |
||||
|
||||
defp handle_preflight(conn), do: conn |
||||
end |
||||
@ -1,86 +0,0 @@
@@ -1,86 +0,0 @@
|
||||
defmodule GcIndexRelayWeb.Plugs.RelayInfo do |
||||
@moduledoc """ |
||||
Serves the NIP-11 relay information document. |
||||
|
||||
When a GET / request arrives with `Accept: application/nostr+json`, this plug |
||||
intercepts it and returns the relay metadata as JSON before the browser pipeline's |
||||
`:accepts` check can reject it with a 406. |
||||
|
||||
Configuration is read from `config :gc_index_relay, :relay_info` — edit that key |
||||
in config/config.exs to describe your relay instance. |
||||
""" |
||||
|
||||
import Plug.Conn |
||||
|
||||
def init(opts), do: opts |
||||
|
||||
def call(%Plug.Conn{method: "GET", request_path: "/"} = conn, _opts) do |
||||
accept = conn |> get_req_header("accept") |> Enum.join(",") |
||||
|
||||
if String.contains?(accept, "application/nostr+json") do |
||||
base_url = build_base_url(conn) |
||||
|
||||
relay_info = |
||||
Application.get_env(:gc_index_relay, :relay_info, []) |
||||
|> Map.new() |
||||
|> resolve_image_urls(base_url) |
||||
|
||||
conn |
||||
|> put_resp_content_type("application/nostr+json") |
||||
|> send_resp(200, Jason.encode!(relay_info)) |
||||
|> halt() |
||||
else |
||||
conn |
||||
end |
||||
end |
||||
|
||||
def call(conn, _opts), do: conn |
||||
|
||||
# Build "scheme://host[:port]" from the incoming request. |
||||
# Standard ports (80 for http, 443 for https) are omitted. |
||||
defp build_base_url(conn) do |
||||
port_suffix = |
||||
case {conn.scheme, conn.port} do |
||||
{:http, 80} -> "" |
||||
{:https, 443} -> "" |
||||
{_, port} -> ":#{port}" |
||||
end |
||||
|
||||
"#{conn.scheme}://#{conn.host}#{port_suffix}" |
||||
end |
||||
|
||||
# Prepend base_url to any relative (non-absolute) value for :icon and :banner. |
||||
defp resolve_image_urls(relay_info, base_url) do |
||||
relay_info |
||||
|> resolve_field(:icon, base_url) |
||||
|> resolve_field(:banner, base_url) |
||||
end |
||||
|
||||
defp resolve_field(map, key, base_url) do |
||||
case Map.get(map, key) do |
||||
nil -> |
||||
map |
||||
|
||||
"" -> |
||||
map |
||||
|
||||
url when is_binary(url) -> |
||||
resolve_url_field(map, key, url, base_url) |
||||
|
||||
_ -> |
||||
map |
||||
end |
||||
end |
||||
|
||||
defp resolve_url_field(map, key, url, base_url) do |
||||
if String.starts_with?(url, ["http://", "https://"]) do |
||||
map |
||||
else |
||||
Map.put(map, key, "#{base_url}#{path_under_base(url)}") |
||||
end |
||||
end |
||||
|
||||
defp path_under_base(url) do |
||||
if String.starts_with?(url, "/"), do: url, else: "/#{url}" |
||||
end |
||||
end |
||||
@ -1,39 +0,0 @@
@@ -1,39 +0,0 @@
|
||||
defmodule Mix.Tasks.Test.Integration do |
||||
use Mix.Task |
||||
|
||||
@shortdoc "Runs integration tests with REQUIRE_DB=true (requires PostgreSQL)" |
||||
|
||||
@moduledoc """ |
||||
Creates the test database if needed, runs migrations, then runs tests tagged `:integration`. |
||||
|
||||
Spawns a subprocess with `REQUIRE_DB=true` so the application starts `GcIndexRelay.Repo` |
||||
under `MIX_ENV=test`. Extra args are forwarded to `mix test` (e.g. `--failed`, `--trace` for line-by-line output). |
||||
""" |
||||
|
||||
@impl Mix.Task |
||||
def run(args) do |
||||
Mix.Task.run("loadconfig") |
||||
|
||||
argv = |
||||
[ |
||||
"REQUIRE_DB=true", |
||||
"MIX_ENV=test", |
||||
"mix", |
||||
"do", |
||||
"ecto.create", |
||||
"--quiet", |
||||
"+", |
||||
"ecto.migrate", |
||||
"--quiet", |
||||
"+", |
||||
"test", |
||||
"--only", |
||||
"integration" |
||||
] ++ args |
||||
|
||||
{_output, exit_code} = |
||||
System.cmd("env", argv, into: IO.stream(:stdio, :line), cd: File.cwd!()) |
||||
|
||||
System.halt(exit_code) |
||||
end |
||||
end |
||||
@ -1,9 +0,0 @@
@@ -1,9 +0,0 @@
|
||||
defmodule GcIndexRelay.Repo.Migrations.AllowNullTagValue do |
||||
use Ecto.Migration |
||||
|
||||
def change do |
||||
alter table(:tags) do |
||||
modify :value, :string, null: true, from: {:string, null: false} |
||||
end |
||||
end |
||||
end |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
# Script for populating the database. You can run it as: |
||||
# |
||||
# mix run priv/repo/seeds.exs |
||||
# |
||||
# Inside the script, you can read and write to any of your |
||||
# repositories directly: |
||||
# |
||||
# GcIndexRelay.Repo.insert!(%GcIndexRelay.SomeSchema{}) |
||||
# |
||||
# We recommend using the bang functions (`insert!`, `update!` |
||||
# and so on) as they will fail if something goes wrong. |
||||
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 152 B |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 770 B |
|
Before Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 26 KiB |
@ -1,377 +0,0 @@
@@ -1,377 +0,0 @@
|
||||
#!/usr/bin/env bash |
||||
# Local development setup for gc_index_relay (run on your host — NOT inside app Docker images). |
||||
# Production/runtime images get dependencies from docker/server.Dockerfile (release build + runtime packages). |
||||
# Safe to run multiple times — all steps are idempotent. |
||||
# |
||||
# Requirements: |
||||
# - Docker must already be installed (https://docs.docker.com/engine/install/) |
||||
# - sudo access to install OS packages (apt-get on Debian/Ubuntu, dnf/yum on Fedora/RHEL) |
||||
# |
||||
# Debian / Ubuntu: Erlang + Elixir are installed from Team RabbitMQ’s apt repositories, as |
||||
# recommended on https://elixir-lang.org/install.html (Launchpad PPA on Ubuntu; Cloudsmith |
||||
# erlang debs on Debian amd64). Fedora/RHEL still use asdf-compiled Erlang. |
||||
# |
||||
# Usage: |
||||
# chmod +x setup.sh |
||||
# ./setup.sh |
||||
|
||||
set -euo pipefail |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Configuration |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
ERLANG_VERSION="28.4.1" |
||||
ELIXIR_VERSION="1.19.5-otp-28" |
||||
|
||||
POSTGRES_HOST="localhost" |
||||
POSTGRES_PORT="5455" |
||||
POSTGRES_USER="postgres" |
||||
POSTGRES_PASSWORD="postgres" |
||||
POSTGRES_DB="gc_index_relay_dev" |
||||
|
||||
DOCKER_CONTAINER_NAME="gc_age_db" |
||||
AGE_IMAGE="apache/age:release_PG17_1.6.0" |
||||
|
||||
ASDF_DIR="$HOME/.asdf" |
||||
ASDF_VERSION="v0.15.0" |
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Helpers |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
GREEN='\033[0;32m' |
||||
YELLOW='\033[1;33m' |
||||
RED='\033[0;31m' |
||||
NC='\033[0m' |
||||
|
||||
log() { echo -e "${GREEN}[setup]${NC} $*"; } |
||||
warn() { echo -e "${YELLOW}[ warn]${NC} $*"; } |
||||
err() { echo -e "${RED}[error]${NC} $*" >&2; exit 1; } |
||||
|
||||
require_cmd() { |
||||
command -v "$1" &>/dev/null || err "'$1' is not installed or not on PATH. $2" |
||||
} |
||||
|
||||
# Install modern Erlang + Elixir via RabbitMQ-maintained apt repos (Elixir install docs). |
||||
# Returns 0 on success, 1 to fall back to asdf. |
||||
install_elixir_erlang_via_rabbitmq_apt() { |
||||
local id version |
||||
[ -f /etc/os-release ] || return 1 |
||||
# shellcheck source=/dev/null |
||||
. /etc/os-release |
||||
id="${ID:-}" |
||||
version="${VERSION_CODENAME:-}" |
||||
|
||||
if [ "$id" = "linuxmint" ]; then |
||||
version="${UBUNTU_CODENAME:-$version}" |
||||
fi |
||||
|
||||
case "$id" in |
||||
ubuntu | pop | linuxmint) |
||||
log "Installing Erlang + Elixir via RabbitMQ Erlang PPA (https://elixir-lang.org/install.html)..." |
||||
sudo apt-get install -y software-properties-common |
||||
sudo add-apt-repository -y ppa:rabbitmq/rabbitmq-erlang |
||||
sudo apt-get update -qq |
||||
sudo apt-get install -y git elixir erlang |
||||
;; |
||||
debian) |
||||
if [ "$(uname -m)" != "x86_64" ]; then |
||||
warn "RabbitMQ Cloudsmith Erlang packages are amd64-only; use asdf on this architecture." |
||||
return 1 |
||||
fi |
||||
case "$version" in |
||||
bullseye | bookworm | trixie) |
||||
log "Installing Erlang via Team RabbitMQ apt + elixir (Debian ${version}; https://www.rabbitmq.com/docs/install-debian)..." |
||||
sudo apt-get install -y curl gnupg apt-transport-https |
||||
curl -1sLf "https://keys.openpgp.org/vks/v1/by-fingerprint/0A9AF2115F4687BD29803A206B73A36E6026DFCA" | |
||||
sudo gpg --dearmor | sudo tee /usr/share/keyrings/com.rabbitmq.team.gpg >/dev/null |
||||
sudo tee /etc/apt/sources.list.d/rabbitmq-erlang.list >/dev/null <<EOF |
||||
deb [arch=amd64 signed-by=/usr/share/keyrings/com.rabbitmq.team.gpg] https://deb1.rabbitmq.com/rabbitmq-erlang/debian/${version} ${version} main |
||||
deb [arch=amd64 signed-by=/usr/share/keyrings/com.rabbitmq.team.gpg] https://deb2.rabbitmq.com/rabbitmq-erlang/debian/${version} ${version} main |
||||
EOF |
||||
sudo apt-get update -qq |
||||
sudo apt-get install -y \ |
||||
erlang-base \ |
||||
erlang-asn1 \ |
||||
erlang-crypto \ |
||||
erlang-eldap \ |
||||
erlang-ftp \ |
||||
erlang-inets \ |
||||
erlang-mnesia \ |
||||
erlang-os-mon \ |
||||
erlang-parsetools \ |
||||
erlang-public-key \ |
||||
erlang-runtime-tools \ |
||||
erlang-snmp \ |
||||
erlang-ssl \ |
||||
erlang-syntax-tools \ |
||||
erlang-tftp \ |
||||
erlang-tools \ |
||||
erlang-xmerl |
||||
sudo apt-get install -y git elixir |
||||
;; |
||||
*) |
||||
warn "Debian ${version:-unknown} not supported for RabbitMQ Erlang apt (expected bullseye, bookworm, or trixie)." |
||||
return 1 |
||||
;; |
||||
esac |
||||
;; |
||||
*) |
||||
return 1 |
||||
;; |
||||
esac |
||||
|
||||
hash -r |
||||
if ! command -v elixir &>/dev/null; then |
||||
warn "elixir not found on PATH after apt install." |
||||
return 1 |
||||
fi |
||||
if ! elixir --version 2>/dev/null | grep -qE 'Elixir 1\.(1[5-9]|[2-9][0-9])'; then |
||||
warn "Elixir from apt is below 1.15; falling back to asdf." |
||||
return 1 |
||||
fi |
||||
log "$(elixir --version 2>/dev/null | grep Elixir || true)" |
||||
return 0 |
||||
} |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# 1. Pre-flight checks |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
log "Starting gc_index_relay local setup..." |
||||
echo |
||||
|
||||
require_cmd docker "Install Docker first: https://docs.docker.com/engine/install/" |
||||
docker info &>/dev/null || err "Docker daemon is not running. Start it and try again." |
||||
log "Docker: $(docker --version)" |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# 2. System build dependencies (Debian/Ubuntu, Fedora, RHEL-like) |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
install_system_deps_apt() { |
||||
log "Installing system build dependencies via apt-get..." |
||||
sudo apt-get update -qq |
||||
sudo apt-get install -y \ |
||||
build-essential \ |
||||
autoconf \ |
||||
libtool \ |
||||
inotify-tools \ |
||||
git \ |
||||
curl \ |
||||
jq |
||||
} |
||||
|
||||
# Fedora / RHEL / Alma / Rocky (dnf or yum). Includes openssl/ncurses headers for asdf Erlang builds. |
||||
install_system_deps_rpm() { |
||||
local pm="$1" |
||||
log "Installing system build dependencies via $pm..." |
||||
sudo "$pm" install -y \ |
||||
gcc \ |
||||
gcc-c++ \ |
||||
make \ |
||||
autoconf \ |
||||
automake \ |
||||
libtool \ |
||||
inotify-tools \ |
||||
git \ |
||||
curl \ |
||||
jq \ |
||||
openssl-devel \ |
||||
ncurses-devel |
||||
} |
||||
|
||||
if command -v apt-get &>/dev/null; then |
||||
install_system_deps_apt |
||||
elif command -v dnf &>/dev/null; then |
||||
install_system_deps_rpm dnf |
||||
elif command -v yum &>/dev/null; then |
||||
install_system_deps_rpm yum |
||||
else |
||||
warn "No supported package manager found (apt-get, dnf, or yum) — skipping system package install." |
||||
warn "Install manually (names differ by distro): C toolchain, autoconf, libtool, inotify-tools, git, curl, jq" |
||||
warn "For asdf Erlang on Fedora/RHEL, you typically also need: openssl-devel, ncurses-devel" |
||||
fi |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# 2b. Erlang + Elixir (apt on Debian/Ubuntu via RabbitMQ repos, else asdf) |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
ELIXIR_FROM_APT=0 |
||||
if command -v apt-get &>/dev/null; then |
||||
if install_elixir_erlang_via_rabbitmq_apt; then |
||||
ELIXIR_FROM_APT=1 |
||||
log "Using apt-installed Erlang/Elixir (no asdf Erlang/Elixir steps)." |
||||
else |
||||
warn "RabbitMQ apt path skipped or failed — installing Erlang/Elixir with asdf." |
||||
fi |
||||
fi |
||||
|
||||
if [ "$ELIXIR_FROM_APT" -eq 0 ]; then |
||||
# --------------------------------------------------------------------------- |
||||
# 3. asdf version manager |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
if [ ! -d "$ASDF_DIR" ]; then |
||||
log "Installing asdf $ASDF_VERSION..." |
||||
git clone https://github.com/asdf-vm/asdf.git "$ASDF_DIR" --branch "$ASDF_VERSION" |
||||
else |
||||
log "asdf already installed at $ASDF_DIR" |
||||
fi |
||||
|
||||
# shellcheck source=/dev/null |
||||
source "$ASDF_DIR/asdf.sh" |
||||
|
||||
add_asdf_to_rc() { |
||||
local rc="$1" |
||||
local line='. "$HOME/.asdf/asdf.sh"' |
||||
if [ -f "$rc" ] && ! grep -qF 'asdf/asdf.sh' "$rc"; then |
||||
echo "" >> "$rc" |
||||
echo "# asdf version manager" >> "$rc" |
||||
echo "$line" >> "$rc" |
||||
log "Added asdf to $rc (will take effect in new shells)" |
||||
fi |
||||
} |
||||
add_asdf_to_rc "$HOME/.bashrc" |
||||
add_asdf_to_rc "$HOME/.zshrc" 2>/dev/null || true |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# 4. Erlang |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
asdf plugin add erlang 2>/dev/null || true |
||||
|
||||
if asdf list erlang 2>/dev/null | grep -qF "$ERLANG_VERSION"; then |
||||
log "Erlang $ERLANG_VERSION already installed" |
||||
else |
||||
log "Installing Erlang $ERLANG_VERSION (compiles from source — takes a few minutes)..." |
||||
asdf install erlang "$ERLANG_VERSION" |
||||
fi |
||||
|
||||
asdf global erlang "$ERLANG_VERSION" |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# 5. Elixir |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
asdf plugin add elixir 2>/dev/null || true |
||||
|
||||
if asdf list elixir 2>/dev/null | grep -qF "$ELIXIR_VERSION"; then |
||||
log "Elixir $ELIXIR_VERSION already installed" |
||||
else |
||||
log "Installing Elixir $ELIXIR_VERSION..." |
||||
asdf install elixir "$ELIXIR_VERSION" |
||||
fi |
||||
|
||||
asdf global elixir "$ELIXIR_VERSION" |
||||
log "$(elixir --version | grep 'Elixir')" |
||||
else |
||||
hash -r |
||||
fi |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# 6. Apache AGE database (Docker) |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
if docker ps -q --filter "name=^${DOCKER_CONTAINER_NAME}$" | grep -q .; then |
||||
log "Database container '$DOCKER_CONTAINER_NAME' is already running" |
||||
elif docker ps -aq --filter "name=^${DOCKER_CONTAINER_NAME}$" | grep -q .; then |
||||
log "Restarting existing database container '$DOCKER_CONTAINER_NAME'..." |
||||
docker start "$DOCKER_CONTAINER_NAME" |
||||
else |
||||
log "Starting Apache AGE database container..." |
||||
docker run -d \ |
||||
--name "$DOCKER_CONTAINER_NAME" \ |
||||
-p "${POSTGRES_PORT}:5432" \ |
||||
-e POSTGRES_USER="$POSTGRES_USER" \ |
||||
-e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \ |
||||
-e POSTGRES_DB="$POSTGRES_DB" \ |
||||
"$AGE_IMAGE" |
||||
fi |
||||
|
||||
log "Waiting for database to accept connections..." |
||||
until docker exec "$DOCKER_CONTAINER_NAME" pg_isready -U "$POSTGRES_USER" &>/dev/null; do |
||||
sleep 1 |
||||
done |
||||
log "Database is ready" |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# 7. .env file |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
ENV_FILE="$PROJECT_DIR/.env" |
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then |
||||
log "Writing .env with database credentials..." |
||||
cat > "$ENV_FILE" <<EOF |
||||
export POSTGRES_HOST=$POSTGRES_HOST |
||||
export POSTGRES_PORT=$POSTGRES_PORT |
||||
export POSTGRES_USER=$POSTGRES_USER |
||||
export POSTGRES_PASSWORD=$POSTGRES_PASSWORD |
||||
export POSTGRES_DB=$POSTGRES_DB |
||||
export REQUIRE_DB=true |
||||
EOF |
||||
else |
||||
log ".env already exists — skipping (delete it to regenerate)" |
||||
fi |
||||
|
||||
# Export DB vars for this session so mix setup can connect. Do not export REQUIRE_DB here — |
||||
# that would make a follow-up `mix test.unit` in the same shell start the Repo. It is only in `.env` for integration tests. |
||||
export POSTGRES_HOST POSTGRES_PORT POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# 8. Mix setup (deps + database) |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
cd "$PROJECT_DIR" |
||||
|
||||
if [ "$ELIXIR_FROM_APT" -eq 1 ]; then |
||||
require_cmd mix "apt elixir package should provide mix; check PATH includes /usr/bin" |
||||
else |
||||
require_cmd mix "asdf should provide mix; run: source \"\$HOME/.asdf/asdf.sh\" or open a new terminal" |
||||
fi |
||||
|
||||
log "Installing Hex and Rebar..." |
||||
mix local.hex --force |
||||
mix local.rebar --force |
||||
|
||||
log "Fetching dependencies..." |
||||
mix deps.get |
||||
|
||||
log "Running mix setup (compile + create DB + migrate)..." |
||||
mix setup |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Done |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
echo |
||||
log "Setup complete!" |
||||
echo |
||||
if [ "$ELIXIR_FROM_APT" -eq 1 ]; then |
||||
echo " To start the server (system Elixir from apt):" |
||||
echo " cd \"$PROJECT_DIR\" && source .env && mix phx.server" |
||||
echo |
||||
echo " Then open: http://localhost:4000" |
||||
echo " REST API: http://localhost:4000/api/events" |
||||
echo |
||||
echo " Integration tests:" |
||||
echo " source .env && mix test.integration" |
||||
echo |
||||
warn "If mix is not found in a new terminal, ensure /usr/bin is on your PATH." |
||||
else |
||||
echo " To start the server (use asdf’s mix — see warning below if mix is missing):" |
||||
echo " source \"\$HOME/.asdf/asdf.sh\" # once per shell, if needed" |
||||
echo " cd \"$PROJECT_DIR\" && source .env && mix phx.server" |
||||
echo |
||||
echo " Then open: http://localhost:4000" |
||||
echo " REST API: http://localhost:4000/api/events" |
||||
echo |
||||
echo " Integration tests: same shell with .env (includes REQUIRE_DB=true):" |
||||
echo " source .env && mix test.integration" |
||||
echo |
||||
warn "Open a new terminal (or run 'source ~/.bashrc') so asdf provides mix/elixir in future sessions." |
||||
fi |
||||
@ -1,170 +0,0 @@
@@ -1,170 +0,0 @@
|
||||
Feature: Nostr Relay REST API |
||||
As a Nostr client (e.g. Jumble at https://jumble.imwald.eu/) |
||||
I want to interact with the relay over HTTP |
||||
So that I can publish and query Nostr events |
||||
|
||||
Background: |
||||
Given the relay is running at http://localhost:4000 |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Discovery |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
Scenario: Client discovers available endpoints |
||||
When the client sends GET /api |
||||
Then the response status is 200 |
||||
And the response lists the available endpoints |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Publishing events |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
Scenario: Client publishes a valid kind 1 note |
||||
Given a valid signed kind 1 event from keypair 1 |
||||
When the client sends POST /api/events with the event body |
||||
Then the response status is 201 Created |
||||
And the response body contains the published event |
||||
|
||||
Scenario: Client publishes a kind 0 profile event with metadata |
||||
Given a valid signed kind 0 event containing a JSON metadata content field |
||||
When the client sends POST /api/events with the event body |
||||
Then the response status is 201 Created |
||||
|
||||
Scenario: Relay rejects a duplicate event |
||||
Given a valid signed event has already been published |
||||
When the client sends POST /api/events with the same event again |
||||
Then the response status is 409 Conflict |
||||
|
||||
Scenario: Relay rejects an event with an invalid ID |
||||
Given a signed event whose ID does not match the hash of its content |
||||
When the client sends POST /api/events with the event body |
||||
Then the response status is 400 Bad Request |
||||
|
||||
Scenario: Relay rejects a NIP-70 protected event |
||||
Given a valid signed event with a ["-"] protection tag |
||||
When the client sends POST /api/events with the event body |
||||
Then the response status is 400 Bad Request |
||||
And the response error contains "auth-required" |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Deleting events (DELETE /api/events/:id) |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
Scenario: Client deletes an existing event by ID |
||||
Given a known event has been published |
||||
When the client sends DELETE /api/events/<event_id> |
||||
Then the response status is 204 No Content |
||||
|
||||
Scenario: Deleting a non-existent event returns 404 |
||||
When the client sends DELETE /api/events/<unknown_id> |
||||
Then the response status is 404 Not Found |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Fetching events (GET /api/events — cacheable, requires since/until/limit) |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
Scenario: Client fetches events using time-bounded query params |
||||
Given two kind 1 notes have been published |
||||
When the client sends GET /api/events?since=0&until=9999999999&limit=10 |
||||
Then the response status is 200 |
||||
And the response contains both kind 1 events |
||||
|
||||
Scenario: GET /api/events without required params is rejected |
||||
When the client sends GET /api/events without "since", "until", or "limit" |
||||
Then the response status is 400 Bad Request |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Fetching events (POST /api/events/filter — what most Nostr clients use) |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
Scenario: Client fetches recent notes (kind 1) |
||||
Given two kind 1 notes have been published |
||||
When the client sends POST /api/events/filter with body {"kinds": [1], "limit": 10} |
||||
Then the response status is 200 |
||||
And the response contains both kind 1 events |
||||
And events are ordered newest first |
||||
|
||||
Scenario: Client fetches a user profile (kind 0) |
||||
Given a kind 0 profile event has been published for keypair 1 |
||||
When the client sends POST /api/events/filter with body {"kinds": [0], "authors": ["<pubkey1>"], "limit": 1} |
||||
Then the response status is 200 |
||||
And the response contains exactly 1 event |
||||
And the event kind is 0 |
||||
|
||||
Scenario: Client fetches events mentioning a specific pubkey (#p tag) |
||||
Given an event tagged with ["p", "<some pubkey>"] has been published |
||||
When the client sends POST /api/events/filter with body {"#p": ["<some pubkey>"], "limit": 10} |
||||
Then the response status is 200 |
||||
And the response contains the tagged event |
||||
|
||||
Scenario: Client fetches events within a time window |
||||
Given two events with different created_at timestamps exist |
||||
When the client sends POST /api/events/filter with a "since" before the newer event |
||||
Then only the newer event is returned |
||||
|
||||
Scenario: Filter without a limit is rejected |
||||
When the client sends POST /api/events/filter with body {"kinds": [1]} |
||||
Then the response status is 400 Bad Request |
||||
|
||||
Scenario: Filter with a limit over 100 is rejected |
||||
When the client sends POST /api/events/filter with body {"limit": 101} |
||||
Then the response status is 400 Bad Request |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Fetching a single event by ID (GET /api/events/:id) |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
Scenario: Client fetches a specific event by ID |
||||
Given a known event has been published |
||||
When the client sends GET /api/events/<event_id> |
||||
Then the response status is 200 |
||||
And the response body contains the correct event |
||||
|
||||
Scenario: Fetching a non-existent event returns 404 |
||||
When the client sends GET /api/events/<unknown_id> |
||||
Then the response status is 404 Not Found |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# CORS — required for browser-based clients like Jumble |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
Scenario: Relay includes CORS headers on API responses |
||||
When the client sends any request to the API |
||||
Then the response includes "Access-Control-Allow-Origin: *" |
||||
And the response includes "Access-Control-Allow-Methods" listing GET, POST, DELETE, OPTIONS |
||||
|
||||
Scenario: Relay handles a browser preflight OPTIONS request |
||||
When the client sends OPTIONS /api/events |
||||
Then the response status is 200 |
||||
And the response includes CORS headers |
||||
And the response body is empty |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# NIP-11 relay information document |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
Scenario: Client requests NIP-11 relay info |
||||
When the client sends GET / with header "Accept: application/nostr+json" |
||||
Then the response status is 200 |
||||
And the response content-type is "application/nostr+json" |
||||
And the response includes a "name" field |
||||
And the response includes a "supported_nips" array |
||||
And the response includes a "limitation" object |
||||
And the response includes an "icon" field with a URL |
||||
|
||||
Scenario: NIP-11 response includes correct CORS headers |
||||
When the client sends GET / with header "Accept: application/nostr+json" |
||||
Then the response includes "Access-Control-Allow-Origin: *" |
||||
|
||||
Scenario: Regular browser request to GET / still serves the HTML page |
||||
When the client sends GET / with header "Accept: text/html" |
||||
Then the response status is 200 |
||||
And the response content-type contains "text/html" |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Health check |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
Scenario: Health check returns OK |
||||
When the client sends GET /health |
||||
Then the response status is 200 |
||||
@ -1,22 +1,11 @@
@@ -1,22 +1,11 @@
|
||||
defmodule GcIndexRelay.NostrTest do |
||||
use GcIndexRelay.DataCase |
||||
|
||||
import GcIndexRelay.NostrFixtures |
||||
|
||||
@moduletag :integration |
||||
|
||||
describe "reference kind 1111 event" do |
||||
test "create_event and get_event round-trip all fields and tags" do |
||||
event = reference_kind1111_pub_event() |
||||
|
||||
assert {:ok, _} = GcIndexRelay.Nostr.create_event(event) |
||||
assert {:ok, loaded} = GcIndexRelay.Nostr.get_event(event.id) |
||||
describe "events" do |
||||
# Tests to be added |
||||
end |
||||
|
||||
assert loaded.kind == event.kind |
||||
assert loaded.content == event.content |
||||
assert loaded.pubkey == event.pubkey |
||||
assert loaded.created_at == event.created_at |
||||
assert loaded.tags == event.tags |
||||
end |
||||
describe "tags" do |
||||
# Tests to be added |
||||
end |
||||
end |
||||
|
||||
@ -1,10 +1,8 @@
@@ -1,10 +1,8 @@
|
||||
defmodule GcIndexRelayWeb.PageControllerTest do |
||||
use GcIndexRelayWeb.ConnCase |
||||
|
||||
@moduletag :unit |
||||
|
||||
test "GET / renders Mercury landing page", %{conn: conn} do |
||||
test "GET /", %{conn: conn} do |
||||
conn = get(conn, ~p"/") |
||||
assert html_response(conn, 200) =~ "Mercury Index-Relay" |
||||
assert html_response(conn, 200) =~ "Peace of mind from prototype to production" |
||||
end |
||||
end |
||||
|
||||
@ -1,85 +0,0 @@
@@ -1,85 +0,0 @@
|
||||
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 |
||||
@ -1,201 +0,0 @@
@@ -1,201 +0,0 @@
|
||||
defmodule GcIndexRelayWeb.RelayIntegrationTest do |
||||
@moduledoc """ |
||||
Integration tests for relay REST API scenarios that require a live database. |
||||
Covers event publishing, deletion, and filter-based querying. |
||||
|
||||
Scenarios are mapped 1:1 to test/features/relay_api.feature. |
||||
Run with: source .env && mix test.integration test/gc_index_relay_web/relay_integration_test.exs |
||||
""" |
||||
|
||||
use GcIndexRelayWeb.ConnCase |
||||
|
||||
import GcIndexRelay.NostrFixtures |
||||
|
||||
@moduletag :integration |
||||
|
||||
setup %{conn: conn} do |
||||
conn = |
||||
conn |
||||
|> put_req_header("accept", "application/json") |
||||
|> put_req_header("content-type", "application/json") |
||||
|
||||
{:ok, conn: conn} |
||||
end |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Publishing events (POST /api/events) |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
describe "POST /api/events — publishing" do |
||||
test "client publishes a valid kind 1 note", %{conn: conn} do |
||||
event = valid_pub_event_fixture(%{kind: 1, content: "hello nostr"}) |
||||
|
||||
conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)}) |
||||
|
||||
assert %{"data" => data} = json_response(conn, 201) |
||||
assert data["id"] == event.id |
||||
assert data["kind"] == 1 |
||||
assert data["content"] == "hello nostr" |
||||
end |
||||
|
||||
test "client publishes a kind 0 profile event with metadata content", %{conn: conn} do |
||||
metadata = Jason.encode!(%{name: "testuser", about: "a test profile", picture: ""}) |
||||
event = valid_pub_event_fixture(%{kind: 0, content: metadata}) |
||||
|
||||
conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)}) |
||||
|
||||
assert %{"data" => data} = json_response(conn, 201) |
||||
assert data["kind"] == 0 |
||||
assert data["content"] == metadata |
||||
end |
||||
|
||||
test "relay rejects a duplicate event with 409", %{conn: conn} do |
||||
event = valid_pub_event_fixture() |
||||
post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)}) |
||||
|
||||
conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)}) |
||||
|
||||
assert json_response(conn, 409) |
||||
end |
||||
end |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Deleting events (DELETE /api/events/:id) |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
describe "DELETE /api/events/:id — deletion" do |
||||
test "client deletes an existing event and gets 204", %{conn: conn} do |
||||
event = valid_pub_event_fixture() |
||||
{:ok, db_event} = GcIndexRelay.Nostr.create_event(event) |
||||
hex_id = Base.encode16(db_event.id, case: :lower) |
||||
|
||||
conn = delete(conn, ~p"/api/events/#{hex_id}") |
||||
|
||||
assert conn.status == 204 |
||||
assert conn.resp_body == "" |
||||
end |
||||
|
||||
test "deleting a non-existent event returns 404", %{conn: conn} do |
||||
conn = delete(conn, ~p"/api/events/#{String.duplicate("b", 64)}") |
||||
|
||||
assert json_response(conn, 404) |
||||
end |
||||
end |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Querying events (GET /api/events — cacheable with query params) |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
describe "GET /api/events — cacheable query" do |
||||
test "client fetches events with since/until/limit query params", %{conn: conn} do |
||||
valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001}) |
||||
|> then(&GcIndexRelay.Nostr.create_event/1) |
||||
|
||||
valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_002, keypair: :keypair2}) |
||||
|> then(&GcIndexRelay.Nostr.create_event/1) |
||||
|
||||
conn = get(conn, "/api/events?since=0&until=9999999999&limit=10") |
||||
|
||||
assert %{"data" => events} = json_response(conn, 200) |
||||
assert length(events) == 2 |
||||
[first, second] = events |
||||
assert first["created_at"] >= second["created_at"] |
||||
end |
||||
end |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Querying events (POST /api/events/filter) |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
describe "POST /api/events/filter — querying" do |
||||
test "client fetches recent kind 1 notes, newest first", %{conn: conn} do |
||||
valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001}) |
||||
|> then(&GcIndexRelay.Nostr.create_event/1) |
||||
|
||||
valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_002, keypair: :keypair2}) |
||||
|> then(&GcIndexRelay.Nostr.create_event/1) |
||||
|
||||
conn = post(conn, ~p"/api/events/filter", %{"kinds" => [1], "limit" => 10}) |
||||
|
||||
assert %{"data" => events} = json_response(conn, 200) |
||||
assert length(events) == 2 |
||||
[first, second] = events |
||||
assert first["created_at"] >= second["created_at"] |
||||
end |
||||
|
||||
test "client fetches a user profile (kind 0) by author", %{conn: conn} do |
||||
%{keypair1: kp} = test_keypairs() |
||||
metadata = Jason.encode!(%{name: "alice"}) |
||||
event = valid_pub_event_fixture(%{kind: 0, content: metadata}) |
||||
{:ok, _} = GcIndexRelay.Nostr.create_event(event) |
||||
|
||||
conn = |
||||
post(conn, ~p"/api/events/filter", %{ |
||||
"kinds" => [0], |
||||
"authors" => [kp.public_key_hex], |
||||
"limit" => 1 |
||||
}) |
||||
|
||||
assert %{"data" => [profile]} = json_response(conn, 200) |
||||
assert profile["kind"] == 0 |
||||
assert profile["pubkey"] == kp.public_key_hex |
||||
end |
||||
|
||||
test "client fetches events mentioning a pubkey via #p tag", %{conn: conn} do |
||||
mentioned_pubkey = String.duplicate("ab", 32) |
||||
event = valid_pub_event_fixture(%{tags: [["p", mentioned_pubkey]]}) |
||||
{:ok, _} = GcIndexRelay.Nostr.create_event(event) |
||||
|
||||
valid_pub_event_fixture(%{created_at: 1_640_000_001, keypair: :keypair2}) |
||||
|> then(&GcIndexRelay.Nostr.create_event/1) |
||||
|
||||
conn = post(conn, ~p"/api/events/filter", %{"#p" => [mentioned_pubkey], "limit" => 10}) |
||||
|
||||
assert %{"data" => events} = json_response(conn, 200) |
||||
assert length(events) == 1 |
||||
assert hd(events)["id"] == event.id |
||||
end |
||||
|
||||
test "client fetches events within a time window", %{conn: conn} do |
||||
valid_pub_event_fixture(%{kind: 1, created_at: 1_500_000_000}) |
||||
|> then(&GcIndexRelay.Nostr.create_event/1) |
||||
|
||||
valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001, keypair: :keypair2}) |
||||
|> then(&GcIndexRelay.Nostr.create_event/1) |
||||
|
||||
conn = |
||||
post(conn, ~p"/api/events/filter", %{ |
||||
"since" => 1_600_000_000, |
||||
"limit" => 10 |
||||
}) |
||||
|
||||
assert %{"data" => events} = json_response(conn, 200) |
||||
assert length(events) == 1 |
||||
assert hd(events)["created_at"] == 1_640_000_001 |
||||
end |
||||
end |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Fetching a single event (GET /api/events/:id) |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
describe "GET /api/events/:id — single event lookup" do |
||||
test "client fetches a specific event by ID", %{conn: conn} do |
||||
event = valid_pub_event_fixture() |
||||
{:ok, db_event} = GcIndexRelay.Nostr.create_event(event) |
||||
hex_id = Base.encode16(db_event.id, case: :lower) |
||||
|
||||
conn = get(conn, ~p"/api/events/#{hex_id}") |
||||
|
||||
assert %{"data" => data} = json_response(conn, 200) |
||||
assert data["id"] == hex_id |
||||
assert data["content"] == event.content |
||||
end |
||||
|
||||
test "fetching a non-existent event returns 404", %{conn: conn} do |
||||
conn = get(conn, ~p"/api/events/#{String.duplicate("a", 64)}") |
||||
|
||||
assert json_response(conn, 404) |
||||
end |
||||
end |
||||
end |
||||
@ -1,176 +0,0 @@
@@ -1,176 +0,0 @@
|
||||
defmodule GcIndexRelayWeb.RelayUnitTest do |
||||
@moduledoc """ |
||||
Unit tests for relay HTTP endpoints that require no database access. |
||||
Tests controller/plug behaviour: routing, validation rejection, CORS headers, |
||||
NIP-11 relay info, and health check. |
||||
|
||||
Scenarios are a subset of test/features/relay_api.feature. |
||||
Run with: mix test.unit test/gc_index_relay_web/relay_unit_test.exs |
||||
""" |
||||
|
||||
use GcIndexRelayWeb.ConnCase, async: true |
||||
|
||||
import GcIndexRelay.NostrFixtures |
||||
|
||||
@moduletag :unit |
||||
|
||||
setup %{conn: conn} do |
||||
conn = |
||||
conn |
||||
|> put_req_header("accept", "application/json") |
||||
|> put_req_header("content-type", "application/json") |
||||
|
||||
{:ok, conn: conn} |
||||
end |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Discovery |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
describe "GET /api — discovery" do |
||||
test "client discovers available endpoints", %{conn: conn} do |
||||
conn = get(conn, ~p"/api") |
||||
|
||||
assert %{"endpoints" => endpoints} = json_response(conn, 200) |
||||
paths = Enum.map(endpoints, & &1["path"]) |
||||
assert "/api/events" in paths |
||||
assert "/api/events/:id" in paths |
||||
assert "/api/events/filter" in paths |
||||
end |
||||
end |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Request validation — rejected before any DB access |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
describe "POST /api/events — validation rejections" do |
||||
test "relay rejects a NIP-70 protected event with 400", %{conn: conn} do |
||||
event = valid_pub_event_fixture(%{tags: [["-"]]}) |
||||
|
||||
conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)}) |
||||
|
||||
assert %{"errors" => %{"detail" => detail}} = json_response(conn, 400) |
||||
assert detail =~ "auth-required" |
||||
end |
||||
end |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# CORS — required for browser-based clients like Jumble |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
describe "CORS headers — browser client compatibility" do |
||||
test "relay includes CORS headers on a GET response", %{conn: conn} do |
||||
conn = get(conn, ~p"/api") |
||||
|
||||
assert get_resp_header(conn, "access-control-allow-origin") == ["*"] |
||||
methods = get_resp_header(conn, "access-control-allow-methods") |> List.first("") |
||||
assert methods =~ "GET" |
||||
assert methods =~ "POST" |
||||
assert methods =~ "DELETE" |
||||
assert methods =~ "OPTIONS" |
||||
end |
||||
|
||||
test "relay includes CORS headers on a POST response", %{conn: conn} do |
||||
# 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") == ["*"] |
||||
end |
||||
|
||||
test "relay responds 200 to a browser preflight OPTIONS request", %{conn: conn} do |
||||
conn = options(conn, "/api/events") |
||||
|
||||
assert conn.status == 200 |
||||
assert conn.resp_body == "" |
||||
assert get_resp_header(conn, "access-control-allow-origin") == ["*"] |
||||
methods = get_resp_header(conn, "access-control-allow-methods") |> List.first("") |
||||
assert methods =~ "OPTIONS" |
||||
end |
||||
end |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# NIP-11 relay information document |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
describe "GET / with Accept: application/nostr+json — NIP-11" do |
||||
test "returns 200 with application/nostr+json content-type", %{conn: conn} do |
||||
conn = |
||||
conn |
||||
|> put_req_header("accept", "application/nostr+json") |
||||
|> get("/") |
||||
|
||||
assert conn.status == 200 |
||||
[content_type | _] = get_resp_header(conn, "content-type") |
||||
assert content_type =~ "application/nostr+json" |
||||
end |
||||
|
||||
test "response body is valid JSON with required NIP-11 fields", %{conn: conn} do |
||||
conn = |
||||
conn |
||||
|> put_req_header("accept", "application/nostr+json") |
||||
|> get("/") |
||||
|
||||
assert {:ok, body} = Jason.decode(conn.resp_body) |
||||
assert is_binary(body["name"]) |
||||
assert is_list(body["supported_nips"]) |
||||
assert is_map(body["limitation"]) |
||||
assert is_binary(body["icon"]) and String.starts_with?(body["icon"], "http") |
||||
end |
||||
|
||||
test "supported_nips list includes NIP-11 and NIP-70", %{conn: conn} do |
||||
conn = |
||||
conn |
||||
|> put_req_header("accept", "application/nostr+json") |
||||
|> get("/") |
||||
|
||||
assert {:ok, %{"supported_nips" => nips}} = Jason.decode(conn.resp_body) |
||||
assert 11 in nips |
||||
assert 70 in nips |
||||
end |
||||
|
||||
test "limitation object contains expected fields", %{conn: conn} do |
||||
conn = |
||||
conn |
||||
|> put_req_header("accept", "application/nostr+json") |
||||
|> get("/") |
||||
|
||||
assert {:ok, %{"limitation" => limitation}} = Jason.decode(conn.resp_body) |
||||
assert Map.has_key?(limitation, "max_limit") |
||||
assert Map.has_key?(limitation, "auth_required") |
||||
assert Map.has_key?(limitation, "payment_required") |
||||
end |
||||
|
||||
test "NIP-11 response includes CORS headers", %{conn: conn} do |
||||
conn = |
||||
conn |
||||
|> put_req_header("accept", "application/nostr+json") |
||||
|> get("/") |
||||
|
||||
assert get_resp_header(conn, "access-control-allow-origin") == ["*"] |
||||
end |
||||
|
||||
test "regular browser request to GET / still returns HTML", %{conn: conn} do |
||||
conn = |
||||
conn |
||||
|> put_req_header("accept", "text/html,application/xhtml+xml") |
||||
|> get("/") |
||||
|
||||
assert conn.status == 200 |
||||
[content_type | _] = get_resp_header(conn, "content-type") |
||||
assert content_type =~ "text/html" |
||||
end |
||||
end |
||||
|
||||
# --------------------------------------------------------------------------- |
||||
# Health check |
||||
# --------------------------------------------------------------------------- |
||||
|
||||
describe "GET /health — health check" do |
||||
test "health check returns 200", %{conn: conn} do |
||||
conn = get(conn, "/health") |
||||
assert conn.status == 200 |
||||
end |
||||
end |
||||
end |
||||
Loading…
Reference in new issue