修复wss连接、一些未知错误
This commit is contained in:
@@ -5,9 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -68,17 +66,10 @@ type realtimeBackplane interface {
|
|||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
// Device sessions are authenticated before upgrade. Keeping origin
|
||||||
if origin == "" {
|
// permissive avoids false negatives behind reverse proxies or custom
|
||||||
return true
|
// domains where Host/Forwarded headers are rewritten.
|
||||||
}
|
return true
|
||||||
|
|
||||||
parsed, err := url.Parse(origin)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return originHostMatchesRequest(parsed.Host, r)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,62 +298,3 @@ func (c *Client) writePump() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func originHostMatchesRequest(originHost string, r *http.Request) bool {
|
|
||||||
requestHosts := []string{
|
|
||||||
strings.TrimSpace(r.Host),
|
|
||||||
strings.TrimSpace(r.Header.Get("X-Forwarded-Host")),
|
|
||||||
}
|
|
||||||
|
|
||||||
originName, err := normalizeHost(originHost)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, host := range requestHosts {
|
|
||||||
if host == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
requestName, err := normalizeHost(host)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if requestName == originName {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeHost(host string) (string, error) {
|
|
||||||
host = strings.TrimSpace(host)
|
|
||||||
if host == "" {
|
|
||||||
return "", fmt.Errorf("empty host")
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(host, "://") {
|
|
||||||
parsed, err := url.Parse(host)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
host = parsed.Host
|
|
||||||
}
|
|
||||||
|
|
||||||
name, _, err := net.SplitHostPort(host)
|
|
||||||
if err == nil {
|
|
||||||
host = name
|
|
||||||
} else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
|
||||||
host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
|
|
||||||
}
|
|
||||||
|
|
||||||
host = strings.ToLower(strings.TrimSpace(host))
|
|
||||||
switch host {
|
|
||||||
case "127.0.0.1", "::1":
|
|
||||||
return "localhost", nil
|
|
||||||
default:
|
|
||||||
return host, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
17
frontend/dist/assets/index-DE3lDjdM.js
vendored
17
frontend/dist/assets/index-DE3lDjdM.js
vendored
File diff suppressed because one or more lines are too long
17
frontend/dist/assets/index-DPzeYqvr.js
vendored
Normal file
17
frontend/dist/assets/index-DPzeYqvr.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
frontend/dist/index.html
vendored
4
frontend/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
|||||||
<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>AirShare Pro</title>
|
<title>AirShare Pro</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DE3lDjdM.js"></script>
|
<script type="module" crossorigin src="/assets/index-DPzeYqvr.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CQ9sinZs.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C-7tVt-S.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const connectedPeer = ref({
|
|||||||
name: '--',
|
name: '--',
|
||||||
type: '等待连接',
|
type: '等待连接',
|
||||||
deviceId: '',
|
deviceId: '',
|
||||||
|
networkGroupKey: '',
|
||||||
})
|
})
|
||||||
const transferItems = ref([])
|
const transferItems = ref([])
|
||||||
const relayServer = ref('/ws')
|
const relayServer = ref('/ws')
|
||||||
@@ -82,6 +83,17 @@ const pendingTransferItems = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const hasPendingItems = computed(() => pendingTransferItems.value.length > 0)
|
const hasPendingItems = computed(() => pendingTransferItems.value.length > 0)
|
||||||
|
const currentTransferNetworkHint = computed(() => {
|
||||||
|
if (!connectedPeer.value.deviceId) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSameLocalNetwork(connectedPeer.value.networkGroupKey) || hasTurnRelayConfigured()) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return '当前是跨网络访问,未配置 TURN 时实时通道可能失败。文本和小文件可回退中转,大文件建议使用 MinIO。'
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
theme,
|
theme,
|
||||||
@@ -208,7 +220,7 @@ async function registerCurrentDevice() {
|
|||||||
device_id: deviceId,
|
device_id: deviceId,
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
type: deviceType,
|
type: deviceType,
|
||||||
network_group_key: window.location.hostname || 'local',
|
network_group_key: deriveNetworkGroupKey(),
|
||||||
})
|
})
|
||||||
|
|
||||||
localStorage.setItem(DEVICE_ID_KEY, device.id)
|
localStorage.setItem(DEVICE_ID_KEY, device.id)
|
||||||
@@ -269,7 +281,7 @@ async function refreshCandidates() {
|
|||||||
description: `${formatDeviceType(device.type)} · 最近活跃 ${formatRelativeTime(device.last_seen_at)}`,
|
description: `${formatDeviceType(device.type)} · 最近活跃 ${formatRelativeTime(device.last_seen_at)}`,
|
||||||
icon: mapDeviceIcon(device.type),
|
icon: mapDeviceIcon(device.type),
|
||||||
connectionType:
|
connectionType:
|
||||||
device.network_group_key && device.network_group_key === window.location.hostname
|
isSameLocalNetwork(device.network_group_key)
|
||||||
? '局域网直连优先'
|
? '局域网直连优先'
|
||||||
: '点对点传输',
|
: '点对点传输',
|
||||||
}))
|
}))
|
||||||
@@ -412,6 +424,7 @@ function connectToPeer(device) {
|
|||||||
name: device.name,
|
name: device.name,
|
||||||
type: device.connectionType || device.type || '点对点传输',
|
type: device.connectionType || device.type || '点对点传输',
|
||||||
deviceId: device.deviceId || device.id || '',
|
deviceId: device.deviceId || device.id || '',
|
||||||
|
networkGroupKey: device.network_group_key || '',
|
||||||
}
|
}
|
||||||
isWaitingRoom.value = false
|
isWaitingRoom.value = false
|
||||||
generatedRoomCode.value = '----'
|
generatedRoomCode.value = '----'
|
||||||
@@ -439,6 +452,7 @@ function ensurePeerSession(device, preserveItems = false) {
|
|||||||
name: device.name,
|
name: device.name,
|
||||||
type: device.connectionType || device.type || '点对点传输',
|
type: device.connectionType || device.type || '点对点传输',
|
||||||
deviceId: nextDeviceId,
|
deviceId: nextDeviceId,
|
||||||
|
networkGroupKey: device.network_group_key || '',
|
||||||
}
|
}
|
||||||
isWaitingRoom.value = false
|
isWaitingRoom.value = false
|
||||||
generatedRoomCode.value = '----'
|
generatedRoomCode.value = '----'
|
||||||
@@ -457,6 +471,7 @@ function closeTransfer() {
|
|||||||
name: '--',
|
name: '--',
|
||||||
type: '等待连接',
|
type: '等待连接',
|
||||||
deviceId: '',
|
deviceId: '',
|
||||||
|
networkGroupKey: '',
|
||||||
}
|
}
|
||||||
viewMode.value = 'main'
|
viewMode.value = 'main'
|
||||||
connectedPeer.value.baseType = '等待连接'
|
connectedPeer.value.baseType = '等待连接'
|
||||||
@@ -733,7 +748,7 @@ async function copyTextItem(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(item.text)
|
await copyToClipboard(item.text)
|
||||||
item.copied = true
|
item.copied = true
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
@@ -748,6 +763,33 @@ async function copyTextItem(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(value) {
|
||||||
|
if (navigator.clipboard?.writeText && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = value
|
||||||
|
textarea.setAttribute('readonly', 'readonly')
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.style.top = '0'
|
||||||
|
textarea.style.left = '-9999px'
|
||||||
|
textarea.style.opacity = '0'
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.focus()
|
||||||
|
textarea.select()
|
||||||
|
textarea.setSelectionRange(0, textarea.value.length)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!document.execCommand('copy')) {
|
||||||
|
throw new Error('copy command failed')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function simulateUpload(id) {
|
function simulateUpload(id) {
|
||||||
clearUploadTimer(id)
|
clearUploadTimer(id)
|
||||||
|
|
||||||
@@ -1169,6 +1211,48 @@ function buildRelayServerPath() {
|
|||||||
return `${protocol}//${window.location.host}/ws`
|
return `${protocol}//${window.location.host}/ws`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasTurnRelayConfigured() {
|
||||||
|
return Array.isArray(runtimeConfig.value?.turn_urls) && runtimeConfig.value.turn_urls.some((item) => String(item || '').trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveNetworkGroupKey() {
|
||||||
|
const hostname = String(window.location.hostname || '').trim().toLowerCase()
|
||||||
|
if (!hostname) {
|
||||||
|
return 'local'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname.endsWith('.local')) {
|
||||||
|
return hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPrivateIPv4Host(hostname)) {
|
||||||
|
return hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameLocalNetwork(networkGroupKey) {
|
||||||
|
const localNetworkGroupKey = deriveNetworkGroupKey()
|
||||||
|
return !!localNetworkGroupKey && networkGroupKey === localNetworkGroupKey
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIPv4Host(hostname) {
|
||||||
|
const parts = hostname.split('.')
|
||||||
|
if (parts.length !== 4 || parts.some((part) => !/^\d+$/.test(part))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const [first, second] = parts.map((part) => Number(part))
|
||||||
|
if (first === 10 || first === 127) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (first === 192 && second === 168) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return first === 172 && second >= 16 && second <= 31
|
||||||
|
}
|
||||||
|
|
||||||
function buildIceServers(configValue) {
|
function buildIceServers(configValue) {
|
||||||
const urls = Array.isArray(configValue?.turn_urls)
|
const urls = Array.isArray(configValue?.turn_urls)
|
||||||
? configValue.turn_urls.map((item) => String(item || '').trim()).filter(Boolean)
|
? configValue.turn_urls.map((item) => String(item || '').trim()).filter(Boolean)
|
||||||
@@ -1235,12 +1319,32 @@ function syncConnectionTypeLabel(status = '') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldRecreateRealtimeTransport(targetDeviceId) {
|
||||||
|
if (!peerConnection || currentRtcPeerId !== targetDeviceId) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peerConnection.signalingState === 'closed') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['failed', 'disconnected', 'closed'].includes(peerConnection.connectionState)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['failed', 'disconnected', 'closed'].includes(peerConnection.iceConnectionState)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return !controlChannel || controlChannel.readyState === 'closed'
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureRealtimeTransport(targetDeviceId, options = {}) {
|
async function ensureRealtimeTransport(targetDeviceId, options = {}) {
|
||||||
if (!targetDeviceId || !supportsWebRTC()) {
|
if (!targetDeviceId || !supportsWebRTC()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!peerConnection || currentRtcPeerId !== targetDeviceId) {
|
if (shouldRecreateRealtimeTransport(targetDeviceId)) {
|
||||||
teardownRealtimeTransport()
|
teardownRealtimeTransport()
|
||||||
createPeerConnection(targetDeviceId)
|
createPeerConnection(targetDeviceId)
|
||||||
}
|
}
|
||||||
@@ -1582,6 +1686,7 @@ function buildPeerSessionFromDevice(deviceId, fallbackConnectionType = '等待
|
|||||||
name: knownDevice?.name || `设备 ${shortId(deviceId)}`,
|
name: knownDevice?.name || `设备 ${shortId(deviceId)}`,
|
||||||
type: formatDeviceType(knownDevice?.type || 'desktop'),
|
type: formatDeviceType(knownDevice?.type || 'desktop'),
|
||||||
connectionType: fallbackConnectionType,
|
connectionType: fallbackConnectionType,
|
||||||
|
network_group_key: knownDevice?.network_group_key || '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1797,12 +1902,35 @@ function handleRelayMessage(raw) {
|
|||||||
|
|
||||||
if (envelope.type === 'transfer.file') {
|
if (envelope.type === 'transfer.file') {
|
||||||
handleIncomingTransferFile(envelope)
|
handleIncomingTransferFile(envelope)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.type === 'peer.session.closed') {
|
||||||
|
handlePeerSessionClosed(envelope)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePeerSessionClosed(envelope) {
|
||||||
|
const senderDeviceId = envelope.device_id || ''
|
||||||
|
if (!senderDeviceId || connectedPeer.value.deviceId !== senderDeviceId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
teardownRealtimeTransport()
|
||||||
|
resetTransferItems()
|
||||||
|
connectedPeer.value = {
|
||||||
|
name: '--',
|
||||||
|
type: '绛夊緟杩炴帴',
|
||||||
|
baseType: '绛夊緟杩炴帴',
|
||||||
|
deviceId: '',
|
||||||
|
networkGroupKey: '',
|
||||||
|
}
|
||||||
|
viewMode.value = 'main'
|
||||||
|
}
|
||||||
|
|
||||||
function handleIncomingTransferCreated(envelope) {
|
function handleIncomingTransferCreated(envelope) {
|
||||||
const payload = envelope.payload || {}
|
const payload = envelope.payload || {}
|
||||||
const senderDeviceId = envelope.device_id || payload.sender_device_id || ''
|
const senderDeviceId = envelope.device_id || payload.sender_device_id || ''
|
||||||
@@ -2178,7 +2306,7 @@ refreshCandidates = async function refreshCandidatesOverride() {
|
|||||||
description: `${formatDeviceType(device.type)} · 最近活跃 ${formatRelativeTime(device.last_seen_at)}`,
|
description: `${formatDeviceType(device.type)} · 最近活跃 ${formatRelativeTime(device.last_seen_at)}`,
|
||||||
icon: mapDeviceIcon(device.type),
|
icon: mapDeviceIcon(device.type),
|
||||||
connectionType:
|
connectionType:
|
||||||
device.network_group_key && device.network_group_key === window.location.hostname
|
isSameLocalNetwork(device.network_group_key)
|
||||||
? '局域网直连优先'
|
? '局域网直连优先'
|
||||||
: '跨网络实时传输',
|
: '跨网络实时传输',
|
||||||
}))
|
}))
|
||||||
@@ -2206,6 +2334,7 @@ connectToPeer = function connectToPeerOverride(device) {
|
|||||||
type: nextBaseType,
|
type: nextBaseType,
|
||||||
baseType: nextBaseType,
|
baseType: nextBaseType,
|
||||||
deviceId: nextDeviceId,
|
deviceId: nextDeviceId,
|
||||||
|
networkGroupKey: device.network_group_key || '',
|
||||||
}
|
}
|
||||||
isWaitingRoom.value = false
|
isWaitingRoom.value = false
|
||||||
generatedRoomCode.value = '----'
|
generatedRoomCode.value = '----'
|
||||||
@@ -2231,6 +2360,7 @@ ensurePeerSession = function ensurePeerSessionOverride(device, preserveItems = f
|
|||||||
type: nextBaseType,
|
type: nextBaseType,
|
||||||
baseType: nextBaseType,
|
baseType: nextBaseType,
|
||||||
deviceId: nextDeviceId,
|
deviceId: nextDeviceId,
|
||||||
|
networkGroupKey: device.network_group_key || '',
|
||||||
}
|
}
|
||||||
isWaitingRoom.value = false
|
isWaitingRoom.value = false
|
||||||
generatedRoomCode.value = '----'
|
generatedRoomCode.value = '----'
|
||||||
@@ -2243,6 +2373,10 @@ ensurePeerSession = function ensurePeerSessionOverride(device, preserveItems = f
|
|||||||
}
|
}
|
||||||
|
|
||||||
closeTransfer = function closeTransferOverride() {
|
closeTransfer = function closeTransferOverride() {
|
||||||
|
if (connectedPeer.value.deviceId) {
|
||||||
|
relayToPeer('peer.session.closed', connectedPeer.value.deviceId, {})
|
||||||
|
}
|
||||||
|
|
||||||
teardownRealtimeTransport()
|
teardownRealtimeTransport()
|
||||||
resetTransferItems()
|
resetTransferItems()
|
||||||
connectedPeer.value = {
|
connectedPeer.value = {
|
||||||
@@ -2250,6 +2384,7 @@ closeTransfer = function closeTransferOverride() {
|
|||||||
type: '等待连接',
|
type: '等待连接',
|
||||||
baseType: '等待连接',
|
baseType: '等待连接',
|
||||||
deviceId: '',
|
deviceId: '',
|
||||||
|
networkGroupKey: '',
|
||||||
}
|
}
|
||||||
viewMode.value = 'main'
|
viewMode.value = 'main'
|
||||||
}
|
}
|
||||||
@@ -2709,6 +2844,7 @@ handleIncomingTransferFile = function handleIncomingTransferFileOverride(envelop
|
|||||||
:connection-type="connectedPeer.type"
|
:connection-type="connectedPeer.type"
|
||||||
:has-pending-items="hasPendingItems"
|
:has-pending-items="hasPendingItems"
|
||||||
:items="transferItems"
|
:items="transferItems"
|
||||||
|
:network-hint="currentTransferNetworkHint"
|
||||||
:peer-name="connectedPeer.name"
|
:peer-name="connectedPeer.name"
|
||||||
@close="closeTransfer"
|
@close="closeTransfer"
|
||||||
@copy-item="copyTextItem"
|
@copy-item="copyTextItem"
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
networkHint: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
items: {
|
items: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -84,6 +88,7 @@ function handleDrop(event) {
|
|||||||
<div class="connected-to">
|
<div class="connected-to">
|
||||||
<h2>{{ peerName }}</h2>
|
<h2>{{ peerName }}</h2>
|
||||||
<p>{{ connectionType }}</p>
|
<p>{{ connectionType }}</p>
|
||||||
|
<small v-if="networkHint" class="connection-hint">{{ networkHint }}</small>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" type="button" @click="$emit('close')">
|
<button class="close-btn" type="button" @click="$emit('close')">
|
||||||
<LocalIcon name="close" size="20" />
|
<LocalIcon name="close" size="20" />
|
||||||
|
|||||||
@@ -708,6 +708,15 @@ input.room-code::placeholder {
|
|||||||
color: var(--success-green);
|
color: var(--success-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connection-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
max-width: 42ch;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
|
|||||||
Reference in New Issue
Block a user