libp2r2p 0.10.6 → 0.10.7
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 +15 -0
- package/index.js +1 -0
- package/kind/index.js +2 -0
- package/nip05/helpers/nip05-identifier.js +57 -0
- package/nip05/index.js +6 -8
- package/nip27/helpers/user-reference.js +79 -0
- package/nip27/index.js +360 -0
- package/package.json +3 -1
- package/url/app-url.js +143 -0
- package/url/index.js +2 -0
package/README.md
CHANGED
|
@@ -329,6 +329,21 @@ the Schnorr signature on every call; it never adds a cache marker to the
|
|
|
329
329
|
event. Their `assert…` counterparts return the original event or throw a
|
|
330
330
|
`ValidationError` with a stable code.
|
|
331
331
|
|
|
332
|
+
NIP-27 text references live in `libp2r2p/nip27`. `extractMedia()` splits
|
|
333
|
+
content into text, URL, profile, event, relay, NIP-05 and hashtag items in
|
|
334
|
+
occurrence order, accepting the optional `@` and `nostr:` mention prefixes
|
|
335
|
+
plus NIP-05 in its standard, root and custom compact spellings.
|
|
336
|
+
`decodeReference()` parses a single reference, and `decodeMediaMetadata()`
|
|
337
|
+
reads the file/media metadata carried in a URL fragment
|
|
338
|
+
(`#m=image/png&dim=640x480&...`).
|
|
339
|
+
|
|
340
|
+
User references (`npub`, `nprofile`, hex pubkeys and every NIP-05 spelling)
|
|
341
|
+
are handled by `libp2r2p/nip27`: `decodeUserReference()` returns the decoded
|
|
342
|
+
form with its canonical compact spelling, `encodeUserReference()` returns
|
|
343
|
+
that canonical spelling, and `resolveUserReference()` resolves it to a
|
|
344
|
+
pubkey. `libp2r2p/nip05` keeps only `queryProfile()`, the NIP-05 lookup, which
|
|
345
|
+
accepts the compact custom forms directly.
|
|
346
|
+
|
|
332
347
|
Public validity checks consistently use a non-throwing `is…` predicate plus an
|
|
333
348
|
`assert…` counterpart when callers need the exact reason. Strict codecs,
|
|
334
349
|
decoders, token validation, and malformed public arguments also throw
|
package/index.js
CHANGED
|
@@ -18,6 +18,7 @@ export * as network from './network/index.js'
|
|
|
18
18
|
export * as nip04 from './nip04/index.js'
|
|
19
19
|
export * as nip05 from './nip05/index.js'
|
|
20
20
|
export * as nip19 from './nip19/index.js'
|
|
21
|
+
export * as nip27 from './nip27/index.js'
|
|
21
22
|
export * as nip44 from './nip44/index.js'
|
|
22
23
|
export * as nip44v3 from './nip44-v3/index.js'
|
|
23
24
|
export * as nip46 from './nip46/index.js'
|
package/kind/index.js
CHANGED
|
@@ -88,6 +88,7 @@ export const USER_STATUSES = 30315
|
|
|
88
88
|
export const I_TAG_TRUSTED_ASSERTION = 30385
|
|
89
89
|
export const CLASSIFIED_LISTING = 30402
|
|
90
90
|
export const DRAFT_CLASSIFIED_LISTING = 30403
|
|
91
|
+
export const SITE_CURATION_SET = 30499
|
|
91
92
|
export const DATE_BASED_CALENDAR_EVENT = 31922
|
|
92
93
|
export const TIME_BASED_CALENDAR_EVENT = 31923
|
|
93
94
|
export const CALENDAR = 31924
|
|
@@ -226,6 +227,7 @@ export const eventKinds = /* @__PURE__ */ Object.freeze({
|
|
|
226
227
|
I_TAG_TRUSTED_ASSERTION,
|
|
227
228
|
CLASSIFIED_LISTING,
|
|
228
229
|
DRAFT_CLASSIFIED_LISTING,
|
|
230
|
+
SITE_CURATION_SET,
|
|
229
231
|
DATE_BASED_CALENDAR_EVENT,
|
|
230
232
|
TIME_BASED_CALENDAR_EVENT,
|
|
231
233
|
CALENDAR,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const NIP05_LOCAL = /^[a-z0-9._-]+$/
|
|
2
|
+
const NIP05_DOMAIN = /^[a-z0-9.-]+$/
|
|
3
|
+
|
|
4
|
+
export function isValidNip05Local (local) {
|
|
5
|
+
return typeof local === 'string' &&
|
|
6
|
+
(local === '_' || (local.length <= 64 && NIP05_LOCAL.test(local)))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isValidNip05Domain (domain) {
|
|
10
|
+
return typeof domain === 'string' &&
|
|
11
|
+
domain.length > 0 &&
|
|
12
|
+
domain.length <= 253 &&
|
|
13
|
+
domain.includes('.') &&
|
|
14
|
+
!domain.startsWith('.') &&
|
|
15
|
+
!domain.endsWith('.') &&
|
|
16
|
+
!domain.includes('..') &&
|
|
17
|
+
NIP05_DOMAIN.test(domain)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function nip05FromLocalDomain (local, domain) {
|
|
21
|
+
if (!isValidNip05Local(local) || !isValidNip05Domain(domain)) return null
|
|
22
|
+
return { local, domain }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Accepts:
|
|
26
|
+
// - `local@domain` (standard NIP-05)
|
|
27
|
+
// - `domain` with exactly one dot -> root `_@domain`
|
|
28
|
+
// - `local.domain...` with more than one dot -> local part + domain (custom extension)
|
|
29
|
+
export function decodeNip05Identifier (value) {
|
|
30
|
+
if (typeof value !== 'string') return null
|
|
31
|
+
const text = value.trim().toLowerCase()
|
|
32
|
+
if (!text) return null
|
|
33
|
+
|
|
34
|
+
const at = text.lastIndexOf('@')
|
|
35
|
+
if (at !== -1) {
|
|
36
|
+
if (at === 0 || at === text.length - 1 || text.includes('@', at + 1)) return null
|
|
37
|
+
return nip05FromLocalDomain(text.slice(0, at), text.slice(at + 1))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!text.includes('.')) return null
|
|
41
|
+
const firstDot = text.indexOf('.')
|
|
42
|
+
if (text.slice(firstDot + 1).includes('.')) {
|
|
43
|
+
return nip05FromLocalDomain(text.slice(0, firstDot), text.slice(firstDot + 1))
|
|
44
|
+
}
|
|
45
|
+
return nip05FromLocalDomain('_', text)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Returns the most compact unambiguous NIP-05 spelling:
|
|
49
|
+
// - root (`_@domain`) becomes `domain` only when the domain has one dot;
|
|
50
|
+
// - a non-root local part becomes `local.domain` unless the local part
|
|
51
|
+
// itself contains dots, which would make the compact form ambiguous.
|
|
52
|
+
export function compactNip05Raw (local, domain) {
|
|
53
|
+
if (local === '_') {
|
|
54
|
+
return domain.split('.').length === 2 ? domain : `_@${domain}`
|
|
55
|
+
}
|
|
56
|
+
return local.includes('.') ? `${local}@${domain}` : `${local}.${domain}`
|
|
57
|
+
}
|
package/nip05/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { normalizeRelayUrl } from '../url/index.js'
|
|
2
|
+
import { decodeNip05Identifier } from './helpers/nip05-identifier.js'
|
|
2
3
|
|
|
3
4
|
const PUBKEY = /^[0-9a-f]{64}$/
|
|
4
|
-
const LOCAL_PART = /^[a-z0-9._-]+$/
|
|
5
5
|
|
|
6
6
|
export async function queryProfile (identifier, {
|
|
7
7
|
fetch: fetchImpl = globalThis.fetch,
|
|
@@ -9,17 +9,15 @@ export async function queryProfile (identifier, {
|
|
|
9
9
|
timeoutMs = 5000
|
|
10
10
|
} = {}) {
|
|
11
11
|
if (typeof identifier !== 'string' || typeof fetchImpl !== 'function') return null
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const domain = normalized.slice(separator + 1)
|
|
17
|
-
if (!LOCAL_PART.test(name) || /[/?#@]/.test(domain)) return null
|
|
12
|
+
const nip05 = decodeNip05Identifier(identifier)
|
|
13
|
+
if (!nip05) return null
|
|
14
|
+
const name = nip05.local
|
|
15
|
+
const domain = nip05.domain
|
|
18
16
|
|
|
19
17
|
let url
|
|
20
18
|
try {
|
|
21
19
|
url = new URL(`https://${domain}/.well-known/nostr.json`)
|
|
22
|
-
if (url.hostname !== domain
|
|
20
|
+
if (url.hostname !== domain || url.username || url.password) return null
|
|
23
21
|
url.searchParams.set('name', name)
|
|
24
22
|
} catch {
|
|
25
23
|
return null
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ValidationError } from '../../error/index.js'
|
|
2
|
+
import { npubDecode, nprofileDecode } from '../../nip19/index.js'
|
|
3
|
+
import { compactNip05Raw, decodeNip05Identifier } from '../../nip05/helpers/nip05-identifier.js'
|
|
4
|
+
|
|
5
|
+
const HEX_PUBKEY = /^[0-9a-f]{64}$/
|
|
6
|
+
|
|
7
|
+
// Strips the optional mention prefixes accepted by NIP-21 (`nostr:`) and the
|
|
8
|
+
// social `@` handle marker. Either prefix may appear first.
|
|
9
|
+
function stripReferencePrefix (value) {
|
|
10
|
+
let text = value.trim()
|
|
11
|
+
let changed = true
|
|
12
|
+
while (changed && text) {
|
|
13
|
+
changed = false
|
|
14
|
+
if (/^nostr:/i.test(text)) {
|
|
15
|
+
text = text.slice(6)
|
|
16
|
+
changed = true
|
|
17
|
+
}
|
|
18
|
+
if (text.startsWith('@')) {
|
|
19
|
+
text = text.slice(1)
|
|
20
|
+
changed = true
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return text
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Decodes a user reference without performing any network lookup.
|
|
27
|
+
// Returns `{ kind: 'pubkey', pubkey, relays, raw }` for npub/nprofile/hex or
|
|
28
|
+
// `{ kind: 'nip05', local, domain, raw }` for NIP-05 (standard or extended),
|
|
29
|
+
// where `raw` is always the most compact canonical spelling.
|
|
30
|
+
export function decodeUserReference (value) {
|
|
31
|
+
if (typeof value !== 'string') return null
|
|
32
|
+
const text = stripReferencePrefix(value)
|
|
33
|
+
if (!text) return null
|
|
34
|
+
|
|
35
|
+
if (HEX_PUBKEY.test(text)) {
|
|
36
|
+
const raw = text.toLowerCase()
|
|
37
|
+
return { kind: 'pubkey', pubkey: raw, relays: [], raw }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (text.toLowerCase().startsWith('npub1')) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = text.toLowerCase()
|
|
43
|
+
return { kind: 'pubkey', pubkey: npubDecode(raw), relays: [], raw }
|
|
44
|
+
} catch {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (text.toLowerCase().startsWith('nprofile1')) {
|
|
50
|
+
try {
|
|
51
|
+
const raw = text.toLowerCase()
|
|
52
|
+
const { pubkey, relays } = nprofileDecode(raw)
|
|
53
|
+
return { kind: 'pubkey', pubkey, relays, raw }
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const nip05 = decodeNip05Identifier(text)
|
|
60
|
+
if (!nip05) return null
|
|
61
|
+
const raw = compactNip05Raw(nip05.local, nip05.domain)
|
|
62
|
+
return { kind: 'nip05', ...nip05, raw }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Returns the canonical compact spelling for a user reference, either as a
|
|
66
|
+
// string or as a decoded reference object.
|
|
67
|
+
export function encodeUserReference (value) {
|
|
68
|
+
const ref = typeof value === 'string'
|
|
69
|
+
? decodeUserReference(value)
|
|
70
|
+
: value && typeof value === 'object' &&
|
|
71
|
+
(value.kind === 'pubkey' || value.kind === 'nip05')
|
|
72
|
+
? value
|
|
73
|
+
: null
|
|
74
|
+
if (!ref) {
|
|
75
|
+
throw new ValidationError('INVALID_USER_REFERENCE', { message: 'Invalid user reference' })
|
|
76
|
+
}
|
|
77
|
+
if (ref.kind === 'pubkey') return ref.raw ?? ref.pubkey
|
|
78
|
+
return ref.raw ?? compactNip05Raw(ref.local, ref.domain)
|
|
79
|
+
}
|
package/nip27/index.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import {
|
|
2
|
+
naddrDecode,
|
|
3
|
+
neventDecode,
|
|
4
|
+
noteDecode,
|
|
5
|
+
nrelayDecode
|
|
6
|
+
} from '../nip19/index.js'
|
|
7
|
+
import { queryProfile } from '../nip05/index.js'
|
|
8
|
+
import { normalizeRelayUrl } from '../url/index.js'
|
|
9
|
+
import {
|
|
10
|
+
decodeUserReference,
|
|
11
|
+
encodeUserReference
|
|
12
|
+
} from './helpers/user-reference.js'
|
|
13
|
+
|
|
14
|
+
export { decodeUserReference, encodeUserReference }
|
|
15
|
+
|
|
16
|
+
const BECH32_BODY = '[ac-hj-np-z02-9]'
|
|
17
|
+
const BOUNDARY_PREFIX = /(?<=^|[\s"«„「¡¿:{([])/.source
|
|
18
|
+
const BOUNDARY_SUFFIX = /(?=\.?$|[.,]?\s|\.(?=\.)|\.?["»”」'!?;\])}])/.source
|
|
19
|
+
|
|
20
|
+
const URL_SOURCE =
|
|
21
|
+
'(?<url>' +
|
|
22
|
+
/(?<protocol>https:\/\/)?(?:[-_A-Za-z0-9]{1,30}\.){1,4}[a-z]{2,63}/.source +
|
|
23
|
+
/(?:(?:\/[-._A-Za-z0-9%@#]{1,300}){0,11}(?<ext>\.[a-z-0-9]{3,4})|(?:\/[-._A-Za-z0-9%@#]{1,300}){0,11})\/?/.source +
|
|
24
|
+
/(?:\??&?(?:[-_+.A-Za-z0-9%*]{1,30}=[-_+.A-Za-z0-9%*]{1,300}&?){1,40}|\?)?/.source +
|
|
25
|
+
/(?:(?:#|(?<=#))[-_+.A-Za-z0-9%=&*:~,]{1,4000})?/.source +
|
|
26
|
+
/(?:\/|(?<![.,]))/.source +
|
|
27
|
+
')'
|
|
28
|
+
|
|
29
|
+
const NIP05_LOCAL = '[a-z0-9._-]{1,64}'
|
|
30
|
+
const NIP05_DOMAIN = '(?:[a-z0-9-]+\\.)+[a-z]{2,63}'
|
|
31
|
+
const NIP05_STANDARD = `(?:@)?(?<nip05>${NIP05_LOCAL}@${NIP05_DOMAIN})`
|
|
32
|
+
const NIP05_AT_ROOT = '@(?<nip05AtRoot>[a-z0-9-]+\\.[a-z]{2,63})'
|
|
33
|
+
const NIP05_AT_CUSTOM = `@(?<nip05AtCustom>${NIP05_LOCAL}\\.${NIP05_DOMAIN})`
|
|
34
|
+
const NIP05_BARE_ROOT = '(?<nip05BareRoot>[a-z0-9-]+\\.[a-z]{2,63})'
|
|
35
|
+
const NIP05_BARE_CUSTOM = `(?<nip05BareCustom>${NIP05_LOCAL}\\.${NIP05_DOMAIN})`
|
|
36
|
+
|
|
37
|
+
function entitySource (name) {
|
|
38
|
+
const bodyLength = name === 'npub' ? '58' : (name === 'nrelay' ? '10,5000' : '58,5000')
|
|
39
|
+
return `(?:@|nostr:)?(?<${name}>${name}1${BECH32_BODY}{${bodyLength}})`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ENTITY_SOURCES = [
|
|
43
|
+
entitySource('nrelay'),
|
|
44
|
+
entitySource('npub'),
|
|
45
|
+
entitySource('nprofile'),
|
|
46
|
+
entitySource('note'),
|
|
47
|
+
entitySource('nevent'),
|
|
48
|
+
entitySource('naddr')
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
const HASHTAG_SOURCE = /(?:#(?<hashtag>[^\s.!¡?¿@#$%^&*()=+/,[{\]};:'"><]+))/.source
|
|
52
|
+
|
|
53
|
+
let regexCache
|
|
54
|
+
function getReferencesRegex (bareNip05) {
|
|
55
|
+
const key = bareNip05 ? 'bare' : 'handle'
|
|
56
|
+
regexCache ??= {}
|
|
57
|
+
if (regexCache[key]) return regexCache[key]
|
|
58
|
+
|
|
59
|
+
const nip05Compact = [
|
|
60
|
+
NIP05_AT_ROOT,
|
|
61
|
+
NIP05_AT_CUSTOM,
|
|
62
|
+
...(bareNip05 ? [NIP05_BARE_ROOT, NIP05_BARE_CUSTOM] : [])
|
|
63
|
+
]
|
|
64
|
+
const alternatives = [
|
|
65
|
+
URL_SOURCE,
|
|
66
|
+
NIP05_STANDARD,
|
|
67
|
+
...nip05Compact,
|
|
68
|
+
...ENTITY_SOURCES,
|
|
69
|
+
HASHTAG_SOURCE
|
|
70
|
+
]
|
|
71
|
+
// Bare compact NIP-05 forms are ambiguous with plain hostnames/URLs, so
|
|
72
|
+
// they are placed first only when explicitly requested.
|
|
73
|
+
if (bareNip05) {
|
|
74
|
+
alternatives.splice(0, 0, NIP05_BARE_ROOT, NIP05_BARE_CUSTOM)
|
|
75
|
+
}
|
|
76
|
+
const source = BOUNDARY_PREFIX + '(?:' + alternatives.join('|') + ')' + BOUNDARY_SUFFIX
|
|
77
|
+
return (regexCache[key] = new RegExp(source, 'gu'))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Strips the optional NIP-21 (`nostr:`) and `@` mention prefixes.
|
|
81
|
+
function stripReferencePrefix (value) {
|
|
82
|
+
let text = value.trim()
|
|
83
|
+
let changed = true
|
|
84
|
+
while (changed && text) {
|
|
85
|
+
changed = false
|
|
86
|
+
if (/^nostr:/i.test(text)) {
|
|
87
|
+
text = text.slice(6)
|
|
88
|
+
changed = true
|
|
89
|
+
}
|
|
90
|
+
if (text.startsWith('@')) {
|
|
91
|
+
text = text.slice(1)
|
|
92
|
+
changed = true
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return text
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeRelays (relays) {
|
|
99
|
+
return (relays || [])
|
|
100
|
+
.map(relay => {
|
|
101
|
+
try {
|
|
102
|
+
return normalizeRelayUrl(relay)
|
|
103
|
+
} catch {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Parses a single NIP-27-style reference (with optional `@`/`nostr:`
|
|
111
|
+
// prefixes): NIP-05 (including the custom compact forms), npub/nprofile/hex
|
|
112
|
+
// accounts, note/nevent/naddr events and nrelay relays.
|
|
113
|
+
export function decodeReference (value) {
|
|
114
|
+
if (typeof value !== 'string') return null
|
|
115
|
+
const original = value.trim()
|
|
116
|
+
if (!original) return null
|
|
117
|
+
const text = stripReferencePrefix(original)
|
|
118
|
+
if (!text) return null
|
|
119
|
+
|
|
120
|
+
const account = decodeUserReference(text)
|
|
121
|
+
if (account) {
|
|
122
|
+
return account.kind === 'pubkey'
|
|
123
|
+
? {
|
|
124
|
+
type: 'pubkey',
|
|
125
|
+
original,
|
|
126
|
+
value: account.raw,
|
|
127
|
+
pubkey: account.pubkey,
|
|
128
|
+
relays: account.relays
|
|
129
|
+
}
|
|
130
|
+
: {
|
|
131
|
+
type: 'nip05',
|
|
132
|
+
original,
|
|
133
|
+
value: account.raw,
|
|
134
|
+
local: account.local,
|
|
135
|
+
domain: account.domain
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (text.startsWith('note1')) {
|
|
140
|
+
try {
|
|
141
|
+
return { type: 'note', original, value: text, id: noteDecode(text) }
|
|
142
|
+
} catch {
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (text.startsWith('nevent1')) {
|
|
147
|
+
try {
|
|
148
|
+
return { type: 'nevent', original, value: text, ...neventDecode(text) }
|
|
149
|
+
} catch {
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (text.startsWith('naddr1')) {
|
|
154
|
+
try {
|
|
155
|
+
return { type: 'naddr', original, value: text, ...naddrDecode(text) }
|
|
156
|
+
} catch {
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (text.startsWith('nrelay1')) {
|
|
161
|
+
try {
|
|
162
|
+
return { type: 'nrelay', original, value: text, relay: nrelayDecode(text) }
|
|
163
|
+
} catch {
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const NIP94_TAGS = {
|
|
171
|
+
url: [{ key: 'url', type: 'array' }],
|
|
172
|
+
'aes-256-gcm': ['key', 'iv'],
|
|
173
|
+
m: ['m'],
|
|
174
|
+
x: [{ key: 'x', type: 'array' }],
|
|
175
|
+
ox: ['ox'],
|
|
176
|
+
size: ['size'],
|
|
177
|
+
dim: ['dim'],
|
|
178
|
+
magnet: ['magnet'],
|
|
179
|
+
i: ['i'],
|
|
180
|
+
blurhash: ['blurhash'],
|
|
181
|
+
thumb: ['thumb'],
|
|
182
|
+
image: ['image'],
|
|
183
|
+
summary: ['summary'],
|
|
184
|
+
alt: ['alt'],
|
|
185
|
+
caption: ['caption']
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Decodes the file/media metadata carried in a URL fragment
|
|
189
|
+
// (`#m=image/png&dim=640x480&alt=...`). Kept generic on purpose: the old
|
|
190
|
+
// draft number (54) was taken by an unrelated NIP.
|
|
191
|
+
export function decodeMediaMetadata (url, { extraTags } = {}) {
|
|
192
|
+
if (typeof url !== 'string' || !url) return {}
|
|
193
|
+
|
|
194
|
+
const tags = extraTags ? { ...NIP94_TAGS, ...extraTags } : NIP94_TAGS
|
|
195
|
+
const tagIndexes = {}
|
|
196
|
+
const obj = (url.match(/(?<=#)[-_+.A-Za-z0-9%=&*]{1,4000}/)?.[0] || '')
|
|
197
|
+
.split('&')
|
|
198
|
+
.filter(Boolean)
|
|
199
|
+
.reduce((memo, item) => {
|
|
200
|
+
let [key, value = ''] = item.split('=')
|
|
201
|
+
key = decodeFragmentValue(key)
|
|
202
|
+
value = decodeFragmentValue(value)
|
|
203
|
+
const config = tags[key]
|
|
204
|
+
if (!config || (tagIndexes[key] ??= 0) === config.length) return memo
|
|
205
|
+
|
|
206
|
+
const name = config[tagIndexes[key]]?.key ?? config[tagIndexes[key]]
|
|
207
|
+
const type = config[tagIndexes[key]]?.type ?? 'string'
|
|
208
|
+
switch (type) {
|
|
209
|
+
case 'array': memo[name] ??= []; memo[name].push(value); break
|
|
210
|
+
case 'string': memo[name] = value; tagIndexes[key]++; break
|
|
211
|
+
default: break
|
|
212
|
+
}
|
|
213
|
+
return memo
|
|
214
|
+
}, {})
|
|
215
|
+
|
|
216
|
+
if ('dim' in obj) {
|
|
217
|
+
const { width, height } = obj.dim.match(
|
|
218
|
+
/(?<width>[1-9]{1}[0-9]{0,10})(?:\s*[xX]\s*)(?<height>[1-9]{1}[0-9]{0,10})/
|
|
219
|
+
)?.groups ?? {}
|
|
220
|
+
if (width !== undefined) obj.width = width
|
|
221
|
+
if (height !== undefined) obj.height = height
|
|
222
|
+
}
|
|
223
|
+
return obj
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function decodeFragmentValue (value) {
|
|
227
|
+
try {
|
|
228
|
+
return decodeURIComponent(value.replace(/\+/g, '%20'))
|
|
229
|
+
} catch {
|
|
230
|
+
return value
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function getReferenceItem (original, groups, { getMimeType }) {
|
|
235
|
+
if (groups.url) {
|
|
236
|
+
const url = `${groups.protocol ? '' : 'https://'}${groups.url}`
|
|
237
|
+
const urlItem = { value: url, ...(groups.ext && { ext: groups.ext }), ...decodeMediaMetadata(url) }
|
|
238
|
+
if (!urlItem.m && typeof getMimeType === 'function') {
|
|
239
|
+
const mime = getMimeType({ url, ext: groups.ext })
|
|
240
|
+
if (mime) urlItem.m = mime
|
|
241
|
+
}
|
|
242
|
+
return { key: 'url', url: urlItem }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (
|
|
246
|
+
groups.nip05 ||
|
|
247
|
+
groups.nip05AtRoot ||
|
|
248
|
+
groups.nip05AtCustom ||
|
|
249
|
+
groups.nip05BareRoot ||
|
|
250
|
+
groups.nip05BareCustom
|
|
251
|
+
) {
|
|
252
|
+
const account = decodeUserReference(original)
|
|
253
|
+
if (!account) return null
|
|
254
|
+
return {
|
|
255
|
+
key: 'nip05',
|
|
256
|
+
nip05: {
|
|
257
|
+
original,
|
|
258
|
+
value: account.raw,
|
|
259
|
+
local: account.local,
|
|
260
|
+
domain: account.domain
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (groups.hashtag) {
|
|
266
|
+
return { key: 'hashtag', hashtag: { value: groups.hashtag } }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const ref = decodeReference(original)
|
|
270
|
+
if (!ref) return null
|
|
271
|
+
switch (ref.type) {
|
|
272
|
+
case 'pubkey': {
|
|
273
|
+
const isNprofile = ref.value.startsWith('nprofile1')
|
|
274
|
+
const profile = {
|
|
275
|
+
pubkey: ref.pubkey,
|
|
276
|
+
relays: normalizeRelays(ref.relays),
|
|
277
|
+
original,
|
|
278
|
+
nip19Type: isNprofile ? 'nprofile' : 'npub'
|
|
279
|
+
}
|
|
280
|
+
if (!isNprofile) profile.npub = ref.value
|
|
281
|
+
return { key: 'profile', profile }
|
|
282
|
+
}
|
|
283
|
+
case 'note':
|
|
284
|
+
return { key: 'event', event: { id: ref.id, relays: [], original, nip19Type: 'note' } }
|
|
285
|
+
case 'nevent':
|
|
286
|
+
return {
|
|
287
|
+
key: 'event',
|
|
288
|
+
event: {
|
|
289
|
+
id: ref.id,
|
|
290
|
+
relays: normalizeRelays(ref.relays),
|
|
291
|
+
original,
|
|
292
|
+
nip19Type: 'nevent',
|
|
293
|
+
...(ref.author !== undefined && { author: ref.author }),
|
|
294
|
+
...(ref.kind !== undefined && { kind: ref.kind })
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
case 'naddr':
|
|
298
|
+
return {
|
|
299
|
+
key: 'event',
|
|
300
|
+
event: {
|
|
301
|
+
identifier: ref.identifier,
|
|
302
|
+
pubkey: ref.pubkey,
|
|
303
|
+
kind: ref.kind,
|
|
304
|
+
relays: normalizeRelays(ref.relays),
|
|
305
|
+
original,
|
|
306
|
+
nip19Type: 'naddr'
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
case 'nrelay':
|
|
310
|
+
return { key: 'relay', relay: { relay: ref.relay, original, value: ref.value } }
|
|
311
|
+
default:
|
|
312
|
+
return null
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Modernized NIP-27 text-reference extractor. Accepts optional `@` and
|
|
317
|
+
// `nostr:` mention prefixes, NIP-05 (standard, root and custom compact) and
|
|
318
|
+
// keeps the same item shape as the previous text extractor for URLs/entities.
|
|
319
|
+
// Bare compact NIP-05 spellings (`bob.example.com`) are only recognized when
|
|
320
|
+
// `{ bareNip05: true }` is passed, since they are otherwise indistinguishable
|
|
321
|
+
// from plain hostnames; prefixed forms (`@bob.example.com`) always work.
|
|
322
|
+
export function extractMedia (content, { bareNip05 = false, getMimeType } = {}) {
|
|
323
|
+
if (typeof content !== 'string') return []
|
|
324
|
+
const regex = getReferencesRegex(bareNip05)
|
|
325
|
+
const items = []
|
|
326
|
+
let end = 0
|
|
327
|
+
|
|
328
|
+
for (const match of content.matchAll(regex)) {
|
|
329
|
+
const start = match.index
|
|
330
|
+
if (start > end) {
|
|
331
|
+
items.push({ key: 'text', text: { value: content.slice(end, start) } })
|
|
332
|
+
}
|
|
333
|
+
const original = match[0]
|
|
334
|
+
end = start + original.length
|
|
335
|
+
const item = getReferenceItem(original, match.groups, { getMimeType })
|
|
336
|
+
if (item) items.push(item)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (items.length === 0) {
|
|
340
|
+
items.push({ key: 'text', text: { value: content } })
|
|
341
|
+
} else if (end < content.length) {
|
|
342
|
+
items.push({ key: 'text', text: { value: content.slice(end) } })
|
|
343
|
+
}
|
|
344
|
+
return items
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Resolves a user reference to a pubkey and relay hints. NIP-05 lookups go
|
|
348
|
+
// through `queryProfile` (accepting standard, root and compact custom
|
|
349
|
+
// spellings); npub/nprofile/hex are resolved locally.
|
|
350
|
+
export async function resolveUserReference (value, options = {}) {
|
|
351
|
+
const account = decodeUserReference(value)
|
|
352
|
+
if (!account) return null
|
|
353
|
+
if (account.kind === 'pubkey') {
|
|
354
|
+
return { pubkey: account.pubkey, relays: account.relays, label: account.raw }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const result = await queryProfile(`${account.local}@${account.domain}`, options)
|
|
358
|
+
if (!result) return null
|
|
359
|
+
return { pubkey: result.pubkey, relays: result.relays, label: account.raw }
|
|
360
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "libp2r2p",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.7",
|
|
4
4
|
"description": "Peer-to-relay-to-peer",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"p2r2p",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"nip04",
|
|
32
32
|
"nip05",
|
|
33
33
|
"nip19",
|
|
34
|
+
"nip27",
|
|
34
35
|
"nip44",
|
|
35
36
|
"nip44-v3",
|
|
36
37
|
"nip46",
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
"./nip04": "./nip04/index.js",
|
|
72
73
|
"./nip05": "./nip05/index.js",
|
|
73
74
|
"./nip19": "./nip19/index.js",
|
|
75
|
+
"./nip27": "./nip27/index.js",
|
|
74
76
|
"./nip44": "./nip44/index.js",
|
|
75
77
|
"./nip44-v3": "./nip44-v3/index.js",
|
|
76
78
|
"./nip46": "./nip46/index.js",
|
package/url/app-url.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { ValidationError } from '../error/index.js'
|
|
2
|
+
import {
|
|
3
|
+
compactNip05Raw,
|
|
4
|
+
nip05FromLocalDomain
|
|
5
|
+
} from '../nip05/helpers/nip05-identifier.js'
|
|
6
|
+
import {
|
|
7
|
+
decodeUserReference,
|
|
8
|
+
encodeUserReference
|
|
9
|
+
} from '../nip27/helpers/user-reference.js'
|
|
10
|
+
import {
|
|
11
|
+
NAPP_ENTITY_REGEX,
|
|
12
|
+
appDecode
|
|
13
|
+
} from '../nip19/index.js'
|
|
14
|
+
|
|
15
|
+
export const APP_URL_MIN_ENTITY_BODY_LENGTH = 48
|
|
16
|
+
|
|
17
|
+
const APP_NAME_MAX_LENGTH = 260
|
|
18
|
+
const CHANNEL_BY_PREFIX = { '+': 'main', '++': 'next', '+++': 'draft' }
|
|
19
|
+
const PREFIX_BY_CHANNEL = { main: '+', next: '++', draft: '+++' }
|
|
20
|
+
const KIND_BY_CHANNEL = { main: 35128, next: 35129, draft: 35130 }
|
|
21
|
+
// eslint-disable-next-line no-control-regex
|
|
22
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/
|
|
23
|
+
|
|
24
|
+
function safeDecode (value) {
|
|
25
|
+
try {
|
|
26
|
+
return decodeURIComponent(value)
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isValidAppName (appName) {
|
|
33
|
+
return typeof appName === 'string' &&
|
|
34
|
+
appName.length > 0 &&
|
|
35
|
+
appName.length <= APP_NAME_MAX_LENGTH &&
|
|
36
|
+
!appName.startsWith('+') &&
|
|
37
|
+
!appName.includes('/') &&
|
|
38
|
+
!CONTROL_CHARS.test(appName)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Decodes a raw (still percent-encoded) first path segment such as
|
|
42
|
+
// `+apps` or `+caf%C3%A9@bob@example.com` or `+3swFhu...`.
|
|
43
|
+
// Returns:
|
|
44
|
+
// - `{ type: 'entity', entity }` for NIP-19 app entities;
|
|
45
|
+
// - `{ type: 'named', prefix, channel, appName, user }` for named URLs
|
|
46
|
+
// (`user` is null when no user part is present or it is invalid);
|
|
47
|
+
// - `null` when the segment is not a valid app URL.
|
|
48
|
+
export function decodeAppUrl (segment) {
|
|
49
|
+
if (typeof segment !== 'string' || !segment) return null
|
|
50
|
+
const prefixMatch = segment.match(/^\+{1,3}/)
|
|
51
|
+
if (!prefixMatch) return null
|
|
52
|
+
const prefix = prefixMatch[0]
|
|
53
|
+
|
|
54
|
+
if (NAPP_ENTITY_REGEX.test(segment)) {
|
|
55
|
+
try {
|
|
56
|
+
appDecode(segment)
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
return { type: 'entity', entity: segment }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const remainder = segment.slice(prefix.length)
|
|
64
|
+
if (!remainder) return null
|
|
65
|
+
const parts = remainder.split('@')
|
|
66
|
+
let appName
|
|
67
|
+
let user = null
|
|
68
|
+
|
|
69
|
+
if (parts.length === 1) {
|
|
70
|
+
appName = safeDecode(parts[0])
|
|
71
|
+
} else if (parts.length === 2) {
|
|
72
|
+
const tail = safeDecode(parts[1])
|
|
73
|
+
appName = safeDecode(parts[0])
|
|
74
|
+
user = tail === null ? null : decodeUserReference(tail)
|
|
75
|
+
} else {
|
|
76
|
+
const local = safeDecode(parts[parts.length - 2])
|
|
77
|
+
const domain = safeDecode(parts[parts.length - 1])
|
|
78
|
+
const nip05 = nip05FromLocalDomain(local, domain)
|
|
79
|
+
if (nip05) {
|
|
80
|
+
user = { kind: 'nip05', ...nip05, raw: compactNip05Raw(local, domain) }
|
|
81
|
+
appName = parts.slice(0, -2).map(safeDecode).join('@')
|
|
82
|
+
} else {
|
|
83
|
+
appName = safeDecode(remainder)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (appName === null || !isValidAppName(appName)) return null
|
|
88
|
+
if (!user && appName.length >= APP_URL_MIN_ENTITY_BODY_LENGTH) return null
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
type: 'named',
|
|
92
|
+
prefix,
|
|
93
|
+
channel: CHANNEL_BY_PREFIX[prefix],
|
|
94
|
+
appName,
|
|
95
|
+
user
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Encodes a named app URL segment. `user` is a decoded user reference:
|
|
100
|
+
// NIP-05 (`bob@example.com`, `_@example.com`, `example.com` or the custom
|
|
101
|
+
// `bob.xyz.example.com` form), npub, nprofile or hex pubkey.
|
|
102
|
+
export function encodeAppUrl ({ appName, channel = 'main', user }) {
|
|
103
|
+
if (!isValidAppName(appName)) {
|
|
104
|
+
throw new ValidationError('INVALID_APP_URL_NAME', { message: 'Invalid app URL name' })
|
|
105
|
+
}
|
|
106
|
+
const prefix = PREFIX_BY_CHANNEL[channel]
|
|
107
|
+
if (!prefix) {
|
|
108
|
+
throw new ValidationError('INVALID_APP_URL_CHANNEL', { message: 'Invalid app URL channel' })
|
|
109
|
+
}
|
|
110
|
+
const userRef = decodeUserReference(user)
|
|
111
|
+
if (!userRef) {
|
|
112
|
+
throw new ValidationError('INVALID_APP_URL_USER', { message: 'Invalid app URL user' })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let encodedAppName = encodeURIComponent(appName)
|
|
116
|
+
let userText
|
|
117
|
+
if (userRef.kind === 'nip05') {
|
|
118
|
+
// Keep `@` raw inside the app name so the verbose NIP-05 form stays
|
|
119
|
+
// readable (`+my@app@bob@example.com`).
|
|
120
|
+
encodedAppName = encodedAppName.replace(/%40/g, '@')
|
|
121
|
+
const appNameHasAt = appName.includes('@')
|
|
122
|
+
if (userRef.local === '_') {
|
|
123
|
+
userText = userRef.domain.split('.').length === 2 && !appNameHasAt
|
|
124
|
+
? userRef.domain
|
|
125
|
+
: `_@${userRef.domain}`
|
|
126
|
+
} else {
|
|
127
|
+
// Prefer the shorter single-`@` custom NIP-05 form (`bob.example.com`)
|
|
128
|
+
// unless the app name itself contains `@`, which forces the verbose form.
|
|
129
|
+
userText = appNameHasAt
|
|
130
|
+
? `${userRef.local}@${userRef.domain}`
|
|
131
|
+
: `${userRef.local}.${userRef.domain}`
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
userText = encodeUserReference(userRef)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return `${prefix}${encodedAppName}@${userText}`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Convenience for callers that need the manifest kind for a decoded channel.
|
|
141
|
+
export function appUrlKindByChannel (channel) {
|
|
142
|
+
return KIND_BY_CHANNEL[channel] || null
|
|
143
|
+
}
|