Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db8eb27e5f | ||
|
|
5ebea55353 | ||
|
|
69241a28b1 | ||
|
|
b4c2214c7a | ||
|
|
17e3ac747c | ||
|
|
9ca1cb457d | ||
|
|
b1493d6792 | ||
|
|
2f3a489a8a | ||
|
|
50d66fa2ea | ||
|
|
cedfc9f99c | ||
|
|
1ca688adda | ||
|
|
133558d923 |
@@ -10,6 +10,14 @@ MEDIAMTX_WHIP_PUBLIC=http://${PUBLIC_HOST}:8889
|
|||||||
MEDIAMTX_HLS_PUBLIC=http://${PUBLIC_HOST}:8890
|
MEDIAMTX_HLS_PUBLIC=http://${PUBLIC_HOST}:8890
|
||||||
BLOSSOM_URL_DEFAULT=http://${PUBLIC_HOST}:8098
|
BLOSSOM_URL_DEFAULT=http://${PUBLIC_HOST}:8098
|
||||||
|
|
||||||
|
# ICE host candidate MediaMTX advertises for WebRTC/WHIP (browser-publish
|
||||||
|
# "stream from this browser"). If you're behind Cloudflare or similar
|
||||||
|
# HTTP(S)-only proxy, this MUST be the raw origin IP, not PUBLIC_HOST —
|
||||||
|
# Cloudflare never forwards raw UDP, so a proxied hostname here makes the
|
||||||
|
# WHIP handshake succeed while media silently never arrives. Same reasoning
|
||||||
|
# as MEDIAMTX_RTMP_PUBLIC above. Plain host/IP, no scheme or port.
|
||||||
|
MEDIAMTX_WEBRTC_HOST=${PUBLIC_HOST}
|
||||||
|
|
||||||
# Default nostr relays for NIP-53 live-event announcements (comma separated,
|
# Default nostr relays for NIP-53 live-event announcements (comma separated,
|
||||||
# changeable at runtime in Settings)
|
# changeable at runtime in Settings)
|
||||||
NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
|
NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
|
||||||
|
|||||||
+11
-3
@@ -35,7 +35,7 @@ services:
|
|||||||
- blossom
|
- blossom
|
||||||
|
|
||||||
mediamtx:
|
mediamtx:
|
||||||
image: docker.io/bluenviron/mediamtx:1.19.2
|
image: docker.io/bluenviron/mediamtx:1.20.0
|
||||||
container_name: podsteadr-mediamtx
|
container_name: podsteadr-mediamtx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -44,8 +44,16 @@ services:
|
|||||||
- "8189:8189/udp" # WebRTC ICE
|
- "8189:8189/udp" # WebRTC ICE
|
||||||
- "8890:8888" # HLS (host 8890; 8888 kept free for other apps)
|
- "8890:8888" # HLS (host 8890; 8888 kept free for other apps)
|
||||||
environment:
|
environment:
|
||||||
# Browsers need a reachable ICE host candidate; set PUBLIC_HOST in .env
|
# Browsers need a reachable ICE host candidate for the actual UDP media
|
||||||
MTX_WEBRTCADDITIONALHOSTS: ${PUBLIC_HOST:-localhost}
|
# path (browser-publish "stream from this browser" / WHIP). This must
|
||||||
|
# be the raw origin IP, NOT PUBLIC_HOST — Cloudflare's proxy only
|
||||||
|
# forwards HTTP(S), never raw UDP, regardless of port (same reason
|
||||||
|
# MEDIAMTX_RTMP_PUBLIC above uses the raw IP instead of the
|
||||||
|
# Cloudflare-proxied domain). Using PUBLIC_HOST here means the browser
|
||||||
|
# resolves the ICE candidate to Cloudflare's edge and the WHIP HTTP
|
||||||
|
# handshake succeeds while media silently never arrives — set
|
||||||
|
# MEDIAMTX_WEBRTC_HOST in .env.
|
||||||
|
MTX_WEBRTCADDITIONALHOSTS: ${MEDIAMTX_WEBRTC_HOST:-localhost}
|
||||||
volumes:
|
volumes:
|
||||||
- ./mediamtx/mediamtx.yml:/mediamtx.yml:ro
|
- ./mediamtx/mediamtx.yml:/mediamtx.yml:ro
|
||||||
- mediamtx-recordings:/recordings
|
- mediamtx-recordings:/recordings
|
||||||
|
|||||||
+24
-9
@@ -44,7 +44,7 @@ Three containers on one compose network:
|
|||||||
| Container | Image | Host ports | Role |
|
| Container | Image | Host ports | Role |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `podsteadr` | built from `Dockerfile` (node:22 + ffmpeg) | 8095 | Fastify API + built Vue UI + RSS feeds |
|
| `podsteadr` | built from `Dockerfile` (node:22 + ffmpeg) | 8095 | Fastify API + built Vue UI + RSS feeds |
|
||||||
| `podsteadr-mediamtx` | `bluenviron/mediamtx:1.19.2` | 1935 (RTMP), 8889 (WHIP), 8189/udp (ICE), 8890→8888 (HLS) | ingest + HLS output + recording |
|
| `podsteadr-mediamtx` | `bluenviron/mediamtx:1.20.0` | 1935 (RTMP), 8889 (WHIP), 8189/udp (ICE), 8890→8888 (HLS) | ingest + HLS output + recording |
|
||||||
| `podsteadr-blossom` | `ghcr.io/hzrd149/blossom-server:4` (4.4.1) | 8098→3000 | sha256-addressed media blobs |
|
| `podsteadr-blossom` | `ghcr.io/hzrd149/blossom-server:4` (4.4.1) | 8098→3000 | sha256-addressed media blobs |
|
||||||
|
|
||||||
Key flows:
|
Key flows:
|
||||||
@@ -153,14 +153,29 @@ frontend/ # Vue 3 + Vite + Tailwind + Pinia
|
|||||||
|
|
||||||
## Remaining work
|
## Remaining work
|
||||||
|
|
||||||
- **Archipelago packaging** (the original deployment target, archy repo):
|
- **Archipelago packaging — done (2026-08-07)**, on the archy repo branch
|
||||||
`apps/podsteadr/manifest.yml` + `apps/podsteadr-mediamtx` + `apps/podsteadr-blossom`
|
`feat/podsteadr-app-package` (not yet merged/pushed): `apps/podsteadr/manifest.yml`
|
||||||
following the `apps/btcpay-server` (dependencies) + `apps/monero-ui`
|
(`container.build` from this repo's own Dockerfile, following the
|
||||||
(`container.build` on `/opt/archipelago/docker/...`) patterns; bind volumes
|
`apps/indeedhub` externally-sourced-app pattern) + `apps/podsteadr-mediamtx` +
|
||||||
under `/var/lib/archipelago/<app>`; add ports **8095, 1935, 8889, 8189/udp,
|
`apps/podsteadr-blossom`, all three on a dedicated `podsteadr-net` bridge
|
||||||
8890, 8098** to `apps/PORTS.md` (chosen 2026-07-10 to avoid fleet collisions —
|
network per the `apps/indeedhub-*` sibling-manifest pattern. Bind volumes
|
||||||
8888 is searxng, hence HLS on 8890). `interfaces.main` → port 8095.
|
under `/var/lib/archipelago/<app>`. Ports **8095, 1935, 8889, 8189/udp, 8890,
|
||||||
- **No git remote yet** — decide where to push (gitea?).
|
8098** added to `apps/PORTS.md` (chosen 2026-07-10 to avoid fleet collisions —
|
||||||
|
8888 is searxng, hence HLS on 8890), all declared `auth: none` with a
|
||||||
|
rationale (public podcast/livestream server — RSS/HLS/blob reads must stay
|
||||||
|
reachable with no Archipelago session; podsteadr already gates its own
|
||||||
|
sensitive routes via NIP-98). `interfaces.main` → port 8095. Passes
|
||||||
|
`scripts/validate-app-manifest.sh` and `cargo test -p archipelago-container
|
||||||
|
manifest` in archy. Not yet verified against a real node install — the
|
||||||
|
`data_uid`/capabilities guesses for blossom and mediamtx (both root-running
|
||||||
|
images writing to fresh bind mounts) are flagged inline as unverified.
|
||||||
|
- Separately, podsteadr is also registered in archy's neode-ui dashboard as an
|
||||||
|
*external* identity-aware app (bookmark to the standalone
|
||||||
|
podsteadr.atobitcoin.io instance + NIP-07 bridge), on archy branch
|
||||||
|
`feat/podsteadr-external-nostr-identity` — a lighter integration than the
|
||||||
|
installable package above, for the already-hosted instance. The two are
|
||||||
|
complementary, not overlapping.
|
||||||
|
- Git remote: `http://146.59.87.168:3000/ssmithx/podsteadr.git` (gitea).
|
||||||
- Browser-tested only synthetically: the wizards should get a real pass with an
|
- Browser-tested only synthetically: the wizards should get a real pass with an
|
||||||
actual NIP-07 extension + OBS (the API surface they call is fully covered by
|
actual NIP-07 extension + OBS (the API surface they call is fully covered by
|
||||||
the e2e script, so surprises should be cosmetic).
|
the e2e script, so surprises should be cosmetic).
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>podsteadr</title>
|
<title>podsteadr</title>
|
||||||
|
<!-- No-op outside an Archipelago iframe (see nostr-provider.js's own
|
||||||
|
window === window.top guard) — safe to always include. Provides
|
||||||
|
window.nostr + auto sign-in via the node's selected nostr identity
|
||||||
|
when opened inside the Archipelago shell. -->
|
||||||
|
<script src="/nostr-provider.js" data-session-url="/api/auth/login" data-session-mode="cookie" data-me-url="/api/auth/me" data-health-url="/api/health"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* NIP-07 Nostr Provider Shim — Archipelago
|
||||||
|
*
|
||||||
|
* Vendored from archy/neode-ui/public/nostr-provider.js (generalized version).
|
||||||
|
* Provides window.nostr (NIP-07) for iframe apps launched inside the
|
||||||
|
* Archipelago shell, bridging signing requests via postMessage to the
|
||||||
|
* parent frame, which relays them to the Archipelago node's identity
|
||||||
|
* manager. Auto sign-in: does NIP-98 auth against this app's own backend,
|
||||||
|
* then reloads so the app picks up the valid session.
|
||||||
|
*
|
||||||
|
* Not vendored via an Archipelago manifest hook (podsteadr isn't an
|
||||||
|
* orchestrator-managed package — see neode-ui's EXTERNAL_URLS /
|
||||||
|
* WEB_ONLY_APP-style "external web app" registration instead), so this
|
||||||
|
* copy won't auto-update with archy's OTA releases. Re-sync by hand from
|
||||||
|
* archy/neode-ui/public/nostr-provider.js if that file changes.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
if (window.__archipelagoNostr) return;
|
||||||
|
window.__archipelagoNostr = true;
|
||||||
|
if (window === window.top) return;
|
||||||
|
|
||||||
|
var pending = {}, nextId = 1;
|
||||||
|
|
||||||
|
function request(method, params) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var id = nextId++;
|
||||||
|
pending[id] = { resolve: resolve, reject: reject };
|
||||||
|
window.parent.postMessage({ type: 'nostr-request', id: id, method: method, params: params || {} }, '*');
|
||||||
|
setTimeout(function () { if (pending[id]) { pending[id].reject(new Error('NIP-07 timeout')); delete pending[id]; } }, 30000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', function (e) {
|
||||||
|
if (!e.data || e.data.type !== 'nostr-response') return;
|
||||||
|
var h = pending[e.data.id]; if (!h) return; delete pending[e.data.id];
|
||||||
|
e.data.error ? h.reject(new Error(e.data.error)) : h.resolve(e.data.result);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.nostr = {
|
||||||
|
getPublicKey: function () { return request('getPublicKey'); },
|
||||||
|
signEvent: function (ev) { return request('signEvent', { event: ev }); },
|
||||||
|
sign: function (ev) { return request('signEvent', { event: ev }); },
|
||||||
|
getRelays: function () { return request('getRelays'); },
|
||||||
|
nip04: {
|
||||||
|
encrypt: function (pk, pt) { return request('nip04.encrypt', { pubkey: pk, plaintext: pt }); },
|
||||||
|
decrypt: function (pk, ct) { return request('nip04.decrypt', { pubkey: pk, ciphertext: ct }); },
|
||||||
|
},
|
||||||
|
nip44: {
|
||||||
|
encrypt: function (pk, pt) { return request('nip44.encrypt', { pubkey: pk, plaintext: pt }); },
|
||||||
|
decrypt: function (pk, ct) { return request('nip44.decrypt', { pubkey: pk, ciphertext: ct }); },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Loading Overlay ---
|
||||||
|
var overlay = null;
|
||||||
|
|
||||||
|
function showLoader(message) {
|
||||||
|
if (overlay) return;
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.id = 'archipelago-auth-overlay';
|
||||||
|
overlay.innerHTML =
|
||||||
|
'<div style="display:flex;flex-direction:column;align-items:center;gap:16px;">' +
|
||||||
|
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" style="animation:archy-spin 1s linear infinite">' +
|
||||||
|
'<circle cx="12" cy="12" r="10" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>' +
|
||||||
|
'<path d="M12 2a10 10 0 019.95 9" stroke="#fb923c" stroke-width="3" stroke-linecap="round"/>' +
|
||||||
|
'</svg>' +
|
||||||
|
'<div style="color:rgba(255,255,255,0.9);font:500 14px/1.4 -apple-system,system-ui,sans-serif">' + (message || 'Signing in...') + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);backdrop-filter:blur(8px);';
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.textContent = '@keyframes archy-spin{to{transform:rotate(360deg)}}';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLoader(message) {
|
||||||
|
if (!overlay) return;
|
||||||
|
var txt = overlay.querySelector('div > div');
|
||||||
|
if (txt) txt.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideLoader() {
|
||||||
|
if (overlay) { overlay.remove(); overlay = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-app config (data-* attrs on the injected <script> tag). Defaults
|
||||||
|
// match indeedhub's original hardcoded values, so apps that don't set any
|
||||||
|
// overrides keep behaving exactly as before.
|
||||||
|
var scriptEl = document.currentScript;
|
||||||
|
var ds = (scriptEl && scriptEl.dataset) || {};
|
||||||
|
var cfg = {
|
||||||
|
healthUrl: ds.healthUrl || '/api/nostr-auth/health',
|
||||||
|
sessionUrl: ds.sessionUrl || '/api/auth/nostr/session',
|
||||||
|
sessionMethod: ds.sessionMethod || 'POST',
|
||||||
|
// 'token' (default): login response is JSON {accessToken, refreshToken};
|
||||||
|
// stored in sessionStorage, matches indeedhub.
|
||||||
|
// 'cookie': server sets the session cookie directly on the login
|
||||||
|
// response (Set-Cookie) — nothing to store client-side, just reload.
|
||||||
|
sessionMode: ds.sessionMode || 'token',
|
||||||
|
// Optional: for cookie-mode apps, check this endpoint first and skip
|
||||||
|
// the NIP-98 handshake entirely if it reports already-authenticated
|
||||||
|
// (401 otherwise) — avoids re-running sign-in on every iframe reload.
|
||||||
|
meUrl: ds.meUrl || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Direct NIP-98 Auth ---
|
||||||
|
var authDone = false;
|
||||||
|
|
||||||
|
function performNip98Auth(pubkey) {
|
||||||
|
var healthUrl = window.location.origin + cfg.healthUrl;
|
||||||
|
var sessionUrl = window.location.origin + cfg.sessionUrl;
|
||||||
|
|
||||||
|
// 1. Check if API backend is reachable (3s timeout)
|
||||||
|
var hc = new AbortController();
|
||||||
|
var ht = setTimeout(function () { hc.abort(); }, 3000);
|
||||||
|
|
||||||
|
fetch(healthUrl, { signal: hc.signal }).then(function (r) {
|
||||||
|
clearTimeout(ht);
|
||||||
|
if (!r.ok) throw new Error('Health ' + r.status);
|
||||||
|
|
||||||
|
// 2. API is up — show loader and do NIP-98
|
||||||
|
showLoader('Signing in with Nostr...');
|
||||||
|
var now = Math.floor(Date.now() / 1000);
|
||||||
|
var event = {
|
||||||
|
kind: 27235, created_at: now, content: '', pubkey: pubkey,
|
||||||
|
tags: [['u', sessionUrl], ['method', cfg.sessionMethod]]
|
||||||
|
};
|
||||||
|
console.log('[nostr-provider] NIP-98: signing for', sessionUrl);
|
||||||
|
return window.nostr.signEvent(event);
|
||||||
|
|
||||||
|
}).then(function (signed) {
|
||||||
|
updateLoader('Creating session...');
|
||||||
|
var ac = new AbortController();
|
||||||
|
setTimeout(function () { ac.abort(); }, 10000);
|
||||||
|
return fetch(sessionUrl, {
|
||||||
|
method: cfg.sessionMethod,
|
||||||
|
headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) },
|
||||||
|
signal: ac.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
}).then(function (res) {
|
||||||
|
console.log('[nostr-provider] NIP-98: response', res.status);
|
||||||
|
if (!res.ok) throw new Error('Auth failed: ' + res.status);
|
||||||
|
if (cfg.sessionMode === 'cookie') {
|
||||||
|
// Session cookie already landed via Set-Cookie on this response.
|
||||||
|
updateLoader('Signed in! Loading...');
|
||||||
|
console.log('[nostr-provider] NIP-98: success (cookie session), reloading...');
|
||||||
|
setTimeout(function () { window.location.reload(); }, 400);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
|
||||||
|
}).then(function (data) {
|
||||||
|
if (!data) return; // cookie-mode: handled above, nothing left to do
|
||||||
|
if (data.accessToken) {
|
||||||
|
sessionStorage.setItem('nostr_token', data.accessToken);
|
||||||
|
sessionStorage.setItem('nostr_pubkey', pubkey);
|
||||||
|
if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken);
|
||||||
|
updateLoader('Signed in! Loading...');
|
||||||
|
console.log('[nostr-provider] NIP-98: success, reloading...');
|
||||||
|
setTimeout(function () { window.location.reload(); }, 400);
|
||||||
|
} else {
|
||||||
|
hideLoader(); authDone = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}).catch(function (err) {
|
||||||
|
hideLoader(); authDone = false;
|
||||||
|
var msg = err.message || String(err);
|
||||||
|
if (msg.indexOf('abort') > -1) msg = 'API timeout';
|
||||||
|
console.warn('[nostr-provider] NIP-98 skipped:', msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function doNip98Auth(pubkey) {
|
||||||
|
if (authDone) return;
|
||||||
|
authDone = true;
|
||||||
|
|
||||||
|
if (cfg.meUrl) {
|
||||||
|
// Already-authenticated check first — avoids re-running the NIP-98
|
||||||
|
// handshake (and its reload) on every iframe load for cookie-session
|
||||||
|
// apps, where there's no client-visible token to check locally.
|
||||||
|
fetch(window.location.origin + cfg.meUrl, { credentials: 'same-origin' })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.ok) {
|
||||||
|
console.log('[nostr-provider] Already authenticated (meUrl ok), skipping NIP-98');
|
||||||
|
authDone = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performNip98Auth(pubkey);
|
||||||
|
})
|
||||||
|
.catch(function () { performNip98Auth(pubkey); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
performNip98Auth(pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for identity from parent Archipelago frame
|
||||||
|
window.addEventListener('message', function (e) {
|
||||||
|
if (!e.data || e.data.type !== 'archipelago:identity') return;
|
||||||
|
var pk = e.data.nostr_pubkey;
|
||||||
|
console.log('[nostr-provider] Identity received:', pk ? pk.slice(0, 12) + '...' : 'none');
|
||||||
|
if (!pk) return;
|
||||||
|
|
||||||
|
// Skip if already signed in with a real token (not mock)
|
||||||
|
try {
|
||||||
|
var token = sessionStorage.getItem('nostr_token');
|
||||||
|
if (token && token.indexOf('mock-') === -1) {
|
||||||
|
console.log('[nostr-provider] Already signed in with real token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (x) {}
|
||||||
|
|
||||||
|
setTimeout(function () { doNip98Auth(pk); }, 1500);
|
||||||
|
});
|
||||||
|
})();
|
||||||
+16
-1
@@ -1,10 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
import { useAuthStore } from './stores/auth';
|
import { useAuthStore } from './stores/auth';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Hex pubkeys look identical at a glance across identities — npub is the
|
||||||
|
// standard nostr display form and is what users actually recognize.
|
||||||
|
const identityLabel = computed(() => {
|
||||||
|
if (auth.displayName) return auth.displayName;
|
||||||
|
if (!auth.pubkey) return '';
|
||||||
|
try {
|
||||||
|
const npub = nip19.npubEncode(auth.pubkey);
|
||||||
|
return npub.slice(0, 12) + '…' + npub.slice(-6);
|
||||||
|
} catch {
|
||||||
|
return auth.pubkey.slice(0, 8) + '…';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await auth.logout();
|
await auth.logout();
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
@@ -23,7 +38,7 @@ async function logout() {
|
|||||||
<RouterLink to="/earnings" class="hover:text-orange-400">Earnings</RouterLink>
|
<RouterLink to="/earnings" class="hover:text-orange-400">Earnings</RouterLink>
|
||||||
<RouterLink to="/settings" class="hover:text-orange-400">Settings</RouterLink>
|
<RouterLink to="/settings" class="hover:text-orange-400">Settings</RouterLink>
|
||||||
<button class="btn-secondary !px-3 !py-1" @click="logout">
|
<button class="btn-secondary !px-3 !py-1" @click="logout">
|
||||||
<span class="max-w-[8rem] truncate font-mono text-xs">{{ auth.displayName || auth.pubkey.slice(0, 8) + '…' }}</span>
|
<span class="max-w-[8rem] truncate font-mono text-xs" :title="auth.pubkey ?? undefined">{{ identityLabel }}</span>
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ export async function uploadToBlossom(
|
|||||||
file: File,
|
file: File,
|
||||||
sha256: string,
|
sha256: string,
|
||||||
onProgress?: (frac: number) => void,
|
onProgress?: (frac: number) => void,
|
||||||
|
onSigning?: () => void,
|
||||||
): Promise<BlossomUpload> {
|
): Promise<BlossomUpload> {
|
||||||
|
// signEvent() waits on the NIP-07 extension's own approval popup, which can
|
||||||
|
// take an unbounded amount of time (or go unnoticed) — tell the caller so
|
||||||
|
// the UI doesn't say "Uploading… 0%" while nothing has actually started.
|
||||||
|
onSigning?.();
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const auth = await nip07().signEvent({
|
const auth = await nip07().signEvent({
|
||||||
kind: 24242,
|
kind: 24242,
|
||||||
|
|||||||
@@ -10,9 +10,41 @@ export async function publishWhip(
|
|||||||
bearer: string,
|
bearer: string,
|
||||||
stream: MediaStream,
|
stream: MediaStream,
|
||||||
): Promise<WhipSession> {
|
): Promise<WhipSession> {
|
||||||
|
// MediaMTX's HLS output only muxes AV1, VP9, H265, H264, Opus, MPEG-4
|
||||||
|
// Audio, or KLV (confirmed live in its logs: "the stream doesn't contain
|
||||||
|
// any supported codec" — the muxer gets created then immediately
|
||||||
|
// destroyed, so the WHIP publish itself still succeeds and the stream
|
||||||
|
// shows as live, but hls/live/<id>/index.m3u8 permanently 404s with
|
||||||
|
// "muxer is waiting to be created"). Browsers default to VP8 for
|
||||||
|
// getUserMedia/getDisplayMedia video, which isn't in that list at all.
|
||||||
|
// Reordering codec preference to H264 first didn't actually change what
|
||||||
|
// got negotiated on a real test (RTCRtpSender.getCapabilities('video')
|
||||||
|
// apparently didn't list H264 on that browser/machine — Chrome's H264
|
||||||
|
// encoder is a separate downloadable component and isn't guaranteed
|
||||||
|
// present) — so try every MediaMTX-supported codec in priority order
|
||||||
|
// instead of only H264, and fail loudly if literally none of them are
|
||||||
|
// available rather than silently falling back to the broken default.
|
||||||
|
const MEDIAMTX_HLS_VIDEO_CODECS = ['video/H264', 'video/VP9', 'video/AV1'];
|
||||||
|
|
||||||
const pc = new RTCPeerConnection();
|
const pc = new RTCPeerConnection();
|
||||||
for (const track of stream.getTracks()) {
|
for (const track of stream.getTracks()) {
|
||||||
pc.addTransceiver(track, { direction: 'sendonly' });
|
const transceiver = pc.addTransceiver(track, { direction: 'sendonly' });
|
||||||
|
if (track.kind === 'video' && typeof transceiver.setCodecPreferences === 'function') {
|
||||||
|
const capabilities = RTCRtpSender.getCapabilities('video');
|
||||||
|
const available = capabilities?.codecs ?? [];
|
||||||
|
const preferred = MEDIAMTX_HLS_VIDEO_CODECS.flatMap((mime) =>
|
||||||
|
available.filter((c) => c.mimeType.toLowerCase() === mime.toLowerCase()),
|
||||||
|
);
|
||||||
|
if (preferred.length === 0) {
|
||||||
|
pc.close();
|
||||||
|
throw new Error(
|
||||||
|
"This browser doesn't support any video codec MediaMTX can turn into HLS " +
|
||||||
|
`(needs one of: ${MEDIAMTX_HLS_VIDEO_CODECS.join(', ')}). Try a different browser, or use OBS instead.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rest = available.filter((c) => !preferred.includes(c));
|
||||||
|
transceiver.setCodecPreferences([...preferred, ...rest]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const offer = await pc.createOffer();
|
const offer = await pc.createOffer();
|
||||||
|
|||||||
@@ -4,18 +4,30 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import PodcastForm, { type PodcastPayload } from '../components/PodcastForm.vue';
|
import PodcastForm, { type PodcastPayload } from '../components/PodcastForm.vue';
|
||||||
|
|
||||||
|
interface Episode {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
pub_date: number;
|
||||||
|
unlisted: number;
|
||||||
|
}
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const podcastId = route.params.id as string;
|
const podcastId = route.params.id as string;
|
||||||
|
|
||||||
const podcast = ref<(PodcastPayload & { id: string }) | null>(null);
|
const podcast = ref<(PodcastPayload & { id: string }) | null>(null);
|
||||||
|
const episodes = ref<Episode[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const saved = ref(false);
|
const saved = ref(false);
|
||||||
|
const episodeError = ref('');
|
||||||
|
const busyEpisodeId = ref('');
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
podcast.value = await api.get<PodcastPayload & { id: string }>(`/api/podcasts/${podcastId}`);
|
const data = await api.get<PodcastPayload & { id: string; episodes: Episode[] }>(`/api/podcasts/${podcastId}`);
|
||||||
|
podcast.value = data;
|
||||||
|
episodes.value = data.episodes;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = (err as Error).message;
|
error.value = (err as Error).message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -27,6 +39,23 @@ function onSaved(): void {
|
|||||||
saved.value = true;
|
saved.value = true;
|
||||||
setTimeout(() => router.push('/'), 800);
|
setTimeout(() => router.push('/'), 800);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleListed(ep: Episode): Promise<void> {
|
||||||
|
const nextUnlisted = !ep.unlisted;
|
||||||
|
if (nextUnlisted && !confirm(`Remove "${ep.title}" from the RSS feed? It stays in your library and can be added back later.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
episodeError.value = '';
|
||||||
|
busyEpisodeId.value = ep.id;
|
||||||
|
try {
|
||||||
|
const updated = await api.put<Episode>(`/api/podcasts/${podcastId}/episodes/${ep.id}`, { unlisted: nextUnlisted });
|
||||||
|
ep.unlisted = updated.unlisted;
|
||||||
|
} catch (err) {
|
||||||
|
episodeError.value = (err as Error).message;
|
||||||
|
} finally {
|
||||||
|
busyEpisodeId.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -40,5 +69,38 @@ function onSaved(): void {
|
|||||||
Saved.
|
Saved.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loading && !error" class="card">
|
||||||
|
<h2 class="mb-1 text-lg font-semibold">Episodes</h2>
|
||||||
|
<p class="mb-4 text-xs text-white/30">
|
||||||
|
Removing an episode from the feed hides it from RSS/podcast apps but keeps it in your
|
||||||
|
library — sales history, reseller listings, and the uploaded file are untouched, and you
|
||||||
|
can add it back any time.
|
||||||
|
</p>
|
||||||
|
<p v-if="episodeError" class="mb-3 rounded-lg bg-red-500/20 border border-red-500/40 p-3 text-sm text-red-200">
|
||||||
|
{{ episodeError }}
|
||||||
|
</p>
|
||||||
|
<p v-if="!episodes.length" class="text-sm text-white/50">No episodes yet.</p>
|
||||||
|
<ul v-else class="space-y-2">
|
||||||
|
<li v-for="ep in episodes" :key="ep.id" class="flex items-center justify-between gap-4 rounded-lg bg-white/5 p-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate font-medium" :class="{ 'text-white/40': ep.unlisted }">{{ ep.title }}</p>
|
||||||
|
<p class="text-xs text-white/30">
|
||||||
|
{{ new Date(ep.pub_date * 1000).toLocaleDateString() }}
|
||||||
|
<span v-if="ep.unlisted" class="ml-2 rounded-full bg-amber-500/20 px-2 py-0.5 text-amber-300">
|
||||||
|
Removed from feed
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn-secondary shrink-0 whitespace-nowrap !py-1.5 !px-3 text-sm"
|
||||||
|
:disabled="busyEpisodeId === ep.id"
|
||||||
|
@click="toggleListed(ep)"
|
||||||
|
>
|
||||||
|
{{ busyEpisodeId === ep.id ? 'Working…' : ep.unlisted ? 'Add back to feed' : 'Remove from feed' }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue';
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
|
||||||
@@ -8,28 +9,64 @@ interface Settings {
|
|||||||
relays: string[];
|
relays: string[];
|
||||||
public_url: string;
|
public_url: string;
|
||||||
admin_pubkey: string | null;
|
admin_pubkey: string | null;
|
||||||
|
login_allowlist_enabled: boolean;
|
||||||
|
login_allowlist: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const form = reactive({ blossom_url: '', relays: '', public_url: '' });
|
const form = reactive({
|
||||||
|
blossom_url: '',
|
||||||
|
relays: '',
|
||||||
|
public_url: '',
|
||||||
|
login_allowlist_enabled: false,
|
||||||
|
login_allowlist: '',
|
||||||
|
});
|
||||||
const saved = ref(false);
|
const saved = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
|
|
||||||
|
/** Accepts npub or raw hex, one per line; returns lowercase hex. Throws on anything invalid. */
|
||||||
|
function parseAllowlist(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
if (line.startsWith('npub1')) {
|
||||||
|
const decoded = nip19.decode(line);
|
||||||
|
if (decoded.type !== 'npub') throw new Error(`not an npub: ${line}`);
|
||||||
|
return decoded.data;
|
||||||
|
}
|
||||||
|
if (!/^[0-9a-f]{64}$/i.test(line)) throw new Error(`not a valid npub or hex pubkey: ${line}`);
|
||||||
|
return line.toLowerCase();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const s = await api.get<Settings>('/api/settings');
|
const s = await api.get<Settings>('/api/settings');
|
||||||
form.blossom_url = s.blossom_url;
|
form.blossom_url = s.blossom_url;
|
||||||
form.relays = s.relays.join('\n');
|
form.relays = s.relays.join('\n');
|
||||||
form.public_url = s.public_url;
|
form.public_url = s.public_url;
|
||||||
|
form.login_allowlist_enabled = s.login_allowlist_enabled;
|
||||||
|
form.login_allowlist = s.login_allowlist.map((pk) => nip19.npubEncode(pk)).join('\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
error.value = '';
|
error.value = '';
|
||||||
saved.value = false;
|
saved.value = false;
|
||||||
|
let allowlist: string[];
|
||||||
|
try {
|
||||||
|
allowlist = parseAllowlist(form.login_allowlist);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = (err as Error).message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await api.put('/api/settings', {
|
await api.put('/api/settings', {
|
||||||
blossom_url: form.blossom_url,
|
blossom_url: form.blossom_url,
|
||||||
relays: form.relays.split('\n').map((r) => r.trim()).filter(Boolean),
|
relays: form.relays.split('\n').map((r) => r.trim()).filter(Boolean),
|
||||||
public_url: form.public_url,
|
public_url: form.public_url,
|
||||||
|
login_allowlist_enabled: form.login_allowlist_enabled,
|
||||||
|
login_allowlist: allowlist,
|
||||||
});
|
});
|
||||||
saved.value = true;
|
saved.value = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -62,6 +99,24 @@ async function save() {
|
|||||||
<input id="set-public" v-model="form.public_url" class="input" type="url" required />
|
<input id="set-public" v-model="form.public_url" class="input" type="url" required />
|
||||||
<p class="mt-1 text-xs text-white/30">Used in RSS feed links. Must be reachable by podcast apps.</p>
|
<p class="mt-1 text-xs text-white/30">Used in RSS feed links. Must be reachable by podcast apps.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="border-t border-white/10 pt-4">
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="form.login_allowlist_enabled" type="checkbox" />
|
||||||
|
Restrict logins to an allowlist
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 text-xs text-white/30">
|
||||||
|
When enabled, only the admin and pubkeys listed below can log in. Everyone else is
|
||||||
|
rejected at login (existing sessions aren't revoked).
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
id="set-allowlist"
|
||||||
|
v-model="form.login_allowlist"
|
||||||
|
class="input mt-2 font-mono text-sm"
|
||||||
|
rows="6"
|
||||||
|
placeholder="npub1... (one per line, npub or hex)"
|
||||||
|
:disabled="!form.login_allowlist_enabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<p v-if="!auth.isAdmin" class="rounded-lg bg-amber-500/20 border border-amber-500/40 p-3 text-sm text-amber-200">
|
<p v-if="!auth.isAdmin" class="rounded-lg bg-amber-500/20 border border-amber-500/40 p-3 text-sm text-amber-200">
|
||||||
Only the admin (first account to log in) can change settings.
|
Only the admin (first account to log in) can change settings.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const podcast = ref<PodcastSummary | null>(null);
|
|||||||
const creatingNew = ref(false);
|
const creatingNew = ref(false);
|
||||||
|
|
||||||
const file = ref<File | null>(null);
|
const file = ref<File | null>(null);
|
||||||
const phase = ref<'idle' | 'hashing' | 'uploading' | 'registering'>('idle');
|
const phase = ref<'idle' | 'hashing' | 'signing' | 'uploading' | 'registering'>('idle');
|
||||||
const progress = ref(0);
|
const progress = ref(0);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ const episodeUrl = ref('');
|
|||||||
const phaseLabel = computed(() => ({
|
const phaseLabel = computed(() => ({
|
||||||
idle: '',
|
idle: '',
|
||||||
hashing: 'Computing sha256…',
|
hashing: 'Computing sha256…',
|
||||||
|
signing: 'Waiting for your nostr extension to approve the upload — check for a popup (it may be behind this window).',
|
||||||
uploading: `Uploading to Blossom… ${(progress.value * 100).toFixed(0)}%`,
|
uploading: `Uploading to Blossom… ${(progress.value * 100).toFixed(0)}%`,
|
||||||
registering: 'Publishing episode…',
|
registering: 'Publishing episode…',
|
||||||
}[phase.value]));
|
}[phase.value]));
|
||||||
@@ -72,9 +73,17 @@ async function publish() {
|
|||||||
durationSecs.value = await probeDuration(file.value);
|
durationSecs.value = await probeDuration(file.value);
|
||||||
const sha = await sha256File(file.value);
|
const sha = await sha256File(file.value);
|
||||||
|
|
||||||
phase.value = 'uploading';
|
|
||||||
const blossomUrl = settings.public!.blossomUrl;
|
const blossomUrl = settings.public!.blossomUrl;
|
||||||
await uploadToBlossom(blossomUrl, file.value, sha, (f) => (progress.value = f));
|
await uploadToBlossom(
|
||||||
|
blossomUrl,
|
||||||
|
file.value,
|
||||||
|
sha,
|
||||||
|
(f) => {
|
||||||
|
phase.value = 'uploading';
|
||||||
|
progress.value = f;
|
||||||
|
},
|
||||||
|
() => (phase.value = 'signing'),
|
||||||
|
);
|
||||||
uploaded.value = { sha256: sha, size: file.value.size };
|
uploaded.value = { sha256: sha, size: file.value.size };
|
||||||
|
|
||||||
phase.value = 'registering';
|
phase.value = 'registering';
|
||||||
|
|||||||
+19
-7
@@ -25,13 +25,22 @@ rtmpAddress: :1935
|
|||||||
|
|
||||||
hls: yes
|
hls: yes
|
||||||
hlsAddress: :8888
|
hlsAddress: :8888
|
||||||
# Standard HLS, not lowLatency: LL-HLS's small per-part buffering window has very little
|
# Switched from mpegts back to lowLatency (2026-08-11): mpegts can only mux
|
||||||
# tolerance for B-frame reordering (common in most OBS encoder presets), and a real test
|
# H264, and browser (WHIP) publishing sends VP8/VP9 depending on the
|
||||||
# stream crashed the muxer twice in ~2 minutes with "too many reordered frames" / "unable to
|
# machine's available encoders — confirmed live, mpegts crashed with "the
|
||||||
# extract DTS" once frame timing got even slightly irregular. Standard HLS buffers a full
|
# MPEG-TS variant of HLS supports H264 video only" for a VP9 browser stream.
|
||||||
# segment before finalizing, which absorbs that jitter — a few extra seconds of latency
|
# lowLatency supports AV1/VP9/H265/H264/Opus, so it's required for browser
|
||||||
# instead of intermittent muxer crashes / viewer buffering.
|
# publishing to produce any HLS output at all.
|
||||||
hlsVariant: mpegts
|
#
|
||||||
|
# Known risk: this is the variant that was moved AWAY from earlier — LL-HLS's
|
||||||
|
# small per-part buffering window has very little tolerance for B-frame
|
||||||
|
# reordering (common in most OBS encoder presets), and a real OBS test
|
||||||
|
# stream crashed the muxer twice in ~2 minutes with "too many reordered
|
||||||
|
# frames" / "unable to extract DTS" once frame timing got even slightly
|
||||||
|
# irregular. If that recurs, the real fix is running two MediaMTX instances
|
||||||
|
# (mpegts for RTMP/OBS, lowLatency for WHIP/browser) since hlsVariant is a
|
||||||
|
# global setting with no per-path override — not flipping back and forth.
|
||||||
|
hlsVariant: lowLatency
|
||||||
hlsAlwaysRemux: yes
|
hlsAlwaysRemux: yes
|
||||||
hlsAllowOrigins: ["*"]
|
hlsAllowOrigins: ["*"]
|
||||||
|
|
||||||
@@ -39,6 +48,9 @@ webrtc: yes
|
|||||||
webrtcAddress: :8889
|
webrtcAddress: :8889
|
||||||
webrtcLocalUDPAddress: :8189
|
webrtcLocalUDPAddress: :8189
|
||||||
webrtcAllowOrigins: ["*"]
|
webrtcAllowOrigins: ["*"]
|
||||||
|
# webrtcAdditionalHosts is set via MTX_WEBRTCADDITIONALHOSTS in
|
||||||
|
# docker-compose.yml (MEDIAMTX_WEBRTC_HOST in .env) — see the comment there
|
||||||
|
# for why it must be the raw IP, not the Cloudflare-proxied domain.
|
||||||
|
|
||||||
# ---- recording -----------------------------------------------------------
|
# ---- recording -----------------------------------------------------------
|
||||||
pathDefaults:
|
pathDefaults:
|
||||||
|
|||||||
@@ -79,8 +79,81 @@ describe('auth', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('login allowlist', () => {
|
||||||
|
const sk2 = generateSecretKey();
|
||||||
|
const pk2 = getPublicKey(sk2);
|
||||||
|
|
||||||
|
function nip98Header2(url: string, method: string): string {
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 27235,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
content: '',
|
||||||
|
tags: [['u', url], ['method', method], ['nonce', Math.random().toString(36).slice(2)]],
|
||||||
|
},
|
||||||
|
sk2,
|
||||||
|
);
|
||||||
|
return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rejects a non-listed pubkey once the allowlist is enabled, admin still logs in', async () => {
|
||||||
|
const enable = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings',
|
||||||
|
headers: { cookie, 'content-type': 'application/json' },
|
||||||
|
payload: { login_allowlist_enabled: true, login_allowlist: [] },
|
||||||
|
});
|
||||||
|
expect(enable.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const blocked = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/login',
|
||||||
|
headers: { authorization: nip98Header2('http://localhost:8095/api/auth/login', 'POST') },
|
||||||
|
});
|
||||||
|
expect(blocked.statusCode).toBe(403);
|
||||||
|
|
||||||
|
const adminStillIn = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/login',
|
||||||
|
headers: { authorization: nip98Header('http://localhost:8095/api/auth/login', 'POST') },
|
||||||
|
});
|
||||||
|
expect(adminStillIn.statusCode).toBe(200);
|
||||||
|
expect(adminStillIn.json().isAdmin).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a pubkey once it is added to the allowlist', async () => {
|
||||||
|
const update = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings',
|
||||||
|
headers: { cookie, 'content-type': 'application/json' },
|
||||||
|
payload: { login_allowlist: [pk2] },
|
||||||
|
});
|
||||||
|
expect(update.statusCode).toBe(200);
|
||||||
|
expect(update.json().login_allowlist).toEqual([pk2]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/login',
|
||||||
|
headers: { authorization: nip98Header2('http://localhost:8095/api/auth/login', 'POST') },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().pubkey).toBe(pk2);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Leave the allowlist disabled so later describe blocks aren't affected.
|
||||||
|
await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings',
|
||||||
|
headers: { cookie, 'content-type': 'application/json' },
|
||||||
|
payload: { login_allowlist_enabled: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('podcasts, episodes, feed', () => {
|
describe('podcasts, episodes, feed', () => {
|
||||||
let podcastId: string;
|
let podcastId: string;
|
||||||
|
let episodeId: string;
|
||||||
const sha = 'c'.repeat(64);
|
const sha = 'c'.repeat(64);
|
||||||
|
|
||||||
it('creates a podcast', async () => {
|
it('creates a podcast', async () => {
|
||||||
@@ -98,6 +171,23 @@ describe('podcasts, episodes, feed', () => {
|
|||||||
expect(res.statusCode).toBe(201);
|
expect(res.statusCode).toBe(201);
|
||||||
podcastId = res.json().id;
|
podcastId = res.json().id;
|
||||||
expect(res.json().podcast_guid).toMatch(/^[0-9a-f-]{36}$/);
|
expect(res.json().podcast_guid).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
expect(res.json().explicit).toBe(false); // not the raw SQLite 0/1
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the edit form round-trip a fetched podcast unmodified (regression: explicit came back as 0/1, not a bool)', async () => {
|
||||||
|
const fetched = await app.inject({ method: 'GET', url: `/api/podcasts/${podcastId}`, headers: { cookie } });
|
||||||
|
expect(fetched.json().explicit).toBe(false);
|
||||||
|
|
||||||
|
const { episodes: _episodes, feed_url: _feedUrl, ...editForm } = fetched.json();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/podcasts/${podcastId}`,
|
||||||
|
headers: { cookie },
|
||||||
|
payload: editForm,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().lightning_address).toBe('tester@getalby.com');
|
||||||
|
expect(res.json().explicit).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('registers an episode after verifying the blob on blossom', async () => {
|
it('registers an episode after verifying the blob on blossom', async () => {
|
||||||
@@ -116,6 +206,7 @@ describe('podcasts, episodes, feed', () => {
|
|||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(201);
|
expect(res.statusCode).toBe(201);
|
||||||
expect(res.json().enclosure_url).toContain(`${sha}.mp4`);
|
expect(res.json().enclosure_url).toContain(`${sha}.mp4`);
|
||||||
|
episodeId = res.json().id;
|
||||||
} finally {
|
} finally {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
}
|
}
|
||||||
@@ -157,6 +248,35 @@ describe('podcasts, episodes, feed', () => {
|
|||||||
});
|
});
|
||||||
expect(cached.statusCode).toBe(304);
|
expect(cached.statusCode).toBe(304);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('removing an episode from the feed hides it from feed.xml but keeps it in the owner list', async () => {
|
||||||
|
const unlist = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/podcasts/${podcastId}/episodes/${episodeId}`,
|
||||||
|
headers: { cookie },
|
||||||
|
payload: { unlisted: true },
|
||||||
|
});
|
||||||
|
expect(unlist.statusCode).toBe(200);
|
||||||
|
expect(unlist.json().unlisted).toBe(1);
|
||||||
|
|
||||||
|
const feed = await app.inject({ method: 'GET', url: `/feeds/${podcastId}/feed.xml` });
|
||||||
|
expect(feed.body).not.toContain(`${sha}.mp4`);
|
||||||
|
|
||||||
|
const owned = await app.inject({ method: 'GET', url: `/api/podcasts/${podcastId}`, headers: { cookie } });
|
||||||
|
expect(owned.json().episodes.some((e: { id: string }) => e.id === episodeId)).toBe(true);
|
||||||
|
|
||||||
|
const relist = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/podcasts/${podcastId}/episodes/${episodeId}`,
|
||||||
|
headers: { cookie },
|
||||||
|
payload: { unlisted: false },
|
||||||
|
});
|
||||||
|
expect(relist.statusCode).toBe(200);
|
||||||
|
expect(relist.json().unlisted).toBe(0);
|
||||||
|
|
||||||
|
const feedAgain = await app.inject({ method: 'GET', url: `/feeds/${podcastId}/feed.xml` });
|
||||||
|
expect(feedAgain.body).toContain(`${sha}.mp4`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('streams + mediamtx auth webhook', () => {
|
describe('streams + mediamtx auth webhook', () => {
|
||||||
|
|||||||
@@ -148,6 +148,17 @@ CREATE TABLE cashu_proofs (
|
|||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX idx_cashu_proofs_unspent ON cashu_proofs(spent_at);
|
CREATE INDEX idx_cashu_proofs_unspent ON cashu_proofs(spent_at);
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
sql: `
|
||||||
|
-- Removing an episode from the RSS feed doesn't have to mean deleting it outright:
|
||||||
|
-- a hard DELETE cascades to purchases/earnings (ON DELETE CASCADE), which would wipe
|
||||||
|
-- a producer's sales history and any unwithdrawn earnings for that episode. "unlisted"
|
||||||
|
-- lets the feed simply omit the episode while everything else (purchases, reseller
|
||||||
|
-- listings, the blob itself) stays intact and reversible.
|
||||||
|
ALTER TABLE episodes ADD COLUMN unlisted INTEGER NOT NULL DEFAULT 0;
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export default async function authRoutes(app: FastifyInstance) {
|
|||||||
if (err instanceof Nip98Error) return reply.code(401).send({ error: err.message });
|
if (err instanceof Nip98Error) return reply.code(401).send({ error: err.message });
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
if (!settings.isLoginAllowed(pubkey)) {
|
||||||
|
return reply.code(403).send({ error: 'this account is not on the login allowlist' });
|
||||||
|
}
|
||||||
upsertUser.run(pubkey, nowSecs(), nowSecs());
|
upsertUser.run(pubkey, nowSecs(), nowSecs());
|
||||||
settings.claimAdminIfUnset(pubkey);
|
settings.claimAdminIfUnset(pubkey);
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default async function feedRoutes(app: FastifyInstance) {
|
|||||||
const { db, settings } = app.ctx;
|
const { db, settings } = app.ctx;
|
||||||
|
|
||||||
const getPodcast = db.prepare('SELECT * FROM podcasts WHERE id = ?');
|
const getPodcast = db.prepare('SELECT * FROM podcasts WHERE id = ?');
|
||||||
const listEpisodes = db.prepare('SELECT * FROM episodes WHERE podcast_id = ? ORDER BY pub_date DESC');
|
const listEpisodes = db.prepare('SELECT * FROM episodes WHERE podcast_id = ? AND unlisted = 0 ORDER BY pub_date DESC');
|
||||||
const listAllPodcasts = db.prepare('SELECT * FROM podcasts ORDER BY created_at');
|
const listAllPodcasts = db.prepare('SELECT * FROM podcasts ORDER BY created_at');
|
||||||
|
|
||||||
app.get('/feeds/:id/feed.xml', async (req, reply) => {
|
app.get('/feeds/:id/feed.xml', async (req, reply) => {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const episodeSchema = z.object({
|
|||||||
episode_no: z.number().int().positive().nullish(),
|
episode_no: z.number().int().positive().nullish(),
|
||||||
pub_date: z.number().int().positive().optional(),
|
pub_date: z.number().int().positive().optional(),
|
||||||
price_sats: z.number().int().positive().nullish(),
|
price_sats: z.number().int().positive().nullish(),
|
||||||
|
unlisted: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default async function podcastRoutes(app: FastifyInstance) {
|
export default async function podcastRoutes(app: FastifyInstance) {
|
||||||
@@ -50,10 +51,18 @@ export default async function podcastRoutes(app: FastifyInstance) {
|
|||||||
return p && p.owner_pubkey === pubkey ? p : null;
|
return p && p.owner_pubkey === pubkey ? p : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SQLite has no boolean type — `explicit` comes back as a raw 0/1 integer.
|
||||||
|
// The client's edit form round-trips whatever this endpoint sends it, and the
|
||||||
|
// update schema requires a real boolean, so this needs to be a true boolean
|
||||||
|
// on the wire or re-submitting an untouched form fails validation.
|
||||||
|
function serializePodcast(p: Podcast): Omit<Podcast, 'explicit'> & { explicit: boolean } {
|
||||||
|
return { ...p, explicit: !!p.explicit };
|
||||||
|
}
|
||||||
|
|
||||||
app.get('/api/podcasts', { preHandler: app.requireAuth }, async (req) => {
|
app.get('/api/podcasts', { preHandler: app.requireAuth }, async (req) => {
|
||||||
const podcasts = listPodcasts.all(req.userPubkey) as Podcast[];
|
const podcasts = listPodcasts.all(req.userPubkey) as Podcast[];
|
||||||
return podcasts.map((p) => ({
|
return podcasts.map((p) => ({
|
||||||
...p,
|
...serializePodcast(p),
|
||||||
feed_url: `${settings.all().public_url}/feeds/${p.id}/feed.xml`,
|
feed_url: `${settings.all().public_url}/feeds/${p.id}/feed.xml`,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@@ -74,7 +83,7 @@ export default async function podcastRoutes(app: FastifyInstance) {
|
|||||||
d.category, d.explicit ? 1 : 0, d.lightning_address ?? null, d.keysend_node ?? null,
|
d.category, d.explicit ? 1 : 0, d.lightning_address ?? null, d.keysend_node ?? null,
|
||||||
d.value_suggested ?? null, podcastGuidForFeedUrl(feedUrl), nowSecs(), nowSecs(),
|
d.value_suggested ?? null, podcastGuidForFeedUrl(feedUrl), nowSecs(), nowSecs(),
|
||||||
);
|
);
|
||||||
return reply.code(201).send({ ...(getPodcast.get(id) as Podcast), feed_url: feedUrl });
|
return reply.code(201).send({ ...serializePodcast(getPodcast.get(id) as Podcast), feed_url: feedUrl });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/podcasts/:id', { preHandler: app.requireAuth }, async (req, reply) => {
|
app.get('/api/podcasts/:id', { preHandler: app.requireAuth }, async (req, reply) => {
|
||||||
@@ -82,7 +91,7 @@ export default async function podcastRoutes(app: FastifyInstance) {
|
|||||||
const p = ownedPodcast(id, req.userPubkey!);
|
const p = ownedPodcast(id, req.userPubkey!);
|
||||||
if (!p) return reply.code(404).send({ error: 'podcast not found' });
|
if (!p) return reply.code(404).send({ error: 'podcast not found' });
|
||||||
return {
|
return {
|
||||||
...p,
|
...serializePodcast(p),
|
||||||
feed_url: `${settings.all().public_url}/feeds/${p.id}/feed.xml`,
|
feed_url: `${settings.all().public_url}/feeds/${p.id}/feed.xml`,
|
||||||
episodes: listEpisodes.all(id) as Episode[],
|
episodes: listEpisodes.all(id) as Episode[],
|
||||||
};
|
};
|
||||||
@@ -105,7 +114,7 @@ export default async function podcastRoutes(app: FastifyInstance) {
|
|||||||
d.lightning_address ?? null, d.keysend_node ?? null, d.value_suggested ?? null,
|
d.lightning_address ?? null, d.keysend_node ?? null, d.value_suggested ?? null,
|
||||||
d.resale_producer_share_pct, nowSecs(), id,
|
d.resale_producer_share_pct, nowSecs(), id,
|
||||||
);
|
);
|
||||||
return getPodcast.get(id) as Podcast;
|
return serializePodcast(getPodcast.get(id) as Podcast);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/podcasts/:id', { preHandler: app.requireAuth }, async (req, reply) => {
|
app.delete('/api/podcasts/:id', { preHandler: app.requireAuth }, async (req, reply) => {
|
||||||
@@ -168,10 +177,10 @@ export default async function podcastRoutes(app: FastifyInstance) {
|
|||||||
const d = { ...episode, ...parsed.data };
|
const d = { ...episode, ...parsed.data };
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE episodes SET title=?, description=?, duration_secs=?, season=?, episode_no=?,
|
UPDATE episodes SET title=?, description=?, duration_secs=?, season=?, episode_no=?,
|
||||||
pub_date=?, price_sats=?
|
pub_date=?, price_sats=?, unlisted=?
|
||||||
WHERE id=?
|
WHERE id=?
|
||||||
`).run(d.title, d.description, d.duration_secs ?? null, d.season ?? null,
|
`).run(d.title, d.description, d.duration_secs ?? null, d.season ?? null,
|
||||||
d.episode_no ?? null, d.pub_date, d.price_sats ?? null, eid);
|
d.episode_no ?? null, d.pub_date, d.price_sats ?? null, d.unlisted ? 1 : 0, eid);
|
||||||
return getEpisode.get(eid, id) as Episode;
|
return getEpisode.get(eid, id) as Episode;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const updateSchema = z.object({
|
|||||||
relays: z.array(z.string().regex(/^wss?:\/\//)).optional(),
|
relays: z.array(z.string().regex(/^wss?:\/\//)).optional(),
|
||||||
public_url: z.string().url().optional(),
|
public_url: z.string().url().optional(),
|
||||||
cashu_mint_url: z.string().url().optional(),
|
cashu_mint_url: z.string().url().optional(),
|
||||||
|
login_allowlist_enabled: z.boolean().optional(),
|
||||||
|
login_allowlist: z.array(z.string().regex(/^[0-9a-f]{64}$/i)).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default async function settingsRoutes(app: FastifyInstance) {
|
export default async function settingsRoutes(app: FastifyInstance) {
|
||||||
@@ -33,11 +35,24 @@ export default async function settingsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
const parsed = updateSchema.safeParse(req.body);
|
const parsed = updateSchema.safeParse(req.body);
|
||||||
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
|
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
|
||||||
const { blossom_url, relays, public_url, cashu_mint_url } = parsed.data;
|
const {
|
||||||
|
blossom_url,
|
||||||
|
relays,
|
||||||
|
public_url,
|
||||||
|
cashu_mint_url,
|
||||||
|
login_allowlist_enabled,
|
||||||
|
login_allowlist,
|
||||||
|
} = parsed.data;
|
||||||
if (blossom_url !== undefined) settings.set('blossom_url', blossom_url);
|
if (blossom_url !== undefined) settings.set('blossom_url', blossom_url);
|
||||||
if (relays !== undefined) settings.set('relays', JSON.stringify(relays));
|
if (relays !== undefined) settings.set('relays', JSON.stringify(relays));
|
||||||
if (public_url !== undefined) settings.set('public_url', public_url);
|
if (public_url !== undefined) settings.set('public_url', public_url);
|
||||||
if (cashu_mint_url !== undefined) settings.set('cashu_mint_url', cashu_mint_url);
|
if (cashu_mint_url !== undefined) settings.set('cashu_mint_url', cashu_mint_url);
|
||||||
|
if (login_allowlist_enabled !== undefined) {
|
||||||
|
settings.set('login_allowlist_enabled', login_allowlist_enabled ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
if (login_allowlist !== undefined) {
|
||||||
|
settings.set('login_allowlist', JSON.stringify(login_allowlist.map((pk) => pk.toLowerCase())));
|
||||||
|
}
|
||||||
return settings.all();
|
return settings.all();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface Settings {
|
|||||||
public_url: string;
|
public_url: string;
|
||||||
admin_pubkey: string | null;
|
admin_pubkey: string | null;
|
||||||
cashu_mint_url: string;
|
cashu_mint_url: string;
|
||||||
|
login_allowlist_enabled: boolean;
|
||||||
|
login_allowlist: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SettingsService {
|
export class SettingsService {
|
||||||
@@ -32,6 +34,8 @@ export class SettingsService {
|
|||||||
public_url: this.get('public_url') ?? this.config.PUBLIC_URL,
|
public_url: this.get('public_url') ?? this.config.PUBLIC_URL,
|
||||||
admin_pubkey: this.get('admin_pubkey'),
|
admin_pubkey: this.get('admin_pubkey'),
|
||||||
cashu_mint_url: this.get('cashu_mint_url') ?? this.config.CASHU_MINT_URL_DEFAULT,
|
cashu_mint_url: this.get('cashu_mint_url') ?? this.config.CASHU_MINT_URL_DEFAULT,
|
||||||
|
login_allowlist_enabled: this.get('login_allowlist_enabled') === 'true',
|
||||||
|
login_allowlist: JSON.parse(this.get('login_allowlist') ?? '[]'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,4 +68,17 @@ export class SettingsService {
|
|||||||
isAdmin(pubkey: string): boolean {
|
isAdmin(pubkey: string): boolean {
|
||||||
return this.get('admin_pubkey') === pubkey;
|
return this.get('admin_pubkey') === pubkey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a pubkey may log in. Disabled by default (everyone allowed). When enabled,
|
||||||
|
* the admin and no-admin-claimed-yet bootstrap case always pass, otherwise the pubkey
|
||||||
|
* must be in the allowlist.
|
||||||
|
*/
|
||||||
|
isLoginAllowed(pubkey: string): boolean {
|
||||||
|
if (this.get('login_allowlist_enabled') !== 'true') return true;
|
||||||
|
if (!this.get('admin_pubkey')) return true;
|
||||||
|
if (this.isAdmin(pubkey)) return true;
|
||||||
|
const list: string[] = JSON.parse(this.get('login_allowlist') ?? '[]');
|
||||||
|
return list.includes(pubkey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface Episode {
|
|||||||
price_sats: number | null;
|
price_sats: number | null;
|
||||||
pub_date: number;
|
pub_date: number;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
|
unlisted: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Purchase {
|
export interface Purchase {
|
||||||
|
|||||||
Reference in New Issue
Block a user