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 +2 -2
- package/src/helpers/app-metadata.js +237 -59
- package/src/index.js +14 -7
- package/src/services/blossom-upload.js +23 -22
- package/src/services/nostr-relays.js +1 -15
- package/src/services/site-manifest.js +25 -2
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.
|
|
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.
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
179
|
+
return null
|
|
55
180
|
}
|
|
181
|
+
}
|
|
56
182
|
|
|
57
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
const filename = (file
|
|
77
|
-
|
|
78
|
-
|
|
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,
|
|
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
|
-
|
|
113
|
+
let indexHtml = ''
|
|
114
|
+
if (indexFile) {
|
|
114
115
|
try {
|
|
115
|
-
|
|
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 =
|
|
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: !
|
|
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'
|
|
42
|
-
.
|
|
43
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 ${
|
|
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 ${
|
|
176
|
+
log(`${info.filename}: Already exists on ${serverUrl}`)
|
|
176
177
|
} else {
|
|
177
|
-
log(`${info.filename}: Uploaded to ${
|
|
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 ${
|
|
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', '
|
|
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) }),
|