nappup 1.0.5 → 1.0.7

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.
@@ -8,15 +8,15 @@ import { fileTypeFromFile } from 'file-type'
8
8
  export function parseArgs (args) {
9
9
  let dir = null
10
10
  let sk = null
11
- let appId = null
11
+ let dTag = null
12
12
  let channel = null
13
13
 
14
14
  for (let i = 0; i < args.length; i++) {
15
15
  if (args[i] === '-s' && args[i + 1]) {
16
16
  sk = args[i + 1]
17
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]
18
+ } else if (args[i] === '-d' && args[i + 1]) {
19
+ dTag = args[i + 1]
20
20
  i++
21
21
  } else if (args[i] === '--main' && channel === null) {
22
22
  channel = 'main'
@@ -32,7 +32,7 @@ export function parseArgs (args) {
32
32
  return {
33
33
  dir: path.resolve(dir ?? '.'),
34
34
  sk,
35
- appId,
35
+ dTag,
36
36
  channel: channel || 'main'
37
37
  }
38
38
  }
@@ -11,7 +11,7 @@ import toApp from '#index.js'
11
11
  const args = parseArgs(process.argv.slice(2))
12
12
  await confirmArgs(args)
13
13
 
14
- const { dir, sk, appId, channel } = args
14
+ const { dir, sk, dTag, channel } = args
15
15
  const fileList = await toFileList(getFiles(dir), dir)
16
16
 
17
- await toApp(fileList, await NostrSigner.create(sk), { log: console.log.bind(console), appId, channel })
17
+ await toApp(fileList, await NostrSigner.create(sk), { log: console.log.bind(console), dTag, channel })
package/package.json CHANGED
@@ -6,11 +6,12 @@
6
6
  "url": "git+https://github.com/44billion/nappup.git"
7
7
  },
8
8
  "license": "GPL-3.0-or-later",
9
- "version": "1.0.5",
9
+ "version": "1.0.7",
10
10
  "description": "Nostr App Uploader",
11
11
  "type": "module",
12
12
  "scripts": {
13
- "test": "node --test 'test/**/*.test.js'"
13
+ "test": "node --test 'test/**/*.test.js'",
14
+ "test:only": "node --test --test-only 'test/**/*.test.js'"
14
15
  },
15
16
  "bin": {
16
17
  "nappup": "bin/nappup/index.js"
@@ -0,0 +1,36 @@
1
+ import { bytesToBase62 } from '#helpers/base62.js'
2
+
3
+ export const NOSTR_APP_ID_MAX_LENGTH = 19
4
+
5
+ export function isNostrAppDTagSafe (string) {
6
+ return isSubdomainSafe(string) && string.length <= NOSTR_APP_ID_MAX_LENGTH
7
+ }
8
+
9
+ function isSubdomainSafe (string) {
10
+ return /(?:^[A-Za-z0-9]$)|(?:^(?!.*--)[A-Za-z0-9][A-Za-z0-9-]{0,63}[A-Za-z0-9]$)/.test(string)
11
+ }
12
+
13
+ export function deriveNostrAppDTag (string) {
14
+ return toSubdomainSafe(string, NOSTR_APP_ID_MAX_LENGTH)
15
+ }
16
+
17
+ async function toSubdomainSafe (string, maxStringLength) {
18
+ const byteLength = base62MaxLengthToMaxSourceByteLength(maxStringLength)
19
+ const bytes = (await toSha1(string)).slice(0, byteLength)
20
+ return bytesToBase62(bytes, maxStringLength)
21
+ }
22
+
23
+ async function toSha1 (string) {
24
+ const bytes = new TextEncoder().encode(string)
25
+ return new Uint8Array(await crypto.subtle.digest('SHA-1', bytes))
26
+ }
27
+
28
+ // base62MaxLengthToMaxSourceByteLength(19) === 14 byte length
29
+ function base62MaxLengthToMaxSourceByteLength (maxStringLength) {
30
+ const log62 = Math.log(62)
31
+ const log256 = Math.log(256)
32
+
33
+ const maxByteLength = (maxStringLength * log62) / log256
34
+
35
+ return Math.floor(maxByteLength)
36
+ }
@@ -0,0 +1,59 @@
1
+ export const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
2
+ const BASE = BigInt(ALPHABET.length)
3
+ const LEADER = ALPHABET[0]
4
+ const CHAR_MAP = new Map([...ALPHABET].map((char, index) => [char, BigInt(index)]))
5
+
6
+ export function bytesToBase62 (bytes, padLength = 0) {
7
+ if (bytes.length === 0) return ''.padStart(padLength, LEADER)
8
+
9
+ let num = 0n
10
+ for (const byte of bytes) {
11
+ num = (num << 8n) + BigInt(byte)
12
+ }
13
+
14
+ let result = ''
15
+ if (num === 0n) return LEADER.padStart(padLength, LEADER)
16
+
17
+ while (num > 0n) {
18
+ const remainder = num % BASE
19
+ result = ALPHABET[Number(remainder)] + result
20
+ num = num / BASE
21
+ }
22
+
23
+ for (const byte of bytes) {
24
+ if (byte !== 0) break
25
+
26
+ result = LEADER + result
27
+ }
28
+
29
+ return result.padStart(padLength, LEADER)
30
+ }
31
+
32
+ export function base62ToBytes (base62Str) {
33
+ if (typeof base62Str !== 'string') { throw new Error('base62ToBytes requires a string argument') }
34
+ if (base62Str.length === 0) return new Uint8Array()
35
+
36
+ let leadingZeros = 0
37
+ for (let i = 0; i < base62Str.length; i++) {
38
+ if (base62Str[i] !== LEADER) break
39
+
40
+ leadingZeros++
41
+ }
42
+
43
+ let num = 0n
44
+ for (const char of base62Str) {
45
+ const value = CHAR_MAP.get(char)
46
+ if (value === undefined) { throw new Error(`Invalid character in Base62 string: ${char}`) }
47
+ num = num * BASE + value
48
+ }
49
+
50
+ const bytes = []
51
+ while (num > 0n) {
52
+ bytes.unshift(Number(num & 0xffn))
53
+ num = num >> 8n
54
+ }
55
+
56
+ const result = new Uint8Array(leadingZeros + bytes.length)
57
+ result.set(bytes, leadingZeros)
58
+ return result
59
+ }
@@ -0,0 +1,15 @@
1
+ export function bytesToHex (uint8aBytes) {
2
+ return Array.from(uint8aBytes).map(b => b.toString(16).padStart(2, '0')).join('')
3
+ }
4
+
5
+ export function hexToBytes (hexString) {
6
+ const arr = new Uint8Array(hexString.length / 2) // create result array
7
+ for (let i = 0; i < arr.length; i++) {
8
+ const j = i * 2
9
+ const h = hexString.slice(j, j + 2)
10
+ const b = Number.parseInt(h, 16) // byte, created from string part
11
+ if (Number.isNaN(b) || b < 0) throw new Error('invalid hex')
12
+ arr[i] = b
13
+ }
14
+ return arr
15
+ }
@@ -0,0 +1,90 @@
1
+ import { bytesToHex, hexToBytes } from '#helpers/byte.js'
2
+ import { bytesToBase62, base62ToBytes, ALPHABET as base62Alphabet } from '#helpers/base62.js'
3
+ import { isNostrAppDTagSafe } from '#helpers/app.js'
4
+
5
+ const MAX_SIZE = 5000
6
+ export const BASE62_ENTITY_REGEX = new RegExp(`^app-[${base62Alphabet}]{,${MAX_SIZE}}$`)
7
+ export const kindByChannel = {
8
+ main: 37448,
9
+ next: 37449,
10
+ draft: 37450
11
+ }
12
+ const channelEnum = Object.keys(kindByChannel)
13
+ const textEncoder = new TextEncoder()
14
+ const textDecoder = new TextDecoder()
15
+
16
+ export function appEncode (ref) {
17
+ if (!isNostrAppDTagSafe(ref['#d'])) { throw new Error('Invalid deduplication tag') }
18
+ const channelIndex = Object.entries(kindByChannel)
19
+ .findIndex(([k, v]) => ref.channel ? k === ref.channel : v === ref.kind)
20
+ if (channelIndex === -1) throw new Error('Wrong channel')
21
+ const tlv = toTlv([
22
+ [textEncoder.encode(ref['#d'])], // type 0 (the array index)
23
+ (ref.relays || []).map(url => textEncoder.encode(url)), // type 1
24
+ [hexToBytes(ref.pubkey)], // type 2
25
+ [uintToBytes(channelIndex)] // type 3
26
+ ])
27
+ const base62 = bytesToBase62(tlv)
28
+ return `app-${base62}`
29
+ }
30
+
31
+ export function appDecode (entity) {
32
+ const [, base62] = entity.split('-')
33
+ const tlv = tlvToObj(base62ToBytes(base62))
34
+ if (!tlv[0]?.[0]) throw new Error('Missing deduplication tag')
35
+ if (!tlv[2]?.[0]) throw new Error('Missing author pubkey')
36
+ if (tlv[2][0].length !== 32) throw new Error('Author pubkey should be 32 bytes')
37
+ if (!tlv[3]?.[0]) throw new Error('Missing channel enum')
38
+ if (tlv[3][0].length !== 1) throw new Error('Channel enum should be 1 byte')
39
+
40
+ const channel = channelEnum[parseInt(tlv[3][0])]
41
+ return {
42
+ '#d': textDecoder.decode(tlv[0][0]),
43
+ pubkey: bytesToHex(tlv[2][0]),
44
+ kind: kindByChannel[channel],
45
+ channel,
46
+ relays: tlv[1] ? tlv[1].map(url => textDecoder.decode(url)) : []
47
+ }
48
+ }
49
+
50
+ // Return shortest uint8Array size (not fixed size)
51
+ function uintToBytes (n, bytes = []) {
52
+ do { bytes.unshift(n & 255) } while ((n >>= 8) > 0)
53
+ return new Uint8Array(bytes)
54
+ }
55
+
56
+ function toTlv (tlvConfig) {
57
+ const arrays = []
58
+ tlvConfig
59
+ .map((v, i) => [i, v])
60
+ .reverse() // if the first type is 0, entity always starts with the '0' char
61
+ .forEach(([type, values]) => {
62
+ // just non-empty values will be included
63
+ values.forEach(v => {
64
+ if (v.length > 255) throw new Error('Value is too big')
65
+ const array = new Uint8Array(v.length + 2)
66
+ array.set([type], 0) // t
67
+ array.set([v.length], 1) // l
68
+ array.set(v, 2) // v
69
+ arrays.push(array)
70
+ })
71
+ })
72
+ return new Uint8Array(arrays.reduce((r, v) => [...r, ...v]))
73
+ }
74
+
75
+ // { 0: [t0v0], 1: [t1v0, t1v2], 3: [t3v0] }
76
+ function tlvToObj (tlv) {
77
+ const ret = {}
78
+ let rest = tlv
79
+ let t, l, v
80
+ while (rest.length > 0) {
81
+ t = rest[0]
82
+ l = rest[1]
83
+ v = rest.slice(2, 2 + l)
84
+ rest = rest.slice(2 + l)
85
+ if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
86
+ ret[t] ??= []
87
+ ret[t].push(v)
88
+ }
89
+ return ret
90
+ }
package/src/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import NMMR from 'nmmr'
2
- import { naddrEncode } from 'nostr-tools/nip19'
2
+ import { appEncode } from '#helpers/nip19.js'
3
3
  import Base122Encoder from '#services/base122-encoder.js'
4
4
  import nostrRelays from '#services/nostr-relays.js'
5
5
  import NostrSigner from '#services/nostr-signer.js'
6
6
  import { streamToChunks } from '#helpers/stream.js'
7
+ import { isNostrAppDTagSafe, deriveNostrAppDTag } from '#helpers/app.js'
7
8
 
8
9
  export default async function (...args) {
9
10
  try {
@@ -12,15 +13,20 @@ export default async function (...args) {
12
13
  await nostrRelays.disconnectAll()
13
14
  }
14
15
  }
15
- export async function toApp (fileList, nostrSigner, { log = () => {}, appId, channel = 'main' } = {}) {
16
+
17
+ export async function toApp (fileList, nostrSigner, { log = () => {}, dTag, channel = 'main' } = {}) {
16
18
  if (!nostrSigner && typeof window !== 'undefined') nostrSigner = window.nostr
17
19
  if (!nostrSigner) throw new Error('No Nostr signer found')
18
20
  if (typeof window !== 'undefined' && nostrSigner === window.nostr) {
19
21
  nostrSigner.getRelays = NostrSigner.prototype.getRelays
20
22
  }
21
23
 
22
- appId ||= fileList[0].webkitRelativePath.split('/')[0]
23
- .trim().replace(/[\s-]/g, '').toLowerCase().slice(0, 32)
24
+ if (typeof dTag === 'string') {
25
+ if (!isNostrAppDTagSafe(dTag)) throw new Error('dTag should be [A-Za-z0-9] with length ranging from 1 to 19')
26
+ } else {
27
+ dTag = fileList[0].webkitRelativePath.split('/')[0].trim()
28
+ if (!isNostrAppDTagSafe(dTag)) dTag = deriveNostrAppDTag(dTag || Math.random().toString(36))
29
+ }
24
30
  let nmmr
25
31
  const fileMetadata = []
26
32
 
@@ -47,16 +53,16 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, appId, cha
47
53
  }
48
54
  }
49
55
 
50
- log(`Uploading bundle #${appId}`)
51
- const bundle = await uploadBundle(appId, channel, fileMetadata, nostrSigner)
56
+ log(`Uploading bundle #${dTag}`)
57
+ const bundle = await uploadBundle(dTag, channel, fileMetadata, nostrSigner)
52
58
 
53
- const naddr = naddrEncode({
54
- identifier: bundle.tags.find(v => v[0] === 'd')[1],
59
+ const appEntity = appEncode({
60
+ '#d': bundle.tags.find(v => v[0] === 'd')[1],
55
61
  pubkey: bundle.pubkey,
56
62
  relays: [],
57
63
  kind: bundle.kind
58
64
  })
59
- log(`Visit at https://44billion.net/${naddr}`)
65
+ log(`Visit at https://44billion.net/${appEntity}`)
60
66
  }
61
67
 
62
68
  async function uploadBinaryDataChunks (nmmr, signer, { mimeType } = {}) {
@@ -110,7 +116,7 @@ async function getPreviousCtags (dTagValue, currentCtagValue, writeRelays, signe
110
116
  })
111
117
  }
112
118
 
113
- async function uploadBundle (appId, channel, fileMetadata, signer) {
119
+ async function uploadBundle (dTag, channel, fileMetadata, signer) {
114
120
  const kind = {
115
121
  main: 37448, // stable
116
122
  next: 37449, // insider
@@ -119,7 +125,7 @@ async function uploadBundle (appId, channel, fileMetadata, signer) {
119
125
  const appBundle = {
120
126
  kind,
121
127
  tags: [
122
- ['d', appId],
128
+ ['d', dTag],
123
129
  ...fileMetadata.map(v => ['file', v.rootHash, v.filename, v.mimeType])
124
130
  ],
125
131
  content: '',
@@ -5,6 +5,7 @@ import * as dotenv from 'dotenv'
5
5
  import { getPublicKey, finalizeEvent } from 'nostr-tools/pure'
6
6
  import { getConversationKey, encrypt, decrypt } from 'nostr-tools/nip44'
7
7
  import nostrRelays, { seedRelays, freeRelays } from '#services/nostr-relays.js'
8
+ import { bytesToHex, hexToBytes } from '#helpers/byte.js'
8
9
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
9
10
 
10
11
  const dotenvPath = process.env.DOTENV_CONFIG_PATH ?? `${__dirname}/../../.env`
@@ -132,22 +133,6 @@ export default class NostrSigner {
132
133
  }
133
134
  }
134
135
 
135
- function bytesToHex (uint8aBytes) {
136
- return Array.from(uint8aBytes).map(b => b.toString(16).padStart(2, '0')).join('')
137
- }
138
-
139
- function hexToBytes (hexString) {
140
- const arr = new Uint8Array(hexString.length / 2) // create result array
141
- for (let i = 0; i < arr.length; i++) {
142
- const j = i * 2
143
- const h = hexString.slice(j, j + 2)
144
- const b = Number.parseInt(h, 16) // byte, created from string part
145
- if (Number.isNaN(b) || b < 0) throw new Error('invalid hex')
146
- arr[i] = b
147
- }
148
- return arr
149
- }
150
-
151
136
  function generateSecretKey () {
152
137
  const randomBytes = crypto.getRandomValues(new Uint8Array(40))
153
138
  const B256 = 2n ** 256n // secp256k1 is short weierstrass curve
File without changes
File without changes