nappup 1.9.0 → 2.0.0
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 +8 -11
- package/src/index.js +130 -626
- package/src/services/blossom-upload.js +3 -2
- package/src/services/bunker-signer.js +14 -8
- package/src/services/irfs-upload.js +81 -106
- package/src/services/nostr-signer.js +1 -1
- package/src/services/site-manifest.js +216 -0
- package/bin/GEMINI.md +0 -6
- package/src/helpers/GEMINI.md +0 -12
- package/src/helpers/bech32.js +0 -131
- package/src/helpers/nip19.js +0 -112
- package/src/services/GEMINI.md +0 -9
- package/src/services/base93-decoder.js +0 -107
- package/src/services/base93-encoder.js +0 -96
|
@@ -134,7 +134,7 @@ async function uploadFileToServer (serverUrl, signer, file, fileHash, mimeType,
|
|
|
134
134
|
* Different servers run in parallel.
|
|
135
135
|
*
|
|
136
136
|
* Returns { uploadedFiles: [...], failedFiles: [...] }
|
|
137
|
-
* where each uploadedFile has { file, filename, sha256, mimeType }
|
|
137
|
+
* where each uploadedFile has { file, filename, sha256, mimeType, size }
|
|
138
138
|
* and each failedFile has { file, filename, mimeType, errors }.
|
|
139
139
|
*/
|
|
140
140
|
export async function uploadFilesToBlossom ({
|
|
@@ -195,7 +195,8 @@ export async function uploadFilesToBlossom ({
|
|
|
195
195
|
file: info.file,
|
|
196
196
|
filename: info.filename,
|
|
197
197
|
sha256: info.sha256,
|
|
198
|
-
mimeType: info.mimeType
|
|
198
|
+
mimeType: info.mimeType,
|
|
199
|
+
size: info.file.size
|
|
199
200
|
})
|
|
200
201
|
} else {
|
|
201
202
|
failedFiles.push({
|
|
@@ -2,7 +2,6 @@ import { generateSecretKey } from 'nostr-tools/pure'
|
|
|
2
2
|
import { BunkerSigner, parseBunkerInput } from 'nostr-tools/nip46'
|
|
3
3
|
import { getRelays } from '#helpers/signer.js'
|
|
4
4
|
|
|
5
|
-
const CONNECT_TIMEOUT = 30_000
|
|
6
5
|
const createToken = Symbol('createToken')
|
|
7
6
|
|
|
8
7
|
export default class NostrBunkerSigner {
|
|
@@ -15,18 +14,25 @@ export default class NostrBunkerSigner {
|
|
|
15
14
|
this.#publicKey = publicKey
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
static async create (bunkerUrl) {
|
|
17
|
+
static async create (bunkerUrl, { connectTimeout = 30_000 } = {}) {
|
|
19
18
|
const bp = await parseBunkerInput(bunkerUrl)
|
|
20
19
|
if (!bp) throw new Error('Invalid bunker URL')
|
|
21
20
|
if (bp.relays.length === 0) throw new Error('Bunker URL must include at least one relay (?relay=wss://...)')
|
|
22
21
|
|
|
23
22
|
const clientSk = generateSecretKey()
|
|
24
|
-
const bunker =
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
const bunker = BunkerSigner.fromBunker(clientSk, bp)
|
|
24
|
+
|
|
25
|
+
let timeoutHandle
|
|
26
|
+
try {
|
|
27
|
+
await Promise.race([
|
|
28
|
+
bunker.connect(),
|
|
29
|
+
new Promise((resolve, reject) => {
|
|
30
|
+
timeoutHandle = setTimeout(() => reject(new Error('Bunker connection timed out')), connectTimeout)
|
|
31
|
+
})
|
|
32
|
+
])
|
|
33
|
+
} finally {
|
|
34
|
+
clearTimeout(timeoutHandle)
|
|
35
|
+
}
|
|
30
36
|
|
|
31
37
|
const publicKey = await bunker.getPublicKey()
|
|
32
38
|
return new this(createToken, bunker, publicKey)
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import nostrRelays, { nappRelays } from '#services/nostr-relays.js'
|
|
2
|
-
import
|
|
2
|
+
import NMMR from 'nmmr'
|
|
3
|
+
import { decode as base93Decode, encode as base93Encode } from 'libp2r2p/base93'
|
|
3
4
|
import { stringifyEvent } from '#helpers/event.js'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Uploads binary data chunks for a file to Nostr relays using the InterRelay File System (IRFS).
|
|
7
8
|
*
|
|
8
9
|
* Splits file content via NMMR (Nostr Merkle Mountain Range) into chunks,
|
|
9
|
-
* encodes each chunk with Base93, and publishes them as kind
|
|
10
|
-
* Supports resume
|
|
10
|
+
* encodes each chunk with Base93, and publishes them as kind 34601 events.
|
|
11
|
+
* Supports resume by querying the deterministic d tag of each MMR position.
|
|
11
12
|
*
|
|
12
13
|
* @param {object} params
|
|
13
14
|
* @param {object} params.nmmr - NMMR instance with chunks already appended
|
|
@@ -16,51 +17,30 @@ import { stringifyEvent } from '#helpers/event.js'
|
|
|
16
17
|
* @param {number} params.chunkLength - Total number of chunks
|
|
17
18
|
* @param {Function} params.log - Logging function
|
|
18
19
|
* @param {number} [params.pause=0] - Current pause duration in ms (for rate-limit backoff)
|
|
19
|
-
* @param {string} params.mimeType - MIME type of the file
|
|
20
20
|
* @param {boolean} [params.shouldReupload=false] - Whether to force re-upload existing chunks
|
|
21
21
|
* @returns {Promise<{pause: number}>} Updated pause duration
|
|
22
22
|
*/
|
|
23
|
-
export async function uploadBinaryDataChunks ({ nmmr, signer, filename, chunkLength, log, pause = 0,
|
|
24
|
-
const pubkey = await signer.getPublicKey()
|
|
23
|
+
export async function uploadBinaryDataChunks ({ nmmr, signer, filename, chunkLength, log, pause = 0, shouldReupload = false }) {
|
|
25
24
|
const writeRelays = (await signer.getRelays()).write
|
|
26
25
|
const relays = [...new Set([...writeRelays, ...nappRelays].map(r => r.trim().replace(/\/$/, '')))]
|
|
27
|
-
|
|
28
|
-
// Find max stored created_at for this file's chunks
|
|
29
26
|
const rootHash = nmmr.getRoot()
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
for (let i = 0; i < allCTags.length; i += 100) {
|
|
34
|
-
const batch = allCTags.slice(i, i + 100)
|
|
35
|
-
const storedEvents = (await nostrRelays.getEvents({
|
|
36
|
-
kinds: [34600],
|
|
37
|
-
authors: [pubkey],
|
|
38
|
-
'#c': batch,
|
|
39
|
-
limit: 1
|
|
40
|
-
}, relays)).result
|
|
41
|
-
|
|
42
|
-
if (storedEvents.length > 0) {
|
|
43
|
-
const batchMaxCreatedAt = storedEvents.reduce((m, e) => Math.max(m, (e && typeof e.created_at === 'number') ? e.created_at : 0), 0)
|
|
44
|
-
if (batchMaxCreatedAt > maxStoredCreatedAt) maxStoredCreatedAt = batchMaxCreatedAt
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Set initial created_at based on what's higher, maxStoredCreatedAt or current time
|
|
49
|
-
let createdAtCursor = (Math.max(maxStoredCreatedAt, Math.floor(Date.now() / 1000)) + chunkLength)
|
|
27
|
+
const dTags = Array.from({ length: chunkLength }, (_, index) => NMMR.deriveChunkId(rootHash, index))
|
|
28
|
+
const { eventsByD } = await getPreviousChunks(dTags, relays, signer)
|
|
50
29
|
|
|
51
30
|
let chunkIndex = 0
|
|
52
31
|
for await (const chunk of nmmr.getChunks()) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
32
|
+
if (chunk.total !== chunkLength || chunk.index !== chunkIndex) {
|
|
33
|
+
throw new Error('NMMR yielded inconsistent chunk metadata')
|
|
34
|
+
}
|
|
35
|
+
const dTag = NMMR.deriveChunkId(rootHash, chunk.index)
|
|
36
|
+
const storedEvents = eventsByD.get(dTag) ?? []
|
|
37
|
+
const validEvents = storedEvents.filter(event => isExpectedChunkEvent(event, { rootHash, index: chunk.index, total: chunk.total, dTag }))
|
|
38
|
+
.sort(compareEventsNewestFirst)
|
|
39
|
+
const foundEvent = validEvents[0]
|
|
40
|
+
const coveredRelays = new Set(validEvents.filter(event => event.id === foundEvent?.id).map(event => event.meta?.relay).filter(Boolean))
|
|
41
|
+
const missingRelays = relays.filter(relay => !coveredRelays.has(relay))
|
|
42
|
+
|
|
43
|
+
if (!shouldReupload && foundEvent) {
|
|
64
44
|
if (missingRelays.length === 0) {
|
|
65
45
|
log(`${filename}: Skipping chunk ${++chunkIndex} of ${chunkLength} (already uploaded)`)
|
|
66
46
|
continue
|
|
@@ -70,21 +50,18 @@ export async function uploadBinaryDataChunks ({ nmmr, signer, filename, chunkLen
|
|
|
70
50
|
continue
|
|
71
51
|
}
|
|
72
52
|
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
53
|
+
const now = Math.floor(Date.now() / 1000)
|
|
54
|
+
const newestStoredCreatedAt = storedEvents.reduce((latest, event) => Math.max(latest, Number.isSafeInteger(event.created_at) ? event.created_at : 0), 0)
|
|
55
|
+
const effectiveCreatedAt = Math.max(now, newestStoredCreatedAt + 1)
|
|
56
|
+
if (effectiveCreatedAt > now + 172800) throw new Error('Existing chunk timestamp is too far in the future to replace safely')
|
|
77
57
|
|
|
78
58
|
const binaryDataChunk = {
|
|
79
|
-
kind:
|
|
59
|
+
kind: 34601,
|
|
80
60
|
tags: [
|
|
81
61
|
['d', dTag],
|
|
82
|
-
|
|
83
|
-
['c', currentCtag, chunk.length, ...chunk.proof],
|
|
84
|
-
...(mimeType ? [['m', mimeType]] : [])
|
|
62
|
+
['mmr', String(chunk.index), String(chunk.total), base93Encode(chunk.proof)]
|
|
85
63
|
],
|
|
86
|
-
|
|
87
|
-
content: new Base93Encoder().update(chunk.contentBytes).getEncoded(),
|
|
64
|
+
content: base93Encode(chunk.contentBytes),
|
|
88
65
|
created_at: effectiveCreatedAt
|
|
89
66
|
}
|
|
90
67
|
|
|
@@ -96,6 +73,41 @@ export async function uploadBinaryDataChunks ({ nmmr, signer, filename, chunkLen
|
|
|
96
73
|
return { pause }
|
|
97
74
|
}
|
|
98
75
|
|
|
76
|
+
function compareEventsNewestFirst (a, b) {
|
|
77
|
+
if (a.created_at !== b.created_at) return b.created_at - a.created_at
|
|
78
|
+
return String(a.id).localeCompare(String(b.id))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function parseChunkEvent (event) {
|
|
82
|
+
if (!event || event.kind !== 34601 || !Array.isArray(event.tags)) throw new Error('Wrong chunk event kind or tags')
|
|
83
|
+
const dTags = event.tags.filter(tag => Array.isArray(tag) && tag[0] === 'd')
|
|
84
|
+
const mmrTags = event.tags.filter(tag => Array.isArray(tag) && tag[0] === 'mmr')
|
|
85
|
+
if (dTags.length !== 1 || dTags[0].length !== 2 || !/^[0-9a-f]{64}$/.test(dTags[0][1])) throw new Error('Wrong chunk d tag')
|
|
86
|
+
if (mmrTags.length !== 1 || mmrTags[0].length !== 4) throw new Error('Wrong chunk mmr tag')
|
|
87
|
+
|
|
88
|
+
const [, index, total, encodedProof] = mmrTags[0]
|
|
89
|
+
const contentBytes = base93Decode(event.content)
|
|
90
|
+
const proof = base93Decode(encodedProof)
|
|
91
|
+
const root = NMMR.calculateRoot({ contentBytes, index, total, proof })
|
|
92
|
+
const numericIndex = Number(index)
|
|
93
|
+
const numericTotal = Number(total)
|
|
94
|
+
if (contentBytes.length < 1 || contentBytes.length > 51000 || (numericIndex < numericTotal - 1 && contentBytes.length !== 51000)) {
|
|
95
|
+
throw new Error('Wrong chunk byte length')
|
|
96
|
+
}
|
|
97
|
+
const d = NMMR.deriveChunkId(root, index)
|
|
98
|
+
if (d !== dTags[0][1]) throw new Error('Chunk d tag mismatch')
|
|
99
|
+
return { root, index: numericIndex, total: numericTotal, proof, contentBytes, d }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isExpectedChunkEvent (event, expected) {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = parseChunkEvent(event)
|
|
105
|
+
return parsed.root === expected.rootHash && parsed.index === expected.index && parsed.total === expected.total && parsed.d === expected.dTag
|
|
106
|
+
} catch (_) {
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
99
111
|
/**
|
|
100
112
|
* Sends a signed Nostr event to relays with retry logic and rate-limit backoff.
|
|
101
113
|
*
|
|
@@ -180,62 +192,25 @@ export async function throttledSendEvent (event, relays, {
|
|
|
180
192
|
})
|
|
181
193
|
}
|
|
182
194
|
|
|
183
|
-
|
|
184
|
-
* Checks if a chunk (identified by its d-tag) already exists on relays.
|
|
185
|
-
*
|
|
186
|
-
* Returns info about existing c-tags on the stored event (for deduplication),
|
|
187
|
-
* whether the current c-tag is already present, the found event itself,
|
|
188
|
-
* and which relays are missing the event.
|
|
189
|
-
*
|
|
190
|
-
* @param {string} dTagValue - The d-tag value of the chunk event
|
|
191
|
-
* @param {string} currentCtagValue - The current c-tag value (rootHash:index)
|
|
192
|
-
* @param {string[]} relays - Array of relay URLs to check
|
|
193
|
-
* @param {object} signer - Nostr signer with getPublicKey()
|
|
194
|
-
* @returns {Promise<{otherCtags: Array, hasEvent: boolean, hasCurrentCtag: boolean, foundEvent?: object, missingRelays?: string[]}>}
|
|
195
|
-
*/
|
|
196
|
-
export async function getPreviousCtags (dTagValue, currentCtagValue, relays, signer) {
|
|
195
|
+
export async function getPreviousChunks (dTagValues, relays, signer) {
|
|
197
196
|
const targetRelays = [...new Set([...relays, ...nappRelays].map(r => r.trim().replace(/\/$/, '')))]
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
Array.isArray(tag) &&
|
|
218
|
-
tag[0] === 'c' &&
|
|
219
|
-
tag[1] === currentCtagValue
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
const otherCtags = prevTags
|
|
223
|
-
.filter(v => {
|
|
224
|
-
const isCTag =
|
|
225
|
-
Array.isArray(v) &&
|
|
226
|
-
v[0] === 'c' &&
|
|
227
|
-
typeof v[1] === 'string' &&
|
|
228
|
-
/^[0-9a-f]{64}:\d+$/.test(v[1])
|
|
229
|
-
if (!isCTag) return false
|
|
230
|
-
|
|
231
|
-
const isntDuplicate = !cTagValues[v[1]]
|
|
232
|
-
cTagValues[v[1]] = true
|
|
233
|
-
return isCTag && isntDuplicate
|
|
234
|
-
})
|
|
235
|
-
|
|
236
|
-
const matchingEvents = storedEvents.filter(e => e.id === bestEvent.id)
|
|
237
|
-
const coveredRelays = new Set(matchingEvents.map(e => e.meta?.relay).filter(Boolean))
|
|
238
|
-
const missingRelays = targetRelays.filter(r => !coveredRelays.has(r))
|
|
239
|
-
|
|
240
|
-
return { otherCtags, hasEvent, hasCurrentCtag, foundEvent: bestEvent, missingRelays }
|
|
197
|
+
const eventsByD = new Map(dTagValues.map(dTag => [dTag, []]))
|
|
198
|
+
const pubkey = await signer.getPublicKey()
|
|
199
|
+
|
|
200
|
+
for (let offset = 0; offset < dTagValues.length; offset += 100) {
|
|
201
|
+
const batch = dTagValues.slice(offset, offset + 100)
|
|
202
|
+
const storedEvents = (await nostrRelays.getEvents({
|
|
203
|
+
kinds: [34601],
|
|
204
|
+
authors: [pubkey],
|
|
205
|
+
'#d': batch,
|
|
206
|
+
limit: batch.length
|
|
207
|
+
}, targetRelays)).result
|
|
208
|
+
|
|
209
|
+
for (const event of storedEvents) {
|
|
210
|
+
const dTag = event.tags?.find(tag => tag[0] === 'd')?.[1]
|
|
211
|
+
if (eventsByD.has(dTag)) eventsByD.get(dTag).push(event)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { eventsByD, targetRelays }
|
|
241
216
|
}
|
|
@@ -8,7 +8,7 @@ import nostrRelays, { seedRelays, freeRelays } from '#services/nostr-relays.js'
|
|
|
8
8
|
import { getRelays } from '#helpers/signer.js'
|
|
9
9
|
import { bytesToBase16, base16ToBytes } from '#helpers/base16.js'
|
|
10
10
|
import { finalizeEvent } from '#helpers/nip01.js'
|
|
11
|
-
import { nsecDecode, nsecEncode } from '
|
|
11
|
+
import { nsecDecode, nsecEncode } from 'libp2r2p/nip19'
|
|
12
12
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
13
13
|
|
|
14
14
|
const dotenvPath = process.env.DOTENV_CONFIG_PATH ?? `${__dirname}/../../.env`
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { NAPP_CATEGORIES } from '#config/napp-categories.js'
|
|
2
|
+
import nostrRelays, { nappRelays } from '#services/nostr-relays.js'
|
|
3
|
+
import { throttledSendEvent } from '#services/irfs-upload.js'
|
|
4
|
+
|
|
5
|
+
const MANAGED_MANIFEST_TAGS = new Set([
|
|
6
|
+
'd', 'service', 'path', 'r', 'name', 'summary', 'description', 'self',
|
|
7
|
+
'c', 'l', 't', 'auto', 'icon', 'key_art', 'screenshot'
|
|
8
|
+
])
|
|
9
|
+
|
|
10
|
+
export function normalizeManifestPath (value) {
|
|
11
|
+
if (typeof value !== 'string') throw new TypeError('Manifest path must be a string')
|
|
12
|
+
const path = value.startsWith('/') ? value.slice(1) : value
|
|
13
|
+
if (!path || path.includes('\\') || /[\u0000-\u001f\u007f]/.test(path)) {
|
|
14
|
+
throw new Error(`Unsafe manifest path: ${JSON.stringify(value)}`)
|
|
15
|
+
}
|
|
16
|
+
const segments = path.split('/')
|
|
17
|
+
if (segments.some(segment => !segment || segment === '.' || segment === '..')) {
|
|
18
|
+
throw new Error(`Unsafe manifest path: ${JSON.stringify(value)}`)
|
|
19
|
+
}
|
|
20
|
+
return path
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validRoot (root) {
|
|
24
|
+
return typeof root === 'string' && /^[0-9a-f]{64}$/.test(root)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function decimalSize (size) {
|
|
28
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
29
|
+
throw new Error('Asset size must be a non-negative safe integer')
|
|
30
|
+
}
|
|
31
|
+
return String(size)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function addField (fields, type, value) {
|
|
35
|
+
if (typeof value === 'string' && value) fields.push(`${type} ${value}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildReferenceTags (uploadService, files, media) {
|
|
39
|
+
const tags = []
|
|
40
|
+
if (uploadService === 'blossom') {
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
if (!validRoot(file.rootHash)) throw new Error('Invalid Blossom SHA-256 hash')
|
|
43
|
+
tags.push(['path', normalizeManifestPath(file.filename), file.rootHash])
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const groups = new Map()
|
|
48
|
+
const addToGroup = (asset, fieldType, fieldValue, country) => {
|
|
49
|
+
if (!asset) return
|
|
50
|
+
if (!validRoot(asset.rootHash)) throw new Error('Invalid asset root hash')
|
|
51
|
+
const mimeType = typeof asset.mimeType === 'string' && asset.mimeType
|
|
52
|
+
? asset.mimeType
|
|
53
|
+
: 'application/octet-stream'
|
|
54
|
+
const size = decimalSize(asset.size)
|
|
55
|
+
const key = `${asset.rootHash}\u0000${mimeType}\u0000${size}`
|
|
56
|
+
let group = groups.get(key)
|
|
57
|
+
if (!group) {
|
|
58
|
+
group = { root: asset.rootHash, mimeType, size, fields: [] }
|
|
59
|
+
groups.set(key, group)
|
|
60
|
+
}
|
|
61
|
+
addField(group.fields, fieldType, fieldValue)
|
|
62
|
+
if (fieldType === 'mark' && country) addField(group.fields, 'country', country)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (uploadService === 'irfs') {
|
|
66
|
+
for (const file of files) addToGroup(file, 'path', normalizeManifestPath(file.filename))
|
|
67
|
+
}
|
|
68
|
+
for (const { asset, mark, country } of media) addToGroup(asset, 'mark', mark, country)
|
|
69
|
+
|
|
70
|
+
for (const group of groups.values()) {
|
|
71
|
+
tags.push(['r', group.root, ...group.fields, `m ${group.mimeType}`, `size ${group.size}`])
|
|
72
|
+
}
|
|
73
|
+
return tags
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildMetadataTags ({
|
|
77
|
+
name, nameLang, isNameAuto, summary, summaryLang, isSummaryAuto,
|
|
78
|
+
isIconAuto, hasIcon, descriptions, self, countries, categories, hashtags
|
|
79
|
+
}) {
|
|
80
|
+
const tags = []
|
|
81
|
+
const trimmedName = typeof name === 'string' ? name.trim() : ''
|
|
82
|
+
const trimmedSummary = typeof summary === 'string' ? summary.trim() : ''
|
|
83
|
+
|
|
84
|
+
if (Array.isArray(countries) && countries.length) {
|
|
85
|
+
for (const country of countries) {
|
|
86
|
+
if (typeof country === 'string' && country) tags.push(['c', country])
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
tags.push(['c', '*'])
|
|
90
|
+
}
|
|
91
|
+
if (typeof self === 'string' && self) tags.push(['self', self])
|
|
92
|
+
|
|
93
|
+
let categoryCount = 0
|
|
94
|
+
for (const entry of Array.isArray(categories) ? categories : []) {
|
|
95
|
+
if (categoryCount >= 3) break
|
|
96
|
+
if (!Array.isArray(entry)) continue
|
|
97
|
+
const [category, subcategories] = entry
|
|
98
|
+
for (const subcategory of Array.isArray(subcategories) ? subcategories : []) {
|
|
99
|
+
if (categoryCount >= 3) break
|
|
100
|
+
if (NAPP_CATEGORIES[category]?.includes(subcategory)) {
|
|
101
|
+
tags.push(['l', `napp.${category}:${subcategory}`])
|
|
102
|
+
categoryCount++
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const entry of (Array.isArray(hashtags) ? hashtags : []).slice(0, 3)) {
|
|
108
|
+
if (!Array.isArray(entry) || typeof entry[0] !== 'string') continue
|
|
109
|
+
const value = entry[0].replace(/\s/g, '').toLowerCase()
|
|
110
|
+
if (!value) continue
|
|
111
|
+
const tag = ['t', value]
|
|
112
|
+
if (typeof entry[1] === 'string' && entry[1]) tag.push(entry[1])
|
|
113
|
+
tags.push(tag)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (trimmedName) {
|
|
117
|
+
const tag = ['name', trimmedName]
|
|
118
|
+
if (typeof nameLang === 'string' && nameLang) tag.push(nameLang)
|
|
119
|
+
tags.push(tag)
|
|
120
|
+
if (isNameAuto) tags.push(['auto', 'name'])
|
|
121
|
+
}
|
|
122
|
+
if (trimmedSummary) {
|
|
123
|
+
const tag = ['summary', trimmedSummary]
|
|
124
|
+
if (typeof summaryLang === 'string' && summaryLang) tag.push(summaryLang)
|
|
125
|
+
tags.push(tag)
|
|
126
|
+
if (isSummaryAuto) tags.push(['auto', 'summary'])
|
|
127
|
+
}
|
|
128
|
+
for (const entry of Array.isArray(descriptions) ? descriptions : []) {
|
|
129
|
+
if (!Array.isArray(entry) || typeof entry[0] !== 'string' || !entry[0]) continue
|
|
130
|
+
const tag = ['description', entry[0]]
|
|
131
|
+
if (typeof entry[1] === 'string' && entry[1]) tag.push(entry[1])
|
|
132
|
+
tags.push(tag)
|
|
133
|
+
}
|
|
134
|
+
if (isIconAuto && hasIcon) tags.push(['auto', 'icon'])
|
|
135
|
+
return tags
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function buildManifestTags ({
|
|
139
|
+
dTag, uploadService, fileMetadata = [], icon, keyArt = [], screenshots = [],
|
|
140
|
+
previousTags = [], ...metadata
|
|
141
|
+
}) {
|
|
142
|
+
if (uploadService !== 'irfs' && uploadService !== 'blossom') {
|
|
143
|
+
throw new Error('Unknown upload service')
|
|
144
|
+
}
|
|
145
|
+
const media = []
|
|
146
|
+
if (icon) media.push({ asset: icon, mark: 'icon' })
|
|
147
|
+
for (const asset of keyArt) media.push({ asset, mark: 'key_art', country: asset.country })
|
|
148
|
+
for (const asset of screenshots) media.push({ asset, mark: 'screenshot', country: asset.country })
|
|
149
|
+
|
|
150
|
+
const unknownTags = (Array.isArray(previousTags) ? previousTags : [])
|
|
151
|
+
.filter(tag => Array.isArray(tag) && !MANAGED_MANIFEST_TAGS.has(tag[0]))
|
|
152
|
+
.slice(0, 10)
|
|
153
|
+
.map(tag => [...tag])
|
|
154
|
+
|
|
155
|
+
return [
|
|
156
|
+
['d', dTag],
|
|
157
|
+
...buildReferenceTags(uploadService, fileMetadata, media),
|
|
158
|
+
['service', uploadService],
|
|
159
|
+
...buildMetadataTags({ ...metadata, hasIcon: Boolean(icon) }),
|
|
160
|
+
...unknownTags
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function manifestKind (channel) {
|
|
165
|
+
return {
|
|
166
|
+
main: 35128,
|
|
167
|
+
next: 35129,
|
|
168
|
+
draft: 35130
|
|
169
|
+
}[channel] ?? 35128
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function newestFirst (a, b) {
|
|
173
|
+
if (b.created_at !== a.created_at) return b.created_at - a.created_at
|
|
174
|
+
return String(a.id).localeCompare(String(b.id))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function uploadSiteManifest ({
|
|
178
|
+
dTag, channel, fileMetadata, uploadService, signer, pause = 0,
|
|
179
|
+
shouldReupload = false, log = () => {}, ...metadata
|
|
180
|
+
}) {
|
|
181
|
+
const kind = manifestKind(channel)
|
|
182
|
+
const relays = [...new Set([...(await signer.getRelays()).write, ...nappRelays]
|
|
183
|
+
.map(relay => relay.trim().replace(/\/$/, '')))]
|
|
184
|
+
const events = (await nostrRelays.getEvents({
|
|
185
|
+
kinds: [kind],
|
|
186
|
+
authors: [await signer.getPublicKey()],
|
|
187
|
+
'#d': [dTag],
|
|
188
|
+
limit: 1
|
|
189
|
+
}, relays)).result
|
|
190
|
+
events.sort(newestFirst)
|
|
191
|
+
const previous = events[0]
|
|
192
|
+
const tags = buildManifestTags({
|
|
193
|
+
dTag, uploadService, fileMetadata, previousTags: previous?.tags, ...metadata
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
if (!shouldReupload && previous && previous.content === '' && JSON.stringify(previous.tags) === JSON.stringify(tags)) {
|
|
197
|
+
const coveredRelays = new Set(events
|
|
198
|
+
.filter(event => event.id === previous.id)
|
|
199
|
+
.map(event => event.meta?.relay)
|
|
200
|
+
.filter(Boolean))
|
|
201
|
+
const missingRelays = relays.filter(relay => !coveredRelays.has(relay))
|
|
202
|
+
if (!missingRelays.length) return previous
|
|
203
|
+
log(`Re-uploading existing site manifest to ${missingRelays.length} missing relays`)
|
|
204
|
+
await throttledSendEvent(previous, missingRelays, {
|
|
205
|
+
pause, trailingPause: true, log, minSuccessfulRelays: 0
|
|
206
|
+
})
|
|
207
|
+
return previous
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const now = Math.floor(Date.now() / 1000)
|
|
211
|
+
const createdAt = Math.max(now, (previous?.created_at ?? -1) + 1)
|
|
212
|
+
if (createdAt > now + 172800) throw new Error('Existing manifest timestamp is too far in the future to replace safely')
|
|
213
|
+
const event = await signer.signEvent({ kind, tags, content: '', created_at: createdAt })
|
|
214
|
+
await throttledSendEvent(event, relays, { pause, trailingPause: true, log })
|
|
215
|
+
return event
|
|
216
|
+
}
|
package/bin/GEMINI.md
DELETED
package/src/helpers/GEMINI.md
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# helpers folder
|
|
2
|
-
|
|
3
|
-
- Place helper functions. A helper function is a single-step functionality to be re-used through out the code base.
|
|
4
|
-
- Each functionality is exported as a single function.
|
|
5
|
-
- These functions are not meant to be used within the test folder or sub-folders, except for being unit-tested themselves.
|
|
6
|
-
|
|
7
|
-
## General Instructions:
|
|
8
|
-
|
|
9
|
-
- Prefer grouping functions that deal with the same type of variable within the
|
|
10
|
-
same file, preferably named after the type name. E.g.: use `helpers/string.js` for
|
|
11
|
-
exporting helper functions that handle strings, or e.g.: use helpers/stream.js` to
|
|
12
|
-
deal with streams.
|
package/src/helpers/bech32.js
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
const ALPHABET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
|
|
2
|
-
|
|
3
|
-
const ALPHABET_MAP = {}
|
|
4
|
-
for (let z = 0; z < ALPHABET.length; z++) {
|
|
5
|
-
const x = ALPHABET.charAt(z)
|
|
6
|
-
ALPHABET_MAP[x] = z
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function polymod (values) {
|
|
10
|
-
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
|
11
|
-
let chk = 1
|
|
12
|
-
for (let p = 0; p < values.length; ++p) {
|
|
13
|
-
const top = chk >> 25
|
|
14
|
-
chk = (chk & 0x1ffffff) << 5 ^ values[p]
|
|
15
|
-
for (let i = 0; i < 5; ++i) {
|
|
16
|
-
if ((top >> i) & 1) {
|
|
17
|
-
chk ^= GEN[i]
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
return chk
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function hrpExpand (hrp) {
|
|
25
|
-
const ret = []
|
|
26
|
-
for (let p = 0; p < hrp.length; ++p) {
|
|
27
|
-
ret.push(hrp.charCodeAt(p) >> 5)
|
|
28
|
-
}
|
|
29
|
-
ret.push(0)
|
|
30
|
-
for (let p = 0; p < hrp.length; ++p) {
|
|
31
|
-
ret.push(hrp.charCodeAt(p) & 31)
|
|
32
|
-
}
|
|
33
|
-
return ret
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function verifyChecksum (hrp, data) {
|
|
37
|
-
return polymod(hrpExpand(hrp).concat(data)) === 1
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function createChecksum (hrp, data) {
|
|
41
|
-
const values = hrpExpand(hrp).concat(data).concat([0, 0, 0, 0, 0, 0])
|
|
42
|
-
const mod = polymod(values) ^ 1
|
|
43
|
-
const ret = []
|
|
44
|
-
for (let p = 0; p < 6; ++p) {
|
|
45
|
-
ret.push((mod >> 5 * (5 - p)) & 31)
|
|
46
|
-
}
|
|
47
|
-
return ret
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function encode (hrp, data) {
|
|
51
|
-
const combined = data.concat(createChecksum(hrp, data))
|
|
52
|
-
let ret = hrp + '1'
|
|
53
|
-
for (let p = 0; p < combined.length; ++p) {
|
|
54
|
-
ret += ALPHABET.charAt(combined[p])
|
|
55
|
-
}
|
|
56
|
-
return ret
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function decode (bechString, limit = 90) {
|
|
60
|
-
let p
|
|
61
|
-
let hasLower = false
|
|
62
|
-
let hasUpper = false
|
|
63
|
-
for (p = 0; p < bechString.length; ++p) {
|
|
64
|
-
if (bechString.charCodeAt(p) < 33 || bechString.charCodeAt(p) > 126) {
|
|
65
|
-
return null
|
|
66
|
-
}
|
|
67
|
-
if (bechString.charCodeAt(p) >= 97 && bechString.charCodeAt(p) <= 122) {
|
|
68
|
-
hasLower = true
|
|
69
|
-
}
|
|
70
|
-
if (bechString.charCodeAt(p) >= 65 && bechString.charCodeAt(p) <= 90) {
|
|
71
|
-
hasUpper = true
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
if (hasLower && hasUpper) {
|
|
75
|
-
return null
|
|
76
|
-
}
|
|
77
|
-
bechString = bechString.toLowerCase()
|
|
78
|
-
const pos = bechString.lastIndexOf('1')
|
|
79
|
-
if (pos < 1 || pos + 7 > bechString.length || bechString.length > limit) {
|
|
80
|
-
return null
|
|
81
|
-
}
|
|
82
|
-
const hrp = bechString.substring(0, pos)
|
|
83
|
-
const data = []
|
|
84
|
-
for (p = pos + 1; p < bechString.length; ++p) {
|
|
85
|
-
const d = ALPHABET_MAP[bechString.charAt(p)]
|
|
86
|
-
if (d === undefined) {
|
|
87
|
-
return null
|
|
88
|
-
}
|
|
89
|
-
data.push(d)
|
|
90
|
-
}
|
|
91
|
-
if (!verifyChecksum(hrp, data)) {
|
|
92
|
-
return null
|
|
93
|
-
}
|
|
94
|
-
return { hrp, data: data.slice(0, data.length - 6) }
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export function toWords (data) {
|
|
98
|
-
let value = 0
|
|
99
|
-
let bits = 0
|
|
100
|
-
const maxV = 31
|
|
101
|
-
const result = []
|
|
102
|
-
for (let i = 0; i < data.length; ++i) {
|
|
103
|
-
value = (value << 8) | data[i]
|
|
104
|
-
bits += 8
|
|
105
|
-
while (bits >= 5) {
|
|
106
|
-
bits -= 5
|
|
107
|
-
result.push((value >> bits) & maxV)
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
if (bits > 0) {
|
|
111
|
-
result.push((value << (5 - bits)) & maxV)
|
|
112
|
-
}
|
|
113
|
-
return result
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function fromWords (data) {
|
|
117
|
-
let value = 0
|
|
118
|
-
let bits = 0
|
|
119
|
-
const maxV = 255
|
|
120
|
-
const result = []
|
|
121
|
-
for (let i = 0; i < data.length; ++i) {
|
|
122
|
-
const element = data[i]
|
|
123
|
-
value = (value << 5) | element
|
|
124
|
-
bits += 5
|
|
125
|
-
while (bits >= 8) {
|
|
126
|
-
bits -= 8
|
|
127
|
-
result.push((value >> bits) & maxV)
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return result
|
|
131
|
-
}
|