update osm
This commit is contained in:
+79
-10
@@ -536,6 +536,69 @@ function readJsonBody(req) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TURNSTILE_SESSION_COOKIE = 'tssbot_turnstile'
|
||||||
|
const TURNSTILE_SESSION_TTL_SECONDS = 30 * 60
|
||||||
|
const TURNSTILE_SESSION_HMAC_KEY = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(`tssbot-turnstile-session|${TURNSTILE_SECRET_KEY}`)
|
||||||
|
.digest()
|
||||||
|
|
||||||
|
function signTurnstileSession(expiresAt) {
|
||||||
|
return crypto
|
||||||
|
.createHmac('sha256', TURNSTILE_SESSION_HMAC_KEY)
|
||||||
|
.update(String(expiresAt))
|
||||||
|
.digest('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTurnstileSessionCookie(req) {
|
||||||
|
const expiresAt = Math.floor(Date.now() / 1000) + TURNSTILE_SESSION_TTL_SECONDS
|
||||||
|
const signature = signTurnstileSession(expiresAt)
|
||||||
|
const value = `${expiresAt}.${signature}`
|
||||||
|
const proto = req.headers['x-forwarded-proto'] || (req.socket.encrypted ? 'https' : 'http')
|
||||||
|
const isHttps = String(proto).split(',')[0].trim() === 'https'
|
||||||
|
const parts = [
|
||||||
|
`${TURNSTILE_SESSION_COOKIE}=${value}`,
|
||||||
|
'Path=/',
|
||||||
|
`Max-Age=${TURNSTILE_SESSION_TTL_SECONDS}`,
|
||||||
|
'HttpOnly',
|
||||||
|
'SameSite=Strict',
|
||||||
|
]
|
||||||
|
if (isHttps) parts.push('Secure')
|
||||||
|
return parts.join('; ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRequestCookies(req) {
|
||||||
|
const header = req.headers.cookie || ''
|
||||||
|
const out = {}
|
||||||
|
for (const part of header.split(/;\s*/)) {
|
||||||
|
if (!part) continue
|
||||||
|
const eq = part.indexOf('=')
|
||||||
|
if (eq < 1) continue
|
||||||
|
out[part.slice(0, eq)] = part.slice(eq + 1)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTurnstileSessionVerified(req) {
|
||||||
|
if (!TURNSTILE_SECRET_KEY) return true
|
||||||
|
const cookies = parseRequestCookies(req)
|
||||||
|
const cookie = cookies[TURNSTILE_SESSION_COOKIE]
|
||||||
|
if (!cookie) return false
|
||||||
|
const dot = cookie.indexOf('.')
|
||||||
|
if (dot < 1) return false
|
||||||
|
const expiresAtStr = cookie.slice(0, dot)
|
||||||
|
const signature = cookie.slice(dot + 1)
|
||||||
|
const expiresAt = Number(expiresAtStr)
|
||||||
|
if (!Number.isFinite(expiresAt) || expiresAt < Math.floor(Date.now() / 1000)) return false
|
||||||
|
const expected = signTurnstileSession(expiresAt)
|
||||||
|
if (signature.length !== expected.length) return false
|
||||||
|
try {
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function callTurnstileSiteverify(token, remoteIp, idempotencyKey) {
|
function callTurnstileSiteverify(token, remoteIp, idempotencyKey) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
@@ -1446,16 +1509,13 @@ const server = http.createServer((req, res) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
readJsonBody(req)
|
if (!isTurnstileSessionVerified(req)) {
|
||||||
.then(async (payload) => {
|
sendJson(res, 403, { error: 'Turnstile session required', detail: 'Solve the site challenge first' })
|
||||||
const verification = await verifyTurnstileToken(payload.turnstile_token, clientIp(req), {
|
|
||||||
expectedAction: 'analytics-delete',
|
|
||||||
expectedHostname: expectedTurnstileHostname(),
|
|
||||||
})
|
|
||||||
if (!verification.success) {
|
|
||||||
sendJson(res, 403, { error: 'Turnstile verification required', detail: verification.error })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readJsonBody(req)
|
||||||
|
.then((payload) => {
|
||||||
const result = deleteViewerData(payload)
|
const result = deleteViewerData(payload)
|
||||||
sendJson(res, 200, result)
|
sendJson(res, 200, result)
|
||||||
})
|
})
|
||||||
@@ -1477,19 +1537,28 @@ const server = http.createServer((req, res) => {
|
|||||||
readJsonBody(req)
|
readJsonBody(req)
|
||||||
.then(async (payload) => {
|
.then(async (payload) => {
|
||||||
const verification = await verifyTurnstileToken(payload.token, clientIp(req), {
|
const verification = await verifyTurnstileToken(payload.token, clientIp(req), {
|
||||||
expectedAction: typeof payload.action === 'string' ? payload.action : undefined,
|
expectedAction: 'site-gate',
|
||||||
expectedHostname: expectedTurnstileHostname(),
|
expectedHostname: expectedTurnstileHostname(),
|
||||||
})
|
})
|
||||||
if (!verification.success) {
|
if (!verification.success) {
|
||||||
sendJson(res, 403, { error: 'Turnstile verification failed', detail: verification.error })
|
sendJson(res, 403, { error: 'Turnstile verification failed', detail: verification.error })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendJson(res, 200, { success: true })
|
const headers = TURNSTILE_SECRET_KEY ? { 'set-cookie': buildTurnstileSessionCookie(req) } : {}
|
||||||
|
send(res, 200, JSON.stringify({ success: true, ttl: TURNSTILE_SESSION_TTL_SECONDS }), {
|
||||||
|
...jsonHeaders,
|
||||||
|
...headers,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.catch((error) => sendJson(res, 400, { error: error.message }))
|
.catch((error) => sendJson(res, 400, { error: error.message }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && req.url === '/api/turnstile/session') {
|
||||||
|
sendJson(res, 200, { verified: isTurnstileSessionVerified(req) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) {
|
if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) {
|
||||||
sendJson(res, 403, { error: 'CORS requests are not allowed' })
|
sendJson(res, 403, { error: 'CORS requests are not allowed' })
|
||||||
return
|
return
|
||||||
|
|||||||
+111
-96
@@ -411,7 +411,111 @@ function TurnstileWidget({ siteKey, theme = 'auto', size = 'normal', action, onV
|
|||||||
return <div ref={containerRef} />
|
return <div ref={containerRef} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SiteGate({ onVerified }) {
|
||||||
|
const [status, setStatus] = useState('idle')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [resetSignal, setResetSignal] = useState(0)
|
||||||
|
const submittingRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function handleVerify(token) {
|
||||||
|
if (submittingRef.current) return
|
||||||
|
submittingRef.current = true
|
||||||
|
setStatus('verifying')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/turnstile/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
setError('Verification failed — please try again.')
|
||||||
|
setStatus('idle')
|
||||||
|
setResetSignal((value) => value + 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus('verified')
|
||||||
|
onVerified()
|
||||||
|
} catch {
|
||||||
|
setError('Network error — please retry.')
|
||||||
|
setStatus('idle')
|
||||||
|
setResetSignal((value) => value + 1)
|
||||||
|
} finally {
|
||||||
|
submittingRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-labelledby="site-gate-title"
|
||||||
|
aria-modal="true"
|
||||||
|
className="fixed inset-0 z-[9999] grid place-items-center bg-bg/95 px-4 py-6 backdrop-blur-sm"
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<div className="flex w-full max-w-sm flex-col items-center gap-4 rounded-md border border-border bg-fury-white p-6 text-center text-text shadow-[0_24px_70px_rgba(0,0,0,0.24)]">
|
||||||
|
<h1 id="site-gate-title" className="text-lg font-semibold">
|
||||||
|
Verifying you are human
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-soft">
|
||||||
|
This quick check protects the site from automated abuse. It usually clears itself.
|
||||||
|
</p>
|
||||||
|
<TurnstileWidget
|
||||||
|
siteKey={turnstileSiteKey}
|
||||||
|
action="site-gate"
|
||||||
|
onVerify={handleVerify}
|
||||||
|
onExpire={() => setError('Challenge expired — please solve it again.')}
|
||||||
|
onError={() => setError('Challenge could not load. Refresh to retry.')}
|
||||||
|
resetSignal={resetSignal}
|
||||||
|
/>
|
||||||
|
{status === 'verifying' ? (
|
||||||
|
<p className="text-xs text-text-soft">Confirming with Cloudflare…</p>
|
||||||
|
) : null}
|
||||||
|
{error ? <p className="text-xs font-semibold text-red-600">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [gateState, setGateState] = useState(turnstileSiteKey ? 'checking' : 'verified')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!turnstileSiteKey) return undefined
|
||||||
|
let cancelled = false
|
||||||
|
fetch('/api/turnstile/session', { headers: { Accept: 'application/json' } })
|
||||||
|
.then((response) => (response.ok ? response.json() : { verified: false }))
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setGateState(data?.verified ? 'verified' : 'required')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setGateState('required')
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (gateState === 'checking') {
|
||||||
|
return <div className="fixed inset-0 grid place-items-center bg-bg" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gateState === 'required') {
|
||||||
|
return <SiteGate onVerified={() => setGateState('verified')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AppContent />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
const [route, setRoute] = useState(() => parseRoute())
|
const [route, setRoute] = useState(() => parseRoute())
|
||||||
const [leaderboard, setLeaderboard] = useState({ status: 'idle', data: null, error: null })
|
const [leaderboard, setLeaderboard] = useState({ status: 'idle', data: null, error: null })
|
||||||
const [live, setLive] = useState({ status: 'idle', data: null, error: null })
|
const [live, setLive] = useState({ status: 'idle', data: null, error: null })
|
||||||
@@ -909,7 +1013,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseAnalyticsConsent(preferences, turnstileToken = '') {
|
function chooseAnalyticsConsent(preferences) {
|
||||||
const previousVisitorId = storedVisitorId(analyticsVisitorKey)
|
const previousVisitorId = storedVisitorId(analyticsVisitorKey)
|
||||||
const nextPreferences = persistAnalyticsPreferences({ ...preferences, chosen: true })
|
const nextPreferences = persistAnalyticsPreferences({ ...preferences, chosen: true })
|
||||||
|
|
||||||
@@ -922,7 +1026,6 @@ function App() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
visitor_id: previousVisitorId,
|
visitor_id: previousVisitorId,
|
||||||
session_id: analyticsSessionId,
|
session_id: analyticsSessionId,
|
||||||
turnstile_token: turnstileToken,
|
|
||||||
}),
|
}),
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
@@ -1048,11 +1151,7 @@ function ConsentBanner({ preferences, onChoose }) {
|
|||||||
const [isOpen, setIsOpen] = useState(() => !preferences.chosen)
|
const [isOpen, setIsOpen] = useState(() => !preferences.chosen)
|
||||||
const [isConfiguring, setIsConfiguring] = useState(() => Boolean(preferences.chosen))
|
const [isConfiguring, setIsConfiguring] = useState(() => Boolean(preferences.chosen))
|
||||||
const [draft, setDraft] = useState(() => normalizeAnalyticsPreferences(preferences))
|
const [draft, setDraft] = useState(() => normalizeAnalyticsPreferences(preferences))
|
||||||
const [turnstileToken, setTurnstileToken] = useState('')
|
|
||||||
const [turnstileReset, setTurnstileReset] = useState(0)
|
|
||||||
const privacySignalEnabled = browserPrivacySignalEnabled()
|
const privacySignalEnabled = browserPrivacySignalEnabled()
|
||||||
const visitorId = storedVisitorId(analyticsVisitorKey)
|
|
||||||
const deleteVerificationRequired = Boolean(visitorId) && Boolean(turnstileSiteKey)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraft(normalizeAnalyticsPreferences(preferences))
|
setDraft(normalizeAnalyticsPreferences(preferences))
|
||||||
@@ -1078,19 +1177,10 @@ function ConsentBanner({ preferences, onChoose }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function savePreferences(nextPreferences) {
|
function savePreferences(nextPreferences) {
|
||||||
const normalized = normalizeAnalyticsPreferences(nextPreferences)
|
onChoose(normalizeAnalyticsPreferences(nextPreferences))
|
||||||
const willDelete = Boolean(visitorId) && !normalized.analytics
|
|
||||||
if (willDelete && turnstileSiteKey && !turnstileToken) return
|
|
||||||
onChoose(normalized, willDelete ? turnstileToken : '')
|
|
||||||
setTurnstileToken('')
|
|
||||||
setTurnstileReset((value) => value + 1)
|
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const willDeleteFromDraft = Boolean(visitorId) && !draft.analytics
|
|
||||||
const blockSaveDraft = willDeleteFromDraft && deleteVerificationRequired && !turnstileToken
|
|
||||||
const blockDeclineAll = Boolean(visitorId) && deleteVerificationRequired && !turnstileToken
|
|
||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -1182,34 +1272,16 @@ function ConsentBanner({ preferences, onChoose }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{deleteVerificationRequired ? (
|
|
||||||
<div className="mt-5">
|
|
||||||
<p className="mb-2 text-xs font-semibold text-text-soft">
|
|
||||||
Confirm you are not a bot to delete your existing analytics data:
|
|
||||||
</p>
|
|
||||||
<TurnstileWidget
|
|
||||||
siteKey={turnstileSiteKey}
|
|
||||||
action="analytics-delete"
|
|
||||||
onVerify={(token) => setTurnstileToken(token)}
|
|
||||||
onExpire={() => setTurnstileToken('')}
|
|
||||||
onError={() => setTurnstileToken('')}
|
|
||||||
resetSignal={turnstileReset}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
<button
|
<button
|
||||||
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text-soft transition hover:bg-surface hover:text-text disabled:cursor-not-allowed disabled:opacity-60"
|
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text-soft transition hover:bg-surface hover:text-text"
|
||||||
disabled={blockDeclineAll}
|
|
||||||
onClick={() => savePreferences({ ...defaultAnalyticsPreferences, chosen: true })}
|
onClick={() => savePreferences({ ...defaultAnalyticsPreferences, chosen: true })}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Decline all
|
Decline all
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-60"
|
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text transition hover:bg-surface"
|
||||||
disabled={blockSaveDraft}
|
|
||||||
onClick={() => savePreferences(draft)}
|
onClick={() => savePreferences(draft)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -1375,43 +1447,6 @@ function Landing({
|
|||||||
teamQuery,
|
teamQuery,
|
||||||
}) {
|
}) {
|
||||||
const treeRef = useRef(null)
|
const treeRef = useRef(null)
|
||||||
const [searchToken, setSearchToken] = useState('')
|
|
||||||
const [searchTurnstileReset, setSearchTurnstileReset] = useState(0)
|
|
||||||
const [searchError, setSearchError] = useState('')
|
|
||||||
const [searchSubmitting, setSearchSubmitting] = useState(false)
|
|
||||||
|
|
||||||
async function handleSearchSubmit(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
if (searchSubmitting) return
|
|
||||||
if (!teamQuery.trim()) return
|
|
||||||
|
|
||||||
setSearchError('')
|
|
||||||
setSearchSubmitting(true)
|
|
||||||
try {
|
|
||||||
if (turnstileSiteKey) {
|
|
||||||
if (!searchToken) {
|
|
||||||
setSearchError('Please complete the verification before searching.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const verifyResponse = await fetch('/api/turnstile/verify', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: searchToken, action: 'team-search' }),
|
|
||||||
})
|
|
||||||
if (!verifyResponse.ok) {
|
|
||||||
setSearchError('Verification failed — please try again.')
|
|
||||||
setSearchToken('')
|
|
||||||
setSearchTurnstileReset((value) => value + 1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await onTeamSearch(event)
|
|
||||||
} finally {
|
|
||||||
setSearchSubmitting(false)
|
|
||||||
setSearchToken('')
|
|
||||||
setSearchTurnstileReset((value) => value + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative left-1/2 w-screen -translate-x-1/2 bg-bg">
|
<div className="relative left-1/2 w-screen -translate-x-1/2 bg-bg">
|
||||||
@@ -1455,7 +1490,7 @@ function Landing({
|
|||||||
|
|
||||||
<form
|
<form
|
||||||
className="mt-5 grid gap-2 rounded-lg border border-border bg-fury-white/88 p-2 shadow-sm backdrop-blur sm:grid-cols-[1fr_auto]"
|
className="mt-5 grid gap-2 rounded-lg border border-border bg-fury-white/88 p-2 shadow-sm backdrop-blur sm:grid-cols-[1fr_auto]"
|
||||||
onSubmit={handleSearchSubmit}
|
onSubmit={onTeamSearch}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
className="min-w-0 rounded-md border border-border bg-bg px-4 py-3 text-sm outline-none transition focus:border-ring focus:shadow-[0_0_0_3px_var(--color-shadow)]"
|
className="min-w-0 rounded-md border border-border bg-bg px-4 py-3 text-sm outline-none transition focus:border-ring focus:shadow-[0_0_0_3px_var(--color-shadow)]"
|
||||||
@@ -1464,31 +1499,11 @@ function Landing({
|
|||||||
onChange={(event) => setTeamQuery(event.target.value)}
|
onChange={(event) => setTeamQuery(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="rounded-md bg-fury-cyan px-5 py-3 text-sm font-semibold text-text transition hover:bg-fury-aqua disabled:cursor-not-allowed disabled:opacity-60"
|
className="rounded-md bg-fury-cyan px-5 py-3 text-sm font-semibold text-text transition hover:bg-fury-aqua"
|
||||||
disabled={searchSubmitting || (Boolean(turnstileSiteKey) && !searchToken)}
|
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{searchSubmitting ? 'Searching…' : 'Search teams'}
|
Search teams
|
||||||
</button>
|
</button>
|
||||||
{turnstileSiteKey ? (
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<TurnstileWidget
|
|
||||||
siteKey={turnstileSiteKey}
|
|
||||||
size="flexible"
|
|
||||||
action="team-search"
|
|
||||||
onVerify={(token) => {
|
|
||||||
setSearchToken(token)
|
|
||||||
setSearchError('')
|
|
||||||
}}
|
|
||||||
onExpire={() => setSearchToken('')}
|
|
||||||
onError={() => setSearchToken('')}
|
|
||||||
resetSignal={searchTurnstileReset}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{searchError ? (
|
|
||||||
<p className="text-xs font-semibold text-red-600 sm:col-span-2">{searchError}</p>
|
|
||||||
) : null}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user