nappup 2.3.2 → 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.2",
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.1",
25
+ "libp2r2p": "^0.10.5",
26
26
  "mime-types": "^3.0.1",
27
27
  "nmmr": "^2.0.0"
28
28
  },
@@ -1,82 +1,260 @@
1
- export function extractHtmlMetadata (htmlContent) {
2
- let name
3
- let description
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
4
3
 
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
- }
4
+ // Decodes the character references commonly used in metadata attributes.
5
+ function decodeHtml (value) {
6
+ return String(value || '').replace(/&(#x[0-9a-f]+|#\d+|amp|quot|apos|lt|gt);/gi, (_, entity) => {
7
+ const lower = entity.toLowerCase()
8
+ if (lower === 'amp') return '&'
9
+ if (lower === 'quot') return '"'
10
+ if (lower === 'apos') return "'"
11
+ if (lower === 'lt') return '<'
12
+ if (lower === 'gt') return '>'
13
+ const codePoint = lower.startsWith('#x')
14
+ ? Number.parseInt(lower.slice(2), 16)
15
+ : Number.parseInt(lower.slice(1), 10)
16
+ return Number.isSafeInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff
17
+ ? String.fromCodePoint(codePoint)
18
+ : _
19
+ })
20
+ }
11
21
 
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
- }
22
+ // Parses attributes without depending on a browser DOM implementation.
23
+ function parseAttributes (tag) {
24
+ const attributes = {}
25
+ const source = tag.replace(/^<\s*[^\s>]+/, '').replace(/\/?\s*>$/, '')
26
+ const pattern = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g
27
+ for (const match of source.matchAll(pattern)) {
28
+ const name = match[1].toLowerCase()
29
+ if (!(name in attributes)) attributes[name] = decodeHtml(match[2] ?? match[3] ?? match[4] ?? '')
30
+ }
31
+ return attributes
32
+ }
33
+
34
+ // Scans selected HTML start tags while respecting quoted greater-than signs.
35
+ function scanTags (htmlContent, names) {
36
+ const tags = []
37
+ const start = new RegExp(`<\\s*(${[...names].join('|')})\\b`, 'ig')
38
+ for (const match of htmlContent.matchAll(start)) {
39
+ let quote = null
40
+ let end = match.index + match[0].length
41
+ for (; end < htmlContent.length; end++) {
42
+ const char = htmlContent[end]
43
+ if (quote) {
44
+ if (char === quote) quote = null
45
+ } else if (char === '"' || char === "'") {
46
+ quote = char
47
+ } else if (char === '>') {
48
+ break
23
49
  }
24
50
  }
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()
51
+ if (end < htmlContent.length) {
52
+ tags.push({ name: match[1].toLowerCase(), attributes: parseAttributes(htmlContent.slice(match.index, end + 1)), index: match.index })
30
53
  }
54
+ }
55
+ return tags
56
+ }
57
+
58
+ // Adds a unique icon source with a stable priority and document order.
59
+ function addIconSource (sources, seen, href, kind, priority, index, { sizes, type } = {}) {
60
+ const value = typeof href === 'string' ? href.trim() : ''
61
+ if (!value || seen.has(value)) return
62
+ seen.add(value)
63
+ sources.push({ href: value, kind, priority, index, sizes, type })
64
+ }
31
65
 
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()
66
+ function metadataMarkup (htmlContent) {
67
+ return String(htmlContent || '')
68
+ .replace(/<!--[\s\S]*?-->/g, '')
69
+ .replace(/<(script|style|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '')
70
+ }
71
+
72
+ // Extracts listing text plus browser, platform and social icon declarations.
73
+ export function extractHtmlMetadata (htmlContent) {
74
+ const html = metadataMarkup(htmlContent)
75
+ let name = decodeHtml(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]).trim() || undefined
76
+ let namePriority = name ? 0 : Infinity
77
+ let description
78
+ let descriptionPriority = Infinity
79
+ let baseHref
80
+ const iconSources = []
81
+ const seenIcons = new Set()
82
+
83
+ try {
84
+ const tags = scanTags(html, new Set(['base', 'link', 'meta']))
85
+ for (const { name: tagName, attributes, index } of tags) {
86
+ if (tagName === 'base' && !baseHref && attributes.href) {
87
+ baseHref = attributes.href.trim()
88
+ continue
89
+ }
90
+ if (tagName === 'link') {
91
+ const rels = new Set((attributes.rel || '').toLowerCase().split(/\s+/).filter(Boolean))
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)
97
+ else if (rels.has('manifest')) addIconSource(iconSources, seenIcons, attributes.href, 'manifest', 40, index)
98
+ else if (rels.has('image_src')) addIconSource(iconSources, seenIcons, attributes.href, 'social-image', 70, index)
99
+ continue
37
100
  }
38
- }
39
101
 
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
- }
102
+ const key = (attributes.property || attributes.name || attributes.itemprop || '').toLowerCase()
103
+ const content = attributes.content?.trim()
104
+ if (!content) continue
105
+ const nextNamePriority = key === 'application-name'
106
+ ? 10
107
+ : key === 'apple-mobile-web-app-title'
108
+ ? 20
109
+ : key === 'og:title' ? 30 : Infinity
110
+ if (nextNamePriority < namePriority) {
111
+ name = content
112
+ namePriority = nextNamePriority
113
+ }
114
+ const priority = key === 'description' ? 10 : key === 'og:description' ? 20 : Infinity
115
+ if (priority < descriptionPriority) {
116
+ description = content
117
+ descriptionPriority = priority
51
118
  }
