pakstr 0.19.2 → 0.20.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.
@@ -0,0 +1,208 @@
1
+ package com.pakstr.app
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertNull
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Test
8
+ import java.net.URL
9
+ import java.util.Base64
10
+
11
+ class Nip98ProxyOriginTest {
12
+
13
+ private val localUrl = URL("http://127.0.0.1:${Config.PORT}/api/auth/login")
14
+
15
+ @Test
16
+ fun validLocalNip98EventGeneratesForwardingOrigin() {
17
+ assertEquals(
18
+ ProxyOrigin("127.0.0.1:${Config.PORT}", "http"),
19
+ localNip98ProxyOrigin(nip98Header(), "POST", localUrl)
20
+ )
21
+ }
22
+
23
+ @Test
24
+ fun bodylessNip98RequestAddsOriginWithoutEntityHeadersOrBody() {
25
+ val authorization = nip98Header()
26
+ val headers = prepareProxyRequestHeaders(
27
+ mapOf(
28
+ "authorization" to authorization,
29
+ "content-type" to "",
30
+ "content-length" to "0",
31
+ "transfer-encoding" to "chunked"
32
+ ),
33
+ "POST",
34
+ localUrl,
35
+ "https://api.example.com"
36
+ )
37
+ val request = bodylessProxyRequestBytes(
38
+ "POST",
39
+ URL("https://api.example.com/api/auth/login"),
40
+ headers
41
+ ).toString(Charsets.ISO_8859_1)
42
+
43
+ assertTrue(request.contains("authorization: $authorization\r\n"))
44
+ assertTrue(request.contains("X-Forwarded-Host: 127.0.0.1:${Config.PORT}\r\n"))
45
+ assertTrue(request.contains("X-Forwarded-Proto: http\r\n"))
46
+ assertFalse(request.contains("Content-Type", ignoreCase = true))
47
+ assertFalse(request.contains("Content-Length", ignoreCase = true))
48
+ assertFalse(request.contains("Transfer-Encoding", ignoreCase = true))
49
+ assertTrue(request.endsWith("Connection: close\r\n\r\n"))
50
+ }
51
+
52
+ @Test
53
+ fun bodyBearingNip98RequestAddsOriginAndPreservesBodyHeaders() {
54
+ val authorization = nip98Header()
55
+ val headers = prepareProxyRequestHeaders(
56
+ mapOf(
57
+ "authorization" to authorization,
58
+ "content-type" to "application/json",
59
+ "content-length" to "11"
60
+ ),
61
+ "POST",
62
+ localUrl,
63
+ "https://api.example.com"
64
+ )
65
+ val policy = proxyRequestBodyPolicy(
66
+ mapOf(
67
+ "content-type" to "application/json",
68
+ "content-length" to "11"
69
+ )
70
+ )
71
+
72
+ assertEquals(authorization, headers["authorization"])
73
+ assertEquals("application/json", headers["content-type"])
74
+ assertEquals("127.0.0.1:${Config.PORT}", headers["X-Forwarded-Host"])
75
+ assertEquals("http", headers["X-Forwarded-Proto"])
76
+ assertTrue(policy.shouldWriteBody)
77
+ assertEquals("application/json", policy.contentType)
78
+ }
79
+
80
+ @Test
81
+ fun noAuthorizationIsUnchanged() {
82
+ val input = mapOf("accept" to "application/json")
83
+
84
+ assertEquals(
85
+ input,
86
+ prepareProxyRequestHeaders(input, "GET", localUrl, "https://api.example.com")
87
+ )
88
+ }
89
+
90
+ @Test
91
+ fun bearerAuthorizationIsUnchanged() {
92
+ val input = mapOf("authorization" to "Bearer token")
93
+
94
+ assertEquals(
95
+ input,
96
+ prepareProxyRequestHeaders(input, "POST", localUrl, "https://api.example.com")
97
+ )
98
+ }
99
+
100
+ @Test
101
+ fun malformedNostrAuthorizationFailsClosed() {
102
+ assertNull(localNip98ProxyOrigin("Nostr not-base64", "POST", localUrl))
103
+ assertNull(localNip98ProxyOrigin("Nostr e30=", "POST", localUrl))
104
+ }
105
+
106
+ @Test
107
+ fun wrongKindFailsClosed() {
108
+ assertNull(localNip98ProxyOrigin(nip98Header(kind = 1), "POST", localUrl))
109
+ }
110
+
111
+ @Test
112
+ fun wrongMethodFailsClosed() {
113
+ assertNull(localNip98ProxyOrigin(nip98Header(method = "GET"), "POST", localUrl))
114
+ }
115
+
116
+ @Test
117
+ fun wrongPathFailsClosed() {
118
+ assertNull(localNip98ProxyOrigin(
119
+ nip98Header(url = "http://127.0.0.1:${Config.PORT}/api/other"),
120
+ "POST",
121
+ localUrl
122
+ ))
123
+ }
124
+
125
+ @Test
126
+ fun wrongQueryFailsClosed() {
127
+ val requestUrl = URL("http://127.0.0.1:${Config.PORT}/api/auth/login?next=one")
128
+
129
+ assertNull(localNip98ProxyOrigin(
130
+ nip98Header(url = "http://127.0.0.1:${Config.PORT}/api/auth/login?next=two"),
131
+ "POST",
132
+ requestUrl
133
+ ))
134
+ }
135
+
136
+ @Test
137
+ fun nonLocalHostFailsClosed() {
138
+ assertNull(localNip98ProxyOrigin(
139
+ nip98Header(url = "http://localhost:${Config.PORT}/api/auth/login"),
140
+ "POST",
141
+ localUrl
142
+ ))
143
+ }
144
+
145
+ @Test
146
+ fun upstreamUrlFailsClosed() {
147
+ assertNull(localNip98ProxyOrigin(
148
+ nip98Header(url = "https://api.example.com/api/auth/login"),
149
+ "POST",
150
+ localUrl
151
+ ))
152
+ }
153
+
154
+ @Test
155
+ fun ambiguousOrUnusableTagsFailClosed() {
156
+ assertNull(localNip98ProxyOrigin(
157
+ nip98Header(extraTags = ",[\"u\",\"http://127.0.0.1:${Config.PORT}/api/auth/login\"]"),
158
+ "POST",
159
+ localUrl
160
+ ))
161
+ assertNull(localNip98ProxyOrigin(
162
+ nip98Header(extraTags = ",[\"method\",\"POST\"]"),
163
+ "POST",
164
+ localUrl
165
+ ))
166
+ assertNull(localNip98ProxyOrigin(nip98Header(url = ""), "POST", localUrl))
167
+ assertNull(localNip98ProxyOrigin(nip98Header(method = ""), "POST", localUrl))
168
+ }
169
+
170
+ @Test
171
+ fun spoofedForwardingHeadersCannotOverrideDerivedValues() {
172
+ val headers = prepareProxyRequestHeaders(
173
+ mapOf(
174
+ "authorization" to nip98Header(),
175
+ "Forwarded" to "host=attacker.example;proto=https",
176
+ "X-Forwarded-Host" to "attacker.example",
177
+ "X-Forwarded-Proto" to "https"
178
+ ),
179
+ "POST",
180
+ localUrl,
181
+ "https://api.example.com"
182
+ )
183
+
184
+ assertFalse(headers.keys.any { it.equals("Forwarded", ignoreCase = true) })
185
+ assertEquals("127.0.0.1:${Config.PORT}", headers["X-Forwarded-Host"])
186
+ assertEquals("http", headers["X-Forwarded-Proto"])
187
+ }
188
+
189
+ @Test
190
+ fun missingApiBaseReturnsExistingHeadersUnchanged() {
191
+ val input = mapOf(
192
+ "authorization" to nip98Header(),
193
+ "X-Forwarded-Host" to "caller.example"
194
+ )
195
+
196
+ assertEquals(input, prepareProxyRequestHeaders(input, "POST", localUrl, null))
197
+ }
198
+
199
+ private fun nip98Header(
200
+ kind: Int = 27235,
201
+ url: String = localUrl.toString(),
202
+ method: String = "POST",
203
+ extraTags: String = ""
204
+ ): String {
205
+ val event = """{"kind":$kind,"tags":[["u","$url"],["method","$method"]$extraTags]}"""
206
+ return "Nostr ${Base64.getEncoder().encodeToString(event.toByteArray())}"
207
+ }
208
+ }
@@ -6,10 +6,219 @@ import android.webkit.MimeTypeMap
6
6
  import com.pakstr.app.debug.AppDebugLogger
