Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,6 @@ dependencies {
implementation(libs.kmp.tor.runtime.service.ui)
implementation(libs.kmp.tor.resource.exec)
implementation(libs.negentropy)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
}
20 changes: 19 additions & 1 deletion app/src/main/java/com/greenart7c3/citrine/Citrine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import android.app.PendingIntent
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.greenart7c3.citrine.database.AppDatabase
import com.greenart7c3.citrine.logs.Log
import com.greenart7c3.citrine.okhttp.HttpClientManager
Expand All @@ -31,8 +35,22 @@ import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

class Citrine : Application() {
class Citrine :
Application(),
SingletonImageLoader.Factory {
val crashReportCache: CrashReportCache by lazy { CrashReportCache(this.applicationContext) }

// Coil image loader whose network fetches route through the app's OkHttp client so nsite
// icon downloads honor the relay's Tor/proxy fail-closed policy instead of going direct.
override fun newImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Builder(context)
.components {
add(
OkHttpNetworkFetcherFactory(
callFactory = { HttpClientManager.getHttpClient(Settings.proxyAllUrls) },
),
)
}
.build()
val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
Log.e("AmberCoroutine", "Caught exception: ${throwable.message}", throwable)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import io.ktor.utils.io.copyTo
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketDeflateExtension
import io.ktor.websocket.readText
import java.io.File
import java.net.ServerSocket
import java.util.concurrent.ConcurrentHashMap
import java.util.zip.Deflater
Expand Down Expand Up @@ -910,6 +911,94 @@ class CustomWebSocketServer(
)
}

/**
* Serves a file from an app-internal [rootDir] (used by installed nsites). Mirrors
* [serveIndex] but reads from a plain [File] tree instead of a SAF DocumentFile. Paths
* are canonicalized and rejected if they escape [rootDir] (path-traversal guard). Falls
* back to index.html for extension-less routes and to 404.html (with a 404 status) when
* nothing matches, per NIP-5A.
*/
private suspend fun serveFromDir(
call: ApplicationCall,
rootDir: File,
) {
val requestedPath = call.request.uri.substringBefore('?').trimStart('/')
val rootCanonical = rootDir.canonicalFile

fun resolveSafe(path: String): File? {
if (path.isEmpty()) return null
val candidate = File(rootCanonical, path).canonicalFile
return if (candidate.path == rootCanonical.path || candidate.path.startsWith(rootCanonical.path + File.separator)) {
candidate
} else {
null
}
}

var target = resolveSafe(requestedPath)?.takeIf { it.isFile }
// SPA/index fallback for the root or extension-less routes.
if (target == null && (requestedPath.isEmpty() || !requestedPath.substringAfterLast('/').contains('.'))) {
target = resolveSafe("index.html")?.takeIf { it.isFile }
}
// 404 fallback.
if (target == null) {
target = resolveSafe("404.html")?.takeIf { it.isFile }
}

if (target == null) {
return call.respond(HttpStatusCode.NotFound, null)
}

val status = if (target.name == "404.html") HttpStatusCode.NotFound else HttpStatusCode.OK
call.respondOutputStream(
contentType = ContentType.defaultForFilePath(target.name),
status = status,
) {
target.inputStream().use { it.copyTo(this) }
}
}

fun startNsiteServerFor(
clientName: String,
rootDir: File,
): WebClientServer {
val port = randomFreePort()

val server = embeddedServer(
CIO,
host = "127.0.0.1",
port = port,
) {
routing {
get("{...}") {
serveFromDir(call, rootDir)
}
}
}.start(wait = false)

return WebClientServer(
name = clientName,
rootUri = rootDir.toURI().toString().toUri(),
port = port,
server = server,
)
}

/** Hot-adds (or replaces) a running server for an installed nsite without restarting the relay. */
fun startNsiteServer(nsite: NsiteInfo) {
val dir = File(Citrine.instance.filesDir, "nsites/${nsite.folderName}")
if (!dir.isDirectory) return
val key = "/${nsite.folderName}"
webClientServers.remove(key)?.server?.stop()
webClientServers[key] = startNsiteServerFor(nsite.folderName, dir)
Log.d(Citrine.TAG, "Started nsite '${nsite.folderName}'")
}

/** Stops and removes a running nsite server (used on delete). */
fun stopNsiteServer(folderName: String) {
webClientServers.remove("/$folderName")?.server?.stop()
}

val proxyClient = HttpClient(io.ktor.client.engine.cio.CIO) {
expectSuccess = false
}
Expand Down Expand Up @@ -992,6 +1081,20 @@ class CustomWebSocketServer(
"Started web client '$name' on port ${clientServer.port}",
)
}

Settings.nsites.forEach { nsite ->
val dir = File(Citrine.instance.filesDir, "nsites/${nsite.folderName}")
if (dir.isDirectory) {
val key = "/${nsite.folderName}"
val clientServer = startNsiteServerFor(nsite.folderName, dir)
webClientServers[key] = clientServer

Log.d(
Citrine.TAG,
"Started nsite '${nsite.folderName}' on port ${clientServer.port}",
)
}
}
}

@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
Expand Down
49 changes: 49 additions & 0 deletions app/src/main/java/com/greenart7c3/citrine/server/Settings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ object Settings {
"wss://directory.yabu.me/",
)

// Suggested relays for discovering nsites (NIP-5A). Used by the "reset to default" action
// in the nsite relay editor; an empty user list falls back to the aggregator relays.
val DEFAULT_NSITE_RELAYS = setOf(
"wss://nsite.run/",
"wss://nos.lol/",
"wss://nostr.land/",
)