119
+ if (key === 'msapplication-tileimage') addIconSource(iconSources, seenIcons, content, 'tile-image', 60, index)
120
+ else if (key === 'og:image:secure_url') addIconSource(iconSources, seenIcons, content, 'social-image', 71, index)
121
+ else if (key === 'og:image' || key === 'og:image:url') addIconSource(iconSources, seenIcons, content, 'social-image', 72, index)
122
+ else if (key === 'image') addIconSource(iconSources, seenIcons, content, 'social-image', 75, index)
123
+ else if (key === 'twitter:image' || key === 'twitter:image:src') addIconSource(iconSources, seenIcons, content, 'social-image', 80, index)
52
124
  }
125
+ } catch (_) {}
126
+
127
+ iconSources.sort((left, right) => left.priority - right.priority || left.index - right.index)
128
+ return {
129
+ name,
130
+ description,
131
+ baseHref,
132
+ iconSources: iconSources.map(({ href, kind, sizes, type }) => ({
133
+ href,
134
+ kind,
135
+ ...(sizes ? { sizes } : {}),
136
+ ...(type ? { type } : {})
137
+ }))
138
+ }
139
+ }
140
+
141
+ // Extracts ordered icon references from a parsed Web App Manifest.
142
+ export function extractWebManifestIcons (manifest) {
143
+ try {
144
+ const parsed = typeof manifest === 'string' ? JSON.parse(manifest) : manifest
145
+ return (Array.isArray(parsed?.icons) ? parsed.icons : [])
146
+ .map((icon, index) => ({
147
+ href: typeof icon?.src === 'string' ? icon.src.trim() : '',
148
+ kind: 'web-app-manifest',
149
+ sizes: typeof icon?.sizes === 'string' ? icon.sizes : undefined,
150
+ type: typeof icon?.type === 'string' ? icon.type : undefined,
151
+ purpose: typeof icon?.purpose === 'string' ? icon.purpose.toLowerCase().split(/\s+/) : ['any'],
152
+ index
153
+ }))
154
+ .filter(icon => icon.href)
155
+ .sort((left, right) => {
156
+ const rank = icon => icon.purpose.includes('any') ? 0 : icon.purpose.includes('maskable') ? 1 : 2
157
+ return rank(left) - rank(right) || left.index - right.index
158
+ })
159
+ .map(({ href, kind, sizes, type }) => ({
160
+ href,
161
+ kind,
162
+ ...(sizes ? { sizes } : {}),
163
+ ...(type ? { type } : {})
164
+ }))
165
+ } catch (_) {
166
+ return []
167
+ }
168
+ }
169
+
170
+ // Returns the path portion of a reference as it would resolve inside the app.
171
+ export function resolveAppPath (reference, basePath = 'index.html', baseHref) {
172
+ try {
173
+ const documentUrl = new URL(basePath, 'https://napp.invalid/')
174
+ const effectiveBase = baseHref ? new URL(baseHref, documentUrl) : documentUrl
175
+ const url = new URL(reference, effectiveBase)
176
+ if (!['http:', 'https:'].includes(url.protocol)) return null
177
+ return decodeURIComponent(url.pathname).replace(/^\/+/, '') || null
53
178
  } catch (_) {
54
- // ignore
179
+ return null
55
180
  }
181
+ }
56
182
 
