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.
- package/android-template/app/src/androidTest/java/com/pakstr/app/Nip98ProxyOriginTest.kt +208 -0
- package/android-template/app/src/main/java/com/pakstr/app/LocalServer.kt +268 -25
- package/android-template/app/src/main/java/com/pakstr/app/Nip98ProxyOrigin.kt +90 -0
- package/android-template/app/src/test/java/com/pakstr/app/ProxyRequestPolicyTest.kt +97 -0
- package/dist/cli.js +8 -0
- package/dist/commands/init.js +16 -0
- package/dist/commands/nsite.js +38 -0
- package/dist/commands/publish.js +50 -2
- package/dist/commands/run.js +5 -5
- package/dist/core/blossom.js +2 -2
- package/dist/core/nsite.js +176 -0
- package/dist/core/pakstrConfig.js +85 -6
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/publish.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/commands/run.js
CHANGED
|
@@ -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
|
|
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
|
-
`
|
|
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
|
package/dist/core/blossom.js
CHANGED
|
@@ -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
|
|
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
|
|
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);
|