8 Commits

Author SHA1 Message Date
Jon Staab 791a4fcb70 Fix relay link, add admin navigation item on mobile
Docker / build-and-push-image (push) Successful in 1h6m43s
2026-06-04 16:00:19 -07:00
Jon Staab 28d1d164f1 Bring back chachi
Docker / build-and-push-image (push) Successful in 1h6m45s
2026-06-03 20:50:20 -07:00
Jon Staab 1097a5eba3 Add link to home page at the top of the sidebar
Docker / build-and-push-image (push) Successful in 1h8m8s
2026-06-03 17:10:38 -07:00
Jon Staab e3083304d0 Disable logging when serving frontend
Docker / build-and-push-image (push) Has been cancelled
2026-06-03 17:07:53 -07:00
Jon Staab 451264106a Make logo customizable
Docker / build-and-push-image (push) Has been cancelled
2026-06-03 16:46:20 -07:00
Jon Staab 96e2fcda49 Use cargo chef to speed up builds 2026-06-03 16:27:56 -07:00
Jon Staab 73cad3a153 Tweak docs 2026-06-03 15:39:56 -07:00
Jon Staab 5f8b08e02c Clean up the dockerfile a bit
Docker / build-and-push-image (push) Successful in 52m10s
2026-06-03 15:32:48 -07:00
11 changed files with 187 additions and 55 deletions
+28 -39
View File
@@ -1,20 +1,36 @@
# syntax=docker/dockerfile:1
# ---------- Build the Rust backend ----------
FROM rust:1.94-bookworm AS backend-build
# cargo-chef caches the compiled dependency graph in its own layer, so a
# source-only change recompiles just our crate instead of every dependency from
# scratch. The recipe is derived solely from Cargo.toml/Cargo.lock, so the
# expensive `cook` layer is reused until the dependency set changes.
# https://github.com/LukeMathWalker/cargo-chef
FROM rust:1.94-bookworm AS chef
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
pkg-config \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/* \
&& cargo install --locked cargo-chef
# Distill the dependency recipe. Cheap, and re-runs on any source change, but
# yields a stable recipe.json as long as dependencies are unchanged — which is
# what keeps the cook layer below cached.
FROM chef AS planner
COPY backend/Cargo.toml backend/Cargo.lock ./
COPY backend/src ./src
RUN cargo chef prepare --recipe-path recipe.json
# Build (and cache) dependencies from the recipe, then compile the application.
# Must share the chef stage's Rust version for the cached layer to apply.
FROM chef AS backend-build
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY backend/Cargo.toml backend/Cargo.lock ./
COPY backend/src ./src
COPY backend/migrations ./migrations
RUN cargo build --release
# ---------- Build the frontend with placeholder config ----------
@@ -35,7 +51,8 @@ COPY frontend ./
ENV VITE_API_URL=__VITE_API_URL__ \
VITE_RELAY_DOMAIN=__VITE_RELAY_DOMAIN__ \
VITE_PLATFORM_NAME=__VITE_PLATFORM_NAME__
VITE_PLATFORM_NAME=__VITE_PLATFORM_NAME__ \
VITE_PLATFORM_LOGO=__VITE_PLATFORM_LOGO__
RUN bun run build
# ---------- Runtime ----------
@@ -54,42 +71,14 @@ WORKDIR /app
COPY --from=backend-build /app/target/release/backend /app/backend
COPY --from=frontend-build /app/frontend/dist /app/dist
COPY --chmod=0755 entrypoint.sh /app/entrypoint.sh
# Single entrypoint: substitute the real config into the prebuilt bundle, then
# run both processes and exit (so the orchestrator restarts us) if either dies.
RUN cat > /app/entrypoint.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
RUN mkdir -p /app/data && chown -R node:node /app/dist /app/data
# Map the provided runtime variables onto the frontend's VITE_* placeholders.
VITE_API_URL="${SERVER_URL:-}"
VITE_RELAY_DOMAIN="${RELAY_DOMAIN:-}"
VITE_PLATFORM_NAME="${PLATFORM_NAME:-}"
USER node:node
# Escape characters that are special in a sed replacement.
esc() { printf '%s' "$1" | sed -e 's/[&|\\]/\\&/g'; }
echo "Applying runtime configuration to the frontend bundle..."
while IFS= read -r -d '' f; do
sed -i \
-e "s|__VITE_API_URL__|$(esc "$VITE_API_URL")|g" \
-e "s|__VITE_RELAY_DOMAIN__|$(esc "$VITE_RELAY_DOMAIN")|g" \
-e "s|__VITE_PLATFORM_NAME__|$(esc "$VITE_PLATFORM_NAME")|g" \
"$f"
done < <(find /app/dist -type f \( -name '*.js' -o -name '*.html' \) -print0)
echo "Starting backend (:2892) and frontend (:3000)..."
/app/backend &
backend_pid=$!
serve -s /app/dist -l 3000 &
serve_pid=$!
trap 'kill -TERM "$backend_pid" "$serve_pid" 2>/dev/null || true' TERM INT
# Exit as soon as either process exits.
wait -n
EOF
RUN chmod +x /app/entrypoint.sh
ENV SERVER_PORT=2892 \
DATABASE_URL=sqlite:///app/data/caravel.db
EXPOSE 2892 3000
+1 -2
View File
@@ -17,12 +17,11 @@ docker run -d \
-p 3000:3000 \
-v my-caravel-data:/app/data \
-e PLATFORM_NAME=Caravel \
-e PLATFORM_LOGO=/caravel.png \
-e RELAY_DOMAIN=example.com \
-e APP_URL=https://example.com \
-e ZOOID_API_URL=http://zooid:3334 \
-e DATABASE_URL=sqlite://data/caravel.db \
-e SERVER_URL=https://api.example.com \
-e SERVER_PORT=2892 \
-e SERVER_ADMIN_PUBKEYS=<your-hex-pubkey> \
-e SERVER_ALLOW_ORIGINS=https://example.com \
-e ROBOT_SECRET=<hex-nostr-secret-key> \
+2 -2
View File
@@ -43,11 +43,11 @@ All configuration is read from the environment by `Env::load()` at startup. **Ev
| Variable | Description |
| ---------------------- | ------------------------------------------------------------------- |
| `SERVER_URL` | Public API base URL; the value the NIP-98 `u` tag must equal exactly |
| `SERVER_PORT` | API bind port (the server always binds to `127.0.0.1`) |
| `SERVER_PORT` | API bind port; the server binds `0.0.0.0` (all interfaces) |
| `SERVER_ADMIN_PUBKEYS` | Comma-separated admin pubkeys (hex) |
| `SERVER_ALLOW_ORIGINS` | Comma-separated allowed CORS origins |
| `APP_URL` | Frontend base URL; used to build links in DMs and invoices |
| `DATABASE_URL` | SQLite URL; relative `sqlite://` paths are resolved under `backend/` |
| `DATABASE_URL` | SQLite URL; relative `sqlite://` paths resolve under the compile-time crate dir (`backend/` in dev, `/app` in the Docker image) |
**Robot identity**
+1 -1
View File
@@ -61,7 +61,7 @@ async fn main() -> Result<()> {
billing.start().await;
});
let url = format!("127.0.0.1:{}", env::get().server_port);
let url = format!("0.0.0.0:{}", env::get().server_port);
let listener = tokio::net::TcpListener::bind(url).await?;
axum::serve(listener, app).await?;
Executable
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Substitute the real config into the prebuilt frontend bundle, then run the
# backend and static server, exiting (so the orchestrator restarts us) if either
# process dies.
set -euo pipefail
# Map the provided runtime variables onto the frontend's VITE_* placeholders.
VITE_API_URL="${SERVER_URL:-}"
VITE_RELAY_DOMAIN="${RELAY_DOMAIN:-}"
VITE_PLATFORM_NAME="${PLATFORM_NAME:-}"
VITE_PLATFORM_LOGO="${PLATFORM_LOGO:-/caravel.png}"
# Escape characters that are special in a sed replacement.
esc() { printf '%s' "$1" | sed -e 's/[&|\\]/\\&/g'; }
echo "Applying runtime configuration to the frontend bundle..."
while IFS= read -r -d '' f; do
sed -i \
-e "s|__VITE_API_URL__|$(esc "$VITE_API_URL")|g" \
-e "s|__VITE_RELAY_DOMAIN__|$(esc "$VITE_RELAY_DOMAIN")|g" \
-e "s|__VITE_PLATFORM_NAME__|$(esc "$VITE_PLATFORM_NAME")|g" \
-e "s|__VITE_PLATFORM_LOGO__|$(esc "$VITE_PLATFORM_LOGO")|g" \
"$f"
done < <(find /app/dist -type f \( -name '*.js' -o -name '*.html' \) -print0)
echo "Starting backend (:2892) and frontend (:3000)..."
/app/backend &
backend_pid=$!
serve -L -s /app/dist -l 3000 &
serve_pid=$!
trap 'kill -TERM "$backend_pid" "$serve_pid" 2>/dev/null || true' TERM INT
# Exit as soon as either process exits.
wait -n
+3
View File
@@ -6,3 +6,6 @@ VITE_RELAY_DOMAIN=spaces.coracle.social
# Platform display name shown in UI
VITE_PLATFORM_NAME=Caravel
# Platform logo shown in UI (path under public/, or an absolute URL)
VITE_PLATFORM_LOGO=/caravel.png
+66 -1
View File
@@ -1,7 +1,7 @@
import { A, useLocation } from "@solidjs/router"
import { createMemo, createSignal, For, Show } from "solid-js"
import { useProfileMetadata, useProfilePicture, useTenantRelays, type Relay } from "@/lib/hooks"
import { account, identity } from "@/lib/state"
import { account, identity, PLATFORM_LOGO, PLATFORM_NAME } from "@/lib/state"
import { fuzzySearch } from "@/lib/search"
import { RELAY_DOMAIN } from "@/lib/subdomain"
import serverIcon from "@/assets/server.svg"
@@ -26,6 +26,14 @@ function RelayIcon() {
return <img src={serverIcon} alt="" aria-hidden="true" class="h-5 w-5" />
}
function AdminIcon() {
return (
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
)
}
export default function AppShell(props: { children?: any }) {
const location = useLocation()
const picture = useProfilePicture(() => account()?.pubkey)
@@ -33,6 +41,7 @@ export default function AppShell(props: { children?: any }) {
const [tenantRelays] = useTenantRelays()
const [searchOpen, setSearchOpen] = createSignal(false)
const [searchQuery, setSearchQuery] = createSignal("")
const [adminOpen, setAdminOpen] = createSignal(false)
const username = createMemo(() => metadata()?.name || metadata()?.display_name || shortenPubkey(account()?.pubkey))
const nip05 = createMemo(() => metadata()?.nip05)
@@ -57,15 +66,28 @@ export default function AppShell(props: { children?: any }) {
: "block rounded-lg px-3 py-2 text-sm text-white/80 hover:bg-white/10 hover:text-white"
}
const mobileNavItemClass = (href: string) => {
const active = location.pathname === href || location.pathname.startsWith(`${href}/`)
return active
? "block rounded-lg bg-gray-100 px-3 py-3 text-sm font-medium text-gray-900"
: "block rounded-lg px-3 py-3 text-sm text-gray-700 hover:bg-gray-100"
}
const openSearchModal = () => setSearchOpen(true)
const closeSearchModal = () => {
setSearchOpen(false)
setSearchQuery("")
}
const openAdminModal = () => setAdminOpen(true)
const closeAdminModal = () => setAdminOpen(false)
return (
<div class="min-h-screen bg-gray-50">
<aside class="hidden md:flex fixed inset-y-0 left-0 w-[260px] bg-slate-900 text-white flex-col z-10">
<A href="/" class="flex items-center gap-3 border-b border-white/15 px-7 py-5 text-lg font-bold text-white hover:bg-white/10">
<img src={PLATFORM_LOGO} alt={PLATFORM_NAME} class="h-7 w-7 rounded" />
<span class="truncate">{PLATFORM_NAME}</span>
</A>
<div class="flex-1 px-4 py-6">
<h2 class="px-3 text-xs font-semibold uppercase tracking-wider text-white/60">Resources</h2>
<ul class="mt-2 space-y-1">
@@ -111,6 +133,7 @@ export default function AppShell(props: { children?: any }) {
class="fixed inset-x-0 bottom-0 z-20 border-t border-gray-200 bg-white md:hidden"
onClick={() => {
if (searchOpen()) closeSearchModal()
if (adminOpen()) closeAdminModal()
}}
>
<div class="flex h-16 items-center justify-between px-6">
@@ -129,6 +152,19 @@ export default function AppShell(props: { children?: any }) {
<A href="/relays" aria-label="Relays" class="rounded-lg p-2 text-gray-700 hover:bg-gray-100">
<RelayIcon />
</A>
<Show when={identity()?.is_admin}>
<button
type="button"
aria-label="Administration"
class="rounded-lg p-2 text-gray-700 hover:bg-gray-100"
onClick={(e) => {
e.stopPropagation()
openAdminModal()
}}
>
<AdminIcon />
</button>
</Show>
</div>
<A href="/account" aria-label="Account settings" class="rounded-full">
<Show
@@ -198,6 +234,35 @@ export default function AppShell(props: { children?: any }) {
</Show>
</div>
</Modal>
<Modal
open={adminOpen()}
onClose={closeAdminModal}
wrapperClass="fixed inset-x-0 top-0 bottom-16 z-30 flex items-end bg-black/40 p-0 md:hidden"
panelClass="w-full overflow-hidden rounded-t-2xl bg-white"
>
<div class="flex items-center justify-between border-b border-gray-200 p-4">
<h2 class="text-xs font-semibold uppercase tracking-wider text-gray-500">Administration</h2>
<button
type="button"
class="rounded-lg px-3 py-2 text-sm text-gray-600 hover:bg-gray-100"
onClick={closeAdminModal}
>
Close
</button>
</div>
<ul class="space-y-1 p-4">
<For each={adminResources}>
{(item) => (
<li>
<A href={item.href} class={mobileNavItemClass(item.href)} onClick={closeAdminModal}>
{item.label}
</A>
</li>
)}
</For>
</ul>
</Modal>
</div>
)
}
@@ -84,7 +84,7 @@ export default function RelayCardHeader(props: RelayCardHeaderProps) {
<StatusBadge status={r().status} />
</div>
<a
href={`wss://${r().subdomain}.${RELAY_DOMAIN}`}
href={`https://${r().subdomain}.${RELAY_DOMAIN}`}
class="text-sm text-blue-600 hover:underline break-all"
>
wss://{r().subdomain}.{RELAY_DOMAIN}
+1
View File
@@ -24,6 +24,7 @@ interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_RELAY_DOMAIN: string
readonly VITE_PLATFORM_NAME?: string
readonly VITE_PLATFORM_LOGO?: string
}
interface ImportMeta {
+1
View File
@@ -27,6 +27,7 @@ export type EventSigner = {
}
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
export const PLATFORM_LOGO = import.meta.env.VITE_PLATFORM_LOGO
export const eventStore = new EventStore()
export const pool = new RelayPool()
+48 -9
View File
@@ -10,9 +10,10 @@ import PaymentSetup from "@/components/PaymentSetup"
import Login from "@/views/Login"
import { createRelayForActiveTenant, resolvePostPaidFlow } from "@/lib/hooks"
import type { Invoice } from "@/lib/api"
import { account, refetchBilling, setToastMessage } from "@/lib/state"
import { account, PLATFORM_LOGO, PLATFORM_NAME, refetchBilling, setToastMessage } from "@/lib/state"
import FlotillaLogo from "@/assets/flotilla-logo.svg"
import NostordLogo from "@/assets/nostord-logo.svg"
import ChachiLogo from "@/assets/chachi-logo.svg"
export default function Home() {
const navigate = useNavigate()
@@ -100,8 +101,8 @@ export default function Home() {
<nav class="sticky top-0 z-20 bg-white/80 backdrop-blur border-b border-gray-100">
<div class="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
<div class="flex items-center gap-2 font-bold text-gray-900">
<img src="/caravel.png" alt="Caravel" class="w-7 h-7 rounded" />
Caravel
<img src={PLATFORM_LOGO} alt={PLATFORM_NAME} class="w-7 h-7 rounded" />
{PLATFORM_NAME}
</div>
<Show
when={account()}
@@ -177,7 +178,7 @@ export default function Home() {
<div class="max-w-5xl mx-auto px-6 py-20">
<h2 class="text-3xl font-bold text-center text-gray-900 mb-4">Everything you need</h2>
<p class="text-center text-gray-500 mb-14 max-w-xl mx-auto">
Caravel takes care of the infrastructure so you can focus on building your community.
{PLATFORM_NAME} takes care of the infrastructure so you can focus on building your community.
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{[
@@ -340,6 +341,44 @@ export default function Home() {
</span>
</div>
</a>
{/* Chachi */}
<a
href="https://chachi.chat"
target="_blank"
rel="noopener noreferrer"
class="group flex flex-col gap-5 rounded-2xl border border-gray-200 bg-white p-8 hover:border-purple-300 hover:shadow-md transition-all"
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<img src={ChachiLogo} alt="Chachi" class="w-12 h-12 rounded-2xl shadow-md shadow-purple-200" />
<div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-purple-600 transition-colors">Chachi</h3>
<p class="text-xs text-gray-400">chachi.chat</p>
</div>
</div>
<span class="text-gray-300 group-hover:text-purple-400 transition-colors mt-1">
<ExternalLinkIcon />
</span>
</div>
<p class="text-sm text-gray-600 leading-relaxed">
A group chat app built on top of Nostr. Chachi makes it easy for your community
to have real-time conversations, all flowing through your own relay.
</p>
<div class="space-y-2">
{["Real-time group messaging", "Bring your own relay", "No accounts — just your Nostr key"].map(f => (
<div class="flex items-start gap-2 text-sm text-gray-600">
<CheckIcon />
{f}
</div>
))}
</div>
<div class="mt-auto pt-2">
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-purple-600">
Visit chachi.chat <ExternalLinkIcon />
</span>
</div>
</a>
</div>
</section>
@@ -361,7 +400,7 @@ export default function Home() {
<div class="relative max-w-5xl mx-auto px-6 py-28 text-center">
<h2 class="text-4xl font-extrabold text-gray-900 mb-4">Ready to launch your relay?</h2>
<p class="text-gray-500 mb-10 max-w-lg mx-auto text-lg">
Join communities already running on Caravel. Set up in minutes.
Join communities already running on {PLATFORM_NAME}. Set up in minutes.
</p>
<button
type="button"
@@ -378,14 +417,14 @@ export default function Home() {
<footer class="border-t border-gray-100 bg-gray-50">
<div class="max-w-5xl mx-auto px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-gray-400">
<div class="flex items-center gap-2 font-semibold text-gray-500">
<img src="/caravel.png" alt="Caravel" class="w-5 h-5 rounded" />
Caravel
<img src={PLATFORM_LOGO} alt={PLATFORM_NAME} class="w-5 h-5 rounded" />
{PLATFORM_NAME}
</div>
<p>© {new Date().getFullYear()} Caravel. Built on Nostr.</p>
<p>© {new Date().getFullYear()} {PLATFORM_NAME}. Built on Nostr.</p>
<div class="flex gap-4">
<a href="https://flotilla.social" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Flotilla</a>
<a href="https://chachi.chat" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Chachi</a>
<a href="https://nostrord.com/" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Nostrord</a>
<a href="https://chachi.chat" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Chachi</a>
</div>
</div>
</footer>