57
- return { name, description }
183
+ // Returns the bundle-relative path represented by a File-like object.
184
+ function filePath (file) {
185
+ const path = file?.webkitRelativePath || file?.name || ''
186
+ return file?.webkitRelativePath ? path.split('/').slice(1).join('/') : path
58
187
  }
59
188
 
189
+ // Finds a conventional favicon file in a bundle.
60
190
  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
191
+ const candidates = fileList.filter(file => {
192
+ const filename = filePath(file).split('/').pop().toLowerCase()
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
211
+ }
212
+
213
+ // Finds the best local icon referenced by HTML or its Web App Manifest.
214
+ export async function findAppIcon (fileList, htmlContent, indexFile, readFileText) {
215
+ const metadata = extractHtmlMetadata(htmlContent)
216
+ const indexPath = filePath(indexFile) || 'index.html'
217
+ const byPath = new Map(fileList.map(file => [filePath(file), file]))
218
+ const findFromSources = async sources => {
219
+ const candidates = []
220
+ let order = 0
221
+ for (const source of sources) {
222
+ const path = resolveAppPath(source.href, indexPath, metadata.baseHref)
223
+ const file = path && byPath.get(path)
224
+ if (!file) continue
225
+ if (source.kind !== 'manifest') {
226
+ candidates.push({ file, quality: iconQuality(source, path), order: order++ })
227
+ continue
68
228
  }
229
+
230
+ try {
231
+ const manifestText = readFileText ? await readFileText(file) : await file.text()
232
+ for (const icon of extractWebManifestIcons(manifestText)) {
233
+ const iconPath = resolveAppPath(icon.href, path)
234
+ if (iconPath && byPath.has(iconPath)) {
235
+ candidates.push({
236
+ file: byPath.get(iconPath),
237
+ quality: iconQuality(icon, iconPath),
238
+ order: order++
239
+ })
240
+ }
241
+ }
242
+ } catch (_) {}
69
243
  }
244
+ return candidates.sort((left, right) => right.quality - left.quality || left.order - right.order)[0]?.file || null
70
245
  }
71
- return null
246
+
247
+ const specificSources = metadata.iconSources.filter(source => !['tile-image', 'social-image'].includes(source.kind))
248
+ const socialSources = metadata.iconSources.filter(source => ['tile-image', 'social-image'].includes(source.kind))
249
+ return (await findFromSources(specificSources)) ||
250
+ findFavicon(fileList) ||
251
+ (await findFromSources(socialSources))
72
252
  }
73
253
 
254
+ // Finds the app entry HTML file.
74
255
  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
256
+ return fileList.find(file => {
257
+ const filename = filePath(file).split('/').pop().toLowerCase()
258
+ return filename === 'index.html' || filename === 'index.htm'
259
+ }) || null
82
260
  }
