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 +2 -2
- package/src/helpers/app-metadata.js +55 -14
- package/src/index.js +8 -3
- package/src/services/blossom-upload.js +23 -22
- 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,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
|
-
|
|
92
|
-
|
|
93
|
-
else if (rels.has('apple-touch-icon
|
|
94
|
-
else if (rels.has('
|
|
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 }) => ({
|
|
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 }) => ({
|
|
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
|
-
|
|
191
|
+
const candidates = fileList.filter(file => {
|
|
178
192
|
const filename = filePath(file).split('/').pop().toLowerCase()
|
|
179
|
-
return
|
|
180
|
-
})
|
|
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')
|
|
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))
|
|
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: !
|
|
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'
|
|
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
|
})
|
|
@@ -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) }),
|