pakstr 0.25.1 → 0.25.2

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.
@@ -9,6 +9,7 @@ import fi.iki.elonen.NanoHTTPD
9
9
  import java.io.BufferedInputStream
10
10
  import java.io.ByteArrayOutputStream
11
11
  import java.io.IOException
12
+ import java.io.PrintWriter
12
13
  import java.net.HttpURLConnection
13
14
  import java.net.InetSocketAddress
14
15
  import java.net.Socket
@@ -88,9 +89,85 @@ internal fun proxyApiUrl(
88
89
  internal data class ProxyHttpResponse(
89
90
  val code: Int,
90
91
  val contentType: String?,
91
- val body: ByteArray
92
+ val body: ByteArray,
93
+ val setCookies: List<String> = emptyList()
92
94
  )
93
95
 
96
+ internal fun proxySetCookieHeaders(
97
+ headers: Map<String?, List<String>>
98
+ ): List<String> = headers.entries
99
+ .filter { (name) -> name.equals("set-cookie", ignoreCase = true) }
100
+ .flatMap { (_, values) -> values }
101
+ .filter(::isValidProxyResponseHeaderValue)
102
+
103
+ private fun isValidProxyResponseHeaderValue(value: String): Boolean =
104
+ '\r' !in value && '\n' !in value
105
+
106
+ private class SetCookieResponse(
107
+ status: NanoHTTPD.Response.IStatus,
108
+ mimeType: String,
109
+ data: java.io.InputStream,
110
+ totalBytes: Long,
111
+ private val setCookies: List<String>
112
+ ) : NanoHTTPD.Response(status, mimeType, data, totalBytes) {
113
+ init {
114
+ addHeader("Set-Cookie", setCookies.first())
115
+ }
116
+
117
+ override fun printHeader(writer: PrintWriter, key: String, value: String) {
118
+ if (key.equals("set-cookie", ignoreCase = true)) {
119
+ setCookies.forEach { writer.append("Set-Cookie: ").append(it).append("\r\n") }
120
+ } else {
121
+ super.printHeader(writer, key, value)
122
+ }
123
+ }
124
+ }
125
+
126
+ internal fun fixedLengthProxyResponse(
127
+ status: NanoHTTPD.Response.IStatus,
128
+ mimeType: String,
129
+ body: ByteArray,
130
+ setCookies: List<String>
131
+ ): NanoHTTPD.Response {
132
+ val validSetCookies = setCookies.filter(::isValidProxyResponseHeaderValue)
133
+ if (validSetCookies.isEmpty()) {
134
+ return NanoHTTPD.newFixedLengthResponse(
135
+ status,
136
+ mimeType,
137
+ body.inputStream(),
138
+ body.size.toLong()
139
+ )
140
+ }
141
+ return SetCookieResponse(
142
+ status,
143
+ mimeType,
144
+ body.inputStream(),
145
+ body.size.toLong(),
146
+ validSetCookies
147
+ )
148
+ }
149
+
150
+ internal fun fixedLengthProxyResponse(
151
+ status: NanoHTTPD.Response.IStatus,
152
+ mimeType: String,
153
+ body: String,
154
+ setCookies: List<String>
155
+ ): NanoHTTPD.Response {
156
+ val validSetCookies = setCookies.filter(::isValidProxyResponseHeaderValue)
157
+ if (validSetCookies.isEmpty()) {
158
+ return NanoHTTPD.newFixedLengthResponse(status, mimeType, body)
159
+ }
160
+ val encoded = NanoHTTPD.newFixedLengthResponse(status, mimeType, body)
161
+ val bytes = encoded.data.use { it.readBytes() }
162
+ return SetCookieResponse(
163
+ status,
164
+ encoded.mimeType,
165
+ bytes.inputStream(),
166
+ bytes.size.toLong(),
167
+ validSetCookies
168
+ )
169
+ }
170
+
94
171
  internal fun bodylessProxyRequestBytes(
95
172
  method: String,
96
173
  url: URL,
@@ -173,19 +250,24 @@ internal fun executeBodylessProxyRequest(
173
250
  }
174
251
  }
175
252
 
176
- private fun readProxyHttpResponse(input: BufferedInputStream): ProxyHttpResponse {
253
+ internal fun readProxyHttpResponse(input: BufferedInputStream): ProxyHttpResponse {
177
254
  val statusLine = readHttpLine(input)
178
255
  val code = statusLine.split(' ', limit = 3).getOrNull(1)?.toIntOrNull()
179
256
  ?: throw IOException("Invalid HTTP status line")
180
257
  val headers = mutableMapOf<String, String>()
258
+ val setCookies = mutableListOf<String>()
181
259
 
182
260
  while (true) {
183
261
  val line = readHttpLine(input)
184
262
  if (line.isEmpty()) break
185
263
  val separator = line.indexOf(':')
186
264
  if (separator <= 0) throw IOException("Invalid HTTP response header")
187
- headers[line.substring(0, separator).lowercase()] =
188
- line.substring(separator + 1).trim()
265
+ val name = line.substring(0, separator).lowercase()
266
+ val value = line.substring(separator + 1).trim()
267
+ headers[name] = value
268
+ if (name == "set-cookie" && isValidProxyResponseHeaderValue(value)) {
269
+ setCookies += value
270
+ }
189
271
  }
190
272
 
191
273
  val body = when {
@@ -198,7 +280,7 @@ private fun readProxyHttpResponse(input: BufferedInputStream): ProxyHttpResponse
198
280
  else -> input.readBytes()
199
281
  }
200
282
 
201
- return ProxyHttpResponse(code, headers["content-type"], body)
283
+ return ProxyHttpResponse(code, headers["content-type"], body, setCookies)
202
284
  }
203
285
 
204
286
  private fun readHttpLine(input: BufferedInputStream): String {
@@ -425,11 +507,11 @@ class LocalServer(
425
507
  "Proxy response: ${response.code}"
426
508
  )
427
509
  }
428
- return newFixedLengthResponse(
510
+ return fixedLengthProxyResponse(
429
511
  Response.Status.lookup(response.code) ?: Response.Status.OK,
430
512
  response.contentType ?: "application/json",
431
- response.body.inputStream(),
432
- response.body.size.toLong()
513
+ response.body,
514
+ response.setCookies
433
515
  )
434
516
  }
435
517
 
@@ -496,18 +578,21 @@ class LocalServer(
496
578
  } ?: ""
497
579
 
498
580
  val responseContentType = connection.contentType ?: "application/json"
581
+ val setCookies = proxySetCookieHeaders(connection.headerFields)
499
582
 
500
583
 
501
584
  connection.disconnect()
502
585
 
503
586
 
504
- return newFixedLengthResponse(
587
+ return fixedLengthProxyResponse(
505
588
 
506
589
  Response.Status.lookup(code) ?: Response.Status.OK,
507
590
 
508
591
  responseContentType,
509
592
 
510
- body
593
+ body,
594
+
595
+ setCookies
511
596
 
512
597
  )
513
598
 
@@ -6,7 +6,13 @@ import org.junit.Assert.assertNull
6
6
  import org.junit.Assert.assertThrows
7
7
  import org.junit.Assert.assertTrue
8
8
  import org.junit.Test
9
+ import fi.iki.elonen.NanoHTTPD
10
+ import java.io.BufferedInputStream
11
+ import java.io.ByteArrayInputStream
12
+ import java.io.ByteArrayOutputStream
13
+ import java.net.Socket
9
14
  import java.net.URL
15
+ import java.nio.charset.StandardCharsets
10
16
 
11
17
  class ProxyRequestPolicyTest {
12
18
 
@@ -94,4 +100,131 @@ class ProxyRequestPolicyTest {
94
100
  )
95
101
  }
96
102
  }
103
+
104
+ @Test
105
+ fun rawResponsePreservesMultipleSetCookieHeaders() {
106
+ val body = "{\"ok\":true}"
107
+ val rawResponse = (
108
+ "HTTP/1.1 200 OK\r\n" +
109
+ "Content-Type: application/json\r\n" +
110
+ "Set-Cookie: session=one; Path=/; HttpOnly\r\n" +
111
+ "set-cookie: refresh=two; Expires=Wed, 21 Oct 2026 07:28:00 GMT\r\n" +
112
+ "Content-Length: ${body.toByteArray().size}\r\n\r\n" +
113
+ body
114
+ ).toByteArray(StandardCharsets.ISO_8859_1)
115
+
116
+ val response = readProxyHttpResponse(
117
+ BufferedInputStream(ByteArrayInputStream(rawResponse))
118
+ )
119
+
120
+ assertEquals(200, response.code)
121
+ assertEquals("application/json", response.contentType)
122
+ assertEquals(body, response.body.toString(StandardCharsets.ISO_8859_1))
123
+ assertEquals(
124
+ listOf(
125
+ "session=one; Path=/; HttpOnly",
126
+ "refresh=two; Expires=Wed, 21 Oct 2026 07:28:00 GMT"
127
+ ),
128
+ response.setCookies
129
+ )
130
+ }
131
+
132
+ @Test
133
+ fun extractsSetCookieHeadersCaseInsensitively() {
134
+ val cookies = proxySetCookieHeaders(
135
+ linkedMapOf(
136
+ null to listOf("HTTP/1.1 200 OK"),
137
+ "Content-Type" to listOf("application/json"),
138
+ "sEt-CoOkIe" to listOf(
139
+ "session=one; Path=/",
140
+ "refresh=two; SameSite=Lax"
141
+ )
142
+ )
143
+ )
144
+
145
+ assertEquals(
146
+ listOf("session=one; Path=/", "refresh=two; SameSite=Lax"),
147
+ cookies
148
+ )
149
+ }
150
+
151
+ @Test
152
+ fun serializesSingleSetCookieHeader() {
153
+ assertEquals(
154
+ listOf("session=one; Path=/; HttpOnly"),
155
+ serializedSetCookies(listOf("session=one; Path=/; HttpOnly"))
156
+ )
157
+ }
158
+
159
+ @Test
160
+ fun serializesMultipleSetCookieHeadersSeparately() {
161
+ assertEquals(
162
+ listOf(
163
+ "session=one; Path=/; HttpOnly",
164
+ "refresh=two; Expires=Wed, 21 Oct 2026 07:28:00 GMT"
165
+ ),
166
+ serializedSetCookies(
167
+ listOf(
168
+ "session=one; Path=/; HttpOnly",
169
+ "refresh=two; Expires=Wed, 21 Oct 2026 07:28:00 GMT"
170
+ )
171
+ )
172
+ )
173
+ }
174
+
175
+ private fun serializedSetCookies(setCookies: List<String>): List<String> {
176
+ val server = object : NanoHTTPD(0) {
177
+ override fun serve(session: IHTTPSession): Response = fixedLengthProxyResponse(
178
+ Response.Status.OK,
179
+ "application/json",
180
+ "{\"ok\":true}",
181
+ setCookies
182
+ )
183
+ }
184
+
185
+ server.start()
186
+ try {
187
+ Socket("127.0.0.1", server.listeningPort).use { socket ->
188
+ socket.soTimeout = 5000
189
+ socket.getOutputStream().apply {
190
+ write(
191
+ "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"
192
+ .toByteArray(StandardCharsets.ISO_8859_1)
193
+ )
194
+ flush()
195
+ }
196
+
197
+ return readResponseHeaders(socket)
198
+ .filter { it.startsWith("Set-Cookie: ") }
199
+ .map { it.substringAfter(": ") }
200
+ }
201
+ } finally {
202
+ server.stop()
203
+ }
204
+ }
205
+
206
+ private fun readResponseHeaders(socket: Socket): List<String> {
207
+ val input = BufferedInputStream(socket.getInputStream())
208
+ assertTrue(readLine(input).startsWith("HTTP/1.1 200"))
209
+ return buildList {
210
+ while (true) {
211
+ val line = readLine(input)
212
+ if (line.isEmpty()) break
213
+ add(line)
214
+ }
215
+ }
216
+ }
217
+
218
+ private fun readLine(input: BufferedInputStream): String {
219
+ val bytes = ByteArrayOutputStream()
220
+ while (true) {
221
+ val value = input.read()
222
+ assertTrue(value != -1)
223
+ if (value == '\r'.code) {
224
+ assertEquals('\n'.code, input.read())
225
+ return bytes.toString(StandardCharsets.ISO_8859_1.name())
226
+ }
227
+ bytes.write(value)
228
+ }
229
+ }
97
230
  }
@@ -65,33 +65,25 @@ async function patchIcon(androidRoot, iconPath, backgroundColor = "#FFFFFF", man
65
65
  recursive: true,
66
66
  });