7
7
 
8
8
  import fi.iki.elonen.NanoHTTPD
9
+ import java.io.BufferedInputStream
10
+ import java.io.ByteArrayOutputStream
9
11
  import java.io.IOException
10
12
  import java.net.HttpURLConnection
13
+ import java.net.InetSocketAddress
14
+ import java.net.Socket
11
15
  import java.net.URL
16
+ import java.nio.charset.StandardCharsets
12
17
  import java.util.Locale
18
+ import javax.net.ssl.HttpsURLConnection
19
+ import javax.net.ssl.SSLSocketFactory
20
+
21
+ internal data class ProxyRequestBodyPolicy(
22
+ val shouldWriteBody: Boolean,
23
+ val contentType: String?
24
+ )
25
+
26
+ internal class InvalidContentLengthException : IllegalArgumentException()
27
+
28
+ internal class UnsupportedTransferEncodingException : IllegalArgumentException()
29
+
30
+ internal fun proxyRequestBodyPolicy(
31
+ headers: Map<String, String>
32
+ ): ProxyRequestBodyPolicy {
33
+ if (headers.containsKey("transfer-encoding")) {
34
+ throw UnsupportedTransferEncodingException()
35
+ }
36
+
37
+ val contentLengthHeader = headers["content-length"]
38
+ val contentLength = when {
39
+ contentLengthHeader == null -> 0L
40
+ !contentLengthHeader.matches(Regex("[0-9]+")) -> {
41
+ throw InvalidContentLengthException()
42
+ }
43
+ else -> contentLengthHeader.toLongOrNull()
44
+ ?: throw InvalidContentLengthException()
45
+ }
46
+
47
+ return ProxyRequestBodyPolicy(
48
+ shouldWriteBody = contentLength > 0,
49
+ contentType = headers["content-type"]
50
+ )
51
+ }
52
+
53
+ internal fun shouldForwardProxyRequestHeader(
54
+ name: String
55
+ ): Boolean = name.lowercase() !in setOf(
56
+ "host",
57
+ "content-length",
58
+ "connection",
59
+ "accept-encoding",
60
+ "transfer-encoding",
61
+ "forwarded",
62
+ "x-forwarded-host",
63
+ "x-forwarded-proto"
64
+ )
65
+
66
+ internal data class ProxyHttpResponse(
67
+ val code: Int,
68
+ val contentType: String?,
69
+ val body: ByteArray
70
+ )
71
+
72
+ internal fun bodylessProxyRequestBytes(
73
+ method: String,
74
+ url: URL,
75
+ headers: Map<String, String>
76
+ ): ByteArray {
77
+ val defaultPort = if (url.protocol == "https") 443 else 80
78
+ val hostHeader = if (url.port == -1 || url.port == defaultPort) {
79
+ url.host
80
+ } else {
81
+ "${url.host}:${url.port}"
82
+ }
83
+ val path = buildString {
84
+ append(url.path.ifEmpty { "/" })
85
+ url.query?.let {
86
+ append('?')
87
+ append(it)
88
+ }
89
+ }
90
+
91
+ return buildString {
92
+ append("$method $path HTTP/1.1\r\n")
93
+ append("Host: $hostHeader\r\n")
94
+ headers.forEach { (name, value) ->
95
+ if (shouldForwardProxyRequestHeader(name) &&
96
+ !name.equals("content-type", ignoreCase = true)
97
+ ) {
98
+ append("$name: $value\r\n")
99
+ }
100
+ }
101
+ append("Connection: close\r\n\r\n")
102
+ }.toByteArray(StandardCharsets.ISO_8859_1)
103
+ }
104
+
105
+ internal fun executeBodylessProxyRequest(
106
+ method: String,
107
+ url: URL,
108
+ headers: Map<String, String>
109
+ ): ProxyHttpResponse {
110
+ val port = when {
111
+ url.port != -1 -> url.port
112
+ url.protocol == "https" -> 443
113
+ else -> 80
114
+ }
115
+ if (url.protocol != "http" && url.protocol != "https") {
116
+ throw IOException("Unsupported API URL protocol: ${url.protocol}")
117
+ }
118
+
119
+ val transport = Socket().apply {
120
+ connect(InetSocketAddress(url.host, port), 15000)
121
+ soTimeout = 30000
122
+ }
123
+ val socket = if (url.protocol == "https") {
124
+ (SSLSocketFactory.getDefault() as SSLSocketFactory).createSocket(
125
+ transport,
126
+ url.host,
127
+ port,
128
+ true
129
+ )
130
+ } else {
131
+ transport
132
+ }
133
+
134
+ socket.use {
135
+ if (url.protocol == "https") {
136
+ val sslSocket = it as javax.net.ssl.SSLSocket
137
+ sslSocket.startHandshake()
138
+ if (!HttpsURLConnection.getDefaultHostnameVerifier()
139
+ .verify(url.host, sslSocket.session)
140
+ ) {
141
+ throw IOException("Hostname verification failed for ${url.host}")
142
+ }
143
+ }
144
+
145
+ it.getOutputStream().apply {
146
+ write(bodylessProxyRequestBytes(method, url, headers))
147
+ flush()
148
+ }
149
+
150
+ return readProxyHttpResponse(BufferedInputStream(it.getInputStream()))
151
+ }
152
+ }
153
+
154
+ private fun readProxyHttpResponse(input: BufferedInputStream): ProxyHttpResponse {
155
+ val statusLine = readHttpLine(input)
156
+ val code = statusLine.split(' ', limit = 3).getOrNull(1)?.toIntOrNull()
157
+ ?: throw IOException("Invalid HTTP status line")
158
+ val headers = mutableMapOf<String, String>()
159
+
160
+ while (true) {
161
+ val line = readHttpLine(input)
162
+ if (line.isEmpty()) break
163
+ val separator = line.indexOf(':')
164
+ if (separator <= 0) throw IOException("Invalid HTTP response header")
165
+ headers[line.substring(0, separator).lowercase()] =
166
+ line.substring(separator + 1).trim()
167
+ }
168
+
169
+ val body = when {
170
+ headers["transfer-encoding"]?.equals("chunked", ignoreCase = true) == true -> {
171
+ readChunkedBody(input)
172
+ }
173
+ headers["content-length"] != null -> {
174
+ readFixedLengthBody(input, headers.getValue("content-length").toLong())
175
+ }
176
+ else -> input.readBytes()
177
+ }
178
+
179
+ return ProxyHttpResponse(code, headers["content-type"], body)
180
+ }
181
+
182
+ private fun readHttpLine(input: BufferedInputStream): String {
183
+ val bytes = ByteArrayOutputStream()
184
+ while (true) {
185
+ val value = input.read()
186
+ if (value == -1) throw IOException("Unexpected end of HTTP response")
187
+ if (value == '\r'.code) {
188
+ if (input.read() != '\n'.code) throw IOException("Invalid HTTP line ending")
189
+ return bytes.toString(StandardCharsets.ISO_8859_1.name())
190
+ }
191
+ bytes.write(value)
192
+ }
193
+ }
194
+
195
+ private fun readChunkedBody(input: BufferedInputStream): ByteArray {
196
+ val body = ByteArrayOutputStream()
197
+ while (true) {
198
+ val size = readHttpLine(input).substringBefore(';').trim().toLong(16)
199
+ if (size == 0L) {
200
+ while (readHttpLine(input).isNotEmpty()) Unit
201
+ return body.toByteArray()
202
+ }
203
+ body.write(readFixedLengthBody(input, size))
204
+ if (readHttpLine(input).isNotEmpty()) throw IOException("Invalid chunk ending")
205
+ }
206
+ }
207
+
208
+ private fun readFixedLengthBody(
209
+ input: BufferedInputStream,
210
+ length: Long
211
+ ): ByteArray {
212
+ if (length > Int.MAX_VALUE) throw IOException("HTTP response body too large")
213
+ val body = ByteArray(length.toInt())
214
+ var offset = 0
215
+ while (offset < body.size) {
216
+ val read = input.read(body, offset, body.size - offset)
217
+ if (read == -1) throw IOException("Unexpected end of HTTP response body")
218
+ offset += read
219
+ }
220
+ return body
221
+ }
13
222
 
