@acms-subir/core 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/docs/live-video.md +91 -0
- package/integration.mjs +39 -0
- package/package.json +2 -1
- package/src/actions/index.ts +3 -2
- package/src/components/LinksManager.tsx +5 -1
- package/src/components/LiveCard.astro +174 -0
- package/src/components/LiveManager.tsx +910 -0
- package/src/components/ThemeSections.astro +2 -1
- package/src/components/WidgetRenderer.astro +3 -0
- package/src/components/editor/extensions/cards.tsx +3 -38
- package/src/components/editor/extensions/mediaCards.tsx +4 -0
- package/src/layouts/Admin.astro +1 -1
- package/src/layouts/Site.astro +28 -0
- package/src/lib/api.ts +6 -2
- package/src/lib/backup/content.ts +1 -1
- package/src/lib/embeds.ts +50 -0
- package/src/lib/live/client.ts +88 -0
- package/src/lib/live/crypto.ts +28 -0
- package/src/lib/live/migration.ts +15 -0
- package/src/lib/live/providers.ts +152 -0
- package/src/lib/live/service.ts +170 -0
- package/src/lib/live/signals.ts +89 -0
- package/src/lib/live/store.ts +49 -0
- package/src/lib/live/types.ts +52 -0
- package/src/lib/live/view.ts +78 -0
- package/src/lib/repo/index.ts +12 -8
- package/src/lib/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +58 -12
- package/src/lib/repo/types.ts +3 -0
- package/src/lib/scheduler.ts +2 -0
- package/src/lib/schema.ts +7 -1
- package/src/lib/themeTokens.ts +9 -0
- package/src/lib/webhook-sql.ts +68 -0
- package/src/lib/webhooks.ts +15 -7
- package/src/lib/widgets.ts +1 -0
- package/src/middleware.ts +10 -1
- package/src/pages/[...slug].astro +6 -0
- package/src/pages/admin/calendar/integrations.astro +2 -219
- package/src/pages/admin/integrations.astro +237 -0
- package/src/pages/admin/live.astro +12 -0
- package/src/pages/api/calendar/docs.ts +4 -2
- package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
- package/src/pages/api/live/settings.ts +45 -0
- package/src/pages/api/live/status.ts +7 -0
- package/src/pages/api/live/thumb.ts +48 -0
- package/src/pages/api/webhooks/docs.ts +61 -0
- package/src/pages/index.astro +6 -0
- package/src/pages/links.astro +6 -0
- package/src/styles/live-admin.css +126 -0
- package/src/styles/live.css +199 -0
- package/src/theme-sdk/contract.mjs +1 -0
- package/src/theme-sdk/tokens.mjs +3 -0
- package/src/themes/editorial/theme.css +5 -0
- package/src/themes/editorial/theme.json +2 -0
- package/src/themes/field/theme.json +1 -0
- package/src/themes/night/theme.json +1 -0
|
@@ -1,221 +1,4 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import { getRepo } from '../../../lib/repo';
|
|
5
|
-
import { API_SCOPES, WEBHOOK_EVENTS, type ApiScope } from '../../../lib/schema';
|
|
6
|
-
import { validateSession, SESSION_COOKIE, OWNER_ENV } from '../../../lib/auth';
|
|
7
|
-
import { CALENDAR, bookingsEnabled, calendarFeedUrl } from '../../../lib/calendar/config';
|
|
8
|
-
import { issueApiKey } from '../../../lib/api-keys';
|
|
9
|
-
import { attemptDelivery, generateWebhookSecret, validateWebhookUrl } from '../../../lib/webhooks';
|
|
10
|
-
import { resolveConfig } from '../../../lib/email/config';
|
|
11
|
-
import { env } from '../../../lib/env';
|
|
12
|
-
import { siteOrigin } from '../../../lib/seo';
|
|
13
|
-
|
|
14
|
-
// Settings → Calendar & integrations (mockup J): subscribe feeds, scoped API
|
|
15
|
-
// keys (shown once), signed webhooks with a delivery log and a test button,
|
|
16
|
-
// and the email setup status. Owner-only: keys and webhooks reach private data.
|
|
17
|
-
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
18
|
-
const repo = await getRepo(Astro.locals);
|
|
19
|
-
const sessionId = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
|
|
20
|
-
const me = sessionId && sessionId !== OWNER_ENV ? await repo.getUser(sessionId) : null;
|
|
21
|
-
const isOwner = sessionId === OWNER_ENV || me?.role === 'owner';
|
|
22
|
-
const settings = await repo.getSettings();
|
|
23
|
-
const origin = siteOrigin(settings, Astro.url);
|
|
24
|
-
let notice = '';
|
|
25
|
-
let error = '';
|
|
26
|
-
let newKey = '';
|
|
27
|
-
let newSecret = '';
|
|
28
|
-
const dev = import.meta.env.DEV;
|
|
29
|
-
|
|
30
|
-
if (Astro.request.method === 'POST') {
|
|
31
|
-
if (!isOwner) return new Response('Owners only', { status: 403 });
|
|
32
|
-
const form = await Astro.request.formData();
|
|
33
|
-
const op = String(form.get('op'));
|
|
34
|
-
try {
|
|
35
|
-
if (op === 'key') {
|
|
36
|
-
const scopes = form.getAll('scopes').map(String).filter((s): s is ApiScope => (API_SCOPES as readonly string[]).includes(s));
|
|
37
|
-
if (!scopes.length) throw new Error('Pick at least one permission.');
|
|
38
|
-
const { key } = await issueApiKey(repo, String(form.get('name') || 'API key').slice(0, 80), scopes);
|
|
39
|
-
newKey = key;
|
|
40
|
-
} else if (op === 'revoke') {
|
|
41
|
-
await repo.deleteApiKey(String(form.get('id')));
|
|
42
|
-
notice = 'Key revoked — anything using it stops working now.';
|
|
43
|
-
} else if (op === 'hook') {
|
|
44
|
-
const url = validateWebhookUrl(String(form.get('url') || ''), dev);
|
|
45
|
-
const events = form.getAll('events').map(String).filter((e) => (WEBHOOK_EVENTS as readonly string[]).includes(e));
|
|
46
|
-
if (!events.length) throw new Error('Pick at least one event.');
|
|
47
|
-
const secret = generateWebhookSecret();
|
|
48
|
-
await repo.createWebhook({ url, secret, events });
|
|
49
|
-
newSecret = secret;
|
|
50
|
-
} else if (op === 'toggle') {
|
|
51
|
-
const h = (await repo.listWebhooks()).find((w) => w.id === form.get('id'));
|
|
52
|
-
if (h) await repo.updateWebhook(h.id, { active: !h.active });
|
|
53
|
-
} else if (op === 'delhook') {
|
|
54
|
-
await repo.deleteWebhook(String(form.get('id')));
|
|
55
|
-
notice = 'Webhook removed.';
|
|
56
|
-
} else if (op === 'test') {
|
|
57
|
-
const h = (await repo.listWebhooks()).find((w) => w.id === form.get('id'));
|
|
58
|
-
if (h) {
|
|
59
|
-
const now = Date.now();
|
|
60
|
-
const d = { id: crypto.randomUUID(), webhookId: h.id, event: 'ping', payload: JSON.stringify({ type: 'ping', createdAt: new Date(now).toISOString(), data: { message: 'Test delivery from acms' } }), status: 'pending' as const, attempts: 0, nextAt: now, responseCode: null, createdAt: now, deliveredAt: null };
|
|
61
|
-
await repo.createDeliveries([d]);
|
|
62
|
-
await attemptDelivery(repo, { ...d, attempts: 3 }, h);
|
|
63
|
-
const last = (await repo.listDeliveries(h.id, 1))[0];
|
|
64
|
-
notice = last?.responseCode ? `Test sent — the endpoint answered ${last.responseCode}.` : 'Test sent — the endpoint didn’t answer.';
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
} catch (err) {
|
|
68
|
-
error = err instanceof Error ? err.message : 'Something went wrong.';
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const [keys, hooks, calendars] = await Promise.all([repo.listApiKeys(), repo.listWebhooks(), repo.listCalendars()]);
|
|
73
|
-
const logs = new Map(await Promise.all(hooks.map(async (h) => [h.id, await repo.listDeliveries(h.id, 5)] as const)));
|
|
74
|
-
const emailConfig = resolveConfig(await repo.email.config(), Astro.locals);
|
|
75
|
-
const provider = emailConfig.error ? 'none' : emailConfig.provider;
|
|
76
|
-
const ago = (ts: number | null) => {
|
|
77
|
-
if (!ts) return 'never';
|
|
78
|
-
const s = Math.floor((Date.now() - ts) / 1000);
|
|
79
|
-
return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago`;
|
|
80
|
-
};
|
|
81
|
-
const SCOPE_LABEL: Record<string, string> = {
|
|
82
|
-
'events:read': 'Read events (incl. private calendars)', 'events:write': 'Create and edit events',
|
|
83
|
-
'bookings:read': 'Read bookings and guest details', 'bookings:write': 'Create, confirm and cancel bookings',
|
|
84
|
-
'resources:read': 'Read resources and links', 'resources:write': 'Edit resources and links',
|
|
85
|
-
};
|
|
86
|
-
const feeds = [
|
|
87
|
-
{ label: 'Everything public', url: origin + calendarFeedUrl() },
|
|
88
|
-
...calendars.filter((c) => c.kind === 'native' && c.visibility !== 'private').map((c) => ({ label: c.name, url: origin + calendarFeedUrl(c.slug) })),
|
|
89
|
-
];
|
|
2
|
+
// Preserve old bookmarks and form submissions after moving integrations.
|
|
3
|
+
return Astro.redirect('/admin/integrations', 307);
|
|
90
4
|
---
|
|
91
|
-
|
|
92
|
-
<Admin title="API & webhooks" section="calendar:integrations">
|
|
93
|
-
<div class="page-head">
|
|
94
|
-
<div>
|
|
95
|
-
<h1>API & webhooks</h1>
|
|
96
|
-
<p class="sub">Connect the calendar{bookingsEnabled() ? ' and bookings' : ''} to Zapier, Make, Slack, a lobby screen or your own code. <a href="/api/calendar/docs" target="_blank" rel="noopener">API reference ↗</a></p>
|
|
97
|
-
</div>
|
|
98
|
-
</div>
|
|
99
|
-
{notice && <p class="notice" role="status">{notice}</p>}
|
|
100
|
-
{error && <p class="notice warn" role="alert">{error}</p>}
|
|
101
|
-
{!isOwner && <p class="notice">Only owners can create keys or webhooks.</p>}
|
|
102
|
-
{newKey && (
|
|
103
|
-
<div class="notice reveal">
|
|
104
|
-
<b>Copy this key now — it won’t be shown again.</b>
|
|
105
|
-
<div class="cal-feed"><input type="text" class="mono" readonly value={newKey} /><button class="btn sm" type="button" data-copy={newKey}>Copy</button></div>
|
|
106
|
-
<span class="hint">Send it as <code>Authorization: Bearer {newKey.slice(0, 14)}…</code></span>
|
|
107
|
-
</div>
|
|
108
|
-
)}
|
|
109
|
-
{newSecret && (
|
|
110
|
-
<div class="notice reveal">
|
|
111
|
-
<b>Signing secret — copy it into the receiver now.</b>
|
|
112
|
-
<div class="cal-feed"><input type="text" class="mono" readonly value={newSecret} /><button class="btn sm" type="button" data-copy={newSecret}>Copy</button></div>
|
|
113
|
-
<span class="hint">Verify <code>X-Acms-Signature</code> = <code>sha256=</code>HMAC-SHA256(secret, <code>{'{X-Acms-Timestamp}.{body}'}</code>).</span>
|
|
114
|
-
</div>
|
|
115
|
-
)}
|
|
116
|
-
|
|
117
|
-
<div class="res-grid">
|
|
118
|
-
<div>
|
|
119
|
-
<section class="card card-pad">
|
|
120
|
-
<div class="card-head"><b>API keys</b>{isOwner && <button class="btn sm" type="button" data-modal="new-key">New key</button>}</div>
|
|
121
|
-
{keys.length === 0 && <p class="hint">No keys yet. Public calendars and bookings don’t need one — keys unlock private data and changes.</p>}
|
|
122
|
-
{keys.map((k) => (
|
|
123
|
-
<div class="int-row">
|
|
124
|
-
<div><b>{k.name}</b> <code>{k.prefix}…</code><div class="int-scopes">{k.scopes.map((s) => <span class="pill lang">{s}</span>)}</div></div>
|
|
125
|
-
<span class="hint">used {ago(k.lastUsedAt)}</span>
|
|
126
|
-
{isOwner && <form method="post"><input type="hidden" name="op" value="revoke" /><input type="hidden" name="id" value={k.id} /><button class="btn sm ghost danger" data-confirm={`Revoke “${k.name}”?`}>Revoke</button></form>}
|
|
127
|
-
</div>
|
|
128
|
-
))}
|
|
129
|
-
<p class="hint">The agent key from Settings → AI & agents also works here, with every permission.</p>
|
|
130
|
-
</section>
|
|
131
|
-
|
|
132
|
-
<section class="card card-pad">
|
|
133
|
-
<div class="card-head"><b>Webhooks</b>{isOwner && <button class="btn sm" type="button" data-modal="new-hook">Add endpoint</button>}</div>
|
|
134
|
-
{hooks.length === 0 && <p class="hint">Get a signed POST the moment something happens — a booking comes in, an event goes live.</p>}
|
|
135
|
-
{hooks.map((h) => {
|
|
136
|
-
const log = logs.get(h.id) ?? [];
|
|
137
|
-
const ok = h.lastStatus !== null && h.lastStatus >= 200 && h.lastStatus < 300;
|
|
138
|
-
return (
|
|
139
|
-
<div class="int-hook">
|
|
140
|
-
<div class="int-row">
|
|
141
|
-
<div><b class="mono">{h.url.replace(/^https?:\/\//, '').slice(0, 60)}</b><div class="int-scopes">{h.events.map((e) => <span class="pill lang">{e}</span>)}</div></div>
|
|
142
|
-
<span class={`int-status ${h.lastStatus === null ? '' : ok ? 'ok' : 'bad'}`}>{h.lastStatus === null ? 'no deliveries yet' : `${h.lastStatus || 'no answer'} · ${ago(h.lastAt)}`}</span>
|
|
143
|
-
{isOwner && (
|
|
144
|
-
<form method="post" class="int-acts">
|
|
145
|
-
<input type="hidden" name="id" value={h.id} />
|
|
146
|
-
<button class="btn sm" name="op" value="test">Send test</button>
|
|
147
|
-
<button class="btn sm ghost" name="op" value="toggle">{h.active ? 'Pause' : 'Resume'}</button>
|
|
148
|
-
<button class="btn sm ghost danger" name="op" value="delhook" data-confirm="Remove this webhook?">Remove</button>
|
|
149
|
-
</form>
|
|
150
|
-
)}
|
|
151
|
-
</div>
|
|
152
|
-
{log.length > 0 && (
|
|
153
|
-
<details class="int-log"><summary>Last {log.length} deliveries</summary>
|
|
154
|
-
{log.map((d) => <div><span class={`status ${d.status === 'delivered' ? 'published' : d.status === 'failed' ? 'archived' : 'scheduled'}`}>{d.status}</span> <code>{d.event}</code> <span class="hint">{d.responseCode ?? '—'} · {d.attempts} attempt{d.attempts === 1 ? '' : 's'} · {ago(d.createdAt)}</span></div>)}
|
|
155
|
-
</details>
|
|
156
|
-
)}
|
|
157
|
-
{!h.active && <p class="hint">Paused — nothing is sent.</p>}
|
|
158
|
-
</div>
|
|
159
|
-
);
|
|
160
|
-
})}
|
|
161
|
-
</section>
|
|
162
|
-
</div>
|
|
163
|
-
|
|
164
|
-
<div class="res-side">
|
|
165
|
-
<section class="card card-pad">
|
|
166
|
-
<div class="card-head"><b>Email</b><span class={`int-status ${provider === 'none' ? 'bad' : 'ok'}`}>{provider === 'none' ? 'Not set up' : ({cloudflare:'Cloudflare Email',resend:'Resend',mailgun:'Mailgun',ses:'Amazon SES'})[provider]}</span></div>
|
|
167
|
-
{provider === 'none' ? (
|
|
168
|
-
<p class="hint">Choose Resend, Mailgun, Amazon SES or Cloudflare in <a href="/admin/email">Email settings</a>. Configure a sender and review delivery activity there.</p>
|
|
169
|
-
) : <p class="hint">Sending as <code>{emailConfig.config.from}</code>. Guests get confirmations, updates and a reminder the day before.</p>}
|
|
170
|
-
</section>
|
|
171
|
-
<section class="card card-pad">
|
|
172
|
-
<div class="card-head"><b>Subscribe feeds</b></div>
|
|
173
|
-
{feeds.map((f) => <div class="int-feed"><span>{f.label}</span><div class="cal-feed"><input type="text" class="mono" readonly value={f.url} /><button class="btn sm" type="button" data-copy={f.url}>Copy</button></div></div>)}
|
|
174
|
-
<p class="hint">Private calendars and resource staff feeds have secret links on their own screens.</p>
|
|
175
|
-
</section>
|
|
176
|
-
<section class="card card-pad">
|
|
177
|
-
<div class="card-head"><b>Quick start</b></div>
|
|
178
|
-
<pre class="int-code">{`curl ${origin}/api/calendar/events?from=2026-10-01
|
|
179
|
-
|
|
180
|
-
curl -H "Authorization: Bearer <key>" \\
|
|
181
|
-
${origin}/api/calendar/bookings?status=pending`}</pre>
|
|
182
|
-
</section>
|
|
183
|
-
</div>
|
|
184
|
-
</div>
|
|
185
|
-
|
|
186
|
-
<Modal id="new-key" title="New API key">
|
|
187
|
-
<form method="post">
|
|
188
|
-
<input type="hidden" name="op" value="key" />
|
|
189
|
-
<label class="field"><span>Name</span><input type="text" name="name" placeholder="Zapier, lobby screen…" required /></label>
|
|
190
|
-
<div class="field"><span>Permissions</span>
|
|
191
|
-
{API_SCOPES.filter((s) => bookingsEnabled() || !s.startsWith('bookings') && !s.startsWith('resources')).map((s) => (
|
|
192
|
-
<label class="check-row"><input type="checkbox" name="scopes" value={s} checked={s.endsWith(':read')} /> {SCOPE_LABEL[s]} <code>{s}</code></label>
|
|
193
|
-
))}
|
|
194
|
-
</div>
|
|
195
|
-
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Create key</button></div>
|
|
196
|
-
</form>
|
|
197
|
-
</Modal>
|
|
198
|
-
<Modal id="new-hook" title="Add webhook endpoint">
|
|
199
|
-
<form method="post">
|
|
200
|
-
<input type="hidden" name="op" value="hook" />
|
|
201
|
-
<label class="field"><span>Endpoint URL</span><input type="url" class="mono" name="url" placeholder="https://hooks.zapier.com/…" required /></label>
|
|
202
|
-
<div class="field"><span>Send these events</span>
|
|
203
|
-
{WEBHOOK_EVENTS.filter((e) => bookingsEnabled() || !e.startsWith('booking')).map((e) => <label class="check-row"><input type="checkbox" name="events" value={e} checked={e === 'booking.created' || e === 'event.published'} /> <code>{e}</code></label>)}
|
|
204
|
-
</div>
|
|
205
|
-
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Add endpoint</button></div>
|
|
206
|
-
<p class="hint modal-note">Deliveries are signed and retried 3 times over about an hour.</p>
|
|
207
|
-
</form>
|
|
208
|
-
</Modal>
|
|
209
|
-
</Admin>
|
|
210
|
-
|
|
211
|
-
<script>
|
|
212
|
-
document.addEventListener('click', async (e) => {
|
|
213
|
-
const c = e.target instanceof Element ? e.target.closest<HTMLElement>('[data-confirm]') : null;
|
|
214
|
-
if (c && !confirm(c.dataset.confirm)) { e.preventDefault(); return; }
|
|
215
|
-
const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
|
|
216
|
-
if (!b) return;
|
|
217
|
-
await navigator.clipboard.writeText(b.dataset.copy ?? '');
|
|
218
|
-
b.textContent = 'Copied';
|
|
219
|
-
setTimeout(() => (b.textContent = 'Copy'), 1200);
|
|
220
|
-
});
|
|
221
|
-
</script>
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../layouts/Admin.astro';
|
|
3
|
+
import Modal from '../../components/Modal.astro';
|
|
4
|
+
import { getRepo } from '../../lib/repo';
|
|
5
|
+
import { API_SCOPES, WEBHOOK_EVENTS, type ApiScope } from '../../lib/schema';
|
|
6
|
+
import { validateSession, SESSION_COOKIE, OWNER_ENV } from '../../lib/auth';
|
|
7
|
+
import { CALENDAR, bookingsEnabled, calendarFeedUrl } from '../../lib/calendar/config';
|
|
8
|
+
import { issueApiKey } from '../../lib/api-keys';
|
|
9
|
+
import { attemptDelivery, generateWebhookSecret, validateWebhookUrl } from '../../lib/webhooks';
|
|
10
|
+
import { resolveConfig } from '../../lib/email/config';
|
|
11
|
+
import { env } from '../../lib/env';
|
|
12
|
+
import { siteOrigin } from '../../lib/seo';
|
|
13
|
+
|
|
14
|
+
// Site-wide integrations. Owners manage keys and webhook subscriptions;
|
|
15
|
+
// calendar-specific feeds and API help appear only when enabled.
|
|
16
|
+
const repo = await getRepo(Astro.locals);
|
|
17
|
+
const sessionId = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
|
|
18
|
+
const me = sessionId && sessionId !== OWNER_ENV ? await repo.getUser(sessionId) : null;
|
|
19
|
+
const isOwner = sessionId === OWNER_ENV || me?.role === 'owner';
|
|
20
|
+
const settings = await repo.getSettings();
|
|
21
|
+
const origin = siteOrigin(settings, Astro.url);
|
|
22
|
+
let notice = '';
|
|
23
|
+
let error = '';
|
|
24
|
+
let newKey = '';
|
|
25
|
+
let newSecret = '';
|
|
26
|
+
const dev = import.meta.env.DEV;
|
|
27
|
+
|
|
28
|
+
if (Astro.request.method === 'POST') {
|
|
29
|
+
if (!isOwner) return new Response('Owners only', { status: 403 });
|
|
30
|
+
const form = await Astro.request.formData();
|
|
31
|
+
const op = String(form.get('op'));
|
|
32
|
+
try {
|
|
33
|
+
if (op === 'key') {
|
|
34
|
+
const scopes = form.getAll('scopes').map(String).filter((s): s is ApiScope => (API_SCOPES as readonly string[]).includes(s));
|
|
35
|
+
if (!scopes.length) throw new Error('Pick at least one permission.');
|
|
36
|
+
const { key } = await issueApiKey(repo, String(form.get('name') || 'API key').slice(0, 80), scopes);
|
|
37
|
+
newKey = key;
|
|
38
|
+
} else if (op === 'revoke') {
|
|
39
|
+
await repo.deleteApiKey(String(form.get('id')));
|
|
40
|
+
notice = 'Key revoked — anything using it stops working now.';
|
|
41
|
+
} else if (op === 'hook') {
|
|
42
|
+
const url = validateWebhookUrl(String(form.get('url') || ''), dev);
|
|
43
|
+
const events = form.getAll('events').map(String).filter((e) => (WEBHOOK_EVENTS as readonly string[]).includes(e));
|
|
44
|
+
if (!events.length) throw new Error('Pick at least one event.');
|
|
45
|
+
const secret = generateWebhookSecret();
|
|
46
|
+
await repo.createWebhook({ url, secret, events });
|
|
47
|
+
newSecret = secret;
|
|
48
|
+
} else if (op === 'subscriptions') {
|
|
49
|
+
const events = form.getAll('events').map(String).filter(e => (WEBHOOK_EVENTS as readonly string[]).includes(e));
|
|
50
|
+
if (!events.length) throw new Error('Pick at least one event.');
|
|
51
|
+
await repo.updateWebhook(String(form.get('id')), { events });
|
|
52
|
+
notice = 'Event subscriptions saved.';
|
|
53
|
+
} else if (op === 'toggle') {
|
|
54
|
+
const h = (await repo.listWebhooks()).find((w) => w.id === form.get('id'));
|
|
55
|
+
if (h) await repo.updateWebhook(h.id, { active: !h.active });
|
|
56
|
+
} else if (op === 'delhook') {
|
|
57
|
+
await repo.deleteWebhook(String(form.get('id')));
|
|
58
|
+
notice = 'Webhook removed.';
|
|
59
|
+
} else if (op === 'test') {
|
|
60
|
+
const h = (await repo.listWebhooks()).find((w) => w.id === form.get('id'));
|
|
61
|
+
if (h) {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const d = { id: crypto.randomUUID(), webhookId: h.id, event: 'ping', payload: JSON.stringify({ type: 'ping', createdAt: new Date(now).toISOString(), data: { message: 'Test delivery from acms' } }), status: 'pending' as const, attempts: 0, nextAt: now, responseCode: null, createdAt: now, deliveredAt: null };
|
|
64
|
+
await repo.createDeliveries([d]);
|
|
65
|
+
await attemptDelivery(repo, d, h);
|
|
66
|
+
const last = (await repo.listDeliveries(h.id, 1))[0];
|
|
67
|
+
notice = last?.responseCode ? `Test sent — the endpoint answered ${last.responseCode}.` : 'Test sent — the endpoint didn’t answer.';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch (err) {
|
|
71
|
+
error = err instanceof Error ? err.message : 'Something went wrong.';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const [keys, hooks, calendars] = await Promise.all([repo.listApiKeys(), repo.listWebhooks(), repo.listCalendars()]);
|
|
76
|
+
const logs = new Map(await Promise.all(hooks.map(async (h) => [h.id, await repo.listDeliveries(h.id, 5)] as const)));
|
|
77
|
+
const emailConfig = resolveConfig(await repo.email.config(), Astro.locals);
|
|
78
|
+
const provider = emailConfig.error ? 'none' : emailConfig.provider;
|
|
79
|
+
const ago = (ts: number | null) => {
|
|
80
|
+
if (!ts) return 'never';
|
|
81
|
+
const s = Math.floor((Date.now() - ts) / 1000);
|
|
82
|
+
return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago`;
|
|
83
|
+
};
|
|
84
|
+
const SCOPE_LABEL: Record<string, string> = {
|
|
85
|
+
'events:read': 'Read events (incl. private calendars)', 'events:write': 'Create and edit events',
|
|
86
|
+
'bookings:read': 'Read bookings and guest details', 'bookings:write': 'Create, confirm and cancel bookings',
|
|
87
|
+
'resources:read': 'Read resources and links', 'resources:write': 'Edit resources and links',
|
|
88
|
+
};
|
|
89
|
+
const feeds = [
|
|
90
|
+
{ label: 'Everything public', url: origin + calendarFeedUrl() },
|
|
91
|
+
...calendars.filter((c) => c.kind === 'native' && c.visibility !== 'private').map((c) => ({ label: c.name, url: origin + calendarFeedUrl(c.slug) })),
|
|
92
|
+
];
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
<Admin title="API & webhooks" section="integrations">
|
|
96
|
+
<div class="card" style="padding:20px;margin-bottom:20px">
|
|
97
|
+
<h2>Live video</h2><p>Connect your own Twitch or YouTube app to discover broadcasts automatically on your homepage and links page.</p>
|
|
98
|
+
<a class="btn" href="/admin/live">Set up live video →</a>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div class="page-head">
|
|
102
|
+
<div>
|
|
103
|
+
<h1>API & webhooks</h1>
|
|
104
|
+
<p class="sub">Connect content, design, media{CALENDAR.enabled ? ', calendars' : ''}{bookingsEnabled() ? ' and bookings' : ''} to your automations. <a href="/api/webhooks/docs" target="_blank" rel="noopener">API reference ↗</a></p>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
{notice && <p class="notice" role="status">{notice}</p>}
|
|
108
|
+
{error && <p class="notice warn" role="alert">{error}</p>}
|
|
109
|
+
{!isOwner && <p class="notice">Only owners can create keys or webhooks.</p>}
|
|
110
|
+
{newKey && (
|
|
111
|
+
<div class="notice reveal">
|
|
112
|
+
<b>Copy this key now — it won’t be shown again.</b>
|
|
113
|
+
<div class="cal-feed"><input type="text" class="mono" readonly value={newKey} /><button class="btn sm" type="button" data-copy={newKey}>Copy</button></div>
|
|
114
|
+
<span class="hint">Send it as <code>Authorization: Bearer {newKey.slice(0, 14)}…</code></span>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
{newSecret && (
|
|
118
|
+
<div class="notice reveal">
|
|
119
|
+
<b>Signing secret — copy it into the receiver now.</b>
|
|
120
|
+
<div class="cal-feed"><input type="text" class="mono" readonly value={newSecret} /><button class="btn sm" type="button" data-copy={newSecret}>Copy</button></div>
|
|
121
|
+
<span class="hint">Verify <code>X-Acms-Signature</code> = <code>sha256=</code>HMAC-SHA256(secret, <code>{'{X-Acms-Timestamp}.{body}'}</code>).</span>
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
<div class="res-grid">
|
|
126
|
+
<div>
|
|
127
|
+
<section class="card card-pad">
|
|
128
|
+
<div class="card-head"><b>API keys</b>{isOwner && <button class="btn sm" type="button" data-modal="new-key">New key</button>}</div>
|
|
129
|
+
{keys.length === 0 && <p class="hint">No keys yet. Public calendars and bookings don’t need one — keys unlock private data and changes.</p>}
|
|
130
|
+
{keys.map((k) => (
|
|
131
|
+
<div class="int-row">
|
|
132
|
+
<div><b>{k.name}</b> <code>{k.prefix}…</code><div class="int-scopes">{k.scopes.map((s) => <span class="pill lang">{s}</span>)}</div></div>
|
|
133
|
+
<span class="hint">used {ago(k.lastUsedAt)}</span>
|
|
134
|
+
{isOwner && <form method="post"><input type="hidden" name="op" value="revoke" /><input type="hidden" name="id" value={k.id} /><button class="btn sm ghost danger" data-confirm={`Revoke “${k.name}”?`}>Revoke</button></form>}
|
|
135
|
+
</div>
|
|
136
|
+
))}
|
|
137
|
+
<p class="hint">Content-agent keys can access calendar integrations. Design and media APIs require explicitly scoped keys.</p>
|
|
138
|
+
</section>
|
|
139
|
+
|
|
140
|
+
<section class="card card-pad">
|
|
141
|
+
<div class="card-head"><b>Webhooks</b>{isOwner && <button class="btn sm" type="button" data-modal="new-hook">Add endpoint</button>}</div>
|
|
142
|
+
{hooks.length === 0 && <p class="hint">Get a signed POST when content goes live, a design is published, media changes or a booking comes in.</p>}
|
|
143
|
+
{hooks.map((h) => {
|
|
144
|
+
const log = logs.get(h.id) ?? [];
|
|
145
|
+
const ok = h.lastStatus !== null && h.lastStatus >= 200 && h.lastStatus < 300;
|
|
146
|
+
return (
|
|
147
|
+
<div class="int-hook">
|
|
148
|
+
<div class="int-row">
|
|
149
|
+
<div><b class="mono">{h.url.replace(/^https?:\/\//, '').slice(0, 60)}</b><div class="int-scopes">{h.events.map((e) => <span class="pill lang">{e}</span>)}</div></div>
|
|
150
|
+
<span class={`int-status ${h.lastStatus === null ? '' : ok ? 'ok' : 'bad'}`}>{h.lastStatus === null ? 'no deliveries yet' : `${h.lastStatus || 'no answer'} · ${ago(h.lastAt)}`}</span>
|
|
151
|
+
{isOwner && (
|
|
152
|
+
<form method="post" class="int-acts">
|
|
153
|
+
<input type="hidden" name="id" value={h.id} />
|
|
154
|
+
<button class="btn sm" name="op" value="test">Send test</button>
|
|
155
|
+
<button class="btn sm ghost" name="op" value="toggle">{h.active ? 'Pause' : 'Resume'}</button>
|
|
156
|
+
<button class="btn sm ghost danger" name="op" value="delhook" data-confirm="Remove this webhook?">Remove</button>
|
|
157
|
+
</form>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
{isOwner && <details class="int-log">
|
|
161
|
+
<summary>Edit event subscriptions</summary>
|
|
162
|
+
<form method="post">
|
|
163
|
+
<input type="hidden" name="op" value="subscriptions" />
|
|
164
|
+
<input type="hidden" name="id" value={h.id} />
|
|
165
|
+
{WEBHOOK_EVENTS.map(e => <label class="check-row"><input type="checkbox" name="events" value={e} checked={h.events.includes(e) || h.events.includes('*')} /> <code>{e}</code></label>)}
|
|
166
|
+
<button class="btn sm" type="submit">Save events</button>
|
|
167
|
+
</form>
|
|
168
|
+
</details>}
|
|
169
|
+
{log.length > 0 && (
|
|
170
|
+
<details class="int-log"><summary>Last {log.length} deliveries</summary>
|
|
171
|
+
{log.map((d) => <div><span class={`status ${d.status === 'delivered' ? 'published' : d.status === 'failed' ? 'archived' : 'scheduled'}`}>{d.status}</span> <code>{d.event}</code> <span class="hint">{d.responseCode ?? '—'} · {d.attempts} attempt{d.attempts === 1 ? '' : 's'} · {ago(d.createdAt)}</span></div>)}
|
|
172
|
+
</details>
|
|
173
|
+
)}
|
|
174
|
+
{!h.active && <p class="hint">Paused — nothing is sent.</p>}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
})}
|
|
178
|
+
</section>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div class="res-side">
|
|
182
|
+
<section class="card card-pad">
|
|
183
|
+
<div class="card-head"><b>Email</b><span class={`int-status ${provider === 'none' ? 'bad' : 'ok'}`}>{provider === 'none' ? 'Not set up' : ({cloudflare:'Cloudflare Email',resend:'Resend',mailgun:'Mailgun',ses:'Amazon SES'})[provider]}</span></div>
|
|
184
|
+
{provider === 'none' ? (
|
|
185
|
+
<p class="hint">Choose Resend, Mailgun, Amazon SES or Cloudflare in <a href="/admin/email">Email settings</a>. Configure a sender and review delivery activity there.</p>
|
|
186
|
+
) : <p class="hint">Sending as <code>{emailConfig.config.from}</code>. Guests get confirmations, updates and a reminder the day before.</p>}
|
|
187
|
+
</section>
|
|
188
|
+
{CALENDAR.enabled && <section class="card card-pad">
|
|
189
|
+
<div class="card-head"><b>Subscribe feeds</b></div>
|
|
190
|
+
{feeds.map((f) => <div class="int-feed"><span>{f.label}</span><div class="cal-feed"><input type="text" class="mono" readonly value={f.url} /><button class="btn sm" type="button" data-copy={f.url}>Copy</button></div></div>)}
|
|
191
|
+
<p class="hint">Private calendars and resource staff feeds have secret links on their own screens.</p>
|
|
192
|
+
</section>}
|
|
193
|
+
<section class="card card-pad">
|
|
194
|
+
<div class="card-head"><b>Quick start</b></div>
|
|
195
|
+
<p class="hint">Use publication events to refresh search indexes, clear caches or trigger builds. Media events can start asset processing. A restore event tells consumers to resync.</p>
|
|
196
|
+
<p><a href="/api/webhooks/docs" target="_blank" rel="noopener">Webhook events and payloads ↗</a></p>
|
|
197
|
+
{CALENDAR.enabled && <p><a href="/api/calendar/docs" target="_blank" rel="noopener">Calendar API reference ↗</a></p>}
|
|
198
|
+
</section>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<Modal id="new-key" title="New API key">
|
|
203
|
+
<form method="post">
|
|
204
|
+
<input type="hidden" name="op" value="key" />
|
|
205
|
+
<label class="field"><span>Name</span><input type="text" name="name" placeholder="Zapier, lobby screen…" required /></label>
|
|
206
|
+
<div class="field"><span>Permissions</span>
|
|
207
|
+
{API_SCOPES.filter((s) => (CALENDAR.enabled || !s.startsWith('events:')) && (bookingsEnabled() || !s.startsWith('bookings:') && !s.startsWith('resources:'))).map((s) => (
|
|
208
|
+
<label class="check-row"><input type="checkbox" name="scopes" value={s} checked={s.endsWith(':read')} /> {SCOPE_LABEL[s] ?? s} <code>{s}</code></label>
|
|
209
|
+
))}
|
|
210
|
+
</div>
|
|
211
|
+
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Create key</button></div>
|
|
212
|
+
</form>
|
|
213
|
+
</Modal>
|
|
214
|
+
<Modal id="new-hook" title="Add webhook endpoint">
|
|
215
|
+
<form method="post">
|
|
216
|
+
<input type="hidden" name="op" value="hook" />
|
|
217
|
+
<label class="field"><span>Endpoint URL</span><input type="url" class="mono" name="url" placeholder="https://hooks.zapier.com/…" required /></label>
|
|
218
|
+
<div class="field"><span>Send these events</span>
|
|
219
|
+
{WEBHOOK_EVENTS.filter((e) => (CALENDAR.enabled || !e.startsWith('event.')) && (bookingsEnabled() || !e.startsWith('booking.'))).map((e) => <label class="check-row"><input type="checkbox" name="events" value={e} checked={e === 'post.published' || e === 'page.published'} /> <code>{e}</code></label>)}
|
|
220
|
+
</div>
|
|
221
|
+
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Add endpoint</button></div>
|
|
222
|
+
<p class="hint modal-note">Deliveries are signed and retried 3 times over about an hour.</p>
|
|
223
|
+
</form>
|
|
224
|
+
</Modal>
|
|
225
|
+
</Admin>
|
|
226
|
+
|
|
227
|
+
<script>
|
|
228
|
+
document.addEventListener('click', async (e) => {
|
|
229
|
+
const c = e.target instanceof Element ? e.target.closest<HTMLElement>('[data-confirm]') : null;
|
|
230
|
+
if (c && !confirm(c.dataset.confirm)) { e.preventDefault(); return; }
|
|
231
|
+
const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
|
|
232
|
+
if (!b) return;
|
|
233
|
+
await navigator.clipboard.writeText(b.dataset.copy ?? '');
|
|
234
|
+
b.textContent = 'Copied';
|
|
235
|
+
setTimeout(() => (b.textContent = 'Copy'), 1200);
|
|
236
|
+
});
|
|
237
|
+
</script>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../layouts/Admin.astro';
|
|
3
|
+
import LiveManager from '../../components/LiveManager';
|
|
4
|
+
import { getRepo } from '../../lib/repo';
|
|
5
|
+
import { requireSession } from '../../lib/api';
|
|
6
|
+
const repo = await getRepo(Astro.locals);
|
|
7
|
+
try { await requireSession(Astro.request, Astro.cookies, repo, true); }
|
|
8
|
+
catch { return new Response('Owner access is required.', { status: 403 }); }
|
|
9
|
+
---
|
|
10
|
+
<Admin title="Live video" section="integrations">
|
|
11
|
+
<LiveManager client:only="react" />
|
|
12
|
+
</Admin>
|
|
@@ -10,7 +10,7 @@ export const GET: APIRoute = ({ url }) => {
|
|
|
10
10
|
|
|
11
11
|
Base: ${o}
|
|
12
12
|
Auth: public reads need no key. Everything else: Authorization: Bearer <key>
|
|
13
|
-
Create keys in the admin:
|
|
13
|
+
Create keys in the admin: Team → API & webhooks. The agent key works too (all permissions).
|
|
14
14
|
All endpoints send CORS headers, so browsers on other sites can call them.
|
|
15
15
|
Times: ISO 8601. Event fields start/end are wall-clock in the event timezone ("2026-10-03T11:00", or "2026-10-03" when allDay).
|
|
16
16
|
Errors: { "error": "message" } with 400 invalid · 401 no/invalid key · 403 missing permission · 404 not found · 409 conflict (slot taken).
|
|
@@ -51,7 +51,9 @@ Guests manage their own booking at ${o}${CALENDAR.bookBase}/manage/<manageToken>
|
|
|
51
51
|
` : ''}
|
|
52
52
|
## Webhooks
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
Full webhook reference (including posts, pages, design, media and restores): /api/webhooks/docs
|
|
55
|
+
|
|
56
|
+
Add endpoints in Team → API & webhooks. Events: event.published, event.updated, event.unpublished, event.deleted${b ? ', booking.created, booking.confirmed, booking.declined, booking.cancelled, booking.rescheduled' : ''}.
|
|
55
57
|
Each POST has headers X-Acms-Event, X-Acms-Delivery, X-Acms-Timestamp and
|
|
56
58
|
X-Acms-Signature: sha256=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>.
|
|
57
59
|
Body: { "id", "type", "createdAt", "data": { ... } }. Failed deliveries retry after 1, 10 and 60 minutes.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getRepo } from '../../../../../lib/repo';
|
|
3
|
+
import { ApiError } from '../../../../../lib/api';
|
|
4
|
+
import { validateSession, SESSION_COOKIE, OWNER_ENV } from '../../../../../lib/auth';
|
|
5
|
+
import { finishOAuth } from '../../../../../lib/live/service';
|
|
6
|
+
export const GET:APIRoute=async({cookies,locals,url})=>{
|
|
7
|
+
const headers={'Cache-Control':'no-store','Referrer-Policy':'no-referrer'};
|
|
8
|
+
try{
|
|
9
|
+
const repo=await getRepo(locals),session=cookies.get(SESSION_COOKIE)?.value;
|
|
10
|
+
// OAuth is intentionally a cross-site GET. Session-bound, one-use state provides CSRF protection.
|
|
11
|
+
const user=await validateSession(session,repo);
|
|
12
|
+
if(!session||!user||user!==OWNER_ENV&&(await repo.getUser(user))?.role!=='owner')throw new ApiError(403,'Sign in as the site owner and start authorization again.');
|
|
13
|
+
await finishOAuth(repo,session,url,locals);
|
|
14
|
+
return new Response(null,{status:303,headers:{...headers,Location:'/admin/live?live=connected'}});
|
|
15
|
+
}catch{
|
|
16
|
+
// Never reflect callback codes, tokens or provider error bodies into an HTML page or URL.
|
|
17
|
+
return new Response(null,{status:303,headers:{...headers,Location:'/admin/live?live=error'}});
|
|
18
|
+
}
|
|
19
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getRepo } from '../../../lib/repo';
|
|
4
|
+
import { ApiError, apiError, json, readJson, requireSession } from '../../../lib/api';
|
|
5
|
+
import { SESSION_COOKIE } from '../../../lib/auth';
|
|
6
|
+
import { Provider } from '../../../lib/live/types';
|
|
7
|
+
import { adminState, beginOAuth, disconnect, saveConnection, syncConnection, validateConfig } from '../../../lib/live/service';
|
|
8
|
+
const Input=z.discriminatedUnion('action',[
|
|
9
|
+
z.object({action:z.literal('saveConnection'),provider:Provider,clientId:z.string().trim().min(1).max(200),clientSecret:z.string().trim().max(8192).default(''),channelInput:z.string().trim().max(2048).default(''),googleStatus:z.enum(['testing','production']).default('testing')}).strict(),
|
|
10
|
+
z.object({action:z.literal('test'),provider:Provider}).strict(),
|
|
11
|
+
z.object({action:z.literal('selectChannel'),provider:z.literal('youtube'),channelId:z.string().min(1).max(200)}).strict(),
|
|
12
|
+
z.object({action:z.literal('enable'),provider:Provider,enabled:z.boolean()}).strict(),
|
|
13
|
+
z.object({action:z.literal('authorize'),provider:z.literal('youtube')}).strict(),
|
|
14
|
+
z.object({action:z.literal('disconnect'),provider:Provider}).strict(),
|
|
15
|
+
z.object({action:z.literal('saveConfig'),config:z.unknown()}).strict(),
|
|
16
|
+
]);
|
|
17
|
+
export const GET:APIRoute=async({request,cookies,locals,url})=>{
|
|
18
|
+
try{const repo=await getRepo(locals);await requireSession(request,cookies,repo,true);return json(await adminState(repo,url,locals));}catch(e){return apiError(e);}
|
|
19
|
+
};
|
|
20
|
+
export const POST:APIRoute=async({request,cookies,locals,url})=>{
|
|
21
|
+
try{
|
|
22
|
+
const repo=await getRepo(locals);await requireSession(request,cookies,repo,true);
|
|
23
|
+
// Browser-based credential changes must originate at this site.
|
|
24
|
+
if(request.headers.get('origin')!==url.origin)throw new ApiError(403,'Submit changes from this site’s admin.');
|
|
25
|
+
const input=Input.parse(await readJson(request,24_000));
|
|
26
|
+
switch(input.action){
|
|
27
|
+
case 'saveConnection':await saveConnection(repo,input,locals);break;
|
|
28
|
+
case 'test':await syncConnection(repo,input.provider,locals,true);break;
|
|
29
|
+
case 'authorize':return json({authorizationUrl:await beginOAuth(repo,cookies.get(SESSION_COOKIE)!.value,url,locals)});
|
|
30
|
+
case 'disconnect':await disconnect(repo,input.provider,locals);break;
|
|
31
|
+
case 'saveConfig':await repo.updateSettings({live:validateConfig(input.config)});break;
|
|
32
|
+
case 'selectChannel':{
|
|
33
|
+
const conn=await repo.live.get(input.provider);const selected=conn?.channels.find(c=>c.id===input.channelId);
|
|
34
|
+
if(!conn||!selected)throw new ApiError(400,'Choose a channel from the authorized account.');
|
|
35
|
+
if(!await repo.live.patch(input.provider,{channelId:selected.id,channelName:selected.name,snapshot:null,upcoming:null,upcomingCheckedAt:0,testedAt:0,enabled:false,lastAttemptAt:0},conn.revision))throw new ApiError(409,'Connection changed; reload and try again.');break;
|
|
36
|
+
}
|
|
37
|
+
case 'enable':{
|
|
38
|
+
const conn=await repo.live.get(input.provider);if(!conn)throw new ApiError(404,'Connection not found.');
|
|
39
|
+
if(input.enabled&&(!conn.testedAt||conn.testedAt<Date.now()-600_000||conn.error||!conn.channelId))throw new ApiError(400,'Run a successful connection test before enabling automatic checks.');
|
|
40
|
+
if(!await repo.live.patch(input.provider,{enabled:input.enabled,nextAt:0,...(!input.enabled?{snapshot:null,upcoming:null,upcomingCheckedAt:0}:{})},conn.revision))throw new ApiError(409,'Connection changed; reload and try again.');break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return json(await adminState(repo,url,locals));
|
|
44
|
+
}catch(e){return apiError(e);}
|
|
45
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getRepo } from '../../../lib/repo';
|
|
3
|
+
import { json, apiError } from '../../../lib/api';
|
|
4
|
+
import { publicState } from '../../../lib/live/service';
|
|
5
|
+
export const GET:APIRoute=async({locals})=>{
|
|
6
|
+
try{return json({snapshot:await publicState(await getRepo(locals)),serverNow:Date.now()});}catch(e){return apiError(e);}
|
|
7
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getRepo } from '../../../lib/repo';
|
|
3
|
+
import { readResponseBytes } from '../../../lib/api';
|
|
4
|
+
import { liveState, thumbSource } from '../../../lib/live/service';
|
|
5
|
+
|
|
6
|
+
// Same-site proxy for the current broadcast's preview image, so visitors
|
|
7
|
+
// never contact YouTube or Twitch until they choose to play. The upstream URL
|
|
8
|
+
// comes only from the current snapshot (fixed hosts, validated ids); the
|
|
9
|
+
// query string is just a cache version.
|
|
10
|
+
const MAX_BYTES = 1_500_000;
|
|
11
|
+
const TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
|
12
|
+
let cached: { src: string; at: number; type: string; body: Uint8Array } | null = null;
|
|
13
|
+
|
|
14
|
+
const missing = () => new Response(null, { status: 404, headers: { 'Cache-Control': 'public, max-age=30' } });
|
|
15
|
+
|
|
16
|
+
export const GET: APIRoute = async ({ locals }) => {
|
|
17
|
+
try {
|
|
18
|
+
const { config, snapshot } = await liveState(await getRepo(locals));
|
|
19
|
+
const src = config.mode !== 'off' && config.display.thumbnail ? thumbSource(snapshot) : '';
|
|
20
|
+
if (!src) return missing();
|
|
21
|
+
if (cached?.src !== src || cached.at < Date.now() - 60_000) {
|
|
22
|
+
// YouTube serves _live stills only while broadcasting; fall back to the
|
|
23
|
+
// regular still (e.g. manual links to an ended stream).
|
|
24
|
+
const candidates = [src, src.replace('hqdefault_live', 'hqdefault')].filter((v, i, a) => a.indexOf(v) === i);
|
|
25
|
+
let found: typeof cached = null;
|
|
26
|
+
for (const url of candidates) {
|
|
27
|
+
// Twitch redirects to a placeholder when offline; treat that as missing.
|
|
28
|
+
const r = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(8_000) });
|
|
29
|
+
const type = (r.headers.get('content-type') ?? '').split(';')[0].trim();
|
|
30
|
+
if (r.status !== 200 || !TYPES.has(type)) { await r.body?.cancel(); continue; }
|
|
31
|
+
found = { src, at: Date.now(), type, body: await readResponseBytes(r, MAX_BYTES) };
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
if (!found) return missing();
|
|
35
|
+
cached = found;
|
|
36
|
+
}
|
|
37
|
+
return new Response(cached.body, {
|
|
38
|
+
headers: {
|
|
39
|
+
'Content-Type': cached.type,
|
|
40
|
+
'Cache-Control': 'public, max-age=60',
|
|
41
|
+
'X-Content-Type-Options': 'nosniff',
|
|
42
|
+
'Content-Security-Policy': "default-src 'none'",
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
} catch {
|
|
46
|
+
return missing();
|
|
47
|
+
}
|
|
48
|
+
};
|