67
67
  const foregroundPath = path_1.default.join(adaptiveDrawablePath, "ic_launcher_foreground.webp");
68
- const { isOpaque } = await (0, sharp_1.default)(absoluteIconPath).stats();
69
- if (isOpaque) {
70
- await (0, sharp_1.default)(absoluteIconPath)
71
- .resize(432, 432, {
72
- fit: "contain",
73
- background: transparent,
74
- })
75
- .webp()
76
- .toFile(foregroundPath);
77
- }
78
- else {
79
- await (0, sharp_1.default)(absoluteIconPath)
80
- .trim({ background: transparent, threshold: 0 })
81
- .resize(264, 264, {
82
- fit: "contain",
83
- background: transparent,
84
- })
85
- .extend({
86
- top: 84,
87
- bottom: 84,
88
- left: 84,
89
- right: 84,
90
- background: transparent,
91
- })
92
- .webp()
93
- .toFile(foregroundPath);
94
- }
68
+ const adaptiveCanvasSize = 432;
69
+ // Fit square artwork inside Android's 264px guaranteed-safe circle.
70
+ const safeArtworkSize = 186;
71
+ const adaptivePadding = (adaptiveCanvasSize - safeArtworkSize) / 2;
72
+ await (0, sharp_1.default)(absoluteIconPath)
73
+ .trim({ background: transparent, threshold: 0 })
74
+ .resize(safeArtworkSize, safeArtworkSize, {
75
+ fit: "contain",
76
+ background: transparent,
77
+ })
78
+ .extend({
79
+ top: adaptivePadding,
80
+ bottom: adaptivePadding,
81
+ left: adaptivePadding,
82
+ right: adaptivePadding,
83
+ background: transparent,
84
+ })
85
+ .webp()
86
+ .toFile(foregroundPath);
95
87
  console.log("🖼️ Generated adaptive foreground");
96
88
  //
97
89
  // 3. REMOVE OLD UNQUALIFIED FOREGROUNDS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pakstr",
3
- "version": "0.25.1",
3
+ "version": "0.25.2",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",