Skip to content

Commit cd29803

Browse files
committed
Make admin per-domain and rewrite base_url when testing locally.
1 parent bfdb55e commit cd29803

File tree

6 files changed

+309
-227
lines changed

6 files changed

+309
-227
lines changed

admin/index.html

Lines changed: 181 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -1,176 +1,205 @@
11
<html>
2-
<head>
3-
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/full.min.css" rel="stylesheet" type="text/css" />
4-
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
5-
<script src="https://unpkg.com/alpinejs" defer></script>
6-
<script>
7-
const API_BASE_URL = "%%API_BASE_URL%%";
8-
const hexes = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, '0'));
9-
10-
function bytesToHex(bytes) {
11-
let hex = '';
12-
for (let i = 0; i < bytes.length; i++) {
13-
hex += hexes[bytes[i]];
14-
}
15-
return hex;
2+
3+
<head>
4+
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/full.min.css" rel="stylesheet" type="text/css" />
5+
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
6+
<script src="https://unpkg.com/alpinejs" defer></script>
7+
<script>
8+
const ADMINISTERED_HOST = "%%ADMINISTERED_HOST%%";
9+
const API_BASE_URL = "%%API_BASE_URL%%";
10+
11+
const hexes = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, '0'));
12+
13+
function bytesToHex(bytes) {
14+
let hex = '';
15+
for (let i = 0; i < bytes.length; i++) {
16+
hex += hexes[bytes[i]];
1617
}
18+
return hex;
19+
}
20+
21+
async function getEvent(kind, content, tags) {
22+
let event = { 'kind': kind, 'content': content, 'tags': tags };
23+
event.pubkey = await window.nostr.getPublicKey();
24+
event.created_at = Math.round(new Date().getTime() / 1000);
25+
serialized = JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]);
26+
event.id = bytesToHex(new Uint8Array(await window.crypto.subtle.digest("SHA-256", (new TextEncoder()).encode(serialized))));
27+
return await window.nostr.signEvent(event);
28+
}
29+
30+
async function getNostrAuthHeader(url, method) {
31+
let authEvent = await getEvent(27235, "", [['u', url], ['method', method]]);
32+
return `Nostr ${btoa(JSON.stringify(authEvent))}`;
33+
}
1734

