qr-secure-send 1.5.2 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.html +223 -29
- package/package.json +1 -1
package/index.html
CHANGED
|
@@ -118,13 +118,45 @@
|
|
|
118
118
|
font-size: 0.8rem; color: #d29922; margin-top: 0.5rem;
|
|
119
119
|
padding: 0.5rem; background: #d299220f; border-radius: 4px;
|
|
120
120
|
}
|
|
121
|
+
|
|
122
|
+
.passphrase-strength {
|
|
123
|
+
font-size: 0.78rem; line-height: 1.4; margin-top: -0.6rem;
|
|
124
|
+
margin-bottom: 0.8rem; padding: 0.4rem 0.6rem;
|
|
125
|
+
border-radius: 4px; display: none;
|
|
126
|
+
}
|
|
127
|
+
.passphrase-strength.weak { color: #f85149; background: #f851490f; display: block; }
|
|
128
|
+
.passphrase-strength.moderate { color: #d29922; background: #d299220f; display: block; }
|
|
129
|
+
.passphrase-strength.strong { color: #3fb950; background: #3fb9500f; display: block; }
|
|
130
|
+
|
|
131
|
+
.wallet-section {
|
|
132
|
+
margin-bottom: 1rem; padding: 0.8rem;
|
|
133
|
+
background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
|
|
134
|
+
}
|
|
135
|
+
.wallet-toggle {
|
|
136
|
+
display: flex; align-items: center; gap: 0.5rem; cursor: pointer;
|
|
137
|
+
font-size: 0.85rem; color: #8b949e; text-transform: none; letter-spacing: 0;
|
|
138
|
+
}
|
|
139
|
+
.wallet-toggle input[type="checkbox"] { width: auto; margin: 0; }
|
|
140
|
+
.wallet-fields { display: none; margin-top: 0.8rem; }
|
|
141
|
+
.wallet-fields.active { display: block; }
|
|
142
|
+
.wallet-address {
|
|
143
|
+
font-family: "SF Mono", "Fira Code", monospace; font-size: 0.82rem;
|
|
144
|
+
color: #58a6ff; word-break: break-all; padding: 0.3rem 0;
|
|
145
|
+
}
|
|
146
|
+
button.wallet-btn {
|
|
147
|
+
padding: 0.5rem 0.8rem; background: #f6851b; color: #fff; border: none;
|
|
148
|
+
border-radius: 6px; font-size: 0.85rem; font-weight: 500; cursor: pointer;
|
|
149
|
+
transition: background 0.2s;
|
|
150
|
+
}
|
|
151
|
+
button.wallet-btn:hover { background: #e2761b; }
|
|
152
|
+
button.wallet-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
121
153
|
</style>
|
|
122
154
|
</head>
|
|
123
155
|
<body>
|
|
124
156
|
<header>
|
|
125
157
|
<h1>QR Secure Send</h1>
|
|
126
158
|
<p>Encrypt and transfer secrets via QR code</p>
|
|
127
|
-
<p style="font-size:0.7rem;color:#484f58;margin-top:0.2rem">v1.
|
|
159
|
+
<p style="font-size:0.7rem;color:#484f58;margin-top:0.2rem">v1.6.1</p>
|
|
128
160
|
</header>
|
|
129
161
|
|
|
130
162
|
<div class="tabs">
|
|
@@ -143,9 +175,25 @@
|
|
|
143
175
|
|
|
144
176
|
<label for="send-passphrase">Encryption Passphrase</label>
|
|
145
177
|
<div class="input-wrap">
|
|
146
|
-
<input type="password" id="send-passphrase" placeholder="
|
|
178
|
+
<input type="password" id="send-passphrase" placeholder="Optional passphrase..." autocomplete="off">
|
|
147
179
|
<button class="toggle-vis" data-target="send-passphrase">show</button>
|
|
148
180
|
</div>
|
|
181
|
+
<div id="send-passphrase-strength" class="passphrase-strength"></div>
|
|
182
|
+
|
|
183
|
+
<div class="wallet-section">
|
|
184
|
+
<label class="wallet-toggle">
|
|
185
|
+
<input type="checkbox" id="send-wallet-toggle">
|
|
186
|
+
Require receiver's crypto wallet (MetaMask)
|
|
187
|
+
</label>
|
|
188
|
+
<div id="send-wallet-fields" class="wallet-fields">
|
|
189
|
+
<label for="send-wallet-address">Receiver Wallet Address</label>
|
|
190
|
+
<input type="text" id="send-wallet-address" placeholder="0x..." autocomplete="off"
|
|
191
|
+
style="font-family:'SF Mono','Fira Code',monospace;font-size:0.85rem;">
|
|
192
|
+
<p class="info" style="margin-top:-0.5rem;text-align:left">
|
|
193
|
+
The receiver must connect this exact wallet to decrypt. The address is mixed into the encryption key.
|
|
194
|
+
</p>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
149
197
|
|
|
150
198
|
<button class="primary" id="generate-btn">Generate QR Code</button>
|
|
151
199
|
<div id="send-error" class="error"></div>
|
|
@@ -162,9 +210,18 @@
|
|
|
162
210
|
<div class="card">
|
|
163
211
|
<label for="recv-passphrase">Decryption Passphrase</label>
|
|
164
212
|
<div class="input-wrap">
|
|
165
|
-
<input type="password" id="recv-passphrase" placeholder="
|
|
213
|
+
<input type="password" id="recv-passphrase" placeholder="Passphrase (leave empty if none was set)..." autocomplete="off">
|
|
166
214
|
<button class="toggle-vis" data-target="recv-passphrase">show</button>
|
|
167
215
|
</div>
|
|
216
|
+
<div id="recv-passphrase-strength" class="passphrase-strength"></div>
|
|
217
|
+
|
|
218
|
+
<div id="recv-wallet-section" class="wallet-section" style="display:none">
|
|
219
|
+
<p style="font-size:0.82rem;color:#d29922;margin-bottom:0.5rem;">
|
|
220
|
+
This secret requires wallet authentication. Connect the designated wallet to decrypt.
|
|
221
|
+
</p>
|
|
222
|
+
<button class="wallet-btn" id="recv-wallet-connect-btn">Connect MetaMask</button>
|
|
223
|
+
<div id="recv-wallet-status" class="wallet-address" style="display:none"></div>
|
|
224
|
+
</div>
|
|
168
225
|
|
|
169
226
|
<div class="btn-row">
|
|
170
227
|
<button class="primary" id="scan-btn">Start Camera</button>
|
|
@@ -187,7 +244,8 @@
|
|
|
187
244
|
|
|
188
245
|
<div style="margin:2rem auto;max-width:480px;padding:0.8rem 1rem;background:#0d4429;border:1px solid #1a7f42;border-radius:8px;color:#3fb950;font-size:0.82rem;text-align:center;line-height:1.5">
|
|
189
246
|
This app runs <strong>entirely in your browser</strong>. Nothing is sent to any server — all encryption and decryption happen locally using the Web Crypto API.
|
|
190
|
-
<br>For fully offline use:
|
|
247
|
+
<br>For fully offline use:
|
|
248
|
+
<br><code style="background:#161b22;padding:0.3rem 0.6rem;border-radius:4px;color:#58a6ff;display:inline-block;margin-top:0.4rem">npm i -g qr-secure-send && qr-secure-send</code>
|
|
191
249
|
</div>
|
|
192
250
|
|
|
193
251
|
<script>
|
|
@@ -913,19 +971,59 @@
|
|
|
913
971
|
}
|
|
914
972
|
|
|
915
973
|
|
|
974
|
+
// =========================================================================
|
|
975
|
+
// PASSPHRASE STRENGTH
|
|
976
|
+
// =========================================================================
|
|
977
|
+
|
|
978
|
+
const BRUTE_FORCE_NOTE = 'There is no rate-limiting \u2014 an attacker with the ' +
|
|
979
|
+
'encrypted data can try passphrases offline at thousands of guesses/sec per GPU.';
|
|
980
|
+
|
|
981
|
+
function evaluatePassphraseStrength(passphrase) {
|
|
982
|
+
if (!passphrase) return { level: 'none', bits: 0, message: '' };
|
|
983
|
+
|
|
984
|
+
let charsetSize = 0;
|
|
985
|
+
if (/[a-z]/.test(passphrase)) charsetSize += 26;
|
|
986
|
+
if (/[A-Z]/.test(passphrase)) charsetSize += 26;
|
|
987
|
+
if (/[0-9]/.test(passphrase)) charsetSize += 10;
|
|
988
|
+
if (/[^a-zA-Z0-9]/.test(passphrase)) charsetSize += 33;
|
|
989
|
+
if (charsetSize === 0) charsetSize = 26;
|
|
990
|
+
|
|
991
|
+
let bits = Math.floor(passphrase.length * Math.log2(charsetSize));
|
|
992
|
+
if (/^(.)\1+$/.test(passphrase)) bits = 4;
|
|
993
|
+
if (/^[a-z]+$/i.test(passphrase)) bits = Math.floor(bits * 0.7);
|
|
994
|
+
if (passphrase.length < 6) bits = Math.min(bits, 20);
|
|
995
|
+
|
|
996
|
+
if (bits < 35) {
|
|
997
|
+
return { level: 'weak', bits, message:
|
|
998
|
+
'Weak (~' + bits + '-bit). Crackable in hours/days by a GPU. ' + BRUTE_FORCE_NOTE +
|
|
999
|
+
' Use 12+ chars with mixed case, digits & symbols, or a 5+ word passphrase.' };
|
|
1000
|
+
}
|
|
1001
|
+
if (bits < 50) {
|
|
1002
|
+
return { level: 'moderate', bits, message:
|
|
1003
|
+
'Moderate (~' + bits + '-bit). May resist casual attacks but not a determined adversary. ' +
|
|
1004
|
+
BRUTE_FORCE_NOTE + ' Consider a longer passphrase for sensitive secrets.' };
|
|
1005
|
+
}
|
|
1006
|
+
return { level: 'strong', bits, message: 'Strong (~' + bits + '-bit).' };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
916
1009
|
// =========================================================================
|
|
917
1010
|
// PAYLOAD HELPERS
|
|
918
1011
|
// =========================================================================
|
|
919
1012
|
|
|
920
1013
|
const GH_PAGES_URL = "https://degenddy.github.io/qr-secure-send/";
|
|
921
1014
|
|
|
922
|
-
|
|
1015
|
+
function buildKeyInput(passphrase, walletAddress) {
|
|
1016
|
+
return walletAddress ? passphrase + ':' + walletAddress.toLowerCase() : passphrase;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Extract encrypted data from raw or URL form. Returns { data, wallet } or null.
|
|
923
1020
|
function extractPayload(text) {
|
|
924
|
-
|
|
925
|
-
try {
|
|
926
|
-
|
|
927
|
-
if (
|
|
928
|
-
|
|
1021
|
+
const sources = [text];
|
|
1022
|
+
try { const h = new URL(text).hash; if (h) sources.push(h.slice(1)); } catch (_) {}
|
|
1023
|
+
for (const s of sources) {
|
|
1024
|
+
if (s.startsWith("QRSECW:")) return { data: s.slice(7), wallet: true };
|
|
1025
|
+
if (s.startsWith("QRSEC:")) return { data: s.slice(6), wallet: false };
|
|
1026
|
+
}
|
|
929
1027
|
return null;
|
|
930
1028
|
}
|
|
931
1029
|
|
|
@@ -957,10 +1055,27 @@
|
|
|
957
1055
|
});
|
|
958
1056
|
});
|
|
959
1057
|
|
|
1058
|
+
// Passphrase strength listeners
|
|
1059
|
+
['send-passphrase', 'recv-passphrase'].forEach(id => {
|
|
1060
|
+
document.getElementById(id).addEventListener('input', () => {
|
|
1061
|
+
const result = evaluatePassphraseStrength(document.getElementById(id).value);
|
|
1062
|
+
const el = document.getElementById(id + '-strength');
|
|
1063
|
+
el.className = 'passphrase-strength' + (result.level !== 'none' ? ' ' + result.level : '');
|
|
1064
|
+
el.textContent = result.message;
|
|
1065
|
+
});
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
// Send wallet toggle
|
|
1069
|
+
document.getElementById("send-wallet-toggle").addEventListener("change", (e) => {
|
|
1070
|
+
document.getElementById("send-wallet-fields").classList.toggle("active", e.target.checked);
|
|
1071
|
+
});
|
|
1072
|
+
|
|
960
1073
|
// QR Generation
|
|
961
1074
|
document.getElementById("generate-btn").addEventListener("click", async () => {
|
|
962
1075
|
const secret = document.getElementById("secret").value.trim();
|
|
963
1076
|
const passphrase = document.getElementById("send-passphrase").value;
|
|
1077
|
+
const useWallet = document.getElementById("send-wallet-toggle").checked;
|
|
1078
|
+
const walletAddress = useWallet ? document.getElementById("send-wallet-address").value.trim() : null;
|
|
964
1079
|
const errEl = document.getElementById("send-error");
|
|
965
1080
|
const output = document.getElementById("qr-output");
|
|
966
1081
|
const info = document.getElementById("qr-info");
|
|
@@ -970,11 +1085,29 @@
|
|
|
970
1085
|
info.style.display = "none";
|
|
971
1086
|
|
|
972
1087
|
if (!secret) { errEl.textContent = "Please enter a secret."; return; }
|
|
973
|
-
|
|
1088
|
+
|
|
1089
|
+
if (!passphrase && !useWallet) {
|
|
1090
|
+
if (!confirm('No passphrase or wallet set. Anyone who scans this QR code can read the secret. Continue?')) return;
|
|
1091
|
+
} else if (passphrase) {
|
|
1092
|
+
const strength = evaluatePassphraseStrength(passphrase);
|
|
1093
|
+
if (strength.level === 'weak') {
|
|
1094
|
+
if (!confirm('Your passphrase is weak (~' + strength.bits + '-bit entropy). ' +
|
|
1095
|
+
'An attacker who captures this QR code could crack it offline. Continue anyway?')) return;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (useWallet) {
|
|
1100
|
+
if (!walletAddress || !/^0x[0-9a-fA-F]{40}$/.test(walletAddress)) {
|
|
1101
|
+
errEl.textContent = "Enter a valid Ethereum wallet address (0x + 40 hex characters).";
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
974
1105
|
|
|
975
1106
|
try {
|
|
976
|
-
const
|
|
977
|
-
const
|
|
1107
|
+
const keyInput = buildKeyInput(passphrase, walletAddress);
|
|
1108
|
+
const encrypted = await encryptSecret(secret, keyInput);
|
|
1109
|
+
const prefix = useWallet ? "QRSECW:" : "QRSEC:";
|
|
1110
|
+
const payload = GH_PAGES_URL + "#" + prefix + encrypted;
|
|
978
1111
|
|
|
979
1112
|
if (payload.length > 2953) {
|
|
980
1113
|
errEl.textContent = "Secret is too long for a QR code. Keep it under ~1950 characters.";
|
|
@@ -996,6 +1129,46 @@
|
|
|
996
1129
|
"loaded from the internet when you start the camera.";
|
|
997
1130
|
}
|
|
998
1131
|
|
|
1132
|
+
// Wallet connect (receive side)
|
|
1133
|
+
let connectedWalletAddress = null;
|
|
1134
|
+
|
|
1135
|
+
document.getElementById("recv-wallet-connect-btn").addEventListener("click", async () => {
|
|
1136
|
+
const statusEl = document.getElementById("recv-wallet-status");
|
|
1137
|
+
const errEl = document.getElementById("recv-error");
|
|
1138
|
+
if (typeof window.ethereum === 'undefined') {
|
|
1139
|
+
errEl.textContent = "MetaMask (or compatible wallet) not detected. Please install MetaMask.";
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
try {
|
|
1143
|
+
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
|
|
1144
|
+
if (!accounts.length) { errEl.textContent = "No accounts returned. Please unlock MetaMask."; return; }
|
|
1145
|
+
connectedWalletAddress = accounts[0].toLowerCase();
|
|
1146
|
+
statusEl.textContent = "Connected: " + connectedWalletAddress;
|
|
1147
|
+
statusEl.style.display = "block";
|
|
1148
|
+
document.getElementById("recv-wallet-connect-btn").textContent = "Wallet Connected";
|
|
1149
|
+
document.getElementById("recv-wallet-connect-btn").disabled = true;
|
|
1150
|
+
} catch (e) {
|
|
1151
|
+
errEl.textContent = "Wallet connection failed: " + (e.message || "User rejected request.");
|
|
1152
|
+
}
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
// Update wallet address if user switches account in MetaMask
|
|
1156
|
+
if (typeof window.ethereum !== 'undefined') {
|
|
1157
|
+
window.ethereum.on('accountsChanged', (accounts) => {
|
|
1158
|
+
if (accounts.length) {
|
|
1159
|
+
connectedWalletAddress = accounts[0].toLowerCase();
|
|
1160
|
+
const statusEl = document.getElementById("recv-wallet-status");
|
|
1161
|
+
statusEl.textContent = "Connected: " + connectedWalletAddress;
|
|
1162
|
+
statusEl.style.display = "block";
|
|
1163
|
+
} else {
|
|
1164
|
+
connectedWalletAddress = null;
|
|
1165
|
+
document.getElementById("recv-wallet-status").style.display = "none";
|
|
1166
|
+
const btn = document.getElementById("recv-wallet-connect-btn");
|
|
1167
|
+
btn.textContent = "Connect MetaMask"; btn.disabled = false;
|
|
1168
|
+
}
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
|
|
999
1172
|
// QR Scanning
|
|
1000
1173
|
document.getElementById("scan-btn").addEventListener("click", async () => {
|
|
1001
1174
|
const passphrase = document.getElementById("recv-passphrase").value;
|
|
@@ -1005,7 +1178,6 @@
|
|
|
1005
1178
|
errEl.textContent = "";
|
|
1006
1179
|
resultBox.style.display = "none";
|
|
1007
1180
|
|
|
1008
|
-
if (!passphrase) { errEl.textContent = "Enter the decryption passphrase first."; return; }
|
|
1009
1181
|
|
|
1010
1182
|
document.getElementById("scan-btn").style.display = "none";
|
|
1011
1183
|
document.getElementById("stop-btn").style.display = "block";
|
|
@@ -1015,23 +1187,33 @@
|
|
|
1015
1187
|
const started = await QRScan.start(
|
|
1016
1188
|
region,
|
|
1017
1189
|
async (decodedText) => {
|
|
1018
|
-
const
|
|
1019
|
-
if (!
|
|
1190
|
+
const payload = extractPayload(decodedText);
|
|
1191
|
+
if (!payload) {
|
|
1020
1192
|
errEl.textContent = "Not a QR Secure Send code.";
|
|
1021
|
-
return false;
|
|
1193
|
+
return false;
|
|
1194
|
+
}
|
|
1195
|
+
if (payload.wallet) {
|
|
1196
|
+
document.getElementById("recv-wallet-section").style.display = "block";
|
|
1197
|
+
if (!connectedWalletAddress) {
|
|
1198
|
+
errEl.textContent = "This secret requires wallet authentication. Connect your wallet first, then scan again.";
|
|
1199
|
+
return false;
|
|
1200
|
+
}
|
|
1022
1201
|
}
|
|
1023
1202
|
try {
|
|
1024
|
-
const
|
|
1203
|
+
const keyInput = buildKeyInput(passphrase, payload.wallet ? connectedWalletAddress : null);
|
|
1204
|
+
const secret = await decryptSecret(payload.data, keyInput);
|
|
1025
1205
|
document.getElementById("recv-value").textContent = secret;
|
|
1026
1206
|
resultBox.style.display = "block";
|
|
1027
1207
|
errEl.textContent = "";
|
|
1028
1208
|
QRScan.stop(region);
|
|
1029
1209
|
document.getElementById("scan-btn").style.display = "block";
|
|
1030
1210
|
document.getElementById("stop-btn").style.display = "none";
|
|
1031
|
-
return true;
|
|
1211
|
+
return true;
|
|
1032
1212
|
} catch (e) {
|
|
1033
|
-
errEl.textContent =
|
|
1034
|
-
|
|
1213
|
+
errEl.textContent = payload.wallet
|
|
1214
|
+
? "Decryption failed. Wrong passphrase or wrong wallet?"
|
|
1215
|
+
: "Decryption failed. Wrong passphrase?";
|
|
1216
|
+
return false;
|
|
1035
1217
|
}
|
|
1036
1218
|
},
|
|
1037
1219
|
(msg) => {
|
|
@@ -1061,31 +1243,43 @@
|
|
|
1061
1243
|
errEl.textContent = "";
|
|
1062
1244
|
resultBox.style.display = "none";
|
|
1063
1245
|
|
|
1064
|
-
if (!passphrase) { errEl.textContent = "Enter the decryption passphrase first."; return; }
|
|
1065
1246
|
|
|
1066
|
-
const
|
|
1067
|
-
if (!
|
|
1247
|
+
const payload = extractPayload(location.hash.slice(1));
|
|
1248
|
+
if (!payload) { errEl.textContent = "No encrypted data found in URL."; return; }
|
|
1249
|
+
|
|
1250
|
+
if (payload.wallet && !connectedWalletAddress) {
|
|
1251
|
+
errEl.textContent = "This secret requires wallet authentication. Connect your wallet first.";
|
|
1252
|
+
document.getElementById("recv-wallet-section").style.display = "block";
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1068
1255
|
|
|
1069
1256
|
try {
|
|
1070
|
-
const
|
|
1257
|
+
const keyInput = buildKeyInput(passphrase, payload.wallet ? connectedWalletAddress : null);
|
|
1258
|
+
const secret = await decryptSecret(payload.data, keyInput);
|
|
1071
1259
|
document.getElementById("recv-value").textContent = secret;
|
|
1072
1260
|
resultBox.style.display = "block";
|
|
1073
1261
|
} catch (e) {
|
|
1074
|
-
errEl.textContent =
|
|
1262
|
+
errEl.textContent = payload.wallet
|
|
1263
|
+
? "Decryption failed. Wrong passphrase or wrong wallet?"
|
|
1264
|
+
: "Decryption failed. Wrong passphrase?";
|
|
1075
1265
|
}
|
|
1076
1266
|
});
|
|
1077
1267
|
|
|
1078
1268
|
// Check URL hash for incoming encrypted data (link-based receive flow)
|
|
1079
|
-
if (location.hash.startsWith("#QRSEC:")) {
|
|
1080
|
-
// Switch to receive tab
|
|
1269
|
+
if (location.hash.startsWith("#QRSEC:") || location.hash.startsWith("#QRSECW:")) {
|
|
1081
1270
|
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
|
1082
1271
|
document.querySelectorAll(".panel").forEach(p => p.classList.remove("active"));
|
|
1083
1272
|
document.querySelector('[data-tab="receive"]').classList.add("active");
|
|
1084
1273
|
document.getElementById("receive").classList.add("active");
|
|
1085
|
-
// Show decrypt button instead of camera controls
|
|
1086
1274
|
document.getElementById("scan-btn").style.display = "none";
|
|
1087
1275
|
document.getElementById("decrypt-btn").style.display = "inline-block";
|
|
1088
1276
|
document.getElementById("link-notice").style.display = "block";
|
|
1277
|
+
if (location.hash.startsWith("#QRSECW:")) {
|
|
1278
|
+
document.getElementById("recv-wallet-section").style.display = "block";
|
|
1279
|
+
document.getElementById("link-notice").textContent =
|
|
1280
|
+
"Encrypted data received via link. This secret requires wallet authentication. " +
|
|
1281
|
+
"Connect your wallet, enter the passphrase, and click Decrypt.";
|
|
1282
|
+
}
|
|
1089
1283
|
}
|
|
1090
1284
|
|
|
1091
1285
|
// Copy to clipboard
|