mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
refactor(commons): move the napplet wire protocol and scheduled posts to commonMain
Tier 4 of the commons migration sweep: two jvmAndroid groups that were each pinned by a single JVM API. Napplet: NappletProtocolJson (and NappletIdentityWatch, NappletRequestRouter, which only waited on it) used java.util.Base64 and System.currentTimeMillis. Now kotlin.io.encoding.Base64 and TimeUtils.now(). The decoder uses PaddingOption.PRESENT_OPTIONAL because java.util.Base64.getDecoder() accepted unpadded input; a test pins that. Scheduled posts: ScheduledPostStore was pinned by java.io.File and a POSIX chmod. It now takes an okio Path + FileSystem (plus a String constructor, the FileSystemNip95BlobStore shape, so callers need no okio). Owner-only permissions are a new expect fun restrictFileToOwner: Files.setPosix... on jvmAndroid, chmod(0600) on iOS. The temp-file swap is FileSystem.atomicMove, which replaces an existing target everywhere, so the rename/delete/rename fallback is gone. The work gate, publisher and notifier came along. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjQQN9CgWHVtNCSWqnKoqY
This commit is contained in:
@@ -1154,7 +1154,7 @@ class AppModules(
|
||||
// Local store for posts the user has scheduled to publish later. Backed by a
|
||||
// single JSON file under the app's private filesDir; read by ScheduledPostWorker.
|
||||
val scheduledPostStore =
|
||||
ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME))
|
||||
ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME).path)
|
||||
|
||||
// Organizes cache clearing
|
||||
val trimmingService by
|
||||
|
||||
@@ -184,6 +184,13 @@ class NappletProtocolJsonTest {
|
||||
assertEquals("Hi", up.bytes.decodeToString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadAcceptsUnpaddedBase64() {
|
||||
// java.util.Base64 decoded "SGk" (no '=') as "Hi"; the commonMain decoder must too.
|
||||
val up = NappletProtocolJson.decodeRequest("""{"type":"upload.upload","request":{"dataBase64":"SGk"}}""") as NappletRequest.UploadBlob
|
||||
assertEquals("Hi", up.bytes.decodeToString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownTypeDecodesToNull() {
|
||||
assertNull(NappletProtocolJson.decodeRequest("""{"type":"inc.emit","id":"1"}"""))
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ class ScheduledPostWorkGateTest {
|
||||
decisions.clear()
|
||||
}
|
||||
|
||||
private fun newStore() = ScheduledPostStore(file)
|
||||
private fun newStore() = ScheduledPostStore(file.path)
|
||||
|
||||
private fun TestScope.startGate(store: ScheduledPostStore) =
|
||||
ScheduledPostWorkGate(
|
||||
|
||||
+12
-8
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.napplet.protocol
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -37,7 +38,7 @@ import kotlinx.serialization.json.long
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import java.util.Base64
|
||||
import kotlin.io.encoding.Base64
|
||||
|
||||
/**
|
||||
* Marshals the KMP-pure [NappletRequest] / [NappletResponse] types to and from the wire the
|
||||
@@ -45,10 +46,10 @@ import java.util.Base64
|
||||
* (`@napplet/shim` / `@napplet/nap`): requests are `{ "type": "<domain>.<action>", "id", ...fields }`
|
||||
* and replies are `{ "type": "<domain>.<action>.result", "id", ...fields }`.
|
||||
*
|
||||
* Lives in `commons/jvmAndroid` (not in any single front end) because the wire contract is identical
|
||||
* Lives in `commons/commonMain` (not in any single front end) because the wire contract is identical
|
||||
* across hosts: the Android `:napplet` WebView host and a future desktop host both marshal through
|
||||
* here. It depends only on `quartz` (Event/Filter), kotlinx.serialization, and `java.util.Base64`
|
||||
* (available on Android API 26+ and the JVM) — no platform-UI or process APIs.
|
||||
* here. It depends only on `quartz` (Event/Filter), kotlinx.serialization and `kotlin.io.encoding`
|
||||
* — no platform-UI or process APIs.
|
||||
*
|
||||
* This is the only place the boundary parses untrusted applet input, so it is deliberately
|
||||
* strict: an unrecognized `type` decodes to `null` (the broker denies it) and a malformed/short
|
||||
@@ -58,6 +59,9 @@ import java.util.Base64
|
||||
object NappletProtocolJson {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// Accepts unpadded input, as java.util.Base64's decoder did before this moved to commonMain.
|
||||
private val lenientBase64 = Base64.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
|
||||
|
||||
/** The `type` discriminant of a request envelope, used to build the matching `.result` type. */
|
||||
fun readType(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("type")
|
||||
|
||||
@@ -166,7 +170,7 @@ object NappletProtocolJson {
|
||||
kind = t.kindOf(),
|
||||
tags = decodeTags(t),
|
||||
content = t.str("content") ?: "",
|
||||
createdAt = t["created_at"]?.jsonPrimitive?.long ?: (System.currentTimeMillis() / 1000),
|
||||
createdAt = t["created_at"]?.jsonPrimitive?.long ?: TimeUtils.now(),
|
||||
)
|
||||
}
|
||||
// NIP-07 nip44.encrypt/decrypt: crypto only, no publish. `peer` is the counterparty
|
||||
@@ -194,7 +198,7 @@ object NappletProtocolJson {
|
||||
// The Blob in `request.data` is inlined as base64 `request.dataBase64` by shell.html.
|
||||
val request = o.getValue("request").jsonObject
|
||||
NappletRequest.UploadBlob(
|
||||
bytes = Base64.getDecoder().decode(request.req("dataBase64")),
|
||||
bytes = lenientBase64.decode(request.req("dataBase64")),
|
||||
contentType = request.str("mimeType") ?: "application/octet-stream",
|
||||
filename = request.str("filename"),
|
||||
)
|
||||
@@ -275,7 +279,7 @@ object NappletProtocolJson {
|
||||
}
|
||||
is NappletResponse.Bytes -> {
|
||||
put("ok", true)
|
||||
put("bytes", Base64.getEncoder().encodeToString(response.bytes))
|
||||
put("bytes", Base64.encode(response.bytes))
|
||||
put("mime", response.contentType)
|
||||
}
|
||||
is NappletResponse.ResourceInfo -> {
|
||||
@@ -301,7 +305,7 @@ object NappletProtocolJson {
|
||||
put("url", item.url)
|
||||
put("ok", item.resource != null)
|
||||
item.resource?.let {
|
||||
put("bytes", Base64.getEncoder().encodeToString(it.bytes))
|
||||
put("bytes", Base64.encode(it.bytes))
|
||||
put("mime", it.contentType)
|
||||
}
|
||||
item.error?.let { put("error", it) }
|
||||
+35
-43
@@ -20,21 +20,31 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.scheduledposts
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.util.platformFileSystem
|
||||
import com.vitorpamplona.amethyst.commons.util.restrictFileToOwner
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toPath
|
||||
|
||||
class ScheduledPostStore(
|
||||
private val storageFile: File,
|
||||
private val nowSec: () -> Long = { System.currentTimeMillis() / 1000 },
|
||||
private val storageFile: Path,
|
||||
private val nowSec: () -> Long = { TimeUtils.now() },
|
||||
private val fileSystem: FileSystem = platformFileSystem,
|
||||
) {
|
||||
/** Takes the platform's own path string, so callers need no okio of their own. */
|
||||
constructor(
|
||||
storagePath: String,
|
||||
nowSec: () -> Long = { TimeUtils.now() },
|
||||
) : this(storagePath.toPath(), nowSec)
|
||||
|
||||
/**
|
||||
* `encodeDefaults = true` so this writes the same bytes Jackson did — kotlinx
|
||||
* omits a value equal to its default, which would silently drop `"version":1`
|
||||
@@ -214,7 +224,7 @@ class ScheduledPostStore(
|
||||
*/
|
||||
suspend fun publishNow(
|
||||
id: String,
|
||||
nowSec: Long = System.currentTimeMillis() / 1000,
|
||||
nowSec: Long = TimeUtils.now(),
|
||||
): Boolean =
|
||||
mutex.withLock {
|
||||
ensureLoaded()
|
||||
@@ -336,8 +346,8 @@ class ScheduledPostStore(
|
||||
private fun reloadFromDiskLocked() {
|
||||
val fromDisk =
|
||||
try {
|
||||
if (storageFile.exists() && storageFile.length() > 0) {
|
||||
json.decodeFromString<ScheduledPostFile>(storageFile.readText()).posts.toMutableList()
|
||||
if (hasContent()) {
|
||||
json.decodeFromString<ScheduledPostFile>(readText()).posts.toMutableList()
|
||||
} else {
|
||||
// File vanished — treat as no external state; keep current in-memory.
|
||||
return
|
||||
@@ -354,8 +364,8 @@ class ScheduledPostStore(
|
||||
if (loaded) return
|
||||
posts =
|
||||
try {
|
||||
if (storageFile.exists() && storageFile.length() > 0) {
|
||||
json.decodeFromString<ScheduledPostFile>(storageFile.readText()).posts.toMutableList()
|
||||
if (hasContent()) {
|
||||
json.decodeFromString<ScheduledPostFile>(readText()).posts.toMutableList()
|
||||
} else {
|
||||
mutableListOf()
|
||||
}
|
||||
@@ -367,7 +377,7 @@ class ScheduledPostStore(
|
||||
// Secure a pre-existing file up front (an older build may have left it at the
|
||||
// 0644 umask default) so it's owner-only even if nothing mutates the store
|
||||
// this session.
|
||||
if (storageFile.exists()) restrictToOwner(storageFile)
|
||||
if (fileSystem.exists(storageFile)) restrictFileToOwner(storageFile, TAG)
|
||||
var dirty = purgeStale(nowSec())
|
||||
// Recover claims stranded by a crash-mid-publish so they aren't lost forever.
|
||||
if (recoverStuckClaimsLocked(nowSec())) dirty = true
|
||||
@@ -412,48 +422,30 @@ class ScheduledPostStore(
|
||||
private fun persist() {
|
||||
val snapshot = posts.toList()
|
||||
_flow.value = snapshot
|
||||
storageFile.parentFile?.mkdirs()
|
||||
val tmp = File(storageFile.parentFile, storageFile.name + ".tmp")
|
||||
val tmp = "$storageFile.tmp".toPath()
|
||||
try {
|
||||
tmp.writeText(json.encodeToString(ScheduledPostFile(version = 1, posts = snapshot)))
|
||||
storageFile.parent?.let { fileSystem.createDirectories(it) }
|
||||
fileSystem.write(tmp) { writeUtf8(json.encodeToString(ScheduledPostFile(version = 1, posts = snapshot))) }
|
||||
// Restrict to owner-only BEFORE the rename so the store is never briefly
|
||||
// world-readable. It holds pre-signed events + the account's pubkey, which
|
||||
// must not leak to other local users on a shared machine.
|
||||
restrictToOwner(tmp)
|
||||
if (!tmp.renameTo(storageFile)) {
|
||||
if (!storageFile.delete()) {
|
||||
Log.w(TAG) { "Failed to delete existing $storageFile before rename retry" }
|
||||
}
|
||||
if (!tmp.renameTo(storageFile)) {
|
||||
Log.e(TAG) { "Failed to rename $tmp to $storageFile" }
|
||||
if (!tmp.delete()) {
|
||||
Log.w(TAG) { "Failed to clean up temp file $tmp after rename failure" }
|
||||
}
|
||||
}
|
||||
}
|
||||
restrictFileToOwner(tmp, TAG)
|
||||
// Replaces an existing target, including on Windows, where a plain rename
|
||||
// onto an existing file fails.
|
||||
fileSystem.atomicMove(tmp, storageFile)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to persist scheduled posts to $storageFile", e)
|
||||
if (!tmp.delete()) {
|
||||
Log.w(TAG) { "Failed to clean up temp file $tmp after persist exception" }
|
||||
try {
|
||||
fileSystem.delete(tmp)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG) { "Failed to clean up temp file $tmp after persist exception: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort chmod to `0600` (owner read/write only). No-op on filesystems
|
||||
* without POSIX permissions (e.g. Windows), where confidentiality relies on the
|
||||
* per-user home directory instead. Never throws.
|
||||
*/
|
||||
private fun restrictToOwner(file: File) {
|
||||
try {
|
||||
Files.setPosixFilePermissions(
|
||||
file.toPath(),
|
||||
setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG) { "Could not restrict permissions on $file: ${e.message}" }
|
||||
}
|
||||
}
|
||||
private fun hasContent(): Boolean = (fileSystem.metadataOrNull(storageFile)?.size ?: 0L) > 0L
|
||||
|
||||
private fun readText(): String = fileSystem.read(storageFile) { readUtf8() }
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ScheduledPostStore"
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.util
|
||||
|
||||
import okio.Path
|
||||
|
||||
/**
|
||||
* Restricts the file at [path] to owner read/write (`0600`), best-effort. Never throws.
|
||||
*
|
||||
* Silent where the filesystem has no POSIX permissions (Windows), where the user profile's
|
||||
* ACLs apply instead; logs under [tag] when a POSIX filesystem refuses.
|
||||
*/
|
||||
expect fun restrictFileToOwner(
|
||||
path: Path,
|
||||
tag: String,
|
||||
)
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.util
|
||||
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.convert
|
||||
import okio.Path
|
||||
import platform.posix.S_IRUSR
|
||||
import platform.posix.S_IWUSR
|
||||
import platform.posix.chmod
|
||||
import platform.posix.errno
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual fun restrictFileToOwner(
|
||||
path: Path,
|
||||
tag: String,
|
||||
) {
|
||||
if (chmod(path.toString(), (S_IRUSR or S_IWUSR).convert()) != 0) {
|
||||
Log.w(tag) { "Could not restrict permissions on $path: errno $errno" }
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.util
|
||||
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import okio.Path
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
|
||||
actual fun restrictFileToOwner(
|
||||
path: Path,
|
||||
tag: String,
|
||||
) {
|
||||
try {
|
||||
Files.setPosixFilePermissions(
|
||||
path.toNioPath(),
|
||||
setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE),
|
||||
)
|
||||
} catch (_: UnsupportedOperationException) {
|
||||
// Windows: no POSIX permissions; the user profile's NTFS ACLs apply instead.
|
||||
} catch (e: Exception) {
|
||||
Log.w(tag) { "Could not restrict permissions on $path: ${e.message}" }
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -47,7 +47,7 @@ class ScheduledPostStoreTest {
|
||||
file = File(temp.root, "scheduled_posts.json")
|
||||
}
|
||||
|
||||
private fun newStore(now: () -> Long = { System.currentTimeMillis() / 1000 }) = ScheduledPostStore(file, now)
|
||||
private fun newStore(now: () -> Long = { System.currentTimeMillis() / 1000 }) = ScheduledPostStore(file.path, now)
|
||||
|
||||
private fun samplePost(
|
||||
id: String = "id-1",
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ object DesktopScheduledPostStore {
|
||||
if (file.exists()) {
|
||||
setOwnerOnly(file, PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)
|
||||
}
|
||||
return ScheduledPostStore(file)
|
||||
return ScheduledPostStore(file.path)
|
||||
}
|
||||
|
||||
private fun setDirPermissions(dir: File) =
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ fun runHeadlessPublish(): Int {
|
||||
}
|
||||
|
||||
private fun headlessDrain(log: (String) -> Unit): Int {
|
||||
val store = ScheduledPostStore(File(scheduledDir(), ScheduledPostStore.FILE_NAME))
|
||||
val store = ScheduledPostStore(File(scheduledDir(), ScheduledPostStore.FILE_NAME).path)
|
||||
|
||||
// Build a plain OkHttp client for websockets — no Tor, no proxy. Headless mode
|
||||
// reads no settings, so we cannot know Tor preferences; scheduled posts that
|
||||
|
||||
Reference in New Issue
Block a user