nappup 1.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/bin/GEMINI.md +6 -0
- package/bin/nappup/helpers.js +83 -0
- package/bin/nappup/index.js +17 -0
- package/lib/GEMINI.md +4 -0
- package/lib/base122.js +171 -0
- package/package.json +54 -0
- package/src/helpers/.gitkeep +0 -0
- package/src/helpers/GEMINI.md +12 -0
- package/src/helpers/stream.js +20 -0
- package/src/helpers/timer.js +4 -0
- package/src/index.js +131 -0
- package/src/services/.gitkeep +0 -0
- package/src/services/GEMINI.md +9 -0
- package/src/services/base122-decoder.js +56 -0
- package/src/services/base122-encoder.js +19 -0
- package/src/services/nostr-relays.js +116 -0
- package/src/services/nostr-signer.js +157 -0
package/bin/GEMINI.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import readline from 'node:readline'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { Readable } from 'node:stream'
|
|
5
|
+
import mime from 'mime-types'
|
|
6
|
+
import { fileTypeFromFile } from 'file-type'
|
|
7
|
+
|
|
8
|
+
export function parseArgs (args) {
|
|
9
|
+
let dir = null
|
|
10
|
+
let sk = null
|
|
11
|
+
let appId = null
|
|
12
|
+
let channel = null
|
|
13
|
+
|
|
14
|
+
for (let i = 0; i < args.length; i++) {
|
|
15
|
+
if (args[i] === '-s' && args[i + 1]) {
|
|
16
|
+
sk = args[i + 1]
|
|
17
|
+
i++ // Skip the next argument as it's part of -k
|
|
18
|
+
} else if (args[i] === '-i' && args[i + 1]) {
|
|
19
|
+
appId = args[i + 1]
|
|
20
|
+
i++
|
|
21
|
+
} else if (args[i] === '--main' && channel === null) {
|
|
22
|
+
channel = 'main'
|
|
23
|
+
} else if (args[i] === '--next' && channel === null) {
|
|
24
|
+
channel = 'next'
|
|
25
|
+
} else if (args[i] === '--draft' && channel === null) {
|
|
26
|
+
channel = 'draft'
|
|
27
|
+
} else if (!args[i].startsWith('-') && dir === null) {
|
|
28
|
+
dir = args[i]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
dir: path.resolve(dir ?? '.'),
|
|
34
|
+
sk,
|
|
35
|
+
appId,
|
|
36
|
+
channel: channel || 'main'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function confirmArgs (args) {
|
|
41
|
+
const rl = readline.createInterface({
|
|
42
|
+
input: process.stdin,
|
|
43
|
+
output: process.stdout
|
|
44
|
+
})
|
|
45
|
+
function askQuestion (query) {
|
|
46
|
+
return new Promise(resolve => rl.question(query, resolve))
|
|
47
|
+
}
|
|
48
|
+
const answer = await askQuestion(
|
|
49
|
+
`Publish app from '${args.dir}' to the ${args.channel} release channel? (y/n) `
|
|
50
|
+
)
|
|
51
|
+
if (answer.toLowerCase() !== 'y') {
|
|
52
|
+
console.log('Operation cancelled by user.')
|
|
53
|
+
rl.close()
|
|
54
|
+
process.exit(0)
|
|
55
|
+
}
|
|
56
|
+
rl.close()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function * getFiles (dir) {
|
|
60
|
+
const dirents = await fs.promises.readdir(dir, { withFileTypes: true })
|
|
61
|
+
for (const dirent of dirents) {
|
|
62
|
+
const res = path.resolve(dir, dirent.name)
|
|
63
|
+
if (dirent.isDirectory()) {
|
|
64
|
+
yield * getFiles(res)
|
|
65
|
+
} else {
|
|
66
|
+
yield res
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function toFileList (filesIterator, dir) {
|
|
72
|
+
const fileList = []
|
|
73
|
+
for await (const f of filesIterator) {
|
|
74
|
+
const fileType = mime.lookup(f)
|
|
75
|
+
const file = {
|
|
76
|
+
stream: () => Readable.toWeb(fs.createReadStream(f)),
|
|
77
|
+
webkitRelativePath: path.relative(dir.replace(/\/[^/]*$/, ''), f),
|
|
78
|
+
type: fileType || (await fileTypeFromFile(f))?.mime || ''
|
|
79
|
+
}
|
|
80
|
+
fileList.push(file)
|
|
81
|
+
}
|
|
82
|
+
return fileList
|
|
83
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import NostrSigner from '#services/nostr-signer.js'
|
|
3
|
+
import {
|
|
4
|
+
parseArgs,
|
|
5
|
+
confirmArgs,
|
|
6
|
+
toFileList,
|
|
7
|
+
getFiles
|
|
8
|
+
} from './helpers.js'
|
|
9
|
+
import toApp from '#index.js'
|
|
10
|
+
|
|
11
|
+
const args = parseArgs(process.argv.slice(2))
|
|
12
|
+
await confirmArgs(args)
|
|
13
|
+
|
|
14
|
+
const { dir, sk, appId, channel } = args
|
|
15
|
+
const fileList = await toFileList(getFiles(dir), dir)
|
|
16
|
+
|
|
17
|
+
await toApp(fileList, await NostrSigner.create(sk), { log: console.log.bind(console), appId, channel })
|
package/lib/GEMINI.md
ADDED
package/lib/base122.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// https://github.com/kevinAlbs/Base122/commit/b62945c2733fa4da8792a1071e40a8b326e8dd1b
|
|
2
|
+
// Provides functions for encoding/decoding data to and from base-122.
|
|
3
|
+
|
|
4
|
+
const kString = 0
|
|
5
|
+
const kUint8Array = 1
|
|
6
|
+
const kDebug = false
|
|
7
|
+
const kIllegals = [
|
|
8
|
+
0, // null
|
|
9
|
+
10, // newline
|
|
10
|
+
13, // carriage return
|
|
11
|
+
34, // double quote
|
|
12
|
+
38, // ampersand
|
|
13
|
+
92 // backslash
|
|
14
|
+
]
|
|
15
|
+
const kShortened = 0b111 // Uses the illegal index to signify the last two-byte char encodes <= 7 bits.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Encodes raw data into base-122.
|
|
19
|
+
* @param {Uint8Array|Buffer|Array|String} rawData - The data to be encoded. This can be an array
|
|
20
|
+
* or Buffer with raw data bytes or a string of bytes (i.e. the type of argument to btoa())
|
|
21
|
+
* @returns {Array} The base-122 encoded data as a regular array of UTF-8 character byte values.
|
|
22
|
+
*/
|
|
23
|
+
function encode (rawData) {
|
|
24
|
+
const dataType = typeof (rawData) === 'string' ? kString : kUint8Array
|
|
25
|
+
let curIndex = 0
|
|
26
|
+
let curBit = 0 // Points to current bit needed
|
|
27
|
+
// const curMask = 0b10000000
|
|
28
|
+
const outData = []
|
|
29
|
+
let getByte = i => rawData[i]
|
|
30
|
+
|
|
31
|
+
if (dataType === kString) {
|
|
32
|
+
getByte = (i) => {
|
|
33
|
+
const val = rawData.codePointAt(i)
|
|
34
|
+
if (val > 255) {
|
|
35
|
+
throw new Error('Unexpected code point at position: ' + i + '. Expected value [0,255]. Got: ' + val)
|
|
36
|
+
}
|
|
37
|
+
return val
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Get seven bits of input data. Returns false if there is no input left.
|
|
42
|
+
function get7 () {
|
|
43
|
+
if (curIndex >= rawData.length) return false
|
|
44
|
+
// Shift, mask, unshift to get first part.
|
|
45
|
+
const firstByte = getByte(curIndex)
|
|
46
|
+
let firstPart = ((0b11111110 >>> curBit) & firstByte) << curBit
|
|
47
|
+
// Align it to a seven bit chunk.
|
|
48
|
+
firstPart >>= 1
|
|
49
|
+
// Check if we need to go to the next byte for more bits.
|
|
50
|
+
curBit += 7
|
|
51
|
+
if (curBit < 8) return firstPart // Do not need next byte.
|
|
52
|
+
curBit -= 8
|
|
53
|
+
curIndex++
|
|
54
|
+
// Now we want bits [0..curBit] of the next byte if it exists.
|
|
55
|
+
if (curIndex >= rawData.length) return firstPart
|
|
56
|
+
const secondByte = getByte(curIndex)
|
|
57
|
+
let secondPart = ((0xFF00 >>> curBit) & secondByte) & 0xFF
|
|
58
|
+
// Align it.
|
|
59
|
+
secondPart >>= 8 - curBit
|
|
60
|
+
return firstPart | secondPart
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
while (true) {
|
|
64
|
+
// Grab 7 bits.
|
|
65
|
+
const bits = get7()
|
|
66
|
+
if (bits === false) break
|
|
67
|
+
debugLog('Seven input bits', print7Bits(bits), bits)
|
|
68
|
+
|
|
69
|
+
const illegalIndex = kIllegals.indexOf(bits)
|
|
70
|
+
if (illegalIndex !== -1) {
|
|
71
|
+
// Since this will be a two-byte character, get the next chunk of seven bits.
|
|
72
|
+
let nextBits = get7()
|
|
73
|
+
debugLog('Handle illegal sequence', print7Bits(bits), print7Bits(nextBits))
|
|
74
|
+
|
|
75
|
+
let b1 = 0b11000010; let b2 = 0b10000000
|
|
76
|
+
if (nextBits === false) {
|
|
77
|
+
debugLog('Last seven bits are an illegal sequence.')
|
|
78
|
+
b1 |= (0b111 & kShortened) << 2
|
|
79
|
+
nextBits = bits // Encode these bits after the shortened signifier.
|
|
80
|
+
} else {
|
|
81
|
+
b1 |= (0b111 & illegalIndex) << 2
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Push first bit onto first byte, remaining 6 onto second.
|
|
85
|
+
const firstBit = (nextBits & 0b01000000) > 0 ? 1 : 0
|
|
86
|
+
b1 |= firstBit
|
|
87
|
+
b2 |= nextBits & 0b00111111
|
|
88
|
+
outData.push(b1)
|
|
89
|
+
outData.push(b2)
|
|
90
|
+
} else {
|
|
91
|
+
outData.push(bits)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return outData
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Decodes base-122 encoded data back to the original data.
|
|
99
|
+
* @param {Uint8Array|Buffer|String} rawData - The data to be decoded. This can be a Uint8Array
|
|
100
|
+
* or Buffer with raw data bytes or a string of bytes (i.e. the type of argument to btoa())
|
|
101
|
+
* @returns {Array} The data in a regular array representing byte values.
|
|
102
|
+
*/
|
|
103
|
+
function decode (base122Data) {
|
|
104
|
+
const strData = typeof (base122Data) === 'string' ? base122Data : utf8DataToString(base122Data)
|
|
105
|
+
const decoded = []
|
|
106
|
+
// const decodedIndex = 0
|
|
107
|
+
let curByte = 0
|
|
108
|
+
let bitOfByte = 0
|
|
109
|
+
|
|
110
|
+
function push7 (byte) {
|
|
111
|
+
byte <<= 1
|
|
112
|
+
// Align this byte to offset for current byte.
|
|
113
|
+
curByte |= (byte >>> bitOfByte)
|
|
114
|
+
bitOfByte += 7
|
|
115
|
+
if (bitOfByte >= 8) {
|
|
116
|
+
decoded.push(curByte)
|
|
117
|
+
bitOfByte -= 8
|
|
118
|
+
// Now, take the remainder, left shift by what has been taken.
|
|
119
|
+
curByte = (byte << (7 - bitOfByte)) & 255
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (let i = 0; i < strData.length; i++) {
|
|
124
|
+
const c = strData.charCodeAt(i)
|
|
125
|
+
// Check if this is a two-byte character.
|
|
126
|
+
if (c > 127) {
|
|
127
|
+
// Note, the charCodeAt will give the codePoint, thus
|
|
128
|
+
// 0b110xxxxx 0b10yyyyyy will give => xxxxxyyyyyy
|
|
129
|
+
const illegalIndex = (c >>> 8) & 7 // 7 = 0b111.
|
|
130
|
+
// We have to first check if this is a shortened two-byte character, i.e. if it only
|
|
131
|
+
// encodes <= 7 bits.
|
|
132
|
+
if (illegalIndex !== kShortened) push7(kIllegals[illegalIndex])
|
|
133
|
+
// Always push the rest.
|
|
134
|
+
push7(c & 127)
|
|
135
|
+
} else {
|
|
136
|
+
// One byte characters can be pushed directly.
|
|
137
|
+
push7(c)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return decoded
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Converts a sequence of UTF-8 bytes to a string.
|
|
145
|
+
* @param {Uint8Array|Buffer} data - The UTF-8 data.
|
|
146
|
+
* @returns {String} A string with each character representing a code point.
|
|
147
|
+
*/
|
|
148
|
+
function utf8DataToString (data) {
|
|
149
|
+
return Buffer.from(data).toString('utf-8')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// For debugging.
|
|
153
|
+
function debugLog () {
|
|
154
|
+
if (kDebug) console.log(...arguments)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// For debugging.
|
|
158
|
+
function print7Bits (num) {
|
|
159
|
+
return '0000000'.substring(num.toString(2).length) + num.toString(2)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// For debugging.
|
|
163
|
+
// eslint-disable-next-line no-unused-vars
|
|
164
|
+
function print8Bits (num) {
|
|
165
|
+
return '00000000'.substring(num.toString(2).length) + num.toString(2)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export {
|
|
169
|
+
encode,
|
|
170
|
+
decode
|
|
171
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nappup",
|
|
3
|
+
"author": "Arthur França",
|
|
4
|
+
"repository": "github:44billion/nappup",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"description": "Nostr App Uploader",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test 'test/**/*.test.js'"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"nappup": "./bin/nappup/index.js"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"dotenv": "^17.2.0",
|
|
17
|
+
"file-type": "^21.0.0",
|
|
18
|
+
"mime-types": "^3.0.1",
|
|
19
|
+
"nmmr": "^1.0.3",
|
|
20
|
+
"nostr-tools": "^2.15.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"eslint": "^9.22.0",
|
|
24
|
+
"eslint-plugin-html": "^8.1.2",
|
|
25
|
+
"globals": "^15.12.0",
|
|
26
|
+
"neostandard": "^0.12.1"
|
|
27
|
+
},
|
|
28
|
+
"imports": {
|
|
29
|
+
"#bin/*": "./bin/*",
|
|
30
|
+
"#lib/*": "./lib/*",
|
|
31
|
+
"#*": [
|
|
32
|
+
"./src/*"
|
|
33
|
+
],
|
|
34
|
+
"#helpers/*": "./src/helpers/*",
|
|
35
|
+
"#services/*": "./src/services/*"
|
|
36
|
+
},
|
|
37
|
+
"exports": {
|
|
38
|
+
".": "./src/index.js"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"nostr",
|
|
42
|
+
"app",
|
|
43
|
+
"uploader",
|
|
44
|
+
"upload"
|
|
45
|
+
],
|
|
46
|
+
"files": [
|
|
47
|
+
"src",
|
|
48
|
+
"src/helpers",
|
|
49
|
+
"src/services",
|
|
50
|
+
"lib",
|
|
51
|
+
"bin",
|
|
52
|
+
"bin/nappup"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
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.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Receives a stream and yields Uint8Array binary chunks of a given size.
|
|
2
|
+
// The last chunk may be smaller than the chunkSize.
|
|
3
|
+
export async function * streamToChunks (stream, chunkSize) {
|
|
4
|
+
let buffer = new Uint8Array(0)
|
|
5
|
+
|
|
6
|
+
for await (const chunk of stream) {
|
|
7
|
+
const newBuffer = new Uint8Array(buffer.length + chunk.length)
|
|
8
|
+
newBuffer.set(buffer)
|
|
9
|
+
newBuffer.set(chunk, buffer.length)
|
|
10
|
+
buffer = newBuffer
|
|
11
|
+
|
|
12
|
+
while (buffer.length >= chunkSize) {
|
|
13
|
+
const chunkToYield = buffer.slice(0, chunkSize)
|
|
14
|
+
buffer = buffer.slice(chunkSize)
|
|
15
|
+
yield chunkToYield
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (buffer.length > 0) yield buffer
|
|
20
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import NMMR from 'nmmr'
|
|
2
|
+
import { naddrEncode } from 'nostr-tools/nip19'
|
|
3
|
+
import Base122Encoder from '#services/base122-encoder.js'
|
|
4
|
+
import nostrRelays from '#services/nostr-relays.js'
|
|
5
|
+
import NostrSigner from '#services/nostr-signer.js'
|
|
6
|
+
import { streamToChunks } from '#helpers/stream.js'
|
|
7
|
+
|
|
8
|
+
export default async function (...args) {
|
|
9
|
+
try {
|
|
10
|
+
return await toApp(...args)
|
|
11
|
+
} finally {
|
|
12
|
+
await nostrRelays.disconnectAll()
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function toApp (fileList, nostrSigner, { log = () => {}, appId, channel = 'main' } = {}) {
|
|
16
|
+
if (!nostrSigner && typeof window !== 'undefined') nostrSigner = window.nostr
|
|
17
|
+
if (!nostrSigner) throw new Error('No Nostr signer found')
|
|
18
|
+
if (typeof window !== 'undefined' && nostrSigner === window.nostr) {
|
|
19
|
+
nostrSigner.getRelays = NostrSigner.prototype.getRelays
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
appId ||= fileList[0].webkitRelativePath.split('/')[0]
|
|
23
|
+
.trim().replace(/[\s-]/g, '').toLowerCase().slice(0, 32)
|
|
24
|
+
let nmmr
|
|
25
|
+
const fileMetadata = []
|
|
26
|
+
|
|
27
|
+
log(`Processing ${fileList.length} files`)
|
|
28
|
+
for (const file of fileList) {
|
|
29
|
+
nmmr = new NMMR()
|
|
30
|
+
const stream = file.stream()
|
|
31
|
+
|
|
32
|
+
let chunkLength = 0
|
|
33
|
+
for await (const chunk of streamToChunks(stream, 54600)) {
|
|
34
|
+
chunkLength++
|
|
35
|
+
nmmr.append(chunk)
|
|
36
|
+
}
|
|
37
|
+
if (chunkLength) {
|
|
38
|
+
// remove root dir
|
|
39
|
+
const filename = file.webkitRelativePath.split('/').slice(1).join('/')
|
|
40
|
+
log(`Uploading ${chunkLength} file parts of ${filename}`)
|
|
41
|
+
await uploadBinaryDataChunks(nmmr, nostrSigner, { mimeType: file.type || 'application/octet-stream' })
|
|
42
|
+
fileMetadata.push({
|
|
43
|
+
rootHash: nmmr.getRoot(),
|
|
44
|
+
filename,
|
|
45
|
+
mimeType: file.type || 'application/octet-stream'
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
log(`Uploading bundle #${appId}`)
|
|
51
|
+
const bundle = await uploadBundle(appId, channel, fileMetadata, nostrSigner)
|
|
52
|
+
|
|
53
|
+
const naddr = naddrEncode({
|
|
54
|
+
identifier: bundle.tags.find(v => v[0] === 'd')[1],
|
|
55
|
+
pubkey: bundle.pubkey,
|
|
56
|
+
relays: [],
|
|
57
|
+
kind: bundle.kind
|
|
58
|
+
})
|
|
59
|
+
log(`Visit at https://44billion.net/${naddr}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function uploadBinaryDataChunks (nmmr, signer, { mimeType } = {}) {
|
|
63
|
+
const writeRelays = (await signer.getRelays()).write
|
|
64
|
+
for await (const chunk of nmmr.getChunks()) {
|
|
65
|
+
const dTag = chunk.x
|
|
66
|
+
const currentCtag = `${chunk.rootX}:${chunk.index}`
|
|
67
|
+
const prevCTags = await getPreviousCtags(dTag, currentCtag, writeRelays, signer)
|
|
68
|
+
const binaryDataChunk = {
|
|
69
|
+
kind: 34600,
|
|
70
|
+
tags: [
|
|
71
|
+
['d', dTag],
|
|
72
|
+
...prevCTags,
|
|
73
|
+
['c', currentCtag, chunk.length, ...chunk.proof],
|
|
74
|
+
...(mimeType ? [['m', mimeType]] : [])
|
|
75
|
+
],
|
|
76
|
+
// These chunks already have the expected size of 54600 bytes
|
|
77
|
+
content: new Base122Encoder().update(chunk.contentBytes).getEncoded(),
|
|
78
|
+
created_at: Math.floor(Date.now() / 1000)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const event = await signer.signEvent(binaryDataChunk)
|
|
82
|
+
await nostrRelays.sendEvent(event, writeRelays)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function getPreviousCtags (dTagValue, currentCtagValue, writeRelays, signer) {
|
|
87
|
+
const storedEvents = await nostrRelays.getEvents({
|
|
88
|
+
kinds: [34600],
|
|
89
|
+
authors: [await signer.getPublicKey()],
|
|
90
|
+
'#d': [dTagValue],
|
|
91
|
+
limit: 1
|
|
92
|
+
}, writeRelays)
|
|
93
|
+
if (storedEvents.length === 0) return []
|
|
94
|
+
|
|
95
|
+
const cTagValues = { [currentCtagValue]: true }
|
|
96
|
+
const prevTags = storedEvents.sort((a, b) => b.created_at - a.created_at)[0].tags
|
|
97
|
+
if (!Array.isArray(prevTags)) return []
|
|
98
|
+
return prevTags
|
|
99
|
+
.filter(v => {
|
|
100
|
+
const isCTag =
|
|
101
|
+
Array.isArray(v) &&
|
|
102
|
+
v[0] === 'c' &&
|
|
103
|
+
typeof v[1] === 'string' &&
|
|
104
|
+
/^[0-9a-f]{64}:\d+$/.test(v[1])
|
|
105
|
+
if (!isCTag) return false
|
|
106
|
+
|
|
107
|
+
const isntDuplicate = !cTagValues[v[1]]
|
|
108
|
+
cTagValues[v[1]] = true
|
|
109
|
+
return isCTag && isntDuplicate
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function uploadBundle (appId, channel, fileMetadata, signer) {
|
|
114
|
+
const kind = {
|
|
115
|
+
main: 37448, // stable
|
|
116
|
+
next: 37449, // insider
|
|
117
|
+
draft: 37450 // vibe coded preview
|
|
118
|
+
}[channel] ?? 37448
|
|
119
|
+
const appBundle = {
|
|
120
|
+
kind,
|
|
121
|
+
tags: [
|
|
122
|
+
['d', appId],
|
|
123
|
+
...fileMetadata.map(v => ['file', v.rootHash, v.filename, v.mimeType])
|
|
124
|
+
],
|
|
125
|
+
content: '',
|
|
126
|
+
created_at: Math.floor(Date.now() / 1000)
|
|
127
|
+
}
|
|
128
|
+
const event = await signer.signEvent(appBundle)
|
|
129
|
+
await nostrRelays.sendEvent(event, (await signer.getRelays()).write)
|
|
130
|
+
return event
|
|
131
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# src/services folder
|
|
2
|
+
|
|
3
|
+
- Place services here. Services are multi-step functionality that make the code more
|
|
4
|
+
readable than if placing every step together on a single function.
|
|
5
|
+
|
|
6
|
+
## General Instructions:
|
|
7
|
+
|
|
8
|
+
- Use object-oriented programming paradigm.
|
|
9
|
+
- Each file default exports one class or singleton.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { decode } from '#lib/base122.js'
|
|
2
|
+
|
|
3
|
+
// Decodes data from base122.
|
|
4
|
+
export default class Base122Decoder {
|
|
5
|
+
textEncoder = new TextEncoder()
|
|
6
|
+
|
|
7
|
+
constructor (source, { mimeType = '' } = {}) {
|
|
8
|
+
this.sourceIterator = source?.[Symbol.iterator]?.() || source?.[Symbol.asyncIterator]?.() || source()
|
|
9
|
+
this.isText = mimeType.startsWith('text/')
|
|
10
|
+
if (this.isText) this.textDecoder = new TextDecoder()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// decoder generator
|
|
14
|
+
* [Symbol.iterator] (base122String) {
|
|
15
|
+
let bytes
|
|
16
|
+
if (this.isText) {
|
|
17
|
+
while (base122String) {
|
|
18
|
+
bytes = this.textEncoder.encode(base122String) // from string to UInt8Array
|
|
19
|
+
// stream=true avoids cutting a multi-byte character
|
|
20
|
+
base122String = yield this.textDecoder.decode(new Uint8Array(decode(bytes)), { stream: true })
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
while (base122String) {
|
|
24
|
+
bytes = this.textEncoder.encode(base122String)
|
|
25
|
+
base122String = yield new Uint8Array(decode(bytes))
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Gets the decoded data.
|
|
31
|
+
getDecoded () { return iteratorToStream(this, this.sourceIterator) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function iteratorToStream (decoder, sourceIterator) {
|
|
35
|
+
return new ReadableStream({
|
|
36
|
+
decoderIterator: null,
|
|
37
|
+
async start (controller) {
|
|
38
|
+
const { value: chunk, done } = await sourceIterator.next()
|
|
39
|
+
if (done) return controller.close()
|
|
40
|
+
|
|
41
|
+
// Pass first chunk when instantiating the decoder generator
|
|
42
|
+
this.decoderIterator = decoder[Symbol.iterator](chunk)
|
|
43
|
+
const { value } = this.decoderIterator.next()
|
|
44
|
+
if (value) controller.enqueue(value)
|
|
45
|
+
},
|
|
46
|
+
async pull (controller) {
|
|
47
|
+
if (!this.decoderIterator) return
|
|
48
|
+
|
|
49
|
+
const { value: chunk, done: sourceDone } = await sourceIterator.next()
|
|
50
|
+
const { value, done } = this.decoderIterator.next(chunk)
|
|
51
|
+
|
|
52
|
+
if (value) controller.enqueue(value)
|
|
53
|
+
if (done || sourceDone) controller.close()
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { encode } from '#lib/base122.js'
|
|
2
|
+
|
|
3
|
+
// Encodes data using base122.
|
|
4
|
+
export default class Base122Encoder {
|
|
5
|
+
textDecoder = new TextDecoder()
|
|
6
|
+
// The encoded data.
|
|
7
|
+
encoded = ''
|
|
8
|
+
|
|
9
|
+
// Updates the encoded data with the given bytes.
|
|
10
|
+
update (bytes) {
|
|
11
|
+
this.encoded += this.textDecoder.decode(new Uint8Array(encode(bytes)))
|
|
12
|
+
return this
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Gets the encoded data.
|
|
16
|
+
getEncoded () {
|
|
17
|
+
return this.encoded
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Relay } from 'nostr-tools/relay'
|
|
2
|
+
import { maybeUnref } from '#helpers/timer.js'
|
|
3
|
+
|
|
4
|
+
export const seedRelays = [
|
|
5
|
+
'wss://purplepag.es',
|
|
6
|
+
'wss://user.kindpag.es',
|
|
7
|
+
'wss://relay.nos.social',
|
|
8
|
+
'wss://relay.nostr.band',
|
|
9
|
+
'wss://nostr.land',
|
|
10
|
+
'wss://indexer.coracle.social'
|
|
11
|
+
]
|
|
12
|
+
export const freeRelays = [
|
|
13
|
+
'wss://relay.damus.io',
|
|
14
|
+
'wss://relay.nostr.band',
|
|
15
|
+
'wss://nos.lol',
|
|
16
|
+
'wss://relay.primal.net'
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
// Interacts with Nostr relays.
|
|
20
|
+
export class NostrRelays {
|
|
21
|
+
#relays = new Map()
|
|
22
|
+
#relayTimeouts = new Map()
|
|
23
|
+
#timeout = 30000 // 30 seconds
|
|
24
|
+
|
|
25
|
+
// Get a relay connection, creating one if it doesn't exist.
|
|
26
|
+
async #getRelay (url) {
|
|
27
|
+
if (this.#relays.has(url)) {
|
|
28
|
+
clearTimeout(this.#relayTimeouts.get(url))
|
|
29
|
+
this.#relayTimeouts.set(url, maybeUnref(setTimeout(() => this.disconnect(url), this.#timeout)))
|
|
30
|
+
return this.#relays.get(url)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const relay = new Relay(url)
|
|
34
|
+
this.#relays.set(url, relay)
|
|
35
|
+
|
|
36
|
+
await relay.connect()
|
|
37
|
+
|
|
38
|
+
this.#relayTimeouts.set(url, maybeUnref(setTimeout(() => this.disconnect(url), this.#timeout)))
|
|
39
|
+
|
|
40
|
+
return relay
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Disconnect from a relay.
|
|
44
|
+
async disconnect (url) {
|
|
45
|
+
if (this.#relays.has(url)) {
|
|
46
|
+
await this.#relays.get(url).close()
|
|
47
|
+
this.#relays.delete(url)
|
|
48
|
+
clearTimeout(this.#relayTimeouts.get(url))
|
|
49
|
+
this.#relayTimeouts.delete(url)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Disconnect from all relays.
|
|
54
|
+
async disconnectAll () {
|
|
55
|
+
for (const url of this.#relays.keys()) {
|
|
56
|
+
await this.disconnect(url)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Get events from a list of relays
|
|
61
|
+
async getEvents (filter, relays, timeout = 5000) {
|
|
62
|
+
const events = []
|
|
63
|
+
const promises = relays.map(async (url) => {
|
|
64
|
+
try {
|
|
65
|
+
const relay = await this.#getRelay(url)
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
const sub = relay.subscribe([filter], {
|
|
68
|
+
onevent: (event) => {
|
|
69
|
+
events.push(event)
|
|
70
|
+
},
|
|
71
|
+
onclose: () => {
|
|
72
|
+
clearTimeout(timer)
|
|
73
|
+
resolve()
|
|
74
|
+
},
|
|
75
|
+
oneose: () => {
|
|
76
|
+
clearTimeout(timer)
|
|
77
|
+
resolve()
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
const timer = maybeUnref(setTimeout(() => {
|
|
81
|
+
sub.close()
|
|
82
|
+
resolve()
|
|
83
|
+
}, timeout))
|
|
84
|
+
})
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error(`Failed to get events from ${url}`, error)
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const results = await Promise.allSettled(promises)
|
|
91
|
+
if (results.some(v => v.status === 'rejected')) throw new Error(results[0].reason)
|
|
92
|
+
return events
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Send an event to a list of relays.
|
|
96
|
+
async sendEvent (event, relays, timeout = 3000) {
|
|
97
|
+
const promises = relays.map(async (url) => {
|
|
98
|
+
try {
|
|
99
|
+
const relay = await this.#getRelay(url)
|
|
100
|
+
const timer = maybeUnref(setTimeout(() => {
|
|
101
|
+
throw new Error(`Timeout sending event to ${url}`)
|
|
102
|
+
}, timeout))
|
|
103
|
+
await relay.publish(event)
|
|
104
|
+
clearTimeout(timer)
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error(`Failed to send event to ${url}`, error)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const results = await Promise.allSettled(promises)
|
|
111
|
+
if (results.some(v => v.status === 'rejected')) throw new Error(results[0].reason)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Share same connection.
|
|
115
|
+
// Connections aren't authenticated, thus no need to split by authed user.
|
|
116
|
+
export default new NostrRelays()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import * as dotenv from 'dotenv'
|
|
4
|
+
import { getPublicKey, finalizeEvent } from 'nostr-tools/pure'
|
|
5
|
+
import { getConversationKey, encrypt, decrypt } from 'nostr-tools/nip44'
|
|
6
|
+
import nostrRelays, { seedRelays, freeRelays } from '#services/nostr-relays.js'
|
|
7
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
8
|
+
|
|
9
|
+
dotenv.config({
|
|
10
|
+
path: process.env.DOTENV_CONFIG_PATH ?? `${__dirname}/../../.env`,
|
|
11
|
+
quiet: true
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const nip44 = {
|
|
15
|
+
getConversationKey,
|
|
16
|
+
encrypt,
|
|
17
|
+
decrypt
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const createToken = Symbol('createToken')
|
|
21
|
+
|
|
22
|
+
export default class NostrSigner {
|
|
23
|
+
#secretKey // bytes
|
|
24
|
+
#publicKey // hex
|
|
25
|
+
|
|
26
|
+
constructor (token, skBytes) {
|
|
27
|
+
if (token !== createToken) throw new Error('Use NostrSigner.create(?sk) to instantiate this class.')
|
|
28
|
+
if (!skBytes) throw new Error('Secret key missing.')
|
|
29
|
+
|
|
30
|
+
this.#secretKey = skBytes
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static async create (skHex) {
|
|
34
|
+
if (skHex) return new this(createToken, hexToBytes(skHex))
|
|
35
|
+
|
|
36
|
+
let skBytes
|
|
37
|
+
let isNewSk = false
|
|
38
|
+
if (process.env.NOSTR_SECRET_KEY) {
|
|
39
|
+
skBytes = hexToBytes(process.env.NOSTR_SECRET_KEY)
|
|
40
|
+
} else {
|
|
41
|
+
isNewSk = true
|
|
42
|
+
skHex = generateSecretKey()
|
|
43
|
+
fs.appendFileSync('.env', `NOSTR_SECRET_KEY=${skHex}\n`)
|
|
44
|
+
skBytes = hexToBytes(skHex)
|
|
45
|
+
}
|
|
46
|
+
const ret = new this(createToken, skBytes)
|
|
47
|
+
if (isNewSk) await ret.#initSk(skHex)
|
|
48
|
+
return ret
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async getRelays () {
|
|
52
|
+
if (this.relays) return this.relays
|
|
53
|
+
|
|
54
|
+
const relayLists = await nostrRelays.getEvents({ authors: [await this.getPublicKey()], kinds: [10002], limit: 1 }, seedRelays)
|
|
55
|
+
const relayList = relayLists.sort((a, b) => b.created_at - a.created_at)[0]
|
|
56
|
+
const rTags = relayList.tags.filter(v => v[0] === 'r' && /^wss?:\/\//.test(v[1]))
|
|
57
|
+
if (rTags.length === 0) return (this.relays = await this.#initRelays())
|
|
58
|
+
|
|
59
|
+
let keys
|
|
60
|
+
const keyAllowList = { read: true, write: true }
|
|
61
|
+
const relays = rTags.reduce((r, v) => {
|
|
62
|
+
keys = [v[2]].filter(v2 => keyAllowList[v2])
|
|
63
|
+
if (keys.length === 0) keys = ['read', 'write']
|
|
64
|
+
keys.forEach(k => r[k].push(v[1]))
|
|
65
|
+
return r
|
|
66
|
+
}, { read: [], write: [] })
|
|
67
|
+
Object.values(relays).forEach(v => { v.length === 0 && v.push(...freeRelays) })
|
|
68
|
+
return (this.relays = relays)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async #initRelays () {
|
|
72
|
+
const relays = freeRelays.slice(0, 2)
|
|
73
|
+
this.relays = {
|
|
74
|
+
read: relays,
|
|
75
|
+
write: relays
|
|
76
|
+
}
|
|
77
|
+
const relayList = await this.signEvent({
|
|
78
|
+
kind: 10002,
|
|
79
|
+
pubkey: await this.getPublicKey(),
|
|
80
|
+
tags: relays.map(v => ['r', v]),
|
|
81
|
+
content: '',
|
|
82
|
+
created_at: Math.floor(Date.now() / 1000)
|
|
83
|
+
})
|
|
84
|
+
await nostrRelays.sendEvent(relayList, [...new Set([...seedRelays, ...this.relays.write])])
|
|
85
|
+
return this.relays
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async #initSk () {
|
|
89
|
+
const pubkey = this.getPublicKey()
|
|
90
|
+
const profile = this.signEvent({
|
|
91
|
+
kind: 0,
|
|
92
|
+
pubkey,
|
|
93
|
+
tags: [],
|
|
94
|
+
content: JSON.stringify({
|
|
95
|
+
name: `Publisher #${Math.random().toString(36).slice(2)}`,
|
|
96
|
+
about: 'An auto-generated https://44billion.net app publisher'
|
|
97
|
+
}),
|
|
98
|
+
created_at: Math.floor(Date.now() / 1000)
|
|
99
|
+
})
|
|
100
|
+
const writeRelays = (await this.getRelays()).write
|
|
101
|
+
await nostrRelays.sendEvent(profile, writeRelays)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// hex
|
|
105
|
+
getPublicKey () {
|
|
106
|
+
if (this.#publicKey) {
|
|
107
|
+
return this.#publicKey
|
|
108
|
+
}
|
|
109
|
+
this.#publicKey = getPublicKey(this.#secretKey)
|
|
110
|
+
return this.#publicKey
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
signEvent (event) {
|
|
114
|
+
return finalizeEvent(event, this.#secretKey)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
nip44 = {
|
|
118
|
+
encrypt: this.nip44Encrypt.bind(this),
|
|
119
|
+
decrypt: this.nip44Decrypt.bind(this)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
nip44Encrypt (pubkey, plaintext) {
|
|
123
|
+
const conversationKey = nip44.getConversationKey(this.#secretKey, pubkey)
|
|
124
|
+
return nip44.encrypt(conversationKey, plaintext)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
nip44Decrypt (pubkey, ciphertext) {
|
|
128
|
+
const conversationKey = nip44.getConversationKey(this.#secretKey, pubkey)
|
|
129
|
+
return nip44.decrypt(conversationKey, ciphertext)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function bytesToHex (uint8aBytes) {
|
|
134
|
+
return Array.from(uint8aBytes).map(b => b.toString(16).padStart(2, '0')).join('')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function hexToBytes (hexString) {
|
|
138
|
+
const arr = new Uint8Array(hexString.length / 2) // create result array
|
|
139
|
+
for (let i = 0; i < arr.length; i++) {
|
|
140
|
+
const j = i * 2
|
|
141
|
+
const h = hexString.slice(j, j + 2)
|
|
142
|
+
const b = Number.parseInt(h, 16) // byte, created from string part
|
|
143
|
+
if (Number.isNaN(b) || b < 0) throw new Error('invalid hex')
|
|
144
|
+
arr[i] = b
|
|
145
|
+
}
|
|
146
|
+
return arr
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function generateSecretKey () {
|
|
150
|
+
const randomBytes = crypto.getRandomValues(new Uint8Array(40))
|
|
151
|
+
const B256 = 2n ** 256n // secp256k1 is short weierstrass curve
|
|
152
|
+
const N = B256 - 0x14551231950b75fc4402da1732fc9bebfn // curve (group) order
|
|
153
|
+
const bytesToNumber = b => BigInt('0x' + (bytesToHex(b) || '0'))
|
|
154
|
+
const mod = (a, b) => { const r = a % b; return r >= 0n ? r : b + r } // mod division
|
|
155
|
+
const num = mod(bytesToNumber(randomBytes), N - 1n) + 1n // takes at least n+8 bytes
|
|
156
|
+
return num.toString(16).padStart(64, '0')
|
|
157
|
+
}
|