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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
|
510
|
+
return fixedLengthProxyResponse(
|
|
429
511
|
Response.Status.lookup(response.code) ?: Response.Status.OK,
|
|
430
512
|
response.contentType ?: "application/json",
|
|
431
|
-
response.body
|
|
432
|
-
response.
|
|
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
|
|
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
|
}
|
package/dist/android/icon.js
CHANGED
|
@@ -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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|