nappup 2.3.3 → 2.3.4

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/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.3",
9
+ "version": "2.3.4",
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.2",
25
+ "libp2r2p": "^0.10.5",
26
26
  "mime-types": "^3.0.1",
27
27
  "nmmr": "^2.0.0"
28
28
  },
@@ -1,4 +1,5 @@
1
1
  const IMAGE_EXTENSIONS = /\.(?:ico|svg|webp|png|jpe?g|gif|avif)(?:[?#].*)?$/i
2
+ const CONVENTIONAL_ICON_BASENAME = /^(?:favicon(?:[-_.]\w+)*|apple-touch-icon(?:-precomposed|[-_.]\w+)*)\.(?:ico|svg|webp|png|jpe?g|gif|avif)$/i
2
3
 
3
4
  // Decodes the character references commonly used in metadata attributes.
4
5
  function decodeHtml (value) {
@@ -55,11 +56,11 @@ function scanTags (htmlContent, names) {
55
56
  }
56
57
 
57
58
  // Adds a unique icon source with a stable priority and document order.
58
- function addIconSource (sources, seen, href, kind, priority, index) {
59
+ function addIconSource (sources, seen, href, kind, priority, index, { sizes, type } = {}) {
59
60
  const value = typeof href === 'string' ? href.trim() : ''
60
61
  if (!value || seen.has(value)) return
61
62
  seen.add(value)
62
- sources.push({ href: value, kind, priority, index })
63
+ sources.push({ href: value, kind, priority, index, sizes, type })
63
64
  }
64
65
 
65
66
  function metadataMarkup (htmlContent) {
@@ -88,10 +89,11 @@ export function extractHtmlMetadata (htmlContent) {
88
89
  }
89
90
  if (tagName === 'link') {
90
91
  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)
92
+ const iconAttributes = { sizes: attributes.sizes, type: attributes.type }
93
+ if (rels.has('icon')) addIconSource(iconSources, seenIcons, attributes.href, 'icon', 10, index, iconAttributes)
94
+ else if (rels.has('apple-touch-icon')) addIconSource(iconSources, seenIcons, attributes.href, 'apple-touch-icon', 20, index, iconAttributes)
95
+ else if (rels.has('apple-touch-icon-precomposed')) addIconSource(iconSources, seenIcons, attributes.href, 'apple-touch-icon', 21, index, iconAttributes)
96
+ else if (rels.has('mask-icon') || rels.has('fluid-icon')) addIconSource(iconSources, seenIcons, attributes.href, 'mask-icon', 30, index, iconAttributes)
95
97
  else if (rels.has('manifest')) addIconSource(iconSources, seenIcons, attributes.href, 'manifest', 40, index)
96
98
  else if (rels.has('image_src')) addIconSource(iconSources, seenIcons, attributes.href, 'social-image', 70, index)
97
99
  continue
@@ -127,7 +129,12 @@ export function extractHtmlMetadata (htmlContent) {
127
129
  name,
128
130
  description,
129
131
  baseHref,
130
- iconSources: iconSources.map(({ href, kind }) => ({ href, kind }))
132
+ iconSources: iconSources.map(({ href, kind, sizes, type }) => ({
133
+ href,
134
+ kind,
135
+ ...(sizes ? { sizes } : {}),
136
+ ...(type ? { type } : {})
137
+ }))
131
138
  }
132
139
  }
133
140
 
@@ -139,6 +146,8 @@ export function extractWebManifestIcons (manifest) {
139
146
  .map((icon, index) => ({
140
147
  href: typeof icon?.src === 'string' ? icon.src.trim() : '',
141
148
  kind: 'web-app-manifest',
149
+ sizes: typeof icon?.sizes === 'string' ? icon.sizes : undefined,
150
+ type: typeof icon?.type === 'string' ? icon.type : undefined,
142
151
  purpose: typeof icon?.purpose === 'string' ? icon.purpose.toLowerCase().split(/\s+/) : ['any'],
143
152
  index
144
153
  }))
@@ -147,7 +156,12 @@ export function extractWebManifestIcons (manifest) {
147
156
  const rank = icon => icon.purpose.includes('any') ? 0 : icon.purpose.includes('maskable') ? 1 : 2
148
157
  return rank(left) - rank(right) || left.index - right.index
149
158
  })
150
- .map(({ href, kind }) => ({ href, kind }))
159
+ .map(({ href, kind, sizes, type }) => ({
160
+ href,
161
+ kind,
162
+ ...(sizes ? { sizes } : {}),
163
+ ...(type ? { type } : {})
164
+ }))
151
165
  } catch (_) {
152
166
  return []
153
167
  }
@@ -174,10 +188,26 @@ function filePath (file) {
174
188
 
175
189
  // Finds a conventional favicon file in a bundle.
176
190
  export function findFavicon (fileList) {
177
- return fileList.find(file => {
191
+ const candidates = fileList.filter(file => {
178
192
  const filename = filePath(file).split('/').pop().toLowerCase()
179
- return filename.startsWith('favicon.') && IMAGE_EXTENSIONS.test(filename)
180
- }) || null
193
+ return CONVENTIONAL_ICON_BASENAME.test(filename) && IMAGE_EXTENSIONS.test(filename)
194
+ })
195
+ return candidates.sort((left, right) => iconQuality({}, filePath(right)) - iconQuality({}, filePath(left)))[0] || null
196
+ }
197
+
198
+ function iconQuality (source, path) {
199
+ const sizes = typeof source?.sizes === 'string' ? source.sizes.toLowerCase() : ''
200
+ const dimensions = [...sizes.matchAll(/(\d{1,5})x(\d{1,5})/g)]
201
+ .map(match => Math.min(Number(match[1]), Number(match[2])))
202
+ if (dimensions.length) return Math.max(...dimensions)
203
+ if (/\bany\b/.test(sizes) || /\.svg(?:[?#]|$)/i.test(path) || source?.type === 'image/svg+xml') return 512
204
+ const filename = path.split('/').pop()
205
+ const inferred = [...filename.matchAll(/(?:^|[-_.])(\d{2,5})x(\d{2,5})(?=[-_.]|$)/gi)]
206
+ .map(match => Math.min(Number(match[1]), Number(match[2])))
207
+ if (inferred.length) return Math.max(...inferred)
208
+ if (/^apple-touch-icon/i.test(filename) || source?.kind === 'apple-touch-icon') return 180
209
+ if (/\.ico$/i.test(filename)) return 32
210
+ return 1
181
211
  }
182
212
 
183
213
  // Finds the best local icon referenced by HTML or its Web App Manifest.
@@ -186,21 +216,32 @@ export async function findAppIcon (fileList, htmlContent, indexFile, readFileTex
186
216
  const indexPath = filePath(indexFile) || 'index.html'
187
217
  const byPath = new Map(fileList.map(file => [filePath(file), file]))
188
218
  const findFromSources = async sources => {
219
+ const candidates = []
220
+ let order = 0
189
221
  for (const source of sources) {
190
222
  const path = resolveAppPath(source.href, indexPath, metadata.baseHref)
191
223
  const file = path && byPath.get(path)
192
224
  if (!file) continue
193
- if (source.kind !== 'manifest') return file
225
+ if (source.kind !== 'manifest') {
226
+ candidates.push({ file, quality: iconQuality(source, path), order: order++ })
227
+ continue
228
+ }
194
229
 
195
230
  try {
196
231
  const manifestText = readFileText ? await readFileText(file) : await file.text()
197
232
  for (const icon of extractWebManifestIcons(manifestText)) {
198
233
  const iconPath = resolveAppPath(icon.href, path)
199
- if (iconPath && byPath.has(iconPath)) return byPath.get(iconPath)
234
+ if (iconPath && byPath.has(iconPath)) {
235
+ candidates.push({
236
+ file: byPath.get(iconPath),
237
+ quality: iconQuality(icon, iconPath),
238
+ order: order++
239
+ })
240
+ }
200
241
  }
201
242
  } catch (_) {}
202
243
  }
203
- return null
244
+ return candidates.sort((left, right) => right.quality - left.quality || left.order - right.order)[0]?.file || null
204
245
  }
205
246
 
206
247
  const specificSources = metadata.iconSources.filter(source => !['tile-image', 'social-image'].includes(source.kind))
package/src/index.js CHANGED
@@ -124,6 +124,7 @@ export async function toApp (fileList, nostrSigner, {
124
124
 
125
125
  const faviconFile = await findAppIcon(fileList, indexHtml, indexFile, file => streamToText(file.stream()))
126
126
  let iconMetadata
127
+ let isExplicitIcon = false
127
128
  let pause = 1000
128
129
 
129
130
  log('Checking for blossom servers...')
@@ -182,6 +183,7 @@ export async function toApp (fileList, nostrSigner, {
182
183
  try {
183
184
  log('Uploading icon from napp.json')
184
185
  iconMetadata = await uploadMediaFromDataUrl(nappJson.icon[0][0], 'icon')
186
+ isExplicitIcon = Boolean(iconMetadata)
185
187
  } catch (error) {
186
188
  log('Failed to upload icon from napp.json', error)
187
189
  }
@@ -246,7 +248,7 @@ export async function toApp (fileList, nostrSigner, {
246
248
  size: uploaded.size
247
249
  }
248
250
  fileMetadata.push(metadata)
249
- if (faviconFile && uploaded.file === faviconFile) iconMetadata = { ...metadata }
251
+ if (!iconMetadata && faviconFile && uploaded.file === faviconFile) iconMetadata = { ...metadata }
250
252
  steps++
251
253
  emit({ type: 'file-uploaded', filename: uploaded.filename, service: 'blossom' })
252
254
  }
@@ -277,7 +279,7 @@ export async function toApp (fileList, nostrSigner, {
277
279
  size: file.size
278
280
  }
279
281
  fileMetadata.push(metadata)
280
- if (faviconFile && file === faviconFile) iconMetadata = { ...metadata }
282
+ if (!iconMetadata && faviconFile && file === faviconFile) iconMetadata = { ...metadata }
281
283
  steps++
282
284
  emit({ type: 'file-uploaded', filename, service: 'irfs' })
283
285
  }
@@ -295,11 +297,13 @@ export async function toApp (fileList, nostrSigner, {
295
297
  summaryLang: nappJson.summary?.[0]?.[1],
296
298
  isSummaryAuto: !nappJson.summary?.[0]?.[0],
297
299
  icon: iconMetadata,
298
- isIconAuto: !nappJson.icon?.[0]?.[0],
300
+ isIconAuto: !isExplicitIcon,
299
301
  descriptions: nappJson.description,
300
302
  keyArt: keyArtMetadata,
301
303
  screenshots: screenshotMetadata,
302
304
  uploadService,
305
+ sourceRelays: writeRelays,
306
+ blossomServers: healthyBlossomServers,
303
307
  signer: nostrSigner,
304
308
  log,
305
309
  pause,
@@ -313,6 +317,7 @@ export async function toApp (fileList, nostrSigner, {
313
317
  const appEntity = appEncode({
314
318
  dTag: manifest.tags.find(tag => tag[0] === 'd')[1],
315
319
  pubkey: manifest.pubkey,
320
+ // Keep empty array to generate the shorter app entity.
316
321
  relays: [],
317
322
  kind: manifest.kind
318
323
  })
@@ -1,11 +1,7 @@
1
1
  import { sha256 } from '@noble/hashes/sha2.js'
2
2
  import nostrRelays from '#services/nostr-relays.js'
3
3
  import { bytesToBase16 } from '#helpers/base16.js'
4
-
5
- function normalizeServerUrl (url) {
6
- if (!url.startsWith('http')) url = 'https://' + url
7
- return url.replace(/\/$/, '') + '/'
8
- }
4
+ import { normalizeBlossomServerUrl } from 'libp2r2p/url'
9
5
 
10
6
  async function createAuthHeader (signer, modify) {
11
7
  const now = Math.floor(Date.now() / 1000)
@@ -37,10 +33,11 @@ export async function getBlossomServers (signer, writeRelays) {
37
33
  events.sort((a, b) => b.created_at - a.created_at)
38
34
  const best = events[0]
39
35
 
40
- return (best.tags ?? [])
41
- .filter(t => Array.isArray(t) && t[0] === 'server' && /^https?:\/\//.test(t[1]))
42
- .map(t => t[1].trim().replace(/\/$/, ''))
43
- .filter(Boolean)
36
+ return [...new Set((best.tags ?? [])
37
+ .filter(t => Array.isArray(t) && t[0] === 'server')
38
+ .flatMap(tag => {
39
+ try { return [normalizeBlossomServerUrl(tag[1])] } catch (_) { return [] }
40
+ }))]
44
41
  }
45
42
 
46
43
  /**
@@ -51,8 +48,9 @@ export async function getBlossomServers (signer, writeRelays) {
51
48
  export async function healthCheckServers (servers, signer, { log = () => {} } = {}) {
52
49
  const results = await Promise.allSettled(
53
50
  servers.map(async (serverUrl) => {
54
- await fetch(normalizeServerUrl(serverUrl), { method: 'HEAD' })
55
- return serverUrl
51
+ const normalized = normalizeBlossomServerUrl(serverUrl)
52
+ await fetch(normalized, { method: 'HEAD' })
53
+ return normalized
56
54
  })
57
55
  )
58
56
 
@@ -88,7 +86,7 @@ export async function computeFileHash (file) {
88
86
  async function uploadFileToServer (serverUrl, signer, file, fileHash, mimeType, { shouldReupload, log, maxRetries = 5 }) {
89
87
  // Check if already uploaded
90
88
  if (!shouldReupload) {
91
- const checkResponse = await fetch(serverUrl + fileHash, { method: 'HEAD' })
89
+ const checkResponse = await fetch(`${serverUrl}/${fileHash}`, { method: 'HEAD' })
92
90
  if (checkResponse.ok) {
93
91
  return { success: true, alreadyExists: true }
94
92
  }
@@ -106,7 +104,7 @@ async function uploadFileToServer (serverUrl, signer, file, fileHash, mimeType,
106
104
  evt.tags.push(['t', 'upload'])
107
105
  evt.tags.push(['x', fileHash])
108
106
  })
109
- const response = await fetch(serverUrl + 'upload', {
107
+ const response = await fetch(`${serverUrl}/upload`, {
110
108
  method: 'PUT',
111
109
  headers: { 'Content-Type': mimeType, Authorization: authorization },
112
110
  body: file.stream(),
@@ -145,7 +143,12 @@ export async function uploadFilesToBlossom ({
145
143
  maxRetries = 5,
146
144
  log = () => {}
147
145
  }) {
148
- if (servers.length === 0) return { uploadedFiles: [], failedFiles: [...fileList.map(f => ({ file: f }))] }
146
+ const normalizedServers = [...new Set(servers.flatMap(server => {
147
+ try { return [normalizeBlossomServerUrl(server)] } catch (_) { return [] }
148
+ }))]
149
+ if (normalizedServers.length === 0) {
150
+ return { uploadedFiles: [], failedFiles: [...fileList.map(f => ({ file: f }))] }
151
+ }
149
152
 
150
153
  // Pre-compute file info
151
154
  const fileInfos = await Promise.all(
@@ -161,24 +164,22 @@ export async function uploadFilesToBlossom ({
161
164
  const fileServerResults = fileInfos.map(() => ({ successCount: 0, errors: [] }))
162
165
 
163
166
  // Upload to each server in parallel, but within a server, upload files sequentially
164
- const serverTasks = servers.map(async (server) => {
165
- const serverUrl = normalizeServerUrl(server)
166
-
167
+ const serverTasks = normalizedServers.map(async (serverUrl) => {
167
168
  for (let i = 0; i < fileInfos.length; i++) {
168
169
  const info = fileInfos[i]
169
- log(`Uploading ${info.filename} to ${server}`)
170
+ log(`Uploading ${info.filename} to ${serverUrl}`)
170
171
  const result = await uploadFileToServer(serverUrl, signer, info.file, info.sha256, info.mimeType, { shouldReupload, log, maxRetries })
171
172
 
172
173
  if (result.success) {
173
174
  fileServerResults[i].successCount++
174
175
  if (result.alreadyExists) {
175
- log(`${info.filename}: Already exists on ${server}`)
176
+ log(`${info.filename}: Already exists on ${serverUrl}`)
176
177
  } else {
177
- log(`${info.filename}: Uploaded to ${server}`)
178
+ log(`${info.filename}: Uploaded to ${serverUrl}`)
178
179
  }
179
180
  } else {
180
- fileServerResults[i].errors.push({ server, error: result.error })
181
- log(`${info.filename}: Failed to upload to ${server}: ${result.error?.message ?? result.error}`)
181
+ fileServerResults[i].errors.push({ server: serverUrl, error: result.error })
182
+ log(`${info.filename}: Failed to upload to ${serverUrl}: ${result.error?.message ?? result.error}`)
182
183
  }
183
184
  }
184
185
  })
@@ -3,10 +3,12 @@ import nostrRelays, { nappRelays } from '#services/nostr-relays.js'
3
3
  import { throttledSendEvent } from '#services/irfs-upload.js'
4
4
  import { sha256 } from '@noble/hashes/sha2.js'
5
5
  import { bytesToBase16 } from '#helpers/base16.js'
6
+ import { normalizeBlossomServerUrl, normalizeRelayUrl } from 'libp2r2p/url'
6
7
 
7
8
  const MANAGED_MANIFEST_TAGS = new Set([
8
9
  'd', 'service', 'path', 'r', 'name', 'summary', 'description', 'self',
9
- 'c', 'l', 't', 'auto', 'icon', 'key_art', 'screenshot', 'x', 'published_at'
10
+ 'c', 'l', 't', 'auto', 'icon', 'key_art', 'screenshot', 'relay', 'server',
11
+ 'x', 'published_at'
10
12
  ])
11
13
 
12
14
  export function normalizeManifestPath (value) {
@@ -169,9 +171,29 @@ function buildMetadataTags ({
169
171
  return tags
170
172
  }
171
173
 
174
+ function normalizeServiceUrls (values, normalizer) {
175
+ const urls = []
176
+ for (const value of Array.isArray(values) ? values : []) {
177
+ try {
178
+ const normalized = normalizer(value)
179
+ if (!urls.includes(normalized)) urls.push(normalized)
180
+ } catch (_) {}
181
+ }
182
+ return urls
183
+ }
184
+
185
+ function buildSourceHintTags (sourceRelays, blossomServers) {
186
+ return [
187
+ ...normalizeServiceUrls(sourceRelays, normalizeRelayUrl)
188
+ .map(relay => ['relay', relay]),
189
+ ...normalizeServiceUrls(blossomServers, normalizeBlossomServerUrl)
190
+ .map(server => ['server', server])
191
+ ]
192
+ }
193
+
172
194
  export function buildManifestTags ({
173
195
  dTag, uploadService, fileMetadata = [], icon, keyArt = [], screenshots = [],
174
- previousTags = [], publishedAt, ...metadata
196
+ previousTags = [], publishedAt, sourceRelays = [], blossomServers = [], ...metadata
175
197
  }) {
176
198
  if (uploadService !== 'irfs' && uploadService !== 'blossom') {
177
199
  throw new Error('Unknown upload service')
@@ -199,6 +221,7 @@ export function buildManifestTags ({
199
221
  ['d', dTag],
200
222
  ...referenceTags,
201
223
  ['service', uploadService],
224
+ ...buildSourceHintTags(sourceRelays, blossomServers),
202
225
  ['x', aggregateHash, 'aggregate'],
203
226
  ['published_at', String(publishedAt)],
204
227
  ...buildMetadataTags({ ...metadata, hasIcon: Boolean(icon) }),