Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
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,
|
||||
# changeable at runtime in Settings)
|
||||
NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
|
||||
|
||||
+11
-3
@@ -35,7 +35,7 @@ services:
|
||||
- blossom
|
||||
|
||||
mediamtx:
|
||||
image: docker.io/bluenviron/mediamtx:1.19.2
|
||||
image: docker.io/bluenviron/mediamtx:1.20.0
|
||||
container_name: podsteadr-mediamtx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -44,8 +44,16 @@ services:
|
||||
- "8189:8189/udp" # WebRTC ICE
|
||||
- "8890:8888" # HLS (host 8890; 8888 kept free for other apps)
|
||||
environment:
|
||||
# Browsers need a reachable ICE host candidate; set PUBLIC_HOST in .env
|
||||
MTX_WEBRTCADDITIONALHOSTS: ${PUBLIC_HOST:-localhost}
|
||||
# Browsers need a reachable ICE host candidate for the actual UDP media
|
||||
# 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:
|
||||
- ./mediamtx/mediamtx.yml:/mediamtx.yml:ro
|
||||
- mediamtx-recordings:/recordings
|
||||
|
||||
+24
-9
@@ -44,7 +44,7 @@ Three containers on one compose network:
|
||||
| Container | Image | Host ports | Role |
|
||||
|---|---|---|---|
|
||||
| `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 |
|
||||
|
||||
Key flows:
|
||||
@@ -153,14 +153,29 @@ frontend/ # Vue 3 + Vite + Tailwind + Pinia
|
||||
|
||||
## Remaining work
|
||||
|
||||
- **Archipelago packaging** (the original deployment target, archy repo):
|
||||
`apps/podsteadr/manifest.yml` + `apps/podsteadr-mediamtx` + `apps/podsteadr-blossom`
|
||||
following the `apps/btcpay-server` (dependencies) + `apps/monero-ui`
|
||||
(`container.build` on `/opt/archipelago/docker/...`) patterns; bind volumes
|
||||
under `/var/lib/archipelago/<app>`; add ports **8095, 1935, 8889, 8189/udp,
|
||||
8890, 8098** to `apps/PORTS.md` (chosen 2026-07-10 to avoid fleet collisions —
|
||||
8888 is searxng, hence HLS on 8890). `interfaces.main` → port 8095.
|
||||
- **No git remote yet** — decide where to push (gitea?).
|
||||
- **Archipelago packaging — done (2026-08-07)**, on the archy repo branch
|
||||
`feat/podsteadr-app-package` (not yet merged/pushed): `apps/podsteadr/manifest.yml`
|
||||
(`container.build` from this repo's own Dockerfile, following the
|
||||
`apps/indeedhub` externally-sourced-app pattern) + `apps/podsteadr-mediamtx` +
|
||||
`apps/podsteadr-blossom`, all three on a dedicated `podsteadr-net` bridge
|
||||
network per the `apps/indeedhub-*` sibling-manifest pattern. Bind volumes
|
||||
under `/var/lib/archipelago/<app>`. Ports **8095, 1935, 8889, 8189/udp, 8890,
|
||||
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
|
||||
actual NIP-07 extension + OBS (the API surface they call is fully covered by
|
||||
the e2e script, so surprises should be cosmetic).
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
<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">
|
||||
import { computed } from 'vue';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const auth = useAuthStore();
|
||||
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() {
|
||||
await auth.logout();
|
||||
router.push('/login');
|
||||
@@ -23,7 +38,7 @@ async function logout() {
|
||||
<RouterLink to="/earnings" class="hover:text-orange-400">Earnings</RouterLink>
|
||||
<RouterLink to="/settings" class="hover:text-orange-400">Settings</RouterLink>
|
||||
<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
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -10,9 +10,41 @@ export async function publishWhip(
|
||||
bearer: string,
|
||||
stream: MediaStream,
|
||||
): 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();
|
||||
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();
|
||||
|
||||
@@ -39,6 +39,9 @@ webrtc: yes
|
||||
webrtcAddress: :8889
|
||||
webrtcLocalUDPAddress: :8189
|
||||
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 -----------------------------------------------------------
|
||||
pathDefaults:
|
||||
|
||||
Reference in New Issue
Block a user