package/src/index.js CHANGED
@@ -4,7 +4,7 @@ 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'
@@ -110,9 +110,11 @@ export async function toApp (fileList, nostrSigner, {
110
110
  const indexFile = findIndexFile(fileList)
111
111
  let manifestName = nappJson.name?.[0]?.[0]
112
112
  let manifestSummary = nappJson.summary?.[0]?.[0]
113
- if (indexFile && (!manifestName || !manifestSummary)) {
113
+ let indexHtml = ''
114
+ if (indexFile) {
114
115
  try {
115
- const { name, description } = extractHtmlMetadata(await streamToText(indexFile.stream()))
116
+ indexHtml = await streamToText(indexFile.stream())
117
+ const { name, description } = extractHtmlMetadata(indexHtml)
116
118
  if (!manifestName) manifestName = name
117
119
  if (!manifestSummary) manifestSummary = description
118
120
  } catch (error) {
@@ -120,8 +122,9 @@ export async function toApp (fileList, nostrSigner, {
120
122
  }
121
123
  }
122
124
 
123
- const faviconFile = findFavicon(fileList)
125
+ const faviconFile = await findAppIcon(fileList, indexHtml, indexFile, file => streamToText(file.stream()))
124
126
  let iconMetadata
127
+ let isExplicitIcon = false
125
128
  let pause = 1000
126
129
 
127
130
  log('Checking for blossom servers...')
@@ -180,6 +183,7 @@ export async function toApp (fileList, nostrSigner, {
180
183
  try {
181
184
  log('Uploading icon from napp.json')
182
185
  iconMetadata = await uploadMediaFromDataUrl(nappJson.icon[0][0], 'icon')
186
+ isExplicitIcon = Boolean(iconMetadata)
183
187
  } catch (error) {
184
188
  log('Failed to upload icon from napp.json', error)
185
189
  }
@@ -244,7 +248,7 @@ export async function toApp (fileList, nostrSigner, {
244
248
  size: uploaded.size
245
249
  }
246
250
  fileMetadata.push(metadata)
247
- if (faviconFile && uploaded.file === faviconFile) iconMetadata = { ...metadata }
251
+ if (!iconMetadata && faviconFile && uploaded.file === faviconFile) iconMetadata = { ...metadata }
248
252
  steps++
249
253
  emit({ type: 'file-uploaded', filename: uploaded.filename, service: 'blossom' })
250
254
  }
@@ -275,7 +279,7 @@ export async function toApp (fileList, nostrSigner, {
275
279
  size: file.size
276
280
  }
277
281
  fileMetadata.push(metadata)
278
- if (faviconFile && file === faviconFile) iconMetadata = { ...metadata }
282
+ if (!iconMetadata && faviconFile && file === faviconFile) iconMetadata = { ...metadata }
279
283
  steps++
280
284
  emit({ type: 'file-uploaded', filename, service: 'irfs' })
281
285
  }
@@ -293,11 +297,13 @@ export async function toApp (fileList, nostrSigner, {
293
297
  summaryLang: nappJson.summary?.[0]?.[1],
294
298
  isSummaryAuto: !nappJson.summary?.[0]?.[0],
295
299
  icon: iconMetadata,
296
- isIconAuto: !nappJson.icon?.[0]?.[0],
300
+ isIconAuto: !isExplicitIcon,
297
301
  descriptions: nappJson.description,
298
302
  keyArt: keyArtMetadata,
299
303
  screenshots: screenshotMetadata,
300
304
  uploadService,
305
+ sourceRelays: writeRelays,
306
+ blossomServers: healthyBlossomServers,
301
307
  signer: nostrSigner,
302
308
  log,
303
309
  pause,
@@ -311,6 +317,7 @@ export async function toApp (fileList, nostrSigner, {
311
317
  const appEntity = appEncode({
312
318
  dTag: manifest.tags.find(tag => tag[0] === 'd')[1],
313
319
  pubkey: manifest.pubkey,
320
+ // Keep empty array to generate the shorter app entity.
314
321
  relays: [],
315
322
  kind: manifest.kind
316
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
  })
@@ -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.
@@ -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) }),