diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 20f76712c..642d980d4 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -36,6 +36,9 @@
+
+
+
@@ -257,5 +260,11 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/All_in_briar_headless.xml b/.idea/runConfigurations/All_in_briar_headless.xml
new file mode 100644
index 000000000..3d404ade6
--- /dev/null
+++ b/.idea/runConfigurations/All_in_briar_headless.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/All_tests.xml b/.idea/runConfigurations/All_tests.xml
index a90c8b90e..a1de55c01 100644
--- a/.idea/runConfigurations/All_tests.xml
+++ b/.idea/runConfigurations/All_tests.xml
@@ -24,6 +24,7 @@
+
-
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/briar_headless.xml b/.idea/runConfigurations/briar_headless.xml
new file mode 100644
index 000000000..3521f671d
--- /dev/null
+++ b/.idea/runConfigurations/briar_headless.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/briar-headless/README.md b/briar-headless/README.md
new file mode 100644
index 000000000..a27d9dc3d
--- /dev/null
+++ b/briar-headless/README.md
@@ -0,0 +1,197 @@
+# Briar REST API
+
+This is a headless Briar peer that exposes a REST API
+with an integrated HTTP server instead of a traditional user interface.
+You can use this API to script the peer behavior
+or to develop your own user interface for it.
+
+## How to use
+
+The REST API peer comes as a `jar` file
+and needs a Java Runtime Environment (JRE) that supports at least Java 8.
+It currently works only on GNU/Linux operating systems.
+
+You can start the peer (and its API server) like this:
+
+ $ java -jar briar-headless/build/libs/briar-headless.jar
+
+It is possible to put parameters at the end.
+Try `--help` for a list of options.
+
+On the first start, it will ask you to create a user account:
+
+ $ java -jar briar-headless.jar
+ No account found. Let's create one!
+
+ Nickname: testuser
+ Password:
+
+After entering a password, it will start up without further output.
+Use the `-v` option if you prefer more verbose logging.
+
+By default, Briar creates a folder `~/.briar` where it stores its database and other files.
+There you also find the authentication token which is required to interact with the API:
+
+ $ cat ~/.briar/auth_token
+ DZbfoUie8sjap7CSDR9y6cgJCojV+xUITTIFbgtAgqk=
+
+You can test that things work as expected by running:
+
+ $ curl -H "Authorization: Bearer DZbfoUie8sjap7CSDR9y6cgJCojV+xUITTIFbgtAgqk=" http://127.0.0.1:7000/v1/contacts
+ []
+
+The answer is an empty JSON array, because you don't have any contacts.
+Note that the HTTP request sets an `Authorization` header with the bearer token.
+A missing or wrong token will result in a `401` response.
+
+## REST API
+
+### Listing all contacts
+
+`GET /v1/contacts`
+
+Returns a JSON array of contacts:
+
+```json
+{
+ "author": {
+ "formatVersion": 1,
+ "id": "y1wkIzAimAbYoCGgWxkWlr6vnq1F8t1QRA/UMPgI0E0=",
+ "name": "Test",
+ "publicKey": "BDu6h1S02bF4W6rgoZfZ6BMjTj/9S9hNN7EQoV05qUo="
+ },
+ "contactId": 1,
+ "verified": true
+}
+```
+
+### Adding a contact
+
+*Not yet implemented*
+
+The only workaround is to add a contact to the Briar app running on a rooted Android phone
+and then move its database (and key files) to the headless peer.
+
+### Listing all private messages
+
+`GET /messages/{contactId}`
+
+The `{contactId}` is the `contactId` of the contact (`1` in the example above).
+It returns a JSON array of private messages:
+
+```json
+{
+ "contactId": 1,
+ "groupId": "oRRvCri85UE2XGcSloAKt/u8JDcMkmDc26SOMouxr4U=",
+ "id": "ZGDrlpCxO9v7doO4Bmijh95QqQDykaS4Oji/mZVMIJ8=",
+ "local": true,
+ "read": true,
+ "seen": true,
+ "sent": true,
+ "text": "test",
+ "timestamp": 1537376633850,
+ "type": "PrivateMessage"
+}
+```
+
+If `local` is `true`, the message was sent by the Briar peer instead of its remote contact.
+
+Attention: There can messages of other `type`s where the message `text` is `null`.
+
+### Writing a private message
+
+`POST /messages/{contactId}`
+
+The text of the message should be posted as JSON:
+
+```json
+{
+ "text": "Hello World!"
+}
+```
+
+### Listing blog posts
+
+`GET /v1/blogs/posts`
+
+Returns a JSON array of blog posts:
+
+```json
+{
+ "author": {
+ "formatVersion": 1,
+ "id": "VNKXkaERPpXmZuFbHHwYT6Qc148D+KNNxQ4hwtx7Kq4=",
+ "name": "Test",
+ "publicKey": "NbwpQWjS3gHMjjDQIASIy/j+bU6NRZnSRT8X8FKDoN4="
+ },
+ "authorStatus": "ourselves",
+ "id": "X1jmHaYfrX47kT5OEd0OD+p/bptyR92IvuOBYSgxETM=",
+ "parentId": null,
+ "read": true,
+ "rssFeed": false,
+ "text": "Test Post Content",
+ "timestamp": 1535397886749,
+ "timestampReceived": 1535397886749,
+ "type": "post"
+}
+```
+
+### Writing a blog post
+
+`POST /v1/blogs/posts`
+
+The text of the blog post should be posted as JSON:
+
+```json
+{
+ "text": "Hello Blog World!"
+}
+```
+
+## Websocket API
+
+The Briar peer uses a websocket to notify a connected API client about new events.
+
+`WS /v1/ws`
+
+The websocket request must use basic auth,
+with the authentication token as the username and a blank password.
+
+You can test connecting to the websocket with curl:
+
+ $ curl --no-buffer \
+ --header "Connection: Upgrade" \
+ --header "Upgrade: websocket" \
+ --header "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
+ --header "Sec-WebSocket-Version: 13" \
+ http://DZbfoUie8sjap7CSDR9y6cgJCojV+xUITTIFbgtAgqk=@127.0.0.1:7000/v1/ws
+
+The headers are only required when testing with curl.
+Your websocket client will most likely add these headers automatically.
+
+### Receiving new private messages
+
+When the Briar peer receives a new private message,
+it will send a JSON object to connected websocket clients:
+
+```json
+{
+ "data": {
+ "contactId": 1,
+ "groupId": "oRRvCri85UE2XGcSloAKt/u8JDcMkmDc26SOMouxr4U=",
+ "id": "JBc+ogQIok/yr+7XtxN2iQgNfzw635mHikNaP5QOEVs=",
+ "local": false,
+ "read": false,
+ "seen": false,
+ "sent": false,
+ "text": "Test Message",
+ "timestamp": 1537389146088,
+ "type": "PrivateMessage"
+ },
+ "name": "PrivateMessageReceivedEvent",
+ "type": "event"
+}
+```
+
+Note that the JSON object in `data` is exactly what the REST API returns
+when listing private messages.
diff --git a/briar-headless/build.gradle b/briar-headless/build.gradle
new file mode 100644
index 000000000..eae48965f
--- /dev/null
+++ b/briar-headless/build.gradle
@@ -0,0 +1,60 @@
+plugins {
+ id 'java'
+ id 'idea'
+ id 'org.jetbrains.kotlin.jvm' version '1.2.70'
+ id 'org.jetbrains.kotlin.kapt' version '1.2.70'
+ id 'witness'
+}
+apply from: 'witness.gradle'
+
+sourceCompatibility = 1.8
+targetCompatibility = 1.8
+
+dependencies {
+ implementation project(path: ':briar-core', configuration: 'default')
+ implementation project(path: ':bramble-java', configuration: 'default')
+
+ implementation 'io.javalin:javalin:2.2.0'
+ implementation 'org.slf4j:slf4j-simple:1.7.25'
+ implementation 'com.fasterxml.jackson.core:jackson-databind:2.9.6'
+ implementation 'com.github.ajalt:clikt:1.5.0'
+
+ kapt 'com.google.dagger:dagger-compiler:2.0.2'
+
+ testImplementation project(path: ':bramble-api', configuration: 'testOutput')
+ testImplementation project(path: ':bramble-core', configuration: 'testOutput')
+
+ def junitVersion = '5.2.0'
+ testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion"
+ testImplementation "org.junit.jupiter:junit-jupiter-params:$junitVersion"
+ testRuntime "org.junit.jupiter:junit-jupiter-engine:$junitVersion"
+ testImplementation "io.mockk:mockk:1.8.6"
+ testImplementation "org.skyscreamer:jsonassert:1.5.0"
+}
+
+jar {
+ manifest {
+ attributes(
+ 'Main-Class': 'org.briarproject.briar.headless.MainKt'
+ )
+ }
+ from {
+ configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
+ }
+}
+
+// At the moment for non-Android projects we need to explicitly mark the code generated by kapt
+// as 'generated source code' for correct highlighting and resolve in IDE.
+idea {
+ module {
+ sourceDirs += file('build/generated/source/kapt/main')
+ generatedSourceDirs += file('build/generated/source/kapt/main')
+ }
+}
+
+test {
+ useJUnitPlatform()
+ testLogging {
+ events "passed", "skipped", "failed"
+ }
+}
diff --git a/briar-headless/src/main/java/org/briarproject/bramble/identity/OutputAuthor.kt b/briar-headless/src/main/java/org/briarproject/bramble/identity/OutputAuthor.kt
new file mode 100644
index 000000000..2b3b4bc5b
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/bramble/identity/OutputAuthor.kt
@@ -0,0 +1,13 @@
+package org.briarproject.bramble.identity
+
+import org.briarproject.bramble.api.identity.Author
+import org.briarproject.briar.headless.json.JsonDict
+
+fun Author.output() = JsonDict(
+ "formatVersion" to formatVersion,
+ "id" to id.bytes,
+ "name" to name,
+ "publicKey" to publicKey
+)
+
+fun Author.Status.output() = name.toLowerCase()
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/BriarHeadlessApp.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/BriarHeadlessApp.kt
new file mode 100644
index 000000000..0d3c42983
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/BriarHeadlessApp.kt
@@ -0,0 +1,27 @@
+package org.briarproject.briar.headless
+
+import dagger.Component
+import org.briarproject.bramble.BrambleCoreEagerSingletons
+import org.briarproject.bramble.BrambleCoreModule
+import org.briarproject.bramble.account.AccountModule
+import org.briarproject.bramble.system.DesktopSecureRandomModule
+import org.briarproject.briar.BriarCoreEagerSingletons
+import org.briarproject.briar.BriarCoreModule
+import java.security.SecureRandom
+import javax.inject.Singleton
+
+@Component(
+ modules = [
+ BrambleCoreModule::class,
+ BriarCoreModule::class,
+ DesktopSecureRandomModule::class,
+ AccountModule::class,
+ HeadlessModule::class
+ ]
+)
+@Singleton
+internal interface BriarHeadlessApp : BrambleCoreEagerSingletons, BriarCoreEagerSingletons {
+ fun getRouter(): Router
+
+ fun getSecureRandom(): SecureRandom
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/BriarService.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/BriarService.kt
new file mode 100644
index 000000000..5f13b739e
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/BriarService.kt
@@ -0,0 +1,64 @@
+package org.briarproject.briar.headless
+
+import com.github.ajalt.clikt.core.UsageError
+import com.github.ajalt.clikt.output.TermUi.echo
+import com.github.ajalt.clikt.output.TermUi.prompt
+import org.briarproject.bramble.api.account.AccountManager
+import org.briarproject.bramble.api.crypto.PasswordStrengthEstimator
+import org.briarproject.bramble.api.crypto.PasswordStrengthEstimator.QUITE_WEAK
+import org.briarproject.bramble.api.identity.AuthorConstants.MAX_AUTHOR_NAME_LENGTH
+import org.briarproject.bramble.api.lifecycle.LifecycleManager
+import java.lang.System.exit
+import javax.annotation.concurrent.Immutable
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Immutable
+@Singleton
+internal class BriarService
+@Inject
+constructor(
+ private val accountManager: AccountManager,
+ private val lifecycleManager: LifecycleManager,
+ private val passwordStrengthEstimator: PasswordStrengthEstimator
+) {
+
+ fun start() {
+ if (!accountManager.accountExists()) {
+ createAccount()
+ } else {
+ val password = prompt("Password", hideInput = true)
+ ?: throw UsageError("Could not get password. Is STDIN connected?")
+ if (!accountManager.signIn(password)) {
+ echo("Error: Password invalid")
+ exit(1)
+ }
+ }
+ val dbKey = accountManager.databaseKey ?: throw AssertionError()
+ lifecycleManager.startServices(dbKey)
+ }
+
+ fun stop() {
+ lifecycleManager.stopServices()
+ lifecycleManager.waitForShutdown()
+ }
+
+ private fun createAccount() {
+ echo("No account found. Let's create one!\n\n")
+ val nickname = prompt("Nickname") { nickname ->
+ if (nickname.length > MAX_AUTHOR_NAME_LENGTH)
+ throw UsageError("Please choose a shorter nickname!")
+ nickname
+ }
+ val password =
+ prompt("Password", hideInput = true, requireConfirmation = true) { password ->
+ if (passwordStrengthEstimator.estimateStrength(password) < QUITE_WEAK)
+ throw UsageError("Please enter a stronger password!")
+ password
+ }
+ if (nickname == null || password == null)
+ throw UsageError("Could not get account information. Is STDIN connected?")
+ accountManager.createAccount(nickname, password)
+ }
+
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/HeadlessDatabaseConfig.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/HeadlessDatabaseConfig.kt
new file mode 100644
index 000000000..31bd59c50
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/HeadlessDatabaseConfig.kt
@@ -0,0 +1,21 @@
+package org.briarproject.briar.headless
+
+import org.briarproject.bramble.api.db.DatabaseConfig
+import java.io.File
+import java.lang.Long.MAX_VALUE
+
+internal class HeadlessDatabaseConfig(private val dbDir: File, private val keyDir: File) :
+ DatabaseConfig {
+
+ override fun getDatabaseDirectory(): File {
+ return dbDir
+ }
+
+ override fun getDatabaseKeyDirectory(): File {
+ return keyDir
+ }
+
+ override fun getMaxSize(): Long {
+ return MAX_VALUE
+ }
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/HeadlessModule.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/HeadlessModule.kt
new file mode 100644
index 000000000..d51ca2c36
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/HeadlessModule.kt
@@ -0,0 +1,116 @@
+package org.briarproject.briar.headless
+
+import dagger.Module
+import dagger.Provides
+import org.briarproject.bramble.api.crypto.CryptoComponent
+import org.briarproject.bramble.api.crypto.PublicKey
+import org.briarproject.bramble.api.db.DatabaseConfig
+import org.briarproject.bramble.api.event.EventBus
+import org.briarproject.bramble.api.lifecycle.IoExecutor
+import org.briarproject.bramble.api.network.NetworkManager
+import org.briarproject.bramble.api.plugin.BackoffFactory
+import org.briarproject.bramble.api.plugin.PluginConfig
+import org.briarproject.bramble.api.plugin.duplex.DuplexPluginFactory
+import org.briarproject.bramble.api.plugin.simplex.SimplexPluginFactory
+import org.briarproject.bramble.api.reporting.DevConfig
+import org.briarproject.bramble.api.reporting.ReportingConstants.DEV_ONION_ADDRESS
+import org.briarproject.bramble.api.reporting.ReportingConstants.DEV_PUBLIC_KEY_HEX
+import org.briarproject.bramble.api.system.Clock
+import org.briarproject.bramble.api.system.LocationUtils
+import org.briarproject.bramble.api.system.ResourceProvider
+import org.briarproject.bramble.network.JavaNetworkModule
+import org.briarproject.bramble.plugin.tor.CircumventionModule
+import org.briarproject.bramble.plugin.tor.CircumventionProvider
+import org.briarproject.bramble.plugin.tor.LinuxTorPluginFactory
+import org.briarproject.bramble.system.JavaSystemModule
+import org.briarproject.bramble.util.StringUtils.fromHexString
+import org.briarproject.briar.headless.blogs.HeadlessBlogModule
+import org.briarproject.briar.headless.contact.HeadlessContactModule
+import org.briarproject.briar.headless.event.HeadlessEventModule
+import org.briarproject.briar.headless.forums.HeadlessForumModule
+import org.briarproject.briar.headless.messaging.HeadlessMessagingModule
+import java.io.File
+import java.security.GeneralSecurityException
+import java.util.Collections.emptyList
+import java.util.concurrent.Executor
+import javax.inject.Singleton
+import javax.net.SocketFactory
+
+@Module(
+ includes = [
+ JavaNetworkModule::class,
+ JavaSystemModule::class,
+ CircumventionModule::class,
+ HeadlessBlogModule::class,
+ HeadlessContactModule::class,
+ HeadlessEventModule::class,
+ HeadlessForumModule::class,
+ HeadlessMessagingModule::class
+ ]
+)
+internal class HeadlessModule(private val appDir: File) {
+
+ @Provides
+ @Singleton
+ internal fun provideDatabaseConfig(): DatabaseConfig {
+ val dbDir = File(appDir, "db")
+ val keyDir = File(appDir, "key")
+ return HeadlessDatabaseConfig(dbDir, keyDir)
+ }
+
+ @Provides
+ internal fun providePluginConfig(
+ @IoExecutor ioExecutor: Executor, torSocketFactory: SocketFactory,
+ backoffFactory: BackoffFactory, networkManager: NetworkManager,
+ locationUtils: LocationUtils, eventBus: EventBus,
+ resourceProvider: ResourceProvider,
+ circumventionProvider: CircumventionProvider, clock: Clock
+ ): PluginConfig {
+ val torDirectory = File(appDir, "tor")
+ val tor = LinuxTorPluginFactory(
+ ioExecutor,
+ networkManager, locationUtils, eventBus, torSocketFactory,
+ backoffFactory, resourceProvider, circumventionProvider, clock,
+ torDirectory
+ )
+ val duplex = listOf(tor)
+ return object : PluginConfig {
+ override fun getDuplexFactories(): Collection {
+ return duplex
+ }
+
+ override fun getSimplexFactories(): Collection {
+ return emptyList()
+ }
+
+ override fun shouldPoll(): Boolean {
+ return true
+ }
+ }
+ }
+
+ @Provides
+ @Singleton
+ internal fun provideDevConfig(crypto: CryptoComponent): DevConfig {
+ return object : DevConfig {
+ override fun getDevPublicKey(): PublicKey {
+ try {
+ return crypto.messageKeyParser
+ .parsePublicKey(fromHexString(DEV_PUBLIC_KEY_HEX))
+ } catch (e: GeneralSecurityException) {
+ throw RuntimeException(e)
+ }
+
+ }
+
+ override fun getDevOnionAddress(): String {
+ return DEV_ONION_ADDRESS
+ }
+
+ override fun getReportDir(): File {
+ return File(appDir, "reportDir")
+ }
+ }
+ }
+
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/Main.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/Main.kt
new file mode 100644
index 000000000..14a18e7ec
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/Main.kt
@@ -0,0 +1,115 @@
+package org.briarproject.briar.headless
+
+import com.github.ajalt.clikt.core.CliktCommand
+import com.github.ajalt.clikt.parameters.options.counted
+import com.github.ajalt.clikt.parameters.options.default
+import com.github.ajalt.clikt.parameters.options.flag
+import com.github.ajalt.clikt.parameters.options.option
+import com.github.ajalt.clikt.parameters.types.int
+import org.briarproject.bramble.BrambleCoreModule
+import org.briarproject.briar.BriarCoreModule
+import org.slf4j.impl.SimpleLogger.DEFAULT_LOG_LEVEL_KEY
+import org.spongycastle.util.encoders.Base64.toBase64String
+import java.io.File
+import java.io.File.separator
+import java.io.IOException
+import java.lang.System.getProperty
+import java.lang.System.setProperty
+import java.nio.file.Files.setPosixFilePermissions
+import java.nio.file.attribute.PosixFilePermission
+import java.nio.file.attribute.PosixFilePermission.*
+import java.security.SecureRandom
+import java.util.logging.Level.*
+import java.util.logging.LogManager
+
+private const val DEFAULT_PORT = 7000
+private val DEFAULT_DATA_DIR = getProperty("user.home") + separator + ".briar"
+
+private class Main : CliktCommand(
+ name = "briar-headless",
+ help = "A Briar peer without GUI that exposes a REST and Websocket API"
+) {
+ private val debug by option("--debug", "-d", help = "Enable printing of debug messages").flag(
+ default = false
+ )
+ private val verbosity by option(
+ "--verbose",
+ "-v",
+ help = "Print verbose log messages"
+ ).counted()
+ private val port by option(
+ "--port",
+ help = "Bind the server to this port. Default: $DEFAULT_PORT",
+ metavar = "PORT",
+ envvar = "BRIAR_PORT"
+ ).int().default(DEFAULT_PORT)
+ private val dataDir by option(
+ "--data-dir",
+ help = "The directory where Briar will store its files. Default: $DEFAULT_DATA_DIR",
+ metavar = "PATH",
+ envvar = "BRIAR_DATA_DIR"
+ ).default(DEFAULT_DATA_DIR)
+
+ override fun run() {
+ // logging
+ val levelSlf4j = if (debug) "DEBUG" else when (verbosity) {
+ 0 -> "WARN"
+ 1 -> "INFO"
+ else -> "DEBUG"
+ }
+ val level = if (debug) ALL else when (verbosity) {
+ 0 -> WARNING
+ 1 -> INFO
+ else -> ALL
+ }
+ setProperty(DEFAULT_LOG_LEVEL_KEY, levelSlf4j)
+ LogManager.getLogManager().getLogger("").level = level
+
+ val dataDir = getDataDir()
+ val app =
+ DaggerBriarHeadlessApp.builder().headlessModule(HeadlessModule(dataDir)).build()
+ // We need to load the eager singletons directly after making the
+ // dependency graphs
+ BrambleCoreModule.initEagerSingletons(app)
+ BriarCoreModule.initEagerSingletons(app)
+
+ val authToken = getOrCreateAuthToken(dataDir, app.getSecureRandom())
+
+ app.getRouter().start(authToken, port, debug)
+ }
+
+ private fun getDataDir(): File {
+ val file = File(dataDir)
+ if (!file.exists() && !file.mkdirs()) {
+ throw IOException("Could not create directory: ${file.absolutePath}")
+ } else if (!file.isDirectory) {
+ throw IOException("Data dir is not a directory: ${file.absolutePath}")
+ }
+ val perms = HashSet()
+ perms.add(OWNER_READ)
+ perms.add(OWNER_WRITE)
+ perms.add(OWNER_EXECUTE)
+ setPosixFilePermissions(file.toPath(), perms)
+ return file
+ }
+
+ private fun getOrCreateAuthToken(dataDir: File, secureRandom: SecureRandom): String {
+ val tokenFile = File(dataDir, "auth_token")
+ return if (tokenFile.isFile) {
+ tokenFile.readText()
+ } else {
+ val authToken = createAuthToken(secureRandom)
+ tokenFile.writeText(authToken)
+ authToken
+ }
+ }
+
+ private fun createAuthToken(secureRandom: SecureRandom): String {
+ val bytes = ByteArray(32)
+ secureRandom.nextBytes(bytes)
+ return toBase64String(bytes)
+ }
+
+}
+
+fun main(args: Array) = Main().main(args)
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/Router.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/Router.kt
new file mode 100644
index 000000000..26231890d
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/Router.kt
@@ -0,0 +1,129 @@
+package org.briarproject.briar.headless
+
+import com.fasterxml.jackson.core.JsonParseException
+import com.fasterxml.jackson.databind.ObjectMapper
+import io.javalin.BadRequestResponse
+import io.javalin.Context
+import io.javalin.Javalin
+import io.javalin.JavalinEvent.SERVER_START_FAILED
+import io.javalin.JavalinEvent.SERVER_STOPPED
+import io.javalin.apibuilder.ApiBuilder.*
+import io.javalin.core.util.ContextUtil
+import io.javalin.core.util.Header.AUTHORIZATION
+import org.briarproject.briar.headless.blogs.BlogController
+import org.briarproject.briar.headless.contact.ContactController
+import org.briarproject.briar.headless.event.WebSocketController
+import org.briarproject.briar.headless.forums.ForumController
+import org.briarproject.briar.headless.messaging.MessagingController
+import java.lang.Runtime.getRuntime
+import java.lang.System.exit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.logging.Logger.getLogger
+import javax.annotation.concurrent.Immutable
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Immutable
+@Singleton
+internal class Router
+@Inject
+constructor(
+ private val briarService: BriarService,
+ private val webSocketController: WebSocketController,
+ private val contactController: ContactController,
+ private val messagingController: MessagingController,
+ private val forumController: ForumController,
+ private val blogController: BlogController
+) {
+
+ private val logger = getLogger(Router::javaClass.name)
+ private val stopped = AtomicBoolean(false)
+
+ fun start(authToken: String, port: Int, debug: Boolean) {
+ briarService.start()
+ getRuntime().addShutdownHook(Thread(this::stop))
+
+ val app = Javalin.create()
+ .port(port)
+ .disableStartupBanner()
+ .enableCaseSensitiveUrls()
+ .event(SERVER_START_FAILED) {serverStopped() }
+ .event(SERVER_STOPPED) { serverStopped() }
+ if (debug) app.enableDebugLogging()
+ app.start()
+
+ app.accessManager { handler, ctx, _ ->
+ if (ctx.header(AUTHORIZATION) == "Bearer $authToken") {
+ handler.handle(ctx)
+ } else {
+ ctx.status(401).result("Unauthorized")
+ }
+ }
+ app.routes {
+ path("/v1") {
+ path("/contacts") {
+ get { ctx -> contactController.list(ctx) }
+ }
+ path("/messages/:contactId") {
+ get { ctx -> messagingController.list(ctx) }
+ post { ctx -> messagingController.write(ctx) }
+ }
+ path("/forums") {
+ get { ctx -> forumController.list(ctx) }
+ post { ctx -> forumController.create(ctx) }
+ }
+ path("/blogs") {
+ path("/posts") {
+ get { ctx -> blogController.listPosts(ctx) }
+ post { ctx -> blogController.createPost(ctx) }
+ }
+ }
+ }
+ }
+ app.ws("/v1/ws") { ws ->
+ ws.onConnect { session ->
+ val authHeader = session.header(AUTHORIZATION)
+ val token = ContextUtil.getBasicAuthCredentials(authHeader)?.username
+ if (authToken == token) {
+ logger.info("Adding websocket session with ${session.remoteAddress}")
+ webSocketController.sessions.add(session)
+ } else {
+ logger.info("Closing websocket connection with ${session.remoteAddress}")
+ session.close(1008, "Invalid Authentication Token")
+ }
+ }
+ ws.onClose { session, _, _ ->
+ logger.info("Removing websocket connection with ${session.remoteAddress}")
+ webSocketController.sessions.remove(session)
+ }
+ }
+ }
+
+ private fun serverStopped() {
+ stop()
+ exit(1)
+ }
+
+ private fun stop() {
+ if (!stopped.getAndSet(true)) {
+ briarService.stop()
+ }
+ }
+
+}
+
+/**
+ * Returns a String from the JSON field or throws [BadRequestResponse] if null or empty.
+ */
+fun Context.getFromJson(field: String) : String {
+ try {
+ // TODO use a static object mapper to avoid re-initializations
+ val jsonNode = ObjectMapper().readTree(body())
+ if (!jsonNode.hasNonNull(field)) throw BadRequestResponse("'$field' missing in JSON")
+ val result = jsonNode.get(field).asText()
+ if (result == null || result.isEmpty()) throw BadRequestResponse("'$field' empty in JSON")
+ return result
+ } catch (e: JsonParseException) {
+ throw BadRequestResponse("Invalid JSON")
+ }
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/blogs/BlogController.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/blogs/BlogController.kt
new file mode 100644
index 000000000..549494b7d
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/blogs/BlogController.kt
@@ -0,0 +1,11 @@
+package org.briarproject.briar.headless.blogs
+
+import io.javalin.Context
+
+interface BlogController {
+
+ fun listPosts(ctx: Context): Context
+
+ fun createPost(ctx: Context): Context
+
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/blogs/BlogControllerImpl.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/blogs/BlogControllerImpl.kt
new file mode 100644
index 000000000..5f1dc0142
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/blogs/BlogControllerImpl.kt
@@ -0,0 +1,51 @@
+package org.briarproject.briar.headless.blogs
+
+import io.javalin.BadRequestResponse
+import io.javalin.Context
+import org.briarproject.bramble.api.identity.IdentityManager
+import org.briarproject.bramble.api.system.Clock
+import org.briarproject.bramble.util.StringUtils
+import org.briarproject.briar.api.blog.BlogConstants.MAX_BLOG_POST_BODY_LENGTH
+import org.briarproject.briar.api.blog.BlogManager
+import org.briarproject.briar.api.blog.BlogPostFactory
+import org.briarproject.briar.headless.getFromJson
+import javax.annotation.concurrent.Immutable
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Immutable
+@Singleton
+internal class BlogControllerImpl
+@Inject
+constructor(
+ private val blogManager: BlogManager,
+ private val blogPostFactory: BlogPostFactory,
+ private val identityManager: IdentityManager,
+ private val clock: Clock
+) : BlogController {
+
+ override fun listPosts(ctx: Context): Context {
+ val posts = blogManager.blogs
+ .flatMap { blog -> blogManager.getPostHeaders(blog.id) }
+ .asSequence()
+ .sortedBy { it.timeReceived }
+ .map { header -> header.output(blogManager.getPostBody(header.id)) }
+ .toList()
+ return ctx.json(posts)
+ }
+
+ override fun createPost(ctx: Context): Context {
+ val text = ctx.getFromJson("text")
+ if (StringUtils.utf8IsTooLong(text, MAX_BLOG_POST_BODY_LENGTH))
+ throw BadRequestResponse("Too long blog post text")
+
+ val author = identityManager.localAuthor
+ val blog = blogManager.getPersonalBlog(author)
+ val now = clock.currentTimeMillis()
+ val post = blogPostFactory.createBlogPost(blog.id, now, null, author, text)
+ blogManager.addLocalPost(post)
+ val header = blogManager.getPostHeader(blog.id, post.message.id)
+ return ctx.json(header.output(text))
+ }
+
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/blogs/HeadlessBlogModule.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/blogs/HeadlessBlogModule.kt
new file mode 100644
index 000000000..affe44110
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/blogs/HeadlessBlogModule.kt
@@ -0,0 +1,16 @@
+package org.briarproject.briar.headless.blogs
+
+import dagger.Module
+import dagger.Provides
+import javax.inject.Singleton
+
+@Module
+class HeadlessBlogModule {
+
+ @Provides
+ @Singleton
+ internal fun provideBlogController(blogController: BlogControllerImpl): BlogController {
+ return blogController
+ }
+
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/blogs/OutputBlogPost.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/blogs/OutputBlogPost.kt
new file mode 100644
index 000000000..65d3f1296
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/blogs/OutputBlogPost.kt
@@ -0,0 +1,21 @@
+package org.briarproject.briar.headless.blogs
+
+import org.briarproject.bramble.identity.output
+import org.briarproject.briar.api.blog.BlogPostHeader
+import org.briarproject.briar.api.blog.MessageType
+import org.briarproject.briar.headless.json.JsonDict
+
+internal fun BlogPostHeader.output(body: String) = JsonDict(
+ "text" to body,
+ "author" to author.output(),
+ "authorStatus" to authorStatus.output(),
+ "type" to type.output(),
+ "id" to id.bytes,
+ "parentId" to parentId?.bytes,
+ "read" to isRead,
+ "rssFeed" to isRssFeed,
+ "timestamp" to timestamp,
+ "timestampReceived" to timeReceived
+ )
+
+internal fun MessageType.output() = name.toLowerCase()
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/contact/ContactController.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/contact/ContactController.kt
new file mode 100644
index 000000000..9eb21a120
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/contact/ContactController.kt
@@ -0,0 +1,9 @@
+package org.briarproject.briar.headless.contact
+
+import io.javalin.Context
+
+interface ContactController {
+
+ fun list(ctx: Context): Context
+
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/contact/ContactControllerImpl.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/contact/ContactControllerImpl.kt
new file mode 100644
index 000000000..bf94c70a5
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/contact/ContactControllerImpl.kt
@@ -0,0 +1,22 @@
+package org.briarproject.briar.headless.contact
+
+import io.javalin.Context
+import org.briarproject.bramble.api.contact.ContactManager
+import javax.annotation.concurrent.Immutable
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Immutable
+@Singleton
+internal class ContactControllerImpl
+@Inject
+constructor(private val contactManager: ContactManager) : ContactController {
+
+ override fun list(ctx: Context): Context {
+ val contacts = contactManager.activeContacts.map { contact ->
+ contact.output()
+ }
+ return ctx.json(contacts)
+ }
+
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/contact/HeadlessContactModule.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/contact/HeadlessContactModule.kt
new file mode 100644
index 000000000..bf91804b3
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/contact/HeadlessContactModule.kt
@@ -0,0 +1,16 @@
+package org.briarproject.briar.headless.contact
+
+import dagger.Module
+import dagger.Provides
+import javax.inject.Singleton
+
+@Module
+class HeadlessContactModule {
+
+ @Provides
+ @Singleton
+ internal fun provideContactController(contactController: ContactControllerImpl): ContactController {
+ return contactController
+ }
+
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/contact/OutputContact.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/contact/OutputContact.kt
new file mode 100644
index 000000000..d0ec5898c
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/contact/OutputContact.kt
@@ -0,0 +1,11 @@
+package org.briarproject.briar.headless.contact
+
+import org.briarproject.bramble.api.contact.Contact
+import org.briarproject.bramble.identity.output
+import org.briarproject.briar.headless.json.JsonDict
+
+internal fun Contact.output() = JsonDict(
+ "contactId" to id.int,
+ "author" to author.output(),
+ "verified" to isVerified
+)
\ No newline at end of file
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/event/HeadlessEventModule.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/event/HeadlessEventModule.kt
new file mode 100644
index 000000000..f540a7fba
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/event/HeadlessEventModule.kt
@@ -0,0 +1,16 @@
+package org.briarproject.briar.headless.event
+
+import dagger.Module
+import dagger.Provides
+import javax.inject.Singleton
+
+@Module
+class HeadlessEventModule {
+
+ @Provides
+ @Singleton
+ internal fun provideWebSocketController(webSocketController: WebSocketControllerImpl): WebSocketController {
+ return webSocketController
+ }
+
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/event/OutputEvent.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/event/OutputEvent.kt
new file mode 100644
index 000000000..dc68c5c93
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/event/OutputEvent.kt
@@ -0,0 +1,13 @@
+package org.briarproject.briar.headless.event
+
+import org.briarproject.briar.api.messaging.event.PrivateMessageReceivedEvent
+import org.briarproject.briar.headless.messaging.output
+import javax.annotation.concurrent.Immutable
+
+@Immutable
+internal class OutputEvent(val name: String, val data: Any) {
+ val type = "event"
+}
+
+internal fun PrivateMessageReceivedEvent<*>.output(body: String) =
+ messageHeader.output(contactId, body)
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/event/WebSocketController.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/event/WebSocketController.kt
new file mode 100644
index 000000000..abbbc81e5
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/event/WebSocketController.kt
@@ -0,0 +1,18 @@
+package org.briarproject.briar.headless.event
+
+import io.javalin.websocket.WsSession
+import org.briarproject.bramble.api.lifecycle.IoExecutor
+import org.briarproject.briar.headless.json.JsonDict
+import javax.annotation.concurrent.ThreadSafe
+
+@ThreadSafe
+interface WebSocketController {
+
+ val sessions: MutableSet
+
+ /**
+ * Sends an event to all open sessions using the [IoExecutor].
+ */
+ fun sendEvent(name: String, obj: JsonDict)
+
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/event/WebSocketControllerImpl.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/event/WebSocketControllerImpl.kt
new file mode 100644
index 000000000..c36156ca1
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/event/WebSocketControllerImpl.kt
@@ -0,0 +1,43 @@
+package org.briarproject.briar.headless.event
+
+import io.javalin.json.JavalinJson.toJson
+import io.javalin.websocket.WsSession
+import org.briarproject.bramble.api.lifecycle.IoExecutor
+import org.briarproject.bramble.util.LogUtils.logException
+import org.briarproject.briar.headless.json.JsonDict
+import org.eclipse.jetty.websocket.api.WebSocketException
+import java.io.IOException
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.Executor
+import java.util.logging.Level.WARNING
+import java.util.logging.Logger.getLogger
+import javax.annotation.concurrent.Immutable
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Immutable
+@Singleton
+internal class WebSocketControllerImpl
+@Inject
+constructor(@IoExecutor private val ioExecutor: Executor) : WebSocketController {
+
+ private val logger = getLogger(WebSocketControllerImpl::javaClass.name)
+
+ override val sessions: MutableSet = ConcurrentHashMap.newKeySet()
+
+ override fun sendEvent(name: String, obj: JsonDict) {
+ val event = toJson(OutputEvent(name, obj))
+ sessions.forEach { session ->
+ ioExecutor.execute {
+ try {
+ session.send(event)
+ } catch (e: WebSocketException) {
+ logException(logger, WARNING, e)
+ } catch (e: IOException) {
+ logException(logger, WARNING, e)
+ }
+ }
+ }
+ }
+
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/forums/ForumController.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/forums/ForumController.kt
new file mode 100644
index 000000000..8ffd8ee90
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/forums/ForumController.kt
@@ -0,0 +1,11 @@
+package org.briarproject.briar.headless.forums
+
+import io.javalin.Context
+
+interface ForumController {
+
+ fun list(ctx: Context): Context
+
+ fun create(ctx: Context): Context
+
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/forums/ForumControllerImpl.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/forums/ForumControllerImpl.kt
new file mode 100644
index 000000000..af1ab391a
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/forums/ForumControllerImpl.kt
@@ -0,0 +1,30 @@
+package org.briarproject.briar.headless.forums
+
+import io.javalin.BadRequestResponse
+import io.javalin.Context
+import org.briarproject.bramble.util.StringUtils
+import org.briarproject.briar.api.forum.ForumConstants.MAX_FORUM_NAME_LENGTH
+import org.briarproject.briar.api.forum.ForumManager
+import org.briarproject.briar.headless.getFromJson
+import javax.annotation.concurrent.Immutable
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Immutable
+@Singleton
+internal class ForumControllerImpl
+@Inject
+constructor(private val forumManager: ForumManager) : ForumController {
+
+ override fun list(ctx: Context): Context {
+ return ctx.json(forumManager.forums.output())
+ }
+
+ override fun create(ctx: Context): Context {
+ val name = ctx.getFromJson("name")
+ if (StringUtils.utf8IsTooLong(name, MAX_FORUM_NAME_LENGTH))
+ throw BadRequestResponse("Forum name is too long")
+ return ctx.json(forumManager.addForum(name).output())
+ }
+
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/forums/HeadlessForumModule.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/forums/HeadlessForumModule.kt
new file mode 100644
index 000000000..5e7b90174
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/forums/HeadlessForumModule.kt
@@ -0,0 +1,16 @@
+package org.briarproject.briar.headless.forums
+
+import dagger.Module
+import dagger.Provides
+import javax.inject.Singleton
+
+@Module
+class HeadlessForumModule {
+
+ @Provides
+ @Singleton
+ internal fun provideForumController(forumController: ForumControllerImpl): ForumController {
+ return forumController
+ }
+
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/forums/OutputForum.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/forums/OutputForum.kt
new file mode 100644
index 000000000..0ed5367cf
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/forums/OutputForum.kt
@@ -0,0 +1,11 @@
+package org.briarproject.briar.headless.forums
+
+import org.briarproject.briar.api.forum.Forum
+import org.briarproject.briar.headless.json.JsonDict
+
+internal fun Forum.output() = JsonDict(
+ "name" to name,
+ "id" to id.bytes
+)
+
+internal fun Collection.output() = map { it.output() }
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/json/JsonDict.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/json/JsonDict.kt
new file mode 100644
index 000000000..00e23fb29
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/json/JsonDict.kt
@@ -0,0 +1,11 @@
+package org.briarproject.briar.headless.json
+
+class JsonDict(vararg pairs: Pair) : HashMap(pairs.size) {
+ init {
+ putAll(pairs)
+ }
+
+ fun putAll(vararg pairs: Pair) {
+ for (p in pairs) put(p.first, p.second)
+ }
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/HeadlessMessagingModule.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/HeadlessMessagingModule.kt
new file mode 100644
index 000000000..31d336a15
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/HeadlessMessagingModule.kt
@@ -0,0 +1,20 @@
+package org.briarproject.briar.headless.messaging
+
+import dagger.Module
+import dagger.Provides
+import org.briarproject.bramble.api.event.EventBus
+import javax.inject.Singleton
+
+@Module
+class HeadlessMessagingModule {
+
+ @Provides
+ @Singleton
+ internal fun provideMessagingController(
+ eventBus: EventBus, messagingController: MessagingControllerImpl
+ ): MessagingController {
+ eventBus.addListener(messagingController)
+ return messagingController
+ }
+
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/MessagingController.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/MessagingController.kt
new file mode 100644
index 000000000..978da8147
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/MessagingController.kt
@@ -0,0 +1,11 @@
+package org.briarproject.briar.headless.messaging
+
+import io.javalin.Context
+
+interface MessagingController {
+
+ fun list(ctx: Context): Context
+
+ fun write(ctx: Context): Context
+
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/MessagingControllerImpl.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/MessagingControllerImpl.kt
new file mode 100644
index 000000000..6fb20b682
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/MessagingControllerImpl.kt
@@ -0,0 +1,123 @@
+package org.briarproject.briar.headless.messaging
+
+import io.javalin.BadRequestResponse
+import io.javalin.Context
+import io.javalin.NotFoundResponse
+import org.briarproject.bramble.api.contact.Contact
+import org.briarproject.bramble.api.contact.ContactId
+import org.briarproject.bramble.api.contact.ContactManager
+import org.briarproject.bramble.api.db.DatabaseExecutor
+import org.briarproject.bramble.api.db.NoSuchContactException
+import org.briarproject.bramble.api.event.Event
+import org.briarproject.bramble.api.event.EventListener
+import org.briarproject.bramble.api.system.Clock
+import org.briarproject.bramble.util.StringUtils.utf8IsTooLong
+import org.briarproject.briar.api.blog.BlogInvitationRequest
+import org.briarproject.briar.api.blog.BlogInvitationResponse
+import org.briarproject.briar.api.forum.ForumInvitationRequest
+import org.briarproject.briar.api.forum.ForumInvitationResponse
+import org.briarproject.briar.api.introduction.IntroductionRequest
+import org.briarproject.briar.api.introduction.IntroductionResponse
+import org.briarproject.briar.api.messaging.*
+import org.briarproject.briar.api.messaging.MessagingConstants.MAX_PRIVATE_MESSAGE_BODY_LENGTH
+import org.briarproject.briar.api.messaging.event.PrivateMessageReceivedEvent
+import org.briarproject.briar.api.privategroup.invitation.GroupInvitationRequest
+import org.briarproject.briar.api.privategroup.invitation.GroupInvitationResponse
+import org.briarproject.briar.headless.event.WebSocketController
+import org.briarproject.briar.headless.event.output
+import org.briarproject.briar.headless.getFromJson
+import org.briarproject.briar.headless.json.JsonDict
+import java.util.concurrent.Executor
+import javax.annotation.concurrent.Immutable
+import javax.inject.Inject
+import javax.inject.Singleton
+
+internal const val EVENT_PRIVATE_MESSAGE = "PrivateMessageReceivedEvent"
+
+@Immutable
+@Singleton
+internal class MessagingControllerImpl
+@Inject
+constructor(
+ private val messagingManager: MessagingManager,
+ private val conversationManager: ConversationManager,
+ private val privateMessageFactory: PrivateMessageFactory,
+ private val contactManager: ContactManager,
+ private val webSocketController: WebSocketController,
+ @DatabaseExecutor private val dbExecutor: Executor,
+ private val clock: Clock
+) : MessagingController, EventListener {
+
+ override fun list(ctx: Context): Context {
+ val contact = getContact(ctx)
+ val jsonVisitor = JsonVisitor(contact.id, messagingManager)
+ val messages = conversationManager.getMessageHeaders(contact.id)
+ .sortedBy { it.timestamp }
+ .map { header -> header.accept(jsonVisitor) }
+ return ctx.json(messages)
+ }
+
+ override fun write(ctx: Context): Context {
+ val contact = getContact(ctx)
+
+ val message = ctx.getFromJson("text")
+ if (utf8IsTooLong(message, MAX_PRIVATE_MESSAGE_BODY_LENGTH))
+ throw BadRequestResponse("Message text too large")
+
+ val group = messagingManager.getContactGroup(contact)
+ val now = clock.currentTimeMillis()
+ val m = privateMessageFactory.createPrivateMessage(group.id, now, message)
+
+ messagingManager.addLocalMessage(m)
+ return ctx.json(m.output(contact.id, message))
+ }
+
+ override fun eventOccurred(e: Event) {
+ when (e) {
+ is PrivateMessageReceivedEvent<*> -> dbExecutor.execute {
+ val body = messagingManager.getMessageBody(e.messageHeader.id)
+ webSocketController.sendEvent(EVENT_PRIVATE_MESSAGE, e.output(body))
+ }
+ }
+ }
+
+ private fun getContact(ctx: Context): Contact {
+ val contactString = ctx.pathParam("contactId")
+ val contactInt = try {
+ Integer.parseInt(contactString)
+ } catch (e: NumberFormatException) {
+ throw NotFoundResponse()
+ }
+ val contactId = ContactId(contactInt)
+ return try {
+ contactManager.getContact(contactId)
+ } catch (e: NoSuchContactException) {
+ throw NotFoundResponse()
+ }
+ }
+}
+
+private class JsonVisitor(
+ private val contactId: ContactId,
+ private val messagingManager: MessagingManager
+) : PrivateMessageVisitor {
+
+ override fun visitPrivateMessageHeader(h: PrivateMessageHeader) =
+ h.output(contactId, messagingManager.getMessageBody(h.id))
+
+ override fun visitBlogInvitationRequest(r: BlogInvitationRequest) = r.output(contactId)
+
+ override fun visitBlogInvitationResponse(r: BlogInvitationResponse) = r.output(contactId)
+
+ override fun visitForumInvitationRequest(r: ForumInvitationRequest) = r.output(contactId)
+
+ override fun visitForumInvitationResponse(r: ForumInvitationResponse) = r.output(contactId)
+
+ override fun visitGroupInvitationRequest(r: GroupInvitationRequest) = r.output(contactId)
+
+ override fun visitGroupInvitationResponse(r: GroupInvitationResponse) = r.output(contactId)
+
+ override fun visitIntroductionRequest(r: IntroductionRequest) = r.output(contactId)
+
+ override fun visitIntroductionResponse(r: IntroductionResponse) = r.output(contactId)
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/OutputPrivateMessage.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/OutputPrivateMessage.kt
new file mode 100644
index 000000000..dcefdc13e
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/OutputPrivateMessage.kt
@@ -0,0 +1,40 @@
+package org.briarproject.briar.headless.messaging
+
+import org.briarproject.bramble.api.contact.ContactId
+import org.briarproject.briar.api.messaging.PrivateMessage
+import org.briarproject.briar.api.messaging.PrivateMessageHeader
+import org.briarproject.briar.headless.json.JsonDict
+
+internal fun PrivateMessageHeader.output(contactId: ContactId) = JsonDict(
+ "type" to "PrivateMessage",
+ "contactId" to contactId.int,
+ "timestamp" to timestamp,
+ "read" to isRead,
+ "seen" to isSeen,
+ "sent" to isSent,
+ "local" to isLocal,
+ "id" to id.bytes,
+ "groupId" to groupId.bytes
+)
+
+internal fun PrivateMessageHeader.output(contactId: ContactId, body: String?): JsonDict {
+ val dict = output(contactId)
+ dict["text"] = body
+ return dict
+}
+
+/**
+ * Use only for outgoing messages that were just sent
+ */
+internal fun PrivateMessage.output(contactId: ContactId, body: String) = JsonDict(
+ "type" to "PrivateMessage",
+ "contactId" to contactId.int,
+ "timestamp" to message.timestamp,
+ "read" to true,
+ "seen" to false,
+ "sent" to false,
+ "local" to true,
+ "id" to message.id.bytes,
+ "groupId" to message.groupId.bytes,
+ "text" to body
+)
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/OutputPrivateRequest.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/OutputPrivateRequest.kt
new file mode 100644
index 000000000..2e956f627
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/OutputPrivateRequest.kt
@@ -0,0 +1,54 @@
+package org.briarproject.briar.headless.messaging
+
+import org.briarproject.bramble.api.contact.ContactId
+import org.briarproject.briar.api.blog.BlogInvitationRequest
+import org.briarproject.briar.api.forum.ForumInvitationRequest
+import org.briarproject.briar.api.introduction.IntroductionRequest
+import org.briarproject.briar.api.messaging.PrivateMessageHeader
+import org.briarproject.briar.api.messaging.PrivateRequest
+import org.briarproject.briar.api.privategroup.invitation.GroupInvitationRequest
+import org.briarproject.briar.api.sharing.InvitationRequest
+import org.briarproject.briar.headless.json.JsonDict
+
+internal fun PrivateRequest<*>.output(contactId: ContactId): JsonDict {
+ val dict = (this as PrivateMessageHeader).output(contactId, message)
+ dict.putAll(
+ "sessionId" to sessionId.bytes,
+ "name" to name,
+ "answered" to wasAnswered()
+ )
+ return dict
+}
+
+internal fun IntroductionRequest.output(contactId: ContactId): JsonDict {
+ val dict = (this as PrivateRequest<*>).output(contactId)
+ dict.putAll(
+ "type" to "IntroductionRequest",
+ "alreadyContact" to isContact
+ )
+ return dict
+}
+
+internal fun InvitationRequest<*>.output(contactId: ContactId): JsonDict {
+ val dict = (this as PrivateRequest<*>).output(contactId)
+ dict["canBeOpened"] = canBeOpened()
+ return dict
+}
+
+internal fun BlogInvitationRequest.output(contactId: ContactId): JsonDict {
+ val dict = (this as InvitationRequest<*>).output(contactId)
+ dict["type"] = "BlogInvitationRequest"
+ return dict
+}
+
+internal fun ForumInvitationRequest.output(contactId: ContactId): JsonDict {
+ val dict = (this as InvitationRequest<*>).output(contactId)
+ dict["type"] = "ForumInvitationRequest"
+ return dict
+}
+
+internal fun GroupInvitationRequest.output(contactId: ContactId): JsonDict {
+ val dict = (this as InvitationRequest<*>).output(contactId)
+ dict["type"] = "GroupInvitationRequest"
+ return dict
+}
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/OutputPrivateResponse.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/OutputPrivateResponse.kt
new file mode 100644
index 000000000..f0b3fbda3
--- /dev/null
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/messaging/OutputPrivateResponse.kt
@@ -0,0 +1,55 @@
+package org.briarproject.briar.headless.messaging
+
+import org.briarproject.bramble.api.contact.ContactId
+import org.briarproject.bramble.identity.output
+import org.briarproject.briar.api.blog.BlogInvitationResponse
+import org.briarproject.briar.api.forum.ForumInvitationResponse
+import org.briarproject.briar.api.introduction.IntroductionResponse
+import org.briarproject.briar.api.messaging.PrivateMessageHeader
+import org.briarproject.briar.api.messaging.PrivateResponse
+import org.briarproject.briar.api.privategroup.invitation.GroupInvitationResponse
+import org.briarproject.briar.api.sharing.InvitationResponse
+import org.briarproject.briar.headless.json.JsonDict
+
+internal fun PrivateResponse.output(contactId: ContactId): JsonDict {
+ val dict = (this as PrivateMessageHeader).output(contactId)
+ dict.putAll(
+ "sessionId" to sessionId.bytes,
+ "accepted" to wasAccepted()
+ )
+ return dict
+}
+
+internal fun IntroductionResponse.output(contactId: ContactId): JsonDict {
+ val dict = (this as PrivateResponse).output(contactId)
+ dict.putAll(
+ "type" to "IntroductionResponse",
+ "introducedAuthor" to introducedAuthor.output(),
+ "introducer" to isIntroducer
+ )
+ return dict
+}
+
+internal fun InvitationResponse.output(contactId: ContactId): JsonDict {
+ val dict = (this as PrivateResponse).output(contactId)
+ dict["shareableId"] = shareableId.bytes
+ return dict
+}
+
+internal fun BlogInvitationResponse.output(contactId: ContactId): JsonDict {
+ val dict = (this as InvitationResponse).output(contactId)
+ dict["type"] = "BlogInvitationResponse"
+ return dict
+}
+
+internal fun ForumInvitationResponse.output(contactId: ContactId): JsonDict {
+ val dict = (this as InvitationResponse).output(contactId)
+ dict["type"] = "ForumInvitationResponse"
+ return dict
+}
+
+internal fun GroupInvitationResponse.output(contactId: ContactId): JsonDict {
+ val dict = (this as InvitationResponse).output(contactId)
+ dict["type"] = "GroupInvitationResponse"
+ return dict
+}
diff --git a/briar-headless/src/test/java/org/briarproject/briar/headless/ControllerTest.kt b/briar-headless/src/test/java/org/briarproject/briar/headless/ControllerTest.kt
new file mode 100644
index 000000000..b0a27244e
--- /dev/null
+++ b/briar-headless/src/test/java/org/briarproject/briar/headless/ControllerTest.kt
@@ -0,0 +1,45 @@
+package org.briarproject.briar.headless
+
+import io.javalin.Context
+import io.javalin.core.util.ContextUtil
+import io.mockk.mockk
+import org.briarproject.bramble.api.contact.Contact
+import org.briarproject.bramble.api.contact.ContactId
+import org.briarproject.bramble.api.contact.ContactManager
+import org.briarproject.bramble.api.identity.Author
+import org.briarproject.bramble.api.identity.IdentityManager
+import org.briarproject.bramble.api.identity.LocalAuthor
+import org.briarproject.bramble.api.sync.Group
+import org.briarproject.bramble.api.sync.Message
+import org.briarproject.bramble.api.system.Clock
+import org.briarproject.bramble.test.TestUtils.*
+import org.briarproject.bramble.util.StringUtils.getRandomString
+import org.skyscreamer.jsonassert.JSONAssert.assertEquals
+import org.skyscreamer.jsonassert.JSONCompareMode.STRICT
+import javax.servlet.http.HttpServletRequest
+import javax.servlet.http.HttpServletResponse
+
+abstract class ControllerTest {
+
+ protected val contactManager = mockk()
+ protected val identityManager = mockk()
+ protected val clock = mockk()
+ protected val ctx = mockk()
+
+ private val request = mockk(relaxed = true)
+ private val response = mockk(relaxed = true)
+ private val outputCtx = ContextUtil.init(request, response)
+
+ protected val group: Group = getGroup(getClientId(), 0)
+ protected val author: Author = getAuthor()
+ protected val localAuthor: LocalAuthor = getLocalAuthor()
+ protected val contact = Contact(ContactId(1), author, localAuthor.id, true, true)
+ protected val message: Message = getMessage(group.id)
+ protected val body: String = getRandomString(5)
+ protected val timestamp = 42L
+
+ protected fun assertJsonEquals(json: String, obj: Any) {
+ assertEquals(json, outputCtx.json(obj).resultString(), STRICT)
+ }
+
+}
diff --git a/briar-headless/src/test/java/org/briarproject/briar/headless/blogs/BlogControllerTest.kt b/briar-headless/src/test/java/org/briarproject/briar/headless/blogs/BlogControllerTest.kt
new file mode 100644
index 000000000..48a07025d
--- /dev/null
+++ b/briar-headless/src/test/java/org/briarproject/briar/headless/blogs/BlogControllerTest.kt
@@ -0,0 +1,120 @@
+package org.briarproject.briar.headless.blogs
+
+import io.javalin.BadRequestResponse
+import io.javalin.json.JavalinJson.toJson
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import org.briarproject.bramble.api.identity.Author.Status.OURSELVES
+import org.briarproject.bramble.api.sync.MessageId
+import org.briarproject.bramble.identity.output
+import org.briarproject.bramble.util.StringUtils.getRandomString
+import org.briarproject.briar.api.blog.*
+import org.briarproject.briar.api.blog.BlogConstants.MAX_BLOG_POST_BODY_LENGTH
+import org.briarproject.briar.api.blog.MessageType.POST
+import org.briarproject.briar.headless.ControllerTest
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Test
+
+internal class BlogControllerTest : ControllerTest() {
+
+ private val blogManager = mockk()
+ private val blogPostFactory = mockk()
+
+ private val controller =
+ BlogControllerImpl(blogManager, blogPostFactory, identityManager, clock)
+
+ private val blog = Blog(group, author, false)
+ private val parentId: MessageId? = null
+ private val rssFeed = false
+ private val read = true
+ private val header = BlogPostHeader(
+ POST, group.id, message.id, parentId, message.timestamp, timestamp, author, OURSELVES,
+ rssFeed, read
+ )
+
+ @Test
+ fun testCreate() {
+ val post = BlogPost(message, null, localAuthor)
+
+ every { ctx.body() } returns """{"text": "$body"}"""
+ every { identityManager.localAuthor } returns localAuthor
+ every { blogManager.getPersonalBlog(localAuthor) } returns blog
+ every { clock.currentTimeMillis() } returns message.timestamp
+ every {
+ blogPostFactory.createBlogPost(
+ message.groupId,
+ message.timestamp,
+ parentId,
+ localAuthor,
+ body
+ )
+ } returns post
+ every { blogManager.addLocalPost(post) } just Runs
+ every { blogManager.getPostHeader(post.message.groupId, post.message.id) } returns header
+ every { ctx.json(header.output(body)) } returns ctx
+
+ controller.createPost(ctx)
+ }
+
+ @Test
+ fun testCreateNoText() {
+ every { ctx.body() } returns """{"foo": "bar"}"""
+
+ assertThrows(BadRequestResponse::class.java) { controller.createPost(ctx) }
+ }
+
+ @Test
+ fun testCreateEmptyText() {
+ every { ctx.body() } returns """{"text": ""}"""
+
+ assertThrows(BadRequestResponse::class.java) { controller.createPost(ctx) }
+ }
+
+ @Test
+ fun testCreateTooLongText() {
+ every { ctx.body() } returns """{"text": "${getRandomString(MAX_BLOG_POST_BODY_LENGTH + 1)}"}"""
+
+ assertThrows(BadRequestResponse::class.java) { controller.createPost(ctx) }
+ }
+
+ @Test
+ fun testList() {
+ every { blogManager.blogs } returns listOf(blog)
+ every { blogManager.getPostHeaders(group.id) } returns listOf(header)
+ every { blogManager.getPostBody(message.id) } returns body
+ every { ctx.json(listOf(header.output(body))) } returns ctx
+
+ controller.listPosts(ctx)
+ }
+
+ @Test
+ fun testEmptyList() {
+ every { blogManager.blogs } returns listOf(blog)
+ every { blogManager.getPostHeaders(group.id) } returns emptyList()
+ every { ctx.json(emptyList()) } returns ctx
+
+ controller.listPosts(ctx)
+ }
+
+ @Test
+ fun testOutputBlogPost() {
+ val json = """
+ {
+ "text": "$body",
+ "author": ${toJson(author.output())},
+ "authorStatus": "ourselves",
+ "type": "post",
+ "id": ${toJson(header.id.bytes)},
+ "parentId": $parentId,
+ "read": $read,
+ "rssFeed": $rssFeed,
+ "timestamp": ${message.timestamp},
+ "timestampReceived": $timestamp
+ }
+ """
+ assertJsonEquals(json, header.output(body))
+ }
+
+}
diff --git a/briar-headless/src/test/java/org/briarproject/briar/headless/contact/ContactControllerTest.kt b/briar-headless/src/test/java/org/briarproject/briar/headless/contact/ContactControllerTest.kt
new file mode 100644
index 000000000..7566c9842
--- /dev/null
+++ b/briar-headless/src/test/java/org/briarproject/briar/headless/contact/ContactControllerTest.kt
@@ -0,0 +1,53 @@
+package org.briarproject.briar.headless.contact
+
+import io.javalin.json.JavalinJson.toJson
+import io.mockk.every
+import org.briarproject.bramble.api.contact.Contact
+import org.briarproject.bramble.identity.output
+import org.briarproject.briar.headless.ControllerTest
+import org.junit.jupiter.api.Test
+
+internal class ContactControllerTest : ControllerTest() {
+
+ private val controller = ContactControllerImpl(contactManager)
+
+ @Test
+ fun testEmptyContactList() {
+ every { contactManager.activeContacts } returns emptyList()
+ every { ctx.json(emptyList()) } returns ctx
+ controller.list(ctx)
+ }
+
+ @Test
+ fun testList() {
+ every { contactManager.activeContacts } returns listOf(contact)
+ every { ctx.json(listOf(contact.output())) } returns ctx
+ controller.list(ctx)
+ }
+
+ @Test
+ fun testOutputContact() {
+ val json = """
+ {
+ "contactId": ${contact.id.int},
+ "author": ${toJson(author.output())},
+ "verified": ${contact.isVerified}
+ }
+ """
+ assertJsonEquals(json, contact.output())
+ }
+
+ @Test
+ fun testOutputAuthor() {
+ val json = """
+ {
+ "formatVersion": 1,
+ "id": ${toJson(author.id.bytes)},
+ "name": "${author.name}",
+ "publicKey": ${toJson(author.publicKey)}
+ }
+ """
+ assertJsonEquals(json, author.output())
+ }
+
+}
diff --git a/briar-headless/src/test/java/org/briarproject/briar/headless/event/WebSocketControllerTest.kt b/briar-headless/src/test/java/org/briarproject/briar/headless/event/WebSocketControllerTest.kt
new file mode 100644
index 000000000..53f7e5eb9
--- /dev/null
+++ b/briar-headless/src/test/java/org/briarproject/briar/headless/event/WebSocketControllerTest.kt
@@ -0,0 +1,75 @@
+package org.briarproject.briar.headless.event
+
+import io.javalin.json.JavalinJson.toJson
+import io.javalin.websocket.WsSession
+import io.mockk.*
+import org.briarproject.bramble.test.ImmediateExecutor
+import org.briarproject.briar.api.messaging.PrivateMessageHeader
+import org.briarproject.briar.api.messaging.event.PrivateMessageReceivedEvent
+import org.briarproject.briar.headless.ControllerTest
+import org.briarproject.briar.headless.messaging.EVENT_PRIVATE_MESSAGE
+import org.briarproject.briar.headless.messaging.output
+import org.eclipse.jetty.websocket.api.WebSocketException
+import org.junit.jupiter.api.Test
+import java.io.IOException
+
+internal class WebSocketControllerTest : ControllerTest() {
+
+ private val session1 = mockk()
+ private val session2 = mockk()
+
+ private val controller = WebSocketControllerImpl(ImmediateExecutor())
+
+ private val header =
+ PrivateMessageHeader(message.id, group.id, timestamp, true, true, true, true)
+ private val event = PrivateMessageReceivedEvent(header, contact.id)
+ private val outputEvent = OutputEvent(EVENT_PRIVATE_MESSAGE, event.output(body))
+
+ @Test
+ fun testSendEvent() {
+ val slot = CapturingSlot()
+
+ every { session1.send(capture(slot)) } just Runs
+
+ controller.sessions.add(session1)
+ controller.sendEvent(EVENT_PRIVATE_MESSAGE, event.output(body))
+
+ assertJsonEquals(slot.captured, outputEvent)
+ }
+
+ @Test
+ fun testSendEventIOException() {
+ testSendEventException(IOException())
+ }
+
+ @Test
+ fun testSendEventWebSocketException() {
+ testSendEventException(WebSocketException())
+ }
+
+ private fun testSendEventException(throwable: Throwable) {
+ val slot = CapturingSlot()
+
+ every { session1.send(capture(slot)) } throws throwable
+ every { session2.send(capture(slot)) } just Runs
+
+ controller.sessions.add(session1)
+ controller.sessions.add(session2)
+ controller.sendEvent(EVENT_PRIVATE_MESSAGE, event.output(body))
+
+ verify { session2.send(slot.captured) }
+ }
+
+ @Test
+ fun testOutputPrivateMessageReceivedEvent() {
+ val json = """
+ {
+ "type": "event",
+ "name": "PrivateMessageReceivedEvent",
+ "data": ${toJson(header.output(contact.id, body))}
+ }
+ """
+ assertJsonEquals(json, outputEvent)
+ }
+
+}
diff --git a/briar-headless/src/test/java/org/briarproject/briar/headless/forums/ForumControllerTest.kt b/briar-headless/src/test/java/org/briarproject/briar/headless/forums/ForumControllerTest.kt
new file mode 100644
index 000000000..70530f3db
--- /dev/null
+++ b/briar-headless/src/test/java/org/briarproject/briar/headless/forums/ForumControllerTest.kt
@@ -0,0 +1,75 @@
+package org.briarproject.briar.headless.forums
+
+import io.javalin.BadRequestResponse
+import io.mockk.every
+import io.mockk.mockk
+import org.briarproject.bramble.test.TestUtils.getRandomBytes
+import org.briarproject.bramble.util.StringUtils.getRandomString
+import org.briarproject.briar.api.forum.Forum
+import org.briarproject.briar.api.forum.ForumConstants.MAX_FORUM_NAME_LENGTH
+import org.briarproject.briar.api.forum.ForumManager
+import org.briarproject.briar.headless.ControllerTest
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Test
+
+internal class ForumControllerTest : ControllerTest() {
+
+ private val forumManager = mockk()
+
+ private val controller = ForumControllerImpl(forumManager)
+
+ private val forum = Forum(group, getRandomString(5), getRandomBytes(5))
+
+ @Test
+ fun list() {
+ every { forumManager.forums } returns listOf(forum)
+ every { ctx.json(listOf(forum.output())) } returns ctx
+
+ controller.list(ctx)
+ }
+
+ @Test
+ fun create() {
+ every { ctx.body() } returns """{"name": "${forum.name}"}"""
+ every { forumManager.addForum(forum.name) } returns forum
+ every { ctx.json(forum.output()) } returns ctx
+
+ controller.create(ctx)
+ }
+
+ @Test
+ fun createNoName() {
+ every { ctx.body() } returns "{}"
+
+ assertThrows(BadRequestResponse::class.java) { controller.create(ctx) }
+ }
+
+ @Test
+ fun createEmptyName() {
+ every { ctx.body() } returns """{"name": ""}"""
+
+ assertThrows(BadRequestResponse::class.java) { controller.create(ctx) }
+ }
+
+ @Test
+ fun createNullName() {
+ every { ctx.body() } returns """{"name": null}"""
+
+ assertThrows(BadRequestResponse::class.java) { controller.create(ctx) }
+ }
+
+ @Test
+ fun createNoJsonName() {
+ every { ctx.body() } returns "foo"
+
+ assertThrows(BadRequestResponse::class.java) { controller.create(ctx) }
+ }
+
+ @Test
+ fun createTooLongName() {
+ every { ctx.body() } returns """{"name": "${getRandomString(MAX_FORUM_NAME_LENGTH + 1)}"}"""
+
+ assertThrows(BadRequestResponse::class.java) { controller.create(ctx) }
+ }
+
+}
diff --git a/briar-headless/src/test/java/org/briarproject/briar/headless/messaging/MessagingControllerImplTest.kt b/briar-headless/src/test/java/org/briarproject/briar/headless/messaging/MessagingControllerImplTest.kt
new file mode 100644
index 000000000..83e04523c
--- /dev/null
+++ b/briar-headless/src/test/java/org/briarproject/briar/headless/messaging/MessagingControllerImplTest.kt
@@ -0,0 +1,243 @@
+package org.briarproject.briar.headless.messaging
+
+import io.javalin.BadRequestResponse
+import io.javalin.Context
+import io.javalin.NotFoundResponse
+import io.javalin.json.JavalinJson.toJson
+import io.mockk.*
+import org.briarproject.bramble.api.contact.ContactId
+import org.briarproject.bramble.api.db.NoSuchContactException
+import org.briarproject.bramble.test.ImmediateExecutor
+import org.briarproject.bramble.test.TestUtils.getRandomId
+import org.briarproject.bramble.util.StringUtils.getRandomString
+import org.briarproject.briar.api.client.SessionId
+import org.briarproject.briar.api.introduction.IntroductionRequest
+import org.briarproject.briar.api.messaging.*
+import org.briarproject.briar.api.messaging.MessagingConstants.MAX_PRIVATE_MESSAGE_BODY_LENGTH
+import org.briarproject.briar.api.messaging.event.PrivateMessageReceivedEvent
+import org.briarproject.briar.headless.ControllerTest
+import org.briarproject.briar.headless.event.WebSocketController
+import org.briarproject.briar.headless.event.output
+import org.briarproject.briar.headless.json.JsonDict
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Test
+
+internal class MessagingControllerImplTest : ControllerTest() {
+
+ private val messagingManager = mockk()
+ private val conversationManager = mockk()
+ private val privateMessageFactory = mockk()
+ private val webSocketController = mockk()
+ private val dbExecutor = ImmediateExecutor()
+
+ private val controller = MessagingControllerImpl(
+ messagingManager,
+ conversationManager,
+ privateMessageFactory,
+ contactManager,
+ webSocketController,
+ dbExecutor,
+ clock
+ )
+
+ private val header =
+ PrivateMessageHeader(message.id, group.id, timestamp, true, true, true, true)
+ private val sessionId = SessionId(getRandomId())
+ private val privateMessage = PrivateMessage(message)
+
+ @Test
+ fun list() {
+ expectGetContact()
+ every { conversationManager.getMessageHeaders(contact.id) } returns listOf(header)
+ every { messagingManager.getMessageBody(message.id) } returns body
+ every { ctx.json(listOf(header.output(contact.id, body))) } returns ctx
+
+ controller.list(ctx)
+ }
+
+ @Test
+ fun listIntroductionRequest() {
+ val request = IntroductionRequest(
+ message.id, group.id, timestamp, true, true, false, true, sessionId, author, body,
+ false, false
+ )
+
+ expectGetContact()
+ every { conversationManager.getMessageHeaders(contact.id) } returns listOf(request)
+ every { ctx.json(listOf(request.output(contact.id))) } returns ctx
+
+ controller.list(ctx)
+ }
+
+ @Test
+ fun emptyList() {
+ every { ctx.pathParam("contactId") } returns contact.id.int.toString()
+ every { contactManager.getContact(contact.id) } returns contact
+ every { conversationManager.getMessageHeaders(contact.id) } returns emptyList()
+ every { ctx.json(emptyList()) } returns ctx
+
+ controller.list(ctx)
+ }
+
+ @Test
+ fun listInvalidContactId() {
+ testInvalidContactId { controller.list(ctx) }
+ }
+
+ @Test
+ fun listNonexistentContactId() {
+ testNonexistentContactId { controller.list(ctx) }
+ }
+
+ @Test
+ fun write() {
+ val slot = CapturingSlot()
+
+ expectGetContact()
+ every { ctx.body() } returns """{"text": "$body"}"""
+ every { messagingManager.getContactGroup(contact) } returns group
+ every { clock.currentTimeMillis() } returns timestamp
+ every {
+ privateMessageFactory.createPrivateMessage(
+ group.id,
+ timestamp,
+ body
+ )
+ } returns privateMessage
+ every { messagingManager.addLocalMessage(privateMessage) } just runs
+ every { ctx.json(capture(slot)) } returns ctx
+
+ controller.write(ctx)
+
+ assertEquals(privateMessage.output(contact.id, body), slot.captured)
+ }
+
+ @Test
+ fun writeInvalidContactId() {
+ testInvalidContactId { controller.write(ctx) }
+ }
+
+ @Test
+ fun writeNonexistentContactId() {
+ testNonexistentContactId { controller.write(ctx) }
+ }
+
+ @Test
+ fun writeNonexistentBody() {
+ expectGetContact()
+ every { ctx.body() } returns """{"foo": "bar"}"""
+
+ assertThrows(BadRequestResponse::class.java) { controller.write(ctx) }
+ }
+
+ @Test
+ fun writeEmptyBody() {
+ expectGetContact()
+ every { ctx.body() } returns """{"text": ""}"""
+
+ assertThrows(BadRequestResponse::class.java) { controller.write(ctx) }
+ }
+
+ @Test
+ fun writeTooLongBody() {
+ expectGetContact()
+ every { ctx.body() } returns """{"text": "${getRandomString(MAX_PRIVATE_MESSAGE_BODY_LENGTH + 1)}"}"""
+
+ assertThrows(BadRequestResponse::class.java) { controller.write(ctx) }
+ }
+
+ @Test
+ fun privateMessageEvent() {
+ val event = PrivateMessageReceivedEvent(header, contact.id)
+
+ every { messagingManager.getMessageBody(message.id) } returns body
+ every { webSocketController.sendEvent(EVENT_PRIVATE_MESSAGE, event.output(body)) } just runs
+
+ controller.eventOccurred(event)
+ }
+
+ @Test
+ fun testOutputPrivateMessageHeader() {
+ val json = """
+ {
+ "text": "$body",
+ "type": "PrivateMessage",
+ "timestamp": $timestamp,
+ "groupId": ${toJson(header.groupId.bytes)},
+ "contactId": ${contact.id.int},
+ "local": ${header.isLocal},
+ "seen": ${header.isSeen},
+ "read": ${header.isRead},
+ "sent": ${header.isSent},
+ "id": ${toJson(header.id.bytes)}
+ }
+ """
+ assertJsonEquals(json, header.output(contact.id, body))
+ }
+
+ @Test
+ fun testOutputPrivateMessage() {
+ val json = """
+ {
+ "text": "$body",
+ "type": "PrivateMessage",
+ "timestamp": ${message.timestamp},
+ "groupId": ${toJson(message.groupId.bytes)},
+ "contactId": ${contact.id.int},
+ "local": true,
+ "seen": false,
+ "read": true,
+ "sent": false,
+ "id": ${toJson(message.id.bytes)}
+ }
+ """
+ assertJsonEquals(json, privateMessage.output(contact.id, body))
+ }
+
+ @Test
+ fun testIntroductionRequestWithEmptyBody() {
+ val request = IntroductionRequest(
+ message.id, group.id, timestamp, true, true, false, true, sessionId, author, null,
+ false, false
+ )
+ val json = """
+ {
+ "text": null,
+ "type": "IntroductionRequest",
+ "timestamp": $timestamp,
+ "groupId": ${toJson(request.groupId.bytes)},
+ "contactId": ${contact.id.int},
+ "local": ${request.isLocal},
+ "seen": ${request.isSeen},
+ "read": ${request.isRead},
+ "sent": ${request.isSent},
+ "id": ${toJson(request.id.bytes)},
+ "sessionId": ${toJson(request.sessionId.bytes)},
+ "name": ${request.name},
+ "answered": ${request.wasAnswered()},
+ "alreadyContact": ${request.isContact}
+ }
+ """
+ assertJsonEquals(json, request.output(contact.id))
+ }
+
+ private fun expectGetContact() {
+ every { ctx.pathParam("contactId") } returns contact.id.int.toString()
+ every { contactManager.getContact(contact.id) } returns contact
+ }
+
+ private fun testNonexistentContactId(function: () -> Context) {
+ every { ctx.pathParam("contactId") } returns "42"
+ every { contactManager.getContact(ContactId(42)) } throws NoSuchContactException()
+
+ assertThrows(NotFoundResponse::class.java) { function.invoke() }
+ }
+
+ private fun testInvalidContactId(function: () -> Context) {
+ every { ctx.pathParam("contactId") } returns "foo"
+
+ assertThrows(NotFoundResponse::class.java) { function.invoke() }
+ }
+
+}
diff --git a/briar-headless/witness.gradle b/briar-headless/witness.gradle
new file mode 100644
index 000000000..9c7d2658b
--- /dev/null
+++ b/briar-headless/witness.gradle
@@ -0,0 +1,61 @@
+dependencyVerification {
+ verify = [
+ 'com.fasterxml.jackson.core:jackson-annotations:2.9.0:jackson-annotations-2.9.0.jar:45d32ac61ef8a744b464c54c2b3414be571016dd46bfc2bec226761cf7ae457a',
+ 'com.fasterxml.jackson.core:jackson-core:2.9.6:jackson-core-2.9.6.jar:fab8746aedd6427788ee390ea04d438ec141bff7eb3476f8bdd5d9110fb2718a',
+ 'com.fasterxml.jackson.core:jackson-databind:2.9.6:jackson-databind-2.9.6.jar:657e3e979446d61f88432b9c50f0ccd9c1fe4f1c822d533f5572e4c0d172a125',
+ 'com.github.ajalt:clikt:1.5.0:clikt-1.5.0.jar:f13ab614cb0372229f6bb1e19aa98ee6f4ac96f253d0e72d482ee4f5fd2a13a9',
+ 'com.google.dagger:dagger-compiler:2.0.2:dagger-compiler-2.0.2.jar:b74bc9de063dd4c6400b232231f2ef5056145b8fbecbf5382012007dd1c071b3',
+ 'com.google.dagger:dagger-producers:2.0-beta:dagger-producers-2.0-beta.jar:99ec15e8a0507ba569e7655bc1165ee5e5ca5aa914b3c8f7e2c2458f724edd6b',
+ 'com.google.dagger:dagger:2.0.2:dagger-2.0.2.jar:84c0282ed8be73a29e0475d639da030b55dee72369e58dd35ae7d4fe6243dcf9',
+ 'com.google.guava:guava:18.0:guava-18.0.jar:d664fbfc03d2e5ce9cab2a44fb01f1d0bf9dfebeccc1a473b1f9ea31f79f6f99',
+ 'com.vaadin.external.google:android-json:0.0.20131108.vaadin1:android-json-0.0.20131108.vaadin1.jar:dfb7bae2f404cfe0b72b4d23944698cb716b7665171812a0a4d0f5926c0fac79',
+ 'io.javalin:javalin:2.2.0:javalin-2.2.0.jar:f7298fa281400559e92f000477a631c75aca9e01776962fd4b392fdb3b714190',
+ 'io.mockk:mockk-agent-api:1.8.6:mockk-agent-api-1.8.6.jar:613512c66538e6349e03df641a868f4ee324f13e2e1dbd67a0ed388aa664a444',
+ 'io.mockk:mockk-agent-common:1.8.6:mockk-agent-common-1.8.6.jar:cb7cb26fae5bfd3c89090858548990f311b27f673b9efa9d0c94f97c463b2863',
+ 'io.mockk:mockk-agent-jvm:1.8.6:mockk-agent-jvm-1.8.6.jar:3f30b98d23ada8b5a44d75b43cd58fc03252fcb96939ff31e7ad659818af1e5d',
+ 'io.mockk:mockk-common:1.8.6:mockk-common-1.8.6.jar:a04b0e2fc7d583807cf89f3bbf5c7501808725e49e385d95486e1008d8ab2ba8',
+ 'io.mockk:mockk-dsl-jvm:1.8.6:mockk-dsl-jvm-1.8.6.jar:c2c5df747ff04d1a3e02212b7b43f9ba4233597ae278928598275d7a7bb26d73',
+ 'io.mockk:mockk-dsl:1.8.6:mockk-dsl-1.8.6.jar:f6014265fe88ef1290c936741bdd0a7c3d9ceba9ee3bd2a153d65b05e1fc7946',
+ 'io.mockk:mockk:1.8.6:mockk-1.8.6.jar:0a200d71bab11facfe50637b1980f53c07a21bfa4dd9eb021ac8e8cc693924b2',
+ 'javax.inject:javax.inject:1:javax.inject-1.jar:91c77044a50c481636c32d916fd89c9118a72195390452c81065080f957de7ff',
+ 'javax.servlet:javax.servlet-api:3.1.0:javax.servlet-api-3.1.0.jar:af456b2dd41c4e82cf54f3e743bc678973d9fe35bd4d3071fa05c7e5333b8482',
+ 'net.bytebuddy:byte-buddy-agent:1.8.8:byte-buddy-agent-1.8.8.jar:dc1a2dcefe72731fa89ae84e32231c74d545ccf8216c79865096e546f20c57e8',
+ 'net.bytebuddy:byte-buddy:1.8.8:byte-buddy-1.8.8.jar:30aed1ae2ee5261b1d2f0e98ec3fcb40755c3f61b378089fb65d56098df1f16b',
+ 'org.apiguardian:apiguardian-api:1.0.0:apiguardian-api-1.0.0.jar:1f58b77470d8d147a0538d515347dd322f49a83b9e884b8970051160464b65b3',
+ 'org.eclipse.jetty.websocket:websocket-api:9.4.12.v20180830:websocket-api-9.4.12.v20180830.jar:6f7ecb42601058ffe4a6c19c5340cac3ebf0f83e2e252b457558f104238278e3',
+ 'org.eclipse.jetty.websocket:websocket-client:9.4.12.v20180830:websocket-client-9.4.12.v20180830.jar:97c6882c858a75776773eaccc01739757c4e9f60a51613878c1f2b2ba03d91af',
+ 'org.eclipse.jetty.websocket:websocket-common:9.4.12.v20180830:websocket-common-9.4.12.v20180830.jar:3c35aefa720c51e09532c16fdbfaaebd1af3e07dee699dacaba8e0ab0adf88e5',
+ 'org.eclipse.jetty.websocket:websocket-server:9.4.12.v20180830:websocket-server-9.4.12.v20180830.jar:7b1bd39006be8c32d7426a119567d860b3e4a3dc3c01a5c91326450bb0213a03',
+ 'org.eclipse.jetty.websocket:websocket-servlet:9.4.12.v20180830:websocket-servlet-9.4.12.v20180830.jar:8d43e0882759ecd093bd1a5a0ef2b4db38ac279212488a34edb8d7de7c45cc4d',
+ 'org.eclipse.jetty:jetty-client:9.4.12.v20180830:jetty-client-9.4.12.v20180830.jar:62efbbfda88cd4f7644242c4b4df8f3b0a671bfeafea7682dabe00352ba07db7',
+ 'org.eclipse.jetty:jetty-http:9.4.12.v20180830:jetty-http-9.4.12.v20180830.jar:20547da653be9942cc63f57e632a732608559aebde69753bc7312cfe16e8d9c0',
+ 'org.eclipse.jetty:jetty-io:9.4.12.v20180830:jetty-io-9.4.12.v20180830.jar:ab1784abbb9e0ed0869ab6568fe46f1faa79fb5e948cf96450daecd9d27ba1db',
+ 'org.eclipse.jetty:jetty-security:9.4.12.v20180830:jetty-security-9.4.12.v20180830.jar:513184970c785ac830424a9c62c2fadfa77a630f44aa0bdd792f00aaa092887e',
+ 'org.eclipse.jetty:jetty-server:9.4.12.v20180830:jetty-server-9.4.12.v20180830.jar:4833644e5c5a09bbddc85f75c53e0c8ed750de120ba248fffd8508028528252d',
+ 'org.eclipse.jetty:jetty-servlet:9.4.12.v20180830:jetty-servlet-9.4.12.v20180830.jar:7310d4cccf8abf27fde0c3f1a32e19c75fe33c6f1ab558f0704d915f0f01cb07',
+ 'org.eclipse.jetty:jetty-util:9.4.12.v20180830:jetty-util-9.4.12.v20180830.jar:60ad53e118a3e7d10418b155b9944d90b2e4e4c732e53ef4f419473288d3f48c',
+ 'org.eclipse.jetty:jetty-webapp:9.4.12.v20180830:jetty-webapp-9.4.12.v20180830.jar:5301e412a32bf7dddcfad458d952179597c61f8fd531c265873562725c3d4646',
+ 'org.eclipse.jetty:jetty-xml:9.4.12.v20180830:jetty-xml-9.4.12.v20180830.jar:5b8298ab3d43ddaf0941d41f51b82c8ae23a247da055fa161b752ab9495155ed',
+ 'org.jetbrains.kotlin:kotlin-annotation-processing-gradle:1.2.70:kotlin-annotation-processing-gradle-1.2.70.jar:820da7e3637066c14eb3d54dc29cd6d4dc4a041ff603d0b15844403de47b7d12',
+ 'org.jetbrains.kotlin:kotlin-compiler-embeddable:1.2.70:kotlin-compiler-embeddable-1.2.70.jar:8958d6f6ce4e49a6cecaaa9a1711a6b03df793fe066a74d88cf4958f20b0f10d',
+ 'org.jetbrains.kotlin:kotlin-reflect:1.2.41:kotlin-reflect-1.2.41.jar:1bab75771dfa2bb5949cd383ceaedf6f8d354fa0d677804fc5a39e320bab70d3',
+ 'org.jetbrains.kotlin:kotlin-reflect:1.2.70:kotlin-reflect-1.2.70.jar:89ef46a458c5ee58b8460d0d22b0bb484eea0589ccffd59d650ef38bcb60e806',
+ 'org.jetbrains.kotlin:kotlin-script-runtime:1.2.70:kotlin-script-runtime-1.2.70.jar:0124dfcf890e39250c3a4481cdd27f038d8321e93ce983730c4cbc10143eadc2',
+ 'org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:1.2.70:kotlin-scripting-compiler-embeddable-1.2.70.jar:cbd88e1cae3f8f2baeb7021d3b76323b30e82663e7b4222a214f33ee314b3653',
+ 'org.jetbrains.kotlin:kotlin-stdlib-common:1.2.70:kotlin-stdlib-common-1.2.70.jar:bb6898bef18e1de5d416d5135ca70dcac6645718c7d84bbddcfeb76ed1c199a1',
+ 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.2.70:kotlin-stdlib-jdk7-1.2.70.jar:b4ace315288134b52fddb58b4a92636faafb2ab5eb46bad97d3bce7623a8e213',
+ 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.2.70:kotlin-stdlib-jdk8-1.2.70.jar:88d0c29f4065b6ad34261fb4a04d39f9813051c6942428d718b649378d057ad1',
+ 'org.jetbrains.kotlin:kotlin-stdlib:1.2.70:kotlin-stdlib-1.2.70.jar:7d20d0a56dd0ea6176137759a6aad331bbfae67436b45e5f0a4d8dafb6985c81',
+ 'org.jetbrains:annotations:13.0:annotations-13.0.jar:ace2a10dc8e2d5fd34925ecac03e4988b2c0f851650c94b8cef49ba1bd111478',
+ 'org.junit.jupiter:junit-jupiter-api:5.2.0:junit-jupiter-api-5.2.0.jar:47f7d71b35dc331210b9ab219bbb00d54332981aa12eb5effe817de17e1ae7b3',
+ 'org.junit.jupiter:junit-jupiter-engine:5.2.0:junit-jupiter-engine-5.2.0.jar:8f994f4094790e246dc84de86a1ff4194ca85e8b13bedaca0207f727ebfbc813',
+ 'org.junit.jupiter:junit-jupiter-params:5.2.0:junit-jupiter-params-5.2.0.jar:34ce02519044ef68217002f640a83e267c4001ce53b68270218d49d00449a836',
+ 'org.junit.platform:junit-platform-commons:1.2.0:junit-platform-commons-1.2.0.jar:7771af2f797d1d0ccce9920eb3cd826fb8fd7659ccb4d8877e76d9412be72cc2',
+ 'org.junit.platform:junit-platform-engine:1.2.0:junit-platform-engine-1.2.0.jar:60b102e94ea01556fdc8c041950a05450edc188e3708f032a6bfb1a50ba0bc22',
+ 'org.objenesis:objenesis:2.6:objenesis-2.6.jar:5e168368fbc250af3c79aa5fef0c3467a2d64e5a7bd74005f25d8399aeb0708d',
+ 'org.opentest4j:opentest4j:1.1.0:opentest4j-1.1.0.jar:65a5fd7380f53aac708bcee3091dbe2dba73a9a2e7645b66e70e0804fc36ee3b',
+ 'org.skyscreamer:jsonassert:1.5.0:jsonassert-1.5.0.jar:a310bc79c3f4744e2b2e993702fcebaf3696fec0063643ffdc6b49a8fb03ef39',
+ 'org.slf4j:slf4j-api:1.7.25:slf4j-api-1.7.25.jar:18c4a0095d5c1da6b817592e767bb23d29dd2f560ad74df75ff3961dbde25b79',
+ 'org.slf4j:slf4j-simple:1.7.25:slf4j-simple-1.7.25.jar:0966e86fffa5be52d3d9e7b89dd674d98a03eed0a454fbaf7c1bd9493bd9d874',
+ ]
+}
diff --git a/settings.gradle b/settings.gradle
index 97910eabe..def39c0cd 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -5,3 +5,4 @@ include ':bramble-java'
include ':briar-api'
include ':briar-core'
include ':briar-android'
+include ':briar-headless'
diff --git a/update-dependency-pinning.sh b/update-dependency-pinning.sh
index 127fa4fb4..040fe9d3b 100755
--- a/update-dependency-pinning.sh
+++ b/update-dependency-pinning.sh
@@ -9,6 +9,7 @@ PROJECTS=(
'briar-api'
'briar-core'
'briar-android'
+ 'briar-headless'
)
# clear witness files to prevent errors when upgrading dependencies