package org.signal.spinner import android.app.Application import android.database.Cursor import androidx.sqlite.db.SupportSQLiteDatabase import com.github.jknack.handlebars.Handlebars import com.github.jknack.handlebars.Template import com.github.jknack.handlebars.helper.ConditionalHelpers import fi.iki.elonen.NanoHTTPD import org.signal.core.util.ExceptionUtil import org.signal.core.util.logging.Log import org.signal.spinner.Spinner.DatabaseConfig import java.lang.IllegalArgumentException import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.Queue import java.util.concurrent.ConcurrentLinkedQueue import kotlin.math.ceil import kotlin.math.max import kotlin.math.min /** * The workhorse of this lib. Handles all of our our web routing and response generation. * * In general, you add routes in [serve], and then build a response by creating a handlebars template (in the assets folder) and then passing in a data class * to [renderTemplate]. */ internal class SpinnerServer( private val application: Application, deviceInfo: Map String>, private val databases: Map, private val plugins: Map ) : NanoHTTPD(5000) { companion object { private val TAG = Log.tag(SpinnerServer::class.java) } private val deviceInfo: Map String> = deviceInfo.filterKeys { !it.startsWith(Spinner.KEY_PREFIX) } private val environment: String = deviceInfo[Spinner.KEY_ENVIRONMENT]?.let { it() } ?: "UNKNOWN" private val handlebars: Handlebars = Handlebars(AssetTemplateLoader(application)).apply { registerHelper("eq", ConditionalHelpers.eq) registerHelper("neq", ConditionalHelpers.neq) } private val recentSql: MutableMap> = mutableMapOf() private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS zzz", Locale.US) override fun serve(session: IHTTPSession): Response { if (session.method == Method.POST) { // Needed to populate session.parameters session.parseBody(mutableMapOf()) } val dbParam: String = session.queryParam("db") ?: session.parameters["db"]?.toString() ?: databases.keys.first() val dbConfig: DatabaseConfig = databases[dbParam] ?: return internalError(IllegalArgumentException("Invalid db param!")) try { return when { session.method == Method.GET && session.uri == "/css/main.css" -> newFileResponse("css/main.css", "text/css") session.method == Method.GET && session.uri == "/js/main.js" -> newFileResponse("js/main.js", "text/javascript") session.method == Method.GET && session.uri == "/" -> getIndex(dbParam, dbConfig.db()) session.method == Method.GET && session.uri == "/browse" -> getBrowse(dbParam, dbConfig.db()) session.method == Method.POST && session.uri == "/browse" -> postBrowse(dbParam, dbConfig, session) session.method == Method.GET && session.uri == "/query" -> getQuery(dbParam) session.method == Method.POST && session.uri == "/query" -> postQuery(dbParam, dbConfig, session) session.method == Method.GET && session.uri == "/recent" -> getRecent(dbParam) else -> { val plugin = plugins[session.uri] if (plugin != null && session.method == Method.GET) { getPlugin(dbParam, plugin) } else { newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_HTML, "Not found") } } } } catch (t: Throwable) { Log.e(TAG, t) return internalError(t) } } fun onSql(dbName: String, sql: String) { val commands: Queue = recentSql[dbName] ?: ConcurrentLinkedQueue() commands += QueryItem(System.currentTimeMillis(), sql) if (commands.size > 500) { commands.remove() } recentSql[dbName] = commands } private fun getIndex(dbName: String, db: SupportSQLiteDatabase): Response { return renderTemplate( "overview", OverviewPageModel( environment = environment, deviceInfo = deviceInfo.resolve(), database = dbName, databases = databases.keys.toList(), plugins = plugins.values.toList(), tables = db.getTables().use { it.toTableInfo() }, indices = db.getIndexes().use { it.toIndexInfo() }, triggers = db.getTriggers().use { it.toTriggerInfo() }, queryResult = db.getTables().use { it.toQueryResult() } ) ) } private fun getBrowse(dbName: String, db: SupportSQLiteDatabase): Response { return renderTemplate( "browse", BrowsePageModel( environment = environment, deviceInfo = deviceInfo.resolve(), database = dbName, databases = databases.keys.toList(), plugins = plugins.values.toList(), tableNames = db.getTableNames() ) ) } private fun postBrowse(dbName: String, dbConfig: DatabaseConfig, session: IHTTPSession): Response { val table: String = session.parameters["table"]?.get(0).toString() val pageSize: Int = session.parameters["pageSize"]?.get(0)?.toInt() ?: 1000 var pageIndex: Int = session.parameters["pageIndex"]?.get(0)?.toInt() ?: 0 val action: String? = session.parameters["action"]?.get(0) val rowCount = dbConfig.db().getTableRowCount(table) val pageCount = ceil(rowCount.toFloat() / pageSize.toFloat()).toInt() when (action) { "first" -> pageIndex = 0 "next" -> pageIndex = min(pageIndex + 1, pageCount - 1) "previous" -> pageIndex = max(pageIndex - 1, 0) "last" -> pageIndex = pageCount - 1 } val query = "select * from $table limit $pageSize offset ${pageSize * pageIndex}" val queryResult = dbConfig.db().query(query).use { it.toQueryResult(columnTransformers = dbConfig.columnTransformers) } return renderTemplate( "browse", BrowsePageModel( environment = environment, deviceInfo = deviceInfo.resolve(), database = dbName, databases = databases.keys.toList(), plugins = plugins.values.toList(), tableNames = dbConfig.db().getTableNames(), table = table, queryResult = queryResult, pagingData = PagingData( rowCount = rowCount, pageSize = pageSize, pageIndex = pageIndex, pageCount = pageCount, startRow = pageSize * pageIndex, endRow = min(pageSize * (pageIndex + 1), rowCount) ) ) ) } private fun getQuery(dbName: String): Response { return renderTemplate( "query", QueryPageModel( environment = environment, deviceInfo = deviceInfo.resolve(), database = dbName, databases = databases.keys.toList(), plugins = plugins.values.toList(), query = "" ) ) } private fun getRecent(dbName: String): Response { val queries: List? = recentSql[dbName] ?.map { it -> RecentQuery( formattedTime = dateFormat.format(Date(it.time)), query = it.query ) } return renderTemplate( "recent", RecentPageModel( environment = environment, deviceInfo = deviceInfo.resolve(), database = dbName, databases = databases.keys.toList(), plugins = plugins.values.toList(), recentSql = queries?.reversed() ) ) } private fun postQuery(dbName: String, dbConfig: DatabaseConfig, session: IHTTPSession): Response { val action: String = session.parameters["action"]?.get(0).toString() val rawQuery: String = session.parameters["query"]?.get(0).toString() val query = if (action == "analyze") "EXPLAIN QUERY PLAN $rawQuery" else rawQuery val startTimeNanos = System.nanoTime() return renderTemplate( "query", QueryPageModel( environment = environment, deviceInfo = deviceInfo.resolve(), database = dbName, databases = databases.keys.toList(), plugins = plugins.values.toList(), query = rawQuery, queryResult = dbConfig.db().query(query).use { it.toQueryResult(queryStartTimeNanos = startTimeNanos, columnTransformers = dbConfig.columnTransformers) } ) ) } private fun getPlugin(dbName: String, plugin: Plugin): Response { return renderTemplate( "plugin", PluginPageModel( environment = environment, deviceInfo = deviceInfo.resolve(), database = dbName, databases = databases.keys.toList(), plugins = plugins.values.toList(), activePlugin = plugin, pluginResult = plugin.get() ) ) } private fun internalError(throwable: Throwable): Response { val stackTrace = ExceptionUtil.convertThrowableToString(throwable) .split("\n") .map { it.trim() } .mapIndexed { index, s -> if (index == 0) s else "  $s" } .joinToString("
") return renderTemplate("error", stackTrace) } private fun renderTemplate(assetName: String, model: Any): Response { val template: Template = handlebars.compile(assetName) val output: String = template.apply(model) return newFixedLengthResponse(output) } private fun newFileResponse(assetPath: String, mimeType: String): Response { return newChunkedResponse( Response.Status.OK, mimeType, application.assets.open(assetPath) ) } private fun Cursor.toQueryResult(queryStartTimeNanos: Long = 0, columnTransformers: List = emptyList()): QueryResult { val numColumns = this.columnCount val columns = mutableListOf() val transformers = mutableListOf() for (i in 0 until numColumns) { val columnName = getColumnName(i) val customTransformer: ColumnTransformer? = columnTransformers.find { it.matches(null, columnName) } columns += if (customTransformer != null) { "$columnName *" } else { columnName } transformers += customTransformer ?: DefaultColumnTransformer } var timeOfFirstRowNanos = 0L val rows = mutableListOf>() while (moveToNext()) { if (timeOfFirstRowNanos == 0L) { timeOfFirstRowNanos = System.nanoTime() } val row = mutableListOf() for (i in 0 until numColumns) { val columnName: String = getColumnName(i) try { row += transformers[i].transform(null, columnName, this) } catch (e: Exception) { row += "*Failed to Transform*\n\n${DefaultColumnTransformer.transform(null, columnName, this)}" } } rows += row } if (timeOfFirstRowNanos == 0L) { timeOfFirstRowNanos = System.nanoTime() } return QueryResult( columns = columns, rows = rows, timeToFirstRow = (max(timeOfFirstRowNanos - queryStartTimeNanos, 0) / 1_000_000.0f).roundForDisplay(3), timeToReadRows = (max(System.nanoTime() - timeOfFirstRowNanos, 0) / 1_000_000.0f).roundForDisplay(3) ) } fun Float.roundForDisplay(decimals: Int = 2): String { return "%.${decimals}f".format(this) } private fun Cursor.toTableInfo(): List { val tables = mutableListOf() while (moveToNext()) { val name = getString(getColumnIndexOrThrow("name")) tables += TableInfo( name = name ?: "null", sql = getString(getColumnIndexOrThrow("sql"))?.formatAsSqlCreationStatement(name) ?: "null" ) } return tables } private fun Cursor.toIndexInfo(): List { val indices = mutableListOf() while (moveToNext()) { indices += IndexInfo( name = getString(getColumnIndexOrThrow("name")) ?: "null", sql = getString(getColumnIndexOrThrow("sql")) ?: "null" ) } return indices } private fun Cursor.toTriggerInfo(): List { val indices = mutableListOf() while (moveToNext()) { indices += TriggerInfo( name = getString(getColumnIndexOrThrow("name")) ?: "null", sql = getString(getColumnIndexOrThrow("sql")) ?: "null" ) } return indices } /** Takes a SQL table creation statement and formats it using HTML */ private fun String.formatAsSqlCreationStatement(name: String): String { val fields = substring(indexOf("(") + 1, this.length - 1).split(",") val fieldStrings = fields.map { s -> "  ${s.trim()},
" }.toMutableList() if (fieldStrings.isNotEmpty()) { fieldStrings[fieldStrings.lastIndex] = "  ${fields.last().trim()}
" } return "CREATE TABLE $name (
" + fieldStrings.joinToString("") + ")" } private fun IHTTPSession.queryParam(name: String): String? { if (queryParameterString == null) { return null } val params: Map = queryParameterString .split("&") .mapNotNull { part -> val parts = part.split("=") if (parts.size == 2) { parts[0] to parts[1] } else { null } } .toMap() return params[name] } private fun Map String>.resolve(): Map { return this.mapValues { entry -> entry.value() }.toMap() } interface PrefixPageData { val environment: String val deviceInfo: Map val database: String val databases: List val plugins: List } data class OverviewPageModel( override val environment: String, override val deviceInfo: Map, override val database: String, override val databases: List, override val plugins: List, val tables: List, val indices: List, val triggers: List, val queryResult: QueryResult? = null ) : PrefixPageData data class BrowsePageModel( override val environment: String, override val deviceInfo: Map, override val database: String, override val databases: List, override val plugins: List, val tableNames: List, val table: String? = null, val queryResult: QueryResult? = null, val pagingData: PagingData? = null, ) : PrefixPageData data class QueryPageModel( override val environment: String, override val deviceInfo: Map, override val database: String, override val databases: List, override val plugins: List, val query: String = "", val queryResult: QueryResult? = null ) : PrefixPageData data class RecentPageModel( override val environment: String, override val deviceInfo: Map, override val database: String, override val databases: List, override val plugins: List, val recentSql: List? ) : PrefixPageData data class PluginPageModel( override val environment: String, override val deviceInfo: Map, override val database: String, override val databases: List, override val plugins: List, val activePlugin: Plugin, val pluginResult: PluginResult ) : PrefixPageData data class QueryResult( val columns: List, val rows: List>, val rowCount: Int = rows.size, val timeToFirstRow: String, val timeToReadRows: String, ) data class TableInfo( val name: String, val sql: String ) data class IndexInfo( val name: String, val sql: String ) data class TriggerInfo( val name: String, val sql: String ) data class PagingData( val rowCount: Int, val pageSize: Int, val pageIndex: Int, val pageCount: Int, val firstPage: Boolean = pageIndex == 0, val lastPage: Boolean = pageIndex == pageCount - 1, val startRow: Int, val endRow: Int ) data class QueryItem( val time: Long, val query: String ) data class RecentQuery( val formattedTime: String, val query: String ) }