14
223
  class LocalServer(
15
224
 
@@ -182,13 +391,51 @@ class LocalServer(
182
391
  }
183
392
 
184
393
  val finalUrl = apiBase.removeSuffix("/") + uri
394
+ val url = URL(finalUrl)
395
+ val method = session?.method?.name ?: "GET"
396
+ val requestHeaders = session?.headers ?: emptyMap()
397
+ val query = session?.queryParameterString
398
+ val localRequestUrl = URL(
399
+ "http://127.0.0.1:${Config.PORT}$uri" +
400
+ if (query.isNullOrEmpty()) "" else "?$query"
401
+ )
402
+ val preparedHeaders = prepareProxyRequestHeaders(
403
+ headers = requestHeaders,
404
+ method = method,
405
+ requestUrl = localRequestUrl,
406
+ apiBase = apiBase
407
+ )
408
+ val bodyPolicy = if (
409
+ method == "POST" || method == "PUT" || method == "PATCH"
410
+ ) {
411
+ proxyRequestBodyPolicy(requestHeaders)
412
+ } else {
413
+ null
414
+ }
185
415
 
186
- val connection = URL(finalUrl).openConnection() as HttpURLConnection
416
+ if (bodyPolicy?.shouldWriteBody == false) {
417
+ val response = executeBodylessProxyRequest(
418
+ method,
419
+ url,
420
+ preparedHeaders
421
+ )
422
+ if (AppDebugLogger.isNetworkLoggingEnabled(context)) {
423
+ AppDebugLogger.network(
424
+ context,
425
+ "Response: ${response.code} $finalUrl"
426
+ )
427
+ }
428
+ return newFixedLengthResponse(
429
+ Response.Status.lookup(response.code) ?: Response.Status.OK,
430
+ response.contentType ?: "application/json",
431
+ response.body.inputStream(),
432
+ response.body.size.toLong()
433
+ )
434
+ }
435
+
436
+ val connection = url.openConnection() as HttpURLConnection
187
437
  connection.doInput = true
188
438
  connection.instanceFollowRedirects = false
189
-
190
- val method = session?.method?.name ?: "GET"
191
-
192
439
  connection.requestMethod = method
193
440
 
194
441
  connection.connectTimeout = 15000
@@ -198,30 +445,14 @@ class LocalServer(
198
445
  * Forward headers
199
446
  */
200
447
 
201
- session?.headers?.forEach { (key, value) ->
202
-
203
- val skip = setOf(
204
- "host", "content-length", "connection", "accept-encoding"
448
+ preparedHeaders.forEach { (key, value) ->
449
+ connection.setRequestProperty(
450
+ key, value
205
451
  )
206
-
207
-
208
- if (!skip.contains(key.lowercase())) {
209
-
210
- connection.setRequestProperty(
211
- key, value
212
- )
213
- }
214
452
  }
215
- session?.headers?.get("authorization")
216
- ?.let { auth ->
217
- connection.setRequestProperty(
218
- "Authorization", auth
219
- )
220
- }
221
-
222
453
 
223
- if (method == "POST" || method == "PUT" || method == "PATCH") {
224
454
 
455
+ if (bodyPolicy?.shouldWriteBody == true) {
225
456
  val files = HashMap<String, String>()
226
457
 
227
458
  session?.parseBody(files)
@@ -231,7 +462,7 @@ class LocalServer(
231
462
  val bytes = body.toByteArray(Charsets.UTF_8)
232
463
 
233
464
  connection.doOutput = true
234
- session?.headers?.get("content-type")
465
+ bodyPolicy.contentType
235
466
  ?.let {
236
467
  connection.setRequestProperty(
237
468
  "Content-Type", it
@@ -280,6 +511,18 @@ class LocalServer(
280
511
 
281
512
  )
282
513
 
514
+ } catch (e: InvalidContentLengthException) {
515
+ return newFixedLengthResponse(
516
+ Response.Status.BAD_REQUEST,
517
+ "application/json",
518
+ """{"error":"invalid_content_length"}"""
519
+ )
520
+ } catch (e: UnsupportedTransferEncodingException) {
521
+ return newFixedLengthResponse(
522
+ Response.Status.BAD_REQUEST,
523
+ "application/json",
524
+ """{"error":"unsupported_transfer_encoding"}"""
525
+ )
283
526
  } catch (e: Exception) {
284
527
  AppDebugLogger.error(
285
528
 
@@ -0,0 +1,90 @@
1
+ package com.pakstr.app
2
+
3
+ import org.json.JSONArray
4
+ import org.json.JSONObject
5
+ import java.net.URL
6
+ import java.util.Base64
7
+
8
+ internal data class ProxyOrigin(
9
+ val host: String,
10
+ val proto: String
11
+ )
12
+
13
+ internal fun localNip98ProxyOrigin(
14
+ authorization: String?,
15
+ method: String,
16
+ requestUrl: URL
17
+ ): ProxyOrigin? {
18
+ val encodedEvent = authorization
19
+ ?.takeIf { it.startsWith("Nostr ") }
20
+ ?.removePrefix("Nostr ")
21
+ ?.takeIf { it.isNotBlank() }
22
+ ?: return null
23
+
24
+ return runCatching {
25
+ val event = JSONObject(
26
+ String(Base64.getDecoder().decode(encodedEvent), Charsets.UTF_8)
27
+ )
28
+ val kind = event.opt("kind")
29
+ if (kind !is Number || kind.toInt() != 27235 || kind.toDouble() != 27235.0) {
30
+ return null
31
+ }
32
+
33
+ val tags = event.optJSONArray("tags") ?: return null
34
+ val signedUrlValue = uniqueUsableTagValue(tags, "u") ?: return null
35
+ val signedMethod = uniqueUsableTagValue(tags, "method") ?: return null
36
+ if (!signedMethod.equals(method, ignoreCase = true)) return null
37
+
38
+ val signedUrl = URL(signedUrlValue)
39
+ if (signedUrl.protocol != "http" ||
40
+ signedUrl.host != "127.0.0.1" ||
41
+ signedUrl.port != Config.PORT ||
42
+ signedUrl.userInfo != null ||
43
+ signedUrl.ref != null ||
44
+ signedUrl.path != requestUrl.path ||
45
+ signedUrl.query != requestUrl.query
46
+ ) {
47
+ return null
48
+ }
49
+
50
+ ProxyOrigin(
51
+ host = "127.0.0.1:${Config.PORT}",
52
+ proto = "http"
53
+ )
54
+ }.getOrNull()
55
+ }
56
+
57
+ internal fun prepareProxyRequestHeaders(
58
+ headers: Map<String, String>,
59
+ method: String,
60
+ requestUrl: URL,
61
+ apiBase: String?
62
+ ): Map<String, String> {
63
+ if (apiBase.isNullOrBlank()) return headers
64
+
65
+ val prepared = headers.filterKeys(::shouldForwardProxyRequestHeader).toMutableMap()
66
+ localNip98ProxyOrigin(
67
+ authorization = headers.entries.firstOrNull {
68
+ it.key.equals("authorization", ignoreCase = true)
69
+ }?.value,
70
+ method = method,
71
+ requestUrl = requestUrl
72
+ )?.let { origin ->
73
+ prepared["X-Forwarded-Host"] = origin.host
74
+ prepared["X-Forwarded-Proto"] = origin.proto
75
+ }
76
+ return prepared
77
+ }
78
+
79
+ private fun uniqueUsableTagValue(tags: JSONArray, name: String): String? {
80
+ var value: String? = null
81
+ var matchingTags = 0
82
+ for (index in 0 until tags.length()) {
83
+ val tag = tags.optJSONArray(index) ?: continue
84
+ if (tag.optString(0) != name) continue
85
+ matchingTags++
86
+ if (tag.length() != 2 || tag.opt(1) !is String) return null
87
+ value = tag.optString(1).takeIf { it.isNotBlank() } ?: return null
88
+ }
89
+ return value.takeIf { matchingTags == 1 }
90
+ }
@@ -0,0 +1,97 @@
1
+ package com.pakstr.app
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertNull
6
+ import org.junit.Assert.assertThrows
7
+ import org.junit.Assert.assertTrue
8
+ import org.junit.Test
9
+ import java.net.URL
10
+
11
+ class ProxyRequestPolicyTest {
12
+
13
+ @Test
14
+ fun bodylessPostDoesNotWriteBodyOrManufactureContentType() {
15
+ val policy = proxyRequestBodyPolicy(emptyMap())
16
+
17
+ assertFalse(policy.shouldWriteBody)
18
+ assertNull(policy.contentType)
19
+ }
20
+
21
+ @Test
22
+ fun zeroLengthPostDoesNotWriteBodyOrManufactureContentType() {
23
+ val policy = proxyRequestBodyPolicy(
24
+ mapOf("content-length" to "0")
25
+ )
26
+
27
+ assertFalse(policy.shouldWriteBody)
28
+ assertNull(policy.contentType)
29
+ }
30
+
31
+ @Test
32
+ fun bodylessRequestOmitsEntityHeadersAndBody() {
33
+ val request = bodylessProxyRequestBytes(
34
+ "POST",
35
+ URL("https://example.com/api/auth/login"),
36
+ mapOf(
37
+ "authorization" to "Nostr event",
38
+ "content-type" to "",
39
+ "content-length" to "0",
40
+ "transfer-encoding" to "chunked"
41
+ )
42
+ ).toString(Charsets.ISO_8859_1)
43
+
44
+ assertTrue(request.startsWith("POST /api/auth/login HTTP/1.1\r\n"))
45
+ assertTrue(request.contains("authorization: Nostr event\r\n"))
46
+ assertFalse(request.contains("Content-Type", ignoreCase = true))
47
+ assertFalse(request.contains("Content-Length", ignoreCase = true))
48
+ assertFalse(request.contains("Transfer-Encoding", ignoreCase = true))
49
+ assertTrue(request.endsWith("Connection: close\r\n\r\n"))
50
+ }
51
+
52
+ @Test
53
+ fun jsonPostWritesBodyAndPreservesContentType() {
54
+ val policy = proxyRequestBodyPolicy(
55
+ mapOf(
56
+ "content-length" to "11",
57
+ "content-type" to "application/json"
58
+ )
59
+ )
60
+
61
+ assertTrue(policy.shouldWriteBody)
62
+ assertEquals("application/json", policy.contentType)
63
+ }
64
+
65
+ @Test
66
+ fun authorizationIsForwardedButTransferEncodingIsNot() {
67
+ assertTrue(shouldForwardProxyRequestHeader("authorization"))
68
+ assertFalse(shouldForwardProxyRequestHeader("transfer-encoding"))
69
+ }
70
+
71
+ @Test
72
+ fun transferEncodedBodyIsRejected() {
73
+ assertThrows(UnsupportedTransferEncodingException::class.java) {
74
+ proxyRequestBodyPolicy(
75
+ mapOf("transfer-encoding" to "chunked")
76
+ )
77
+ }
78
+ }
79
+
80
+ @Test
81
+ fun malformedContentLengthIsRejected() {
82
+ assertThrows(InvalidContentLengthException::class.java) {
83
+ proxyRequestBodyPolicy(
84
+ mapOf("content-length" to "invalid")
85
+ )
86
+ }
87
+ }
88
+
89
+ @Test
90
+ fun negativeContentLengthIsRejected() {
91
+ assertThrows(InvalidContentLengthException::class.java) {
92
+ proxyRequestBodyPolicy(
93
+ mapOf("content-length" to "-1")
94
+ )
95
+ }
96
+ }
97
+ }
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ const sign_1 = require("./commands/sign");
9
9
  const publish_1 = require("./commands/publish");
10
10
  const run_1 = require("./commands/run");
11
11
  const init_1 = require("./commands/init");
12
+ const nsite_1 = require("./commands/nsite");
12
13
  const dotenv_1 = require("./core/dotenv");
13
14
  const package_json_1 = __importDefault(require("../package.json"));
14
15
  async function runCLI(argv) {
@@ -40,6 +41,12 @@ async function runCLI(argv) {
40
41
  case "sign":
41
42
  await (0, sign_1.signCommand)(configFlag ?? undefined);
42
43
  return;
44
+ case "nsite":
45
+ await (0, nsite_1.nsiteCommand)(configFlag ?? undefined, {
46
+ dir: extractFlag(args, "--dir"),
47
+ dryRun: args.includes("--dry-run"),
48
+ });
49
+ return;
43
50
  case "publish":
44
51
  await (0, publish_1.publishCommand)(configFlag ?? undefined, {
45
52
  dryRun: args.includes("--dry-run"),
@@ -75,6 +82,7 @@ Usage:
75
82
  pakstr init [--force] [--config <path>]
76
83
  pakstr build [--config <path>]
77
84
  pakstr sign [--config <path>]
85
+ pakstr nsite [--config <path>] [--dir <path>] [--dry-run]
78
86
  pakstr publish [--config <path>] [--dry-run] [--verify] [--out-json <path>]
79
87
  pakstr run [--config <path>] [--dry-run] [--verify-publish] [--publish-out-json <path>]
80
88
 
@@ -196,6 +196,22 @@ publish:
196
196
  source: upload
197
197
  # source: https://downloads.example.com/my-app.apk # Or an absolute public APK URL.
198
198
 
199
+ nsite:
200
+ enabled: true
201
+ relays:
202
+ - wss://nostr.cercatrova.me
203
+ - wss://relay.primal.net
204
+ - wss://nos.lol
205
+ - wss://relay.damus.io
206
+ servers:
207
+ - https://cdn.hzrd149.com
208
+ - https://cdn.sovbit.host
209
+ - https://cdn.nostrcheck.me
210
+ - https://nostr.download
211
+ publishProfile: true # Publishes only when explicit profile data is added.
212
+ publishRelayList: true
213
+ publishServerList: true
214
+
199
215
  relay: wss://relay.zapstore.dev
200
216
  # publishKey: PAKSTR_PUBLISH_NSEC # OPTIONAL. Omit to reuse ${nostr_1.PAKSTR_NSEC_ENV} for publishing.
201
217
 
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.nsiteCommand = nsiteCommand;
7
+ const path_1 = __importDefault(require("path"));
8
+ const nsite_1 = require("../core/nsite");
9
+ const nostr_1 = require("../core/nostr");
10
+ const pakstrConfig_1 = require("../core/pakstrConfig");
11
+ const zapStore_1 = require("../core/zapStore");
12
+ async function nsiteCommand(configPath, options = {}) {
13
+ const directoryOverride = options.dir ? path_1.default.resolve(options.dir) : undefined;
14
+ const config = (0, pakstrConfig_1.loadPakstrConfig)(configPath, { webOverride: directoryOverride });
15
+ const directory = directoryOverride ?? config.build.web;
16
+ const publishNsec = (0, nostr_1.resolvePublishNsec)(config.publish.publishKey, process.env);
17
+ const transport = options.transport ?? (options.dryRun ? new zapStore_1.MockRelayTransport() : new zapStore_1.WebsocketRelayTransport());
18
+ console.log("\n🌐 pakstr nsite" + (options.dryRun ? " (dry-run)" : ""));
19
+ console.log("Directory:", directory);
20
+ console.log("Publishing as:", publishNsec.envVar);
21
+ console.log("Blossom servers:", config.publish.nsite.servers.join(", "));
22
+ console.log("Relays:", config.publish.nsite.relays.join(", "));
23
+ const result = await (0, nsite_1.publishNsite)({
24
+ directory,
25
+ config: config.publish.nsite,
26
+ secret: publishNsec.bytes,
27
+ transport,
28
+ dryRun: options.dryRun,
29
+ fetchImpl: options.fetchImpl,
30
+ });
31
+ console.log(`Files: ${result.files.length} (${result.totalBytes} bytes)`);
32
+ console.log(`Events: ${result.events.map(item => item.event.kind).join(", ")}`);
33
+ if (result.partialFailures > 0)
34
+ console.log(`⚠️ Nsite published with ${result.partialFailures} target failure(s)`);
35
+ else
36
+ console.log(options.dryRun ? "✅ Nsite dry run complete" : "✅ Nsite published");
37
+ return result;
38
+ }
@@ -12,6 +12,7 @@ const giteaRelease_1 = require("../core/giteaRelease");
12
12
  const identityProof_1 = require("../core/identityProof");
13
13
  const nostr_1 = require("../core/nostr");
14
14
  const pakstrConfig_1 = require("../core/pakstrConfig");
15
+ const nsite_1 = require("../core/nsite");
15
16
  const zapStore_1 = require("../core/zapStore");
16
17
  const zapstoreConfig_1 = require("../core/zapstoreConfig");
17
18
  /** `pakstr publish` — optionally upload the signed APK and publish its Zapstore events. */
@@ -23,9 +24,40 @@ async function publishCommand(configPath, options = {}) {
23
24
  const apkPath = path_1.default.resolve(config.build.out);
24
25
  const uploadEnabled = config.publish.upload.enabled;
25
26
  const zapstoreEnabled = config.publish.zapstoreEnabled;
27
+ const nsiteEnabled = config.publish.nsite.enabled;
26
28
  const fetchImpl = options.fetchImpl ?? options.blossomFetch;
29
+ if (!uploadEnabled && !zapstoreEnabled && !nsiteEnabled) {
30
+ throw new Error("Nothing to publish: publish.upload, publish.zapstore, and publish.nsite are disabled");
31
+ }
32
+ const transport = options.transport ?? (options.dryRun ? new zapStore_1.MockRelayTransport() : new zapStore_1.WebsocketRelayTransport());
27
33
  if (!uploadEnabled && !zapstoreEnabled) {
28
- throw new Error("Nothing to publish: both publish.upload and publish.zapstore are disabled");
34
+ console.log("\n📤 pakstr publish" + (options.dryRun ? " (dry-run)" : ""));
35
+ console.log("App:", config.app.appName, `(${config.app.appId})`);
36
+ console.log("Upload: disabled");
37
+ console.log("Zapstore: disabled");
38
+ const publishNsec = (0, zapStore_1.resolvePublishSecret)(config.publish.publishKey, process.env);
39
+ const nsite = await (0, nsite_1.publishNsite)({
40
+ directory: config.build.web,
41
+ config: config.publish.nsite,
42
+ secret: publishNsec.bytes,
43
+ transport,
44
+ dryRun: options.dryRun,
45
+ fetchImpl,
46
+ });
47
+ const result = { artifactUrl: "", uploadProvider: null, nsite };
48
+ if (options.outJson) {
49
+ fs_1.default.writeFileSync(options.outJson, JSON.stringify({
50
+ appId: config.app.appId,
51
+ appName: config.app.appName,
52
+ versionName: config.app.versionName,
53
+ versionCode: config.app.versionCode,
54
+ nsite,
55
+ publishedAt: new Date().toISOString(),
56
+ dryRun: !!options.dryRun,
57
+ }, null, 2) + "\n", "utf8");
58
+ console.log("📝 Publish summary written to:", options.outJson);
59
+ }
60
+ return result;
29
61
  }
30
62
  console.log("\n📤 pakstr publish" + (options.dryRun ? " (dry-run)" : ""));
31
63
  console.log("App:", config.app.appName, `(${config.app.appId})`);
@@ -59,7 +91,6 @@ async function publishCommand(configPath, options = {}) {
59
91
  console.log("⚠️ No .identity-proof sidecar found — kind 30509 will not be published.");
60
92
  }
61
93
  }
62
- const transport = options.transport ?? (options.dryRun ? new zapStore_1.MockRelayTransport() : new zapStore_1.WebsocketRelayTransport());
63
94
  let zapstoreConfig;
64
95
  let publishNsec;
65
96
  let npub;
@@ -236,11 +267,27 @@ async function publishCommand(configPath, options = {}) {
236
267
  throw new Error(`Artifact verification failed: HTTP ${response.status}`);
237
268
  }
238
269
  }
270
+ let nsiteResult;
271
+ if (nsiteEnabled) {
272
+ const nsiteSecret = publishNsec ?? (0, zapStore_1.resolvePublishSecret)(config.publish.publishKey, process.env);
273
+ nsiteResult = await (0, nsite_1.publishNsite)({
274
+ directory: config.build.web,
275
+ config: config.publish.nsite,
276
+ secret: nsiteSecret.bytes,
277
+ transport,
278
+ dryRun: options.dryRun,
279
+ fetchImpl,
280
+ });
281
+ if (nsiteResult.partialFailures > 0) {
282
+ console.log(`⚠️ Nsite published with ${nsiteResult.partialFailures} target failure(s)`);
283
+ }
284
+ }
239
285
  const result = {
240
286
  artifactUrl,
241
287
  uploadProvider,
242
288
  zapstore: zapstoreResult,
243
289
  ...(zapstoreResult ?? {}),
290
+ ...(nsiteResult ? { nsite: nsiteResult } : {}),
244
291
  };
245
292
  console.log("🔗 Artifact URL:", artifactUrl);
246
293
  if (options.outJson) {
@@ -267,6 +314,7 @@ async function publishCommand(configPath, options = {}) {
267
314
  identityProofEventId: zapstoreResult?.identityProofEventId,
268
315
  publishedAt: new Date().toISOString(),
269
316
  dryRun: !!options.dryRun,
317
+ ...(nsiteResult ? { nsite: nsiteResult } : {}),
270
318
  };
271
319
  fs_1.default.writeFileSync(options.outJson, JSON.stringify(summary, null, 2) + "\n", "utf8");
272
320
  console.log("📝 Publish summary written to:", options.outJson);
@@ -27,16 +27,16 @@ async function runCommand(configPath, options = {}) {
27
27
  const config = (0, pakstrConfig_1.loadPakstrConfig)(configPath);
28
28
  // Verify PAKSTR_NSEC present (fail fast before any work).
29
29
  (0, nostr_1.requireSigningNsec)(process.env);
30
- // Pre-validate the Nostr publish nsec when Zapstore or Blossom needs it.
31
- if (config.publish.zapstoreEnabled || (config.publish.upload.enabled && config.publish.upload.provider === "blossom")) {
30
+ // Pre-validate the Nostr publish nsec when Zapstore, Blossom, or nsite needs it.
31
+ if (config.publish.zapstoreEnabled || config.publish.nsite.enabled || (config.publish.upload.enabled && config.publish.upload.provider === "blossom")) {
32
32
  if (!(0, nostr_1.isPublishNsecPresent)(config.publish.publishKey, process.env)) {
33
33
  const envVar = config.publish.publishKey === undefined
34
34
  ? "PAKSTR_NSEC"
35
35
  : config.publish.publishKey === null
36
36
  ? "PAKSTR_PUBLISH_NSEC"
37
37
  : config.publish.publishKey;
38
- throw new Error(`${envVar} is required for publishing and must not be empty ` +
39
- `(publish.zapstore.enabled is true). Pre-validation failed before any build work.`);
38
+ throw new Error(`${envVar} is required for publishing and must not be empty. ` +
39
+ `Pre-validation failed before any build work.`);
40
40
  }
41
41
  }
42
42
  console.log("\n🚀 pakstr run");
@@ -44,7 +44,7 @@ async function runCommand(configPath, options = {}) {
44
44
  console.log("Version:", config.app.versionName, `(${config.app.versionCode})`);
45
45
  console.log("Web:", config.build.web);
46
46
  console.log("Out:", path_1.default.resolve(config.build.out));
47
- const publishingEnabled = config.publish.zapstoreEnabled || config.publish.upload.enabled;
47
+ const publishingEnabled = config.publish.zapstoreEnabled || config.publish.upload.enabled || config.publish.nsite.enabled;
48
48
  console.log("Publish:", publishingEnabled ? "enabled" : "disabled");
49
49
  if (options.dryRun) {
50
50
  // Skip build + sign — no Docker, no APK. Only exercise the publish
@@ -95,10 +95,10 @@ async function uploadToBlossom(opts) {
95
95
  try {
96
96
  const descriptor = JSON.parse(text);
97
97
  if (descriptor.sha256 && descriptor.sha256 !== sha256) {
98
- throw new BlossomError("Blossom response SHA-256 does not match the uploaded APK");
98
+ throw new BlossomError("Blossom response SHA-256 does not match the uploaded file");
99
99
  }
100
100
  if (descriptor.size !== undefined && descriptor.size !== size) {
101
- throw new BlossomError("Blossom response size does not match the uploaded APK");
101
+ throw new BlossomError("Blossom response size does not match the uploaded file");
102
102
  }
103
103
  if (!descriptor.url)
104
104
  descriptor.url = `${serverUrl}/${sha256}`;
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.NSITE_MANIFEST_KIND = void 0;
7
+ exports.publishNsite = publishNsite;
8
+ exports.inspectNsiteDirectory = inspectNsiteDirectory;
9
+ exports.buildNsiteEventTemplates = buildNsiteEventTemplates;
10
+ const crypto_1 = require("crypto");
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const blossom_1 = require("./blossom");
14
+ const nostr_1 = require("./nostr");
15
+ exports.NSITE_MANIFEST_KIND = 15128;
16
+ async function publishNsite(options) {
17
+ const files = inspectNsiteDirectory(options.directory);
18
+ const fileResults = [];
19
+ let partialFailures = 0;
20
+ for (const file of files) {
21
+ const servers = [];
22
+ if (options.dryRun) {
23
+ servers.push(...options.config.servers.map(target => ({ target, success: true, skipped: true })));
24
+ }
25
+ else {
26
+ const artifact = {
27
+ filePath: file.filePath,
28
+ filename: path_1.default.basename(file.filePath),
29
+ contentType: file.contentType,
30
+ sha256: file.sha256,
31
+ size: file.size,
32
+ };
33
+ for (const serverUrl of options.config.servers) {
34
+ try {
35
+ await (0, blossom_1.uploadToBlossom)({
36
+ serverUrl,
37
+ artifact,
38
+ secret: options.secret,
39
+ contentType: file.contentType,
40
+ fetchImpl: options.fetchImpl,
41
+ });
42
+ servers.push({ target: serverUrl, success: true });
43
+ }
44
+ catch (error) {
45
+ partialFailures++;
46
+ const status = typeof error === "object" && error !== null && "status" in error
47
+ ? Number(error.status)
48
+ : undefined;
49
+ servers.push({ target: serverUrl, success: false, ...(Number.isFinite(status) ? { status } : {}) });
50
+ }
51
+ }
52
+ if (!servers.some(result => result.success)) {
53
+ throw new Error(`Nsite upload failed for ${file.path} on every configured Blossom server`);
54
+ }
55
+ }
56
+ fileResults.push({ ...file, servers });
57
+ }
58
+ const createdAt = Math.floor((options.now?.() ?? Date.now()) / 1000);
59
+ const eventTemplates = buildNsiteEventTemplates(files, options.config, createdAt);
60
+ const events = [];
61
+ for (const template of eventTemplates) {
62
+ const event = await (0, nostr_1.signNostrEvent)(template, options.secret);
63
+ const relays = [];
64
+ if (options.dryRun) {
65
+ relays.push(...options.config.relays.map(target => ({ target, success: true, skipped: true })));
66
+ }
67
+ else {
68
+ for (const relayUrl of options.config.relays) {
69
+ try {
70
+ await options.transport.publish(event, relayUrl);
71
+ relays.push({ target: relayUrl, success: true });
72
+ }
73
+ catch {
74
+ partialFailures++;
75
+ relays.push({ target: relayUrl, success: false });
76
+ }
77
+ }
78
+ if (event.kind === exports.NSITE_MANIFEST_KIND && !relays.some(result => result.success)) {
79
+ throw new Error("Nsite manifest was rejected by every configured relay");
80
+ }
81
+ }
82
+ events.push({ event, relays });
83
+ }
84
+ return {
85
+ dryRun: !!options.dryRun,
86
+ directory: path_1.default.resolve(options.directory),
87
+ totalBytes: files.reduce((sum, file) => sum + file.size, 0),
88
+ files: fileResults,
89
+ events,
90
+ partialFailures,
91
+ };
92
+ }
93
+ function inspectNsiteDirectory(directory) {
94
+ const root = path_1.default.resolve(directory);
95
+ if (!fs_1.default.existsSync(root) || !fs_1.default.lstatSync(root).isDirectory()) {
96
+ throw new Error(`Nsite directory does not exist or is not a directory: ${root}`);
97
+ }
98
+ const indexPath = path_1.default.join(root, "index.html");
99
+ if (!fs_1.default.existsSync(indexPath) || !fs_1.default.lstatSync(indexPath).isFile()) {
100
+ throw new Error(`Nsite directory must contain index.html: ${root}`);
101
+ }
102
+ const files = [];
103
+ const walk = (current) => {
104
+ const entries = fs_1.default.readdirSync(current, { withFileTypes: true })
105
+ .sort((a, b) => a.name.localeCompare(b.name));
106
+ for (const entry of entries) {
107
+ const filePath = path_1.default.join(current, entry.name);
108
+ if (entry.isSymbolicLink())
109
+ continue;
110
+ if (entry.isDirectory()) {
111
+ walk(filePath);
112
+ }
113
+ else if (entry.isFile()) {
114
+ const bytes = fs_1.default.readFileSync(filePath);
115
+ const relative = path_1.default.relative(root, filePath).split(path_1.default.sep).join("/");
116
+ files.push({
117
+ filePath,
118
+ path: `/${relative}`,
119
+ sha256: (0, crypto_1.createHash)("sha256").update(bytes).digest("hex"),
120
+ size: bytes.length,
121
+ contentType: contentTypeFor(filePath),
122
+ });
123
+ }
124
+ }
125
+ };
126
+ walk(root);
127
+ return files.sort((a, b) => a.path.localeCompare(b.path));
128
+ }
129
+ function buildNsiteEventTemplates(files, config, createdAt) {
130
+ const templates = [{
131
+ kind: exports.NSITE_MANIFEST_KIND,
132
+ created_at: createdAt,
133
+ tags: [
134
+ ...files.map(file => ["path", file.path, file.sha256]),
135
+ ...config.servers.map(server => ["server", server]),
136
+ ...config.relays.map(relay => ["relay", relay]),
137
+ ["client", "pakstr"],
138
+ ],
139
+ content: "",
140
+ }];
141
+ if (config.publishRelayList) {
142
+ templates.push({ kind: 10002, created_at: createdAt, tags: config.relays.map(relay => ["r", relay, "write"]), content: "" });
143
+ }
144
+ if (config.publishServerList) {
145
+ templates.push({ kind: 10063, created_at: createdAt, tags: config.servers.map(server => ["server", server]), content: "" });
146
+ }
147
+ if (config.publishProfile && config.profile && Object.keys(config.profile).length > 0) {
148
+ templates.push({ kind: 0, created_at: createdAt, tags: [], content: JSON.stringify(config.profile) });
149
+ }
150
+ return templates;
151
+ }
152
+ function contentTypeFor(filePath) {
153
+ const types = {
154
+ ".css": "text/css; charset=utf-8",
155
+ ".gif": "image/gif",
156
+ ".html": "text/html; charset=utf-8",
157
+ ".ico": "image/x-icon",
158
+ ".jpeg": "image/jpeg",
159
+ ".jpg": "image/jpeg",
160
+ ".js": "text/javascript; charset=utf-8",
161
+ ".json": "application/json; charset=utf-8",
162
+ ".map": "application/json; charset=utf-8",
163
+ ".mjs": "text/javascript; charset=utf-8",
164
+ ".otf": "font/otf",
165
+ ".png": "image/png",
166
+ ".svg": "image/svg+xml",
167
+ ".txt": "text/plain; charset=utf-8",
168
+ ".wasm": "application/wasm",
169
+ ".webmanifest": "application/manifest+json",
170
+ ".webp": "image/webp",
171
+ ".woff": "font/woff",
172
+ ".woff2": "font/woff2",
173
+ ".xml": "application/xml",
174
+ };
175
+ return types[path_1.default.extname(filePath).toLowerCase()] ?? "application/octet-stream";
176
+ }
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.ConfigError = exports.APP_ID_PATTERN = exports.PERMISSION_ALIASES = exports.PAKSTR_CONFIG_FILENAME = void 0;
6
+ exports.ConfigError = exports.DEFAULT_NSITE_SERVERS = exports.DEFAULT_NSITE_RELAYS = exports.APP_ID_PATTERN = exports.PERMISSION_ALIASES = exports.PAKSTR_CONFIG_FILENAME = void 0;
7
7
  exports.sanitizeAppIdSegment = sanitizeAppIdSegment;
8
8
  exports.loadPakstrConfig = loadPakstrConfig;
9
9
  exports.validatePakstrConfig = validatePakstrConfig;
@@ -43,6 +43,18 @@ function sanitizeAppIdSegment(input) {
43
43
  s = s.replace(/^[^a-z]+/, ""); // drop leading non-letters (digits, underscores)
44
44
  return s.length > 0 ? s : null;
45
45
  }
46
+ exports.DEFAULT_NSITE_RELAYS = [
47
+ "wss://nostr.cercatrova.me",
48
+ "wss://relay.primal.net",
49
+ "wss://nos.lol",
50
+ "wss://relay.damus.io",
51
+ ];
52
+ exports.DEFAULT_NSITE_SERVERS = [
53
+ "https://cdn.hzrd149.com",
54
+ "https://cdn.sovbit.host",
55
+ "https://cdn.nostrcheck.me",
56
+ "https://nostr.download",
57
+ ];
46
58
  class ConfigError extends Error {
47
59
  configPath;
48
60
  constructor(message, configPath) {
@@ -59,7 +71,7 @@ function fail(message, configPath) {
59
71
  * Load and fully validate `pakstr.yaml` from `configPath` (defaults to
60
72
  * `pakstr.yaml` in `cwd`). Returns paths resolved relative to the yaml file.
61
73
  */
62
- function loadPakstrConfig(configPath) {
74
+ function loadPakstrConfig(configPath, options = {}) {
63
75
  const resolvedPath = path_1.default.resolve(configPath ?? path_1.default.join(process.cwd(), exports.PAKSTR_CONFIG_FILENAME));
64
76
  if (!fs_1.default.existsSync(resolvedPath) || !fs_1.default.lstatSync(resolvedPath).isFile()) {
65
77
  fail(`pakstr.yaml not found: ${resolvedPath}`);
@@ -76,7 +88,7 @@ function loadPakstrConfig(configPath) {
76
88
  if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw)) {
77
89
  fail("pakstr.yaml must be a mapping with `app`, `build`, and optionally `publish` sections", resolvedPath);
78
90
  }
79
- return resolveAndValidate(raw, resolvedPath);
91
+ return resolveAndValidate(raw, resolvedPath, options);
80
92
  }
81
93
  /**
82
94
  * Validate an in-memory Pakstr configuration through `resolveAndValidate`.
@@ -86,7 +98,7 @@ function loadPakstrConfig(configPath) {
86
98
  function validatePakstrConfig(config, configPath) {
87
99
  resolveAndValidate(config, configPath ?? "");
88
100
  }
89
- function resolveAndValidate(config, configPath) {
101
+ function resolveAndValidate(config, configPath, options = {}) {
90
102
  const configDir = configPath ? path_1.default.dirname(configPath) : process.cwd();
91
103
  const where = (field) => configPath ? `${field} (in ${configPath})` : field;
92
104
  const app = config.app;
@@ -160,7 +172,7 @@ function resolveAndValidate(config, configPath) {
160
172
  if (!build || typeof build !== "object")
161
173
  fail(`Missing required section: build`, configPath);
162
174
  requireString(build.web, "build.web", configPath);
163
- const webAbs = path_1.default.resolve(configDir, build.web);
175
+ const webAbs = options.webOverride ? path_1.default.resolve(options.webOverride) : path_1.default.resolve(configDir, build.web);
164
176
  if (!fs_1.default.existsSync(webAbs) || !fs_1.default.lstatSync(webAbs).isDirectory()) {
165
177
  fail(`${where("build.web")} does not exist or is not a directory: ${webAbs}`, configPath);
166
178
  }
@@ -226,6 +238,31 @@ function resolveAndValidate(config, configPath) {
226
238
  }
227
239
  }
228
240
  const relay = publish?.relay ?? "wss://relay.zapstore.dev";
241
+ const rawNsite = publish?.nsite;
242
+ if (rawNsite !== undefined && (rawNsite === null || typeof rawNsite !== "object" || Array.isArray(rawNsite))) {
243
+ fail(`${where("publish.nsite")} must be a mapping`, configPath);
244
+ }
245
+ if (rawNsite?.enabled !== undefined && typeof rawNsite.enabled !== "boolean") {
246
+ fail(`${where("publish.nsite.enabled")} must be a boolean`, configPath);
247
+ }
248
+ const booleanNsiteFields = ["publishProfile", "publishRelayList", "publishServerList"];
249
+ for (const field of booleanNsiteFields) {
250
+ if (rawNsite?.[field] !== undefined && typeof rawNsite[field] !== "boolean") {
251
+ fail(`${where(`publish.nsite.${field}`)} must be a boolean`, configPath);
252
+ }
253
+ }
254
+ const nsiteRelays = resolveUrlList(rawNsite?.relays, exports.DEFAULT_NSITE_RELAYS, "publish.nsite.relays", ["ws:", "wss:"], configPath);
255
+ const nsiteServers = resolveUrlList(rawNsite?.servers, exports.DEFAULT_NSITE_SERVERS, "publish.nsite.servers", ["http:", "https:"], configPath).map(value => value.replace(/\/$/, ""));
256
+ const nsiteProfile = resolveNsiteProfile(rawNsite?.profile, configPath);
257
+ const nsite = {
258
+ enabled: rawNsite === undefined ? false : rawNsite.enabled ?? true,
259
+ relays: nsiteRelays,
260
+ servers: nsiteServers,
261
+ publishProfile: rawNsite?.publishProfile ?? true,
262
+ publishRelayList: rawNsite?.publishRelayList ?? true,
263
+ publishServerList: rawNsite?.publishServerList ?? true,
264
+ ...(nsiteProfile ? { profile: nsiteProfile } : {}),
265
+ };
229
266
  if (publish?.blossom !== undefined && publish.upload !== undefined) {
230
267
  fail(`${where("publish.blossom")} cannot be combined with publish.upload`, configPath);
231
268
  }
@@ -292,9 +329,51 @@ function resolveAndValidate(config, configPath) {
292
329
  app: resolvedApp,
293
330
  build: { web: webAbs, out, builder },
294
331
  runtime: resolvedRuntime,
295
- publish: { zapstoreEnabled, zapstoreSource, upload, publishKey, relay, blossom },
332
+ publish: { zapstoreEnabled, zapstoreSource, upload, nsite, publishKey, relay, blossom },
296
333
  };
297
334
  }
335
+ function resolveUrlList(value, defaults, field, protocols, configPath) {
336
+ const values = value === undefined ? defaults : value;
337
+ if (!Array.isArray(values) || values.length === 0 || values.some(item => typeof item !== "string" || item.length === 0)) {
338
+ fail(`${field} must be a non-empty list of URLs`, configPath);
339
+ }
340
+ const resolved = [];
341
+ for (const item of values) {
342
+ let parsed;
343
+ try {
344
+ parsed = new URL(item);
345
+ }
346
+ catch {
347
+ fail(`${field} entries must be absolute ${protocols.join("/")} URLs`, configPath);
348
+ }
349
+ if (!protocols.includes(parsed.protocol) || !parsed.hostname || parsed.username || parsed.password) {
350
+ fail(`${field} entries must be absolute ${protocols.join("/")} URLs without credentials`, configPath);
351
+ }
352
+ const normalized = parsed.toString().replace(/\/$/, "");
353
+ if (!resolved.includes(normalized))
354
+ resolved.push(normalized);
355
+ }
356
+ return resolved;
357
+ }
358
+ function resolveNsiteProfile(value, configPath) {
359
+ if (value === undefined)
360
+ return undefined;
361
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
362
+ fail("publish.nsite.profile must be a mapping", configPath);
363
+ }
364
+ const fields = ["name", "display_name", "about", "picture", "banner", "website", "nip05", "lud16", "lud06"];
365
+ const entries = [];
366
+ for (const field of fields) {
367
+ const fieldValue = value[field];
368
+ if (fieldValue !== undefined) {
369
+ if (typeof fieldValue !== "string" || fieldValue.length === 0) {
370
+ fail(`publish.nsite.profile.${field} must be a non-empty string`, configPath);
371
+ }
372
+ entries.push([field, fieldValue]);
373
+ }
374
+ }
375
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
376
+ }
298
377
  function requireString(value, field, configPath) {
299
378
  if (typeof value !== "string" || value.length === 0) {
300
379
  fail(`Missing required field: ${field}`, configPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pakstr",
3
- "version": "0.19.2",
3
+ "version": "0.20.0",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",