libp2r2p 0.7.0 → 0.9.0
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/README.md +112 -8
- package/base16/index.js +6 -3
- package/base36/index.js +12 -8
- package/base62/index.js +15 -11
- package/base64/index.js +18 -2
- package/base93/index.js +19 -7
- package/content-key/event/index.js +40 -12
- package/double-dh/index.js +21 -6
- package/ecdh/index.js +12 -1
- package/error/index.js +32 -0
- package/event/helpers/serialize.js +31 -0
- package/event/index.js +116 -0
- package/i18n/index.js +15 -13
- package/idb/index.js +5 -3
- package/idb-queue/index.js +21 -20
- package/index.js +10 -0
- package/key/index.js +31 -18
- package/kind/index.js +244 -0
- package/nip04/index.js +47 -0
- package/nip05/index.js +61 -0
- package/nip19/index.js +176 -43
- package/nip44/helpers.js +95 -0
- package/nip44/index.js +14 -0
- package/nip44-v3/index.js +35 -16
- package/nip46/helpers/frame.js +6 -6
- package/nip46/helpers/url.js +8 -6
- package/nip46/services/bunker-signer.js +7 -6
- package/nip46/services/client.js +8 -7
- package/nip46/services/server-session.js +8 -7
- package/nip46/services/transport.js +9 -8
- package/nip96/index.js +285 -0
- package/nip98/index.js +56 -0
- package/nwt/index.js +241 -0
- package/package.json +22 -3
- package/private-channel/helpers/chunks.js +7 -6
- package/private-channel/helpers/event.js +5 -4
- package/private-channel/index.js +63 -61
- package/private-channel/services/received-chunks.js +4 -3
- package/private-message/index.js +32 -31
- package/private-messenger/index.js +313 -54
- package/private-messenger/recovery/index.js +15 -14
- package/private-messenger/services/channel-state.js +28 -7
- package/private-messenger/services/storage-maintenance.js +142 -46
- package/relay/helpers/hll.js +3 -1
- package/relay/services/query.js +3 -3
- package/relay/services/relay-connection.js +222 -121
- package/relay/services/relay-pool.js +13 -10
- package/temporary-storage/index.js +3 -1
- package/url/index.js +131 -0
- package/web-storage-queue/index.js +8 -6
package/kind/index.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
export const METADATA = 0
|
|
2
|
+
export const TEXT_NOTE = 1
|
|
3
|
+
export const RECOMMEND_RELAY = 2
|
|
4
|
+
export const FOLLOWS = 3
|
|
5
|
+
export const ENCRYPTED_DIRECT_MESSAGE = 4
|
|
6
|
+
export const DELETION = 5
|
|
7
|
+
export const REPOST = 6
|
|
8
|
+
export const REACTION = 7
|
|
9
|
+
export const BADGE_AWARD = 8
|
|
10
|
+
export const SEAL = 13
|
|
11
|
+
export const PRIVATE_DIRECT_MESSAGE = 14
|
|
12
|
+
export const GENERIC_REPOST = 16
|
|
13
|
+
export const PICTURE = 20
|
|
14
|
+
export const VIDEO = 21
|
|
15
|
+
export const SHORT_VIDEO = 22
|
|
16
|
+
export const CHANNEL_CREATE = 40
|
|
17
|
+
export const CHANNEL_METADATA = 41
|
|
18
|
+
export const CHANNEL_MESSAGE = 42
|
|
19
|
+
export const CHANNEL_HIDE_MESSAGE = 43
|
|
20
|
+
export const CHANNEL_MUTE_USER = 44
|
|
21
|
+
export const REGULAR_CUSTOM_APP_DATA = 78
|
|
22
|
+
export const PERSONAL_COPY = 1006
|
|
23
|
+
export const OPEN_TIMESTAMPS = 1040
|
|
24
|
+
export const GIFT_WRAP = 1059
|
|
25
|
+
export const FILE_METADATA = 1063
|
|
26
|
+
export const COMMENT = 1111
|
|
27
|
+
export const VOICE_MESSAGE = 1222
|
|
28
|
+
export const VOICE_MESSAGE_REPLY = 1244
|
|
29
|
+
export const LIVE_CHAT_MESSAGE = 1311
|
|
30
|
+
export const PROBLEM_TRACKER = 1971
|
|
31
|
+
export const REPORT = 1984
|
|
32
|
+
export const LABEL = 1985
|
|
33
|
+
export const PRIVATE_CHANNEL_BROADCAST = 3560
|
|
34
|
+
export const COMMUNITY_POST_APPROVAL = 4550
|
|
35
|
+
export const JOB_REQUEST = 5999
|
|
36
|
+
export const JOB_RESULT = 6999
|
|
37
|
+
export const JOB_FEEDBACK = 7000
|
|
38
|
+
export const ZAP_GOAL = 9041
|
|
39
|
+
export const ZAP_REQUEST = 9734
|
|
40
|
+
export const ZAP = 9735
|
|
41
|
+
export const HIGHLIGHTS = 9802
|
|
42
|
+
export const MUTE_LIST = 10000
|
|
43
|
+
export const PINNED_NOTES = 10001
|
|
44
|
+
export const READ_WRITE_RELAYS = 10002
|
|
45
|
+
export const BOOKMARKS = 10003
|
|
46
|
+
export const COMMUNITIES = 10004
|
|
47
|
+
export const PUBLIC_CHATS = 10005
|
|
48
|
+
export const BLOCKED_RELAYS = 10006
|
|
49
|
+
export const SEARCH_RELAYS = 10007
|
|
50
|
+
export const SIMPLE_GROUPS = 10009
|
|
51
|
+
export const RELAY_FEEDS = 10012
|
|
52
|
+
export const INTERESTS = 10015
|
|
53
|
+
export const MEDIA_FOLLOWS = 10020
|
|
54
|
+
export const EMOJIS = 10030
|
|
55
|
+
export const DM_RELAYS = 10050
|
|
56
|
+
export const FILE_SERVER_PREFERENCE = 10096
|
|
57
|
+
export const GOOD_WIKI_AUTHORS = 10101
|
|
58
|
+
export const GOOD_WIKI_RELAYS = 10102
|
|
59
|
+
export const NWC_WALLET_INFO = 13194
|
|
60
|
+
export const LIGHTNING_PUB_RPC = 21000
|
|
61
|
+
export const AUTH = 22242
|
|
62
|
+
export const NWC_WALLET_REQUEST = 23194
|
|
63
|
+
export const NWC_WALLET_RESPONSE = 23195
|
|
64
|
+
export const SIGNER_RPC = 24133
|
|
65
|
+
export const HTTP_AUTH = 27235
|
|
66
|
+
export const NWT = 27519
|
|
67
|
+
export const FOLLOW_SET = 30000
|
|
68
|
+
export const LIST = 30001
|
|
69
|
+
export const RELAY_SET = 30002
|
|
70
|
+
export const BOOKMARK_SET = 30003
|
|
71
|
+
export const CURATION_SET = 30004
|
|
72
|
+
export const VIDEO_CURATION_SET = 30005
|
|
73
|
+
export const PICTURE_CURATION_SET = 30006
|
|
74
|
+
export const KIND_MUTE_SET = 30007
|
|
75
|
+
export const PROFILE_BADGES = 30008
|
|
76
|
+
export const BADGE_DEFINITION = 30009
|
|
77
|
+
export const INTEREST_SET = 30015
|
|
78
|
+
export const CREATE_OR_UPDATE_STALL = 30017
|
|
79
|
+
export const CREATE_OR_UPDATE_PRODUCT = 30018
|
|
80
|
+
export const LONG_FORM_CONTENT = 30023
|
|
81
|
+
export const DRAFT_LONG = 30024
|
|
82
|
+
export const EMOJI_SET = 30030
|
|
83
|
+
export const RELEASE_ARTIFACT_SET = 30063
|
|
84
|
+
export const CUSTOM_APP_DATA = 30078
|
|
85
|
+
export const APP_CURATION_SET = 30267
|
|
86
|
+
export const LIVE_EVENT = 30311
|
|
87
|
+
export const USER_STATUSES = 30315
|
|
88
|
+
export const I_TAG_TRUSTED_ASSERTION = 30385
|
|
89
|
+
export const CLASSIFIED_LISTING = 30402
|
|
90
|
+
export const DRAFT_CLASSIFIED_LISTING = 30403
|
|
91
|
+
export const DATE_BASED_CALENDAR_EVENT = 31922
|
|
92
|
+
export const TIME_BASED_CALENDAR_EVENT = 31923
|
|
93
|
+
export const CALENDAR = 31924
|
|
94
|
+
export const CALENDAR_EVENT_RSVP = 31925
|
|
95
|
+
export const HANDLER_RECOMMENDATION = 31989
|
|
96
|
+
export const HANDLER_INFORMATION = 31990
|
|
97
|
+
export const EDITABLE_VIDEO = 34235
|
|
98
|
+
export const EDITABLE_SHORT_VIDEO = 34236
|
|
99
|
+
export const COMMUNITY_DEFINITION = 34550
|
|
100
|
+
export const BINARY_DATA_CHUNK = 34601
|
|
101
|
+
export const MAIN_SITE_MANIFEST = 35128
|
|
102
|
+
export const NEXT_SITE_MANIFEST = 35129
|
|
103
|
+
export const DRAFT_SITE_MANIFEST = 35130
|
|
104
|
+
export const STARTER_PACK = 39089
|
|
105
|
+
export const MEDIA_STARTER_PACK = 39092
|
|
106
|
+
|
|
107
|
+
const classifications = ['regular', 'replaceable', 'ephemeral', 'addressable']
|
|
108
|
+
|
|
109
|
+
function isValidKind (kind) {
|
|
110
|
+
return Number.isInteger(kind) && kind >= 0 && kind <= 0xffff
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function isRegularKind (kind) {
|
|
114
|
+
return isValidKind(kind) && (
|
|
115
|
+
(kind >= 1000 && kind < 10000) ||
|
|
116
|
+
(kind >= 4 && kind < 45) ||
|
|
117
|
+
kind === 1 || kind === 2
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function isReplaceableKind (kind) {
|
|
122
|
+
return isValidKind(kind) && (kind === 0 || kind === 3 || (kind >= 10000 && kind < 20000))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function isEphemeralKind (kind) {
|
|
126
|
+
return isValidKind(kind) && kind >= 20000 && kind < 30000
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function isAddressableKind (kind) {
|
|
130
|
+
return isValidKind(kind) && kind >= 30000 && kind < 40000
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function classifyKind (kind) {
|
|
134
|
+
const predicates = [isRegularKind, isReplaceableKind, isEphemeralKind, isAddressableKind]
|
|
135
|
+
return classifications.filter((_, index) => predicates[index](kind))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const eventKinds = /* @__PURE__ */ Object.freeze({
|
|
139
|
+
METADATA,
|
|
140
|
+
TEXT_NOTE,
|
|
141
|
+
RECOMMEND_RELAY,
|
|
142
|
+
FOLLOWS,
|
|
143
|
+
ENCRYPTED_DIRECT_MESSAGE,
|
|
144
|
+
DELETION,
|
|
145
|
+
REPOST,
|
|
146
|
+
REACTION,
|
|
147
|
+
BADGE_AWARD,
|
|
148
|
+
SEAL,
|
|
149
|
+
PRIVATE_DIRECT_MESSAGE,
|
|
150
|
+
GENERIC_REPOST,
|
|
151
|
+
PICTURE,
|
|
152
|
+
VIDEO,
|
|
153
|
+
SHORT_VIDEO,
|
|
154
|
+
CHANNEL_CREATE,
|
|
155
|
+
CHANNEL_METADATA,
|
|
156
|
+
CHANNEL_MESSAGE,
|
|
157
|
+
CHANNEL_HIDE_MESSAGE,
|
|
158
|
+
CHANNEL_MUTE_USER,
|
|
159
|
+
REGULAR_CUSTOM_APP_DATA,
|
|
160
|
+
PERSONAL_COPY,
|
|
161
|
+
OPEN_TIMESTAMPS,
|
|
162
|
+
GIFT_WRAP,
|
|
163
|
+
FILE_METADATA,
|
|
164
|
+
COMMENT,
|
|
165
|
+
VOICE_MESSAGE,
|
|
166
|
+
VOICE_MESSAGE_REPLY,
|
|
167
|
+
LIVE_CHAT_MESSAGE,
|
|
168
|
+
PROBLEM_TRACKER,
|
|
169
|
+
REPORT,
|
|
170
|
+
LABEL,
|
|
171
|
+
PRIVATE_CHANNEL_BROADCAST,
|
|
172
|
+
COMMUNITY_POST_APPROVAL,
|
|
173
|
+
JOB_REQUEST,
|
|
174
|
+
JOB_RESULT,
|
|
175
|
+
JOB_FEEDBACK,
|
|
176
|
+
ZAP_GOAL,
|
|
177
|
+
ZAP_REQUEST,
|
|
178
|
+
ZAP,
|
|
179
|
+
HIGHLIGHTS,
|
|
180
|
+
MUTE_LIST,
|
|
181
|
+
PINNED_NOTES,
|
|
182
|
+
READ_WRITE_RELAYS,
|
|
183
|
+
BOOKMARKS,
|
|
184
|
+
COMMUNITIES,
|
|
185
|
+
PUBLIC_CHATS,
|
|
186
|
+
BLOCKED_RELAYS,
|
|
187
|
+
SEARCH_RELAYS,
|
|
188
|
+
SIMPLE_GROUPS,
|
|
189
|
+
RELAY_FEEDS,
|
|
190
|
+
INTERESTS,
|
|
191
|
+
MEDIA_FOLLOWS,
|
|
192
|
+
EMOJIS,
|
|
193
|
+
DM_RELAYS,
|
|
194
|
+
FILE_SERVER_PREFERENCE,
|
|
195
|
+
GOOD_WIKI_AUTHORS,
|
|
196
|
+
GOOD_WIKI_RELAYS,
|
|
197
|
+
NWC_WALLET_INFO,
|
|
198
|
+
LIGHTNING_PUB_RPC,
|
|
199
|
+
AUTH,
|
|
200
|
+
NWC_WALLET_REQUEST,
|
|
201
|
+
NWC_WALLET_RESPONSE,
|
|
202
|
+
SIGNER_RPC,
|
|
203
|
+
HTTP_AUTH,
|
|
204
|
+
NWT,
|
|
205
|
+
FOLLOW_SET,
|
|
206
|
+
LIST,
|
|
207
|
+
RELAY_SET,
|
|
208
|
+
BOOKMARK_SET,
|
|
209
|
+
CURATION_SET,
|
|
210
|
+
VIDEO_CURATION_SET,
|
|
211
|
+
PICTURE_CURATION_SET,
|
|
212
|
+
KIND_MUTE_SET,
|
|
213
|
+
PROFILE_BADGES,
|
|
214
|
+
BADGE_DEFINITION,
|
|
215
|
+
INTEREST_SET,
|
|
216
|
+
CREATE_OR_UPDATE_STALL,
|
|
217
|
+
CREATE_OR_UPDATE_PRODUCT,
|
|
218
|
+
LONG_FORM_CONTENT,
|
|
219
|
+
DRAFT_LONG,
|
|
220
|
+
EMOJI_SET,
|
|
221
|
+
RELEASE_ARTIFACT_SET,
|
|
222
|
+
CUSTOM_APP_DATA,
|
|
223
|
+
APP_CURATION_SET,
|
|
224
|
+
LIVE_EVENT,
|
|
225
|
+
USER_STATUSES,
|
|
226
|
+
I_TAG_TRUSTED_ASSERTION,
|
|
227
|
+
CLASSIFIED_LISTING,
|
|
228
|
+
DRAFT_CLASSIFIED_LISTING,
|
|
229
|
+
DATE_BASED_CALENDAR_EVENT,
|
|
230
|
+
TIME_BASED_CALENDAR_EVENT,
|
|
231
|
+
CALENDAR,
|
|
232
|
+
CALENDAR_EVENT_RSVP,
|
|
233
|
+
HANDLER_RECOMMENDATION,
|
|
234
|
+
HANDLER_INFORMATION,
|
|
235
|
+
EDITABLE_VIDEO,
|
|
236
|
+
EDITABLE_SHORT_VIDEO,
|
|
237
|
+
COMMUNITY_DEFINITION,
|
|
238
|
+
BINARY_DATA_CHUNK,
|
|
239
|
+
MAIN_SITE_MANIFEST,
|
|
240
|
+
NEXT_SITE_MANIFEST,
|
|
241
|
+
DRAFT_SITE_MANIFEST,
|
|
242
|
+
STARTER_PACK,
|
|
243
|
+
MEDIA_STARTER_PACK
|
|
244
|
+
})
|
package/nip04/index.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { cbc } from '@noble/ciphers/aes.js'
|
|
2
|
+
import { randomBytes } from '@noble/ciphers/utils.js'
|
|
3
|
+
|
|
4
|
+
import { base16ToBytes } from '../base16/index.js'
|
|
5
|
+
import { base64ToBytes, bytesToBase64 } from '../base64/index.js'
|
|
6
|
+
import { sharedXOnlySecret } from '../ecdh/index.js'
|
|
7
|
+
import { ValidationError } from '../error/index.js'
|
|
8
|
+
|
|
9
|
+
const encoder = new TextEncoder()
|
|
10
|
+
const decoder = new TextDecoder('utf-8', { fatal: true })
|
|
11
|
+
|
|
12
|
+
function decodeBase64 (value) {
|
|
13
|
+
if (typeof value !== 'string' || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
|
14
|
+
throw new ValidationError('INVALID_BASE64')
|
|
15
|
+
}
|
|
16
|
+
const bytes = base64ToBytes(value)
|
|
17
|
+
if (bytesToBase64(bytes) !== value) throw new ValidationError('NON_CANONICAL_BASE64')
|
|
18
|
+
return bytes
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function conversationKey (secretKey, pubkey) {
|
|
22
|
+
if (!(secretKey instanceof Uint8Array) || secretKey.length !== 32) throw new ValidationError('INVALID_SECRET_KEY')
|
|
23
|
+
if (typeof pubkey !== 'string' || !/^[0-9a-f]{64}$/.test(pubkey)) throw new ValidationError('INVALID_PUBLIC_KEY')
|
|
24
|
+
return sharedXOnlySecret(secretKey, pubkey)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function encrypt (secretKey, pubkey, plaintext) {
|
|
28
|
+
if (typeof plaintext !== 'string') throw new ValidationError('PLAINTEXT_SHOULD_BE_A_STRING')
|
|
29
|
+
const iv = randomBytes(16)
|
|
30
|
+
const ciphertext = cbc(conversationKey(secretKey, pubkey), iv).encrypt(encoder.encode(plaintext))
|
|
31
|
+
return `${bytesToBase64(ciphertext)}?iv=${bytesToBase64(iv)}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function decrypt (secretKey, pubkey, payload) {
|
|
35
|
+
if (typeof payload !== 'string') throw new ValidationError('CIPHERTEXT_SHOULD_BE_A_STRING')
|
|
36
|
+
const parts = payload.split('?iv=')
|
|
37
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) throw new ValidationError('INVALID_NIP04_ENVELOPE')
|
|
38
|
+
const ciphertext = decodeBase64(parts[0])
|
|
39
|
+
const iv = decodeBase64(parts[1])
|
|
40
|
+
if (iv.length !== 16 || ciphertext.length === 0 || ciphertext.length % 16 !== 0) throw new ValidationError('INVALID_NIP04_ENVELOPE')
|
|
41
|
+
try {
|
|
42
|
+
return decoder.decode(cbc(conversationKey(secretKey, pubkey), iv).decrypt(ciphertext))
|
|
43
|
+
} catch (cause) {
|
|
44
|
+
if (cause instanceof ValidationError && cause.code !== 'INVALID_NIP04_CIPHERTEXT') throw cause
|
|
45
|
+
throw new ValidationError('INVALID_NIP04_CIPHERTEXT', { cause })
|
|
46
|
+
}
|
|
47
|
+
}
|
package/nip05/index.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { normalizeRelayUrl } from '../url/index.js'
|
|
2
|
+
|
|
3
|
+
const PUBKEY = /^[0-9a-f]{64}$/
|
|
4
|
+
const LOCAL_PART = /^[a-z0-9._-]+$/
|
|
5
|
+
|
|
6
|
+
export async function queryProfile (identifier, {
|
|
7
|
+
fetch: fetchImpl = globalThis.fetch,
|
|
8
|
+
signal,
|
|
9
|
+
timeoutMs = 5000
|
|
10
|
+
} = {}) {
|
|
11
|
+
if (typeof identifier !== 'string' || typeof fetchImpl !== 'function') return null
|
|
12
|
+
const normalized = identifier.trim().toLowerCase()
|
|
13
|
+
const separator = normalized.lastIndexOf('@')
|
|
14
|
+
if (separator <= 0 || separator === normalized.length - 1) return null
|
|
15
|
+
const name = normalized.slice(0, separator)
|
|
16
|
+
const domain = normalized.slice(separator + 1)
|
|
17
|
+
if (!LOCAL_PART.test(name) || /[/?#@]/.test(domain)) return null
|
|
18
|
+
|
|
19
|
+
let url
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(`https://${domain}/.well-known/nostr.json`)
|
|
22
|
+
if (url.hostname !== domain.split(':')[0] || url.username || url.password) return null
|
|
23
|
+
url.searchParams.set('name', name)
|
|
24
|
+
} catch {
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const controller = new AbortController()
|
|
29
|
+
const onAbort = () => controller.abort(signal.reason)
|
|
30
|
+
if (signal?.aborted) throw signal.reason ?? new DOMException('This operation was aborted', 'AbortError')
|
|
31
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
32
|
+
const timeout = Number.isFinite(timeoutMs) && timeoutMs >= 0
|
|
33
|
+
? setTimeout(() => controller.abort(new DOMException('NIP-05 request timed out', 'TimeoutError')), timeoutMs)
|
|
34
|
+
: null
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetchImpl(url, {
|
|
38
|
+
headers: { Accept: 'application/json' },
|
|
39
|
+
redirect: 'error',
|
|
40
|
+
signal: controller.signal
|
|
41
|
+
})
|
|
42
|
+
if (!response.ok) return null
|
|
43
|
+
const data = await response.json()
|
|
44
|
+
const pubkey = data?.names?.[name]
|
|
45
|
+
if (!PUBKEY.test(pubkey)) return null
|
|
46
|
+
const relays = []
|
|
47
|
+
for (const relay of data?.relays?.[pubkey] ?? []) {
|
|
48
|
+
try {
|
|
49
|
+
const normalizedRelay = normalizeRelayUrl(relay)
|
|
50
|
+
if (!relays.includes(normalizedRelay)) relays.push(normalizedRelay)
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
return { pubkey, relays }
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (signal?.aborted) throw signal.reason ?? error
|
|
56
|
+
return null
|
|
57
|
+
} finally {
|
|
58
|
+
if (timeout !== null) clearTimeout(timeout)
|
|
59
|
+
signal?.removeEventListener('abort', onAbort)
|
|
60
|
+
}
|
|
61
|
+
}
|