Implement outbox for profile lookup
This commit is contained in:
+22
-1
@@ -35,6 +35,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/tenant/billing", put(update_tenant_billing));
|
||||
|
||||
let admin_routes = Router::new()
|
||||
.route("/admin/check", get(admin_check))
|
||||
.route("/admin/tenants", get(admin_list_tenants))
|
||||
.route(
|
||||
"/admin/tenants/:pubkey",
|
||||
@@ -108,7 +109,7 @@ fn extract_auth_pubkey(headers: &HeaderMap, method: &Method, uri: &Uri) -> Resul
|
||||
let path = uri.path_and_query().map(|v| v.as_str()).unwrap_or(uri.path());
|
||||
let url = format!("{}://{}{}", scheme, host, path);
|
||||
let pubkey = verify_nip98(auth_header, &url, method.as_str())?;
|
||||
Ok(pubkey.to_string())
|
||||
Ok(pubkey.to_hex())
|
||||
}
|
||||
|
||||
async fn get_tenant(
|
||||
@@ -396,6 +397,26 @@ async fn admin_list_tenants(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AdminCheckResponse {
|
||||
is_admin: bool,
|
||||
}
|
||||
|
||||
async fn admin_check(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
) -> Response {
|
||||
let pubkey = match extract_auth_pubkey(&headers, &method, &uri) {
|
||||
Ok(pubkey) => pubkey,
|
||||
Err(_) => return unauthorized(),
|
||||
};
|
||||
|
||||
let is_admin = state.admin_pubkeys.contains(&pubkey);
|
||||
(StatusCode::OK, Json(AdminCheckResponse { is_admin })).into_response()
|
||||
}
|
||||
|
||||
async fn admin_get_tenant(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
|
||||
+16
-1
@@ -1,5 +1,8 @@
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
|
||||
use nostr_sdk::nostr::key::PublicKey;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
@@ -31,7 +34,7 @@ impl Config {
|
||||
let admin_pubkeys = env::var("PLATFORM_ADMIN_PUBKEYS")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter_map(normalize_pubkey)
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let zooid_api_url =
|
||||
@@ -74,6 +77,18 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_pubkey(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
match PublicKey::from_str(trimmed) {
|
||||
Ok(pubkey) => Some(pubkey.to_hex()),
|
||||
Err(_) => Some(trimmed.to_lowercase()),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_database_url(database_url: String) -> String {
|
||||
const PREFIX: &str = "sqlite://";
|
||||
if !database_url.starts_with(PREFIX) {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"applesauce-accounts": "^5.1.0",
|
||||
"applesauce-common": "^5.1.0",
|
||||
"applesauce-core": "^5.1.0",
|
||||
"applesauce-loaders": "^5.1.0",
|
||||
"applesauce-relay": "^5.1.0",
|
||||
"applesauce-signers": "^5.1.0",
|
||||
"applesauce-wallet-connect": "^5.0.1",
|
||||
@@ -281,6 +282,8 @@
|
||||
|
||||
"applesauce-core": ["applesauce-core@5.1.0", "", { "dependencies": { "debug": "^4.4.0", "fast-deep-equal": "^3.1.3", "hash-sum": "^2.0.0", "nanoid": "^5.0.9", "nostr-tools": "~2.19", "rxjs": "^7.8.1" } }, "sha512-kk4nHndK4zjS8Sa6mC8LGtQ0LDSP4hlCGPJ9lpyIln7MkZaNFWD9eFd+fsEhfE9kyrne9IyYuVfJNp+EqY1b9w=="],
|
||||
|
||||
"applesauce-loaders": ["applesauce-loaders@5.1.0", "", { "dependencies": { "applesauce-core": "^5.1.0", "nanoid": "^5.0.9", "rxjs": "^7.8.1" } }, "sha512-xllWYl7KxG0oaJqKVdZNzxN8OZQoDMaMmaTAO9Ao1Son+mmJyR9Q4UVicSwSlzzHarf59WCfvJeSdrSHIitkHg=="],
|
||||
|
||||
"applesauce-relay": ["applesauce-relay@5.1.0", "", { "dependencies": { "@noble/hashes": "^1.7.1", "applesauce-core": "^5.1.0", "nanoid": "^5.0.9", "nostr-tools": "~2.19", "rxjs": "^7.8.1" } }, "sha512-d0LTJmQmr5gsYFm9A6efPEo2Bx/ewoL7LNsIdieMx34QohZBpPb137RvU9KQ1lFIXTm0tudd8VYfAPncqti2OQ=="],
|
||||
|
||||
"applesauce-signers": ["applesauce-signers@5.1.0", "", { "dependencies": { "@noble/secp256k1": "^1.7.1", "applesauce-core": "^5.1.0", "debug": "^4.4.0", "nanoid": "^5.0.9", "rxjs": "^7.8.2" } }, "sha512-sdQe6J1txYV1GVX8/zSGZDyyXuuZomePHSfUDozZmNAnXhCXE0wqVfhLK0yegVMnomSgoeDUCsGmJiTE2BHqoQ=="],
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"applesauce-accounts": "^5.1.0",
|
||||
"applesauce-common": "^5.1.0",
|
||||
"applesauce-core": "^5.1.0",
|
||||
"applesauce-loaders": "^5.1.0",
|
||||
"applesauce-relay": "^5.1.0",
|
||||
"applesauce-signers": "^5.1.0",
|
||||
"applesauce-wallet-connect": "^5.0.1",
|
||||
|
||||
@@ -15,7 +15,7 @@ import AdminRelays from "./pages/admin/AdminRelays"
|
||||
import AdminRelayDetail from "./pages/admin/AdminRelayDetail"
|
||||
import AdminRelayEdit from "./pages/admin/AdminRelayEdit"
|
||||
import { useActiveAccount } from "./lib/nostr"
|
||||
import { adminListTenants } from "./lib/api"
|
||||
import { adminCheck as fetchAdminCheck } from "./lib/api"
|
||||
|
||||
function Layout(props: { children?: any }) {
|
||||
const location = useLocation()
|
||||
@@ -54,10 +54,7 @@ export default function App() {
|
||||
const account = useActiveAccount()
|
||||
const [adminCheck] = createResource(
|
||||
() => account()?.id,
|
||||
async () => {
|
||||
await adminListTenants()
|
||||
return true
|
||||
},
|
||||
() => fetchAdminCheck(),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
@@ -68,7 +65,7 @@ export default function App() {
|
||||
<Show when={account()}>
|
||||
<Show when={!adminCheck.loading} fallback={<div class="max-w-4xl mx-auto px-4 py-8 text-gray-500">Checking admin access...</div>}>
|
||||
<Show
|
||||
when={!adminCheck.error}
|
||||
when={!adminCheck.error && !!adminCheck()?.is_admin}
|
||||
fallback={<div class="max-w-4xl mx-auto px-4 py-8 text-red-600">You do not have admin access.</div>}
|
||||
>
|
||||
<Page />
|
||||
|
||||
@@ -9,14 +9,11 @@ export default function Navbar() {
|
||||
return (
|
||||
<nav class="bg-white border-b border-gray-200">
|
||||
<div class="max-w-4xl mx-auto px-4 h-14 flex items-center justify-between">
|
||||
<A href="/" class="flex items-center gap-2">
|
||||
<A href={account() ? "/relays" : "/"} class="flex items-center gap-2">
|
||||
<img src="/caravel.png" alt={PLATFORM_NAME} class="h-8 w-8 rounded-full object-cover" />
|
||||
<span class="font-bold text-gray-900 text-lg">{PLATFORM_NAME}</span>
|
||||
</A>
|
||||
<div class="flex items-center gap-4">
|
||||
<A href="/relays" class="text-sm text-gray-600 hover:text-gray-900">
|
||||
Dashboard
|
||||
</A>
|
||||
<Show
|
||||
when={account()}
|
||||
fallback={
|
||||
|
||||
@@ -124,6 +124,10 @@ export type UpdateRelayInput = {
|
||||
plan: string
|
||||
}
|
||||
|
||||
export type AdminCheck = {
|
||||
is_admin: boolean
|
||||
}
|
||||
|
||||
export function listTenantRelays() {
|
||||
return request<Relay[]>("/tenant/relays")
|
||||
}
|
||||
@@ -179,6 +183,10 @@ export function adminListTenants() {
|
||||
return request<Tenant[]>("/admin/tenants")
|
||||
}
|
||||
|
||||
export function adminCheck() {
|
||||
return request<AdminCheck>("/admin/check")
|
||||
}
|
||||
|
||||
export function adminGetTenant(pubkey: string) {
|
||||
return request<TenantDetail>(`/admin/tenants/${pubkey}`)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { EventStore } from "applesauce-core"
|
||||
import { getProfilePicture } from "applesauce-core/helpers/profile"
|
||||
import { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection"
|
||||
import { includeMailboxes } from "applesauce-core/observable"
|
||||
import { RelayPool } from "applesauce-relay"
|
||||
import { AccountManager } from "applesauce-accounts"
|
||||
import type { IAccount, SerializedAccount } from "applesauce-accounts"
|
||||
import { registerCommonAccountTypes } from "applesauce-accounts/accounts"
|
||||
import { createEventLoaderForStore } from "applesauce-loaders/loaders"
|
||||
import { NostrConnectSigner } from "applesauce-signers"
|
||||
import { map, of } from "rxjs"
|
||||
|
||||
export const API_URL = import.meta.env.VITE_API_URL
|
||||
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME || "Caravel"
|
||||
|
||||
const PROFILE_RELAYS = ["wss://purplepag.es", "wss://relay.damus.io", "wss://nos.lol"]
|
||||
|
||||
export const eventStore = new EventStore()
|
||||
export const pool = new RelayPool()
|
||||
export const accounts = new AccountManager()
|
||||
|
||||
createEventLoaderForStore(eventStore, pool, {
|
||||
lookupRelays: ["wss://purplepag.es/", "wss://relay.damus.io/", "wss://indexer.coracle.social/"],
|
||||
extraRelays: ["wss://relay.damus.io/", "wss://nos.lol/", "wss://relay.primal.net/"],
|
||||
})
|
||||
|
||||
const ACCOUNTS_STORAGE_KEY = "caravel.accounts"
|
||||
const ACTIVE_ACCOUNT_STORAGE_KEY = "caravel.activeAccount"
|
||||
|
||||
@@ -79,10 +86,7 @@ export function useProfilePicture(pubkey: () => string | undefined) {
|
||||
setPicture(getProfilePicture(profile))
|
||||
})
|
||||
|
||||
// Fetch the kind 0 from relays and add to the event store
|
||||
const reqSub = pool.request(PROFILE_RELAYS, { kinds: [0], authors: [pk] }).subscribe(event => {
|
||||
eventStore.add(event)
|
||||
})
|
||||
const reqSub = primeProfiles([pk])
|
||||
|
||||
onCleanup(() => {
|
||||
profileSub.unsubscribe()
|
||||
@@ -92,3 +96,35 @@ export function useProfilePicture(pubkey: () => string | undefined) {
|
||||
|
||||
return picture
|
||||
}
|
||||
|
||||
export function primeProfiles(pubkeys: string[]) {
|
||||
const uniquePubkeys = Array.from(new Set(pubkeys.filter(Boolean)))
|
||||
if (uniquePubkeys.length === 0) {
|
||||
return { unsubscribe() {} }
|
||||
}
|
||||
|
||||
const seedRelays = Array.from(pool.relays.keys())
|
||||
const mailboxSeedSub = seedRelays.length
|
||||
? pool.request(seedRelays, { kinds: [10002], authors: uniquePubkeys }).subscribe((event) => {
|
||||
eventStore.add(event)
|
||||
})
|
||||
: undefined
|
||||
|
||||
const outboxMap$ = of(uniquePubkeys.map((pubkey) => ({ pubkey }))).pipe(
|
||||
includeMailboxes(eventStore),
|
||||
map((pointers) => (seedRelays.length > 0 ? setFallbackRelays(pointers, seedRelays) : pointers)),
|
||||
map((pointers) => selectOptimalRelays(pointers, { maxConnections: 8, maxRelaysPerUser: 3 })),
|
||||
map(createOutboxMap),
|
||||
)
|
||||
|
||||
const profileSub = pool.outboxSubscription(outboxMap$, { kinds: [0] }).subscribe((message) => {
|
||||
if (message !== "EOSE") eventStore.add(message)
|
||||
})
|
||||
|
||||
return {
|
||||
unsubscribe() {
|
||||
profileSub.unsubscribe()
|
||||
mailboxSeedSub?.unsubscribe()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,24 +26,23 @@ export default function Account() {
|
||||
|
||||
return (
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">Account</h1>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">My Account</h1>
|
||||
|
||||
<div class="space-y-6">
|
||||
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Account Status</h2>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Account Status</h2>
|
||||
<Show when={tenant()}>
|
||||
{(t) => (
|
||||
<span class="rounded-full border border-gray-300 bg-gray-100 px-2.5 py-1 text-xs font-medium uppercase tracking-wide text-gray-700">
|
||||
{t().status}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={tenant.loading}>
|
||||
<p class="text-gray-500">Loading account...</p>
|
||||
</Show>
|
||||
<Show when={tenant()}>
|
||||
{(t) => (
|
||||
<div class="text-sm text-gray-700 space-y-2">
|
||||
<p>
|
||||
Status: <span class="font-medium capitalize">{t().status}</span>
|
||||
</p>
|
||||
<p class="break-all">Tenant pubkey: {t().pubkey}</p>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||
@@ -51,12 +50,6 @@ export default function Account() {
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Enable automatic payments by providing your Nostr Wallet Connect URL.
|
||||
</p>
|
||||
<p class="text-sm mb-3">
|
||||
Current setting:{" "}
|
||||
<span class={recurringEnabled() ? "text-green-700 font-medium" : "text-gray-600"}>
|
||||
{recurringEnabled() ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { A } from "@solidjs/router"
|
||||
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, Show } from "solid-js"
|
||||
import { getProfilePicture } from "applesauce-core/helpers/profile"
|
||||
import { adminListTenants } from "../../lib/api"
|
||||
import { eventStore, primeProfiles } from "../../lib/nostr"
|
||||
|
||||
function shortenPubkey(pubkey: string) {
|
||||
if (pubkey.length <= 16) return pubkey
|
||||
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
|
||||
}
|
||||
|
||||
export default function AdminTenants() {
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [tenants] = createResource(adminListTenants)
|
||||
const [profiles, setProfiles] = createSignal<Record<string, { name?: string, about?: string, picture?: string }>>({})
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const q = query().trim().toLowerCase()
|
||||
@@ -14,6 +22,31 @@ export default function AdminTenants() {
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const list = tenants() ?? []
|
||||
if (!list.length) return
|
||||
|
||||
const pubkeys = list.map(t => t.pubkey)
|
||||
const reqSub = primeProfiles(pubkeys)
|
||||
const profileSubs = pubkeys.map((pubkey) =>
|
||||
eventStore.profile(pubkey).subscribe((profile) => {
|
||||
setProfiles(prev => ({
|
||||
...prev,
|
||||
[pubkey]: {
|
||||
name: profile?.name || profile?.display_name,
|
||||
about: profile?.about,
|
||||
picture: getProfilePicture(profile),
|
||||
},
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
reqSub.unsubscribe()
|
||||
for (const sub of profileSubs) sub.unsubscribe()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">Tenants</h1>
|
||||
@@ -34,16 +67,32 @@ export default function AdminTenants() {
|
||||
<Show when={(filtered().length ?? 0) > 0} fallback={<p class="text-gray-500">No tenants found.</p>}>
|
||||
<ul class="space-y-3">
|
||||
<For each={filtered()}>
|
||||
{(tenant) => (
|
||||
{(tenant) => {
|
||||
const profile = () => profiles()[tenant.pubkey]
|
||||
|
||||
return (
|
||||
<li>
|
||||
<A href={`/admin/tenants/${tenant.pubkey}`} class="block border border-gray-200 rounded-lg p-4 bg-white hover:border-gray-300">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-sm break-all text-gray-700">{tenant.pubkey}</p>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex items-start gap-3">
|
||||
<Show
|
||||
when={profile()?.picture}
|
||||
fallback={<div class="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-xs">{(profile()?.name || tenant.pubkey).slice(0, 1).toUpperCase()}</div>}
|
||||
>
|
||||
<img src={profile()?.picture} alt="Profile" class="h-10 w-10 rounded-full object-cover" />
|
||||
</Show>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-gray-900 truncate">{profile()?.name || shortenPubkey(tenant.pubkey)}</p>
|
||||
<p class="mt-1 text-sm text-gray-600 line-clamp-2">{profile()?.about || "No profile bio"}</p>
|
||||
<p class="mt-1 text-xs text-gray-500 break-all">{tenant.pubkey}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs uppercase tracking-wide text-gray-500">{tenant.status}</p>
|
||||
</div>
|
||||
</A>
|
||||
</li>
|
||||
)}
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
|
||||
Reference in New Issue
Block a user