var allowedKinds: Set<Int> = emptySet()
var allowedPubKeys: Set<String> = emptySet()
var allowedTaggedPubKeys: Set<String> = emptySet()
Expand Down Expand Up @@ -47,6 +55,18 @@ object Settings {
var onionHostname = ""
var webClients = mutableMapOf<String, String>()

// Installed nsites (NIP-5A static websites). Downloaded to filesDir/nsites/<folderName>
// and served through the same localhost web-client mechanism as [webClients].
var nsites: MutableList<NsiteInfo> = mutableListOf()

// Epoch seconds of the last daily nsite update check. Gated like [lastBackup] so the
// 100s service timer only runs the check roughly once per day.
var lastNsiteCheck: Long = 0L

// Relays queried to discover nsites. nsites use only this set (never the aggregator
// relays); an empty list falls back to [DEFAULT_NSITE_RELAYS].
var nsiteRelays: Set<String> = DEFAULT_NSITE_RELAYS

var relayAggregatorEnabled = false
var aggregatorPubkey = ""
var relayAggregatorKinds: Set<Int> = setOf(0, 1, 3, 5, 6, 7, 1111, 10000, 10002, 30023)
Expand Down Expand Up @@ -115,6 +135,9 @@ object Settings {
useTor = false
onionHostname = ""
webClients = mutableMapOf()
nsites = mutableListOf()
lastNsiteCheck = 0L
nsiteRelays = DEFAULT_NSITE_RELAYS
relayAggregatorEnabled = false
aggregatorPubkey = ""
relayAggregatorKinds = setOf(0, 1, 3, 5, 6, 7, 1111, 10000, 10002, 30023)
Expand All @@ -133,8 +156,34 @@ object Settings {
fun webClientFromJson(json: String): MutableMap<String, String> = JacksonMapper.mapper.readValue<MutableMap<String, String>>(json)

fun webClientsToJson(): String = JacksonMapper.mapper.writeValueAsString(webClients)

fun nsitesFromJson(json: String): MutableList<NsiteInfo> = JacksonMapper.mapper.readValue<MutableList<NsiteInfo>>(json)

fun nsitesToJson(): String = JacksonMapper.mapper.writeValueAsString(nsites)
}

/**
* An installed nsite (NIP-5A static website).
*
* [address] is the canonical identifier: "15128:<pubkey>" for a root site or
* "35128:<pubkey>:<dTag>" for a named site. [folderName] is a filesystem-safe slug
* (prefixed with "nsite_") used both as the on-disk directory under filesDir/nsites and
* as the "<folderName>.localhost" subdomain the relay serves it from. [aggregateHash] is
* the last-applied ["x", <hash>, "aggregate"] value, compared against a freshly fetched
* manifest to detect updates.
*/
data class NsiteInfo(
val address: String = "",
val pubkey: String = "",
val kind: Int = 0,
val dTag: String = "",
val displayName: String = "",
val folderName: String = "",
val aggregateHash: String = "",
val autoUpdate: Boolean = false,
val lastChecked: Long = 0L,
)

enum class OlderThan {
DAY,
WEEK,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ class ClipboardReceiver : BroadcastReceiver() {
Citrine.instance.applicationScope.launch {
Citrine.instance.client.disconnect()
}
} else if (intent != null && intent.hasExtra("nsite_update")) {
val address = intent.getStringExtra("nsite_update") ?: return
NotificationManagerCompat.from(context).cancel(NSITE_UPDATE_NOTIFICATION_ID)
Citrine.instance.applicationScope.launch {
NsiteManager.applyUpdateByAddress(address)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ object PrefKeys {
const val ONION_HOSTNAME = "onion_hostname"

const val WEB_CLIENTS = "web_clients"
const val NSITES = "nsites"
const val LAST_NSITE_CHECK = "last_nsite_check"
const val NSITE_RELAYS = "nsite_relays"

const val RELAY_AGGREGATOR_ENABLED = "relay_aggregator_enabled"
const val AGGREGATOR_PUBKEY = "aggregator_pubkey"
Expand Down Expand Up @@ -97,6 +100,13 @@ object LocalPreferences {
} else {
remove(PrefKeys.WEB_CLIENTS)
}
if (settings.nsites.isNotEmpty()) {
putString(PrefKeys.NSITES, Settings.nsitesToJson())
} else {
remove(PrefKeys.NSITES)
}
putLong(PrefKeys.LAST_NSITE_CHECK, settings.lastNsiteCheck)
putStringSet(PrefKeys.NSITE_RELAYS, settings.nsiteRelays)

putBoolean(PrefKeys.RELAY_AGGREGATOR_ENABLED, settings.relayAggregatorEnabled)
putString(PrefKeys.AGGREGATOR_PUBKEY, settings.aggregatorPubkey)
Expand Down Expand Up @@ -155,6 +165,11 @@ object LocalPreferences {
prefs.getString(PrefKeys.WEB_CLIENTS, null)?.let {
Settings.webClients = Settings.webClientFromJson(it)
}
prefs.getString(PrefKeys.NSITES, null)?.let {
Settings.nsites = Settings.nsitesFromJson(it)
}
Settings.lastNsiteCheck = prefs.getLong(PrefKeys.LAST_NSITE_CHECK, 0L)
Settings.nsiteRelays = prefs.getStringSet(PrefKeys.NSITE_RELAYS, null) ?: Settings.DEFAULT_NSITE_RELAYS

Settings.relayAggregatorEnabled = prefs.getBoolean(PrefKeys.RELAY_AGGREGATOR_ENABLED, false)
Settings.aggregatorPubkey = prefs.getString(PrefKeys.AGGREGATOR_PUBKEY, "") ?: ""
Expand Down
Loading
Loading