18-
async function getEvent(kind, content, tags) {
19-
let event = {'kind': kind, 'content': content, 'tags': tags};
20-
event.pubkey = await window.nostr.getPublicKey();
21-
event.created_at = Math.round(new Date().getTime() / 1000);
22-
serialized = JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]);
23-
event.id = bytesToHex(new Uint8Array(await window.crypto.subtle.digest("SHA-256", (new TextEncoder()).encode(serialized))));
24-
return await window.nostr.signEvent(event);
35+
async function getSites(sites) {
36+
while (!window.nostr) {
37+
await new Promise(r => setTimeout(r, 0));
2538
}
2639

27-
async function getNostrAuthHeader(url, method) {
28-
let authEvent = await getEvent(27235, "", [['u', url], ['method', method]]);
29-
return `Nostr ${btoa(JSON.stringify(authEvent))}`;
40+
let endpoint = `${API_BASE_URL}/api/sites`;
41+
sites.length = 0;
42+
let response = await fetch(endpoint, {
43+
headers: { 'Authorization': await getNostrAuthHeader(window.location.protocol + endpoint, 'GET') },
44+
});
45+
for (const s of await response.json()) {
46+
sites.push(s);
3047
}
48+
}
3149

32-
async function getSites(sites) {
33-
while (!window.nostr) {
34-
await new Promise(r => setTimeout(r, 0));
35-
}
36-
let endpoint = `${API_BASE_URL}/api/sites`;
37-
sites.length = 0;
38-
let response = await fetch(endpoint, {
39-
headers: {'Authorization': await getNostrAuthHeader(window.location.protocol + endpoint, 'GET')},
40-
});
41-
for (const s of await response.json()) {
42-
sites.push(s);
43-
}
50+
async function saveSite(domain) {
51+
let endpoint = `${API_BASE_URL}/api/sites`;
52+
let response = await fetch(endpoint, {
53+
method: 'POST',
54+
headers: {
55+
'Accept': 'application/json',
56+
'Content-Type': 'application/json',
57+
'Authorization': await getNostrAuthHeader(window.location.protocol + endpoint, 'POST'),
58+
},
59+
body: JSON.stringify({
60+
domain: domain,
61+
}),
62+
});
63+
return response.ok;
64+
}
65+
66+
function getApiBaseUrl() {
67+
if (API_BASE_URL.startsWith("//localhost:")) {
68+
return `http:${API_BASE_URL}`;
69+
} else {
70+
return `${window.location.protocol}//${ADMINISTERED_HOST}`;
4471
}
72+
}
4573

46-
async function saveSite(domain) {
47-
let endpoint = `${API_BASE_URL}/api/sites`;
48-
let response = await fetch(endpoint, {
49-
method: 'POST',
50-
headers: {
51-
'Accept': 'application/json',
52-
'Content-Type': 'application/json',
53-
'Authorization': await getNostrAuthHeader(window.location.protocol + endpoint, 'POST'),
54-
},
55-
body: JSON.stringify({
56-
domain: domain,
57-
}),
58-
});
74+
function getApiExtraHeaders() {
75+
if (API_BASE_URL.startsWith("//localhost:")) {
76+
return { "X-Target-Host": ADMINISTERED_HOST };
77+
} else {
78+
return {};
5979
}
80+
}
6081

61-
function getSiteApiBaseUrl(siteDomain) {
62-
if (API_BASE_URL.startsWith("//localhost:")) {
63-
return `http:${API_BASE_URL}`;
64-
} else {
65-
return `${window.location.protocol}//${siteDomain}`;
66-
}
82+
async function getConfig(themes) {
83+
while (!window.nostr) {
84+
await new Promise(r => setTimeout(r, 0));
6785
}
6886

69-
async function getConfig(site, themes) {
70-
themes.length = 0;
71-
72-
let endpoint = `${getSiteApiBaseUrl(site.domain)}/api/config`;
73-
let res = await fetch(new URL(endpoint), {
74-
headers: { authorization: await getNostrAuthHeader(endpoint, 'GET') }
75-
});
76-
let configResponse = await res.json();
77-
endpoint = `${getSiteApiBaseUrl(site.domain)}/api/themes`;
78-
res = await fetch(new URL(endpoint), {
79-
headers: { authorization: await getNostrAuthHeader(endpoint, 'GET') }
80-
});
81-
let themesResponse = await res.json();
82-
for (t of themesResponse['themes']) {
83-
themes.push({name: t.name, license: t.license, description: t.description, selected: t.name == configResponse['theme']});
84-
}
85-
themes.sort((a, b) => a.name.localeCompare(b.name));
87+
themes.length = 0;
88+
89+
let endpoint = `${getApiBaseUrl()}/api/config`;
90+
let headers = { ...{ authorization: await getNostrAuthHeader(endpoint, 'GET') }, ...getApiExtraHeaders() };
91+
let res = await fetch(new URL(endpoint), { headers: headers });
92+
let configResponse = await res.json();
93+
endpoint = `${getApiBaseUrl()}/api/themes`;
94+
res = await fetch(new URL(endpoint), {
95+
headers: { authorization: await getNostrAuthHeader(endpoint, 'GET') }
96+
});
97+
let themesResponse = await res.json();
98+
for (t of themesResponse['themes']) {
99+
themes.push({ name: t.name, license: t.license, description: t.description, selected: t.name == configResponse['theme'] });
86100
}
101+
themes.sort((a, b) => a.name.localeCompare(b.name));
102+
}
87103

88-
async function saveConfig(site, themes, desiredThemeName) {
89-
let endpoint = `${getSiteApiBaseUrl(site.domain)}/api/config`;
90-
let response = await fetch(endpoint, {
91-
method: 'PUT',
92-
headers: {
93-
'Accept': 'application/json',
94-
'Content-Type': 'application/json',
95-
'Authorization': await getNostrAuthHeader(endpoint, 'PUT'),
96-
},
97-
body: JSON.stringify({
98-
theme: desiredThemeName,
99-
}),
100-
});
101-
if (response.ok) {
102-
for (let theme of themes) {
103-
theme.selected = theme.name === desiredThemeName;
104-
}
105-
} else {
106-
alert("Error changing theme!");
104+
async function saveConfig(themes, desiredThemeName) {
105+
let endpoint = `${getApiBaseUrl()}/api/config`;
106+
let headers = {
107+
...{
108+
'Accept': 'application/json',
109+
'Content-Type': 'application/json',
110+
'Authorization': await getNostrAuthHeader(endpoint, 'PUT'),
111+
},
112+
...getApiExtraHeaders()
113+
};
114+
let response = await fetch(endpoint, {
115+
method: 'PUT',
116+
headers: headers,
117+
body: JSON.stringify({ theme: desiredThemeName }),
118+
});
119+
if (response.ok) {
120+
for (let theme of themes) {
121+
theme.selected = theme.name === desiredThemeName;
107122
}
123+
} else {
124+
alert("Error changing theme!");
108125
}
109-
</script>
110-
</head>
111-
<body>
112-
<div class="w-full mx-auto" x-data="{site: null, sites: [], themes: []}" x-init="await getSites(sites); if (sites.length > 0) { site = sites[0]; await getConfig(site, themes); }">
113-
<div class="navbar bg-base-200">
114-
<div class="flex-1">
115-
<a class="btn btn-ghost text-xl">Servus!</a>
116-
</div>
117-
<div class="flex-none">
118-
<ul class="menu menu-horizontal px-1">
119-
<li>
120-
<details>
121-
<summary>Sites</summary>
122-
<ul class="bg-base-100 rounded-t-none p-2">
123-
<template x-for="s in sites">
124-
<li><a x-on:click="site = s" x-text="s.domain"></a></li>
125-
</template>
126-
<li><a x-on:click="site = null">New</a></li>
127-
</ul>
128-
</details>
129-
</li>
130-
</ul>
131-
</div>
132-
</div> <!-- /navbar -->
133-
<div>
134-
<template x-if="!site">
135-
<div class="flex items-center justify-center">
136-
<div class="w-3/4 mt-24" x-data="{domain: ''}">
137-
<div class="form-control">
138-
<label class="label" for="domain">
139-
<span class="label-text">Domain</span>
140-
</label>
141-
<input x-model="domain" type="text" name="domain" class="input input-bordered input-lg" />
142-
</div>
143-
<div class="w-full flex justify-center items-center mt-2">
144-
<div class="w-1/2 flex justify-center items-center gap-2">
145-
<button x-on:click="await saveSite(domain); await getSites(sites); site = sites[0];" class="btn btn-primary mt-1">Save</button>
126+
}
127+
</script>
128+
</head>
129+
130+
<body>
131+
<div x-data="{host: ADMINISTERED_HOST}">
132+
<template x-if="!host">
133+
<div x-data="{sites: []}" x-init="(async () => { await getSites(sites); })()">
134+
<div class="mt-12">
135+
<div class="w-full mt-24">
136+
<div class="flex flex-wrap gap-4">
137+
<template x-for="site in sites" :key="site.domain">
138+
<div class="card w-96 card-xs shadow-sm bg-neutral text-neutral-content">
139+
<div class="card-body">
140+
<h2 class="card-title" x-text="site.domain"></h2>
141+
<div class="justify-end card-actions">
142+
<a class="btn btn-primary" :href="'/.admin/' + site.domain">Go</a>
143+
</div>
144+
</div>
146145
</div>
146+
</template>
147+
<div class="card w-96 card-xs shadow-sm bg-neutral text-neutral-content"
148+
x-data="{domain: '', editing: false}">
149+
<template x-if="!editing">
150+
<div class="card-body">
151+
<div class="justify-end card-actions">
152+
<a class="btn btn-primary" x-on:click="editing = true;">New site</a>
153+
</div>
154+
</div>
155+
</template>
156+
<template x-if="editing">
157+
<div class="card-body">
158+
<label class="label" for="domain">
159+
<span class="label-text">Domain</span>
160+
</label>
161+
<input x-model="domain" type="text" name="domain"
162+
class="input input-bordered input-lg" />
163+
<div class="justify-end card-actions">
164+
<a class="btn btn-primary"
165+
x-on:click="if (await saveSite(domain)) { domain = ''; editing = false; await getSites(sites); } else { alert('Error saving site!'); }">Save</a>
166+
<a class="btn" x-on:click="domain = ''; editing = false">Cancel</a>
167+
</div>
168+
</div>
169+
</template>
147170
</div>
148171
</div>
149172
</div>
150-
</template> <!-- /!site -->
151-
<template x-if="site">
152-
153-
<div class="mt-12">
154-
<h1 class="text-2xl text-center" x-text="site.domain"></h1>
155-
<div class="w-full mt-24">
156-
<div class="grid gap-4 justify-between grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
157-
<template x-for="theme in themes">
158-
<div :class="theme.selected ? 'bg-primary text-primary-content' : 'bg-neutral text-neutral-content'" class="card w-96 card-xs shadow-sm">
159-
<div class="card-body">
160-
<h2 class="card-title" x-text="theme.name"></h2>
161-
<p x-text="theme.description"></p>
162-
<p>License: <span x-text="theme.license"></span></p>
163-
<div class="justify-end card-actions">
164-
<button :class="{'btn-primary': !theme.selected}" class="btn" x-on:click="let changed = await saveConfig(site, themes, theme.name); if (changed) { }">Select</button>
165-
</div>
166-
</div>
173+
</div>
174+
</div>
175+
</template>
176+
<template x-if="host">
177+
<div class="w-full mx-auto" x-data="{themes: []}" x-init="await getConfig(themes);">
178+
<div>
179+
<div class="mt-12">
180+
<div class="w-full mt-24">
181+
<div class="flex flex-wrap gap-4">
182+
<template x-for="theme in themes">
183+
<div :class="theme.selected ? 'bg-primary text-primary-content' : 'bg-neutral text-neutral-content'"
184+
class="card w-96 card-xs shadow-sm">
185+
<div class="card-body">
186+
<h2 class="card-title" x-text="theme.name"></h2>
187+
<p x-text="theme.description"></p>
188+
<p>License: <span x-text="theme.license"></span></p>
189+
<div class="justify-end card-actions">
190+
<button :class="{'btn-primary': !theme.selected}" class="btn"
191+
x-on:click="await saveConfig(themes, theme.name);">Select</button>
167192
</div>
168-
</template>
169-
</div>
193+
</div>
194+
</div>
195+
</template>
170196
</div>
171197
</div>
172-
</template> <!-- /site -->
173-
</div> <!-- /main -->
174-
</div>
175-
</body>
176-
</html>
198+
</div>
199+
</div>
200+
</div>
201+
</template>
202+
</div>
203+
</body>
204+
205+
</html>

docs/api.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ A `PUT` to `/api/config` can be used to change the site's theme.
1414

1515
NB: All requests require a [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) authorization header to be present!
1616

17+
### Testing on localhost
18+
19+
The `X-Target-Host` request header can be passed to specify which site's API is to invoked when hitting the API via `localhost` or `127.0.0.1`. This is not a problem in production environments, when the site can be determined from the actual host used to access the API.
20+
1721
## Blossom API
1822

1923
Servus implements the [Blossom API](https://github.com/hzrd149/blossom) and therefore acts as your personal Blossom server.

0 commit comments

Comments
 (0)