nappup 2.3.1 → 2.3.3

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.
@@ -153,7 +153,7 @@ export async function readEnvSetValue (args, {
153
153
  }
154
154
 
155
155
  export async function confirmArgs (args) {
156
- if (args.yes) return
156
+ if (args.yes) return true
157
157
  const rl = readline.createInterface({
158
158
  input: process.stdin,
159
159
  output: process.stdout
@@ -162,14 +162,18 @@ export async function confirmArgs (args) {
162
162
  return new Promise(resolve => rl.question(query, resolve))
163
163
  }
164
164
  const answer = await askQuestion(
165
- `Publish app from '${args.dir}' as '${args.dTag}' to the ${args.channel} release channel? (y/n) `
165
+ confirmationPrompt(args)
166
166
  )
167
+ rl.close()
167
168
  if (answer.toLowerCase() !== 'y') {
168
169
  console.log('Operation cancelled by user.')
169
- rl.close()
170
- process.exit(0)
170
+ return false
171
171
  }
172
- rl.close()
172
+ return true
173
+ }
174
+
175
+ export function confirmationPrompt (args) {
176
+ return `Publish app from '${args.dir}' as '${args.dTag}' to the ${args.channel} release channel using ${args.npub}? (y/n) `
173
177
  }
174
178
 
175
179
  export async function * getFiles (dir) {
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import path from 'node:path'
3
+ import { npubEncode } from 'libp2r2p/nip19'
3
4
  import { GENERIC_BUILD_FOLDER_NAMES } from '#helpers/app.js'
4
5
  import {
5
6
  parseArgs,
@@ -57,8 +58,6 @@ async function upload (args, dotenvState) {
57
58
  }
58
59
  args.dTag = dTag
59
60
 
60
- await confirmArgs(args)
61
- const fileList = await toFileList(getFiles(dir), dir)
62
61
  const bunkerUrl = sk?.startsWith('bunker://')
63
62
  ? sk
64
63
  : !sk && process.env.NOSTR_SECRET_KEY?.startsWith('bunker://')
@@ -73,10 +72,17 @@ async function upload (args, dotenvState) {
73
72
  })
74
73
  } else {
75
74
  const { default: NostrSigner } = await import('#services/nostr-signer.js')
76
- signer = await NostrSigner.create(sk)
75
+ signer = await NostrSigner.create(sk, {
76
+ deferInitialization: true,
77
+ dotenvFilePath: dotenvState.filePath
78
+ })
77
79
  }
78
80
 
79
81
  try {
82
+ args.npub = npubEncode(await signer.getPublicKey())
83
+ if (!await confirmArgs(args)) return
84
+ await signer.initialize?.()
85
+ const fileList = await toFileList(getFiles(dir), dir)
80
86
  const { default: toApp } = await import('#index.js')
81
87
  await toApp(fileList, signer, { log: console.log.bind(console), dTag, channel, shouldReupload })
82
88
  } finally {
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "git+https://github.com/44billion/nappup.git"
7
7
  },
8
8
  "license": "MIT",
9
- "version": "2.3.1",
9
+ "version": "2.3.3",
10
10
  "description": "Nostr App Uploader",
11
11
  "type": "module",
12
12
  "scripts": {
@@ -22,7 +22,7 @@
22
22
  "@noble/hashes": "^2.0.0",
23
23
  "dotenv": "^17.2.0",
24
24
  "file-type": "^21.0.0",
25
- "libp2r2p": "^0.10.1",
25
+ "libp2r2p": "^0.10.2",
26
26
  "mime-types": "^3.0.1",
27
27
  "nmmr": "^2.0.0"
28
28
  },
@@ -1,82 +1,219 @@
1
- export function extractHtmlMetadata (htmlContent) {
2
- let name
3
- let description
1
+ const IMAGE_EXTENSIONS = /\.(?:ico|svg|webp|png|jpe?g|gif|avif)(?:[?#].*)?$/i
4
2
 
5
- try {
6
- const titleRegex = /<title[^>]*>([\s\S]*?)<\/title>/i
7
- const titleMatch = htmlContent.match(titleRegex)
8
- if (titleMatch && titleMatch[1]) {
9
- name = titleMatch[1].trim()
10
- }
3
+ // Decodes the character references commonly used in metadata attributes.
4
+ function decodeHtml (value) {
5
+ return String(value || '').replace(/&(#x[0-9a-f]+|#\d+|amp|quot|apos|lt|gt);/gi, (_, entity) => {
6
+ const lower = entity.toLowerCase()
7
+ if (lower === 'amp') return '&'
8
+ if (lower === 'quot') return '"'
9
+ if (lower === 'apos') return "'"
10
+ if (lower === 'lt') return '<'
11
+ if (lower === 'gt') return '>'
12
+ const codePoint = lower.startsWith('#x')
13
+ ? Number.parseInt(lower.slice(2), 16)
14
+ : Number.parseInt(lower.slice(1), 10)
15
+ return Number.isSafeInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff
16
+ ? String.fromCodePoint(codePoint)
17
+ : _
18
+ })
19
+ }
11
20
 
12
- if (!name) {
13
- const ogTitleRegex = /<meta\s+[^>]*(?:property|name)\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([^"']+)["'][^>]*>/i
14
- const ogTitleMatch = htmlContent.match(ogTitleRegex)
15
- if (ogTitleMatch && ogTitleMatch[1]) {
16
- name = ogTitleMatch[1].trim()
17
- } else {
18
- const altOgTitleRegex = /<meta\s+[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:title["'][^>]*>/i
19
- const altOgTitleMatch = htmlContent.match(altOgTitleRegex)
20
- if (altOgTitleMatch && altOgTitleMatch[1]) {
21
- name = altOgTitleMatch[1].trim()
22
- }
21
+ // Parses attributes without depending on a browser DOM implementation.
22
+ function parseAttributes (tag) {
23
+ const attributes = {}
24
+ const source = tag.replace(/^<\s*[^\s>]+/, '').replace(/\/?\s*>$/, '')
25
+ const pattern = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g
26
+ for (const match of source.matchAll(pattern)) {
27
+ const name = match[1].toLowerCase()
28
+ if (!(name in attributes)) attributes[name] = decodeHtml(match[2] ?? match[3] ?? match[4] ?? '')
29
+ }
30
+ return attributes
31
+ }
32
+
33
+ // Scans selected HTML start tags while respecting quoted greater-than signs.
34
+ function scanTags (htmlContent, names) {
35
+ const tags = []
36
+ const start = new RegExp(`<\\s*(${[...names].join('|')})\\b`, 'ig')
37
+ for (const match of htmlContent.matchAll(start)) {
38
+ let quote = null
39
+ let end = match.index + match[0].length
40
+ for (; end < htmlContent.length; end++) {
41
+ const char = htmlContent[end]
42
+ if (quote) {
43
+ if (char === quote) quote = null
44
+ } else if (char === '"' || char === "'") {
45
+ quote = char
46
+ } else if (char === '>') {
47
+ break
23
48
  }
24
49
  }
25
-
26
- const metaDescRegex = /<meta\s+[^>]*name\s*=\s*["']description["'][^>]*content\s*=\s*["']([^"']+)["'][^>]*>/i
27
- const metaDescMatch = htmlContent.match(metaDescRegex)
28
- if (metaDescMatch && metaDescMatch[1]) {
29
- description = metaDescMatch[1].trim()
50
+ if (end < htmlContent.length) {
51
+ tags.push({ name: match[1].toLowerCase(), attributes: parseAttributes(htmlContent.slice(match.index, end + 1)), index: match.index })
30
52
  }
53
+ }
54
+ return tags
55
+ }
56
+
57
+ // Adds a unique icon source with a stable priority and document order.
58
+ function addIconSource (sources, seen, href, kind, priority, index) {
59
+ const value = typeof href === 'string' ? href.trim() : ''
60
+ if (!value || seen.has(value)) return
61
+ seen.add(value)
62
+ sources.push({ href: value, kind, priority, index })
63
+ }
31
64
 
32
- if (!description) {
33
- const altMetaDescRegex = /<meta\s+[^>]*content\s*=\s*["']([^"']+)["'][^>]*name\s*=\s*["']description["'][^>]*>/i
34
- const altMetaDescMatch = htmlContent.match(altMetaDescRegex)
35
- if (altMetaDescMatch && altMetaDescMatch[1]) {
36
- description = altMetaDescMatch[1].trim()
65
+ function metadataMarkup (htmlContent) {
66
+ return String(htmlContent || '')
67
+ .replace(/<!--[\s\S]*?-->/g, '')
68
+ .replace(/<(script|style|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '')
69
+ }
70
+
71
+ // Extracts listing text plus browser, platform and social icon declarations.
72
+ export function extractHtmlMetadata (htmlContent) {
73
+ const html = metadataMarkup(htmlContent)
74
+ let name = decodeHtml(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]).trim() || undefined
75
+ let namePriority = name ? 0 : Infinity
76
+ let description
77
+ let descriptionPriority = Infinity
78
+ let baseHref
79
+ const iconSources = []
80
+ const seenIcons = new Set()
81
+
82
+ try {
83
+ const tags = scanTags(html, new Set(['base', 'link', 'meta']))
84
+ for (const { name: tagName, attributes, index } of tags) {
85
+ if (tagName === 'base' && !baseHref && attributes.href) {
86
+ baseHref = attributes.href.trim()
87
+ continue
88
+ }
89
+ if (tagName === 'link') {
90
+ const rels = new Set((attributes.rel || '').toLowerCase().split(/\s+/).filter(Boolean))
91
+ if (rels.has('icon')) addIconSource(iconSources, seenIcons, attributes.href, 'icon', 10, index)
92
+ else if (rels.has('apple-touch-icon')) addIconSource(iconSources, seenIcons, attributes.href, 'apple-touch-icon', 20, index)
93
+ else if (rels.has('apple-touch-icon-precomposed')) addIconSource(iconSources, seenIcons, attributes.href, 'apple-touch-icon', 21, index)
94
+ else if (rels.has('mask-icon') || rels.has('fluid-icon')) addIconSource(iconSources, seenIcons, attributes.href, 'mask-icon', 30, index)
95
+ else if (rels.has('manifest')) addIconSource(iconSources, seenIcons, attributes.href, 'manifest', 40, index)
96
+ else if (rels.has('image_src')) addIconSource(iconSources, seenIcons, attributes.href, 'social-image', 70, index)
97
+ continue
37
98
  }
38
- }
39
99
 
40
- if (!description) {
41
- const ogDescRegex = /<meta\s+[^>]*(?:property|name)\s*=\s*["']og:description["'][^>]*content\s*=\s*["']([^"']+)["'][^>]*>/i
42
- const ogDescMatch = htmlContent.match(ogDescRegex)
43
- if (ogDescMatch && ogDescMatch[1]) {
44
- description = ogDescMatch[1].trim()
45
- } else {
46
- const altOgDescRegex = /<meta\s+[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:description["'][^>]*>/i
47
- const altOgDescMatch = htmlContent.match(altOgDescRegex)
48
- if (altOgDescMatch && altOgDescMatch[1]) {
49
- description = altOgDescMatch[1].trim()
50
- }
100
+ const key = (attributes.property || attributes.name || attributes.itemprop || '').toLowerCase()
101
+ const content = attributes.content?.trim()
102
+ if (!content) continue
103
+ const nextNamePriority = key === 'application-name'
104
+ ? 10
105
+ : key === 'apple-mobile-web-app-title'
106
+ ? 20
107
+ : key === 'og:title' ? 30 : Infinity
108
+ if (nextNamePriority < namePriority) {
109
+ name = content
110
+ namePriority = nextNamePriority
111
+ }
112
+ const priority = key === 'description' ? 10 : key === 'og:description' ? 20 : Infinity
113
+ if (priority < descriptionPriority) {
114
+ description = content
115
+ descriptionPriority = priority
51
116
  }
117
+ if (key === 'msapplication-tileimage') addIconSource(iconSources, seenIcons, content, 'tile-image', 60, index)
118
+ else if (key === 'og:image:secure_url') addIconSource(iconSources, seenIcons, content, 'social-image', 71, index)
119
+ else if (key === 'og:image' || key === 'og:image:url') addIconSource(iconSources, seenIcons, content, 'social-image', 72, index)
120
+ else if (key === 'image') addIconSource(iconSources, seenIcons, content, 'social-image', 75, index)
121
+ else if (key === 'twitter:image' || key === 'twitter:image:src') addIconSource(iconSources, seenIcons, content, 'social-image', 80, index)
52
122
  }
123
+ } catch (_) {}
124
+
125
+ iconSources.sort((left, right) => left.priority - right.priority || left.index - right.index)
126
+ return {
127
+ name,
128
+ description,
129
+ baseHref,
130
+ iconSources: iconSources.map(({ href, kind }) => ({ href, kind }))
131
+ }
132
+ }
133
+
134
+ // Extracts ordered icon references from a parsed Web App Manifest.
135
+ export function extractWebManifestIcons (manifest) {
136
+ try {
137
+ const parsed = typeof manifest === 'string' ? JSON.parse(manifest) : manifest
138
+ return (Array.isArray(parsed?.icons) ? parsed.icons : [])
139
+ .map((icon, index) => ({
140
+ href: typeof icon?.src === 'string' ? icon.src.trim() : '',
141
+ kind: 'web-app-manifest',
142
+ purpose: typeof icon?.purpose === 'string' ? icon.purpose.toLowerCase().split(/\s+/) : ['any'],
143
+ index
144
+ }))
145
+ .filter(icon => icon.href)
146
+ .sort((left, right) => {
147
+ const rank = icon => icon.purpose.includes('any') ? 0 : icon.purpose.includes('maskable') ? 1 : 2
148
+ return rank(left) - rank(right) || left.index - right.index
149
+ })
150
+ .map(({ href, kind }) => ({ href, kind }))
53
151
  } catch (_) {
54
- // ignore
152
+ return []
55
153
  }
154
+ }
56
155
 
57
- return { name, description }
156
+ // Returns the path portion of a reference as it would resolve inside the app.
157
+ export function resolveAppPath (reference, basePath = 'index.html', baseHref) {
158
+ try {
159
+ const documentUrl = new URL(basePath, 'https://napp.invalid/')
160
+ const effectiveBase = baseHref ? new URL(baseHref, documentUrl) : documentUrl
161
+ const url = new URL(reference, effectiveBase)
162
+ if (!['http:', 'https:'].includes(url.protocol)) return null
163
+ return decodeURIComponent(url.pathname).replace(/^\/+/, '') || null
164
+ } catch (_) {
165
+ return null
166
+ }
58
167
  }
59
168
 
169
+ // Returns the bundle-relative path represented by a File-like object.
170
+ function filePath (file) {
171
+ const path = file?.webkitRelativePath || file?.name || ''
172
+ return file?.webkitRelativePath ? path.split('/').slice(1).join('/') : path
173
+ }
174
+
175
+ // Finds a conventional favicon file in a bundle.
60
176
  export function findFavicon (fileList) {
61
- const faviconExtensions = ['ico', 'svg', 'webp', 'png', 'jpg', 'jpeg', 'gif']
62
- for (const file of fileList) {
63
- const filename = (file.webkitRelativePath || file.name || '').split('/').pop().toLowerCase()
64
- if (filename.startsWith('favicon.')) {
65
- const ext = filename.split('.').pop()
66
- if (faviconExtensions.includes(ext)) {
67
- return file
68
- }
177
+ return fileList.find(file => {
178
+ const filename = filePath(file).split('/').pop().toLowerCase()
179
+ return filename.startsWith('favicon.') && IMAGE_EXTENSIONS.test(filename)
180
+ }) || null
181
+ }
182
+
183
+ // Finds the best local icon referenced by HTML or its Web App Manifest.
184
+ export async function findAppIcon (fileList, htmlContent, indexFile, readFileText) {
185
+ const metadata = extractHtmlMetadata(htmlContent)
186
+ const indexPath = filePath(indexFile) || 'index.html'
187
+ const byPath = new Map(fileList.map(file => [filePath(file), file]))
188
+ const findFromSources = async sources => {
189
+ for (const source of sources) {
190
+ const path = resolveAppPath(source.href, indexPath, metadata.baseHref)
191
+ const file = path && byPath.get(path)
192
+ if (!file) continue
193
+ if (source.kind !== 'manifest') return file
194
+
195
+ try {
196
+ const manifestText = readFileText ? await readFileText(file) : await file.text()
197
+ for (const icon of extractWebManifestIcons(manifestText)) {
198
+ const iconPath = resolveAppPath(icon.href, path)
199
+ if (iconPath && byPath.has(iconPath)) return byPath.get(iconPath)
200
+ }
201
+ } catch (_) {}
69
202
  }
203
+ return null
70
204
  }
71
- return null
205
+
206
+ const specificSources = metadata.iconSources.filter(source => !['tile-image', 'social-image'].includes(source.kind))
207
+ const socialSources = metadata.iconSources.filter(source => ['tile-image', 'social-image'].includes(source.kind))
208
+ return (await findFromSources(specificSources)) ||
209
+ findFavicon(fileList) ||
210
+ (await findFromSources(socialSources))
72
211
  }
73
212
 
213
+ // Finds the app entry HTML file.
74
214
  export function findIndexFile (fileList) {
75
- for (const file of fileList) {
76
- const filename = (file.webkitRelativePath || file.name || '').split('/').pop().toLowerCase()
77
- if (filename === 'index.html' || filename === 'index.htm') {
78
- return file
79
- }
80
- }
81
- return null
215
+ return fileList.find(file => {
216
+ const filename = filePath(file).split('/').pop().toLowerCase()
217
+ return filename === 'index.html' || filename === 'index.htm'
218
+ }) || null
82
219
  }
package/src/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import NMMR from 'nmmr'
2
- import { appEncode, npubEncode } from 'libp2r2p/nip19'
2
+ import { appEncode } from 'libp2r2p/nip19'
3
3
  import nostrRelays from '#services/nostr-relays.js'
4
4
  import { getRelays } from '#helpers/signer.js'
5
5
  import { streamToChunks, streamToText } from '#helpers/stream.js'
6
6
  import { isNostrAppDTagSafe, GENERIC_BUILD_FOLDER_NAMES } from '#helpers/app.js'
7
- import { extractHtmlMetadata, findFavicon, findIndexFile } from '#helpers/app-metadata.js'
7
+ import { extractHtmlMetadata, findAppIcon, findIndexFile } from '#helpers/app-metadata.js'
8
8
  import { getBlossomServers, healthCheckServers, uploadFilesToBlossom } from '#services/blossom-upload.js'
9
9
  import { uploadBinaryDataChunks } from '#services/irfs-upload.js'
10
10
  import { uploadSiteManifest } from '#services/site-manifest.js'
@@ -79,8 +79,7 @@ export async function toApp (fileList, nostrSigner, {
79
79
 
80
80
  const writeRelays = [...new Set((await nostrSigner.getRelays()).write
81
81
  .map(relay => relay.trim().replace(/\/$/, '')))]
82
- const npub = npubEncode(await nostrSigner.getPublicKey())
83
- log(`Found ${writeRelays.length} outbox relays for pubkey ${npub}:\n${writeRelays.join(', ')}`)
82
+ log(`Found ${writeRelays.length} outbox relays:\n${writeRelays.join(', ')}`)
84
83
  if (!writeRelays.length) throw new Error('No outbox relays found')
85
84
 
86
85
  if (typeof dTag === 'string') {
@@ -111,9 +110,11 @@ export async function toApp (fileList, nostrSigner, {
111
110
  const indexFile = findIndexFile(fileList)
112
111
  let manifestName = nappJson.name?.[0]?.[0]
113
112
  let manifestSummary = nappJson.summary?.[0]?.[0]
114
- if (indexFile && (!manifestName || !manifestSummary)) {
113
+ let indexHtml = ''
114
+ if (indexFile) {
115
115
  try {
116
- const { name, description } = extractHtmlMetadata(await streamToText(indexFile.stream()))
116
+ indexHtml = await streamToText(indexFile.stream())
117
+ const { name, description } = extractHtmlMetadata(indexHtml)
117
118
  if (!manifestName) manifestName = name
118
119
  if (!manifestSummary) manifestSummary = description
119
120
  } catch (error) {
@@ -121,7 +122,7 @@ export async function toApp (fileList, nostrSigner, {
121
122
  }
122
123
  }
123
124
 
124
- const faviconFile = findFavicon(fileList)
125
+ const faviconFile = await findAppIcon(fileList, indexHtml, indexFile, file => streamToText(file.stream()))
125
126
  let iconMetadata
126
127
  let pause = 1000
127
128
 
@@ -1,19 +1,5 @@
1
1
  import { relayPool } from 'libp2r2p/relay'
2
-
3
- export const seedRelays = [
4
- 'wss://relay.44billion.net',
5
- 'wss://purplepag.es',
6
- 'wss://user.kindpag.es',
7
- 'wss://relay.nos.social',
8
- 'wss://nostr.land',
9
- 'wss://indexer.coracle.social'
10
- ]
11
- export const freeRelays = [
12
- 'wss://relay.primal.net',
13
- 'wss://nos.lol',
14
- 'wss://relay.damus.io'
15
- ]
16
- export const nappRelays = ['wss://relay.44billion.net']
2
+ export { freeRelays, nappRelays, seedRelays } from 'libp2r2p/relay'
17
3
 
18
4
  // sendEvent returns quickly after the first successful publish. Upload flows
19
5
  // need the terminal per-relay report so retries and replication stay correct.
@@ -12,15 +12,19 @@ const createToken = Symbol('createToken')
12
12
  export default class NostrSigner {
13
13
  #secretKey // bytes
14
14
  #publicKey // hex
15
+ #needsInitialization
16
+ #dotenvFilePath
15
17
 
16
- constructor (token, skBytes) {
18
+ constructor (token, skBytes, { needsInitialization = false, dotenvFilePath } = {}) {
17
19
  if (token !== createToken) throw new Error('Use NostrSigner.create(?sk) to instantiate this class.')
18
20
  if (!skBytes) throw new Error('Secret key missing.')
19
21
 
20
22
  this.#secretKey = skBytes
23
+ this.#needsInitialization = needsInitialization
24
+ this.#dotenvFilePath = dotenvFilePath
21
25
  }
22
26
 
23
- static async create (sk) {
27
+ static async create (sk, { deferInitialization = false, dotenvFilePath } = {}) {
24
28
  if (sk) {
25
29
  if (sk.startsWith('nsec')) sk = nsecDecode(sk)
26
30
  return new this(createToken, base16ToBytes(sk))
@@ -37,13 +41,26 @@ export default class NostrSigner {
37
41
  } else {
38
42
  isNewSk = true
39
43
  skBytes = generateSecretKey()
40
- setEncryptedDotenvValue('NOSTR_SECRET_KEY', nsecEncode(bytesToBase16(skBytes)))
41
44
  }
42
- const ret = new this(createToken, skBytes)
43
- if (isNewSk) await ret.#initSk(sk)
45
+ const ret = new this(createToken, skBytes, {
46
+ needsInitialization: isNewSk,
47
+ dotenvFilePath
48
+ })
49
+ if (isNewSk && !deferInitialization) await ret.initialize()
44
50
  return ret
45
51
  }
46
52
 
53
+ async initialize () {
54
+ if (!this.#needsInitialization) return
55
+ setEncryptedDotenvValue(
56
+ 'NOSTR_SECRET_KEY',
57
+ nsecEncode(bytesToBase16(this.#secretKey)),
58
+ this.#dotenvFilePath ? { filePath: this.#dotenvFilePath } : undefined
59
+ )
60
+ this.#needsInitialization = false
61
+ await this.#initSk()
62
+ }
63
+
47
64
  async getRelays () {
48
65
  return getRelays.call(this)
49
66
  }